Skip to content

Commit b3cde30

Browse files
committed
Merge remote-tracking branch 'origin/staging' into improvement/platform
2 parents bbd7284 + 800f56f commit b3cde30

29 files changed

Lines changed: 18024 additions & 96 deletions

File tree

apps/docs/content/docs/en/triggers/table.mdx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,6 @@ Triggers when rows are inserted or updated in a table
3838
| `changedColumns` | json | List of column names that changed \(empty for inserts\) |
3939
| `rowId` | string | The unique row ID |
4040
| `headers` | json | Column names from the table schema |
41-
| `rowNumber` | number | The position of the row in the table |
4241
| `tableId` | string | The table ID |
4342
| `tableName` | string | The table name |
4443
| `timestamp` | string | Event timestamp in ISO format |

apps/sim/app/api/table/[tableId]/rows/route.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ async function handleBatchInsert(
7171
workspaceId: validated.workspaceId,
7272
userId,
7373
positions: validated.positions,
74+
orderKeys: validated.orderKeys,
7475
},
7576
table,
7677
requestId
@@ -83,6 +84,7 @@ async function handleBatchInsert(
8384
id: r.id,
8485
data: r.data,
8586
position: r.position,
87+
orderKey: r.orderKey ?? undefined,
8688
createdAt: r.createdAt instanceof Date ? r.createdAt.toISOString() : r.createdAt,
8789
updatedAt: r.updatedAt instanceof Date ? r.updatedAt.toISOString() : r.updatedAt,
8890
})),
@@ -162,6 +164,8 @@ export const POST = withRouteHandler(
162164
workspaceId: validated.workspaceId,
163165
userId: authResult.userId,
164166
position: validated.position,
167+
afterRowId: validated.afterRowId,
168+
beforeRowId: validated.beforeRowId,
165169
},
166170
table,
167171
requestId
@@ -174,9 +178,11 @@ export const POST = withRouteHandler(
174178
id: row.id,
175179
data: row.data,
176180
position: row.position,
181+
orderKey: row.orderKey ?? undefined,
177182
createdAt: row.createdAt instanceof Date ? row.createdAt.toISOString() : row.createdAt,
178183
updatedAt: row.updatedAt instanceof Date ? row.updatedAt.toISOString() : row.updatedAt,
179184
},
185+
180186
message: 'Row inserted successfully',
181187
},
182188
})

apps/sim/app/api/tools/clickhouse/utils.ts

Lines changed: 21 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
1-
import { validateDatabaseHost } from '@/lib/core/security/input-validation.server'
1+
import {
2+
validateDatabaseHost,
3+
validateSqlWhereClause,
4+
} from '@/lib/core/security/input-validation.server'
25
import type { ClickHouseConnectionConfig } from '@/tools/clickhouse/types'
36

47
const REQUEST_TIMEOUT_MS = 30_000
@@ -60,7 +63,8 @@ export interface ClickHouseIntrospectionResult {
6063
*/
6164
async function clickhouseRequest(
6265
config: ClickHouseConnectionConfig,
63-
statement: string
66+
statement: string,
67+
options: { readOnly?: boolean } = {}
6468
): Promise<ClickHouseHttpResult> {
6569
const hostValidation = await validateDatabaseHost(config.host, 'host')
6670
if (!hostValidation.isValid) {
@@ -70,6 +74,12 @@ async function clickhouseRequest(
7074
const protocol = config.secure ? 'https' : 'http'
7175
const url = new URL(`${protocol}://${config.host}:${config.port}/`)
7276
url.searchParams.set('database', config.database)
77+
if (options.readOnly) {
78+
// Server-enforced read-only: ClickHouse rejects any write/DDL and forbids the
79+
// query from re-enabling writes via `SET readonly=0`. This is the real boundary
80+
// for the query operation; the SQL-shape checks below are defense-in-depth.
81+
url.searchParams.set('readonly', '1')
82+
}
7383

7484
const controller = new AbortController()
7585
const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS)
@@ -290,7 +300,9 @@ export async function executeClickHouseQuery(
290300
)
291301
}
292302
}
293-
const result = await clickhouseRequest(config, ensureJsonFormat(query))
303+
const result = await clickhouseRequest(config, ensureJsonFormat(query), {
304+
readOnly: options.enforceReadOnly,
305+
})
294306
return parseRowsResult(result)
295307
}
296308

@@ -448,37 +460,14 @@ function sanitizeSingleIdentifier(identifier: string): string {
448460
}
449461

450462
/**
451-
* Rejects WHERE clauses containing patterns commonly used in SQL injection so
452-
* that user-supplied conditions cannot escape the intended mutation.
463+
* Rejects WHERE clauses containing SQL-injection or always-true tautology
464+
* patterns so user-supplied conditions cannot broaden a mutation to every row.
465+
* Delegates to the shared {@link validateSqlWhereClause} guard (defense-in-depth).
453466
*/
454467
function validateWhereClause(where: string): void {
455-
const dangerousPatterns = [
456-
/;\s*(drop|delete|insert|alter|create|truncate|rename|grant|revoke)/i,
457-
/union\s+(all\s+)?select/i,
458-
/into\s+outfile/i,
459-
/--/,
460-
/\/\*/,
461-
/\*\//,
462-
/\bor\s+(['"]?)(\w+)\1\s*=\s*\1\2\1/i,
463-
/\bor\s+true\b/i,
464-
/\bor\s+false\b/i,
465-
/\band\s+(['"]?)(\w+)\1\s*=\s*\1\2\1/i,
466-
/\band\s+true\b/i,
467-
/\band\s+false\b/i,
468-
/\bsleep\s*\(/i,
469-
/;\s*\w+/,
470-
// Constant / tautological conditions that don't reference columns and would
471-
// broaden a mutation to all rows (e.g. "1=1", "1 < 2", "'a'='a'", bare "1"/"true").
472-
/\b\d+\s*(?:=|==|<>|!=|<=|>=|<|>)\s*\d+\b/,
473-
/(['"])([^'"]*)\1\s*(?:=|==|<>|!=)\s*\1\2\1/,
474-
/\b(\w+)\s*=\s*\1\b/i,
475-
/^\s*(?:\d+|true|false)\s*$/i,
476-
]
477-
478-
for (const pattern of dangerousPatterns) {
479-
if (pattern.test(where)) {
480-
throw new Error('WHERE clause contains potentially dangerous operation')
481-
}
468+
const result = validateSqlWhereClause(where, 'WHERE clause')
469+
if (!result.isValid) {
470+
throw new Error(result.error)
482471
}
483472
}
484473

apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-grid.tsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -837,9 +837,12 @@ export function TableGrid({
837837

838838
function handleInsertRow(offset: 0 | 1) {
839839
if (!contextMenu.row) return
840+
const anchorId = contextMenu.row.id
841+
// Fractional ordering: express intent by neighbor id, not integer position.
842+
const intent = offset === 0 ? { beforeRowId: anchorId } : { afterRowId: anchorId }
840843
const position = contextMenu.row.position + offset
841844
createRef.current(
842-
{ data: {}, position },
845+
{ data: {}, ...intent },
843846
{
844847
onSuccess: (response: Record<string, unknown>) => {
845848
const newRowId = extractCreatedRowId(response)
@@ -904,7 +907,7 @@ export function TableGrid({
904907
const sourceArrayIndex = rowsRef.current.findIndex((r) => r.id === contextRow.id)
905908
closeContextMenu()
906909
createRef.current(
907-
{ data: rowData, position },
910+
{ data: rowData, afterRowId: contextRow.id },
908911
{
909912
onSuccess: (response: Record<string, unknown>) => {
910913
const newRowId = extractCreatedRowId(response)

apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/utils.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -302,7 +302,12 @@ export function computeNormalizedSelection(
302302
export function collectRowSnapshots(rows: Iterable<TableRowType>): DeletedRowSnapshot[] {
303303
const snapshots: DeletedRowSnapshot[] = []
304304
for (const row of rows) {
305-
snapshots.push({ rowId: row.id, data: { ...row.data }, position: row.position })
305+
snapshots.push({
306+
rowId: row.id,
307+
data: { ...row.data },
308+
position: row.position,
309+
orderKey: row.orderKey,
310+
})
306311
}
307312
return snapshots
308313
}

apps/sim/background/workflow-column-execution.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -589,7 +589,6 @@ async function runWorkflowAndWriteTerminal(
589589
changedColumns: [],
590590
rowId,
591591
headers,
592-
rowNumber: row.position,
593592
tableId,
594593
tableName,
595594
timestamp: new Date().toISOString(),

apps/sim/hooks/queries/tables.ts

Lines changed: 39 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -512,7 +512,13 @@ export function useCreateTableRow({ workspaceId, tableId }: RowMutationContext)
512512
) => {
513513
return requestJson(createTableRowContract, {
514514
params: { tableId },
515-
body: { workspaceId, data: variables.data as RowData, position: variables.position },
515+
body: {
516+
workspaceId,
517+
data: variables.data as RowData,
518+
position: variables.position,
519+
afterRowId: variables.afterRowId,
520+
beforeRowId: variables.beforeRowId,
521+
},
516522
})
517523
},
518524
onSuccess: (response) => {
@@ -592,35 +598,47 @@ function reconcileCreatedRow(
592598
if (!old) return old
593599
if (old.pages.some((p) => p.rows.some((r) => r.id === row.id))) return old
594600

595-
const pages = old.pages.map((page) =>
596-
page.rows.some((r) => r.position >= row.position)
597-
? {
598-
...page,
599-
rows: page.rows.map((r) =>
600-
r.position >= row.position ? { ...r, position: r.position + 1 } : r
601-
),
602-
}
603-
: page
604-
)
601+
// Use key-ordering only when the new row AND every cached row have an
602+
// `orderKey` — then no neighbor bump is needed and order is exact. If any
603+
// cached row is un-keyed (mid-backfill), fall back to the legacy `position`
604+
// path so un-keyed rows aren't yanked to the front by an empty-string sort.
605+
const byKey =
606+
row.orderKey != null && old.pages.every((p) => p.rows.every((r) => r.orderKey != null))
607+
const sortRows = (rows: TableRow[]) =>
608+
byKey
609+
? [...rows].sort((a, b) => (a.orderKey as string).localeCompare(b.orderKey as string))
610+
: [...rows].sort((a, b) => a.position - b.position)
611+
const fitsAfter = (last: TableRow | undefined) =>
612+
last === undefined ||
613+
(byKey
614+
? (last.orderKey as string) >= (row.orderKey as string)
615+
: last.position >= row.position)
616+
617+
const pages = byKey
618+
? old.pages
619+
: old.pages.map((page) =>
620+
page.rows.some((r) => r.position >= row.position)
621+
? {
622+
...page,
623+
rows: page.rows.map((r) =>
624+
r.position >= row.position ? { ...r, position: r.position + 1 } : r
625+
),
626+
}
627+
: page
628+
)
605629

606630
let inserted = false
607631
const nextPages = pages.map((page) => {
608632
if (inserted) return page
609-
const last = page.rows[page.rows.length - 1]
610-
const fits = last === undefined || last.position >= row.position
611-
if (!fits) return page
633+
if (!fitsAfter(page.rows[page.rows.length - 1])) return page
612634
inserted = true
613-
const merged = [...page.rows, row].sort((a, b) => a.position - b.position)
614-
return { ...page, rows: merged }
635+
return { ...page, rows: sortRows([...page.rows, row]) }
615636
})
616637

617638
if (!inserted && nextPages.length > 0) {
618639
const lastIdx = nextPages.length - 1
619640
const lastPage = nextPages[lastIdx]
620-
nextPages[lastIdx] = {
621-
...lastPage,
622-
rows: [...lastPage.rows, row].sort((a, b) => a.position - b.position),
623-
}
641+
nextPages[lastIdx] = { ...lastPage, rows: sortRows([...lastPage.rows, row]) }
624642
}
625643

626644
const firstPage = nextPages[0]
@@ -655,6 +673,7 @@ export function useBatchCreateTableRows({ workspaceId, tableId }: RowMutationCon
655673
workspaceId,
656674
rows: variables.rows as RowData[],
657675
positions: variables.positions,
676+
orderKeys: variables.orderKeys,
658677
},
659678
})
660679
},

apps/sim/hooks/use-table-undo.ts

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ import {
55
useAddTableColumn,
66
useBatchCreateTableRows,
77
useBatchUpdateTableRows,
8-
useCreateTableRow,
98
useDeleteColumn,
109
useDeleteTableRow,
1110
useDeleteTableRows,
@@ -56,7 +55,6 @@ export function useTableUndo({
5655
const canRedo = useTableUndoStore((s) => (s.stacks[tableId]?.redo.length ?? 0) > 0)
5756

5857
const updateRowMutation = useUpdateTableRow({ workspaceId, tableId })
59-
const createRowMutation = useCreateTableRow({ workspaceId, tableId })
6058
const batchCreateRowsMutation = useBatchCreateTableRows({ workspaceId, tableId })
6159
const batchUpdateRowsMutation = useBatchUpdateTableRows({ workspaceId, tableId })
6260
const deleteRowMutation = useDeleteTableRow({ workspaceId, tableId })
@@ -137,11 +135,18 @@ export function useTableUndo({
137135
if (direction === 'undo') {
138136
deleteRowMutation.mutate(action.rowId)
139137
} else {
140-
createRowMutation.mutate(
141-
{ data: action.data ?? {}, position: action.position },
138+
// Redo via the batch path so the saved orderKey restores exact placement.
139+
// The single-insert API has no orderKey field, and under the fractional-ordering
140+
// flag its `position` is read as a rank — a gappy saved position misplaces.
141+
batchCreateRowsMutation.mutate(
142+
{
143+
rows: [action.data ?? {}],
144+
positions: [action.position],
145+
orderKeys: action.orderKey ? [action.orderKey] : undefined,
146+
},
142147
{
143148
onSuccess: (response) => {
144-
const newRowId = extractCreatedRowId(response as Record<string, unknown>)
149+
const newRowId = response?.data?.rows?.[0]?.id
145150
if (newRowId && newRowId !== action.rowId) {
146151
patchUndoRowId(tableId, action.rowId, newRowId)
147152
}
@@ -165,6 +170,9 @@ export function useTableUndo({
165170
{
166171
rows: action.rows.map((r) => r.data),
167172
positions: action.rows.map((r) => r.position),
173+
orderKeys: action.rows.every((r) => r.orderKey)
174+
? action.rows.map((r) => r.orderKey as string)
175+
: undefined,
168176
},
169177
{
170178
onSuccess: (response) => {
@@ -187,6 +195,9 @@ export function useTableUndo({
187195
{
188196
rows: action.rows.map((row) => row.data),
189197
positions: action.rows.map((row) => row.position),
198+
orderKeys: action.rows.every((row) => row.orderKey)
199+
? action.rows.map((row) => row.orderKey as string)
200+
: undefined,
190201
},
191202
{
192203
onSuccess: (response) => {

apps/sim/lib/api/contracts/tables.ts

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -147,12 +147,29 @@ export const rowDataSchema = domainObjectSchema<RowData>()
147147
export const tableDefinitionSchema = domainObjectSchema<TableDefinition>()
148148
export const tableRowSchema = domainObjectSchema<TableRow>()
149149

150-
export const insertTableRowBodySchema = z.object({
150+
/**
151+
* Plain-object base for the single-row insert body. Kept un-refined so callers
152+
* (e.g. the v1 public contract) can `.omit()` fields before applying
153+
* {@link rowAnchorMutexRefine} — Zod forbids `.omit()` on a refined schema.
154+
*/
155+
export const insertTableRowBodyBaseSchema = z.object({
151156
workspaceId: z.string().min(1, 'Workspace ID is required'),
152157
data: rowDataSchema,
153158
position: z.number().int().min(0).optional(),
159+
/** Fractional ordering: insert directly after this row id. Takes precedence over `position`. */
160+
afterRowId: z.string().min(1).optional(),
161+
/** Fractional ordering: insert directly before this row id. Takes precedence over `position`. */
162+
beforeRowId: z.string().min(1).optional(),
154163
})
155164

165+
/** `afterRowId` and `beforeRowId` are mutually exclusive insert anchors. */
166+
export const rowAnchorMutexRefine = [
167+
(data: { afterRowId?: string; beforeRowId?: string }) => !data.afterRowId || !data.beforeRowId,
168+
{ message: 'afterRowId and beforeRowId are mutually exclusive' },
169+
] as const
170+
171+
export const insertTableRowBodySchema = insertTableRowBodyBaseSchema.refine(...rowAnchorMutexRefine)
172+
156173
/**
157174
* POST `/api/table/[tableId]/rows/upsert` body — insert-or-update keyed by a
158175
* unique column name. `conflictTarget` is optional (server picks a single
@@ -175,13 +192,18 @@ export const batchInsertTableRowsBodySchema = z
175192
`Cannot insert more than ${TABLE_LIMITS.MAX_BATCH_INSERT_SIZE} rows per batch`
176193
),
177194
positions: z.array(z.number().int().min(0)).max(TABLE_LIMITS.MAX_BATCH_INSERT_SIZE).optional(),
195+
/** Fractional ordering: exact per-row order keys (undo restore). Takes precedence over `positions`. */
196+
orderKeys: z.array(z.string().min(1)).max(TABLE_LIMITS.MAX_BATCH_INSERT_SIZE).optional(),
178197
})
179198
.refine((data) => !data.positions || data.positions.length === data.rows.length, {
180199
message: 'positions array length must match rows array length',
181200
})
182201
.refine((data) => !data.positions || new Set(data.positions).size === data.positions.length, {
183202
message: 'positions must not contain duplicates',
184203
})
204+
.refine((data) => !data.orderKeys || data.orderKeys.length === data.rows.length, {
205+
message: 'orderKeys array length must match rows array length',
206+
})
185207

186208
/**
187209
* POST `/api/table/[tableId]/rows` body — accepts either a batch payload

apps/sim/lib/api/contracts/v1/tables/index.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@ import {
44
createTableColumnBodySchema,
55
deleteTableColumnBodySchema,
66
deleteTableRowsBodySchema,
7-
insertTableRowBodySchema,
7+
insertTableRowBodyBaseSchema,
8+
rowAnchorMutexRefine,
89
rowDataSchema,
910
tableIdParamsSchema,
1011
tableRowParamsSchema,
@@ -60,7 +61,9 @@ export const v1CreateTableBodySchema = createTableBodySchema.omit({
6061
* Public API insert row body — no caller-controlled `position`. Server places
6162
* new rows at the tail; ordering by index is an in-app affordance only.
6263
*/
63-
export const v1InsertTableRowBodySchema = insertTableRowBodySchema.omit({ position: true })
64+
export const v1InsertTableRowBodySchema = insertTableRowBodyBaseSchema
65+
.omit({ position: true })
66+
.refine(...rowAnchorMutexRefine)
6467

6568
/**
6669
* Public API batch insert body — no `positions`. Same rationale as above.

0 commit comments

Comments
 (0)