Skip to content

Commit 0f452e8

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-24614-system-run-user-grants
2 parents 678acbb + f89d29e commit 0f452e8

16 files changed

Lines changed: 883 additions & 75 deletions

‎.changeset/24536-cel-env-reuse.md‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@objectstack/formula': patch
3+
---
4+
5+
perf(formula): a CEL evaluation reuses one environment instead of building the stdlib on every call, and compiles reuse the canonical parse environment
6+
7+
Clause-②: no (an internal cache: no accept set moves, no evaluation result moves, no exported signature changes; owes the seat's review of record before the queue)
8+
9+
- **`celEngine.evaluate` reuses one environment.** It used to build a new cel-js `Environment` and register the whole stdlib into it on every call, which objectql's formula fields reach once per row per field. The stdlib now reads `now()`, the timezone and the permission binding of `current_user.can` / `current_user.hasCapability` from a per-evaluation slot. `evaluate` saves the slot, sets it and restores it in a `finally`, so a nested or a thrown evaluation leaves the outer binding as it was. Evaluations also run a bounded per-source cache of parsed programs, keyed on the source after its row-dependent rewrites.
10+
- **`celEngine.compile` reuses the canonical parse environment** that `parseCelToAst` already shares.
11+
- Results, error kinds and error messages are unchanged. `registerStdLib`, `registerPermissionPredicate` and `registerCapabilityPredicate` keep their signatures and their behaviour.
12+
- Measured on one source evaluated 20,000 times: about 142 µs per evaluation before, about 3 µs after. A 25-row read of an object with three formula fields built 78 environments before and builds none after.
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@objectstack/cli': patch
3+
---
4+
5+
fix(cli): `os migrate apply --allow-destructive`'s organization-ownership refusal says what 18.0 supports for a v17 database, and names no step of the command 18.0 refuses
6+
7+
Clause-②: no
8+
9+
- **`os migrate apply --allow-destructive` on a v17-shaped database.** When the run would drop `organization_id` from a table the organization-ownership ceremony owns, and the ceremony has not verified on the database, the refusal now says that 18.0 supports fresh installs only and does not upgrade a v17 database: keep a v17 database on a 17.x release until the 18.x release that carries the upgrade path, whose ceremony drops these columns itself. The v18 boot refusal and `os migrate organization-ownership`'s own refusal say the same. Before, it told the operator to run `os migrate organization-ownership --out PLAN` and then `--apply --plan-file PLAN`, which 18.0 refuses on every database (`NOT_IMPLEMENTED`). The `--json` refusal's `detail` no longer names that command either: it reads "No run of the organization-ownership ceremony is recorded on this database.", or names the last unverified run with its date and blocking count.
10+
- **`os migrate organization-ownership --help`** shows what the command answers in 18.0: exit `1` with `NOT_IMPLEMENTED`, nothing read or written, and the same refusal as JSON. Before, it showed six plan and apply examples that 18.0 refuses.
11+
- **`os dev` on a v17-shaped development database.** When it does not upgrade the database in place (the ceremony's plan is refused, or the database is not a local SQLite file), it now ends with the same position: keep a v17 development database on a 17.x release until the 18.x release that carries the upgrade path, and upgrade it there. Before, it told the developer to run `os migrate organization-ownership --dev` and then the same command with `--apply`.
12+
13+
Nothing else changes. The refusals still hold back the whole run, and their message code (`refused_organization_ownership_unmigrated`), `tables`, `marker`, exit code `1` and every flag are as before.

‎packages/cli/src/commands/migrate/apply-organization-ownership-gate.integration.test.ts‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,9 @@ function caseDb(columns: readonly string[]): string {
104104

105105
const dropArgv = (file: string) => ['--database-url', `file:${file}`, '--allow-destructive', '--yes', '--json'];
106106

107+
/** A step of `os migrate organization-ownership`, which 18.0 refuses on every database. */
108+
const WITHDRAWN_COMMAND = /os migrate organization-ownership|--plan-file|organization-ownership --out/;
109+
107110
beforeAll(async () => {
108111
for (const key of [...OVERRIDING_ENV, 'NODE_ENV'] as const) savedEnv[key] = process.env[key];
109112
for (const key of OVERRIDING_ENV) delete process.env[key];
@@ -151,7 +154,9 @@ describe('os migrate apply --allow-destructive waits for the organization-owners
151154
created: [],
152155
marker: null,
153156
});
154-
expect(payload.detail).toMatch(/os migrate organization-ownership --apply/);
157+
expect(payload.detail).toMatch(/organization-ownership ceremony/);
158+
// 18.0 refuses `os migrate organization-ownership` on every database: the refusal names no step of it.
159+
expect(payload.detail).not.toMatch(WITHDRAWN_COMMAND);
155160
expect(columnsOf(file, 'sys_platform_setting')).toContain('organization_id');
156161
}, CASE_TIMEOUT_MS);
157162

@@ -164,6 +169,7 @@ describe('os migrate apply --allow-destructive waits for the organization-owners
164169
expect(exitCode).toBe(1);
165170
expect(payload).toMatchObject({ message: 'refused_organization_ownership_unmigrated', tables: ['sys_platform_setting'] });
166171
expect(payload.detail).toMatch(/is not verified \(2 blocking/);
172+
expect(payload.detail).not.toMatch(WITHDRAWN_COMMAND);
167173
expect(columnsOf(file, 'sys_platform_setting')).toContain('organization_id');
168174
}, CASE_TIMEOUT_MS);
169175

Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [ADR-0131 D10 item 5] What `os migrate apply --allow-destructive` tells the
5+
* operator when it holds back an `organization_id` drop the organization-ownership
6+
* ceremony owns, in 18.0.
7+
*
8+
* 18.0 supports fresh installs only, and its registered
9+
* `os migrate organization-ownership` refuses on every database, plan and apply
10+
* alike (`organization-ownership.release-refusal.test.ts`). So the drop gate's
11+
* refusal, the human face and the `--json` `detail` both, names no step of that
12+
* command: it says what 18.0 supports for a v17 database, the 17.x release to
13+
* keep it on and the 18.x release that carries the upgrade path, as the boot
14+
* refusal does (`organizationOwnershipRefusalMessage`, `@objectstack/runtime`).
15+
*
16+
* ## What is real here
17+
*
18+
* The command's own parse, its drift handling and the gate run for real. The
19+
* boot, the occupancy probe, the process exit and the ceremony's marker read
20+
* are replaced: the subject is the text the gate hands the operator, for each
21+
* marker state it can read, and a control proves the harness reaches the
22+
* writes once the marker is verified. The real command on a v17-shape SQLite
23+
* table is `apply-organization-ownership-gate.integration.test.ts`.
24+
*/
25+
26+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
27+
import { dirname, resolve } from 'node:path';
28+
import { fileURLToPath } from 'node:url';
29+
import MigrateApply from './apply.js';
30+
import { isExitSignal } from '../../utils/format.js';
31+
32+
// ⛔ `vi.hoisted`, never a value import of `bootSchemaStack`: this suite boots
33+
// nothing, and a value import of that symbol files a test in the integration
34+
// tier (`vitest-tiers.ts`).
35+
const mocks = vi.hoisted(() => ({
36+
bootSchemaStack: vi.fn(),
37+
exitOneShotCommand: vi.fn(async (_code: number) => {}),
38+
readOrganizationOwnershipMarker: vi.fn(),
39+
}));
40+
41+
vi.mock('../../utils/schema-migrate.js', async (importOriginal) => ({
42+
...(await importOriginal<Record<string, unknown>>()),
43+
bootSchemaStack: mocks.bootSchemaStack,
44+
}));
45+
vi.mock('../../utils/migrate-occupancy-gate.js', () => ({
46+
OCCUPANCY_HINT: 'occupancy hint',
47+
probeMigrationTarget: vi.fn(async () => ({ status: 'free' })),
48+
}));
49+
vi.mock('../../utils/one-shot-exit.js', () => ({ exitOneShotCommand: mocks.exitOneShotCommand }));
50+
vi.mock('../../utils/organization-ownership-drop-gate.js', async (importOriginal) => ({
51+
...(await importOriginal<Record<string, unknown>>()),
52+
readOrganizationOwnershipMarker: mocks.readOrganizationOwnershipMarker,
53+
}));
54+
55+
const HERE = dirname(fileURLToPath(import.meta.url));
56+
const CLI_ROOT = resolve(HERE, '..', '..', '..');
57+
/** oclif builds its whole command table on the first `run()` in a process. */
58+
const RUN_TIMEOUT = 60_000;
59+
60+
/**
61+
* The drift a v17-shape table offers: a physical `organization_id` on a table
62+
* whose v18 object declares none.
63+
*/
64+
const ORGANIZATION_DROP = {
65+
category: 'destructive',
66+
table: 'sys_platform_setting',
67+
column: 'organization_id',
68+
op: { type: 'drop_column', column: 'organization_id' },
69+
description: 'sys_platform_setting.organization_id: column exists in the database but not in metadata (orphaned)',
70+
};
71+
72+
/** A step of `os migrate organization-ownership`, which 18.0 refuses on every database. */
73+
const WITHDRAWN_COMMAND = /os migrate organization-ownership|--plan-file|organization-ownership --out/;
74+
75+
const UNVERIFIED = { id: 'adr-0131-organization-ownership', last_run_at: '2026-10-01T00:00:00.000Z', verified_at: null, blocking: 2 };
76+
const VERIFIED = { ...UNVERIFIED, verified_at: '2026-10-01T00:00:00.000Z', blocking: 0 };
77+
78+
let flushSchemaDdl: ReturnType<typeof vi.fn>;
79+
let applyMigrationEntries: ReturnType<typeof vi.fn>;
80+
81+
function bootWithOrganizationDrop(): void {
82+
flushSchemaDdl = vi.fn(async () => []);
83+
applyMigrationEntries = vi.fn(async () => ({ applied: [ORGANIZATION_DROP], skipped: [] }));
84+
mocks.bootSchemaStack.mockResolvedValue({
85+
driver: {},
86+
drivers: [],
87+
dbLabel: 'file:v17.db',
88+
dbSource: { kind: 'flag' },
89+
telemetryDatabase: null,
90+
composition: { plugins: [], hostConfigPath: null, hostConfigLoaded: false, hostConfigError: null, notes: [] },
91+
detectManagedDrift: async () => [ORGANIZATION_DROP],
92+
driftApplyLimit: () => null,
93+
pendingSchemaWork: [],
94+
kernel: { getService: () => ({}) },
95+
flushSchemaDdl,
96+
applyMigrationEntries,
97+
shutdown: vi.fn(async () => {}),
98+
});
99+
}
100+
101+
let stdout: ReturnType<typeof vi.spyOn>;
102+
let log: ReturnType<typeof vi.spyOn>;
103+
let stderr: ReturnType<typeof vi.spyOn>;
104+
let savedExitCode: typeof process.exitCode;
105+
beforeEach(() => {
106+
savedExitCode = process.exitCode;
107+
process.exitCode = undefined;
108+
mocks.bootSchemaStack.mockReset();
109+
mocks.exitOneShotCommand.mockClear();
110+
mocks.readOrganizationOwnershipMarker.mockReset();
111+
// `emitJson` awaits the write's DRAIN callback, so the double must invoke it.
112+
stdout = vi.spyOn(process.stdout, 'write').mockImplementation(((chunk: unknown, enc?: unknown, cb?: unknown) => {
113+
const done = typeof enc === 'function' ? enc : cb;
114+
if (typeof done === 'function') done();
115+
return true;
116+
}) as typeof process.stdout.write);
117+
log = vi.spyOn(console, 'log').mockImplementation(() => {});
118+
stderr = vi.spyOn(console, 'error').mockImplementation(() => {});
119+
});
120+
afterEach(() => {
121+
stdout.mockRestore();
122+
log.mockRestore();
123+
stderr.mockRestore();
124+
process.exitCode = savedExitCode;
125+
});
126+
127+
/**
128+
* Run the command; answer the status it ends the process with, the JSON
129+
* document it emitted, and the refusal line the human face printed.
130+
*/
131+
async function runApply(argv: string[]) {
132+
const err = await MigrateApply.run(argv, { root: CLI_ROOT }).then(() => null, (e: unknown) => e);
133+
if (err !== null && !isExitSignal(err)) throw err;
134+
const exit = err !== null
135+
? (err as { oclif?: { exit?: number } }).oclif?.exit
136+
: mocks.exitOneShotCommand.mock.calls.at(-1)?.[0];
137+
const out = stdout.mock.calls.map((c: unknown[]) => String(c[0])).join('').trim();
138+
const refusal = log.mock.calls.map((c: unknown[]) => c.map(String).join(' ')).find((line: string) => line.includes('Refusing this --allow-destructive run'));
139+
return { exit, payload: out ? JSON.parse(out) : null, refusal };
140+
}
141+
142+
const ARGV = ['--database-url', 'file:v17.db', '--allow-destructive', '--yes'];
143+
144+
describe('os migrate apply --allow-destructive: the organization-ownership refusal names no command 18.0 refuses', () => {
145+
for (const [state, flag] of [['no ceremony run recorded', null], ['an unverified run recorded', UNVERIFIED]] as const) {
146+
it(`${state}: the human face refuses, exit 1, saying what 18.0 supports, and nothing is written`, async () => {
147+
bootWithOrganizationDrop();
148+
mocks.readOrganizationOwnershipMarker.mockResolvedValue({ verified: false, flag });
149+
150+
const { exit, refusal } = await runApply(ARGV);
151+
152+
expect(exit).toBe(1);
153+
expect(refusal, 'the gate printed its refusal').toBeDefined();
154+
expect(refusal).toContain('sys_platform_setting');
155+
expect(refusal).not.toMatch(WITHDRAWN_COMMAND);
156+
expect(refusal).toContain('17.x release');
157+
expect(refusal).toContain('18.x release');
158+
if (flag) expect(refusal).toContain('2 blocking');
159+
expect(flushSchemaDdl).not.toHaveBeenCalled();
160+
expect(applyMigrationEntries).not.toHaveBeenCalled();
161+
}, RUN_TIMEOUT);
162+
163+
it(`${state}: under --json the refusal's detail names no command 18.0 refuses either`, async () => {
164+
bootWithOrganizationDrop();
165+
mocks.readOrganizationOwnershipMarker.mockResolvedValue({ verified: false, flag });
166+
167+
const { exit, payload } = await runApply([...ARGV, '--json']);
168+
169+
expect(exit).toBe(1);
170+
expect(payload).toMatchObject({
171+
message: 'refused_organization_ownership_unmigrated',
172+
tables: ['sys_platform_setting'],
173+
applied: [],
174+
created: [],
175+
marker: flag,
176+
});
177+
expect(payload.detail).not.toMatch(WITHDRAWN_COMMAND);
178+
expect(applyMigrationEntries).not.toHaveBeenCalled();
179+
}, RUN_TIMEOUT);
180+
}
181+
});
182+
183+
describe('control', () => {
184+
it('a verified marker lets the drop through and exits 0, so the harness can tell a refusal from a run', async () => {
185+
bootWithOrganizationDrop();
186+
mocks.readOrganizationOwnershipMarker.mockResolvedValue({ verified: true, flag: VERIFIED });
187+
188+
const { exit, payload } = await runApply([...ARGV, '--json']);
189+
190+
expect(exit).toBe(0);
191+
expect(applyMigrationEntries).toHaveBeenCalledTimes(1);
192+
expect(payload.applied).toEqual([ORGANIZATION_DROP]);
193+
}, RUN_TIMEOUT);
194+
});

‎packages/cli/src/commands/migrate/apply.ts‎

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -571,10 +571,19 @@ export default class MigrateApply extends Command {
571571
);
572572
const marker = await readOrganizationOwnershipMarker(engine as never);
573573
if (!marker.verified) {
574+
// 18.0 supports fresh installs only, and its `os migrate
575+
// organization-ownership` refuses on every database, plan and apply
576+
// alike (`ORGANIZATION_OWNERSHIP_UPGRADE_UNAVAILABLE`, ruling record
577+
// 6259608309 on the v18 epic, item 1). So neither the `detail` nor the
578+
// refusal names a step of that command: they say what 18.0 supports
579+
// for a v17 database, in the boot refusal's terms
580+
// (`organizationOwnershipRefusalMessage`, `@objectstack/runtime`).
581+
// The 18.x release that registers the ceremony names its steps here
582+
// again.
574583
const detail = marker.flag
575-
? `The last run of "os migrate organization-ownership --apply --plan-file FILE" (${marker.flag.last_run_at}) is not verified `
576-
+ `(${marker.flag.blocking} blocking item(s)).`
577-
: 'No run of "os migrate organization-ownership --apply --plan-file FILE" is recorded on this database.';
584+
? `The last run of the organization-ownership ceremony recorded on this database (${marker.flag.last_run_at}) `
585+
+ `is not verified (${marker.flag.blocking} blocking item(s)).`
586+
: 'No run of the organization-ownership ceremony is recorded on this database.';
578587
if (flags.json) {
579588
await emitJson({
580589
...target, created: [], applied: [], skipped: drift, pending,
@@ -585,9 +594,9 @@ export default class MigrateApply extends Command {
585594
printError(
586595
`Refusing this --allow-destructive run: it would drop organization_id from ${organizationDrops.join(', ')}, a drop the organization-ownership ceremony `
587596
+ `owns, and the ceremony has not verified on this database. ${detail} Dropping the column first would turn `
588-
+ 'every legacy organization row into an environment row without the ceremony\'s promotion or report. Run '
589-
+ '"os migrate organization-ownership --out PLAN" to write and review its plan, then '
590-
+ '"os migrate organization-ownership --apply --plan-file PLAN"; it drops these columns itself, in its '
597+
+ 'every legacy organization row into an environment row without the ceremony\'s promotion or report. 18.0 '
598+
+ 'supports fresh installs only and does not upgrade a v17 database: keep a v17 database on a 17.x release '
599+
+ 'until the 18.x release that carries the upgrade path, whose ceremony drops these columns itself, in its '
591600
+ 'column-drops step, before it records the run verified. Nothing in this run was applied.',
592601
);
593602
this.exit(1);

‎packages/cli/src/commands/migrate/organization-ownership.release-refusal.test.ts‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,18 @@ describe('os migrate organization-ownership — refused in 18.0', () => {
143143
expect(mod.ORGANIZATION_OWNERSHIP_UPGRADE_UNAVAILABLE).toContain('an 18.x release');
144144
});
145145

146+
it('its --help examples show the refusal: none is a plan or an apply 18.0 refuses on every database', () => {
147+
const examples = mod.default.examples.map((e) => (typeof e === 'string' ? { command: e, description: '' } : e));
148+
expect(examples.length).toBeGreaterThan(0);
149+
for (const { command, description } of examples) {
150+
expect(command).toMatch(/migrate organization-ownership/);
151+
expect(command).not.toMatch(/--out|--apply|--plan-file|--fate-supplement/);
152+
expect(description).toContain('NOT_IMPLEMENTED');
153+
}
154+
// The ceremony class keeps its own examples, for the 18.x release that registers it.
155+
expect(mod.OrganizationOwnershipCeremony.examples.some((e) => String(e).includes('--apply --plan-file'))).toBe(true);
156+
});
157+
146158
it('the registered command keeps the ceremony class whole underneath: same flags, its own run', () => {
147159
expect(Object.getPrototypeOf(mod.default)).toBe(mod.OrganizationOwnershipCeremony);
148160
expect(mod.default.flags).toBe(mod.OrganizationOwnershipCeremony.flags);

‎packages/cli/src/commands/migrate/organization-ownership.ts‎

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -327,7 +327,7 @@ export class OrganizationOwnershipCeremony extends Command {
327327
'verified id-to-name rewrite, mirror deletion and column drops, in that order, ' +
328328
'resumable from a recorded checkpoint, then the post-check.';
329329

330-
static override examples = [
330+
static override examples: Command.Example[] = [
331331
'$ os migrate organization-ownership',
332332
'$ os migrate organization-ownership --out plans/v18-ownership.json',
333333
'$ os migrate organization-ownership --database-url postgres://… --json',
@@ -1125,6 +1125,25 @@ export default class MigrateOrganizationOwnership extends OrganizationOwnershipC
11251125
'Refused in 18.0, which supports fresh installs only: the v17 → v18 upgrade path ships in an 18.x release. ' +
11261126
OrganizationOwnershipCeremony.description;
11271127

1128+
/**
1129+
* The ceremony's own examples show a plan and an apply, which 18.0 refuses on
1130+
* every database, fresh or not, so `--help` shows what an invocation answers
1131+
* in this release instead. The ceremony class keeps its examples for the 18.x
1132+
* release that registers it.
1133+
*/
1134+
static override examples: Command.Example[] = [
1135+
{
1136+
description:
1137+
'Refused in 18.0, plan and apply alike: exit 1 with NOT_IMPLEMENTED, nothing read or written. Keep a v17 ' +
1138+
'database on a 17.x release until the 18.x release that carries the upgrade path',
1139+
command: '<%= config.bin %> migrate organization-ownership',
1140+
},
1141+
{
1142+
description: 'The same refusal as JSON: error, reason upgrade-unavailable, code NOT_IMPLEMENTED, and the detail',
1143+
command: '<%= config.bin %> migrate organization-ownership --json',
1144+
},
1145+
];
1146+
11281147
override async run(): Promise<void> {
11291148
const { flags } = await this.parse(MigrateOrganizationOwnership);
11301149
if (flags.json) {

0 commit comments

Comments
 (0)