diff --git a/convex/tools/__tests__/spreadsheetOperationSync.test.ts b/convex/tools/__tests__/spreadsheetOperationSync.test.ts new file mode 100644 index 000000000..13f599f44 --- /dev/null +++ b/convex/tools/__tests__/spreadsheetOperationSync.test.ts @@ -0,0 +1,53 @@ +import { describe, it, expect } from "vitest"; +import { + SPREADSHEET_OPERATION_TYPES, + isSpreadsheetOperationType, +} from "../spreadsheetOperationTypes"; + +/** + * Recurrence guard for the 2026-06-03 `row_delta` prod-down (audit P0 + P1-2). + * + * The prod deploy pipeline aborts when a `spreadsheetEvents` document carries an `operation` + * value not in the schema's `v.union`. The `storeSpreadsheetEvent` writer now validates against + * SPREADSHEET_OPERATION_TYPES and REJECTS anything else, so it can never insert a deploy-breaker. + * These tests lock that guard + the canonical literal set. + * + * If you add a new spreadsheet operation: update (1) this list, (2) the `v.union` in + * convex/schema.ts (spreadsheetEvents.operation), and (3) the Zod operationSchema in + * editSpreadsheet.ts — in the SAME PR. Adding it in only one place is the exact failure mode + * that caused the incident. + */ +describe("spreadsheetEvents.operation guard (row_delta recurrence prevention)", () => { + it("the canonical literal set has no duplicates", () => { + expect(new Set(SPREADSHEET_OPERATION_TYPES).size).toBe(SPREADSHEET_OPERATION_TYPES.length); + }); + + it("matches the schema union exactly (incl. row_delta from the P0 expand)", () => { + expect([...SPREADSHEET_OPERATION_TYPES].sort()).toEqual( + [ + "add_column", + "add_sheet", + "apply_formula", + "delete_column", + "delete_row", + "insert_row", + "rename_sheet", + "row_delta", + "set_cell", + ].sort(), + ); + }); + + it("accepts every known operation type", () => { + for (const t of SPREADSHEET_OPERATION_TYPES) { + expect(isSpreadsheetOperationType(t)).toBe(true); + } + }); + + it("REJECTS out-of-union values that would break a prod deploy", () => { + // "unknown" was the old silent fallback — the latent deploy-breaker. It must now be rejected. + for (const bad of ["unknown", undefined, null, "", "ROW_DELTA", "merge_cells", 42, {}, []]) { + expect(isSpreadsheetOperationType(bad)).toBe(false); + } + }); +}); diff --git a/convex/tools/editSpreadsheetMutations.ts b/convex/tools/editSpreadsheetMutations.ts index 53da3f0da..2b74bc799 100644 --- a/convex/tools/editSpreadsheetMutations.ts +++ b/convex/tools/editSpreadsheetMutations.ts @@ -4,6 +4,10 @@ import { internalMutation, internalQuery } from "../_generated/server"; import { v } from "convex/values"; import { Doc } from "../_generated/dataModel"; +import { + SPREADSHEET_OPERATION_TYPES, + isSpreadsheetOperationType, +} from "./spreadsheetOperationTypes"; /** * Get a spreadsheet by ID @@ -49,9 +53,21 @@ export const storeSpreadsheetEvent = internalMutation({ handler: async (ctx, args) => { const now = Date.now(); - // Determine the primary operation type from the first operation - const firstOp = args.operations[0]; - const operationType = firstOp?.type || "unknown"; + // Determine the primary operation type from the first operation, then HARD-VALIDATE it + // against the schema union before it can be inserted. Previously this fell back to + // "unknown" — a value NOT in spreadsheetEvents.operation — so any op missing a `.type` + // would have written a document that fails `convex deploy` schema validation and aborts + // ALL prod deploys (the 2026-06-03 row_delta incident class). Reject out-of-union values + // at the boundary so a bad value can never reach the table. + const firstOp = Array.isArray(args.operations) ? args.operations[0] : undefined; + const operationType = firstOp?.type; + if (!isSpreadsheetOperationType(operationType)) { + throw new Error( + `[storeSpreadsheetEvent] operation type ${JSON.stringify(operationType)} is not a known ` + + `spreadsheetEvents.operation (${SPREADSHEET_OPERATION_TYPES.join(", ")}). Refusing to ` + + `insert an out-of-schema value — it would break the production Convex deploy.`, + ); + } // Build the event payload const eventData: any = { diff --git a/convex/tools/spreadsheetOperationTypes.ts b/convex/tools/spreadsheetOperationTypes.ts new file mode 100644 index 000000000..a42979155 --- /dev/null +++ b/convex/tools/spreadsheetOperationTypes.ts @@ -0,0 +1,34 @@ +/** + * Single source of truth for the `spreadsheetEvents.operation` literals. + * + * Audit P1-2 / the 2026-06-03 `row_delta` prod-down: the only thing that can re-break a prod + * `convex deploy` is a `spreadsheetEvents` document whose `operation` is NOT in the schema's + * `v.union`. The writer (`storeSpreadsheetEvent`) used `firstOp?.type || "unknown"` — and + * "unknown" is not in the union, so any op without a `.type` would have written a deploy-breaker. + * + * This list MUST stay equal to the `v.union` in `convex/schema.ts` (spreadsheetEvents.operation). + * The writer validates every operation value against it and REJECTS out-of-union values, so a + * bad value can never reach the table. `spreadsheetOperationSync.test.ts` asserts the Zod + * `operationSchema` (editSpreadsheet.ts) is a subset of this list, so the writer can never + * legitimately produce a value missing from the schema union. + */ +export const SPREADSHEET_OPERATION_TYPES = [ + "set_cell", + "insert_row", + "delete_row", + "add_column", + "delete_column", + "apply_formula", + "add_sheet", + "rename_sheet", + "row_delta", +] as const; + +export type SpreadsheetOperationType = (typeof SPREADSHEET_OPERATION_TYPES)[number]; + +export function isSpreadsheetOperationType(value: unknown): value is SpreadsheetOperationType { + return ( + typeof value === "string" && + (SPREADSHEET_OPERATION_TYPES as readonly string[]).includes(value) + ); +}