Skip to content

Commit 12751bb

Browse files
committed
Merge origin/main into claude/issue-5367-readscope-500
2 parents 2826a9c + 5ab0842 commit 12751bb

40 files changed

Lines changed: 1068 additions & 327 deletions
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
---
2+
'@objectstack/cli': patch
3+
---
4+
5+
CLI: load the SQL driver's schema-work classifier lazily, so an unbuilt driver no longer breaks command discovery (#5726)
6+
7+
`packages/cli/src/utils/schema-migrate.ts` statically value-imported
8+
`isInPlaceSchemaWork` from `@objectstack/driver-sql`. oclif's `findCommand`
9+
`import()`s every command module on every CLI invocation, and nine commands
10+
reach that file (`meta:resync`, `migrate`, and seven `migrate:*`), so a
11+
workspace whose `packages/drivers/driver-sql/dist` was not built printed nine
12+
`MODULE_NOT_FOUND` blocks — naming nine commands the operator never invoked —
13+
in front of whatever command they actually ran, and dropped all nine out of the
14+
command table (`os migrate plan` answered `Command migrate:plan not found.`).
15+
16+
The import is now `await import('@objectstack/driver-sql')` at the point of use,
17+
inside the two renderers that need the classifier. The classifier keeps its one
18+
definition in the driver — it is a fact about `PendingSchemaWorkKind` and a copy
19+
in the CLI could disagree, listing a row rewrite under the heading that promises
20+
the work is never data-losing.
21+
22+
No user-visible behaviour change: this is local/worktree developer experience
23+
only, and CI always builds before running the CLI. `renderPendingSchemaWork` and
24+
`summarizePendingSchemaWork` — internal helpers, not part of the package's
25+
public entry — are now `async`.
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
---
2+
"@objectstack/objectql": patch
3+
---
4+
5+
fix(objectql): the hook layer logs through the `Logger` contract instead of a local dialect (#5637)
6+
7+
`packages/spec/src/contracts/logger.ts` declares `error(message, error?: Error,
8+
meta?)` — the `Error` slot is **second**, the meta bag **third**. Both hook
9+
modules declared their own four-method logger shape instead, and that shape
10+
spelled `error` as `(msg, meta?)`, so every call site put its diagnostic in the
11+
`Error` slot.
12+
13+
Nothing caught it. The contract's type satisfies the local shape structurally
14+
(a function of fewer parameters is assignable, and `any` is compatible both
15+
ways), so `tsc` never spoke; and the implementation the platform injects today,
16+
`ObjectLogger`, dispatches its second argument **by shape**
17+
(`errorOrMeta instanceof Error`), so the meta landed anyway. That tolerance is
18+
not something the contract declares. Its two sibling implementations —
19+
`ConsoleLogger` / `JsonLogger` in `@objectstack/observability` — follow the
20+
contract literally: the meta object lands in the `error` slot, `error.message`
21+
and `error.stack` read `undefined`, `meta` **is** `undefined`, and the whole
22+
diagnostic evaporates, leaving a bare sentence. The first host to plug a
23+
faithful structured logger into `ctx.logger` would have lost the fields of every
24+
hook diagnostic, with a symptom ("the log has fewer fields than it used to")
25+
that is close to unattributable.
26+
27+
So the dialect is gone rather than being met halfway (Prime Directive #12 — one
28+
contract, no consumer-side dialects):
29+
30+
- `WrapDeclarativeOptions.logger` and `BindHooksOptions.logger` are now
31+
`HookDiagnosticsLogger` = `Pick<Logger, 'debug' | 'info' | 'warn' | 'error'>`,
32+
taken from `@objectstack/spec/contracts` — the four levels this layer calls,
33+
and nothing more.
34+
- All four `error(...)` call sites pass the meta in the contract's third
35+
parameter (`error(msg, undefined, { … })`). The values in hand at each site
36+
are a `CelFault` (`{ kind, message }`) or a `catch` binding of type `unknown`,
37+
none of them statically an `Error`, so the `Error` slot stays empty and each
38+
message is carried in meta exactly as before.
39+
40+
`debug`/`info`/`warn` already matched the contract and are unchanged.
41+
42+
No behaviour change for hosts on `ObjectLogger` (the default, and what
43+
`ctx.logger` / `engine.logger` supply): it accepts all three shapes since #5575,
44+
so an empty `Error` slot renders the same record it rendered before. Callers
45+
passing a full `Logger` are unaffected — a `Logger` satisfies the narrowed type
46+
unchanged. A caller that hand-rolled a four-method object still satisfies it too,
47+
as long as its `error` does not *require* a meta object in the second position;
48+
if yours does, move that parameter to third — the contract's order is now the
49+
one the hook layer calls with.
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
---
2+
"@objectstack/metadata-protocol": patch
3+
---
4+
5+
refactor(metadata-protocol): 删除 `saveMetaItem` 里已不可达的 legacy raw-engine 写入分支 (#5264)
6+
7+
`saveMetaItem` 过去有两条持久化路径:repository 写入路径(追加
8+
`sys_metadata_history`、发 watch 事件、带单调 `seq`),以及其后的 legacy
9+
raw-engine 分支(直接 `engine.insert` / `engine.update``sys_metadata`,
10+
没有 history 行、没有 watch 事件、没有 `seq`,回执形如
11+
`Saved customization overlay (env-wide) — type=…`)。后者的进入条件是
12+
`isOverlayAllowed(type) || isRuntimeCreateAllowed(type)` 为假。
13+
14+
**没有行为变化 —— 这条分支在运行时已经到不了。** #5086(PR #5263)把
15+
code-only 类型的拒绝提到了同一方法更早的位置,并且不再以 `environmentId`
16+
为条件:它抛错的判据与上面那个条件恰好互为反面,读的还是同一个规范化后的
17+
类型键(`canonicalizeMetaRequestType` 在方法开头折叠单复数,两个标志读取器
18+
内部又各自折叠一次)。`OS_METADATA_WRITABLE` 也不是缺口:在那里解锁一个
19+
类型会让 `isOverlayAllowed` 为真,从而走回 repository 路径。因此凡是能走到
20+
分叉点的写入,一律走 repository 路径。
21+
22+
保留 `useRepoPath` 的代价不是多几行代码,而是它是一份 grep 得到、读起来
23+
像活代码的样板:照它推理会得出「`sys_metadata` 存在一个不写 history 的
24+
合法写入口」——现在没有了。
25+
26+
`deleteMetaItem` 里结构对称的那条 legacy 分支**一行未动**:它在
27+
control-plane kernel(`environmentId === undefined`)上删除 code-only 遗留行
28+
时仍然可达且必要(#5263 特意没有收紧删除侧,因为删除是修复动作),该分支上
29+
新增了说明它为何还活着的注释。

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

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -161,9 +161,9 @@ export default class MigrateApply extends Command {
161161
if (!flags.json) {
162162
printInfo(`Database: ${chalk.white(stack.dbLabel)}`);
163163
console.log('');
164-
renderPendingSchemaWork(pending);
164+
await renderPendingSchemaWork(pending);
165165
renderPlan(drift);
166-
if (pending.length > 0) printInfo(summarizePendingSchemaWork(pending));
166+
if (pending.length > 0) printInfo(await summarizePendingSchemaWork(pending));
167167
printInfo(summarize(drift));
168168
if (deferred.length > 0) {
169169
printWarning(`${deferred.length} destructive change(s) will be SKIPPED (re-run with --allow-destructive to include them).`);
@@ -211,7 +211,7 @@ export default class MigrateApply extends Command {
211211

212212
console.log('');
213213
if (created.length > 0) {
214-
printSuccess(`Created/extended ${created.length} table(s): ${summarizePendingSchemaWork(created)}.`);
214+
printSuccess(`Created/extended ${created.length} table(s): ${await summarizePendingSchemaWork(created)}.`);
215215
}
216216
printSuccess(`Applied ${applied.length} change(s).`);
217217
if (skipped.length > 0) {

packages/cli/src/commands/migrate/plan.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -158,9 +158,9 @@ export default class MigratePlan extends Command {
158158
return;
159159
}
160160

161-
renderPendingSchemaWork(pending);
161+
await renderPendingSchemaWork(pending);
162162
renderPlan(drift);
163-
if (pending.length > 0) printInfo(summarizePendingSchemaWork(pending));
163+
if (pending.length > 0) printInfo(await summarizePendingSchemaWork(pending));
164164
printInfo(summarize(drift));
165165
console.log(chalk.dim(' Apply with: ') + chalk.white('os migrate apply') +
166166
chalk.dim(' (add --allow-destructive for drops / tightenings)'));
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #5726 — no CLI production module may STATICALLY value-import a driver.
5+
*
6+
* The cost of one such import is not paid by the command that needs the driver.
7+
* oclif's `findCommand` walks the command table and `import()`s every command
8+
* module on **every** CLI invocation, so a broken import chain anywhere in that
9+
* table is charged to whatever command you actually ran. `schema-migrate.ts` is
10+
* shared by nine commands (`meta:resync`, `migrate`, and seven `migrate:*`), and
11+
* its one static `import { isInPlaceSchemaWork } from '@objectstack/driver-sql'`
12+
* meant that an unbuilt `packages/drivers/driver-sql/dist` made `os dev` print
13+
* nine `MODULE_NOT_FOUND` blocks (eighteen — `dev` forks a child) naming nine
14+
* commands the operator never invoked, and made all nine vanish from the command
15+
* table: `os migrate plan` answered `Command migrate:plan not found.` The real
16+
* cause was `pnpm build`, which nothing in that output said.
17+
*
18+
* These are source-level assertions on purpose. The defect lives in the shape of
19+
* the import graph, which is decided at authoring time and is invisible to any
20+
* test that merely calls the functions — every behavioural test in this package
21+
* runs in a workspace where the driver happens to be built.
22+
*
23+
* Scanning `src` rather than `dist` is the same choice: `dist` is what oclif
24+
* loads, but building it inside a unit test would be far slower than the thing
25+
* it guards, and `tsc` does not move imports between the two forms — a static
26+
* value import in `src` is a static import in `dist`, and an `await import()`
27+
* stays dynamic.
28+
*/
29+
30+
import { describe, it, expect } from 'vitest';
31+
import { readdirSync, readFileSync } from 'node:fs';
32+
import { join } from 'node:path';
33+
import { fileURLToPath } from 'node:url';
34+
35+
/** `packages/cli/src` — this file lives in `src/utils/`. */
36+
const SRC_ROOT = fileURLToPath(new URL('..', import.meta.url));
37+
38+
/** Every production `.ts` under `packages/cli/src`, as `[relativePath, source]`. */
39+
function productionSources(): Array<[string, string]> {
40+
return readdirSync(SRC_ROOT, { recursive: true, encoding: 'utf8' })
41+
.filter((rel) => rel.endsWith('.ts') && !rel.endsWith('.d.ts'))
42+
.filter((rel) => !/\.(test|spec)\.ts$/.test(rel))
43+
.map((rel) => [rel, readFileSync(join(SRC_ROOT, rel), 'utf8')] as [string, string]);
44+
}
45+
46+
/**
47+
* Static `import … from '<specifier>'` statements, with the leading `type`
48+
* keyword captured when present.
49+
*
50+
* `[^;]*?` cannot cross a statement terminator, so a multi-line import clause is
51+
* matched whole while two adjacent statements can never be spliced together.
52+
*/
53+
const STATIC_IMPORT = /^[ \t]*import[ \t]+(?:(type)[ \t]+)?([^;]*?)[ \t]*from[ \t]*['"]([^'"]+)['"]/gm;
54+
55+
/** Bare side-effect imports — `import '<specifier>';` — which also load the module. */
56+
const SIDE_EFFECT_IMPORT = /^[ \t]*import[ \t]*['"]([^'"]+)['"]/gm;
57+
58+
const DRIVER_PACKAGE = /^@objectstack\/driver-/;
59+
60+
describe('#5726 — CLI command modules must not statically value-import a driver', () => {
61+
it('has no static value import of any @objectstack/driver-* package in production sources', () => {
62+
const offenders: string[] = [];
63+
64+
for (const [rel, src] of productionSources()) {
65+
for (const m of src.matchAll(STATIC_IMPORT)) {
66+
const [, typeKeyword, clause, specifier] = m;
67+
if (!DRIVER_PACKAGE.test(specifier)) continue;
68+
// `import type { … } from` erases entirely — no runtime edge, no cost.
69+
if (typeKeyword) continue;
70+
// A clause of inline `type` specifiers still emits the module under
71+
// `verbatimModuleSyntax`. Flagged deliberately: the fix (hoisting the
72+
// keyword to `import type`) is trivial and always available, so there is
73+
// no reason to let the risky spelling through on a technicality.
74+
offenders.push(`${rel}: import ${clause.trim()} from '${specifier}'`);
75+
}
76+
for (const m of src.matchAll(SIDE_EFFECT_IMPORT)) {
77+
if (DRIVER_PACKAGE.test(m[1])) offenders.push(`${rel}: import '${m[1]}'`);
78+
}
79+
}
80+
81+
expect(
82+
offenders,
83+
'A static value import of a driver package makes EVERY command that reaches this module ' +
84+
'fail oclif command discovery when the driver is not built, and prints one MODULE_NOT_FOUND ' +
85+
'block per such command in front of whatever the operator actually ran (#5726). ' +
86+
'Use `await import(\'@objectstack/driver-…\')` at the point of use, or `import type` when ' +
87+
'only the types are needed.',
88+
).toEqual([]);
89+
});
90+
91+
it('still reaches the driver for the in-place classifier, lazily — one definition, loaded later', () => {
92+
const src = readFileSync(join(SRC_ROOT, 'utils/schema-migrate.ts'), 'utf8');
93+
94+
// The point of the fix is NOT "stop depending on driver-sql". The
95+
// additive/in-place split is a fact about `PendingSchemaWorkKind`, declared
96+
// beside that union in the driver; re-deriving it here would let the CLI
97+
// disagree with the driver the day a kind is added — by listing a row
98+
// rewrite under a heading that promises the work is never data-losing
99+
// (#3954). Pin that the dependency survives, in its dynamic form.
100+
expect(src).toMatch(/await import\((['"])@objectstack\/driver-sql\1\)/);
101+
expect(src).toContain('isInPlaceSchemaWork');
102+
});
103+
104+
it('awaits every call of the now-async pending-work renderers', () => {
105+
// `renderPendingSchemaWork` returns `Promise<void>`, so a dropped `await` is
106+
// not a type error — it is output that races the process exit. (The repo
107+
// already carries an eslint rule for exactly this shape on `formatOutput`.)
108+
const CALL = /(\bawait\s+|\bfunction\s+|\.)?\b(renderPendingSchemaWork|summarizePendingSchemaWork)\s*\(/g;
109+
const unawaited: string[] = [];
110+
111+
for (const [rel, src] of productionSources()) {
112+
for (const m of src.matchAll(CALL)) {
113+
const prefix = m[1] ?? '';
114+
if (/^function\s+$/.test(prefix)) continue; // the declaration itself
115+
if (/^await\s+$/.test(prefix)) continue;
116+
unawaited.push(`${rel}: ${m[0].trim()}`);
117+
}
118+
}
119+
120+
expect(unawaited, 'These renderers became async in #5726 — await them.').toEqual([]);
121+
});
122+
});

packages/cli/src/utils/schema-migrate.pending-render.test.ts

Lines changed: 18 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -46,13 +46,13 @@ const IN_PLACE: PendingSchemaWork[] = [
4646
];
4747

4848
describe('renderPendingSchemaWork (#3954)', () => {
49-
it('renders nothing at all when there is nothing pending', () => {
50-
renderPendingSchemaWork([]);
49+
it('renders nothing at all when there is nothing pending', async () => {
50+
await renderPendingSchemaWork([]);
5151
expect(out()).toBe('');
5252
});
5353

54-
it('keeps the additive section exactly as it was when only additive work is pending', () => {
55-
renderPendingSchemaWork(ADDITIVE);
54+
it('keeps the additive section exactly as it was when only additive work is pending', async () => {
55+
await renderPendingSchemaWork(ADDITIVE);
5656
expect(out()).toContain('New (additive — created when you apply)');
5757
expect(out()).toContain('widgets');
5858
expect(out()).toContain('[create_table, 2 column(s)]');
@@ -61,16 +61,16 @@ describe('renderPendingSchemaWork (#3954)', () => {
6161
expect(out()).not.toContain('In place');
6262
});
6363

64-
it('puts the datetime convergence under its OWN heading, not the additive one', () => {
65-
renderPendingSchemaWork(IN_PLACE);
64+
it('puts the datetime convergence under its OWN heading, not the additive one', async () => {
65+
await renderPendingSchemaWork(IN_PLACE);
6666
expect(out()).toContain('In place (existing rows converged when you apply)');
6767
// The additive heading claims the work is never data-losing; a row rewrite
6868
// must never be listed beneath it.
6969
expect(out()).not.toContain('New (additive');
7070
});
7171

72-
it('names the columns and the size of each in-place step', () => {
73-
renderPendingSchemaWork(IN_PLACE);
72+
it('names the columns and the size of each in-place step', async () => {
73+
await renderPendingSchemaWork(IN_PLACE);
7474
expect(out()).toContain('normalize_datetime_storage: at');
7575
expect(out()).toContain('1,234,567 row update(s)');
7676
expect(out()).toContain('widen_datetime_columns: at, created_at');
@@ -83,30 +83,30 @@ describe('renderPendingSchemaWork (#3954)', () => {
8383
expect(out()).toContain('9 row table rebuild');
8484
});
8585

86-
it('shows both sections when both kinds are pending', () => {
87-
renderPendingSchemaWork([...ADDITIVE, ...IN_PLACE]);
86+
it('shows both sections when both kinds are pending', async () => {
87+
await renderPendingSchemaWork([...ADDITIVE, ...IN_PLACE]);
8888
expect(out()).toContain('New (additive — created when you apply)');
8989
expect(out()).toContain('In place (existing rows converged when you apply)');
9090
});
9191

92-
it('reads an unmeasured count as unknown rather than zero', () => {
93-
renderPendingSchemaWork([{ table: 'evt', kind: 'normalize_datetime_storage', columns: ['at'] }]);
92+
it('reads an unmeasured count as unknown rather than zero', async () => {
93+
await renderPendingSchemaWork([{ table: 'evt', kind: 'normalize_datetime_storage', columns: ['at'] }]);
9494
expect(out()).toContain('? row update(s)');
9595
expect(out()).not.toContain('0 row update(s)');
9696
});
9797
});
9898

9999
describe('summarizePendingSchemaWork (#3954)', () => {
100-
it('is unchanged for purely additive work', () => {
101-
expect(summarizePendingSchemaWork(ADDITIVE)).toBe('1 table(s) to create, 1 column(s) to add');
100+
it('is unchanged for purely additive work', async () => {
101+
expect(await summarizePendingSchemaWork(ADDITIVE)).toBe('1 table(s) to create, 1 column(s) to add');
102102
});
103103

104-
it('is unchanged when nothing is pending', () => {
105-
expect(summarizePendingSchemaWork([])).toBe('0 table(s) to create, 0 column(s) to add');
104+
it('is unchanged when nothing is pending', async () => {
105+
expect(await summarizePendingSchemaWork([])).toBe('0 table(s) to create, 0 column(s) to add');
106106
});
107107

108-
it('never omits in-place work — this is the line read before confirming', () => {
109-
const summary = summarizePendingSchemaWork([...ADDITIVE, ...IN_PLACE]);
108+
it('never omits in-place work — this is the line read before confirming', async () => {
109+
const summary = await summarizePendingSchemaWork([...ADDITIVE, ...IN_PLACE]);
110110
expect(summary).toContain('1 table(s) to create');
111111
expect(summary).toContain('1 column(s) to add');
112112
expect(summary).toContain('5 temporal column(s) to converge in place');

0 commit comments

Comments
 (0)