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
10 changes: 10 additions & 0 deletions .changeset/30816-backfill-row-error-sanitize.md
Original file line number Diff line number Diff line change
@@ -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.
23 changes: 22 additions & 1 deletion packages/objectql/src/record-currency-backfill.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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);
}
});
});
11 changes: 7 additions & 4 deletions packages/objectql/src/record-currency-backfill.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 '<name>' 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. */
Expand Down
22 changes: 22 additions & 0 deletions packages/rest/src/meta-record-currency-backfill-route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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<string, unknown>) => {
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 });
Expand Down
Loading