Skip to content

Commit adce192

Browse files
feat(cli): os migrate organization-ownership reports the declared sharing-rule mirrors as advisory while this release's boot still seeds them (#23988)
Fixes #23948 Clause-②: yes (widening — the apply report file, the plan file and both `--json` payloads gain an `advisory` field on a departure entry, and the run's `advisory` count includes that departure's rows) ## What changes On a v17 database whose packages declare sharing rules, `os migrate organization-ownership --apply` ended with `complete: true, blocking: 11` and never certified. The `adr-0131-organization-ownership` marker kept `verified_at` null. The post-check waited for the `declared-rule-mirrors` departure on `sys_sharing_rule` to leave. That departure is the uncustomized `managed_by in (package, platform)` rows with no organization. A served boot of this release writes exactly those rows, so on this release they never leave. **Reach.** Measured on cloud staging by the cloud#2868 PM (card comment 6218950893, 15:32Z): environment `9930e6cf` runs framework `f7e836da1ed3` with HotCRM 5.0.0. Its tenant upgrade ran the ceremony to `complete: true` and stopped on `sys_sharing_rule: 11 row(s) remain (uncustomized declared sharing rules — …: 11)`, the environment's only blocker. All 10 staging environments with HotCRM and the 5 production demos are expected to land there. The maintainer's order (quoted on the card) is 「框架先放行这一项」: "在启动流程还会种声明共享规则的版本上,仪式不把这些镜像当成阻断项,只列为提示;等 D2 随 #15204 落地后再恢复阻断". The director's ordering ruling for C3 stage 9 (#23952, "The order (ruled)") scopes it: the zero-NULL gate "is read as satisfied by a D10 report whose only remaining NULL rows are the `declared-rule-mirrors` the ceremony itself charts for this stage; any other NULL row keeps blocking". This PR implements that, and nothing wider: - **The plan** (`organization-ownership-plan.ts`) marks a departure `advisory` when no step of this release can carry its rows out (`PlannedDeparture.advisory`: why, and what removes them). Their NULL rows keep the table's NOT NULL reading `willReceive: false` with `N row(s) stay NULL on this release, reported as advisory: LABEL`. Before this change the plan read `willReceive: true` for `sys_sharing_rule` on such a database, which was not true on this release. One warning per such departure tells the operator before the first write, in the plan and again in `--apply`: `sys_sharing_rule holds N organization-less row(s) of LABEL, which stay: REASON ...`. - **The apply's post-check** (`organization-ownership-apply.ts`) leaves advisory rows out of `remaining`, so they are not discrepancies and not `blocking`. It counts them in the run's `advisory`, beside the unattributable rows, so the ledger verdict `{ complete, blocking, advisory }` carries them. Nothing new is deleted or attributed: the rows keep `organization_id` NULL. - **The command's human summary** (`organization-ownership.ts`) prints `N row(s) advisory, not held against certification — LABEL: REASON` under the table. Its closing warnings now name the unattributable rows and the advisory rows separately. Before, it said "N row(s) whose owner cannot be derived" for the whole `advisory` count. - **What still blocks:** `global-rung-move`, `template-promotion`, every host fate supplement departure (even one named `declared-rule-mirrors`: the rule is keyed on the table AND the id), and every other remaining row. ### The `--json` / report-file / plan-file shape change (additive) - `report.tables[].departures[]` (`--json` and the `--out` report file): `{ id, label, as, rows }` gains `advisory?: string`. Present means these rows stay on this release and do not hold certification. Absent means the departure blocks, exactly as before. - `report.tables[].remaining`: no longer counts an advisory departure's rows. - `report.advisory` and the `sys_migration` row's `advisory`: unattributable rows plus advisory departure rows. The field's doc already said "and rows held for another stage", and now the count includes them. - `plan.tables[].departures[]` (plan file and plan `--json`): the same `advisory?: string`. `plan.tables[].notNull` for the table reads as above, and `plan.warnings` and `report.warnings` gain the sentence above. - The inventory digest is unchanged: `05ba6e8512e2…` before and after this change. The verdict rule is not inventory data. So a plan reviewed on the parent commit still executes, and an interrupted ceremony still resumes. ## The restore condition (the hand-off to #23952) `DECLARED_SHARING_RULE_SEEDER_RUNS` (`packages/cli/src/utils/organization-ownership-inventory.ts`, `true`) states that a served boot of this release still seeds declared sharing rules. `advisoryDepartureReason(object, departureId)` beside it is the one rule: it answers only for `sys_sharing_rule` + `declared-rule-mirrors`, and only while the constant is `true`. **The flip point for #23952:** set the constant to `false` in the change that retires `bootstrapDeclaredSharingRules`. From then the mirrors block again until that stage's ceremony step deletes them. What makes that flip mechanical rather than remembered is a behavioural pin, not a source-text pin. `organization-ownership.declared-rule-mirrors.integration.test.ts`, case 1, boots a served stack of this release over a SQLite file: `@objectstack/verify`'s `bootStack`, which is `os serve`'s composition with plugin starts. The package declares one sharing rule. The case asserts the rows that boot wrote equal what the constant says: one `managed_by 'package'`, uncustomized, organization-less row while `true`, and none once `false`. When the seeder retires, the boot writes nothing, and the case fails with a message naming the constant until it flips. Alternatives weighed: - **A source pin over `sharing-plugin.ts:775`.** A CLI test reading plugin-sharing's source is a cross-package read. It must be declared in `scripts/cross-package-test-inputs.mjs` and `turbo.json`, both outside this claim's surface. It also pins a call site rather than the behaviour: a retirement that kept the call but emptied the seeder would leave it green. - **A capability that plugin-sharing exports.** That needs an edit under `packages/plugins/plugin-sharing/**` (#23952's, held off) and a new export on its published entry (refused for this card). - **Reading the ADR-0087 registry entry #23952 will write for the seeder's retirement.** That entry does not exist yet. Keying on a guessed id is a version string in disguise. - **A version string.** The card prefers otherwise. ## Measurements All on this branch unless stated. Base `161aee1047`, head `98f87644e1`. Not merged with `main`: the head merge-trees clean against it, including #23874's `sys_scim_provider` row in the same inventory file (disjoint hunks). - **Positive control on unchanged code, at the door** (CLI dist built at `161aee1047`). The served boot plus 17.x leftovers fixture is HotCRM's shape: ten declared rules a 17.x seeder left, plus one the served boot of this release seeded, plus an organization's rule, a customized declared rule and a `sys_job` still carrying `organization_id`. Plan and `--apply` (`--dev`) gave `complete: true, blocking: 11, advisory: 0` and exit 1. The marker read `verified_at` NULL, `applied_at` set, `blocking` 11. The unit-level positive control: the old test at `organization-ownership-apply.test.ts:885` passed on unchanged code with `blocking: 1`. - **The fix, at the door** (`organization-ownership.declared-rule-mirrors.integration.test.ts`, case 2). `--apply --json` exits 0 with `complete: true, blocking: 0, advisory: 11`. The `sys_sharing_rule` departure carries `rows: 11` with `advisory`, and `remaining` is 0. `sys_migration` reads `verified_at` set, `blocking` 0, `advisory` 11. All 13 rule rows are kept, 11 still NULL. Then a served boot of the certified file starts, with `OrganizationOwnershipBootGuardPlugin` composed and `sys_job`'s `organization_id` put back so the guard has a v17-shaped table and decides on the marker. `findV17ShapeTables` answers `['sys_job']`, and `readOrganizationOwnershipMarker`, `os migrate apply`'s drop gate, reads `verified: true`. - **A database that already ran `--apply` and stopped at `blocking: 11`**, both upgrade paths: - *Same inventory digest (resume).* The pre-fix fixture above, re-run with this branch's CLI and the same reviewed plan file: every step `checkpointed`, `resumedFrom` all nine, post-check only. Result `complete: true, blocking: 0, advisory: 11`, the marker verified, 13 rules kept. - *Another digest (restart).* This is staging's case: `f7e836da1ed3`'s inventory differs from this base's (`git diff --stat` 38+/1- on the inventory file), and `main` has since added #23874's row. Same fixture with the checkpoint's digest set to another value and the marker set back to `blocking 11` / `verified_at` NULL. Plan again, then `--apply`, with this change built over `main` `2e0e4517a6`: `restartedFrom` names all nine steps, each runs again as `done` (idempotent), and the result is `complete: true, blocking: 0, advisory: 11`, verified, 13 rules kept. - **Remote face (sqld).** `@sqld/linux-x64` 0.24.1-pre.42 (`sqld 0.24.32`), reached as `libsql://127.0.0.1:PORT?tls=0`. `organization-ownership.remote-face.integration.test.ts` now carries 11 organization-less declared rules. Plan, then `--apply`: `blocking 0`, the departure advisory with `rows: 11`, the marker `applied 1, verified 1`, 11 NULL rules kept. Run locally with `OS_TEST_SQLD_BIN` set; the test starts and stops its own `sqld`. CI skips it without the binary. - **Ablation** (the card's negative control). On committed `5c0aa5c752`, `scripts/ablation-replace.mjs` replaced `(d.advisory ? 0 : d.rows)` with `(d.rows)` in `departingRows` (anchor 1 to 0, blob `e3237c0dc629` to `a5f7d694d01e`). `organization-ownership-apply.declared-rule-mirrors.test.ts` then went red as predicted: `expected [ true, 11, 11 ] to deeply equal [ true, 0, 11 ]` (`[complete, blocking, advisory]`, so the apply's `blocking` was back at 11). The keyed case went red as `[ true, 13, 11 ]`, and the restore case stayed green. The restore was proven: blob equal to HEAD (`e3237c0dc629`) and `git diff HEAD` empty. The subject resolves by relative import to `src/`, so no rebuild was involved. - **Every reader of the marker** (dispatch item 3). The boot guard (`organization-ownership-boot-guard.ts:260`), the drop gate (`organization-ownership-drop-gate.ts:68`), `platform-objects`' `isDataMigrationVerified`, and objectql's two reads (`engine.ts:11525`, `:11685`) all go through `isDataMigrationFlagVerified`: `verified_at` set and `blocking === 0`. None reads `advisory`. The command writes `passed = complete && blocking === 0` (`organization-ownership.ts:772`). So `blocking 0` certifies everywhere the marker is read. - **What the ceremony's own boot does to those rows** (dispatch item 2). Nothing. The apply boot composes `SharingServicePlugin` through `composeProviderForDeclarations`, with `start()` suppressed (`schema-migration-plugins.ts`). The plan and apply runs left `sys_sharing_rule` byte-identical, apart from the customized rule's attribution. A served boot does seed: measured in case 1 and in the fixture's eleventh row. That boot is `os serve` / `os dev`, the one the v18 refusal stops at `kernel:ready`, after plugin starts. - **The NOT NULL reading for `sys_sharing_rule`** (dispatch item 4). `willReceive: false` with `11 row(s) stay NULL on this release, reported as advisory: uncustomized declared sharing rules — …`, in both the plan and the report. The ceremony has no NOT NULL DDL step; the reading is the report's statement, and it stays true. ## Tests - `src/utils/organization-ownership-apply.test.ts`: the test formerly titled "does not certify a ceremony whose departures have not left: the declared sharing-rule mirrors keep verified_at null" now reads "certifies a ceremony whose only remaining NULL rows are the declared sharing-rule mirrors this release still seeds: advisory, named, kept". It asserts `blocking 0`, the verdict, the departure's `advisory`, `remaining 0`, the NOT NULL reason, the warning and the rows kept. - `src/utils/organization-ownership-apply.declared-rule-mirrors.test.ts` (new, integration tier: it value-imports `better-sqlite3`), HotCRM's eleven: - certifies: `[true, 0, 11]`, nothing deleted, the customized rule attributed; - **the restore pin**: with the seeder retired (`advisoryDepartureReason` answered with `seederRuns: false` through a `vi.mock` wrapper; the other cases go through the shipped constant), the same fixture gives `blocking 11`, the verdict not passing, and no warning; - the negative leg: a host fate supplement departure named `declared-rule-mirrors` on its own table still blocks (`blocking 2`) beside the advisory mirrors. - `src/commands/migrate/organization-ownership.declared-rule-mirrors.integration.test.ts` (new, integration tier): the served-boot tie (case 1) and the door (case 2) above. - `src/commands/migrate/organization-ownership.remote-face.integration.test.ts`: 11 mirrors in its 17.x leftovers, and their advisory departure asserted. ## Local runs All on `5c0aa5c752` unless stated. Every build and test went through `os-verify-lock.sh`, with the exit read from its `VERDICT` line. - CLI unit tier: `pnpm --filter @objectstack/cli exec vitest run --project unit --maxWorkers=2` gave 305 files and 4558 tests passed, `VERDICT command-exit 0`. - Every test file reaching the ceremony's modules, both tiers (26 files: `organization-ownership*`, `sqlite-wasm`, `schema-migrate.one-shot-family`, `schema-migrate.sql-driver-lookup`), with `OS_TEST_SQLD_BIN` set: 26 files and 342 tests passed, `VERDICT command-exit 0`. - `pnpm --filter @objectstack/cli typecheck` (`tsc --noEmit` plus `check:test-typecheck`, the new test files included) on `98f87644e1`: exit 0, test layer unchanged at its pinned 3 files / 28 errors. The workspace `dist/` it read was built from `main` `2e0e4517a6` plus this change. - `pnpm exec eslint --no-inline-config` over the eight changed `.ts` files: exit 0. - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 66 families, and all 66 ran with exit 0 (`--ran` with an exit code per line: 66 derived, 66 run, 0 NOT-MEASURED). `check:dual-build-cjs-loads` and `check:i18n-coverage` first answered exit 3, PREREQUISITE NOT MET: packages outside the CLI closure had no `dist/`. After building those packages, both answered 0. After the last commit (`98f87644e1`, which changes only the changeset text), the 20 families the derivation names for the changeset file ran again on that head, all exit 0. The other 46 read none of the changed bytes, and their readings stand from `5c0aa5c752`. The families CI runs are cited from this PR's check-runs in the report. ## Acceptance notes - **#23952's conflict rule stands and is not implemented here.** "The ceremony deletes the mirrors" (its item 4) is that stage's. This PR deletes nothing. #23952 remains open, and its executor flips `DECLARED_SHARING_RULE_SEEDER_RUNS` in the change that retires the seeder. The served-boot case fails until then. - **The ruling's "any other NULL row keeps blocking" against today's unattributable rows.** On `main` before this change, unattributable rows (fate 4 reported rows, and fate 3 rows no anchor names under a wall) were already `advisory`, never `blocking`: `advisory = after.summary.unattributableRows`. So the dispatch's suggested negative leg, "an unattributable non-mirror row still keeps blocking > 0", does not hold on `main`. The negative leg here is another departure that has not left. Whether the ruling's sentence means to make unattributable rows block the marker is the ruling owner's reading; this PR changes neither. - **After certification the served boot keeps seeding.** Every `os serve` re-runs `defineRule` (an upsert by name and organization), and a rule a package adds later lands as a new organization-less row. The marker is not re-read at boot, so nothing re-blocks; #23952's step deletes them all. - **The boot guard was not what stopped these databases.** `complete: true` meant the column drops had run, so `findV17ShapeTables` found nothing and the guard never read the marker. The stuck `verified_at` mattered to the marker's other readers: `os migrate apply`'s drop gate here, and the certification reader cloud's upgrade uses (objectstack-ai/cloud#2868, outside this repository). - **Wall time.** The new door file ran in about 18 s of tests plus about 37 s of import on this shared box: four boots, plan, apply and two served boots. The remote-face file is similar when `sqld` is present. Neither declares a test budget, and no class fits. --- _Generated by [Claude Code](https://claude.ai/code/session_01PFB9ZGRWYvjhTXsd8i6KBp)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent d050a8f commit adce192

9 files changed

Lines changed: 728 additions & 31 deletions
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
"@objectstack/cli": minor
3+
---
4+
5+
feat(cli): `os migrate organization-ownership --apply` certifies a database whose packages declare sharing rules — the declared sharing-rule mirrors this release's boot still seeds are reported as advisory, not as discrepancies
6+
7+
Clause-②: yes (widening — the apply report file, the plan file and both `--json` payloads gain an `advisory` field on a departure entry, and the run's `advisory` count includes that departure's rows)
8+
9+
- **What an operator saw.** On a v17 database whose packages declare sharing rules (HotCRM declares eleven), `--apply` ended with `complete: true` and `blocking: 11`, and never certified: the `adr-0131-organization-ownership` row of `sys_migration` kept `verified_at` null. The post-check waited for the `declared-rule-mirrors` departure on `sys_sharing_rule` to leave. That departure is the uncustomized `managed_by` `package` / `platform` rows with no organization. This release's own served boot writes those rows on every start, including the first v18 boot that the organization-ownership refusal stops, so they could never leave on this release.
10+
- **What it sees now.** `--apply` reports `blocking: 0` and certifies: `verified_at` is set, with `blocking` 0 in the marker every reader checks. The mirror rows are named, counted and kept:
11+
- the plan and the apply print a warning before the first write: `sys_sharing_rule holds N organization-less row(s) of … which stay: …`. The plan file's and the report's `warnings` carry it too;
12+
- in the plan file and in the apply report (`--json` and the `--out` file), the `declared-rule-mirrors` entry of `tables[].departures[]` keeps `id`, `label`, `as` and `rows`, and gains `advisory`: why the rows stay, and what removes them. A departure without `advisory` still blocks;
13+
- `tables[].remaining` no longer counts these rows. The run's `advisory` and the ledger's `advisory` count them, beside the unattributable rows;
14+
- the rows keep `organization_id` NULL, so `sys_sharing_rule`'s `notNull` reads `willReceive: false` with `N row(s) stay NULL on this release, reported as advisory: …`. The plan file used to read `willReceive: true` for that table;
15+
- the human summary prints `N row(s) advisory, not held against certification — …` under the table. Its closing warnings name the unattributable rows and the advisory rows separately;
16+
- nothing is deleted. Every rule row stays as it was.
17+
- **What still blocks.** Every other departure: `global-rung-move`, `template-promotion`, and a host fate supplement's own departures, even one named `declared-rule-mirrors`. Every other remaining row blocks too.
18+
- **Already ran `--apply` and stopped at `blocking: 11`?** Plan again on this release, review the plan, and run `--apply` with it. This change leaves the inventory digest as it was, so a checkpoint recorded under this release's inventory resumes: only the post-check runs, and it certifies. A checkpoint recorded by a release whose inventory differed is superseded: every step runs again, each one idempotent, and the post-check certifies.
19+
- **When blocking returns.** The release that retires the declared sharing-rule seeder (ADR-0131 D2) sets `DECLARED_SHARING_RULE_SEEDER_RUNS` to `false` in the same change. From that release on, these rows hold certification again until its ceremony deletes them.
Lines changed: 238 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,238 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* `os migrate organization-ownership --apply` on a v17-shaped database whose
5+
* packages declare sharing rules, on a release whose served boot still seeds
6+
* them — through the command, over a SQLite file.
7+
*
8+
* ## The database
9+
*
10+
* Built the way an environment's is. First a served boot of THIS release
11+
* (`@objectstack/verify`'s `bootStack`: `os serve`'s composition, plugin starts
12+
* included) of a package declaring one sharing rule, over the file. Then what
13+
* a 17.x runtime left beside it: ten more declared rules its seeder wrote
14+
* organization-less, an organization's own rule, a customized declared rule
15+
* with no organization, and a column-drop table still carrying
16+
* `organization_id`. That is HotCRM's measured shape: eleven declared rules,
17+
* one of them seeded by the first v18 boot, before any ceremony ran.
18+
*
19+
* ## The cases
20+
*
21+
* 1. The served boot writes the declared rule as exactly the row the
22+
* `declared-rule-mirrors` departure names (`managed_by 'package'`,
23+
* uncustomized, no organization), and that is what
24+
* `DECLARED_SHARING_RULE_SEEDER_RUNS` states. ⛔ When the seeder retires
25+
* this case fails until the constant flips — in the same change.
26+
* 2. Plan, then `--apply --json`, development boots like the boot that built
27+
* the file: `blocking 0`, the eleven rows advisory and named, the
28+
* `sys_migration` marker verified with `blocking 0`, every rule row kept.
29+
* Then a served boot of the certified file with the v18 boot guard
30+
* composed (`os serve`'s refusal) starts — with a table put back in the
31+
* v17 shape, so the guard decides on the marker and not on columns — and
32+
* `os migrate apply`'s drop gate reads the marker as verified.
33+
*
34+
* The same verdict without a boot — the restore once the seeder retires, and a
35+
* host departure of the same id that still blocks — is
36+
* `utils/organization-ownership-apply.declared-rule-mirrors.test.ts`.
37+
*/
38+
39+
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
40+
import Database from 'better-sqlite3';
41+
import { mkdtempSync, rmSync } from 'node:fs';
42+
import { tmpdir } from 'node:os';
43+
import { join } from 'node:path';
44+
import { fileURLToPath } from 'node:url';
45+
import { ORGANIZATION_OWNERSHIP_MIGRATION_ID } from '@objectstack/spec/system';
46+
import { bootStack } from '@objectstack/verify';
47+
import { findV17ShapeTables, OrganizationOwnershipBootGuardPlugin } from '@objectstack/runtime';
48+
import { isExitSignal } from '../../utils/format.js';
49+
import { readOrganizationOwnershipMarker } from '../../utils/organization-ownership-drop-gate.js';
50+
import { DECLARED_SHARING_RULE_SEEDER_RUNS } from '../../utils/organization-ownership-inventory.js';
51+
52+
// Pay the first transform of the dist-resolved workspace deps the boots reach
53+
// through dynamic `import()`s at MODULE LOAD, not inside a clocked case.
54+
import '@objectstack/objectql';
55+
import '@objectstack/metadata-protocol';
56+
57+
type Command = (typeof import('./organization-ownership.js'))['default'];
58+
let MigrateOrganizationOwnership: Command;
59+
60+
const CLI_ROOT = fileURLToPath(new URL('../../..', import.meta.url));
61+
const CASE_TIMEOUT_MS = 120_000;
62+
/** The rows a 17.x seeder left, beside the one this release's served boot writes. */
63+
const V17_MIRRORS = 10;
64+
const MIRRORS = V17_MIRRORS + 1;
65+
66+
/** Env that would point a boot somewhere other than the fixture, or change what it composes. */
67+
const OVERRIDING_ENV = [
68+
'OS_DATABASE_URL', 'DATABASE_URL', 'TURSO_DATABASE_URL', 'TURSO_AUTH_TOKEN', 'OS_DATABASE_AUTH_TOKEN',
69+
'OS_DATABASE_DRIVER', 'OS_HOME', 'OS_ARTIFACT_PATH', 'OS_TELEMETRY_DB', 'OS_TENANCY_POSTURE',
70+
'OS_MULTI_ORG_ENABLED', 'OS_SKIP_SCHEMA_SYNC', 'NODE_ENV',
71+
] as const;
72+
73+
/** A package declaring one sharing rule; built anew per boot, as `bootStack` asks. */
74+
const stackConfig = () => ({
75+
manifest: { id: 'com.test.declared_rule_mirrors', name: 'Declared rule mirrors', type: 'app', version: '1.0.0' },
76+
objects: [
77+
{
78+
name: 'crm_deal',
79+
label: 'Deal',
80+
fields: { name: { type: 'text', label: 'Name' }, status: { type: 'text', label: 'Status' } },
81+
},
82+
],
83+
sharingRules: [
84+
{
85+
name: 'share_open_deals',
86+
type: 'criteria',
87+
object: 'crm_deal',
88+
accessLevel: 'read',
89+
condition: 'record.status == "open"',
90+
sharedWith: { type: 'position', value: 'sales_rep' },
91+
},
92+
],
93+
});
94+
95+
let dir: string;
96+
let file: string;
97+
/** What the served boot left in `sys_sharing_rule`, read before the 17.x leftovers join it. */
98+
let seeded: Record<string, unknown>[];
99+
const savedEnv: Record<string, string | undefined> = {};
100+
const savedCwd = process.cwd();
101+
102+
/** One statement against the file, through a connection of its own. */
103+
function sql(statement: string, ...params: unknown[]): Record<string, unknown>[] {
104+
const db = new Database(file);
105+
try {
106+
const prepared = db.prepare(statement);
107+
return prepared.reader ? (prepared.all(...params) as Record<string, unknown>[]) : (prepared.run(...params), []);
108+
} finally {
109+
db.close();
110+
}
111+
}
112+
113+
/** The real command, its stdout parsed, and the exit it asked for. */
114+
async function run(args: string[]): Promise<{ payload: any; exitCode: number }> {
115+
const savedExit = process.exitCode;
116+
const swallow = ((_chunk: unknown, ...rest: unknown[]) => {
117+
const cb = rest.find((a) => typeof a === 'function') as (() => void) | undefined;
118+
if (cb) cb();
119+
return true;
120+
}) as typeof process.stdout.write;
121+
const stdout = vi.spyOn(process.stdout, 'write').mockImplementation(swallow);
122+
vi.spyOn(process.stderr, 'write').mockImplementation(swallow);
123+
vi.spyOn(console, 'log').mockImplementation(() => {});
124+
vi.spyOn(console, 'warn').mockImplementation(() => {});
125+
vi.spyOn(console, 'error').mockImplementation(() => {});
126+
process.chdir(dir);
127+
let exitCode: number | undefined;
128+
try {
129+
try {
130+
await MigrateOrganizationOwnership.run(args, { root: CLI_ROOT });
131+
} catch (error) {
132+
if (!isExitSignal(error)) throw error;
133+
exitCode = (error as { oclif?: { exit?: number } }).oclif?.exit;
134+
}
135+
const payload = JSON.parse(stdout.mock.calls.map((c) => String(c[0])).join('').trim());
136+
return { payload, exitCode: exitCode ?? (process.exitCode as number | undefined) ?? 0 };
137+
} finally {
138+
process.chdir(savedCwd);
139+
process.exitCode = savedExit;
140+
vi.restoreAllMocks();
141+
}
142+
}
143+
144+
describe('os migrate organization-ownership on a database whose packages declare sharing rules', () => {
145+
beforeAll(async () => {
146+
for (const key of OVERRIDING_ENV) {
147+
savedEnv[key] = process.env[key];
148+
delete process.env[key];
149+
}
150+
({ default: MigrateOrganizationOwnership } = await import('./organization-ownership.js'));
151+
dir = mkdtempSync(join(tmpdir(), 'os-ceremony-declared-rules-'));
152+
file = join(dir, 'fixture.db');
153+
154+
// The served boot of this release, over the file.
155+
const stack = await bootStack(stackConfig(), { databaseFile: file });
156+
await stack.stop();
157+
seeded = sql('SELECT name, managed_by, customized, organization_id FROM sys_sharing_rule ORDER BY name');
158+
159+
// What a 17.x runtime left beside it.
160+
const [{ id: organization }] = sql(`SELECT id FROM sys_organization WHERE slug = 'default'`) as Array<{ id: string }>;
161+
const rule = (id: string, name: string, managedBy: string, customized: number, organizationId: string | null) =>
162+
sql(
163+
'INSERT INTO sys_sharing_rule (id, organization_id, name, label, object_name, criteria_json, recipient_type, ' +
164+
`recipient_id, access_level, active, managed_by, customized) VALUES (?, ?, ?, ?, 'crm_deal', ?, 'position', ` +
165+
`'sales_rep', 'read', 1, ?, ?)`,
166+
id, organizationId, name, name, '{"status":"open"}', managedBy, customized,
167+
);
168+
for (let i = 1; i <= V17_MIRRORS; i++) rule(`rule_v17_${i}`, `share_v17_${i}`, 'package', 0, null);
169+
rule('rule_organization', 'organization_rule', 'admin', 0, organization!);
170+
rule('rule_customized', 'customized_rule', 'package', 1, null);
171+
sql('ALTER TABLE sys_job ADD COLUMN organization_id varchar(255)');
172+
sql(`INSERT INTO sys_job (id, name, organization_id) VALUES ('job_v17', 'nightly', NULL)`);
173+
}, CASE_TIMEOUT_MS);
174+
175+
afterAll(() => {
176+
for (const key of OVERRIDING_ENV) {
177+
if (savedEnv[key] === undefined) delete process.env[key];
178+
else process.env[key] = savedEnv[key];
179+
}
180+
if (dir) rmSync(dir, { recursive: true, force: true });
181+
});
182+
183+
it('a served boot of this release writes the declared rule as the row the departure names, as DECLARED_SHARING_RULE_SEEDER_RUNS states', () => {
184+
expect(
185+
seeded,
186+
`DECLARED_SHARING_RULE_SEEDER_RUNS (utils/organization-ownership-inventory.ts) is ${DECLARED_SHARING_RULE_SEEDER_RUNS}, and ` +
187+
`a served boot of this release wrote ${seeded.length} declared sharing rule row(s): set the constant to what the ` +
188+
'boot does, in the change that changed the boot',
189+
).toEqual(
190+
DECLARED_SHARING_RULE_SEEDER_RUNS
191+
? [{ name: 'share_open_deals', managed_by: 'package', customized: 0, organization_id: null }]
192+
: [],
193+
);
194+
});
195+
196+
it('--apply certifies: blocking 0, the eleven rows advisory, the marker verified; then the served boot with the v18 guard starts', async () => {
197+
const planFile = join(dir, 'plan.json');
198+
const plan = await run(['--dev', '--database-url', `file:${file}`, '--json', '--out', planFile]);
199+
expect(plan.payload.error, JSON.stringify(plan.payload).slice(0, 800)).toBeUndefined();
200+
expect(plan.exitCode).toBe(0);
201+
// Said before the first write, and carried by the plan file.
202+
expect(plan.payload.plan.warnings).toContainEqual(expect.stringMatching(new RegExp(`^sys_sharing_rule holds ${MIRRORS} organization-less row\\(s\\) of `)));
203+
204+
const apply = await run([
205+
'--dev', '--apply', '--backup-taken', '--plan-file', planFile, '--database-url', `file:${file}`, '--json',
206+
'--out', join(dir, 'apply.json'),
207+
]);
208+
expect(apply.payload.error, JSON.stringify(apply.payload).slice(0, 800)).toBeUndefined();
209+
const { report } = apply.payload;
210+
const unattributable = report.tables.reduce((n: number, t: { unattributable: unknown[] }) => n + t.unattributable.length, 0);
211+
expect([apply.exitCode, report.complete, report.blocking, report.advisory]).toEqual([0, true, 0, unattributable + MIRRORS]);
212+
expect(report.tables.find((t: { object: string }) => t.object === 'sys_sharing_rule')).toMatchObject({
213+
remaining: 0,
214+
departures: [{ id: 'declared-rule-mirrors', as: 'mirror-deletion', rows: MIRRORS, advisory: expect.stringMatching(/own boot still writes them/) }],
215+
notNull: { willReceive: false, reason: expect.stringMatching(new RegExp(`^${MIRRORS} row\\(s\\) stay NULL on this release, reported as advisory: `)) },
216+
});
217+
expect(
218+
sql('SELECT verified_at IS NOT NULL AS verified, applied_at IS NOT NULL AS applied, blocking, advisory FROM sys_migration WHERE id = ?', ORGANIZATION_OWNERSHIP_MIGRATION_ID),
219+
).toEqual([{ verified: 1, applied: 1, blocking: 0, advisory: unattributable + MIRRORS }]);
220+
// Nothing of the table was deleted; the eleven kept their NULL.
221+
expect(sql('SELECT COUNT(*) AS n, SUM(organization_id IS NULL) AS nulls FROM sys_sharing_rule')).toEqual([{ n: MIRRORS + 2, nulls: MIRRORS }]);
222+
223+
// A table back in the v17 shape, so the guard decides on the marker.
224+
sql('ALTER TABLE sys_job ADD COLUMN organization_id varchar(255)');
225+
const served = await bootStack(stackConfig(), {
226+
databaseFile: file,
227+
extraPlugins: [new OrganizationOwnershipBootGuardPlugin({ dev: true })],
228+
});
229+
try {
230+
const engine = served.kernel.getService('objectql') as Parameters<typeof findV17ShapeTables>[0] &
231+
Parameters<typeof readOrganizationOwnershipMarker>[0];
232+
expect(await findV17ShapeTables(engine)).toEqual(['sys_job']);
233+
expect((await readOrganizationOwnershipMarker(engine)).verified).toBe(true);
234+
} finally {
235+
await served.stop();
236+
}
237+
}, CASE_TIMEOUT_MS);
238+
});

‎packages/cli/src/commands/migrate/organization-ownership.remote-face.integration.test.ts‎

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,9 @@
2727
* - the plan exits 0 and leaves the database byte-identical (a logical dump of
2828
* every catalog row and every table's rows, before and after), and names the
2929
* remote database, not the placeholder Knex client;
30-
* - the apply completes with `blocking 0`;
30+
* - the apply completes with `blocking 0`, the declared sharing-rule mirrors a
31+
* 17.x seeder left reported as advisory (this release's boot still seeds
32+
* them: `DECLARED_SHARING_RULE_SEEDER_RUNS`), not as discrepancies;
3133
* - each column and the index keyed on it are gone from the server, the rows
3234
* are not, and the `adr-0131-organization-ownership` marker is verified.
3335
*
@@ -77,6 +79,8 @@ const OVERRIDING_ENV = [
7779

7880
/** `column-drop` tables the 17.x leftovers are put on, when the composition has them. */
7981
const LEFTOVER_CANDIDATES = ['sys_job', 'sys_metadata_activation', 'sys_presence'] as const;
82+
/** Declared sharing rules a 17.x seeder wrote organization-less (HotCRM declares eleven). */
83+
const RULE_MIRRORS = 11;
8084

8185
let dir: string;
8286
let projectDir: string;
@@ -204,6 +208,15 @@ describe.skipIf(!SQLD)('os migrate organization-ownership on a Turso REMOTE data
204208
if (leftovers.includes('sys_job')) {
205209
await exec(`INSERT INTO "sys_job" ("id", "organization_id") VALUES ('job_v17_a', NULL), ('job_v17_b', NULL)`);
206210
}
211+
if (tables.has('sys_sharing_rule')) {
212+
for (let i = 1; i <= RULE_MIRRORS; i++) {
213+
await exec(
214+
`INSERT INTO "sys_sharing_rule" ("id", "name", "object_name", "managed_by", "customized", "organization_id") ` +
215+
`VALUES (?, ?, 'crm_deal', 'package', 0, NULL)`,
216+
[`rule_v17_${i}`, `share_v17_${i}`],
217+
);
218+
}
219+
}
207220
});
208221
expect(leftovers.length).toBeGreaterThan(0);
209222
}, CASE_TIMEOUT_MS);
@@ -233,6 +246,10 @@ describe.skipIf(!SQLD)('os migrate organization-ownership on a Turso REMOTE data
233246
]);
234247
expect(apply.payload.error, JSON.stringify(apply.payload).slice(0, 800)).toBeUndefined();
235248
expect([apply.exitCode, apply.payload.report.complete, apply.payload.report.blocking]).toEqual([0, true, 0]);
249+
expect(apply.payload.report.tables.find((t: { object: string }) => t.object === 'sys_sharing_rule')).toMatchObject({
250+
remaining: 0,
251+
departures: [{ id: 'declared-rule-mirrors', rows: RULE_MIRRORS, advisory: expect.any(String) }],
252+
});
236253

237254
for (const t of leftovers) expect(await hasOrganizationColumn(t)).toBe(false);
238255
const after = await secondConnection(async (exec) => ({
@@ -244,7 +261,8 @@ describe.skipIf(!SQLD)('os migrate organization-ownership on a Turso REMOTE data
244261
[ORGANIZATION_OWNERSHIP_MIGRATION_ID],
245262
)).map((r) => ({ applied: Number(r.applied), verified: Number(r.verified) })),
246263
jobs: leftovers.includes('sys_job') ? Number((await exec('SELECT count(*) AS n FROM "sys_job"'))[0]!.n) : 2,
264+
rules: Number((await exec('SELECT count(*) AS n FROM "sys_sharing_rule" WHERE "organization_id" IS NULL'))[0]!.n),
247265
}));
248-
expect(after).toEqual({ indexes: [], marker: [{ applied: 1, verified: 1 }], jobs: 2 });
266+
expect(after).toEqual({ indexes: [], marker: [{ applied: 1, verified: 1 }], jobs: 2, rules: RULE_MIRRORS });
249267
}, CASE_TIMEOUT_MS);
250268
});

0 commit comments

Comments
 (0)