/** * @vitest-environment node * * Lock-order regression guard: a column-creating CSV import must acquire the * per-table row-order advisory lock (`user_table_rows_pos`) BEFORE writing * `user_table_definitions`, matching the rows_pos → definitions order that plain * inserts take via the row-count trigger. The opposite order deadlocks * concurrent inserts on the same table. */ import { userTableDefinitions } from '@sim/db/schema' import { dbChainMock, dbChainMockFns, resetDbChainMock } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' import { importAppendRows } from '@/lib/table/import-data' import type { TableDefinition } from '@/lib/table/types' vi.mock('@sim/db', () => dbChainMock) vi.mock('@/lib/core/config/feature-flags', () => ({ isFeatureEnabled: vi.fn().mockResolvedValue(false), })) 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((row) => row), 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: [] })), })) const TABLE: TableDefinition = { id: 'tbl-1', name: 'People', description: null, schema: { columns: [{ name: 'name', type: 'string' }] }, 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'), } /** * invocationCallOrder of the first `tx.execute(...)` whose SQL contains * `substring` — checks the template strings, the interpolated values (the * advisory key is passed as a value), and `sql.raw` output. */ function executeOrderContaining(substring: string): number { const { calls, invocationCallOrder } = dbChainMockFns.execute.mock for (let i = 0; i < calls.length; i++) { const arg = calls[i][0] as { strings?: unknown; values?: unknown; rawSql?: unknown } | undefined const haystacks: string[] = [] if (Array.isArray(arg?.strings)) { haystacks.push( ...(arg.strings as unknown[]).filter((s): s is string => typeof s === 'string') ) } if (Array.isArray(arg?.values)) { haystacks.push(...(arg.values as unknown[]).filter((v): v is string => typeof v === 'string')) } if (typeof arg?.rawSql === 'string') haystacks.push(arg.rawSql) if (haystacks.some((s) => s.includes(substring))) return invocationCallOrder[i] } return -1 } /** invocationCallOrder of the first `tx.update(table)` call. */ function updateOrderForTable(table: unknown): number { const { calls, invocationCallOrder } = dbChainMockFns.update.mock for (let i = 0; i < calls.length; i++) { if (calls[i][0] === table) return invocationCallOrder[i] } return -1 } describe('table import lock ordering', () => { beforeEach(() => { vi.clearAllMocks() resetDbChainMock() }) it('acquires the rows_pos advisory before the user_table_definitions write when adding columns', async () => { // The lock pre-acquire and the column-creating write both run at the top of // the import, before the batch-insert loop. The loop's order-key aggregates // aren't fully wired in this unit mock, so tolerate a downstream error after // the locks under test have been recorded. await importAppendRows( TABLE, [{ name: 'new_col', type: 'string' }], [{ name: 'Alice', new_col: 'x' }], { workspaceId: 'ws-1', userId: 'user-1', requestId: 'req-1' } ).catch(() => {}) const rowsPosLockOrder = executeOrderContaining('user_table_rows_pos') const definitionsWriteOrder = updateOrderForTable(userTableDefinitions) expect(rowsPosLockOrder).toBeGreaterThan(0) expect(definitionsWriteOrder).toBeGreaterThan(0) expect(rowsPosLockOrder).toBeLessThan(definitionsWriteOrder) }) })