diff --git a/.changeset/15204-s6b2a-meta-resync-example-binders.md b/.changeset/15204-s6b2a-meta-resync-example-binders.md new file mode 100644 index 00000000000..eb592f48db0 --- /dev/null +++ b/.changeset/15204-s6b2a-meta-resync-example-binders.md @@ -0,0 +1,18 @@ +--- +'@objectstack/cli': minor +'@objectstack/plugin-security': patch +'@objectstack/driver-sql': patch +'@objectstack/runtime': patch +--- + +feat(cli)!: `os meta resync` is deleted + +Clause-②: yes (narrowing) + + + +**BREAKING**, shipped as `minor` under the repo's launch-window convention for breaking changes. + +`os meta resync` reconciled the platform-default `sys_permission_set` rows (`admin_full_access`, `member_default`, …) to the shipped declaration. Since the authorization resolver reads permission sets from the security catalog (ADR-0131 D3), an edited default set is served from its declaration at the next boot, and the rows the command reconciled decide nothing. The command, its `--yes` / `--json` modes and its skip-count explanation are gone; a script that calls it gets oclif's unknown-command error. + +In `@objectstack/plugin-security`, `@objectstack/driver-sql` and `@objectstack/runtime`, shipped comments and one warning sentence (the permission-set backfill's re-provision list) no longer name the command. Their behaviour is unchanged. diff --git a/.changeset/22581-migrate-reads-env-files.md b/.changeset/22581-migrate-reads-env-files.md index 1dd7be40975..50724f395d2 100644 --- a/.changeset/22581-migrate-reads-env-files.md +++ b/.changeset/22581-migrate-reads-env-files.md @@ -6,7 +6,7 @@ fix(cli): `os migrate` reads the project's `.env` files as `os serve` does, and `os serve`, `os start` and `os dev` load the project's `.env*` files before they read any variable. `os migrate` read only the process environment. So a project keeping `OS_DATABASE_URL` in `.env` served one database and migrated another: `os migrate plan` reported `.objectstack/data/objectstack.db` while `os serve` opened the `.env` database. An `OS_AUTH_SECRET` kept in `.env` left the auth family (`sys_user`, `sys_account`, …) out of the plan. -Every command that boots the one-shot schema stack now loads the same files first, through the same `dotenv-flow` call, with `os serve`'s mode rule: `NODE_ENV`, else `production`, and `development` for `os migrate security-catalog-overlays --dev`, which composes as `os serve --dev`. That covers every `os migrate` subcommand, and also `os meta resync`, `os secret rewrap`, `os secret orphans` and `os storage orphans`. A variable exported in the shell still wins over `.env`, as it does for `os serve`. The SQLite occupancy check that `os migrate apply` and the other write commands run before booting reads the same files, so it checks the database the command is about to migrate. +Every command that boots the one-shot schema stack now loads the same files first, through the same `dotenv-flow` call, with `os serve`'s mode rule: `NODE_ENV`, else `production`, and `development` for `os migrate security-catalog-overlays --dev`, which composes as `os serve --dev`. That covers every `os migrate` subcommand, and also `os secret rewrap`, `os secret orphans` and `os storage orphans`. A variable exported in the shell still wins over `.env`, as it does for `os serve`. The SQLite occupancy check that `os migrate apply` and the other write commands run before booting reads the same files, so it checks the database the command is about to migrate. This supersedes two phrasings in this release's `os migrate plan` / `apply` auth-family entry: `os migrate` now does read the project's `.env` files, and the deployment's `OS_AUTH_SECRET` composes the auth family whether it is exported or kept in `.env`. diff --git a/content/docs/data-modeling/drivers.mdx b/content/docs/data-modeling/drivers.mdx index 5065db485e9..3021dfe3ccd 100644 --- a/content/docs/data-modeling/drivers.mdx +++ b/content/docs/data-modeling/drivers.mdx @@ -735,7 +735,7 @@ A file-backed SQLite database is switched to **WAL** (write-ahead logging) the first time ObjectStack connects to it. SQLite's own default is a rollback journal (`journal_mode = delete`), which is the wrong trade for the way this platform is actually used — **several processes on one file**: a dev server, -`os migrate`, `os meta resync`, a test run. +`os migrate`, a test run. | | rollback journal (SQLite default) | WAL (ObjectStack default) | |:---|:---|:---| diff --git a/docs/qa/platform-checklist/areas/access-security.json b/docs/qa/platform-checklist/areas/access-security.json index 0034176ba3e..e379a6cbc9b 100644 --- a/docs/qa/platform-checklist/areas/access-security.json +++ b/docs/qa/platform-checklist/areas/access-security.json @@ -809,7 +809,7 @@ "app": "showcase", "requires": [ "showcase_inquiry is OWD private with create granted to the baseline — depth only matters where the baseline stops (permission-sets.ts docblock)", - "personas staffed by granting the sets (sys_user_permission_set) or assigning the bound positions manager/exec/ops (bind-position-sets.ts wires position → set at boot)" + "personas staffed by granting the sets (sys_user_permission_set) or assigning the positions manager/exec/ops (each declares its set in permissionSets, examples/app-showcase/src/security/positions.ts)" ] }, "steps": [ diff --git a/docs/qa/platform-checklist/areas/search.json b/docs/qa/platform-checklist/areas/search.json index 4538380d3a8..f3c3120cc97 100644 --- a/docs/qa/platform-checklist/areas/search.json +++ b/docs/qa/platform-checklist/areas/search.json @@ -11,7 +11,7 @@ "provenance": "run #7629 (framework, 2026-08-11) drove search.rls-both-personas to 4/4 PASS on exactly this persona — 3 invoices visible to the member vs 15 to admin. Promoted from prose on that one item to a replayable area recipe by the 17.1.0 sweep (#10236 B1); the Mei/Ada measurements above are from #10257. Step 1 re-pointed invite-first by #21060 after QA run #21056 met the invite_only default (403 SELF_REGISTRATION_CLOSED on an uninvited sign-up).", "app": "showcase", "requires": [ - "a stock dev boot — the position→permission-set bindings are created automatically at startup on `kernel:bootstrapped`, including ['contributor','showcase_contributor'] (examples/app-showcase/src/security/bind-position-sets.ts, POSITION_PERMISSION_SET_BINDINGS). ⛔ Do NOT hand-create a sys_position_permission_set row: it already exists, and a duplicate proves nothing about the merge", + "a stock dev boot — the `contributor` position declares `showcase_contributor` in its `permissionSets` (examples/app-showcase/src/security/positions.ts), which the resolver reads by name (ADR-0131 D3/D4). ⛔ Do NOT hand-create a sys_position_permission_set row: no resolver reads it, so it proves nothing about the merge", "system-context write access for the position binding (the sys_user_position insert) — the same pattern access-security.json's permission-set items already replay", "an isolated boot (own port + file DB, dogfood §0); export OS_PORT= as well as passing -p (RUNNER.md's connector self-URL fact) so unrelated flow noise does not pollute the run", "an admin session to INVITE the step-1 address before step 1: POST /api/v1/auth/organization/invite-member {\"email\": \"\", \"role\": \"member\"}. The stock showcase declares no auth.audience, so its posture is the invite_only default, which answers an uninvited sign-up 403 SELF_REGISTRATION_CLOSED; a pending, unexpired sys_invitation row for the address is what admits it (an admin POST /api/v1/auth/admin/create-user is the other door, and answers mustChangePassword:true)" diff --git a/examples/app-crm/objectstack.config.ts b/examples/app-crm/objectstack.config.ts index 8ff69b242ba..10ffc96fc6b 100644 --- a/examples/app-crm/objectstack.config.ts +++ b/examples/app-crm/objectstack.config.ts @@ -18,7 +18,6 @@ import { SalesUserPermissionSet, GuestPortalProfile, } from './src/security/index.js'; -import { registerCrmPositionBindings } from './src/security/bind-position-sets.js'; import { CrmSeedData } from './src/data/index.js'; import { CrmDatasource, CrmAnalyticsDatasource } from './src/datasources/crm.datasource.js'; import { CrmTranslationBundle } from './src/translations/crm.translation.js'; @@ -118,12 +117,3 @@ export default defineStack({ // Seed data data: CrmSeedData, }); - -/** - * [#8060] Ensure the persona position↔permission-set bindings exist after the - * security bootstraps (cannot be a seed — see bind-position-sets.ts). Mirrors - * app-showcase's own `onEnable` wiring. - */ -export const onEnable = async (ctx: unknown): Promise => { - registerCrmPositionBindings(ctx as Parameters[0]); -}; diff --git a/examples/app-crm/src/security/bind-position-sets.ts b/examples/app-crm/src/security/bind-position-sets.ts deleted file mode 100644 index e8b9944d199..00000000000 --- a/examples/app-crm/src/security/bind-position-sets.ts +++ /dev/null @@ -1,121 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * [#8060] Position ↔ permission-set bindings for the CRM example. - * - * [ADR-0131 D3/D4] The bindings are DECLARED on the positions - * (`permissionSets` in `sales-positions.ts`), and that is what the - * authorization resolver reads. This binder still writes the same pairs as - * `sys_position_permission_set` rows for the readers that have not moved off - * the junction yet; it is deleted with the last of them, and - * `test/position-bindings.test.ts` holds its list equal to the declarations. - * - * Mirrors `examples/app-showcase/src/security/bind-position-sets.ts`. The - * history: bindings used to live only as `sys_position_permission_set` rows, - * and this app declared three positions - * (`sales_rep`, `sales_manager`, `finance_approver`) and a `crm_sales_user` - * permission set that never met — every persona silently degraded to the - * `everyone` baseline (probe-blocked on every CRM object) until an admin - * hand-assigned the set. - * - * This cannot be a declarative SEED: the seed loader runs before the security - * bootstrap creates the `sys_position` / `sys_permission_set` rows, so the - * name references cannot resolve. We play the admin's part imperatively — - * inserting each missing binding idempotently (dedup by position+set pair, - * stable ids) — on `kernel:bootstrapped`, the anchor the kernel fires only - * AFTER every `kernel:ready` handler (incl. the security bootstrap) has - * settled. - * - * `crm_sales_user` is deliberately NOT marked `isDefault`: that would - * auto-bind it to the `everyone` anchor and grant every user — including - * ones holding none of the three positions — full CRUD on every CRM object, - * which changes the example's security story instead of completing it - * (ruling on #8060). Only the three declared positions get the set. - */ - -export const BINDINGS: ReadonlyArray = [ - ['sales_rep', 'crm_sales_user'], - ['sales_manager', 'crm_sales_user'], - ['finance_approver', 'crm_sales_user'], -]; - -const SYS = { isSystem: true } as const; - -interface BindHostContext { - ql: { - find: (object: string, query: unknown, options?: unknown) => Promise; - insert: (object: string, data: Record, options?: unknown) => Promise; - }; - logger?: { info?: (...a: unknown[]) => void; warn?: (...a: unknown[]) => void }; - hook?: (event: string, handler: () => Promise | void) => void; -} - -/** Find one row by `name`, passing the system context the way the engine's own - * read path expects it (merged from `query.context`; see objectql `find`). */ -async function findOneByName(ctx: BindHostContext, object: string, name: string): Promise<{ id?: string } | undefined> { - try { - const rows = (await ctx.ql.find(object, { where: { name }, limit: 1, context: SYS })) as - | Array<{ id?: string }> - | { records?: Array<{ id?: string }> }; - if (Array.isArray(rows)) return rows[0]; - return rows?.records?.[0]; - } catch (err) { - ctx.logger?.warn?.('[crm] position binding lookup failed', { - object, - name, - error: err instanceof Error ? err.message : String(err), - }); - return undefined; - } -} - -export function registerCrmPositionBindings(ctx: BindHostContext): void { - const run = async (): Promise => { - let created = 0; - for (const [positionName, setName] of BINDINGS) { - const position = await findOneByName(ctx, 'sys_position', positionName); - const set = await findOneByName(ctx, 'sys_permission_set', setName); - if (!position?.id || !set?.id) { - ctx.logger?.warn?.('[crm] position binding skipped (row missing)', { position: positionName, set: setName }); - continue; - } - const existing = (await ctx.ql.find( - 'sys_position_permission_set', - { where: { position_id: position.id, permission_set_id: set.id }, limit: 1, context: SYS }, - )) as unknown; - const hit = Array.isArray(existing) ? existing[0] : (existing as { records?: unknown[] })?.records?.[0]; - if (hit) continue; - try { - await ctx.ql.insert( - 'sys_position_permission_set', - { id: `ppsb_crm_${positionName}`, position_id: position.id, permission_set_id: set.id }, - { context: SYS }, - ); - created += 1; - } catch (err) { - ctx.logger?.warn?.('[crm] position binding insert failed', { - position: positionName, - set: setName, - error: err instanceof Error ? err.message : String(err), - }); - } - } - ctx.logger?.info?.('[crm] position bindings ensured', { created, total: BINDINGS.length }); - }; - - // Bind on `kernel:bootstrapped` — the anchor that fires only after every - // `kernel:ready` handler (incl. the security bootstrap that seeds the - // position/set rows) has settled. Fall back to a deferred immediate run - // if the host context somehow omits the hook registrar (never true for the - // real runtime `PluginContext`, which always provides one — this branch is - // unreachable in practice, so a microtask deferral is equivalent to the - // macrotask `setTimeout` it replaces). Pure-ES deferral, no ambient - // `setTimeout`/`window`/`node` global: `examples/**` sources reachable from - // `scripts/analytics-reconcile/*.ts` type-check under the workspace ROOT - // tsconfig, which declares neither `dom` nor `types: ["node"]`. - if (typeof ctx.hook === 'function') { - ctx.hook('kernel:bootstrapped', run); - } else { - void Promise.resolve().then(run); - } -} diff --git a/examples/app-crm/test/position-bindings.test.ts b/examples/app-crm/test/position-bindings.test.ts index 7756be56f1f..68ea7cead93 100644 --- a/examples/app-crm/test/position-bindings.test.ts +++ b/examples/app-crm/test/position-bindings.test.ts @@ -2,23 +2,23 @@ /** * [ADR-0131 D3/D4] The CRM personas' permission sets are declared on their - * positions (`permissionSets`), which the authorization resolver reads. The - * junction binder (`src/security/bind-position-sets.ts`) still writes the same - * pairs for the readers that have not moved off `sys_position_permission_set`; - * until it is deleted, the two must name the same pairs, or those readers - * judge a position by bindings the resolver does not grant. + * positions (`permissionSets`), which the authorization resolver reads; no + * junction row is written for them. Pinned literally so a persona losing its + * set is a red here, not a silently narrower boot. */ import { describe, expect, it } from 'vitest'; -import { BINDINGS } from '../src/security/bind-position-sets.js'; import { FinanceApproverPosition, SalesManagerPosition, SalesRepPosition } from '../src/security/sales-positions.js'; describe('CRM position bindings', () => { - it('the junction binder writes exactly the bindings the position definitions declare', () => { + it('each persona position declares crm_sales_user', () => { const declared = [SalesRepPosition, SalesManagerPosition, FinanceApproverPosition] .flatMap((p) => (p.permissionSets ?? []).map((s) => `${p.name} -> ${s}`)) .sort(); - expect(declared).toHaveLength(3); - expect(BINDINGS.map(([p, s]) => `${p} -> ${s}`).sort()).toEqual(declared); + expect(declared).toEqual([ + 'finance_approver -> crm_sales_user', + 'sales_manager -> crm_sales_user', + 'sales_rep -> crm_sales_user', + ]); }); }); diff --git a/examples/app-showcase/objectstack.config.ts b/examples/app-showcase/objectstack.config.ts index ce5fefe511b..f1777eba834 100644 --- a/examples/app-showcase/objectstack.config.ts +++ b/examples/app-showcase/objectstack.config.ts @@ -18,7 +18,6 @@ import { ShowcaseExternalDatasource } from './src/system/datasources/showcase-ex import { ExternalCustomer, ExternalOrder } from './src/data/objects/external/index.js'; import { setupShowcaseExternalDatasource } from './src/system/datasources/external-fixture.js'; import { registerRecalcEndpoint } from './src/system/server/recalc-endpoint.js'; -import { registerShowcasePositionBindings } from './src/security/bind-position-sets.js'; import { registerShowcaseApprovalDemo } from './src/security/seed-approval-demo.js'; import { TaskViews, ProjectViews, InquiryViews, BusinessUnitViews, ContactViews, FieldZooViews } from './src/ui/views/index.js'; import { ShowcaseApp } from './src/ui/apps/index.js'; @@ -293,9 +292,6 @@ export const onEnable = async (ctx: unknown): Promise => { await setupShowcaseExternalDatasource(ctx as Parameters[0]); // Mount the custom REST endpoint behind the `showcase_recalc_estimate` api action. registerRecalcEndpoint(ctx as Parameters[0]); - // [#2926 ②] Ensure the persona position↔permission-set bindings exist after - // the security bootstraps (cannot be a seed — see bind-position-sets.ts). - registerShowcasePositionBindings(ctx as Parameters[0]); // Make the v16 approval features (会签 / quorum) demonstrable on a fresh boot: // assign the dev admin the approver positions and launch the signoff flows so // real pending requests land in the inbox (cannot be a seed — see diff --git a/examples/app-showcase/src/data/seed/index.ts b/examples/app-showcase/src/data/seed/index.ts index 3a24a642188..80abc28c1b5 100644 --- a/examples/app-showcase/src/data/seed/index.ts +++ b/examples/app-showcase/src/data/seed/index.ts @@ -258,12 +258,10 @@ const orgUnits = SeedSchema.parse({ ], }); -// [#2926 ②] Position ↔ permission-set bindings are NOT seeded here: the seed -// loader runs before the security bootstrap creates the sys_position / -// sys_permission_set rows, so the required name references cannot resolve. -// They are ensured imperatively on kernel:bootstrapped instead (after every -// kernel:ready handler, incl. the security bootstrap, has settled) — see -// `src/security/bind-position-sets.ts` (wired via `onEnable`). +// [#2926 ②, ADR-0131 D3/D4] Position ↔ permission-set bindings are NOT seeded +// here: each position declares the sets it distributes in `permissionSets` +// (`src/security/positions.ts`), and the authorization resolver reads them +// from the definition — no binding row exists to seed. const teams = defineSeed(Team, { mode: 'upsert', diff --git a/examples/app-showcase/src/security/bind-position-sets.ts b/examples/app-showcase/src/security/bind-position-sets.ts deleted file mode 100644 index 645ccd00166..00000000000 --- a/examples/app-showcase/src/security/bind-position-sets.ts +++ /dev/null @@ -1,123 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * [#2926 ②] Position ↔ permission-set bindings for the showcase personas. - * - * [ADR-0131 D3/D4] The bindings are DECLARED on the positions - * (`permissionSets` in `positions.ts`), and that is what the authorization - * resolver reads. This binder still writes the same pairs as - * `sys_position_permission_set` rows for the readers that have not moved off - * the junction yet (the delegated-admin gate, the explainer, the sharing - * recipients); it is deleted with the last of them. - * `test/inert-wirings.test.ts` holds this list equal to the declarations. - * - * This cannot be a declarative SEED: the seed loader runs before the security - * bootstrap creates the `sys_position` / `sys_permission_set` rows, so the name - * references cannot resolve and the required lookups fail validation. So we play - * the admin's part imperatively — inserting each missing binding idempotently - * (dedup by position+set pair, stable ids). - * - * Timing matters. `kernel:ready` handlers run SEQUENTIALLY, each awaited, in - * registration order (`kernel.ts` `trigger`). The showcase AppPlugin starts - * BEFORE the Security plugin, so an app hook on `kernel:ready` runs *before* - * the security bootstrap has created the position/set rows — the rows never - * appear from inside that hook. We therefore bind on **`kernel:bootstrapped`**, - * the anchor the kernel fires only AFTER every `kernel:ready` handler has - * settled (`kernel.ts` Phase 3.5 / `lite-kernel.ts`), so the bootstrap rows are - * guaranteed present. - * - * `everyone → showcase_member_default` is NOT bound here: the security plugin - * auto-binds the app's `isDefault` set (resolved as its `fallbackPermissionSet`) - * to `everyone` at boot. This list only carries the persona → set bindings the - * framework cannot infer. - */ - -/** - * The persona bindings as junction rows — the same pairs the position - * definitions declare (`inert-wirings.test.ts` pins the equality). - */ -export const POSITION_PERMISSION_SET_BINDINGS: ReadonlyArray = [ - ['contributor', 'showcase_contributor'], - ['manager', 'showcase_manager'], - ['exec', 'showcase_executive'], - ['auditor', 'showcase_auditor'], - ['ops', 'showcase_ops'], - ['field_ops_delegate', 'showcase_field_ops_delegate'], - ['client_liaison', 'showcase_client_liaison'], - ['client_portal_user', 'showcase_guest_portal'], -]; - -const SYS = { isSystem: true } as const; - -interface BindHostContext { - ql: { - find: (object: string, query: unknown, options?: unknown) => Promise; - insert: (object: string, data: Record, options?: unknown) => Promise; - }; - logger?: { info?: (...a: unknown[]) => void; warn?: (...a: unknown[]) => void }; - hook?: (event: string, handler: () => Promise | void) => void; -} - -/** Find one row by `name`, passing the system context the way the engine's own - * read path expects it (merged from `query.context`; see objectql `find`). */ -async function findOneByName(ctx: BindHostContext, object: string, name: string): Promise<{ id?: string } | undefined> { - try { - const rows = (await ctx.ql.find(object, { where: { name }, limit: 1, context: SYS })) as - | Array<{ id?: string }> - | { records?: Array<{ id?: string }> }; - if (Array.isArray(rows)) return rows[0]; - return rows?.records?.[0]; - } catch (err) { - ctx.logger?.warn?.('[showcase] position binding lookup failed', { - object, - name, - error: err instanceof Error ? err.message : String(err), - }); - return undefined; - } -} - -export function registerShowcasePositionBindings(ctx: BindHostContext): void { - const run = async (): Promise => { - let created = 0; - for (const [positionName, setName] of POSITION_PERMISSION_SET_BINDINGS) { - const position = await findOneByName(ctx, 'sys_position', positionName); - const set = await findOneByName(ctx, 'sys_permission_set', setName); - if (!position?.id || !set?.id) { - ctx.logger?.warn?.('[showcase] position binding skipped (row missing)', { position: positionName, set: setName }); - continue; - } - const existing = (await ctx.ql.find( - 'sys_position_permission_set', - { where: { position_id: position.id, permission_set_id: set.id }, limit: 1, context: SYS }, - )) as unknown; - const hit = Array.isArray(existing) ? existing[0] : (existing as { records?: unknown[] })?.records?.[0]; - if (hit) continue; - try { - await ctx.ql.insert( - 'sys_position_permission_set', - { id: `ppsb_showcase_${positionName}`, position_id: position.id, permission_set_id: set.id }, - { context: SYS }, - ); - created += 1; - } catch (err) { - ctx.logger?.warn?.('[showcase] position binding insert failed', { - position: positionName, - set: setName, - error: err instanceof Error ? err.message : String(err), - }); - } - } - ctx.logger?.info?.('[showcase] position bindings ensured', { created, total: POSITION_PERMISSION_SET_BINDINGS.length }); - }; - - // Bind on `kernel:bootstrapped` — the anchor that fires only after every - // `kernel:ready` handler (incl. the security bootstrap that seeds the - // position/set rows) has settled. Fall back to a deferred immediate run - // if the host context somehow omits the hook registrar. - if (typeof ctx.hook === 'function') { - ctx.hook('kernel:bootstrapped', run); - } else { - setTimeout(() => void run(), 0); - } -} diff --git a/examples/app-showcase/src/security/permission-sets.ts b/examples/app-showcase/src/security/permission-sets.ts index 631edfd1a0d..62a413f870b 100644 --- a/examples/app-showcase/src/security/permission-sets.ts +++ b/examples/app-showcase/src/security/permission-sets.ts @@ -72,8 +72,8 @@ export const ContributorPermissionSet = definePermissionSet({ // // No policy below lists `positions`, deliberately. A set's row-level policies // are part of what the set grants, so they hold for EVERY holder of the set, - // whichever way it is held: through the `contributor` position binding - // (bind-position-sets.ts) or granted directly (the delegated admin below may + // whichever way it is held: through the `contributor` position's declared + // `permissionSets` (positions.ts) or granted directly (the delegated admin below may // hand this set out). `positions` narrows a policy to callers holding one of // the listed positions (ADR-0090 P2) — a tool for a policy on a baseline set // every member resolves, not for a set that is itself the grant. diff --git a/examples/app-showcase/src/security/positions.ts b/examples/app-showcase/src/security/positions.ts index 297cd2e2401..3a8d66a1bcb 100644 --- a/examples/app-showcase/src/security/positions.ts +++ b/examples/app-showcase/src/security/positions.ts @@ -10,10 +10,9 @@ * (ADR-0090 D3; the old `parent` field on positions never existed at runtime). * * Each position names the permission sets it distributes in `permissionSets` - * (ADR-0131 D3/D4): the authorization resolver reads them from here, by name. - * `src/security/bind-position-sets.ts` still writes the same bindings as - * `sys_position_permission_set` rows, for the readers that have not moved off - * the junction yet; `test/inert-wirings.test.ts` holds the two equal. + * (ADR-0131 D3/D4): the authorization resolver reads them from here, by name, + * and no binding row is written for them. `test/inert-wirings.test.ts` pins + * the declared pairs. * * Two positions are BUILT-IN and never declared by an app: `everyone` * (implicitly held by every authenticated member — the tenant baseline, diff --git a/examples/app-showcase/src/security/seed-approval-demo.ts b/examples/app-showcase/src/security/seed-approval-demo.ts index a0d0b09a7e7..74ff671836d 100644 --- a/examples/app-showcase/src/security/seed-approval-demo.ts +++ b/examples/app-showcase/src/security/seed-approval-demo.ts @@ -18,9 +18,9 @@ * - `sys_approval_request` is engine-owned (ADR-0103: get/list only), so a * request can't be inserted through the generic data API either. * - * So we play the admin's part imperatively, exactly like `bind-position-sets.ts`: - * on `kernel:bootstrapped` (after the security bootstrap has created the - * position/permission rows and the automation engine is wired) we + * So we play the admin's part imperatively: on `kernel:bootstrapped` (after + * every `kernel:ready` handler has settled and the automation engine is + * wired) we * 1. assign the dev-seeded admin to `manager` / `finance` / `legal` so they * are a resolvable approver on every demo request (and can act in the * inbox); diff --git a/examples/app-showcase/test/inert-wirings.test.ts b/examples/app-showcase/test/inert-wirings.test.ts index 1af7f7ca9f0..a27c26d2a4e 100644 --- a/examples/app-showcase/test/inert-wirings.test.ts +++ b/examples/app-showcase/test/inert-wirings.test.ts @@ -26,7 +26,6 @@ import { validateFunctionalCompleteness } from '@objectstack/lint'; import type { JobHandlerContext } from '@objectstack/runtime'; import { healthFor, sweepProjectHealth } from '../src/automation/jobs/index.js'; import * as jobsModule from '../src/automation/jobs/index.js'; -import { POSITION_PERMISSION_SET_BINDINGS } from '../src/security/bind-position-sets.js'; import { ADMIN_EMAIL, PHONE_DEMO_USER, @@ -701,14 +700,23 @@ describe('sharing rules target an object under record-sharing enforcement (#9237 expect(rules.length).toBeGreaterThan(0); }); - // [ADR-0131 D3/D4] The junction binder still writes the same bindings for the - // readers that have not moved off `sys_position_permission_set`; until it is - // deleted the two must name the same pairs, or those readers judge a - // position by bindings the resolver does not grant. - it('the junction binder writes exactly the bindings the position definitions declare', () => { + // [ADR-0131 D3/D4] The junction binder is deleted: the position's own + // `permissionSets` is the one binding, and no row repeats it. + it('each persona position declares the permission set its personas hold (ADR-0131 D3/D4)', () => { + // The position definition is the binding the resolver reads; no junction + // row is written for it. Pinned literally so a persona losing its set is a + // red here, not a silently narrower dogfood boot. const declared = positions.flatMap((p) => (p.permissionSets ?? []).map((s) => `${p.name} -> ${s}`)).sort(); - expect(declared.length).toBeGreaterThan(0); - expect(POSITION_PERMISSION_SET_BINDINGS.map(([p, s]) => `${p} -> ${s}`).sort()).toEqual(declared); + expect(declared).toEqual([ + 'auditor -> showcase_auditor', + 'client_liaison -> showcase_client_liaison', + 'client_portal_user -> showcase_guest_portal', + 'contributor -> showcase_contributor', + 'exec -> showcase_executive', + 'field_ops_delegate -> showcase_field_ops_delegate', + 'manager -> showcase_manager', + 'ops -> showcase_ops', + ]); }); it('no rule is anchored on an object whose OWD leaves nothing to widen', () => { diff --git a/packages/cli/src/commands/meta/resync-skip-explanation.test.ts b/packages/cli/src/commands/meta/resync-skip-explanation.test.ts deleted file mode 100644 index 6408df66492..00000000000 --- a/packages/cli/src/commands/meta/resync-skip-explanation.test.ts +++ /dev/null @@ -1,80 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. -// -// #9184 — `os meta resync` reports a nonzero skip count with no explanation. -// -// The docblock half (#9130 / PR #9183) already explains, IN SOURCE, that a -// stored `'admin'` (or legacy `'user'`) stamp is not always a deliberate -// Setup takeover — on any install created before commit 712e185db the platform's own -// seeder wrote that exact stamp, so `resynced 0 / skipped N` is a permanent, -// by-design outcome there. This card is the runtime-output half: the same -// explanation has to reach the operator who is staring at the terminal, -// never having gone looking for the source. -// -// What is pinned here: -// • the explanation fires exactly when `resyncSkipped > 0` — the same -// condition the skip-count summary itself uses, never a narrower one -// (e.g. `resynced === 0`); -// • it names BOTH spellings of the legacy-vs-current provenance value -// (`admin` and `user`), because a stored legacy row can surface under -// either; -// • it says the skip is expected / by design / not a failure — the whole -// point of the card; -// • it does NOT reintroduce "intentional override" framing for the -// admin-owned case — the docblock removed that as false (before commit 712e185db the -// seeder wrote the stamp, not an admin), and this line must not -// contradict it. - -import { describe, it, expect } from 'vitest'; -import { resyncSkipExplanationLine } from './resync.js'; - -describe('resyncSkipExplanationLine — the runtime-output half of #9184', () => { - it('is silent when nothing was skipped', () => { - expect(resyncSkipExplanationLine(0)).toBeNull(); - }); - - it('is silent for a negative count too (defensive — should never occur)', () => { - expect(resyncSkipExplanationLine(-1)).toBeNull(); - }); - - it('fires for a full skip (the classic pre-#8692 "resynced 0 / skipped N" case)', () => { - const line = resyncSkipExplanationLine(8); - expect(line).not.toBeNull(); - expect(line).toContain('admin'); - expect(line).toContain('user'); - expect(line).toContain("before the seeder began stamping its default sets 'platform'"); - }); - - it('fires identically for a PARTIAL skip — same trigger as the summary, not `resynced === 0`', () => { - // The card requires this line to ride the same condition as the skip - // summary (`resyncSkipped > 0`), not a narrower "totally stuck" gate. A - // caller with resynced=3/skipped=2 must get the same explanation as - // resynced=0/skipped=8. - expect(resyncSkipExplanationLine(2)).toBe(resyncSkipExplanationLine(8)); - }); - - it('says the outcome is expected / by design / not a failure', () => { - const line = resyncSkipExplanationLine(1)!; - expect(line.toLowerCase()).toMatch(/expected|by design/); - expect(line.toLowerCase()).toContain('not a failure'); - }); - - it('never claims the admin-owned case is a deliberate override — the framing PR #9183 removed', () => { - const line = resyncSkipExplanationLine(1)!; - // The case predating commit 712e185db is exactly the platform's own seeder inheriting a - // field default, not an administrator deciding anything — the docblock - // in bootstrap-platform-admin.ts is explicit that stating this as - // "(intentional override)" is a lie for those rows. This line must keep - // that same neutral framing rather than reintroducing it. - expect(line).toContain("isn't always a deliberate Setup takeover"); - expect(line).not.toMatch(/admin[^.]*\(intentional override\)/i); - }); - - it('keeps the package-owned case correctly framed as ALWAYS deliberate, unlike admin', () => { - // Unlike a stored 'admin'/'user' stamp, a package-owned row genuinely is - // always a deliberate override — the docblock draws this distinction and - // the runtime line must not blur it by applying uniform "override" - // language to both provenance classes. - const line = resyncSkipExplanationLine(1)!; - expect(line).toMatch(/package-owned row.*always a deliberate override/s); - }); -}); diff --git a/packages/cli/src/commands/meta/resync.ts b/packages/cli/src/commands/meta/resync.ts deleted file mode 100644 index b1c92228da8..00000000000 --- a/packages/cli/src/commands/meta/resync.ts +++ /dev/null @@ -1,243 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -import { Command, Flags } from '@oclif/core'; -import chalk from 'chalk'; -import { createInterface } from 'node:readline'; -import { - printHeader, - printSuccess, - printWarning, - printError, - printInfo, - printStep, - createTimer, - emitJson, - errorCodeFields, -} from '../../utils/format.js'; -import { bootSchemaStack } from '../../utils/schema-migrate.js'; -import { bootstrapPlatformAdmin, securityDefaultPermissionSets } from '@objectstack/plugin-security'; - -async function confirm(question: string): Promise { - if (!process.stdin.isTTY) return false; // non-interactive → require --yes - const rl = createInterface({ input: process.stdin, output: process.stdout }); - try { - const answer: string = await new Promise((resolve) => rl.question(question, resolve)); - return /^y(es)?$/i.test(answer.trim()); - } finally { - rl.close(); - } -} - -function safeGetService(kernel: any, name: string): any { - try { - return kernel?.getService?.(name); - } catch { - return undefined; - } -} - -/** - * The dim explanatory line printed under the skip-summary `printWarning` - * when `resyncSkipped > 0` — the runtime-output half of #9184 (the docblock - * half, #9130/PR #9183, already explains this in source). - * - * `null` when nothing was skipped, so the caller's `if (resyncSkipped > 0)` - * gate is the single source of truth for the trigger condition: this line - * rides the SAME condition as the skip-count summary it sits under, never a - * separate one (e.g. `resynced === 0`) — a partial skip deserves the same - * explanation as a total one, and "alongside the skip summary" in the issue - * means exactly that pairing. - * - * Deliberately neutral about the skipped row's OWN provenance, matching the - * seeder docblock (`bootstrap-platform-admin.ts`) this line must not - * contradict: the skip decision is unconditionally intentional (only - * platform-owned rows are ever reconciled), but a stored `'admin'` stamp is - * NOT always a deliberate Setup takeover — on any install predating commit 712e185db - * the platform's own seeder wrote that exact stamp, so `resynced 0 / skipped - * N` is a permanent, by-design outcome there rather than a sign the command - * failed. `'user'` is named too: it is the legacy spelling of the same - * class, healed to `'admin'` by `normalizeManagedByVocab`, so a stored - * legacy row can still surface here under either spelling. - */ -export function resyncSkipExplanationLine(resyncSkipped: number): string | null { - if (resyncSkipped <= 0) return null; - // One unwrapped sentence per concatenated piece — never split by an - // embedded '\n' — so a key phrase can never straddle a hard line break - // (the terminal soft-wraps on its own, same as the file's other dim - // follow-up lines a few lines up). - return ( - " Expected, not a failure — resync only reconciles platform-owned rows. " + - "A stored 'admin' stamp (or the legacy 'user' spelling) isn't always a deliberate Setup takeover: " + - "on installs created before the seeder began stamping its default sets 'platform', the platform's own " + - "seeded defaults carry that same stamp, so a " + - "persistent skip count here can be permanent by design. A package-owned row, by contrast, is " + - "always a deliberate override by the package that owns it." - ); -} - -/** - * `os meta resync` — reconcile materialized metadata to the compiled `dist` - * without a `--fresh` wipe (#2705). - * - * The default permission sets (admin_full_access / member_default / - * viewer_readonly …) are seeded INSERT-ONCE at boot: an existing row is never - * clobbered, so an edit to a default set's source is served with its OLD value - * until the DB is wiped — a silent dev-loop trap (a permission-gated action - * "mysteriously" keeps its old behavior). Every OTHER metadata seed (declared - * permission sets, positions, built-in roles, capabilities) already upserts on - * boot; this command closes the one remaining gap by force-reconciling the - * default permission-set rows to the shipped declaration. - * - * Business data is never touched — only `sys_permission_set` definition rows, - * and only rows still `managed_by:'platform'` are reconciled. A row a package - * owns (`'package'`) is left alone as a deliberate override. A row stamped - * `'admin'` — or the legacy spelling `'user'`, healed to `'admin'` by the - * boot-time vocabulary normalizer (`normalizeManagedByVocab`) — is left alone - * too, but is NOT always a deliberate override: on any install created before - * commit 712e185db (the 2026-08-15 ruling) the platform's OWN seeded default sets carry that same - * `'admin'` stamp, indistinguishable from a genuine Setup takeover, so - * `resynced 0 / skipped N` is a permanent, by-design outcome on those - * installs rather than a bug. - */ -export default class MetaResync extends Command { - static override description = - 'Reconcile materialized metadata (default permission sets, which boot seeds insert-once) to the compiled dist without a --fresh wipe'; - - static override examples = [ - '$ os meta resync', - '$ os meta resync --yes', - '$ os meta resync --json', - ]; - - static override flags = { - 'database-url': Flags.string({ - description: 'Database URL to reconcile (defaults to $OS_DATABASE_URL / the project DB)', - env: 'OS_DATABASE_URL', - }), - yes: Flags.boolean({ char: 'y', description: 'Skip the confirmation prompt', default: false }), - json: Flags.boolean({ description: 'Output as JSON (implies non-interactive; requires --yes to mutate)' }), - }; - - async run(): Promise { - const { flags } = await this.parse(MetaResync); - const timer = createTimer(); - let exitCode = 0; - - if (!flags.json) { - printHeader('Meta · resync'); - printStep('Booting runtime stack…'); - } - - // [#21391] A run that can never reach the write — no `--yes`, and nobody - // at a terminal to confirm (`--json`, or stdin not a TTY) — answers - // `confirmation_required` and writes nothing, so it boots READ-ONLY, the - // boot `os migrate plan` takes. A run that may write keeps the plain boot: - // `sys_permission_set` must exist before the resync writes into it, and - // the interactive prompt comes after the boot. - const mayWrite = flags.yes || (!flags.json && process.stdin.isTTY === true); - - let stack; - try { - stack = await bootSchemaStack({ - jsonOutput: flags.json, - databaseUrl: flags['database-url'], - ...(mayWrite ? {} : { deferSchemaDdl: true, readOnlyProbe: true }), - }); - } catch (error: any) { - if (flags.json) await emitJson({ error: error.message, ...errorCodeFields(error) }, 0, { compact: true }); - else printError(error.message || String(error)); - process.exit(1); - } - - try { - const ql = safeGetService(stack.kernel, 'objectql'); - // Prefer the SecurityPlugin's resolved set when a full stack is booted (it - // picks up an app's `defaultPermissionSets` override); fall back to the - // platform defaults, which is what the standalone stack this command boots - // exposes. Either way these are the sets `bootstrapPlatformAdmin` seeds. - const svcSets = safeGetService(stack.kernel, 'security.bootstrapPermissionSets'); - const sets: any[] = Array.isArray(svcSets) && svcSets.length > 0 ? svcSets : securityDefaultPermissionSets; - - if (!ql) { - if (flags.json) { - await emitJson({ error: 'objectql_unavailable' }, 0, { compact: true }); - return; - } - printError('ObjectQL service is not available — cannot resync.'); - return; - } - if (!Array.isArray(sets) || sets.length === 0) { - if (flags.json) { - await emitJson({ resynced: 0, resyncSkipped: 0, inserted: 0, message: 'no_default_permission_sets' }, 0, { compact: true }); - return; - } - printWarning('No default permission sets are available to resync.'); - return; - } - - // Confirmation gate — resync overwrites the default permission-set - // definitions from the compiled dist, so admin Setup edits to those sets - // are replaced. Business data is never touched. - if (!flags.yes) { - if (flags.json || !process.stdin.isTTY) { - if (flags.json) { - await emitJson({ resynced: 0, resyncSkipped: 0, inserted: 0, message: 'confirmation_required', hint: 'pass --yes' }, 0, { compact: true }); - return; - } - printWarning('Confirmation required. Re-run with --yes to resync.'); - return; - } - printInfo(`Database: ${chalk.white(stack.dbLabel)}`); - printWarning('This overwrites the default permission-set definitions from the compiled dist.'); - console.log(chalk.dim(' Admin Setup edits to those sets are replaced. Business data is untouched.')); - console.log(chalk.dim(' Sets an admin or a package has taken over (managed_by user/package) are left alone.')); - const ok = await confirm(chalk.bold(`\nResync ${sets.length} default permission set(s) to ${stack.dbLabel}? [y/N] `)); - if (!ok) { - printInfo('Aborted — no changes made.'); - return; - } - } - - const logger = flags.json - ? undefined - : { info: (m: string) => printInfo(m), warn: (m: string) => printWarning(m) }; - - const report = await bootstrapPlatformAdmin(ql, sets as any[], { resync: true, logger }); - const resynced = report.resynced ?? 0; - const resyncSkipped = report.resyncSkipped ?? 0; - // seeded = existing + newly inserted; existing (under resync) = resynced + - // resyncSkipped, so the remainder is what this run freshly seeded. - const inserted = Math.max(0, (report.seeded ?? 0) - resynced - resyncSkipped); - - if (flags.json) { - await emitJson({ database: stack.dbLabel, resynced, resyncSkipped, inserted, duration: timer.elapsed() }); - return; - } - - console.log(''); - if (resynced > 0 || inserted > 0) { - printSuccess(`Reconciled ${resynced} default permission set(s) to dist${inserted > 0 ? `, seeded ${inserted} new` : ''}.`); - } else { - printInfo('Nothing reconciled.'); - } - if (resyncSkipped > 0) { - printWarning(`Left ${resyncSkipped} set(s) untouched (admin- or package-owned).`); - const note = resyncSkipExplanationLine(resyncSkipped); - if (note) console.log(chalk.dim(note)); - } - console.log(chalk.dim(` ${timer.display()}`)); - console.log(''); - } catch (error: any) { - exitCode = 1; - if (flags.json) await emitJson({ error: error.message, ...errorCodeFields(error) }, 0, { compact: true }); - else printError(error.message || String(error)); - } finally { - await stack.shutdown(); - // A one-shot command must exit even when the booted app stack left - // keep-alive handles open (schedulers/watchers registered on kernel:ready - // that `runtime.stop()` cannot fully drain). Matches the process.exit - // posture the other one-shot CLI commands use. - process.exit(exitCode); - } - } -} diff --git a/packages/cli/src/utils/format.ts b/packages/cli/src/utils/format.ts index 5804f510bee..60b6aa464bb 100644 --- a/packages/cli/src/utils/format.ts +++ b/packages/cli/src/utils/format.ts @@ -45,7 +45,7 @@ export const CLI_ALIAS = 'os'; * * Every other `--json` site in this CLI reports its duration INSIDE the * payload (`{ ...report, duration: timer.elapsed() }` — `os lint`, - * `os migrate meta`, `os migrate summary-nulls`, `os meta resync`), which is + * `os migrate meta`, `os migrate summary-nulls`), which is * what those two meant to do as well. With this union a duration in the exit * slot is a compile error, so the mistake cannot be made silently again. * diff --git a/packages/cli/src/utils/platform-migrations-arming.integration.test.ts b/packages/cli/src/utils/platform-migrations-arming.integration.test.ts index 55fc8398bea..1681e61b4a9 100644 --- a/packages/cli/src/utils/platform-migrations-arming.integration.test.ts +++ b/packages/cli/src/utils/platform-migrations-arming.integration.test.ts @@ -322,7 +322,7 @@ describe('#9380 the kernel:ready platform migrations, on the boots that reach th // WRITE modes boot without `deferSchemaDdl` — `--apply` of `os migrate // meta --stored`, `summary-nulls`, `value-shapes`, `recorded-by`, // `files-to-references` and `audit-metadata-bodies`, `os migrate resume - // --run`, `os secret orphans --delete`, `os meta resync --yes`. (Every + // --run`, `os secret orphans --delete`. (Every // no-write mode boots deferred since #21391.) Each writes only what the // operator confirmed; if the arming were gated on deferral instead of on // being a one-shot boot, every one of them would also repair rows the diff --git a/packages/cli/src/utils/schema-migrate.dev-self-heal-fence.integration.test.ts b/packages/cli/src/utils/schema-migrate.dev-self-heal-fence.integration.test.ts index a935878def5..9bd08f8a6fe 100644 --- a/packages/cli/src/utils/schema-migrate.dev-self-heal-fence.integration.test.ts +++ b/packages/cli/src/utils/schema-migrate.dev-self-heal-fence.integration.test.ts @@ -11,8 +11,8 @@ * one-shot command boot under `NODE_ENV=development` would otherwise inherit * the self-heal: * - * - a NON-deferred one-shot boot (every write mode: `os meta resync --yes`, - * the data commands' `--apply`) runs boot schema sync, so it would apply + * - a NON-deferred one-shot boot (every write mode: the data commands' + * `--apply`) runs boot schema sync, so it would apply * safe drift its operator never saw in a preview; * - `os migrate plan` defers its DDL (#3917), so its own boot never reaches * the reconcile — this file pins that it stays write-free on the card's diff --git a/packages/cli/src/utils/schema-migrate.lazy-driver-import.test.ts b/packages/cli/src/utils/schema-migrate.lazy-driver-import.test.ts index 62794d8f06a..49d758ac305 100644 --- a/packages/cli/src/utils/schema-migrate.lazy-driver-import.test.ts +++ b/packages/cli/src/utils/schema-migrate.lazy-driver-import.test.ts @@ -6,8 +6,8 @@ * The cost of one such import is not paid by the command that needs the driver. * oclif's `findCommand` walks the command table and `import()`s every command * module on **every** CLI invocation, so a broken import chain anywhere in that - * table is charged to whatever command you actually ran. `schema-migrate.ts` is - * shared by nine commands (`meta:resync`, `migrate`, and seven `migrate:*`), and + * table is charged to whatever command you actually ran. `schema-migrate.ts` + * was shared by nine commands (then `meta:resync`, `migrate`, and seven `migrate:*`), and * its one static `import { isInPlaceSchemaWork } from '@objectstack/driver-sql'` * meant that an unbuilt `packages/drivers/driver-sql/dist` made `os dev` print * nine `MODULE_NOT_FOUND` blocks (eighteen — `dev` forks a child) naming nine diff --git a/packages/cli/src/utils/schema-migrate.one-shot-family.integration.test.ts b/packages/cli/src/utils/schema-migrate.one-shot-family.integration.test.ts index adfba40bdd5..6dd09036b82 100644 --- a/packages/cli/src/utils/schema-migrate.one-shot-family.integration.test.ts +++ b/packages/cli/src/utils/schema-migrate.one-shot-family.integration.test.ts @@ -54,7 +54,6 @@ import type { IObjectQLEngine } from '@objectstack/spec/contracts'; import { isExitSignal } from './format.js'; import { bootSchemaStack } from './schema-migrate.js'; import { buildDataMigrationPlugins } from './data-migration-plugins.js'; -import MetaResync from '../commands/meta/resync.js'; import MigrateAccountIssuer from '../commands/migrate/account-issuer.js'; import MigrateApply from '../commands/migrate/apply.js'; import MigrateAuditMetadataBodies from '../commands/migrate/audit-metadata-bodies.js'; @@ -146,11 +145,6 @@ const invoke = (command: { run: (argv: string[], opts: { root: string }) => Prom * modes it has. The first case below holds this table equal to the source. */ const CALLERS: Record = { - 'commands/meta/resync.ts': { - run: invoke(MetaResync), - noWrite: [{ label: 'meta resync (no --yes)', argv: ['--database-url', '@DB@', '--json'] }], - write: [{ label: 'meta resync --yes', argv: ['--yes', '--database-url', '@DB@', '--json'] }], - }, 'commands/migrate/account-issuer.ts': { run: invoke(MigrateAccountIssuer), noWrite: [{ label: 'migrate account-issuer', argv: ['--database-url', '@DB@', '--json'] }], @@ -309,7 +303,7 @@ interface RunResult { * process-global and `emitJson` sets it, so it is saved and restored. The * stdout spy is installed before the boot, because a `--json` boot reserves * stdout and keeps whatever `process.stdout.write` is at that moment. - * `os meta resync` ends in `process.exit()`, so that is trapped too. + * A command that ends in `process.exit()` is trapped too. */ async function runJson(command: Invoke, argv: string[]): Promise { const savedExit = process.exitCode; diff --git a/packages/cli/src/utils/schema-migrate.ts b/packages/cli/src/utils/schema-migrate.ts index c59ed50e908..a3b8cb6ef2b 100644 --- a/packages/cli/src/utils/schema-migrate.ts +++ b/packages/cli/src/utils/schema-migrate.ts @@ -137,7 +137,7 @@ export interface SchemaStack { dbSource: DatabaseSource; managedTableCount: number; /** The booted kernel — `getService('objectql')` etc. for one-shot commands - * beyond schema migration (e.g. `os meta resync`, #2705). */ + * beyond schema migration. */ kernel: any; /** * Create-table / add-column work the boot sync was held back from running @@ -579,7 +579,7 @@ export async function bootSchemaStack( * a `--json` flag, and each of them re-introduced the same defect * independently: `os migrate plan` / `apply` / `resume` / `recorded-by` / * `summary-nulls` / `value-shapes` / `files-to-references`, `os migrate - * meta --stored` and `os meta resync` all emitted ~60 INFO lines around + * meta --stored` and the since-retired `os meta resync` all emitted ~60 INFO lines around * their payload. Booting the stack is what makes a command a member of * this family, so this is the one place a new member cannot avoid — and * with no default, a new member has to *decide* rather than inherit the @@ -641,7 +641,7 @@ export async function bootSchemaStack( * about. * * A WRITE mode that needs the tables to exist before it writes (`--apply` - * of the data commands, `os meta resync --yes`) leaves this off. + * of the data commands) leaves this off. */ deferSchemaDdl?: boolean; /** @@ -789,7 +789,7 @@ export async function bootSchemaStack( // • the write modes — `--apply` of `os migrate meta --stored` / // `value-shapes` / `recorded-by` / `summary-nulls` / // `files-to-references` / `audit-metadata-bodies`, `os migrate resume - // --run`, `os secret orphans --delete`, `os meta resync --yes` — boot + // --run`, `os secret orphans --delete` — boot // NOT deferred, so a `defer`-keyed policy would have left all of them // repairing rows. They write, but only the change the operator // confirmed; `os migrate apply` likewise. A repair riding along is a @@ -1063,8 +1063,8 @@ export async function bootSchemaStack( * package is `import type`, which erases. A *static* value import for it was * not a local cost, because this file is not loaded only when someone migrates: * oclif's `findCommand` `import()`s every command module on **every** CLI - * invocation, and nine commands reach this file (`meta:resync`, `migrate`, and - * seven `migrate:*`). So an unbuilt `packages/drivers/driver-sql/dist` did not + * invocation, and nine commands reached this file (then `meta:resync`, `migrate`, + * and seven `migrate:*`). So an unbuilt `packages/drivers/driver-sql/dist` did not * merely break those nine — running *any* command, `os dev` included, printed * one `MODULE_NOT_FOUND` block per command in front of the output you asked for * (and `os dev` forks a child, so you saw each one twice), while the nine diff --git a/packages/cli/test/json-stdout-purity.e2e.test.ts b/packages/cli/test/json-stdout-purity.e2e.test.ts index 698034ba9bc..0bb95fb871e 100644 --- a/packages/cli/test/json-stdout-purity.e2e.test.ts +++ b/packages/cli/test/json-stdout-purity.e2e.test.ts @@ -81,7 +81,6 @@ const COMMANDS_DIR = resolve(HERE, '../src/commands'); * --stored` rather than `migrate meta`. */ const FAMILY: Record = { - 'meta resync': [], // Its bare form boots: it takes the `os migrate plan` shape (`deferSchemaDdl` // + `readOnlyProbe`), so a fixture with no database file is opened as an // empty in-memory one rather than being brought into existence. No table diff --git a/packages/drivers/driver-sql/src/sql-driver.ts b/packages/drivers/driver-sql/src/sql-driver.ts index 01edeb164c7..64886ac39d7 100644 --- a/packages/drivers/driver-sql/src/sql-driver.ts +++ b/packages/drivers/driver-sql/src/sql-driver.ts @@ -7067,7 +7067,7 @@ export class SqlDriver implements IDataDriver { * Put a file-backed SQLite database in WAL mode (#3941). * * ObjectStack's normal shape is **several processes on one file**: a dev - * server, `os migrate`, `os meta resync`, a test run. SQLite's built-in + * server, `os migrate`, a test run. SQLite's built-in * default — `journal_mode = delete`, a rollback journal — is the worst mode * for that, in two measured ways: * diff --git a/packages/plugins/plugin-security/src/claim-seed-ownership.ts b/packages/plugins/plugin-security/src/claim-seed-ownership.ts index c4898cbfcc6..ae4bfca26a3 100644 --- a/packages/plugins/plugin-security/src/claim-seed-ownership.ts +++ b/packages/plugins/plugin-security/src/claim-seed-ownership.ts @@ -561,7 +561,7 @@ export async function claimSeedOwnership( // nor any later object. The rows stay unowned and the next run claims // them, because the predicate is still true of them. That run is the // next `app:seeded` (this boot or a later one) or the next promotion — - // ⚠️ NOT `os meta resync` and NOT a bootstrap replay: on an install that + // ⚠️ NOT a bootstrap replay: on an install that // already has an admin both short-circuit on `already_have_admin` and // never reach this function. logger?.warn?.( diff --git a/packages/plugins/plugin-security/src/permission-set-projection.ts b/packages/plugins/plugin-security/src/permission-set-projection.ts index 9e91bba87ed..463ca038640 100644 --- a/packages/plugins/plugin-security/src/permission-set-projection.ts +++ b/packages/plugins/plugin-security/src/permission-set-projection.ts @@ -1677,7 +1677,7 @@ export async function reconcilePermissionSetProjection( '`sys_permission_set` records with NO metadata definition backing them, and the one-time backfill ' + 'did not write one. Nothing will look broken: the records still list in Setup and the evaluator ' + 'still resolves them from the table — but the definitions are absent from the metadata store, so a ' + - 'metadata-driven re-provision (fresh environment, package reinstall, `meta resync`) recreates none ' + + 'metadata-driven re-provision (fresh environment, package reinstall) recreates none ' + 'of them, and every boot retries and fails identically. Fix: make the record body spec-valid — the ' + 'error below names the offending key; `permissionSetBodyFromRow()` already drops storage columns ' + '(`active`, timestamps, provenance), so a rejection here means the stored facet JSON itself is ' + diff --git a/packages/runtime/src/standalone-stack.ts b/packages/runtime/src/standalone-stack.ts index ec6d82723f3..fc8fb2beee0 100644 --- a/packages/runtime/src/standalone-stack.ts +++ b/packages/runtime/src/standalone-stack.ts @@ -812,7 +812,7 @@ export async function createStandaloneStack(config?: StandaloneStackConfig): Pro // // Keyed on an EXPLICIT `dev: true`, never on `factoryDev`'s NODE_ENV // default: `bootSchemaStack` passes no `dev`, and a non-deferred one-shot - // boot (`os meta resync --yes`, the data commands' `--apply`) runs schema + // boot (the data commands' `--apply`) runs schema // sync — under `NODE_ENV=development` it would apply safe drift its // operator never confirmed. The step-down keeps the default; the self-heal // is a serving host's declaration. diff --git a/scripts/check-cli-command-ids.mjs b/scripts/check-cli-command-ids.mjs index a7db333dfc6..1a0c4fe1b8f 100644 --- a/scripts/check-cli-command-ids.mjs +++ b/scripts/check-cli-command-ids.mjs @@ -317,7 +317,7 @@ const ROOT_DIR_WATCH_HINTS = ['scripts/**']; const FIXTURE_EXEMPTIONS = [ { file: 'scripts/docs-audit/affected-docs.mjs', - text: 'os meta resync-plan', + text: 'os meta register-plan', why: "affected-docs's own self-test case for 'a sibling command id is not this one'", }, { diff --git a/scripts/docs-audit/affected-docs.mjs b/scripts/docs-audit/affected-docs.mjs index 6010e3c025e..2a549dcc8e3 100644 --- a/scripts/docs-audit/affected-docs.mjs +++ b/scripts/docs-audit/affected-docs.mjs @@ -5008,18 +5008,18 @@ function selfTest() { const commandIdCases = [ // [path under the commands root, expected id, label] - ['meta/resync.ts', 'meta resync', 'topic + command — the #9230 specimen'], + ['meta/register.ts', 'meta register', 'topic + command (the #9230 specimen was `meta resync`, since retired)'], ['build.ts', 'build', 'a top-level command'], ['migrate/recorded-by.ts', 'migrate recorded-by', 'a hyphen is an ordinary id char'], ['migrate/index.ts', 'migrate', 'a topic index IS the topic, not "migrate index"'], ['a/b/c.ts', 'a b c', 'deeper nesting is read off the path, not special-cased'], ['cloud/whoami.js', 'cloud whoami', 'the compiled extension resolves the same way'], - ['meta/resync-skip-explanation.test.ts', null, 'a test file leaves a `.test` segment — declined, not guessed'], + ['meta/delete-json-error-code.test.ts', null, 'a test file leaves a `.test` segment — declined, not guessed'], ['__tests__/helper.ts', null, 'an underscore dir is not an oclif id segment'], ['meta/fixture.json', null, 'a non-source file under the commands dir'], ['index.ts', null, 'a bare index at the commands root would name the binary, not a command'], - ['meta/Resync.ts', null, 'an uppercase filename is not an oclif id'], + ['meta/Register.ts', null, 'an uppercase filename is not an oclif id'], ]; for (const [rel, want, label] of commandIdCases) check('commandIdFor', label, rel, want, commandIdFor(rel)); @@ -5043,7 +5043,7 @@ function selfTest() { const readFakePkg = (dir) => (dir === 'packages/cli' ? cliPkg : { name: '@objectstack/spec' }); const anchorFor = (f) => commandAnchorFor(f, readFakePkg, (d) => cliTree.has(d)); const commandAnchorCases = [ - ['packages/cli/src/commands/meta/resync.ts', 'os meta resync', 'the specimen resolves to its phrase'], + ['packages/cli/src/commands/meta/register.ts', 'os meta register', 'the specimen resolves to its phrase'], ['packages/cli/src/commands/build.ts', 'os build', 'a top-level command'], ['packages/cli/src/utils/schema-migrate.js', null, 'a CLI file OUTSIDE the commands dir is not a command'], ['packages/spec/src/commands/thing.ts', null, 'a commands dir in a package that declares no oclif is ignored'], @@ -5057,20 +5057,20 @@ function selfTest() { check('commandAnchorFor', 'a file outside any commands dir is not reported as unmapped either', 'packages/cli/src/utils/schema-migrate.js', null, anchorFor('packages/cli/src/utils/schema-migrate.js')); - // The doc-side matcher. `os meta resync` must find the page that writes it and must NOT + // The doc-side matcher. `os meta register` must find the page that writes it and must NOT // find prose that merely uses the words. - const cmdRe = commandPatternFor('meta resync', ['os', 'objectstack']); + const cmdRe = commandPatternFor('meta register', ['os', 'objectstack']); const commandMatchCases = [ - ['`os migrate`, `os meta resync`, a test run.', true, 'the real drivers.mdx line — inside a code span'], - ['Run `objectstack meta resync --json` to re-sync.', true, 'the alternate declared binary'], - ['npx objectstack meta resync', true, 'the npx form is the same phrase with a prefix'], - ['os meta\tresync', true, 'extra horizontal whitespace between the words'], - ['resync the metadata cache', false, 'the BARE token never matches — the whole point of the phrase'], - ['the meta resync step', false, 'the words without a binary name are prose'], - ['macos meta resync', false, 'a binary name must stand alone, not end another word'], - ['os meta resyncAll', false, 'a longer identifier is not the command'], - ['os meta resync-plan', false, 'a sibling command id is not this one'], - ['os meta\nresync', false, 'a line break is not intra-command whitespace'], + ['`os migrate`, `os meta register`, a test run.', true, 'inside a code span'], + ['Run `objectstack meta register --json` to register it.', true, 'the alternate declared binary'], + ['npx objectstack meta register', true, 'the npx form is the same phrase with a prefix'], + ['os meta\tregister', true, 'extra horizontal whitespace between the words'], + ['register the metadata cache', false, 'the BARE token never matches — the whole point of the phrase'], + ['the meta register step', false, 'the words without a binary name are prose'], + ['macos meta register', false, 'a binary name must stand alone, not end another word'], + ['os meta registerAll', false, 'a longer identifier is not the command'], + ['os meta register-plan', false, 'a sibling command id is not this one'], + ['os meta\nregister', false, 'a line break is not intra-command whitespace'], ]; for (const [text, want, label] of commandMatchCases) { check('commandPatternFor', label, JSON.stringify(text), want, cmdRe.test(text)); diff --git a/skills/objectstack-platform/references/operations.md b/skills/objectstack-platform/references/operations.md index 0d0a5237658..4c790c33a4f 100644 --- a/skills/objectstack-platform/references/operations.md +++ b/skills/objectstack-platform/references/operations.md @@ -92,7 +92,7 @@ Port resolution is the same for `os dev` and `os start` (both spawn `os serve`): |:--------|:-------------| | `os data create` / `get` / `query` / `update` / `delete` | Record-level CRUD against a running server (there is no `os data seed` / `export` / `import` — seed data in the `data:` collection loads automatically at boot) | | `os diff` | Compare two ObjectStack config files and detect breaking changes | -| `os meta register` / `os meta resync` | Register (create/update) metadata on a target server / re-sync it (there is no `os meta apply`) | +| `os meta register` | Register (create/update) metadata on a target server (there is no `os meta apply`) | | `os migrate plan` / `os migrate apply` | Dry-run / apply physical-DB drift reconciliation from metadata (forward-only — no batch rollback; `os rollback` was removed) | ## Environments & deploy