d25d482dc2
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
406 lines
13 KiB
TypeScript
406 lines
13 KiB
TypeScript
/**
|
||
* @vitest-environment node
|
||
*/
|
||
import { dbChainMock, dbChainMockFns, resetDbChainMock } from '@sim/testing'
|
||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||
import { deleteColumn, renameColumn } from '@/lib/table/columns/service'
|
||
import {
|
||
batchInsertRows,
|
||
insertRow,
|
||
replaceTableRows,
|
||
updateRow,
|
||
upsertRow,
|
||
} from '@/lib/table/rows/service'
|
||
import type { TableDefinition } from '@/lib/table/types'
|
||
import { getUniqueColumns } from '@/lib/table/validation'
|
||
|
||
vi.mock('@sim/db', () => dbChainMock)
|
||
|
||
// Capacity is exercised in billing.test.ts; here it's a no-op so the timeout-scaling
|
||
// suites can use large synthetic row counts without tripping the plan limit.
|
||
vi.mock('@/lib/table/billing', () => ({
|
||
assertRowCapacity: vi.fn().mockResolvedValue(1_000_000),
|
||
notifyTableRowUsage: vi.fn(),
|
||
getMaxRowsPerTable: vi.fn().mockResolvedValue(1_000_000),
|
||
wouldExceedRowLimit: () => false,
|
||
TableRowLimitError: class TableRowLimitError extends Error {},
|
||
}))
|
||
|
||
vi.mock('@/lib/table/validation', () => ({
|
||
validateRowSize: vi.fn(() => ({ valid: true, errors: [] })),
|
||
validateRowAgainstSchema: vi.fn(() => ({ valid: true, errors: [] })),
|
||
coerceRowToSchema: vi.fn(() => ({ valid: true, errors: [] })),
|
||
coerceRowValues: vi.fn(),
|
||
validateTableName: vi.fn(() => ({ valid: true, errors: [] })),
|
||
validateTableSchema: vi.fn(() => ({ valid: true, errors: [] })),
|
||
getUniqueColumns: vi.fn(() => []),
|
||
checkUniqueConstraintsDb: vi.fn(async () => ({ valid: true, errors: [] })),
|
||
checkBatchUniqueConstraintsDb: vi.fn(async () => ({ valid: true, errors: [] })),
|
||
}))
|
||
|
||
/**
|
||
* Inspects the queued `trx.execute(...)` calls for SQL containing `substring`.
|
||
* Works with both `sql\`...\`` (produces `{ strings, values }`) and `sql.raw(...)`
|
||
* (produces `{ rawSql }`) from the global drizzle mock.
|
||
*/
|
||
function findExecutedSqlContaining(substring: string): boolean {
|
||
return dbChainMockFns.execute.mock.calls.some(([arg]) => {
|
||
if (!arg || typeof arg !== 'object') return false
|
||
const a = arg as Record<string, unknown>
|
||
if (Array.isArray(a.strings)) {
|
||
return (a.strings as string[]).some((s) => typeof s === 'string' && s.includes(substring))
|
||
}
|
||
if (typeof a.rawSql === 'string') {
|
||
return (a.rawSql as string).includes(substring)
|
||
}
|
||
return false
|
||
})
|
||
}
|
||
|
||
function findExecutedRawSql(substring: string): string | undefined {
|
||
for (const [arg] of dbChainMockFns.execute.mock.calls) {
|
||
if (!arg || typeof arg !== 'object') continue
|
||
const raw = (arg as { rawSql?: unknown }).rawSql
|
||
if (typeof raw === 'string' && raw.includes(substring)) return raw
|
||
}
|
||
return undefined
|
||
}
|
||
|
||
const EXISTING_ROW = {
|
||
id: 'row-1',
|
||
tableId: 'tbl-1',
|
||
workspaceId: 'ws-1',
|
||
data: { name: 'Alice', age: 30 },
|
||
position: 1,
|
||
createdAt: new Date('2024-01-01'),
|
||
updatedAt: new Date('2024-01-01'),
|
||
}
|
||
|
||
const TABLE: TableDefinition = {
|
||
id: 'tbl-1',
|
||
name: 'People',
|
||
description: null,
|
||
schema: {
|
||
columns: [
|
||
{ name: 'name', type: 'string' },
|
||
{ name: 'age', type: 'number' },
|
||
],
|
||
},
|
||
metadata: null,
|
||
rowCount: 0,
|
||
maxRows: 1000,
|
||
workspaceId: 'ws-1',
|
||
createdBy: 'user-1',
|
||
archivedAt: null,
|
||
createdAt: new Date('2024-01-01'),
|
||
updatedAt: new Date('2024-01-01'),
|
||
}
|
||
|
||
describe('updateRow — partial merge', () => {
|
||
beforeEach(() => {
|
||
vi.clearAllMocks()
|
||
resetDbChainMock()
|
||
dbChainMockFns.limit.mockResolvedValue([EXISTING_ROW])
|
||
})
|
||
|
||
it('preserves columns not included in the partial update', async () => {
|
||
const result = await updateRow(
|
||
{ tableId: 'tbl-1', rowId: 'row-1', data: { age: 31 }, workspaceId: 'ws-1' },
|
||
TABLE,
|
||
'req-1'
|
||
)
|
||
|
||
expect(result.data).toEqual({ name: 'Alice', age: 31 })
|
||
expect(dbChainMockFns.set).toHaveBeenCalledWith(
|
||
expect.objectContaining({ data: { name: 'Alice', age: 31 } })
|
||
)
|
||
})
|
||
|
||
it('allows updating a single column without affecting others', async () => {
|
||
const result = await updateRow(
|
||
{ tableId: 'tbl-1', rowId: 'row-1', data: { name: 'Bob' }, workspaceId: 'ws-1' },
|
||
TABLE,
|
||
'req-1'
|
||
)
|
||
|
||
expect(result.data).toEqual({ name: 'Bob', age: 30 })
|
||
expect(dbChainMockFns.set).toHaveBeenCalledWith(
|
||
expect.objectContaining({ data: { name: 'Bob', age: 30 } })
|
||
)
|
||
})
|
||
|
||
it('allows explicitly nulling a field while preserving others', async () => {
|
||
const result = await updateRow(
|
||
{ tableId: 'tbl-1', rowId: 'row-1', data: { age: null }, workspaceId: 'ws-1' },
|
||
TABLE,
|
||
'req-1'
|
||
)
|
||
|
||
expect(result.data).toEqual({ name: 'Alice', age: null })
|
||
expect(dbChainMockFns.set).toHaveBeenCalledWith(
|
||
expect.objectContaining({ data: { name: 'Alice', age: null } })
|
||
)
|
||
})
|
||
|
||
it('handles a full-row update correctly (idempotent merge)', async () => {
|
||
const result = await updateRow(
|
||
{ tableId: 'tbl-1', rowId: 'row-1', data: { name: 'Bob', age: 25 }, workspaceId: 'ws-1' },
|
||
TABLE,
|
||
'req-1'
|
||
)
|
||
|
||
expect(result.data).toEqual({ name: 'Bob', age: 25 })
|
||
})
|
||
|
||
it('throws when the row does not exist', async () => {
|
||
dbChainMockFns.limit.mockResolvedValueOnce([])
|
||
|
||
await expect(
|
||
updateRow(
|
||
{ tableId: 'tbl-1', rowId: 'row-missing', data: { age: 31 }, workspaceId: 'ws-1' },
|
||
TABLE,
|
||
'req-1'
|
||
)
|
||
).rejects.toThrow('Row not found')
|
||
})
|
||
})
|
||
|
||
describe('insertRow — position race safety (migration 0198 + advisory lock)', () => {
|
||
beforeEach(() => {
|
||
vi.resetAllMocks()
|
||
resetDbChainMock()
|
||
vi.mocked(getUniqueColumns).mockReturnValue([])
|
||
})
|
||
|
||
it('auto-position inserts acquire the per-table advisory lock before reading max(position)', async () => {
|
||
await expect(
|
||
insertRow({ tableId: 'tbl-1', data: { name: 'a' }, workspaceId: 'ws-1' }, TABLE, 'req-1')
|
||
).rejects.toBeDefined()
|
||
|
||
expect(findExecutedSqlContaining('pg_advisory_xact_lock')).toBe(true)
|
||
expect(findExecutedSqlContaining('hashtextextended')).toBe(true)
|
||
})
|
||
|
||
it('explicit-position inserts also acquire the advisory lock to serialize order-key minting', async () => {
|
||
await expect(
|
||
insertRow(
|
||
{ tableId: 'tbl-1', data: { name: 'a' }, workspaceId: 'ws-1', position: 5 },
|
||
TABLE,
|
||
'req-1'
|
||
)
|
||
).rejects.toBeDefined()
|
||
|
||
// A position-based insert resolves its order_key from the neighbor at that
|
||
// rank; the lock serializes concurrent minting at the same slot.
|
||
expect(findExecutedSqlContaining('pg_advisory_xact_lock')).toBe(true)
|
||
})
|
||
|
||
it('batchInsertRows acquires the advisory lock (always auto-positioned)', async () => {
|
||
await expect(
|
||
batchInsertRows(
|
||
{ tableId: 'tbl-1', rows: [{ name: 'a' }, { name: 'b' }], workspaceId: 'ws-1' },
|
||
TABLE,
|
||
'req-1'
|
||
)
|
||
).rejects.toBeDefined()
|
||
|
||
expect(findExecutedSqlContaining('pg_advisory_xact_lock')).toBe(true)
|
||
})
|
||
|
||
it('upsertRow skips the advisory lock on the update path (match found)', async () => {
|
||
vi.mocked(getUniqueColumns).mockReturnValue([{ name: 'name', type: 'string', unique: true }])
|
||
dbChainMockFns.limit.mockResolvedValueOnce([
|
||
{
|
||
id: 'row-1',
|
||
tableId: 'tbl-1',
|
||
workspaceId: 'ws-1',
|
||
data: { name: 'Alice', age: 30 },
|
||
position: 0,
|
||
createdAt: new Date(),
|
||
updatedAt: new Date(),
|
||
},
|
||
])
|
||
dbChainMockFns.returning.mockResolvedValueOnce([
|
||
{
|
||
id: 'row-1',
|
||
tableId: 'tbl-1',
|
||
workspaceId: 'ws-1',
|
||
data: { name: 'Alice', age: 31 },
|
||
position: 0,
|
||
createdAt: new Date(),
|
||
updatedAt: new Date(),
|
||
},
|
||
])
|
||
|
||
await upsertRow(
|
||
{
|
||
tableId: 'tbl-1',
|
||
workspaceId: 'ws-1',
|
||
data: { name: 'Alice', age: 31 },
|
||
conflictTarget: 'name',
|
||
},
|
||
TABLE,
|
||
'req-1'
|
||
)
|
||
|
||
expect(findExecutedSqlContaining('pg_advisory_xact_lock')).toBe(false)
|
||
})
|
||
|
||
it('upsertRow acquires the advisory lock on the insert path (no match)', async () => {
|
||
vi.mocked(getUniqueColumns).mockReturnValue([{ name: 'name', type: 'string', unique: true }])
|
||
// Initial existing-row check + post-lock re-check both find no match.
|
||
dbChainMockFns.limit.mockResolvedValueOnce([])
|
||
dbChainMockFns.limit.mockResolvedValueOnce([])
|
||
|
||
await expect(
|
||
upsertRow(
|
||
{
|
||
tableId: 'tbl-1',
|
||
workspaceId: 'ws-1',
|
||
data: { name: 'Bob', age: 25 },
|
||
conflictTarget: 'name',
|
||
},
|
||
TABLE,
|
||
'req-1'
|
||
)
|
||
).rejects.toBeDefined()
|
||
|
||
expect(findExecutedSqlContaining('pg_advisory_xact_lock')).toBe(true)
|
||
})
|
||
|
||
it('upsertRow re-checks after acquiring the lock and switches to UPDATE when a racing tx inserted the row', async () => {
|
||
vi.mocked(getUniqueColumns).mockReturnValue([{ name: 'name', type: 'string', unique: true }])
|
||
// Initial existing-row check: no match (another tx has not committed yet).
|
||
dbChainMockFns.limit.mockResolvedValueOnce([])
|
||
// Post-lock re-check: a racing tx just inserted the row.
|
||
const racedRow = {
|
||
id: 'row-raced',
|
||
tableId: 'tbl-1',
|
||
workspaceId: 'ws-1',
|
||
data: { name: 'Bob', age: 25 },
|
||
position: 0,
|
||
createdAt: new Date(),
|
||
updatedAt: new Date(),
|
||
}
|
||
dbChainMockFns.limit.mockResolvedValueOnce([racedRow])
|
||
// UPDATE returning the patched row.
|
||
dbChainMockFns.returning.mockResolvedValueOnce([
|
||
{ ...racedRow, data: { name: 'Bob', age: 26 } },
|
||
])
|
||
|
||
const result = await upsertRow(
|
||
{
|
||
tableId: 'tbl-1',
|
||
workspaceId: 'ws-1',
|
||
data: { name: 'Bob', age: 26 },
|
||
conflictTarget: 'name',
|
||
},
|
||
TABLE,
|
||
'req-1'
|
||
)
|
||
|
||
expect(findExecutedSqlContaining('pg_advisory_xact_lock')).toBe(true)
|
||
expect(result.operation).toBe('update')
|
||
expect(result.row.id).toBe('row-raced')
|
||
expect(dbChainMockFns.update).toHaveBeenCalled()
|
||
expect(dbChainMockFns.insert).not.toHaveBeenCalled()
|
||
})
|
||
})
|
||
|
||
describe('mutation paths — SET LOCAL timeouts', () => {
|
||
beforeEach(() => {
|
||
vi.clearAllMocks()
|
||
resetDbChainMock()
|
||
})
|
||
|
||
it('insertRow sets the default 10s/3s/5s timeouts', async () => {
|
||
await expect(
|
||
insertRow({ tableId: 'tbl-1', data: { name: 'a' }, workspaceId: 'ws-1' }, TABLE, 'req-1')
|
||
).rejects.toBeDefined()
|
||
|
||
expect(findExecutedRawSql("SET LOCAL statement_timeout = '10000ms'")).toBeDefined()
|
||
expect(findExecutedRawSql("SET LOCAL lock_timeout = '3000ms'")).toBeDefined()
|
||
expect(
|
||
findExecutedRawSql("SET LOCAL idle_in_transaction_session_timeout = '5000ms'")
|
||
).toBeDefined()
|
||
})
|
||
|
||
it('batchInsertRows raises statement_timeout to 60s', async () => {
|
||
await expect(
|
||
batchInsertRows(
|
||
{ tableId: 'tbl-1', rows: [{ name: 'a' }], workspaceId: 'ws-1' },
|
||
TABLE,
|
||
'req-1'
|
||
)
|
||
).rejects.toBeDefined()
|
||
|
||
expect(findExecutedRawSql("SET LOCAL statement_timeout = '60000ms'")).toBeDefined()
|
||
})
|
||
|
||
it('replaceTableRows scales statement_timeout with (existing + new) row count', async () => {
|
||
const bigTable: TableDefinition = { ...TABLE, rowCount: 100_000, maxRows: 1_000_000 }
|
||
const payload = Array.from({ length: 50_000 }, (_, i) => ({ name: `row-${i}` }))
|
||
|
||
await replaceTableRows(
|
||
{ tableId: 'tbl-1', workspaceId: 'ws-1', rows: payload },
|
||
bigTable,
|
||
'req-1'
|
||
)
|
||
|
||
// (100_000 + 50_000) × 3ms/row = 450_000ms; above 120_000 floor, below 600_000 cap
|
||
expect(findExecutedRawSql("SET LOCAL statement_timeout = '450000ms'")).toBeDefined()
|
||
})
|
||
|
||
it('replaceTableRows caps scaled timeout at 10 minutes for very large tables', async () => {
|
||
const hugeTable: TableDefinition = { ...TABLE, rowCount: 10_000_000, maxRows: 20_000_000 }
|
||
|
||
await replaceTableRows({ tableId: 'tbl-1', workspaceId: 'ws-1', rows: [] }, hugeTable, 'req-1')
|
||
|
||
// 10M × 3ms = 30M ms, capped at 600_000ms (10 min)
|
||
expect(findExecutedRawSql("SET LOCAL statement_timeout = '600000ms'")).toBeDefined()
|
||
})
|
||
|
||
it('replaceTableRows uses the 120s floor on small tables', async () => {
|
||
const smallTable: TableDefinition = { ...TABLE, rowCount: 10 }
|
||
|
||
await replaceTableRows(
|
||
{ tableId: 'tbl-1', workspaceId: 'ws-1', rows: [{ name: 'a' }, { name: 'b' }] },
|
||
smallTable,
|
||
'req-1'
|
||
)
|
||
|
||
// 12 × 3ms = 36ms → floored at 120_000ms
|
||
expect(findExecutedRawSql("SET LOCAL statement_timeout = '120000ms'")).toBeDefined()
|
||
})
|
||
|
||
it('renameColumn is metadata-only — no per-row JSONB rewrite regardless of row count', async () => {
|
||
dbChainMockFns.limit.mockResolvedValueOnce([{ ...TABLE, rowCount: 500_000 }])
|
||
|
||
await renameColumn({ tableId: 'tbl-1', oldName: 'name', newName: 'full_name' }, 'req-1')
|
||
|
||
// Row data is keyed by the column's stable id (unchanged by a rename), so no
|
||
// `user_table_rows` key rewrite is executed — and thus no scaled timeout.
|
||
expect(findExecutedSqlContaining('jsonb_build_object')).toBe(false)
|
||
})
|
||
|
||
it('deleteColumn uses the 60s floor on small tables', async () => {
|
||
dbChainMockFns.limit.mockResolvedValueOnce([{ ...TABLE, rowCount: 100 }])
|
||
|
||
await deleteColumn({ tableId: 'tbl-1', columnName: 'age' }, 'req-1')
|
||
|
||
// 100 × 2ms = 200ms → floored at 60_000ms
|
||
expect(findExecutedRawSql("SET LOCAL statement_timeout = '60000ms'")).toBeDefined()
|
||
})
|
||
|
||
it('replaceTableRows acquires the per-table advisory lock to serialize concurrent replaces', async () => {
|
||
await replaceTableRows(
|
||
{ tableId: 'tbl-1', workspaceId: 'ws-1', rows: [{ name: 'a' }] },
|
||
{ ...TABLE, rowCount: 5 },
|
||
'req-1'
|
||
)
|
||
|
||
expect(findExecutedSqlContaining('pg_advisory_xact_lock')).toBe(true)
|
||
expect(findExecutedSqlContaining('hashtextextended')).toBe(true)
|
||
})
|
||
})
|