Skip to content
Merged
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
53 changes: 53 additions & 0 deletions convex/tools/__tests__/spreadsheetOperationSync.test.ts
Original file line number Diff line number Diff line change
@@ -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);
}
});
});
22 changes: 19 additions & 3 deletions convex/tools/editSpreadsheetMutations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 = {
Expand Down
34 changes: 34 additions & 0 deletions convex/tools/spreadsheetOperationTypes.ts
Original file line number Diff line number Diff line change
@@ -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)
);
}
Loading