Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 95 additions & 6 deletions apps/sim/lib/copilot/chat/workspace-context.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'] },
Expand Down Expand Up @@ -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'] },
Expand Down Expand Up @@ -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' },
Expand Down
28 changes: 26 additions & 2 deletions apps/sim/lib/copilot/chat/workspace-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions apps/sim/lib/copilot/generated/vfs-snapshot-v1.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions apps/sim/lib/copilot/vfs/serializers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand Down
5 changes: 5 additions & 0 deletions apps/sim/lib/copilot/vfs/workspace-vfs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
Expand All @@ -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', {
Expand Down Expand Up @@ -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,
Expand Down
32 changes: 32 additions & 0 deletions apps/sim/lib/table/column-keys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import type {
Filter,
RowData,
Sort,
TableMetadata,
TableSchema,
WorkflowGroup,
} from '@/lib/table/types'
Expand All @@ -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<string, TableSchema['columns'][number]>()
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
Expand Down
90 changes: 90 additions & 0 deletions apps/sim/lib/table/schema-fingerprint.test.ts
Original file line number Diff line number Diff line change
@@ -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')
})
})
Loading
Loading