From be390f72f4065598c1e4ba90db99b6b00ef6ba12 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 02:49:32 +0000 Subject: [PATCH 1/5] feat(rest, client): a tenant-admin REST door for the record-currency code backfill POST /meta/_migrate-record-currency-codes runs backfillRecordCurrencyCodes for the caller's current organization only, gated on setup.write and mounted behind metadata.endpoints.maintenance; preview unless apply is true. Adds the SDK method, the REST route-ledger row, the objectql barrel re-export of the two organization report types, the 409 remedy naming the door, and a truncated walk that no longer claims every row was examined. Claude-Session: https://claude.ai/code/session_016WV4cyrhYJgbkZHbbqud3F Co-authored-by: Claude --- .../commands/migrate/record-currency-codes.ts | 7 + packages/client/src/index.ts | 47 +++ .../src/meta-automation-descriptors.test.ts | 24 ++ .../client/src/return-type-precision.test.ts | 4 + .../src/protocol-write-face.ts | 4 +- packages/objectql/src/index.ts | 2 + .../objectql/src/record-currency-backfill.ts | 15 +- ...eta-record-currency-backfill-route.test.ts | 331 ++++++++++++++++++ .../meta-record-currency-backfill-route.ts | 209 +++++++++++ .../src/rest-config-mount-table.pin.test.ts | 16 +- packages/rest/src/rest-route-ledger.ts | 3 + packages/rest/src/rest-server.ts | 19 + scripts/check-undeclared-dep-imports.mjs | 14 + 13 files changed, 690 insertions(+), 5 deletions(-) create mode 100644 packages/rest/src/meta-record-currency-backfill-route.test.ts create mode 100644 packages/rest/src/meta-record-currency-backfill-route.ts diff --git a/packages/cli/src/commands/migrate/record-currency-codes.ts b/packages/cli/src/commands/migrate/record-currency-codes.ts index fb7a3751ce9..b152eea5575 100644 --- a/packages/cli/src/commands/migrate/record-currency-codes.ts +++ b/packages/cli/src/commands/migrate/record-currency-codes.ts @@ -304,6 +304,13 @@ export default class MigrateRecordCurrencyCodes extends Command { if (report.failures.length > 0) { printError(`${report.failures.length} row(s) could not be written — re-run to finish them.`); + } else if (report.truncated) { + // The cap cut the walk short: neither "done" nor "nothing to do" is known. + printWarning( + `Walk incomplete — ${report.scannedRecords} row(s) examined, ${report.codeless} codeless` + + (apply ? `, ${report.written} written` : '') + + '. Re-run without --max-records (or with a higher one) to finish.', + ); } else if (apply && report.written > 0) { printSuccess( `Wrote ${report.code} on ${report.written} row(s). ` + diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index b67dd091396..d37bfc8646c 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -2201,6 +2201,9 @@ export class ObjectStackClient { * which is not a dependency of this package, and a second declaration in * spec would drift against the CLI that renders the same report. Its two * ledger rows carry the same note. ⛔ Do not re-declare it here. + * `migrateRecordCurrencyCodes`, added after the ruling, stays unbound for + * the same reason (its report type lives in `@objectstack/objectql`) and + * answers `unknown`, which the caller narrows. */ /** @@ -2270,6 +2273,50 @@ export class ObjectStackClient { return this.unwrapResponse(res); }, + /** + * ADR-0144: write a currency code into the rows of a `record`-mode amount + * that hold an amount beside a blank code — the rows stored before the + * field was switched to `currencyMode: 'record'`. The server-side form of + * `os migrate record-currency-codes`, for an organization admin with no + * shell on the deployment's database. + * + * **Your current organization only.** The server fills the rows of the + * caller's current organization and no other; there is no + * environment-wide form and no organization parameter (each organization + * has its own home currency). `code` is the one you give, never derived. + * + * **Preview by default.** Without `apply: true` it reports what it would + * write and writes nothing; the report is the one the CLI's `--json` + * prints (`@objectstack/objectql`'s `RecordCurrencyBackfillReport`: + * `scannedRecords` / `codeless` / `written` / the two skip counts, + * `sampleRecordIds`, `applied`, `truncated`, `failures`, `organizations`). + * `truncated: true` means the walk stopped at its row cap and some rows + * were not examined. + * + * Requires `setup.write` in the current organization (403 otherwise). + * + * Unbound for the reason `migrateStored` above is (ruling 2C): the + * report's named type lives in `@objectstack/objectql`, which this + * package does not depend on. ⛔ Do not re-declare it here. Answered as + * `unknown`, not `any`: a new `any` return is refused by + * `check:exported-any-returns` (`exported-any-returns.json` is + * shrink-only), and `unknown` makes the caller narrow, as `getPublished` + * does under ruling 1C. + */ + migrateRecordCurrencyCodes: async (opts: { object: string; field: string; code: string; apply?: boolean }) => { + const route = this.getRoute('metadata'); + const res = await this.fetch(`${this.baseUrl}${route}/_migrate-record-currency-codes`, { + method: 'POST', + body: JSON.stringify({ + object: opts.object, + field: opts.field, + code: opts.code, + ...(opts.apply === true ? { apply: true } : {}), + }), + }); + return this.unwrapResponse(res); + }, + /** * ADR-0020 D3.3 FSM introspection: the legal next states for `field` * from state `from`, per the object's `state_machine` validation rule. diff --git a/packages/client/src/meta-automation-descriptors.test.ts b/packages/client/src/meta-automation-descriptors.test.ts index 7dfce63e7d8..f83de580097 100644 --- a/packages/client/src/meta-automation-descriptors.test.ts +++ b/packages/client/src/meta-automation-descriptors.test.ts @@ -110,3 +110,27 @@ describe('client.automation descriptors (#3563 PR-5)', () => { expect(out.flows[0].bound).toBe(true); }); }); + +describe('client.meta.migrateRecordCurrencyCodes (ADR-0144 record-currency code backfill)', () => { + it('POSTs the three names to the backfill door, a preview unless apply is true', async () => { + const report = { object: 'deal', organizationId: 'org_a', codeless: 2, written: 0, applied: false, truncated: false }; + const { client, fetchMock } = createMockClient(report); + const out = await client.meta.migrateRecordCurrencyCodes({ object: 'deal', field: 'amount', code: 'USD' }); + expect(String(fetchMock.mock.calls[0][0])).toBe('http://localhost:3000/api/v1/meta/_migrate-record-currency-codes'); + expect(fetchMock.mock.calls[0][1]?.method).toBe('POST'); + expect(JSON.parse(String(fetchMock.mock.calls[0][1]?.body))).toEqual({ object: 'deal', field: 'amount', code: 'USD' }); + // The REST door answers the report BARE; it reaches the caller as it was sent. + expect(out).toEqual(report); + }); + + it('sends apply only when it is true, and never an organization', async () => { + const { client, fetchMock } = createMockClient({ applied: true }); + await client.meta.migrateRecordCurrencyCodes({ object: 'deal', field: 'amount', code: 'USD', apply: true }); + await client.meta.migrateRecordCurrencyCodes({ object: 'deal', field: 'amount', code: 'USD', apply: false }); + const bodies = fetchMock.mock.calls.map((c: unknown[]) => JSON.parse(String((c[1] as { body?: unknown })?.body))); + expect(bodies).toEqual([ + { object: 'deal', field: 'amount', code: 'USD', apply: true }, + { object: 'deal', field: 'amount', code: 'USD' }, + ]); + }); +}); diff --git a/packages/client/src/return-type-precision.test.ts b/packages/client/src/return-type-precision.test.ts index 3ca94766b0f..3c136a57891 100644 --- a/packages/client/src/return-type-precision.test.ts +++ b/packages/client/src/return-type-precision.test.ts @@ -428,6 +428,10 @@ export async function returnTypePrecisionPins12038(): Promise { // which this package does not depend on. Pinned so a well-meaning sweep // does not "fix" it against the ruling. expectTypeOf(await client.meta.migrateStored()).toBeAny(); + // `migrateRecordCurrencyCodes` is unbound for the same reason (its report + // type lives in `@objectstack/objectql`), but answers `unknown`, not `any`: + // the caller narrows, and `exported-any-returns.json` stays shrink-only. + expectTypeOf(await client.meta.migrateRecordCurrencyCodes({ object: 'deal', field: 'amount', code: 'USD' })).toEqualTypeOf(); // ── the packages.* eight ────────────────────────────────────────────── expectTypeOf(await client.packages.publish('com.acme.crm')).toEqualTypeOf(); diff --git a/packages/metadata-protocol/src/protocol-write-face.ts b/packages/metadata-protocol/src/protocol-write-face.ts index 37fa03843df..bbe9a4e19b3 100644 --- a/packages/metadata-protocol/src/protocol-write-face.ts +++ b/packages/metadata-protocol/src/protocol-write-face.ts @@ -253,7 +253,9 @@ function detectDestructiveObjectChanges(prev: any, next: any): Array<{ + 'display as a plain number, fall into the null group of a grouped aggregate, and sit outside every ' + 'currency-paired filter until they hold a code; once saved, write it with ' + `\`os migrate record-currency-codes --object ${objectName} --field ${fname} --code \`` - + ' (a dry run; add --apply to write).', + + ' (a dry run; add --apply to write), or, with no shell, as an organization admin with ' + + `\`POST /api/v1/meta/_migrate-record-currency-codes\` and the body \`{ "object": "${objectName}", "field": "${fname}", "code": "" }\`` + + ' (a preview of your current organization\'s rows; add "apply": true to write).', }); } } diff --git a/packages/objectql/src/index.ts b/packages/objectql/src/index.ts index 526c646892f..66f099f7c72 100644 --- a/packages/objectql/src/index.ts +++ b/packages/objectql/src/index.ts @@ -565,6 +565,8 @@ export type { RecordCurrencyBackfillEngine, RecordCurrencyBackfillLogger, RecordCurrencyBackfillOptions, + RecordCurrencyBackfillOrganization, + RecordCurrencyBackfillOrganizationsRefusal, } from './record-currency-backfill.js'; // [ADR-0144 D5, D7 parameter 1, Open item 8] The explicit recompute of stored // home amounts behind `os migrate recompute-home-amounts`: the write's own diff --git a/packages/objectql/src/record-currency-backfill.ts b/packages/objectql/src/record-currency-backfill.ts index b3dd1047e7a..8d55be86027 100644 --- a/packages/objectql/src/record-currency-backfill.ts +++ b/packages/objectql/src/record-currency-backfill.ts @@ -462,7 +462,12 @@ export function formatRecordCurrencyBackfillReport(report: RecordCurrencyBackfil } } if (report.codeless === 0) { - lines.push(`✓ Every ${pair} amount already has its currency code — nothing to backfill.`); + // A walk the cap cut short saw only some rows, so it cannot say "every". + lines.push( + report.truncated + ? `No codeless ${pair} amount among the ${report.scannedRecords} row(s) examined.` + : `✓ Every ${pair} amount already has its currency code — nothing to backfill.`, + ); } else { lines.push( report.applied @@ -485,6 +490,14 @@ export function formatRecordCurrencyBackfillReport(report: RecordCurrencyBackfil } if (report.truncated) { lines.push('⚠ Walk incomplete — some rows were not examined; re-run without the cap to finish.'); + // An unscoped run judged "one organization" from the rows it examined: the + // rows past the cap were never checked for another organization's. + if (report.organizationId === null && !report.allOrganizations) { + lines.push( + ' The organization check saw only the rows examined: rows past the cap may belong to another ' + + 'organization, so name the organization (one run per organization) before writing.', + ); + } } // Claims no row or table, never that nothing was written: the command that // prints it boots through the driver's connect, which can switch a SQLite diff --git a/packages/rest/src/meta-record-currency-backfill-route.test.ts b/packages/rest/src/meta-record-currency-backfill-route.test.ts new file mode 100644 index 00000000000..9677c79ffdb --- /dev/null +++ b/packages/rest/src/meta-record-currency-backfill-route.test.ts @@ -0,0 +1,331 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `POST /api/v1/meta/_migrate-record-currency-codes` — the tenant-admin door for + * the record-currency code backfill (ADR-0144; ruling 6670707546 item B). + * + * Driven on the real `RestServer` registration, with a real `ObjectQL` engine + * over an in-memory driver holding the rows of two organizations, so the + * scope is judged on what the driver stores and returns, not on a mocked + * backfill. The caller's context is the only stand-in. + */ + +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { ObjectQL } from '@objectstack/objectql'; +import { RestServer } from './rest-server.js'; + +const PATH = '/api/v1/meta/_migrate-record-currency-codes'; + +const ADMIN_A = { userId: 'u_a', tenantId: 'org_a', systemPermissions: ['setup.access', 'setup.write'] }; +const ADMIN_B = { userId: 'u_b', tenantId: 'org_b', systemPermissions: ['setup.access', 'setup.write'] }; + +function makeDriver() { + const stores = new Map>(); + const writes: Array<{ object: string; id: string; data: Record }> = []; + const storeFor = (o: string) => { + let s = stores.get(o); + if (!s) { s = new Map(); stores.set(o, s); } + return s; + }; + // Implicit equality plus the comparison operators the keyset walk emits. + const checkOp = (value: any, cond: any): boolean => { + if (cond === null || typeof cond !== 'object' || Array.isArray(cond) || cond instanceof Date) { + return value === cond; + } + return Object.entries(cond).every(([op, target]: [string, any]) => { + switch (op) { + case '$eq': return value === target; + case '$ne': return value !== target; + case '$gt': return value > target; + case '$gte': return value >= target; + case '$lt': return value < target; + case '$lte': return value <= target; + case '$in': return Array.isArray(target) && target.includes(value); + case '$nin': return Array.isArray(target) && !target.includes(value); + default: return true; + } + }); + }; + const matches = (row: any, where: any): boolean => { + if (!where || typeof where !== 'object') return true; + return Object.entries(where).every(([k, v]: [string, any]) => { + if (k === '$and') return (v as any[]).every((w) => matches(row, w)); + if (k === '$or') return (v as any[]).some((w) => matches(row, w)); + if (k === '$not') return !matches(row, v); + return checkOp(row?.[k], v); + }); + }; + const driver: any = { + name: 'memory', version: '0.0.0', supports: {}, + async connect() {}, async disconnect() {}, async checkHealth() { return true; }, async execute() { return null; }, + async find(object: string, ast: any) { + const rows = Array.from(storeFor(object).values()).filter((r) => matches(r, ast?.where)); + return typeof ast?.limit === 'number' ? rows.slice(0, ast.limit) : rows; + }, + async findOne(object: string, ast: any) { + for (const r of storeFor(object).values()) if (matches(r, ast?.where)) return r; + return null; + }, + async create(object: string, data: Record) { + const row = { ...data }; + storeFor(object).set(String(row.id), row); + return row; + }, + async update(object: string, id: string, data: Record) { + writes.push({ object, id, data }); + const s = storeFor(object); + const row = { ...s.get(id), ...data, id }; + s.set(id, row); + return row; + }, + async delete(object: string, id: string) { return storeFor(object).delete(id); }, + async count() { return 0; }, + async bulkCreate(object: string, rows: Record[]) { + return Promise.all(rows.map((r) => this.create(object, r, undefined))); + }, + async bulkUpdate() { return []; }, async bulkDelete() {}, + async beginTransaction() { return { __trx: true, commit: async () => {}, rollback: async () => {} }; }, + async commit() {}, async rollback() {}, + }; + return { driver, storeFor, writes }; +} + +function mockServer() { + return { + get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn(), patch: vi.fn(), + use: vi.fn(), listen: vi.fn().mockResolvedValue(undefined), close: vi.fn().mockResolvedValue(undefined), + }; +} + +function mockRes() { + const res: any = { + statusCode: 200, + json: vi.fn(function (this: any, body: any) { this._body = body; return this; }), + send: vi.fn(), + status: vi.fn(function (this: any, code: number) { this.statusCode = code; return this; }), + header: vi.fn(), + }; + return res; +} + +const PROTOCOL: any = { + getDiscovery: vi.fn().mockResolvedValue({ version: 'v0', routes: { data: '', metadata: '', ui: '', auth: '/auth' } }), + getMetaTypes: vi.fn().mockResolvedValue([]), + getMetaItems: vi.fn().mockResolvedValue([]), + getMetaItem: vi.fn().mockResolvedValue(null), +}; + +/** A `RestServer` over `engine` (none: no provider wired), answering every caller as `context`. */ +function boot( + engine: ObjectQL | undefined, + context: Record | undefined, + config: Record = {}, +) { + const provider = engine ? vi.fn(async () => engine) : undefined; + const rest: any = new RestServer( + mockServer() as any, + PROTOCOL, + config as any, + undefined, undefined, undefined, undefined, + provider as any, + ); + rest.resolveExecCtx = async () => context; + rest.registerRoutes(); + const route = rest.getRoutes().find((r: any) => r.method === 'POST' && r.path === PATH); + return { + provider, + route, + call: async (body: unknown) => { + if (!route) throw new Error(`POST ${PATH} is not registered`); + const res = mockRes(); + await route.handler({ params: {}, query: {}, headers: {}, body }, res); + return { status: res.statusCode as number, body: res.json.mock.calls.at(-1)?.[0] as any }; + }, + }; +} + +describe('POST /meta/_migrate-record-currency-codes — the caller\'s organization only', () => { + let engine: ObjectQL; + let d: ReturnType; + + const stored = (id: string, row: Record) => { + d.storeFor('deal').set(id, { id, name: id, ...row }); + }; + const deal = (id: string) => d.storeFor('deal').get(id); + /** The `where` of every driver read of `deal`. */ + const dealReads = () => vi.mocked(d.driver.find).mock.calls + .filter(([o]: unknown[]) => o === 'deal') + .map(([, ast]: unknown[]) => JSON.stringify((ast as any)?.where)); + + beforeEach(async () => { + engine = new ObjectQL(); + d = makeDriver(); + vi.spyOn(d.driver, 'find'); + vi.spyOn(d.driver, 'update'); + engine.registerDriver(d.driver, true); + await engine.init(); + engine.registry.registerObject({ + name: 'deal', + fields: { + name: { type: 'text' }, + amount: { type: 'currency', currencyConfig: { currencyMode: 'record', currencyField: 'currency_code' } }, + currency_code: { type: 'text', valueDomain: 'iso_4217_currency' }, + }, + } as any); + // Two organizations with codeless rows; one row of A already holds a code. + stored('a1', { organization_id: 'org_a', amount: 100 }); + stored('a2', { organization_id: 'org_a', amount: 200 }); + stored('a3', { organization_id: 'org_a', amount: 300, currency_code: 'CNY' }); + stored('b1', { organization_id: 'org_b', amount: 400 }); + }); + + it('a preview counts the caller\'s organization\'s rows only, and writes nothing', async () => { + const answer = await boot(engine, ADMIN_A).call({ object: 'deal', field: 'amount', code: 'USD' }); + expect(answer.status).toBe(200); + expect(answer.body).toMatchObject({ + object: 'deal', organizationId: 'org_a', allOrganizations: false, field: 'amount', codeField: 'currency_code', + code: 'USD', scannedRecords: 3, codeless: 2, written: 0, skippedCodePresent: 1, applied: false, + truncated: false, failures: [], + }); + expect(answer.body.sampleRecordIds).toEqual(['a1', 'a2']); + expect(answer.body.organizations).toEqual([ + { organizationId: 'org_a', scannedRecords: 3, codeless: 2, written: 0, skippedCodePresent: 1, skippedAmountBlank: 0 }, + ]); + expect(vi.mocked(d.driver.update)).not.toHaveBeenCalled(); + expect(d.writes).toEqual([]); + expect(deal('a1').currency_code).toBeUndefined(); + expect(deal('b1').currency_code).toBeUndefined(); + // Every read of the rows names the caller's organization. + expect(dealReads().length).toBeGreaterThan(0); + for (const where of dealReads()) expect(where).toContain('"organization_id":"org_a"'); + }); + + it('the other organization\'s admin sees and writes only its own row', async () => { + const preview = await boot(engine, ADMIN_B).call({ object: 'deal', field: 'amount', code: 'EUR' }); + expect(preview.body).toMatchObject({ organizationId: 'org_b', scannedRecords: 1, codeless: 1, written: 0 }); + expect(preview.body.sampleRecordIds).toEqual(['b1']); + + const applied = await boot(engine, ADMIN_B).call({ object: 'deal', field: 'amount', code: 'EUR', apply: true }); + expect(applied.body).toMatchObject({ organizationId: 'org_b', codeless: 1, written: 1, applied: true }); + expect(deal('b1').currency_code).toBe('EUR'); + expect(deal('a1').currency_code).toBeUndefined(); + expect(deal('a2').currency_code).toBeUndefined(); + expect(d.writes.map((w) => w.id)).toEqual(['b1']); + }); + + it('apply: true writes the caller\'s codeless rows, never a coded row or another organization\'s', async () => { + 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({ organizationId: 'org_a', codeless: 2, written: 2, applied: true, failures: [] }); + expect(deal('a1').currency_code).toBe('USD'); + expect(deal('a2').currency_code).toBe('USD'); + expect(deal('a3').currency_code).toBe('CNY'); + expect(deal('b1').currency_code).toBeUndefined(); + expect(d.writes.map((w) => w.id).sort()).toEqual(['a1', 'a2']); + // Idempotent: a second preview finds nothing left. + const again = await boot(engine, ADMIN_A).call({ object: 'deal', field: 'amount', code: 'USD' }); + expect(again.body).toMatchObject({ codeless: 0, written: 0 }); + }); + + 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 }); + expect(answer.body).toMatchObject({ organizationId: 'org_b', written: 1 }); + expect(d.writes.map((w) => w.id)).toEqual(['b1']); + }); + + it('a caller without `setup.write` is refused 403 FORBIDDEN before the engine is reached', async () => { + for (const caller of [ + { userId: 'u_m', tenantId: 'org_a', systemPermissions: ['setup.access'] }, + { userId: 'u_md', tenantId: 'org_a', systemPermissions: ['manage_metadata'] }, + ]) { + const stack = boot(engine, caller); + const answer = await stack.call({ object: 'deal', field: 'amount', code: 'USD', apply: true }); + expect({ status: answer.status, code: answer.body?.error?.code }, caller.userId).toEqual({ status: 403, code: 'FORBIDDEN' }); + expect(String(answer.body?.error?.message)).toContain('`setup.write`'); + expect(stack.provider).not.toHaveBeenCalled(); + } + expect(dealReads()).toEqual([]); + expect(d.writes).toEqual([]); + }); + + it('a caller with no current organization is refused 403 FORBIDDEN: there is no environment-wide form', async () => { + for (const caller of [ + { userId: 'u_x', systemPermissions: ['setup.write'] }, + { userId: 'u_sys', isSystem: true }, + ]) { + const stack = boot(engine, caller); + const answer = await stack.call({ object: 'deal', field: 'amount', code: 'USD', apply: true }); + expect({ status: answer.status, code: answer.body?.error?.code }, caller.userId).toEqual({ status: 403, code: 'FORBIDDEN' }); + expect(String(answer.body?.error?.message)).toContain('no current organization'); + expect(stack.provider).not.toHaveBeenCalled(); + } + expect(dealReads()).toEqual([]); + expect(d.writes).toEqual([]); + }); + + it('an organization in the body is refused 400 VALIDATION_ERROR, not honoured and not ignored', async () => { + for (const extra of [{ organizationId: 'org_b' }, { allOrganizations: true }]) { + const stack = boot(engine, ADMIN_A); + const answer = await stack.call({ object: 'deal', field: 'amount', code: 'USD', apply: true, ...extra }); + const key = Object.keys(extra)[0]; + expect({ status: answer.status, code: answer.body?.error?.code }, key).toEqual({ status: 400, code: 'VALIDATION_ERROR' }); + expect(String(answer.body?.error?.message)).toContain(`'${key}' is not accepted`); + expect(stack.provider).not.toHaveBeenCalled(); + } + expect(d.writes).toEqual([]); + expect(deal('b1').currency_code).toBeUndefined(); + }); + + it('a malformed body is refused 400 VALIDATION_ERROR before the engine is reached', async () => { + for (const body of [ + undefined, + [], + { field: 'amount', code: 'USD' }, + { object: 'deal', field: ' ', code: 'USD' }, + { object: 'deal', field: 'amount', code: 'USD', apply: 'true' }, + { object: 'deal', field: 'amount', code: 'USD', maxRecords: 10 }, + ]) { + const stack = boot(engine, ADMIN_A); + const answer = await stack.call(body); + expect({ status: answer.status, code: answer.body?.error?.code }, JSON.stringify(body)).toEqual({ status: 400, code: 'VALIDATION_ERROR' }); + expect(stack.provider).not.toHaveBeenCalled(); + } + expect(d.writes).toEqual([]); + }); + + it('the backfill\'s own refusals answer with its status and code, before any row is read', async () => { + const notIso = await boot(engine, ADMIN_A).call({ object: 'deal', field: 'amount', code: 'usd', apply: true }); + expect({ status: notIso.status, code: notIso.body?.error?.code }).toEqual({ status: 400, code: 'INVALID_FIELD' }); + const notRecord = await boot(engine, ADMIN_A).call({ object: 'deal', field: 'name', code: 'USD', apply: true }); + expect({ status: notRecord.status, code: notRecord.body?.error?.code }).toEqual({ status: 400, code: 'INVALID_FIELD' }); + const noObject = await boot(engine, ADMIN_A).call({ object: 'no_such_object', field: 'amount', code: 'USD' }); + expect(noObject.status).toBe(404); + expect(dealReads()).toEqual([]); + expect(d.writes).toEqual([]); + }); + + it('with no data engine wired it answers 501 NOT_IMPLEMENTED', async () => { + const answer = await boot(undefined, ADMIN_A).call({ object: 'deal', field: 'amount', code: 'USD' }); + expect({ status: answer.status, code: answer.body?.error?.code }).toEqual({ status: 501, code: 'NOT_IMPLEMENTED' }); + }); +}); + +describe('POST /meta/_migrate-record-currency-codes — mounted behind `metadata.endpoints.maintenance`', () => { + it('is mounted by default, and the maintenance switch off takes it away', () => { + expect(boot(undefined, ADMIN_A).route).toBeDefined(); + expect(boot(undefined, ADMIN_A, { metadata: { endpoints: { maintenance: false } } }).route).toBeUndefined(); + // The sibling switches leave it mounted. + for (const key of ['types', 'items', 'item']) { + expect(boot(undefined, ADMIN_A, { metadata: { endpoints: { [key]: false } } }).route, key).toBeDefined(); + } + }); + + it('the environment-scoped base mounts its twin', () => { + const rest: any = new RestServer(mockServer() as any, PROTOCOL, { api: { enableProjectScoping: true } } as any); + rest.registerRoutes(); + const paths = rest.getRoutes().filter((r: any) => r.method === 'POST' && r.path.endsWith('/_migrate-record-currency-codes')) + .map((r: any) => r.path); + expect(paths).toContain('/api/v1/environments/:environmentId/meta/_migrate-record-currency-codes'); + }); +}); diff --git a/packages/rest/src/meta-record-currency-backfill-route.ts b/packages/rest/src/meta-record-currency-backfill-route.ts new file mode 100644 index 00000000000..f58d3e510fb --- /dev/null +++ b/packages/rest/src/meta-record-currency-backfill-route.ts @@ -0,0 +1,209 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `POST {metaPath}/_migrate-record-currency-codes` — the tenant-admin door for + * the record-currency code backfill, the REST twin of + * `os migrate record-currency-codes` (ADR-0144; ruling 6670707546, letter C, + * item B). + * + * ## Why a door + * + * An existing amount switched to `currencyMode: 'record'` leaves every row it + * already stored holding an amount beside a blank code; the metadata save that + * makes the switch answers 409 `destructive_change` naming the backfill. The + * CLI form needs a shell on the deployment's database, which a cloud-managed + * tenant admin does not have. This door is that admin's finish line, shaped on + * `POST {metaPath}/_migrate-stored` (`meta-collection-routes.ts`): preview + * unless `apply: true`, the report the CLI's `--json` prints, mounted behind + * the same `metadata.endpoints.maintenance` switch. + * + * ## One organization: the caller's + * + * The walk, the counts and the writes are `@objectstack/objectql`'s + * `backfillRecordCurrencyCodes`, called with `organizationId` = the caller's + * CURRENT organization (`ExecutionContext.tenantId`, vetted by the resolver), + * so the door reads and writes that organization's rows only. ⛔ There is no + * environment-wide form, and no `organizationId` (or `allOrganizations`) in + * the body: every organization has its own home currency, and an organization + * admin answers for one organization's rows. A body key outside + * `{ object, field, code, apply }` is refused with `400 VALIDATION_ERROR` + * naming it, so a caller who meant to name another organization is told so + * instead of silently filling its own. A caller with no current organization + * is refused `403 FORBIDDEN` before anything is read. + * + * ## The gate: `setup.write` + * + * The capability the ruling recommends (`manage_metadata` is the alternative + * the maintainer may still name): the tenant Setup write capability, resolved + * — like every `systemPermissions` member — for the caller's current + * organization. `isSystem` bypasses, as on every capability gate here. Asked + * FIRST of all, before the body is judged or the engine resolved, so a refused + * caller learns nothing about which objects or kernels exist (the + * `_migrate-stored` posture). There is no walled refusal: unlike the stored + * metadata `_migrate-stored` rewrites, these rows are the organization's own, + * under every tenancy posture. + * + * ## The engine, and why the import is dynamic + * + * The engine is the REQUEST's environment's (`objectQLProvider`, through the + * shared environment resolver), reached by `wiredEngineOrLoud` as the object + * state read reaches it. `@objectstack/objectql` is a devDependency of this + * package, deliberately (the stance `meta-object-state-routes.ts` records), so + * the function is loaded through a DYNAMIC import inside a `try`: a host serving + * REST without the data engine answers `501 NOT_IMPLEMENTED` instead of failing + * to load. `scripts/check-undeclared-dep-imports.mjs` ledgers that probe + * against this file. + * + * ## Errors + * + * Only codes the registry already carries: `FORBIDDEN` (capability, no current + * organization), `VALIDATION_ERROR` (the body), `NOT_IMPLEMENTED` (no engine), + * and the backfill's own refusals (`INVALID_FIELD` for a field that is not a + * record-mode amount or a code outside ISO 4217, the object-not-found answer), + * each answered through `handleRouteError` with the status the function gave. + */ + +import { rethrowAuthzStoreUnavailable } from '@objectstack/core'; +import { handleRouteError } from './error-response.js'; +import { logWarn } from './log.js'; +import type { MetaRouteScope } from './meta-collection-routes.js'; +import { wiredEngineOrLoud, type RestServer } from './rest-server.js'; + +/** The capability the door demands, in the caller's current organization (ruling 6670707546 item B). */ +export const RECORD_CURRENCY_BACKFILL_CAPABILITY = 'setup.write'; + +/** The body keys the door reads — the whole closed set. */ +const BODY_KEYS: readonly string[] = ['object', 'field', 'code', 'apply']; + +/** + * The `RestServer` members {@link registerMetaRecordCurrencyBackfillRoute} + * reads — every one of them, and no other. Each is typed AS the class member, + * so a signature change on the class is a change here too. `routeManager` is + * captured by value inside `registerMetadataEndpoints`' swap (the gated + * registrar, as for every `/meta` group), and the provider field goes as a + * getter, read at request time. + */ +export interface MetaRecordCurrencyBackfillRouteHost { + readonly routeManager: RestServer['routeManager']; + resolveExecCtx: RestServer['resolveExecCtx']; + readonly objectQLProvider: RestServer['objectQLProvider']; + resolveRequestEnvironmentId: RestServer['resolveRequestEnvironmentId']; +} + +/** `400 VALIDATION_ERROR` naming the body key it judges. */ +function bodyRefusal(field: string, message: string): Error { + return Object.assign(new Error(message), { code: 'VALIDATION_ERROR', status: 400, field }); +} + +/** The three names and the apply flag, judged before the engine is resolved. */ +function readBody(body: unknown): { object: string; field: string; code: string; apply: boolean } { + if (body === undefined || body === null || typeof body !== 'object' || Array.isArray(body)) { + throw bodyRefusal('body', 'The body must be a JSON object: { object, field, code, apply? }.'); + } + const b = body as Record; + for (const key of Object.keys(b)) { + if (BODY_KEYS.includes(key)) continue; + throw bodyRefusal( + key, + key === 'organizationId' || key === 'allOrganizations' + ? `'${key}' is not accepted: this door fills the rows of your current organization only. ` + + 'Switch to the organization whose rows you mean, and send { object, field, code, apply? }.' + : `Unknown body key '${key}': the body is { object, field, code, apply? }.`, + ); + } + for (const key of ['object', 'field', 'code'] as const) { + const value = b[key]; + if (typeof value !== 'string' || value.trim() === '') { + throw bodyRefusal(key, `'${key}' is required and must be a non-blank string.`); + } + } + if (b.apply !== undefined && typeof b.apply !== 'boolean') { + throw bodyRefusal('apply', "'apply' must be true or false; omit it for a preview."); + } + return { object: b.object as string, field: b.field as string, code: b.code as string, apply: b.apply === true }; +} + +/** + * Register `POST {metaPath}/_migrate-record-currency-codes`, behind + * `metadata.endpoints.maintenance`, with the server's members as `this` and + * the `/meta` head bindings as `scope`. A literal `POST` path: no `POST + * {metaPath}/:type` exists to capture it, so its place in the order is free. + */ +export function registerMetaRecordCurrencyBackfillRoute( + this: MetaRecordCurrencyBackfillRouteHost, + scope: MetaRouteScope, +): void { + const { metadata, metaPath, isScoped } = scope; + if (metadata.endpoints.maintenance === false) return; + this.routeManager.register({ + method: 'POST', + path: `${metaPath}/_migrate-record-currency-codes`, + handler: async (req: any, res: any) => { + try { + const environmentId = isScoped ? req.params?.environmentId : undefined; + const ctx = await this.resolveExecCtx(environmentId, req).catch(rethrowAuthzStoreUnavailable); + const held = new Set(Array.isArray(ctx?.systemPermissions) ? ctx.systemPermissions : []); + if (!ctx?.isSystem && !held.has(RECORD_CURRENCY_BACKFILL_CAPABILITY)) { + res.status(403).json({ + error: { + code: 'FORBIDDEN', + message: + 'Backfilling record currency codes requires the `setup.write` capability in your ' + + 'current organization.', + }, + }); + return; + } + const organizationId = typeof ctx?.tenantId === 'string' && ctx.tenantId !== '' ? ctx.tenantId : undefined; + if (organizationId === undefined) { + res.status(403).json({ + error: { + code: 'FORBIDDEN', + message: + 'This request has no current organization, and the backfill fills the rows of the ' + + 'caller\'s current organization only: select the organization whose rows you mean ' + + 'and retry. Nothing was read.', + }, + }); + return; + } + const body = readBody(req.body); + const ql: any = await wiredEngineOrLoud( + Boolean(this.objectQLProvider), + async () => this.objectQLProvider!(await this.resolveRequestEnvironmentId(environmentId, req)), + ); + let backfill: typeof import('@objectstack/objectql')['backfillRecordCurrencyCodes'] | undefined; + try { + ({ backfillRecordCurrencyCodes: backfill } = await import('@objectstack/objectql')); + } catch { + backfill = undefined; + } + if ( + typeof backfill !== 'function' + || typeof ql?.getObject !== 'function' + || typeof ql?.find !== 'function' + || typeof ql?.update !== 'function' + ) { + res.status(501).json({ + error: { + code: 'NOT_IMPLEMENTED', + message: 'The record currency code backfill is not available in this runtime: it has no data engine.', + }, + }); + return; + } + // Per-row failures are in the report the caller receives; the + // log line is the operator's copy. + const logger = { info: () => {}, warn: (message: string) => logWarn(message) }; + const report = await backfill(ql, logger, { ...body, organizationId }); + res.json(report); + } catch (error: any) { + handleRouteError(res, error); + } + }, + metadata: { + summary: 'Write a currency code into the current organization\'s codeless rows of a record-mode amount', + tags: ['metadata'], + }, + }); +} diff --git a/packages/rest/src/rest-config-mount-table.pin.test.ts b/packages/rest/src/rest-config-mount-table.pin.test.ts index 1202dd55fe8..89d146d4afa 100644 --- a/packages/rest/src/rest-config-mount-table.pin.test.ts +++ b/packages/rest/src/rest-config-mount-table.pin.test.ts @@ -48,8 +48,9 @@ * (#27627). A pin that only watched * `GET {prefix}/:type/:name` would sit green through twelve of them * moving, which is the state #15854 measured. - * - `metadata.endpoints.maintenance` gates THREE, one of them the write - * door `POST {prefix}/_migrate-stored`. + * - `metadata.endpoints.maintenance` gates FOUR, two of them write doors: + * `POST {prefix}/_migrate-stored` and the record-currency code backfill + * `POST {prefix}/_migrate-record-currency-codes`. * * Asserting the difference is EXACTLY a named set catches a gate that grows a * route as loudly as one that loses a route. @@ -192,9 +193,17 @@ const CASES: Array<{ path: string; removes: string[] }> = [ // [#15542] THREE whole-store operations, one of them a WRITE door. The key // the ruling created so that closing a listing read stops disarming a // migration door. + // + // [ADR-0144, ruling 6670707546 item B] FOUR: the record-currency code + // backfill door joined it, behind this switch as `_migrate-stored` is. { path: 'metadata.endpoints.maintenance', - removes: [`GET ${META}/_drafts`, `GET ${META}/diagnostics`, `POST ${META}/_migrate-stored`], + removes: [ + `GET ${META}/_drafts`, + `GET ${META}/diagnostics`, + `POST ${META}/_migrate-record-currency-codes`, + `POST ${META}/_migrate-stored`, + ], }, // [#15854] TWELVE routes (thirteen since #27627, below) — the WHOLE per-item face, which is what the // switch's name has always promised: the reads, the per-item WRITES @@ -266,6 +275,7 @@ const CASES: Array<{ path: string; removes: string[] }> = [ `GET ${META}/types`, `POST ${META}/:type/:name/publish`, `POST ${META}/:type/:name/rollback`, + `POST ${META}/_migrate-record-currency-codes`, `POST ${META}/_migrate-stored`, `PUT ${META}/:type/:name`, ], diff --git a/packages/rest/src/rest-route-ledger.ts b/packages/rest/src/rest-route-ledger.ts index a3f8e4fab72..70eb6847147 100644 --- a/packages/rest/src/rest-route-ledger.ts +++ b/packages/rest/src/rest-route-ledger.ts @@ -225,6 +225,9 @@ export const REST_ROUTE_LEDGER: readonly RestRouteLedgerEntry[] = [ { route: 'POST /api/v1/meta/_migrate-stored', family: 'metadata', source: 'route-manager', disposition: 'sdk', client: 'meta.migrateStored', authz: 'anonymous-deny-meta', note: 'ADR-0087 stored-row canonicalization, the route form of `os migrate meta --stored`: it rewrites stored `sys_metadata` rows in place to their canonical form; gated on `manage_metadata`, preview unless { apply: true }. DELIBERATELY UNBOUND — this row would name the schema, but the report\'s only named type, `StoredMigrationReport`, lives in `@objectstack/metadata-protocol` (unreachable from the spec/api namespace this field resolves against); a second declaration in spec would drift against the CLI rendering the same report. Answered BARE on this surface, enveloped on the dispatcher twin' }, + // [ADR-0144, ruling 6670707546 item B] The tenant-admin twin of the CLI backfill. + { route: 'POST /api/v1/meta/_migrate-record-currency-codes', family: 'metadata', source: 'route-manager', disposition: 'sdk', client: 'meta.migrateRecordCurrencyCodes', + note: 'ADR-0144 record-currency code backfill, the route form of `os migrate record-currency-codes`: writes the one given code into the codeless rows of a record-mode amount in the CALLER\'S CURRENT ORGANIZATION only (no environment-wide form, no organization in the body); gated on `setup.write`, preview unless { apply: true }, behind `metadata.endpoints.maintenance`. DELIBERATELY UNBOUND for `migrateStored`\'s reason — the report\'s named type, `RecordCurrencyBackfillReport`, lives in `@objectstack/objectql`, unreachable from the spec/api namespace this field resolves against. REST-only: the dispatcher has no twin. Answered BARE' }, { route: 'GET /api/v1/meta/:type', family: 'metadata', source: 'route-manager', disposition: 'sdk', client: 'meta.getItems' }, { route: 'GET /api/v1/meta/:type/:name/references', family: 'metadata', source: 'route-manager', disposition: 'sdk', client: 'meta.getReferences', responseSchema: 'FindReferencesToMetaResponseSchema', diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index 5d315ce0be7..403f8befc0f 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -383,6 +383,11 @@ import { // [#23050 slice 13e] The `/meta` object state-machine read moved to its own module — see its // header. import { registerMetaObjectStateRoute, type MetaObjectStateRouteHost } from './meta-object-state-routes.js'; +// [ADR-0144, ruling 6670707546 item B] The record-currency code backfill door — see its header. +import { + registerMetaRecordCurrencyBackfillRoute, + type MetaRecordCurrencyBackfillRouteHost, +} from './meta-record-currency-backfill-route.js'; import { loadExcelJs, type Worksheet } from './xlsx-module.js'; // [#18386] `?template=true` on the export door: the import template's column // rule, request reading and workbook. See the module header. @@ -5603,6 +5608,20 @@ export class RestServer { }; registerMetaObjectStateRoute.call(objectStateHost, scope); + // [ADR-0144, ruling 6670707546 item B] The tenant-admin door for the + // record-currency code backfill (`POST {metaPath}/_migrate-record-currency-codes`), + // in `meta-record-currency-backfill-route.ts`, behind the maintenance switch as + // `_migrate-stored` is. A literal `POST` path no `:type` mount captures, so its + // place in the order is free; built here, inside the umbrella's swap, so the route + // manager it captures is the wrapping registrar. Read that module's header. + const recordCurrencyBackfillHost: MetaRecordCurrencyBackfillRouteHost = { + routeManager: this.routeManager, + resolveExecCtx: (...a) => this.resolveExecCtx(...a), + get objectQLProvider() { return readObjectQLProvider(); }, + resolveRequestEnvironmentId: (...a) => this.resolveRequestEnvironmentId(...a), + }; + registerMetaRecordCurrencyBackfillRoute.call(recordCurrencyBackfillHost, scope); + // [#23050 slice 13e] The published read (`GET {metaPath}/:type/:name/published`) // moved to `meta-item-history-routes.ts`, beside the history family, and is registered // HERE so it keeps its place in the order: after the object state-machine read, the diff --git a/scripts/check-undeclared-dep-imports.mjs b/scripts/check-undeclared-dep-imports.mjs index daf2563b29f..11eca16a549 100644 --- a/scripts/check-undeclared-dep-imports.mjs +++ b/scripts/check-undeclared-dep-imports.mjs @@ -323,6 +323,20 @@ const LEDGER = [ + 'the same seam for exactly that reason — so declaring it would reverse a stance the tree ' + 'states, not repair an omission.', }, + { + pkg: '@objectstack/rest', + dep: '@objectstack/objectql', + // [ADR-0144, ruling 6670707546 item B] The record-currency code backfill door loads + // `backfillRecordCurrencyCodes` the way the state-machine door above loads its function. + file: 'packages/rest/src/meta-record-currency-backfill-route.ts', + kind: 'optional-runtime-probe', + why: + 'The record-currency code backfill door (`POST /meta/_migrate-record-currency-codes`) calls ' + + 'the engine package\'s backfill function through the same guarded probe as the state-machine ' + + 'door: the import is wrapped in try/catch and a deployment serving REST WITHOUT the data ' + + 'engine answers 501 NOT_IMPLEMENTED rather than failing to load. Declaring it would couple ' + + '`@objectstack/rest` to the engine, the stance the row above records.', + }, // The objectui Console (imported as ui/ at the cutover, #25752 bill item 20). Two rows, // one per dependency, each covering the package's whole `src/` directory: the evidence // is the manifest, so it is per package, not per call site. From acf60146fba6d6312a46c5fa24a9bde42b69ed1e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 02:59:36 +0000 Subject: [PATCH 2/5] fix(rest): answer the backfill door on the shared envelope pair; pin the truncated walk text Claude-Session: https://claude.ai/code/session_016WV4cyrhYJgbkZHbbqud3F Co-authored-by: Claude --- ...0675-record-currency-backfill-rest-door.md | 22 +++++ content/docs/data-modeling/currency.mdx | 4 + content/docs/deployment/cli.mdx | 34 +++++++ .../migrate/record-currency-codes.test.ts | 14 +++ .../src/meta-automation-descriptors.test.ts | 6 +- ...protocol-destructive-currency-mode.test.ts | 3 + .../src/record-currency-backfill.test.ts | 25 ++++++ ...eta-record-currency-backfill-route.test.ts | 8 +- .../meta-record-currency-backfill-route.ts | 89 +++++++++++-------- packages/rest/src/rest-route-ledger.ts | 2 +- 10 files changed, 167 insertions(+), 40 deletions(-) create mode 100644 .changeset/30675-record-currency-backfill-rest-door.md diff --git a/.changeset/30675-record-currency-backfill-rest-door.md b/.changeset/30675-record-currency-backfill-rest-door.md new file mode 100644 index 00000000000..2d80dce927e --- /dev/null +++ b/.changeset/30675-record-currency-backfill-rest-door.md @@ -0,0 +1,22 @@ +--- +'@objectstack/rest': patch +'@objectstack/client': patch +'@objectstack/objectql': patch +'@objectstack/cli': patch +'@objectstack/metadata-protocol': patch +--- + +feat(rest, client): a tenant-admin REST door for the record-currency code backfill + +Clause-②: yes (widening) +Metadata-protocol: none + +An organization admin with no shell on the deployment can now run the record-currency code backfill (`os migrate record-currency-codes`) over HTTP: `POST /api/v1/meta/_migrate-record-currency-codes` with `{ object, field, code, apply? }`, or `client.meta.migrateRecordCurrencyCodes(...)` from the SDK. + +- It fills the codeless rows of **the caller's current organization only**. There is no environment-wide form, and the body takes no organization: `organizationId`, `allOrganizations` or any other key outside the four is refused with `VALIDATION_ERROR` / 400. A request with no current organization is refused `403 FORBIDDEN`. +- It requires `setup.write` in that organization (`403 FORBIDDEN` otherwise), and it is mounted behind `metadata.endpoints.maintenance` with the other maintenance routes. +- Preview unless `apply` is `true`. The answer is the report the CLI prints under `--json`, and the code, field and object refusals are the CLI's. +- The SDK method answers `unknown`: the report's named type lives in `@objectstack/objectql`. +- The 409 `destructive_change` finding `currency_mode_to_record` now names this route beside the CLI command. +- `@objectstack/objectql` now exports `RecordCurrencyBackfillOrganization` and `RecordCurrencyBackfillOrganizationsRefusal` from its entry. +- A backfill walk stopped by `--max-records` no longer prints "nothing to backfill" for rows it did not examine. It says the walk is incomplete, and an unscoped run says its organization check saw only the rows examined. diff --git a/content/docs/data-modeling/currency.mdx b/content/docs/data-modeling/currency.mdx index 4e14e0f4d5a..d38a69aa349 100644 --- a/content/docs/data-modeling/currency.mdx +++ b/content/docs/data-modeling/currency.mdx @@ -148,6 +148,10 @@ naming no organization there is refused before any write and lists each organiza codeless row count, and `--all-organizations` writes the one code into every organization's rows when it really is right for all of them. See [`os migrate record-currency-codes`](/docs/deployment/cli#os-migrate-record-currency-codes). +With no shell on the deployment (a hosted organization), an organization admin holding +`setup.write` runs the same backfill for their current organization's rows with +`POST /api/v1/meta/_migrate-record-currency-codes` and the body +`{ "object", "field", "code", "apply" }`, a preview unless `apply` is `true`. ### Aggregating a record-mode amount diff --git a/content/docs/deployment/cli.mdx b/content/docs/deployment/cli.mdx index f1e87e11440..33cc66b353e 100644 --- a/content/docs/deployment/cli.mdx +++ b/content/docs/deployment/cli.mdx @@ -1663,6 +1663,40 @@ every write turns a blank code into a code, so a second run finds nothing to wri Re-running until the report says zero *is* the verification, which is why this command records no flag. +A walk that reaches `--max-records` stops there: the report says `truncated: true`, the +text says the walk is incomplete, and the command never prints "nothing to backfill" +for rows it did not examine. Re-run without the cap to finish. + +**Without shell access, use the route.** An organization admin on a hosted deployment +cannot reach its database from a shell. The same backfill is exposed over HTTP, for the +rows of **the caller's current organization only**: + +```http +POST /api/v1/meta/_migrate-record-currency-codes +Content-Type: application/json + +{ "object": "opportunity", "field": "amount", "code": "USD", "apply": true } +``` + +or from the SDK: + +```ts +const preview = await client.meta.migrateRecordCurrencyCodes({ object: 'opportunity', field: 'amount', code: 'USD' }); +const result = await client.meta.migrateRecordCurrencyCodes({ object: 'opportunity', field: 'amount', code: 'USD', apply: true }); +``` + +It answers `{ "success": true, "data": … }` with, as `data`, the report this command +prints under `--json` (its `report` member), and +takes the same posture: **preview unless `apply` is literally `true`**. It is the +`--org` run for the caller's current organization: there is no environment-wide form, +and the body takes no organization (`organizationId` or `allOrganizations` in it is +refused with `VALIDATION_ERROR` / 400, as is any other key outside `object`, `field`, +`code` and `apply`). A request with no current organization is refused `403`. It +requires the `setup.write` capability in that organization and answers `403` +otherwise; the code, field and object refusals are the command's. It is mounted with +the other maintenance routes, so `metadata.endpoints.maintenance: false` takes it away. +There is no Setup button for it yet: that is separate UI work. + #### `os migrate recompute-home-amounts` A record-mode amount that declares a home amount is converted into the organization's home diff --git a/packages/cli/src/commands/migrate/record-currency-codes.test.ts b/packages/cli/src/commands/migrate/record-currency-codes.test.ts index 24dde49d6e1..db5864b2fd4 100644 --- a/packages/cli/src/commands/migrate/record-currency-codes.test.ts +++ b/packages/cli/src/commands/migrate/record-currency-codes.test.ts @@ -316,6 +316,20 @@ describe('os migrate record-currency-codes', () => { expect(lines.find((line) => line.startsWith('Dry run —'))).toBe(`${DRY_RUN_LINE} ${JOURNAL_MODE_SWITCH_NOTE}`); }, RUN_TIMEOUT); + it('a walk --max-records cut short says it is incomplete, never "nothing to backfill"', async () => { + boot(POSTGRES_CONFIG); + vi.mocked(backfillRecordCurrencyCodes).mockResolvedValue({ + ...REPORT, organizationId: 'org_a', allOrganizations: false, organizations: [], + scannedRecords: 2, codeless: 0, skippedCodePresent: 2, sampleRecordIds: [], truncated: true, + } as any); + await MigrateRecordCurrencyCodes.run([...NAMING, '--org', 'org_a', '--max-records', '2'], { root: CLI_ROOT }); + const text = printedLines().join('\n'); + expect(text).toContain('No codeless opportunity.amount amount among the 2 row(s) examined.'); + expect(text).toContain('Walk incomplete — 2 row(s) examined, 0 codeless.'); + expect(text).not.toContain('Nothing to backfill'); + expect(text).not.toContain('nothing to backfill'); + }, RUN_TIMEOUT); + it('on PostgreSQL the dry-run line names no switch', async () => { boot(POSTGRES_CONFIG); await MigrateRecordCurrencyCodes.run(NAMING, { root: CLI_ROOT }); diff --git a/packages/client/src/meta-automation-descriptors.test.ts b/packages/client/src/meta-automation-descriptors.test.ts index f83de580097..29df6f9ab01 100644 --- a/packages/client/src/meta-automation-descriptors.test.ts +++ b/packages/client/src/meta-automation-descriptors.test.ts @@ -114,17 +114,17 @@ describe('client.automation descriptors (#3563 PR-5)', () => { describe('client.meta.migrateRecordCurrencyCodes (ADR-0144 record-currency code backfill)', () => { it('POSTs the three names to the backfill door, a preview unless apply is true', async () => { const report = { object: 'deal', organizationId: 'org_a', codeless: 2, written: 0, applied: false, truncated: false }; - const { client, fetchMock } = createMockClient(report); + const { client, fetchMock } = createMockClient({ success: true, data: report }); const out = await client.meta.migrateRecordCurrencyCodes({ object: 'deal', field: 'amount', code: 'USD' }); expect(String(fetchMock.mock.calls[0][0])).toBe('http://localhost:3000/api/v1/meta/_migrate-record-currency-codes'); expect(fetchMock.mock.calls[0][1]?.method).toBe('POST'); expect(JSON.parse(String(fetchMock.mock.calls[0][1]?.body))).toEqual({ object: 'deal', field: 'amount', code: 'USD' }); - // The REST door answers the report BARE; it reaches the caller as it was sent. + // The REST door answers the report as the `data` of the shared envelope; the caller gets the report. expect(out).toEqual(report); }); it('sends apply only when it is true, and never an organization', async () => { - const { client, fetchMock } = createMockClient({ applied: true }); + const { client, fetchMock } = createMockClient({ success: true, data: { applied: true } }); await client.meta.migrateRecordCurrencyCodes({ object: 'deal', field: 'amount', code: 'USD', apply: true }); await client.meta.migrateRecordCurrencyCodes({ object: 'deal', field: 'amount', code: 'USD', apply: false }); const bodies = fetchMock.mock.calls.map((c: unknown[]) => JSON.parse(String((c[1] as { body?: unknown })?.body))); diff --git a/packages/objectql/src/protocol-destructive-currency-mode.test.ts b/packages/objectql/src/protocol-destructive-currency-mode.test.ts index 39f79e8f5f6..624b0b97549 100644 --- a/packages/objectql/src/protocol-destructive-currency-mode.test.ts +++ b/packages/objectql/src/protocol-destructive-currency-mode.test.ts @@ -104,6 +104,9 @@ describe('ObjectStackProtocolImplementation - currency_mode_to_record', () => { expect(message).toContain('fall into the null group of a grouped aggregate'); expect(message).toContain('sit outside every currency-paired filter'); expect(message).toContain(`os migrate record-currency-codes --object ${object} --field amount --code `); + // …and, for an organization admin with no shell, the REST door beside it. + expect(message).toContain('`POST /api/v1/meta/_migrate-record-currency-codes`'); + expect(message).toContain(`{ "object": "${object}", "field": "amount", "code": "" }`); // It reads no row of the object: the only reads are the // metadata store's own (the row this save overwrites). for (const verb of ['find', 'findOne', 'count', 'aggregate'] as const) { diff --git a/packages/objectql/src/record-currency-backfill.test.ts b/packages/objectql/src/record-currency-backfill.test.ts index 7786e2c4822..df5a22feb40 100644 --- a/packages/objectql/src/record-currency-backfill.test.ts +++ b/packages/objectql/src/record-currency-backfill.test.ts @@ -194,6 +194,31 @@ describe('backfillRecordCurrencyCodes — rows stored before the switch to recor ); }); + it('a walk the cap cuts short says so, and never claims every row was examined', async () => { + // Two coded rows first, so the capped walk meets no codeless row at all. + stored('c1', { amount: 1, currency_code: 'EUR' }); + stored('c2', { amount: 2, currency_code: 'EUR' }); + stored('c3', { amount: 3 }); + const capped = await backfillRecordCurrencyCodes(engine, quietLogger, { + object: 'deal', field: 'amount', code: 'USD', maxRecords: 2, + }); + expect(capped).toMatchObject({ scannedRecords: 2, codeless: 0, truncated: true }); + const lines = formatRecordCurrencyBackfillReport(capped); + expect(lines).toContain('No codeless deal.amount amount among the 2 row(s) examined.'); + expect(lines.some((l) => l.includes('nothing to backfill'))).toBe(false); + expect(lines).toContain('⚠ Walk incomplete — some rows were not examined; re-run without the cap to finish.'); + // The run was unscoped, so its one-organization verdict saw only those rows. + expect(lines.some((l) => l.includes('The organization check saw only the rows examined'))).toBe(true); + expect(recordCurrencyBackfillComplete(capped)).toBe(false); + + // A scoped run names its organization, so it owes no organization-check line. + const scoped = formatRecordCurrencyBackfillReport({ ...capped, organizationId: 'org_a' }); + expect(scoped.some((l) => l.includes('The organization check'))).toBe(false); + // The same walk, uncapped, is complete and says so. + const whole = await backfillRecordCurrencyCodes(engine, quietLogger, { object: 'deal', field: 'amount', code: 'USD' }); + expect(whole).toMatchObject({ scannedRecords: 3, codeless: 1, truncated: false }); + }); + it('a code that is not an ISO 4217 member is refused before any row is read', async () => { seedMixed(); for (const code of ['usd', 'XYZ', 'US', ' USD', '']) { 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 9677c79ffdb..9a9fdffaea0 100644 --- a/packages/rest/src/meta-record-currency-backfill-route.test.ts +++ b/packages/rest/src/meta-record-currency-backfill-route.test.ts @@ -139,7 +139,9 @@ function boot( if (!route) throw new Error(`POST ${PATH} is not registered`); const res = mockRes(); await route.handler({ params: {}, query: {}, headers: {}, body }, res); - return { status: res.statusCode as number, body: res.json.mock.calls.at(-1)?.[0] as any }; + const raw = res.json.mock.calls.at(-1)?.[0] as any; + // `body` is the payload of `{ success: true, data }`, or the refusal envelope itself. + return { status: res.statusCode as number, raw, body: raw?.success === true ? raw.data : raw }; }, }; } @@ -182,6 +184,8 @@ describe('POST /meta/_migrate-record-currency-codes — the caller\'s organizati it('a preview counts the caller\'s organization\'s rows only, and writes nothing', async () => { const answer = await boot(engine, ADMIN_A).call({ object: 'deal', field: 'amount', code: 'USD' }); expect(answer.status).toBe(200); + // The report is the `data` of the shared envelope, and nothing rides beside it. + expect(Object.keys(answer.raw).sort()).toEqual(['data', 'success']); expect(answer.body).toMatchObject({ object: 'deal', organizationId: 'org_a', allOrganizations: false, field: 'amount', codeField: 'currency_code', code: 'USD', scannedRecords: 3, codeless: 2, written: 0, skippedCodePresent: 1, applied: false, @@ -297,6 +301,8 @@ describe('POST /meta/_migrate-record-currency-codes — the caller\'s organizati it('the backfill\'s own refusals answer with its status and code, before any row is read', async () => { const notIso = await boot(engine, ADMIN_A).call({ object: 'deal', field: 'amount', code: 'usd', apply: true }); expect({ status: notIso.status, code: notIso.body?.error?.code }).toEqual({ status: 400, code: 'INVALID_FIELD' }); + expect(notIso.raw).toMatchObject({ success: false }); + expect(notIso.body.error.details?.fields?.[0]).toMatchObject({ field: 'currency_code', code: 'INVALID_FIELD' }); const notRecord = await boot(engine, ADMIN_A).call({ object: 'deal', field: 'name', code: 'USD', apply: true }); expect({ status: notRecord.status, code: notRecord.body?.error?.code }).toEqual({ status: 400, code: 'INVALID_FIELD' }); const noObject = await boot(engine, ADMIN_A).call({ object: 'no_such_object', field: 'amount', code: 'USD' }); diff --git a/packages/rest/src/meta-record-currency-backfill-route.ts b/packages/rest/src/meta-record-currency-backfill-route.ts index f58d3e510fb..ae35ea74fa0 100644 --- a/packages/rest/src/meta-record-currency-backfill-route.ts +++ b/packages/rest/src/meta-record-currency-backfill-route.ts @@ -54,17 +54,23 @@ * to load. `scripts/check-undeclared-dep-imports.mjs` ledgers that probe * against this file. * - * ## Errors + * ## The envelope, and errors * - * Only codes the registry already carries: `FORBIDDEN` (capability, no current - * organization), `VALIDATION_ERROR` (the body), `NOT_IMPLEMENTED` (no engine), - * and the backfill's own refusals (`INVALID_FIELD` for a field that is not a + * Born on the shared pair (`sendOk` / `sendError`, `@objectstack/types`): the + * report is the `data` of `{ success: true, data }`, the payload the CLI's + * `--json` prints as its `report`, and every refusal is + * `{ success: false, error: { code, message } }`. Only codes the registry + * already carries: `FORBIDDEN` (capability, no current organization), + * `VALIDATION_ERROR` (the body), `NOT_IMPLEMENTED` (no engine), and the + * backfill's own refusals (`INVALID_FIELD` for a field that is not a * record-mode amount or a code outside ISO 4217, the object-not-found answer), - * each answered through `handleRouteError` with the status the function gave. + * answered with the status and code the function threw, resolved by the shared + * `resolveThrownHttpError`. Anything else is a fault: `sendServerFault` logs + * the throw and answers the route's generic text. */ import { rethrowAuthzStoreUnavailable } from '@objectstack/core'; -import { handleRouteError } from './error-response.js'; +import { demotedDeclaredCode, resolveThrownHttpError, sendError, sendOk, sendServerFault } from '@objectstack/types'; import { logWarn } from './log.js'; import type { MetaRouteScope } from './meta-collection-routes.js'; import { wiredEngineOrLoud, type RestServer } from './rest-server.js'; @@ -144,28 +150,22 @@ export function registerMetaRecordCurrencyBackfillRoute( const ctx = await this.resolveExecCtx(environmentId, req).catch(rethrowAuthzStoreUnavailable); const held = new Set(Array.isArray(ctx?.systemPermissions) ? ctx.systemPermissions : []); if (!ctx?.isSystem && !held.has(RECORD_CURRENCY_BACKFILL_CAPABILITY)) { - res.status(403).json({ - error: { - code: 'FORBIDDEN', - message: - 'Backfilling record currency codes requires the `setup.write` capability in your ' + - 'current organization.', - }, - }); - return; + return sendError( + res, + 403, + 'FORBIDDEN', + 'Backfilling record currency codes requires the `setup.write` capability in your current organization.', + ); } const organizationId = typeof ctx?.tenantId === 'string' && ctx.tenantId !== '' ? ctx.tenantId : undefined; if (organizationId === undefined) { - res.status(403).json({ - error: { - code: 'FORBIDDEN', - message: - 'This request has no current organization, and the backfill fills the rows of the ' + - 'caller\'s current organization only: select the organization whose rows you mean ' + - 'and retry. Nothing was read.', - }, - }); - return; + return sendError( + res, + 403, + 'FORBIDDEN', + 'This request has no current organization, and the backfill fills the rows of the caller\'s ' + + 'current organization only: select the organization whose rows you mean and retry. Nothing was read.', + ); } const body = readBody(req.body); const ql: any = await wiredEngineOrLoud( @@ -184,21 +184,40 @@ export function registerMetaRecordCurrencyBackfillRoute( || typeof ql?.find !== 'function' || typeof ql?.update !== 'function' ) { - res.status(501).json({ - error: { - code: 'NOT_IMPLEMENTED', - message: 'The record currency code backfill is not available in this runtime: it has no data engine.', - }, - }); - return; + return sendError( + res, + 501, + 'NOT_IMPLEMENTED', + 'The record currency code backfill is not available in this runtime: it has no data engine.', + ); } // Per-row failures are in the report the caller receives; the // log line is the operator's copy. const logger = { info: () => {}, warn: (message: string) => logWarn(message) }; const report = await backfill(ql, logger, { ...body, organizationId }); - res.json(report); - } catch (error: any) { - handleRouteError(res, error); + return sendOk(res, report); + } catch (error: unknown) { + // A refusal the body judge or the backfill DECLARED (a 4xx with + // its code) is the caller's answer; anything else is a fault. + const thrown = resolveThrownHttpError(error); + if (thrown.declaredStatus !== undefined && thrown.status >= 400 && thrown.status < 500) { + const field = (error as { field?: unknown } | null)?.field; + const declaredCode = demotedDeclaredCode(thrown); + return sendError(res, thrown.status, thrown.code, thrown.message, { + ...(typeof field === 'string' + ? { details: { fields: [{ field, code: thrown.code, message: thrown.message }] } } + : {}), + ...(declaredCode !== undefined ? { declaredCode } : {}), + }); + } + const status = thrown.declaredStatus !== undefined && thrown.status >= 500 ? thrown.status : 500; + return sendServerFault( + res, + status, + status === 500 ? 'INTERNAL_ERROR' : thrown.code, + 'Backfilling record currency codes failed.', + error, + ); } }, metadata: { diff --git a/packages/rest/src/rest-route-ledger.ts b/packages/rest/src/rest-route-ledger.ts index 70eb6847147..3b512d41fd5 100644 --- a/packages/rest/src/rest-route-ledger.ts +++ b/packages/rest/src/rest-route-ledger.ts @@ -227,7 +227,7 @@ export const REST_ROUTE_LEDGER: readonly RestRouteLedgerEntry[] = [ note: 'ADR-0087 stored-row canonicalization, the route form of `os migrate meta --stored`: it rewrites stored `sys_metadata` rows in place to their canonical form; gated on `manage_metadata`, preview unless { apply: true }. DELIBERATELY UNBOUND — this row would name the schema, but the report\'s only named type, `StoredMigrationReport`, lives in `@objectstack/metadata-protocol` (unreachable from the spec/api namespace this field resolves against); a second declaration in spec would drift against the CLI rendering the same report. Answered BARE on this surface, enveloped on the dispatcher twin' }, // [ADR-0144, ruling 6670707546 item B] The tenant-admin twin of the CLI backfill. { route: 'POST /api/v1/meta/_migrate-record-currency-codes', family: 'metadata', source: 'route-manager', disposition: 'sdk', client: 'meta.migrateRecordCurrencyCodes', - note: 'ADR-0144 record-currency code backfill, the route form of `os migrate record-currency-codes`: writes the one given code into the codeless rows of a record-mode amount in the CALLER\'S CURRENT ORGANIZATION only (no environment-wide form, no organization in the body); gated on `setup.write`, preview unless { apply: true }, behind `metadata.endpoints.maintenance`. DELIBERATELY UNBOUND for `migrateStored`\'s reason — the report\'s named type, `RecordCurrencyBackfillReport`, lives in `@objectstack/objectql`, unreachable from the spec/api namespace this field resolves against. REST-only: the dispatcher has no twin. Answered BARE' }, + note: 'ADR-0144 record-currency code backfill, the route form of `os migrate record-currency-codes`: writes the one given code into the codeless rows of a record-mode amount in the CALLER\'S CURRENT ORGANIZATION only (no environment-wide form, no organization in the body); gated on `setup.write`, preview unless { apply: true }, behind `metadata.endpoints.maintenance`. DELIBERATELY UNBOUND for `migrateStored`\'s reason — the report\'s named type, `RecordCurrencyBackfillReport`, lives in `@objectstack/objectql`, unreachable from the spec/api namespace this field resolves against. REST-only: the dispatcher has no twin. Answered in the shared envelope, the report as `data`' }, { route: 'GET /api/v1/meta/:type', family: 'metadata', source: 'route-manager', disposition: 'sdk', client: 'meta.getItems' }, { route: 'GET /api/v1/meta/:type/:name/references', family: 'metadata', source: 'route-manager', disposition: 'sdk', client: 'meta.getReferences', responseSchema: 'FindReferencesToMetaResponseSchema', From 415ea427ace00f66f1c2ff5648829a48b0a27298 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 03:42:14 +0000 Subject: [PATCH 3/5] fix(rest, objectql): register the backfill door in the rest censuses; a refused row reads the hook's sentence The new `POST /meta/_migrate-record-currency-codes` write door joins the censuses that enumerate every REST write door and registrar module: - the `/meta` write-door enumeration (a DATA door, gated on `setup.write`, with its own refusal and control cases), the #8497 record-echo tripwire (no record echo), the #22694 hook-refusal ledger (driven per row), the registrar file lists, the objectQLProvider consumer census (7 sites), the query-slot census (no slot) and the docblock floors. Driving it through the hook-refusal ledger found a real leak: a row whose code write a sandboxed hook refused reported the `hook '' threw:` debug wrapper as its failure text, and a crashed hook's row its native error. The backfill now reads the throw as the import row does: the hook's own sentence for a refusal, the `/data` door's withheld sentence for a 5xx. The operator's log line keeps the whole text. Measured by ablation: without the objectql change, 12 rows of the ledger go red. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_016WV4cyrhYJgbkZHbbqud3F --- ...0675-record-currency-backfill-rest-door.md | 1 + .../src/record-currency-backfill.test.ts | 54 ++++++++++++ .../objectql/src/record-currency-backfill.ts | 23 ++++- ...-write-door-capability-enumeration.test.ts | 86 ++++++++++++++++++- .../src/objectql-slot-consumer-census.test.ts | 8 +- .../rest-server-canonical-query-ast.test.ts | 4 + .../src/rest-server-docblock-position.test.ts | 5 +- .../rest/src/rest-server-registrar-files.ts | 8 ++ ...-response-internal-fields.tripwire.test.ts | 6 ++ ...route-hook-refusal-sentence.ledger.test.ts | 24 +++++- 10 files changed, 208 insertions(+), 11 deletions(-) diff --git a/.changeset/30675-record-currency-backfill-rest-door.md b/.changeset/30675-record-currency-backfill-rest-door.md index 2d80dce927e..890e1416495 100644 --- a/.changeset/30675-record-currency-backfill-rest-door.md +++ b/.changeset/30675-record-currency-backfill-rest-door.md @@ -20,3 +20,4 @@ An organization admin with no shell on the deployment can now run the record-cur - The 409 `destructive_change` finding `currency_mode_to_record` now names this route beside the CLI command. - `@objectstack/objectql` now exports `RecordCurrencyBackfillOrganization` and `RecordCurrencyBackfillOrganizationsRefusal` from its entry. - A backfill walk stopped by `--max-records` no longer prints "nothing to backfill" for rows it did not examine. It says the walk is incomplete, and an unscoped run says its organization check saw only the rows examined. +- A backfill row whose write a sandboxed hook refused now reports the hook's own sentence, not the `hook '' threw:` debug wrapper, and a crashed hook's row reports the write doors' withheld fault sentence. The operator's log line keeps the whole text. diff --git a/packages/objectql/src/record-currency-backfill.test.ts b/packages/objectql/src/record-currency-backfill.test.ts index df5a22feb40..3fd8b09d328 100644 --- a/packages/objectql/src/record-currency-backfill.test.ts +++ b/packages/objectql/src/record-currency-backfill.test.ts @@ -435,3 +435,57 @@ describe('backfillRecordCurrencyCodes — one organization at a time', () => { expect(formatRecordCurrencyBackfillReport(report).some((l) => l.includes('organization'))).toBe(false); }); }); + +/** + * [#30675] The report is also a REST answer (`POST /meta/_migrate-record-currency-codes`), + * so a failed row's entry reads a sandboxed hook's throw as the write doors read + * it: the hook's own sentence for a refusal, the door's withheld sentence for a + * crash, never the `hook '' threw:` debug wrapper. The operator's log line + * keeps the whole text. + */ +describe('a failed row reads a sandboxed hook as the write doors do', () => { + const SENTENCE = 'Locked rows cannot be changed here.'; + const sandboxed = (inner: string) => Object.assign( + Object.assign(new Error(`hook 'guard' threw: Error: ${inner}`), { name: 'SandboxError' }), + { innerMessage: inner }, + ); + const run = async (thrown: () => Error) => { + const warned: string[] = []; + const stub = { + getObject: () => ({ + fields: { + amount: { type: 'currency', currencyConfig: { currencyMode: 'record', currencyField: 'currency_code' } }, + currency_code: { type: 'text', valueDomain: 'iso_4217_currency' }, + }, + }), + find: async () => [{ id: 'r1', amount: 5 }], + update: async () => { throw thrown(); }, + }; + const report = await backfillRecordCurrencyCodes(stub, { info: () => {}, warn: (m: string) => warned.push(m) }, { + object: 'deal', field: 'amount', code: 'USD', apply: true, retry: { maxRetries: 1 }, + }); + return { report, warned }; + }; + + it("a refusal: the hook's own sentence, the wrapper only in the log", async () => { + const { report, warned } = await run(() => sandboxed(SENTENCE)); + expect(report.failures).toEqual([{ recordId: 'r1', error: SENTENCE }]); + expect(warned.join('\n')).toContain(`hook 'guard' threw: Error: ${SENTENCE}`); + }); + + it("a crash and the sandbox's own fault: the door's withheld sentence, no native text", async () => { + for (const thrown of [ + () => sandboxed('TypeError: __crash_words__'), + () => Object.assign(new Error("hook 'guard' exceeded CPU budget of 50ms"), { name: 'SandboxError' }), + ]) { + const { report } = await run(thrown); + expect(report.failures).toHaveLength(1); + expect(report.failures[0]!.error).not.toMatch(/__crash_words__|budget|threw:/); + } + }); + + it('CONTROL: any other throw 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' }]); + }); +}); diff --git a/packages/objectql/src/record-currency-backfill.ts b/packages/objectql/src/record-currency-backfill.ts index 8d55be86027..3db40f4b2c6 100644 --- a/packages/objectql/src/record-currency-backfill.ts +++ b/packages/objectql/src/record-currency-backfill.ts @@ -86,7 +86,7 @@ * `sys_migration` flag (the `summary-nulls` reasoning: a flag nothing reads). */ -import { keysetWalk, type KeysetPageQuery } from '@objectstack/types'; +import { isSandboxOrigin, isSandboxOwnFault, keysetWalk, mapDataError, sandboxBusinessMessage, type KeysetPageQuery } from '@objectstack/types'; import { objectNotFoundError, withTransientRetry, type RetryOptions } from '@objectstack/core'; import { isValueDomainMember } from '@objectstack/spec/shared'; @@ -430,7 +430,7 @@ async function walkCodelessRows( // what is still codeless. const message = (err as any)?.message ?? String(err); logger.warn(`[record-currency-backfill] ${object} record ${String(id)}: ${message}`); - report.failures.push({ recordId: String(id), error: message }); + report.failures.push({ recordId: String(id), error: rowFailureSentence(err, object, message) }); } } } @@ -441,6 +441,25 @@ async function walkCodelessRows( return report; } +/** + * [#30675] The sentence a failed row's report entry carries. The report is now + * also a REST answer (`POST /meta/_migrate-record-currency-codes`), so a + * sandboxed hook's throw is read as the write doors read it — the import row's + * rule (`toFailedResult`, `@objectstack/core`'s import runner): a sandboxed + * 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. + */ +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; +} + /** Human-readable report body, shared by the CLI's dry-run and apply output. */ export function formatRecordCurrencyBackfillReport(report: RecordCurrencyBackfillReport): string[] { const lines: string[] = []; diff --git a/packages/rest/src/meta-write-door-capability-enumeration.test.ts b/packages/rest/src/meta-write-door-capability-enumeration.test.ts index 98fcaa76cca..76ae2b895fd 100644 --- a/packages/rest/src/meta-write-door-capability-enumeration.test.ts +++ b/packages/rest/src/meta-write-door-capability-enumeration.test.ts @@ -162,6 +162,31 @@ const DOORS: readonly Door[] = [ // capability gate, so the door set loses a spelling, not a capability. ]; +/** + * [#30675] The `/meta` write doors that write a DATA object's rows, not + * metadata, and so are not `manage_metadata` doors: they reach no protocol + * method, and their gate is a tenant Setup capability resolved for the + * caller's current organization. Enumerated in the same closed set (the + * comparison below derives from both tables), with their own refusal cases in + * the `#30675` describe at the end of this file. + */ +interface DataDoor { + readonly label: string; + readonly method: 'POST'; + readonly path: string; + readonly capability: string; + readonly body: unknown; +} + +const DATA_DOORS: readonly DataDoor[] = [ + { + label: 'POST /meta/_migrate-record-currency-codes — record-currency code backfill [#30675]', + method: 'POST', path: `${META}/_migrate-record-currency-codes`, + capability: 'setup.write', + body: { object: 'deal', field: 'currency_code', code: 'USD' }, + }, +]; + /** Every protocol method any door reaches — all present, so 501 is never the answer. */ const PROTOCOL_METHODS = [...new Set(DOORS.map((d) => d.protocolMethod))]; @@ -190,14 +215,14 @@ const META_SEGMENT = /(?:^|\/)meta(?:\/|$)/; * The same door, addressed on another mount base * (`/api/v1/meta/...` → `/api/v1/environments/:environmentId/meta/...`). */ -function onBase(door: Door, base: string): string { +function onBase(door: Pick, base: string): string { return `${base}${door.path.slice(UNSCOPED_BASE.length)}`; } /** Every door, on every base the composition under test mounts. */ function expectedDoors(bases: readonly string[]): string[] { return bases - .flatMap((base) => DOORS.map((d) => `${d.method} ${onBase(d, base)}`)) + .flatMap((base) => [...DOORS, ...DATA_DOORS].map((d) => `${d.method} ${onBase(d, base)}`)) .sort(); } @@ -320,7 +345,7 @@ describe('#11473 — "CLOSED, enumerated set" holds for EVERY base, not just the expect(doors).toEqual(expectedDoors([UNSCOPED_BASE, SCOPED_BASE])); // Stated as a count too, because the number is the part that regressed: // the pre-#11473 prefix filter saw 5 of these 10. - expect(doors).toHaveLength(DOORS.length * 2); + expect(doors).toHaveLength((DOORS.length + DATA_DOORS.length) * 2); }); it("'required' mounts ONLY the scoped base — the case a `/api/v1/meta` prefix filter enumerated as empty", () => { @@ -335,7 +360,7 @@ describe('#11473 — "CLOSED, enumerated set" holds for EVERY base, not just the ); const doors = stack.registeredWriteDoors(); expect(doors).toEqual(expectedDoors([SCOPED_BASE])); - expect(doors).toHaveLength(DOORS.length); + expect(doors).toHaveLength(DOORS.length + DATA_DOORS.length); expect(doors.every((d: string) => d.includes(SCOPED_BASE))).toBe(true); // And nothing is left on the unscoped base to fall back to. expect(doors.some((d: string) => d.includes(`${META}/`))).toBe(false); @@ -609,3 +634,56 @@ describe('ADR-0131 D6 — `manage_org_presentation` is refused, and no door thre expect(stack.calls[MIGRATE_DOOR.protocolMethod]).toBe(0); }); }); + +/** + * [#30675] The data doors' own refusal cases. Booted with NO data engine, so a + * caller the gate admits gets `501 NOT_IMPLEMENTED` — the answer that tells the + * gate's verdict apart from the refusal, the role the protocol call count plays + * for the metadata doors above. No case here reaches a row (the route's own + * test file drives the walk and the writes on a real engine). + */ +describe('#30675 — the data doors refuse on their own capability, not `manage_metadata`', () => { + const knockData = async (door: DataDoor, context: Record | undefined, base = UNSCOPED_BASE) => { + const stack = boot(context, base === UNSCOPED_BASE ? {} : { enableProjectScoping: true, projectResolution: 'auto' }); + const path = onBase(door, base); + const route = (stack.rest as any).getRoutes().find((r: any) => r.method === door.method && r.path === path); + if (!route) throw new Error(`route not registered: ${door.method} ${path}`); + const res = mockRes(); + await route.handler({ + method: door.method, path, params: base === UNSCOPED_BASE ? {} : { environmentId: 'env_probe' }, + query: {}, headers: {}, body: door.body, + }, res); + return { status: res.statusCode, body: res.json.mock.calls.at(-1)?.[0] }; + }; + + it.each(DATA_DOORS.map((d) => [d.label, d] as const))('%s → anonymous gets 401 from the /meta umbrella', async (_l, door) => { + expect((await knockData(door, undefined)).status).toBe(401); + }); + + it.each(DATA_DOORS.map((d) => [d.label, d] as const))( + '%s → refuses a capability-less caller, a `manage_metadata` holder and `setup.access` alone (403)', + async (_l, door) => { + for (const systemPermissions of [[], ['manage_metadata'], ['setup.access', 'studio.access']]) { + const out = await knockData(door, { userId: 'u', tenantId: 'org_a', systemPermissions }); + expect(out.status).toBe(403); + expect(out.body).toMatchObject({ error: { code: 'FORBIDDEN' } }); + } + }, + ); + + it.each(DATA_DOORS.map((d) => [d.label, d] as const))( + '%s → the control: its own capability (and `isSystem`) passes the gate, on both bases', + async (_l, door) => { + for (const base of [UNSCOPED_BASE, SCOPED_BASE]) { + for (const ctx of [ + { userId: 'u', tenantId: 'org_a', systemPermissions: [door.capability] }, + { isSystem: true, tenantId: 'org_a' }, + ]) { + const out = await knockData(door, ctx, base); + expect(out.status).toBe(501); + expect(out.body).toMatchObject({ error: { code: 'NOT_IMPLEMENTED' } }); + } + } + }, + ); +}); diff --git a/packages/rest/src/objectql-slot-consumer-census.test.ts b/packages/rest/src/objectql-slot-consumer-census.test.ts index b7c363132ad..7a5442bd62d 100644 --- a/packages/rest/src/objectql-slot-consumer-census.test.ts +++ b/packages/rest/src/objectql-slot-consumer-census.test.ts @@ -155,6 +155,8 @@ const SLOT_FILES = [ 'meta-item-history-routes.ts', // [#30205] A user's assigned work: the counts and the hand-over (the SIXTH consumer). 'user-work-routes.ts', + // [#30675] The record-currency code backfill door (the SEVENTH consumer). + 'meta-record-currency-backfill-route.ts', ] as const; const SOURCES = new Map(SLOT_FILES.map((file) => [ file, file === 'rest-server.ts' ? SOURCE : readFileSync(resolve(HERE, file), 'utf8'), @@ -232,11 +234,13 @@ describe('[#18546] §1 the `objectQLProvider` consumer census is mechanical', () // `meta-object-state-routes.ts` and `/published`'s 1 in // `meta-item-history-routes.ts`. [#30205] 6: a user's assigned work, its // one site in `user-work-routes.ts`, shared by both of its routes. + // [#30675] 7: the record-currency code backfill door, its one site in + // `meta-record-currency-backfill-route.ts`. const sites = censusInvocationSites('objectQLProvider'); // The message carries the sites, so a failure here hands the next // reader the new census instead of a bare number. expect(sites.map((s) => `${s.file}:${s.line}: ${s.text}`).join('\n')).toBeTruthy(); - expect(sites).toHaveLength(6); + expect(sites).toHaveLength(7); }); it('⭐ [#18559] ALL FIVE consumers reach the slot through `wiredEngineOrLoud` — none reads it directly', () => { @@ -252,7 +256,7 @@ describe('[#18546] §1 the `objectQLProvider` consumer census is mechanical', () // the site rather than a bare number. expect(direct.map((s) => `${s.file}:${s.line}: ${s.text}`).join('\n')).toBe(''); expect(direct).toHaveLength(0); - expect(viaSeam).toHaveLength(6); + expect(viaSeam).toHaveLength(7); // ⭐ CONTROL for the window predicate itself, on BOTH axes. It reads a // WINDOW of source text, so "3 of 3" is only a reading if the same diff --git a/packages/rest/src/rest-server-canonical-query-ast.test.ts b/packages/rest/src/rest-server-canonical-query-ast.test.ts index 6353d4b6b45..8a744006f6f 100644 --- a/packages/rest/src/rest-server-canonical-query-ast.test.ts +++ b/packages/rest/src/rest-server-canonical-query-ast.test.ts @@ -238,6 +238,9 @@ const REGISTRAR_QUERY_SLOT_FLOORS: Readonly { // [#27627] The sum 4 → 5: the record page's related list read. // [#27627] The sum unchanged by the app metadata bundle (no slot). // [#28470] The sum 5 → 6: the record page's stream reads (`readStream`). + // [#30675] The sum unchanged by the record-currency code backfill (no slot). expect(Object.keys(REGISTRAR_QUERY_SLOT_FLOORS).sort()).toEqual([...REST_SERVER_REGISTRAR_FILES].sort()); expect(CENSUS.filter(({ file }) => REGISTRAR_SOURCES.has(file)).map(({ file }) => file)) .toEqual([...REST_SERVER_REGISTRAR_FILES]); diff --git a/packages/rest/src/rest-server-docblock-position.test.ts b/packages/rest/src/rest-server-docblock-position.test.ts index 910ee1c212a..4a0d92c1078 100644 --- a/packages/rest/src/rest-server-docblock-position.test.ts +++ b/packages/rest/src/rest-server-docblock-position.test.ts @@ -251,6 +251,9 @@ const MOVED_FILE_FLOORS: Readonly = REST_SERVER_REGISTRAR_FILES .filter((file): file is Exclude => file !== FILE) @@ -313,6 +316,6 @@ describe('[#23050 slice 3] the registrar list this pin reads', () => { // keys, the function's, and the engine resolver's inside it). expect(Object.keys(MOVED_FILE_FLOORS).sort()) .toEqual(REST_SERVER_REGISTRAR_FILES.filter((file) => file !== FILE).sort()); - expect(MOVED_FILES.reduce((n, { minBlocks }) => n + minBlocks, 0)).toBe(101); + expect(MOVED_FILES.reduce((n, { minBlocks }) => n + minBlocks, 0)).toBe(108); }); }); diff --git a/packages/rest/src/rest-server-registrar-files.ts b/packages/rest/src/rest-server-registrar-files.ts index e111b7aade9..b276de1fb15 100644 --- a/packages/rest/src/rest-server-registrar-files.ts +++ b/packages/rest/src/rest-server-registrar-files.ts @@ -78,6 +78,10 @@ export const REST_SERVER_REGISTRAR_FILES = [ // [#30205] `registerUserWorkEndpoints`' body, a user's assigned work: the counts and // the hand-over. New, not a moved slice. 'user-work-routes.ts', + // [#30675] `registerMetaRecordCurrencyBackfillRoute`' body, the record-currency code + // backfill door. New, not a moved slice, and a `/meta` group — see + // {@link REST_SERVER_META_ROUTE_FILES}. + 'meta-record-currency-backfill-route.ts', ] as const; /** One file on {@link REST_SERVER_REGISTRAR_FILES}. */ @@ -126,6 +130,10 @@ export const REST_SERVER_META_ROUTE_FILES: readonly RestServerRegistrarFile[] = // moved slice; like the per-item writes it goes through the inner method's per-item // registrar, handed over in its scope. 'meta-app-bundle-routes.ts', + // [#30675] The record-currency code backfill, `POST {metaPath}/_migrate-record-currency-codes`. + // New, not a moved slice; like the state-machine read it registers on the route + // manager itself, by value from a host literal built inside the inner method. + 'meta-record-currency-backfill-route.ts', ]; /** diff --git a/packages/rest/src/rest-write-response-internal-fields.tripwire.test.ts b/packages/rest/src/rest-write-response-internal-fields.tripwire.test.ts index 32284936f33..14986dde310 100644 --- a/packages/rest/src/rest-write-response-internal-fields.tripwire.test.ts +++ b/packages/rest/src/rest-write-response-internal-fields.tripwire.test.ts @@ -361,6 +361,12 @@ const DISPOSITIONS: Record = { // Metadata plane: bodies carry metadata ITEMS. `internal: true` is a field // flag on a data object's field, and a metadata item is not a data record. 'POST /api/v1/meta/_migrate-stored': { kind: 'no-record-echo', why: 'Metadata plane: migration receipt.' }, + // [#30675] Writes a data object's rows, but answers only the run's report: + // counts, a few record ids, and per-row failure sentences — no stored value. + 'POST /api/v1/meta/_migrate-record-currency-codes': { + kind: 'no-record-echo', + why: 'Backfill report: counts, sample record ids and failure sentences, never a stored field value.', + }, 'PUT /api/v1/meta/:type/:name': { kind: 'no-record-echo', why: 'Metadata plane: metadata item, not a data record.' }, 'DELETE /api/v1/meta/:type/:name': { kind: 'no-record-echo', why: 'Metadata plane: delete receipt.' }, 'POST /api/v1/meta/:type/:name/publish': { kind: 'no-record-echo', why: 'Metadata plane: publish receipt.' }, diff --git a/packages/rest/src/rest-write-route-hook-refusal-sentence.ledger.test.ts b/packages/rest/src/rest-write-route-hook-refusal-sentence.ledger.test.ts index 8a9e4ce4eb2..4b20d6d6685 100644 --- a/packages/rest/src/rest-write-route-hook-refusal-sentence.ledger.test.ts +++ b/packages/rest/src/rest-write-route-hook-refusal-sentence.ledger.test.ts @@ -107,6 +107,14 @@ const FORM_VIEW = { type Fn = (...args: any[]) => any; +/** [#30675] A record-mode amount and its code column, for the backfill door's row. */ +const RECORD_CURRENCY_FIELDS = { + name: { type: 'text' }, + mz_amount: { type: 'currency', currencyConfig: { currencyMode: 'record', currencyField: 'mz_currency' } }, + mz_currency: { type: 'text', valueDomain: 'iso_4217_currency' }, + organization_id: { type: 'text' }, +}; + /** Reads the harness answers; every other protocol method rejects with the refusal. */ const READS: Record = { getDiscovery: async () => ({ version: 'v0', routes: {} }), @@ -153,14 +161,19 @@ function boot(error: () => Error, protocol: Record = {}): Booted { server[verb] = (path: string, handler: Fn) => direct.set(`${verb.toUpperCase()} ${path}`, handler); } const service = (label: string) => async () => rejecting(label, calls, error); - const caller = { userId: 'u1', systemPermissions: ['manage_metadata', 'manage_platform_settings'] }; + // [#30675] `setup.write` and a current organization: the record-currency + // backfill door fills that organization's rows under that capability. + const caller = { userId: 'u1', tenantId: 'org_mz', systemPermissions: ['manage_metadata', 'manage_platform_settings', 'setup.write'] }; // [#30205] The assigned-work hand-over reads and writes through the data // engine itself: one user field to move, an active target, and a row whose // `update` the refusal reaches. const engine = { transaction: async (fn: Fn) => fn({}), getConfigs: () => ({ [OBJECT]: { name: OBJECT, fields: { owner_id: { type: 'lookup', reference: 'sys_user' } } } }), - find: async (object: string) => (object === 'sys_user' ? [{ id: 'u2', banned: false }] : [{ id: 'r1' }]), + find: async (object: string) => (object === 'sys_user' ? [{ id: 'u2', banned: false }] : [{ id: 'r1', mz_amount: 5, organization_id: 'org_mz' }]), + // [#30675] The backfill reads the object's declaration: one record-mode + // amount, and the `r1` row above holds it beside a blank code. + getObject: (name: string) => (name === OBJECT ? { name, fields: RECORD_CURRENCY_FIELDS } : undefined), count: async () => 0, update: async () => { calls.push('engine.update'); throw error(); }, }; @@ -301,6 +314,13 @@ const HOOK_ABORTED_400 = { status: 400, code: 'HOOK_ABORTED' } as const; */ const ANSWERS_THE_SENTENCE: Record = { 'POST /api/v1/meta/_migrate-stored': { called: 'protocol.migrateStoredMetadata' }, + // [#30675] Per row, inside a `200`: the hook refuses the row's code write, + // and the report's failure entry is the answer. + 'POST /api/v1/meta/_migrate-record-currency-codes': { + called: 'engine.update', + body: { object: OBJECT, field: 'mz_amount', code: 'USD', apply: true }, + answer: (res) => res.body?.data?.failures?.[0]?.error, + }, 'PUT /api/v1/meta/:type/:name': { called: 'protocol.saveMetaItem', body: { name: 'name_1', label: 'X' } }, 'DELETE /api/v1/meta/:type/:name': { called: 'protocol.deleteMetaItem' }, 'POST /api/v1/meta/:type/:name/publish': { called: 'protocol.publishMetaItem' }, From b56a12d6eee68716920e33b7c88aaf7cfb5534d1 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 03:58:46 +0000 Subject: [PATCH 4/5] docs(permissions): row 50b, setup.write bypassed on the record-currency backfill door `check:system-context-census` reds on an elevation read no row owns: the new door's capability gate lets `isSystem` through. Row 50b names it, and the generated register is rewritten with `--write`. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_016WV4cyrhYJgbkZHbbqud3F --- content/docs/permissions/system-context.mdx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/content/docs/permissions/system-context.mdx b/content/docs/permissions/system-context.mdx index 948492a51d4..15eb4f3aac9 100644 --- a/content/docs/permissions/system-context.mdx +++ b/content/docs/permissions/system-context.mdx @@ -197,6 +197,7 @@ One of the largest consumers of the flag; | 48 | Object API-exposure gate bypassed (`apiEnabled` / `apiMethods`) | runtime | Get: internal self-writes ignore exposure declarations — these govern **external** exposure, not engine self-writes. The gate is `refuseUnexposedDataAction`, which `callData` asks first and the `/data` domain asks before its request gate; `callData` reads the same bypass once more for each nested-relation condition's target | `callData`, `refuseUnexposedDataAction` | | 49 | Action `requiredPermissions` bypassed | runtime | Get: engine self-invocation runs any action | `actionPermissionError` | | 50 | `manage_metadata` bypassed on metadata writes | runtime, rest | Get: schema writes without the capability (the permission-set clone door's save included) — and, because REST's save door and the stored-version read doors ask one save verdict, an app's full stored version on `/layers`, `?layers=true` and `/diff`, whatever its entry gates withhold | `handleMetadataRequest`, `registerMetaCollectionRoutes`, `registerMetaItemWriteRoutes`, `registerMetaItemHistoryRoutes`, `metaSaveVerdict`, `handlePermissionSetCloneWrite` | +| 50b | `setup.write` bypassed on the record-currency code backfill door | rest | Get: `POST /meta/_migrate-record-currency-codes` without the capability. The door still fills only the caller's current organization's rows: a system context with no organization is refused like any other caller | `registerMetaRecordCurrencyBackfillRoute` | | 51 | The shared metadata-write verdict itself returns `allowed` | metadata-core | Get: the one function all of row 50's doors consult answers yes before any capability is examined | `metaWriteCapabilityVerdict` | | 52 | Anonymous-deny seam satisfied on the domain dispatchers and the package/federation routes | runtime, rest | Get: passes with no `userId` | `handleActionsRequest`, `handleAIRequest`, `handleAutomationRequest`, `handleMetadataRequest`, `handleSecurityRequest`, `handlePackagesRequest`, `handleAnalyticsRequest`, `handleI18nRequest`, `registerExternalDatasourceRoutes`, `refusePackageRequest` | | 53 | MCP principal check satisfied | runtime | Get: MCP surface reachable with no user | `handleMcpRequest` | @@ -622,6 +623,7 @@ and CI refuses one that differs from what the tree produces. | `packages/rest/src/meta-collection-routes.ts#registerMetaCollectionRoutes` | 1 | 50 | | `packages/rest/src/meta-item-history-routes.ts#registerMetaItemHistoryRoutes` | 2 | 50 | | `packages/rest/src/meta-item-write-routes.ts#registerMetaItemWriteRoutes` | 1 | 50 | +| `packages/rest/src/meta-record-currency-backfill-route.ts#registerMetaRecordCurrencyBackfillRoute` | 1 | 50b | | `packages/rest/src/package-routes.ts#refusePackageRequest` | 2 | 52, 54 | | `packages/rest/src/rest-server.ts#enforceAuth` | 1 | 18 | | `packages/rest/src/rest-server.ts#mayReadAuthoringTrail` | 1 | 8b | From 12af728fa8a19542811c866268a2bbb27cd105c4 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 03:59:31 +0000 Subject: [PATCH 5/5] docs(spec): the maintenance switch's describe names the record-currency backfill door `metadata.endpoints.maintenance` now also mounts `POST /meta/_migrate-record-currency-codes`, and each endpoints switch's describe() enumerates every mount it gates. The reference page is regenerated from the schema. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_016WV4cyrhYJgbkZHbbqud3F --- .changeset/30675-record-currency-backfill-rest-door.md | 1 + content/docs/references/api/rest-server.mdx | 2 +- packages/spec/src/api/rest-server.zod.ts | 3 ++- 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/.changeset/30675-record-currency-backfill-rest-door.md b/.changeset/30675-record-currency-backfill-rest-door.md index 890e1416495..851f3557e4c 100644 --- a/.changeset/30675-record-currency-backfill-rest-door.md +++ b/.changeset/30675-record-currency-backfill-rest-door.md @@ -4,6 +4,7 @@ '@objectstack/objectql': patch '@objectstack/cli': patch '@objectstack/metadata-protocol': patch +'@objectstack/spec': patch --- feat(rest, client): a tenant-admin REST door for the record-currency code backfill diff --git a/content/docs/references/api/rest-server.mdx b/content/docs/references/api/rest-server.mdx index a5fed38d891..28a66d44832 100644 --- a/content/docs/references/api/rest-server.mdx +++ b/content/docs/references/api/rest-server.mdx @@ -225,7 +225,7 @@ const result = BatchEndpointsConfigSchema.parse(data); | **types** | `boolean` | optional (default: `true`) | Mount the metadata type list — `GET /meta` and `GET /meta/types` (one handler, two paths) | | **items** | `boolean` | optional (default: `true`) | Mount the per-type item list — `GET /meta/:type`, and nothing else | | **item** | `boolean` | optional (default: `true`) | Mount the whole per-item face — `GET`, `PUT` and `DELETE /meta/:type/:name`, its `/references` and `/layers` reads, the history family (`GET` `/history`, `/audit`, `/diff`, `/published`; `POST` `/publish`, `/rollback`), `GET /meta/book/:name/tree` and the app metadata bundle `GET /meta/app/:appId/bundle`, which registers through the same per-item registrar because every member it serves is the item read's own answer | -| **maintenance** | `boolean` | optional (default: `true`) | Mount the whole-store maintenance operations — `GET /meta/diagnostics`, `GET /meta/_drafts` and the `POST /meta/_migrate-stored` write door | +| **maintenance** | `boolean` | optional (default: `true`) | Mount the whole-store maintenance operations — `GET /meta/diagnostics`, `GET /meta/_drafts`, the `POST /meta/_migrate-stored` write door and the `POST /meta/_migrate-record-currency-codes` record-currency code backfill | | **schema** | `never` | optional | [REMOVED] `metadata.endpoints.schema` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — it gated a route that does not exist: the REST server mounts no `GET /meta/:type/:name/schema`, so `false` removed nothing and `true` added nothing. Delete the key; `endpoints.types` / `items` / `item` / `maintenance` are the switches that gate real mounts. | diff --git a/packages/spec/src/api/rest-server.zod.ts b/packages/spec/src/api/rest-server.zod.ts index 5ad969fd065..52dacf75742 100644 --- a/packages/spec/src/api/rest-server.zod.ts +++ b/packages/spec/src/api/rest-server.zod.ts @@ -538,7 +538,8 @@ export const MetadataEndpointsConfigSchema = lazySchema(() => z.object({ maintenance: z.boolean().default(true) .describe( 'Mount the whole-store maintenance operations — `GET /meta/diagnostics`, ' - + '`GET /meta/_drafts` and the `POST /meta/_migrate-stored` write door', + + '`GET /meta/_drafts`, the `POST /meta/_migrate-stored` write door and the ' + + '`POST /meta/_migrate-record-currency-codes` record-currency code backfill', ), /** * [REMOVED in commit b3a63d32c] Gated a route that does not exist: the REST server