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..851f3557e4c --- /dev/null +++ b/.changeset/30675-record-currency-backfill-rest-door.md @@ -0,0 +1,24 @@ +--- +'@objectstack/rest': patch +'@objectstack/client': patch +'@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 + +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. +- 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/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/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 | 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/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/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..29df6f9ab01 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({ 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 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({ 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))); + 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/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..3fd8b09d328 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', '']) { @@ -410,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 b3dd1047e7a..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[] = []; @@ -462,7 +481,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 +509,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..9a9fdffaea0 --- /dev/null +++ b/packages/rest/src/meta-record-currency-backfill-route.test.ts @@ -0,0 +1,337 @@ +// 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); + 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 }; + }, + }; +} + +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); + // 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, + 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' }); + 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' }); + 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..ae35ea74fa0 --- /dev/null +++ b/packages/rest/src/meta-record-currency-backfill-route.ts @@ -0,0 +1,228 @@ +// 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. + * + * ## The envelope, and errors + * + * 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), + * 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 { 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'; + +/** 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)) { + 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) { + 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( + 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' + ) { + 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 }); + 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: { + 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/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-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..3b512d41fd5 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 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', 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-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/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' }, 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 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.