From 9e4afd8b1ac350539c472596a057ca754bfeeb91 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 06:38:24 +0000 Subject: [PATCH] fix(objectql): the record-currency backfill cleans a driver's raw text out of a failed row A non-sandboxed row failure now reads sanitizeRowError's sentence, the import door's rule, in the report the REST door returns; the operator's log line keeps the whole text. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_016WV4d1GEfMtjCJfYePyAPD --- .../30816-backfill-row-error-sanitize.md | 10 ++++++++ .../src/record-currency-backfill.test.ts | 23 ++++++++++++++++++- .../objectql/src/record-currency-backfill.ts | 11 +++++---- ...eta-record-currency-backfill-route.test.ts | 22 ++++++++++++++++++ 4 files changed, 61 insertions(+), 5 deletions(-) create mode 100644 .changeset/30816-backfill-row-error-sanitize.md diff --git a/.changeset/30816-backfill-row-error-sanitize.md b/.changeset/30816-backfill-row-error-sanitize.md new file mode 100644 index 00000000000..7eda68bf177 --- /dev/null +++ b/.changeset/30816-backfill-row-error-sanitize.md @@ -0,0 +1,10 @@ +--- +'@objectstack/objectql': patch +--- + +fix(objectql): the record-currency backfill cleans a driver's raw text out of a failed row, as the import door does + +Clause-②: no +Metadata-protocol: none + +A failed row in the record-currency backfill report (`failures[].error`) now reads the import row's cleaned sentence (`sanitizeRowError` from `@objectstack/core`, the one rule both doors call) when the row's write threw anything other than a sandboxed hook's error. A driver's SQL statement, constraint name or column list no longer reaches the report, which a tenant admin reads over `POST /api/v1/meta/_migrate-record-currency-codes` and the operator reads in `os migrate record-currency-codes`. A message with no driver text reads as before. The operator's warning line keeps the whole text, so nothing is lost for diagnosis. diff --git a/packages/objectql/src/record-currency-backfill.test.ts b/packages/objectql/src/record-currency-backfill.test.ts index 3fd8b09d328..d9c9a4f1043 100644 --- a/packages/objectql/src/record-currency-backfill.test.ts +++ b/packages/objectql/src/record-currency-backfill.test.ts @@ -13,6 +13,7 @@ // guard and the code field's `valueDomain` judge each one. import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { sanitizeRowError } from '@objectstack/core'; import { ObjectQL } from './engine.js'; import { backfillRecordCurrencyCodes, @@ -484,8 +485,28 @@ describe('a failed row reads a sandboxed hook as the write doors do', () => { } }); - it('CONTROL: any other throw keeps its message, as before', async () => { + it('CONTROL: a throw with no driver text keeps its message, as before', async () => { const { report } = await run(() => new Error('driver refused this row')); expect(report.failures).toEqual([{ recordId: 'r1', error: 'driver refused this row' }]); }); + + /** + * [#30816] A driver's own throw carries its SQL, constraint and column names. + * The row reads the import row's cleaned sentence (`sanitizeRowError`, the + * one rule both doors call), the CLI's printed report reads the same, and + * the operator's log line keeps the whole text. + */ + it('a driver-shaped throw: the import row\'s cleaned sentence, the raw text only in the log', async () => { + for (const raw of [ + `update "deal" set "currency_code" = $1 where "id" = $2 - value too long for type character varying(3)`, + 'SQLITE_CONSTRAINT: UNIQUE constraint failed: deal.currency_code', + `insert into "deal" ("id") values ($1) - insert or update on table "deal" violates foreign key constraint "deal_owner_fkey"`, + ]) { + const { report, warned } = await run(() => new Error(raw)); + expect(report.failures, raw).toEqual([{ recordId: 'r1', error: sanitizeRowError(raw) }]); + expect(report.failures[0]!.error, raw).not.toMatch(/"deal"|\$1|currency_code|_fkey/); + expect(formatRecordCurrencyBackfillReport(report).join('\n'), raw).toContain(` • r1: ${sanitizeRowError(raw)}`); + expect(warned.join('\n'), raw).toContain(raw); + } + }); }); diff --git a/packages/objectql/src/record-currency-backfill.ts b/packages/objectql/src/record-currency-backfill.ts index 3db40f4b2c6..5fd2c44126f 100644 --- a/packages/objectql/src/record-currency-backfill.ts +++ b/packages/objectql/src/record-currency-backfill.ts @@ -87,7 +87,7 @@ */ import { isSandboxOrigin, isSandboxOwnFault, keysetWalk, mapDataError, sandboxBusinessMessage, type KeysetPageQuery } from '@objectstack/types'; -import { objectNotFoundError, withTransientRetry, type RetryOptions } from '@objectstack/core'; +import { objectNotFoundError, sanitizeRowError, withTransientRetry, type RetryOptions } from '@objectstack/core'; import { isValueDomainMember } from '@objectstack/spec/shared'; import { isRecordCurrencyBlank, recordCurrencyFieldOf } from './validation/record-currency-code.js'; @@ -449,15 +449,18 @@ async function walkCodelessRows( * error the `/data` door answers with a 5xx (a crash, a declared 5xx, the * sandbox's own fault) takes that door's withheld sentence, and a sandboxed * refusal takes the hook's own sentence, never the `hook '' threw:` - * debug wrapper. Any other throw keeps its message, as before. The operator's - * log line above keeps the whole text. + * debug wrapper. [#30816] Any other throw is cleaned by the import row's own + * sanitizer (`sanitizeRowError`, `@objectstack/core`), so a driver's raw text + * (SQL, constraint names, column lists) never reaches the report; a message + * with no driver text reads as before. The operator's log line above keeps + * the whole text. */ function rowFailureSentence(err: unknown, object: string, message: string): string { if (isSandboxOrigin(err) || isSandboxOwnFault(err)) { const door = mapDataError(err, object); if (door.status >= 500) return String(door.body.error); } - return sandboxBusinessMessage(err) ?? message; + return sandboxBusinessMessage(err) ?? sanitizeRowError(message); } /** Human-readable report body, shared by the CLI's dry-run and apply output. */ diff --git a/packages/rest/src/meta-record-currency-backfill-route.test.ts b/packages/rest/src/meta-record-currency-backfill-route.test.ts index 9a9fdffaea0..4f7c7af4d39 100644 --- a/packages/rest/src/meta-record-currency-backfill-route.test.ts +++ b/packages/rest/src/meta-record-currency-backfill-route.test.ts @@ -11,6 +11,7 @@ */ import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { sanitizeRowError } from '@objectstack/core'; import { ObjectQL } from '@objectstack/objectql'; import { RestServer } from './rest-server.js'; @@ -231,6 +232,27 @@ describe('POST /meta/_migrate-record-currency-codes — the caller\'s organizati expect(again.body).toMatchObject({ codeless: 0, written: 0 }); }); + /** + * [#30816] A driver's own throw on one row reaches the tenant admin as the + * import row's cleaned sentence (`sanitizeRowError`), never the driver's + * SQL, constraint or column names; the other rows are still written. + */ + it('a driver-shaped failure on one row answers the cleaned sentence, not the driver\'s text', async () => { + const raw = 'update "deal" set "currency_code" = $1 where "id" = $2 - ' + + 'value too long for type character varying(3)'; + vi.mocked(d.driver.update).mockImplementation(async (object: string, id: string, data: Record) => { + if (id === 'a2') throw new Error(raw); + const row = { ...d.storeFor(object).get(id), ...data, id }; + d.storeFor(object).set(id, row); + return row; + }); + const answer = await boot(engine, ADMIN_A).call({ object: 'deal', field: 'amount', code: 'USD', apply: true }); + expect(answer.status).toBe(200); + expect(answer.body).toMatchObject({ codeless: 2, written: 1, failures: [{ recordId: 'a2', error: sanitizeRowError(raw) }] }); + expect(answer.body.failures[0].error).not.toMatch(/update|"deal"|"currency_code"|\$1/); + expect(deal('a1')?.currency_code).toBe('USD'); + }); + it('`isSystem` with a current organization passes the capability gate and stays in that organization', async () => { const answer = await boot(engine, { userId: 'u_sys', isSystem: true, tenantId: 'org_b' }) .call({ object: 'deal', field: 'amount', code: 'EUR', apply: true });