From 02b0834b72468adf6d03e8b29ba417a98e9dff16 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Thu, 16 Jul 2026 16:03:31 -0700 Subject: [PATCH] feat(mothership): expose table rows_version and schema hash to the copilot VFS snapshot --- .../copilot/chat/workspace-context.test.ts | 101 ++++++++++++++++-- .../sim/lib/copilot/chat/workspace-context.ts | 28 ++++- .../lib/copilot/generated/vfs-snapshot-v1.ts | 2 + apps/sim/lib/copilot/vfs/serializers.ts | 2 + apps/sim/lib/copilot/vfs/workspace-vfs.ts | 5 + apps/sim/lib/table/column-keys.ts | 32 ++++++ apps/sim/lib/table/schema-fingerprint.test.ts | 90 ++++++++++++++++ apps/sim/lib/table/schema-fingerprint.ts | 21 ++++ apps/sim/lib/table/service.ts | 43 ++------ apps/sim/lib/table/snapshot-cache.ts | 16 +-- apps/sim/lib/table/types.ts | 6 ++ 11 files changed, 292 insertions(+), 54 deletions(-) create mode 100644 apps/sim/lib/table/schema-fingerprint.test.ts create mode 100644 apps/sim/lib/table/schema-fingerprint.ts diff --git a/apps/sim/lib/copilot/chat/workspace-context.test.ts b/apps/sim/lib/copilot/chat/workspace-context.test.ts index 2f8caa114f7..cb5aa718d48 100644 --- a/apps/sim/lib/copilot/chat/workspace-context.test.ts +++ b/apps/sim/lib/copilot/chat/workspace-context.test.ts @@ -130,8 +130,22 @@ describe('buildWorkspaceMd - determinism (prompt-cache stability)', () => { { id: 'wf-1', name: 'Alpha', isDeployed: true, folderPath: null }, ], tables: [ - { id: 't-2', name: 'Orders', description: null, rowCount: 5 }, - { id: 't-1', name: 'Customers', description: null, rowCount: 9 }, + { + id: 't-2', + name: 'Orders', + description: null, + rowCount: 5, + rowsVersion: 2, + schemaHash: 'bbb', + }, + { + id: 't-1', + name: 'Customers', + description: null, + rowCount: 9, + rowsVersion: 1, + schemaHash: 'aaa', + }, ], knowledgeBases: [ { id: 'kb-2', name: 'Docs', connectorTypes: ['notion', 'github'] }, @@ -187,8 +201,22 @@ describe('buildWorkspaceMd - determinism (prompt-cache stability)', () => { { id: 'wf-2', name: 'Zeta', isDeployed: false, folderPath: null }, ], tables: [ - { id: 't-1', name: 'Customers', description: null, rowCount: 9 }, - { id: 't-2', name: 'Orders', description: null, rowCount: 5 }, + { + id: 't-1', + name: 'Customers', + description: null, + rowCount: 9, + rowsVersion: 1, + schemaHash: 'aaa', + }, + { + id: 't-2', + name: 'Orders', + description: null, + rowCount: 5, + rowsVersion: 2, + schemaHash: 'bbb', + }, ], knowledgeBases: [ { id: 'kb-1', name: 'Articles', connectorTypes: ['notion', 'github'] }, @@ -261,16 +289,77 @@ describe('buildWorkspaceMd - determinism (prompt-cache stability)', () => { it('ignores volatile table row counts', () => { const a = buildWorkspaceMd( - baseData({ tables: [{ id: 't-1', name: 'Customers', description: null, rowCount: 1 }] }) + baseData({ + tables: [ + { + id: 't-1', + name: 'Customers', + description: null, + rowCount: 1, + rowsVersion: 1, + schemaHash: 'aaa', + }, + ], + }) ) const b = buildWorkspaceMd( - baseData({ tables: [{ id: 't-1', name: 'Customers', description: null, rowCount: 9999 }] }) + baseData({ + tables: [ + { + id: 't-1', + name: 'Customers', + description: null, + rowCount: 9999, + rowsVersion: 1, + schemaHash: 'aaa', + }, + ], + }) ) expect(a).toBe(b) expect(a).not.toContain('rows') }) }) +describe('buildVfsSnapshot - table versioning', () => { + it('emits rowsVersion and schemaHash so row writes and schema edits change the snapshot', () => { + const snap = buildVfsSnapshot( + baseData({ + tables: [ + { + id: 't-1', + name: 'Customers', + description: null, + rowsVersion: 7, + schemaHash: 'abc123def456', + }, + ], + }) + ) + expect(snap.tables).toEqual([ + { id: 't-1', name: 'Customers', rowsVersion: 7, schemaHash: 'abc123def456' }, + ]) + }) + + it('omits a zero rowsVersion to mirror the Go contract omitempty semantics', () => { + const snap = buildVfsSnapshot( + baseData({ + tables: [ + { + id: 't-1', + name: 'Customers', + description: null, + rowsVersion: 0, + schemaHash: 'abc123def456', + }, + ], + }) + ) + expect(snap.tables?.[0]).not.toHaveProperty('rowsVersion') + expect(snap.tables?.[0]).toHaveProperty('schemaHash', 'abc123def456') + }) +}) + describe('custom blocks', () => { const customBlocks = [ { type: 'custom_block_abc', name: 'Invoice Parser', description: 'Parses invoices' }, diff --git a/apps/sim/lib/copilot/chat/workspace-context.ts b/apps/sim/lib/copilot/chat/workspace-context.ts index 7558b06f56c..7f249370533 100644 --- a/apps/sim/lib/copilot/chat/workspace-context.ts +++ b/apps/sim/lib/copilot/chat/workspace-context.ts @@ -20,6 +20,8 @@ import type { import { normalizeVfsSegment } from '@/lib/copilot/vfs/normalize-segment' import { canonicalWorkflowVfsDir, canonicalWorkspaceFilePath } from '@/lib/copilot/vfs/path-utils' import { getAccessibleOAuthCredentials } from '@/lib/credentials/environment' +import { schemaFingerprint } from '@/lib/table/schema-fingerprint' +import type { TableMetadata, TableSchema } from '@/lib/table/types' import { listWorkspaceFiles } from '@/lib/uploads/contexts/workspace' import { listCustomBlockSummariesForWorkspace } from '@/lib/workflows/custom-blocks/operations' import { listCustomTools } from '@/lib/workflows/custom-tools/operations' @@ -67,7 +69,14 @@ export interface WorkspaceMdData { // prompt prefix); kept optional so callers that still have it cheaply (the VFS // materializer via listTables) need not change, while generateWorkspaceContext // skips the per-table COUNT query entirely. - tables: Array<{ id: string; name: string; description?: string | null; rowCount?: number }> + tables: Array<{ + id: string + name: string + description?: string | null + rowCount?: number + rowsVersion: number + schemaHash: string + }> files: Array<{ id: string; name: string; type: string; size: number; folderPath?: string | null }> oauthIntegrations: Array<{ id: string @@ -393,6 +402,9 @@ async function buildWorkspaceMdData( id: userTableDefinitions.id, name: userTableDefinitions.name, description: userTableDefinitions.description, + schema: userTableDefinitions.schema, + metadata: userTableDefinitions.metadata, + rowsVersion: userTableDefinitions.rowsVersion, }) .from(userTableDefinitions) .where( @@ -497,7 +509,15 @@ async function buildWorkspaceMdData( // delta and needlessly bust the prompt cache. connectorTypes: connectorTypesByKb.get(kb.id)?.sort(stableCompare), })), - tables: tables.map((t) => ({ id: t.id, name: t.name, description: t.description })), + tables: tables.map((t) => ({ + id: t.id, + name: t.name, + description: t.description, + rowsVersion: t.rowsVersion, + // Raw stored schema + metadata: columnOrder lives in metadata, so a + // pure reorder (metadata-only write) must still change the hash. + schemaHash: schemaFingerprint(t.schema as TableSchema, t.metadata as TableMetadata | null), + })), files: files.map((f) => ({ id: f.id, name: f.name, @@ -620,6 +640,10 @@ export function buildVfsSnapshot(data: WorkspaceMdData): VfsSnapshotV1 { id: t.id, name: t.name, ...(t.description ? { description: t.description } : {}), + // Omission mirrors the Go contract's omitempty so sim-emitted and + // Go-remarshaled JSON stay byte-identical for the delta diff. + ...(t.rowsVersion ? { rowsVersion: t.rowsVersion } : {}), + ...(t.schemaHash ? { schemaHash: t.schemaHash } : {}), })), files: data.files.map((f) => ({ id: f.id, diff --git a/apps/sim/lib/copilot/generated/vfs-snapshot-v1.ts b/apps/sim/lib/copilot/generated/vfs-snapshot-v1.ts index 9a4df7519b1..58c0d09adee 100644 --- a/apps/sim/lib/copilot/generated/vfs-snapshot-v1.ts +++ b/apps/sim/lib/copilot/generated/vfs-snapshot-v1.ts @@ -107,6 +107,8 @@ export interface VfsSnapshotV1Table { description?: string id: string name: string + rowsVersion?: number + schemaHash?: string } /** * This interface was referenced by `VfsSnapshotV1`'s JSON-Schema diff --git a/apps/sim/lib/copilot/vfs/serializers.ts b/apps/sim/lib/copilot/vfs/serializers.ts index 571768bad6a..f1a6306d438 100644 --- a/apps/sim/lib/copilot/vfs/serializers.ts +++ b/apps/sim/lib/copilot/vfs/serializers.ts @@ -319,6 +319,7 @@ export function serializeTableMeta(table: { description?: string | null schema: unknown rowCount: number + rowsVersion: number maxRows: number createdAt: Date | string updatedAt: Date | string @@ -330,6 +331,7 @@ export function serializeTableMeta(table: { description: table.description || undefined, schema: table.schema, rowCount: table.rowCount, + rowsVersion: table.rowsVersion, maxRows: table.maxRows, createdAt: table.createdAt instanceof Date ? table.createdAt.toISOString() : table.createdAt, updatedAt: table.updatedAt instanceof Date ? table.updatedAt.toISOString() : table.updatedAt, diff --git a/apps/sim/lib/copilot/vfs/workspace-vfs.ts b/apps/sim/lib/copilot/vfs/workspace-vfs.ts index 8305ccaf121..af9e3016c55 100644 --- a/apps/sim/lib/copilot/vfs/workspace-vfs.ts +++ b/apps/sim/lib/copilot/vfs/workspace-vfs.ts @@ -100,6 +100,7 @@ import { BINARY_DOC_TASKS, MAX_DOCUMENT_PREVIEW_CODE_BYTES } from '@/lib/executi import { runSandboxTask, SandboxUserCodeError } from '@/lib/execution/sandbox/run-task' import { getKnowledgeBases } from '@/lib/knowledge/service' import { validateMermaidSource } from '@/lib/mermaid/validate' +import { schemaFingerprint } from '@/lib/table/schema-fingerprint' import { listTables } from '@/lib/table/service' import { listWorkspaceFileFolders } from '@/lib/uploads/contexts/workspace/workspace-file-folder-manager' import { @@ -1647,6 +1648,7 @@ export class WorkspaceVFS { description: table.description, schema: table.schema, rowCount: table.rowCount, + rowsVersion: table.rowsVersion, maxRows: table.maxRows, createdAt: table.createdAt, updatedAt: table.updatedAt, @@ -1659,6 +1661,8 @@ export class WorkspaceVFS { name: t.name, description: t.description, rowCount: t.rowCount, + rowsVersion: t.rowsVersion, + schemaHash: schemaFingerprint(t.schema), })) } catch (err) { logger.warn('Failed to materialize tables', { @@ -2319,6 +2323,7 @@ export class WorkspaceVFS { description: table.description, schema: table.schema, rowCount: table.rowCount, + rowsVersion: table.rowsVersion, maxRows: table.maxRows, createdAt: table.createdAt, updatedAt: table.updatedAt, diff --git a/apps/sim/lib/table/column-keys.ts b/apps/sim/lib/table/column-keys.ts index ec770b047ea..5087e48738a 100644 --- a/apps/sim/lib/table/column-keys.ts +++ b/apps/sim/lib/table/column-keys.ts @@ -14,6 +14,7 @@ import type { Filter, RowData, Sort, + TableMetadata, TableSchema, WorkflowGroup, } from '@/lib/table/types' @@ -40,6 +41,37 @@ export function generateColumnId(): string { return `col_${generateId().replace(/-/g, '')}` } +/** + * Returns `schema` with `columns` sorted by `metadata.columnOrder` (the user- + * editable visible order). Columns missing from `columnOrder` are appended at + * the end in their original (schema-creation) order — covers tables created + * before `columnOrder` existed and any drift from out-of-band column adds. + * + * This makes `schema.columns` the single source of truth for column order on + * the wire. The client doesn't have to join the two arrays itself — every + * consumer (grid, sidebar, copilot, mothership) gets the same ordered list. + */ +export function applyColumnOrderToSchema( + schema: TableSchema, + metadata: TableMetadata | null +): TableSchema { + const order = metadata?.columnOrder + if (!order || order.length === 0) return schema + // `columnOrder` holds stable column ids (legacy entries equal the name == id). + const byId = new Map() + for (const c of schema.columns) byId.set(getColumnId(c), c) + const ordered: TableSchema['columns'] = [] + for (const id of order) { + const c = byId.get(id) + if (c) { + ordered.push(c) + byId.delete(id) + } + } + for (const c of byId.values()) ordered.push(c) + return { ...schema, columns: ordered } +} + /** * Matches a column against a reference that may be a stable id (first-party * callers) or a display name (legacy / mothership / public API). Id match is diff --git a/apps/sim/lib/table/schema-fingerprint.test.ts b/apps/sim/lib/table/schema-fingerprint.test.ts new file mode 100644 index 00000000000..71e1f939a7b --- /dev/null +++ b/apps/sim/lib/table/schema-fingerprint.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from 'vitest' +import { schemaFingerprint } from '@/lib/table/schema-fingerprint' +import type { TableSchema } from '@/lib/table/types' + +function schema(columns: Array<{ id?: string; name: string }>): TableSchema { + return { columns: columns.map((c) => ({ ...c, type: 'string' })) } as TableSchema +} + +describe('schemaFingerprint', () => { + it('is stable for the same column shape', () => { + const a = schemaFingerprint(schema([{ id: 'col_1', name: 'email' }])) + const b = schemaFingerprint(schema([{ id: 'col_1', name: 'email' }])) + expect(a).toBe(b) + expect(a).toMatch(/^[0-9a-f]{12}$/) + }) + + it('changes on rename, add, and reorder', () => { + const base = schemaFingerprint( + schema([ + { id: 'col_1', name: 'email' }, + { id: 'col_2', name: 'age' }, + ]) + ) + const renamed = schemaFingerprint( + schema([ + { id: 'col_1', name: 'contact' }, + { id: 'col_2', name: 'age' }, + ]) + ) + const added = schemaFingerprint( + schema([ + { id: 'col_1', name: 'email' }, + { id: 'col_2', name: 'age' }, + { id: 'col_3', name: 'city' }, + ]) + ) + const reordered = schemaFingerprint( + schema([ + { id: 'col_2', name: 'age' }, + { id: 'col_1', name: 'email' }, + ]) + ) + expect(new Set([base, renamed, added, reordered]).size).toBe(4) + }) + + it('keys legacy columns without ids by name (getColumnId fallback)', () => { + const legacy = schemaFingerprint(schema([{ name: 'email' }])) + const withId = schemaFingerprint(schema([{ id: 'col_1', name: 'email' }])) + expect(legacy).not.toBe(withId) + }) + + it('changes on a pure metadata.columnOrder reorder of the RAW schema', () => { + // The user-visible order lives in metadata.columnOrder and is written by a + // metadata-only update (no schema write, no rows_version bump) — the hash + // is the ONLY signal such a reorder can move, so it must move. + const raw = schema([ + { id: 'col_1', name: 'email' }, + { id: 'col_2', name: 'age' }, + ]) + const unordered = schemaFingerprint(raw, null) + const reordered = schemaFingerprint(raw, { columnOrder: ['col_2', 'col_1'] }) + expect(reordered).not.toBe(unordered) + + // Raw schema + metadata order must hash identically to an already + // order-applied schema (getTableById/listTables output) without metadata — + // otherwise the same table carries two hashes across call sites. + const applied = schemaFingerprint( + schema([ + { id: 'col_2', name: 'age' }, + { id: 'col_1', name: 'email' }, + ]) + ) + expect(reordered).toBe(applied) + }) + + it('matches the golden hash (pins the storage-key format across refactors)', () => { + // Snapshot-cache storage keys embed this hash + // (table-snapshots/{ws}/{tableId}/v{version}-{hash}.csv). Changing the + // hashed shape orphans every cached CSV and emits a one-time '~table' + // delta for every table in every live chat — if this assertion fails, + // that blast radius is intentional and reviewed, or the change is wrong. + const golden = schemaFingerprint( + schema([ + { id: 'col_1', name: 'email' }, + { id: 'col_2', name: 'age' }, + ]) + ) + expect(golden).toBe('f49aa06b1b7c') + }) +}) diff --git a/apps/sim/lib/table/schema-fingerprint.ts b/apps/sim/lib/table/schema-fingerprint.ts new file mode 100644 index 00000000000..4fa3dfacb1e --- /dev/null +++ b/apps/sim/lib/table/schema-fingerprint.ts @@ -0,0 +1,21 @@ +import { createHash } from 'crypto' +import { applyColumnOrderToSchema, getColumnId } from '@/lib/table/column-keys' +import type { TableMetadata, TableSchema } from '@/lib/table/types' + +/** + * Fingerprint of a table's column shape (id + display name + user-visible order). `rows_version` + * only advances on row mutations (the trigger fires on `user_table_rows`), so a schema edit — + * rename, add, remove, or reorder a column — is invisible to the version counter. Consumers that + * key on `rows_version` (the snapshot cache, the copilot VFS snapshot) pair it with this hash so + * schema changes are detected too. + * + * Pass `metadata` when `schema` is the RAW stored JSONB: the user-visible order lives in + * `metadata.columnOrder` and is folded in here, so a pure reorder (metadata-only write) still + * changes the hash. Callers holding an already order-applied schema (getTableById/listTables + * output) can omit it — re-applying the same order is a no-op, so both paths hash identically. + */ +export function schemaFingerprint(schema: TableSchema, metadata?: TableMetadata | null): string { + const ordered = applyColumnOrderToSchema(schema, metadata ?? null) + const shape = ordered.columns.map((c) => [getColumnId(c), c.name]) + return createHash('sha1').update(JSON.stringify(shape)).digest('hex').slice(0, 12) +} diff --git a/apps/sim/lib/table/service.ts b/apps/sim/lib/table/service.ts index 90f3c6f6e7a..33dcc2a9e0f 100644 --- a/apps/sim/lib/table/service.ts +++ b/apps/sim/lib/table/service.ts @@ -17,7 +17,12 @@ import { and, count, eq, isNull, sql } from 'drizzle-orm' import { generateRestoreName } from '@/lib/core/utils/restore-name' import type { DbOrTx } from '@/lib/db/types' import { assertRowCapacity, notifyTableRowUsage } from '@/lib/table/billing' -import { generateColumnId, getColumnId, withGeneratedColumnIds } from '@/lib/table/column-keys' +import { + applyColumnOrderToSchema, + generateColumnId, + getColumnId, + withGeneratedColumnIds, +} from '@/lib/table/column-keys' import { COLUMN_TYPES, NAME_PATTERN, TABLE_LIMITS } from '@/lib/table/constants' import { EMPTY_JOB_FIELDS, latestJobForTable, latestJobsForTables } from '@/lib/table/jobs/service' import { nKeysBetween } from '@/lib/table/order-key' @@ -76,37 +81,6 @@ export async function withLockedTable( }) } -/** - * Returns `schema` with `columns` sorted by `metadata.columnOrder` (the user- - * editable visible order). Columns missing from `columnOrder` are appended at - * the end in their original (schema-creation) order — covers tables created - * before `columnOrder` existed and any drift from out-of-band column adds. - * - * This makes `schema.columns` the single source of truth for column order on - * the wire. The client doesn't have to join the two arrays itself — every - * consumer (grid, sidebar, copilot, mothership) gets the same ordered list. - */ -function applyColumnOrderToSchema( - schema: TableSchema, - metadata: TableMetadata | null -): TableSchema { - const order = metadata?.columnOrder - if (!order || order.length === 0) return schema - // `columnOrder` holds stable column ids (legacy entries equal the name == id). - const byId = new Map() - for (const c of schema.columns) byId.set(getColumnId(c), c) - const ordered: TableSchema['columns'] = [] - for (const id of order) { - const c = byId.get(id) - if (c) { - ordered.push(c) - byId.delete(id) - } - } - for (const c of byId.values()) ordered.push(c) - return { ...schema, columns: ordered } -} - /** * Gets a table by ID with full details. * @@ -133,6 +107,7 @@ export async function getTableById( createdAt: userTableDefinitions.createdAt, updatedAt: userTableDefinitions.updatedAt, rowCount: userTableDefinitions.rowCount, + rowsVersion: userTableDefinitions.rowsVersion, }) .from(userTableDefinitions) .where( @@ -154,6 +129,7 @@ export async function getTableById( schema: applyColumnOrderToSchema(table.schema as TableSchema, metadata), metadata, rowCount: Math.max(0, table.rowCount - pendingDeleteRemaining), + rowsVersion: table.rowsVersion, maxRows: table.maxRows, workspaceId: table.workspaceId, createdBy: table.createdBy, @@ -189,6 +165,7 @@ export async function listTables( createdAt: userTableDefinitions.createdAt, updatedAt: userTableDefinitions.updatedAt, rowCount: userTableDefinitions.rowCount, + rowsVersion: userTableDefinitions.rowsVersion, }) .from(userTableDefinitions) .where( @@ -218,6 +195,7 @@ export async function listTables( schema: applyColumnOrderToSchema(t.schema as TableSchema, metadata), metadata, rowCount: Math.max(0, t.rowCount - pendingDeleteRemaining), + rowsVersion: t.rowsVersion, maxRows: t.maxRows, workspaceId: t.workspaceId, createdBy: t.createdBy, @@ -389,6 +367,7 @@ export async function createTable( schema: newTable.schema as TableSchema, metadata: null, rowCount: data.initialRowCount ?? 0, + rowsVersion: 0, maxRows: newTable.maxRows, workspaceId: newTable.workspaceId, createdBy: newTable.createdBy, diff --git a/apps/sim/lib/table/snapshot-cache.ts b/apps/sim/lib/table/snapshot-cache.ts index 716288fb98d..67680d389a2 100644 --- a/apps/sim/lib/table/snapshot-cache.ts +++ b/apps/sim/lib/table/snapshot-cache.ts @@ -11,7 +11,6 @@ * contain — and be addressed by — its owning tenant. */ -import { createHash } from 'crypto' import { db } from '@sim/db' import { userTableDefinitions } from '@sim/db/schema' import { createLogger } from '@sim/logger' @@ -19,6 +18,7 @@ import { eq } from 'drizzle-orm' import { getColumnId } from '@/lib/table/column-keys' import { formatCsvValue, neutralizeCsvFormula, toCsvRow } from '@/lib/table/export-format' import { selectExportRowPage } from '@/lib/table/jobs/service' +import { schemaFingerprint } from '@/lib/table/schema-fingerprint' import type { TableDefinition } from '@/lib/table/types' import { createMultipartUpload, deleteFile, headObject } from '@/lib/uploads/core/storage-service' @@ -52,18 +52,6 @@ export class TableSnapshotTooLargeError extends Error { } } -/** - * Fingerprint of the table's column shape (id + display name + order). `rows_version` only advances - * on row mutations (the trigger fires on `user_table_rows`), so without this a schema edit — rename, - * add, remove, or reorder a column — would change the CSV header/columns but keep the same key and - * serve a stale snapshot. Folding it into the key invalidates the cache on any schema change. This - * is also the seam for a future column-subset / filtered projection (mix it into the same hash). - */ -function schemaFingerprint(table: TableDefinition): string { - const shape = table.schema.columns.map((c) => [getColumnId(c), c.name]) - return createHash('sha1').update(JSON.stringify(shape)).digest('hex').slice(0, 12) -} - /** Storage key for a table's snapshot at a given row version + column shape. */ function snapshotKey( workspaceId: string, @@ -153,7 +141,7 @@ export async function getOrCreateTableSnapshot( table: TableDefinition, requestId: string ): Promise { - const shapeHash = schemaFingerprint(table) + const shapeHash = schemaFingerprint(table.schema) const version = await readRowsVersion(table.id) const key = snapshotKey(table.workspaceId, table.id, version, shapeHash) diff --git a/apps/sim/lib/table/types.ts b/apps/sim/lib/table/types.ts index 384ca5afb7e..5649399cfd5 100644 --- a/apps/sim/lib/table/types.ts +++ b/apps/sim/lib/table/types.ts @@ -339,6 +339,12 @@ export interface TableDefinition { schema: TableSchema metadata?: TableMetadata | null rowCount: number + /** + * Monotonic counter bumped +1 per row-write statement by the `bump_user_table_rows_version` + * DB trigger (never written from app code). Does NOT advance on schema edits — pair with + * `schemaFingerprint` when full change detection is needed. + */ + rowsVersion: number maxRows: number workspaceId: string createdBy: string