Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions .changeset/30675-record-currency-backfill-rest-door.md
Original file line number Diff line number Diff line change
@@ -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 '<name>' 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.
4 changes: 4 additions & 0 deletions content/docs/data-modeling/currency.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
34 changes: 34 additions & 0 deletions content/docs/deployment/cli.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions content/docs/permissions/system-context.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand Down Expand Up @@ -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 |
Expand Down
2 changes: 1 addition & 1 deletion content/docs/references/api/rest-server.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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. |


Expand Down
14 changes: 14 additions & 0 deletions packages/cli/src/commands/migrate/record-currency-codes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down
7 changes: 7 additions & 0 deletions packages/cli/src/commands/migrate/record-currency-codes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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). ` +
Expand Down
47 changes: 47 additions & 0 deletions packages/client/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/

/**
Expand Down Expand Up @@ -2270,6 +2273,50 @@ export class ObjectStackClient {
return this.unwrapResponse<any>(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<unknown>(res);
},

/**
* ADR-0020 D3.3 FSM introspection: the legal next states for `field`
* from state `from`, per the object's `state_machine` validation rule.
Expand Down
24 changes: 24 additions & 0 deletions packages/client/src/meta-automation-descriptors.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
]);
});
});
4 changes: 4 additions & 0 deletions packages/client/src/return-type-precision.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -428,6 +428,10 @@ export async function returnTypePrecisionPins12038(): Promise<void> {
// 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<unknown>();

// ── the packages.* eight ──────────────────────────────────────────────
expectTypeOf(await client.packages.publish('com.acme.crm')).toEqualTypeOf<PackagePublishResult>();
Expand Down
4 changes: 3 additions & 1 deletion packages/metadata-protocol/src/protocol-write-face.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <ISO 4217 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": "<ISO 4217 code>" }\``
+ ' (a preview of your current organization\'s rows; add "apply": true to write).',
});
}
}
Expand Down
2 changes: 2 additions & 0 deletions packages/objectql/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 <ISO 4217 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": "<ISO 4217 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) {
Expand Down
Loading
Loading