Skip to content

Commit ce576d2

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-22726-write-answer-warnings
2 parents 7a9273c + ed1de8c commit ce576d2

28 files changed

Lines changed: 4220 additions & 31063 deletions
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
---
2+
'@objectstack/spec': patch
3+
---
4+
5+
The migration registry `src/migrations/registry.ts` is generated at build and no longer committed
6+
7+
Clause-②: no
8+
9+
`src/migrations/registry.ts` keeps its path and its exports, and the built package carries the same
10+
`./migrations` entry as before. What changes is where the file comes from in this repository: it is
11+
git-ignored and written whole by `gen:migration-registry` from two committed sources,
12+
`src/migrations/registry.ts.template` (the hand-written step rationale, doc comments and table
13+
skeleton) and `src/migrations/entries/` (one file per entry). The generator runs on `pnpm install`
14+
(the package's new `prepare` script), as the first step of `build`, and before `typecheck`, `test`
15+
and `test:repo`; its self-test runs inside every generation. `check:migration-registry` is removed,
16+
since there is no committed copy left to compare.
17+
18+
For a contributor: add a migration entry as a file under `src/migrations/entries/` and edit any other
19+
registry prose in `registry.ts.template`. An edit to `registry.ts` itself is never committed, and the
20+
next install or build overwrites it.
21+
22+
Measured with `pnpm pack` against the previous build: 2,070 files each, and 4 differ. Those are
23+
`package.json`'s `scripts` (`build`, `typecheck`, `test` and `test:repo` now start with the generation,
24+
and `check:migration-registry` is gone; `pnpm pack` leaves `prepare` out of the packed manifest) and the
25+
three `.build-input-hash` stamps that record the build's inputs. Every `dist` file, declaration, source
26+
map and JSON Schema is byte-identical. No schema, type, export or runtime behaviour changes.
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
'@objectstack/cli': patch
3+
---
4+
5+
fix(cli): `os package install` no longer reports a hot install when the runtime answers `hotLoaded: false` (#22729)
6+
7+
Clause-②: no
8+
9+
- **What was wrong.** `os package install` printed "Package installed into the running kernel" for any `200` from `POST /api/v1/marketplace/install-local`. On the endpoint's lenient path (a catalog manifest whose hot-register throws), the runtime writes the package to its ledger and answers `200` with `hotLoaded: false` and the register error's message as `hotLoadError`: the package loads at the runtime's next restart, and the running kernel does not hold it. The command read neither key.
10+
- **What it prints now.** On `hotLoaded: false` it prints a warning that the package is installed but the running kernel could not load it, and that it loads at the runtime's next restart, followed by a `Hot-load error:` line carrying `hotLoadError` as the runtime answered it. When `hotLoadError` is missing, blank or not a string, no reason line is printed. The exit code stays `0`: the install itself succeeded.
11+
- **What did not change.** A hot-loaded install (`hotLoaded: true`) prints exactly as before, and so does an answer without `hotLoaded` (a runtime that predates the key). The command has no `--json` output, and none is added.

‎.gitattributes‎

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -64,11 +64,10 @@
6464
# The migrations registry is the interesting one since #7297: its three append
6565
# tables now come from `packages/spec/src/migrations/entries/` (one file per
6666
# entry, the `.changeset/*.md` shape), so the conflict two retirement cards used
67-
# to have is gone at the SOURCE — different entries are different files. The
68-
# file stays out of this list anyway, because it is now MIXED: a driver that
69-
# deferred it whole would resolve its still-hand-written prose by regenerating,
70-
# which loses an edit rather than a merge. `check:migration-registry` is what
71-
# guards the generated half instead.
67+
# to have is gone at the SOURCE — different entries are different files. Since
68+
# #22554 the registry itself is git-ignored and generated whole at install and
69+
# build, so git never merges it; its hand-written prose lives in
70+
# `registry.ts.template`, which is plain source and text-merges like any other.
7271
#
7372
# The strictness ledger's COUNTS file joined at #5107 — the ledger's numbers were
7473
# the repo's hottest conflict surface and merged in the one way that hides: two

‎.github/workflows/lint.yml‎

Lines changed: 9 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -283,11 +283,11 @@ jobs:
283283
# over the ten merge-group runs measured for #16496; the PM dispatch-gates
284284
# self-test alone 597 s), and none of its expensive steps read a
285285
# merge group's file surface: a docs-only group paid the full battery.
286-
# So on `merge_group` and `pull_request` the TEN scoped families below
286+
# So on `merge_group` and `pull_request` the NINE scoped families below
287287
# -- each step carrying `if: steps.gate-families.outputs.<id> != 'skip'`
288288
# -- run only when the changed paths touch the files that family reads.
289-
# One of the ten, `migration_registry` (#19753), is a gate this job did
290-
# not run before at all; the other nine were scoped away from it.
289+
# (A tenth, `migration_registry` (#19753), retired with its gate in
290+
# #22554: the registry it compared is no longer committed.)
291291
# `push` on main and the scheduled full run keep the whole battery: the
292292
# script runs everything for any event it does not scope.
293293
#
@@ -399,24 +399,12 @@ jobs:
399399
- name: ADR anchors + number uniqueness (governed code names its decision)
400400
run: pnpm check:adr-anchors
401401

402-
# Migration registry matches its entry files (#19753). `registry.ts` is
403-
# the generated concatenation of `packages/spec/src/migrations/entries/`,
404-
# it ships in the spec package's `dist`, and this check is the only
405-
# proof the two agree -- `check:generated --reconcile-only` (in the
406-
# `Type Check · source gates` job) proves the script is classified,
407-
# never that the artifact is current. It ran
408-
# in no CI step, so an entry edited without `gen:migration-registry`
409-
# shipped the old text with every required check green.
410-
#
411-
# Scoped, on the maintainer's ruling for that card: a step in THIS
412-
# required job, run when the change touches what the check reads
413-
# (`migration_registry` in the selector above). ⛔ Not a separate
414-
# `paths:`-filtered job: one that never reports on an unrelated PR
415-
# leaves a required context pending forever. Cheap -- tsx over one
416-
# directory, no build (~1.4 s measured locally) -- so it sits early.
417-
- name: Migration registry matches its entry files
418-
if: steps.gate-families.outputs.migration_registry != 'skip'
419-
run: pnpm --filter @objectstack/spec check:migration-registry
402+
# No "migration registry matches its entry files" step since #22554:
403+
# `packages/spec/src/migrations/registry.ts` is git-ignored and generated
404+
# whole from `registry.ts.template` + `entries/` by `gen:migration-registry`,
405+
# which `pnpm install` above already ran (spec's `prepare`, self-test
406+
# included). There is no committed copy left to compare, and an entry the
407+
# generator refuses fails that install step, loudly, in every job.
420408

421409
# Enforces the no-restricted-imports guard against @objectstack/spec root
422410
# namespace imports (the dormant rule was never run in CI). Syntactic

‎.gitignore‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,12 @@ packages/*/*.tsbuildinfo
6262
# Generated JSON Schema (rebuilt by `pnpm gen:schema` during build)
6363
packages/spec/json-schema/
6464

65+
# Generated migration registry (rebuilt by `pnpm gen:migration-registry` on
66+
# install and as the first step of build, from registry.ts.template + entries/),
67+
# and the staging file its atomic write renames into place.
68+
packages/spec/src/migrations/registry.ts
69+
packages/spec/src/migrations/registry.ts.*.tmp
70+
6571
# Generated bundle size reports
6672
packages/spec/bundle-size-report.json
6773

‎packages/cli/src/commands/package/install.ts‎

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ import { readFile } from 'node:fs/promises';
2626
import { existsSync } from 'node:fs';
2727
import { resolve as resolvePath } from 'node:path';
2828
import { Args, Command, Flags } from '@oclif/core';
29-
import { printHeader, printKV, printSuccess, printError, printStep, isExitSignal } from '../../utils/format.js';
29+
import { printHeader, printKV, printSuccess, printWarning, printError, printStep, isExitSignal } from '../../utils/format.js';
3030

3131
export default class PackageInstall extends Command {
3232
static override description =
@@ -220,12 +220,34 @@ export default class PackageInstall extends Command {
220220
}
221221

222222
const data = res.body?.data ?? res.body ?? {};
223+
// [#22729] A `200` is not by itself a hot install. install-local's
224+
// LENIENT path — a cloud-fetched manifest whose `manifest.register`
225+
// throws — writes the ledger entry and answers `200` with
226+
// `hotLoaded: false` and the register error's message as `hotLoadError`
227+
// (#22695): the package is installed and loads at the runtime's next
228+
// restart, and the running kernel does not hold it. Only an explicit
229+
// `false` is read that way; an answer without the key (a host that
230+
// predates it) prints as it always did. The air-gapped file path never
231+
// reaches this answer — an inline manifest whose register throws is
232+
// refused `422 PLUGIN_REGISTER_FAILED` — but the read is the same code.
233+
//
234+
// ⛔ No invented reason: a `hotLoaded: false` whose `hotLoadError` is
235+
// missing, blank or not a string prints no reason line at all.
236+
const notHotLoaded = data.hotLoaded === false;
237+
const hotLoadError = typeof data.hotLoadError === 'string' ? data.hotLoadError.trim() : '';
223238
console.log('');
224-
printSuccess('Package installed into the running kernel');
239+
if (notHotLoaded) {
240+
printWarning(
241+
"Package installed, but the running kernel could not load it — it loads at the runtime's next restart",
242+
);
243+
} else {
244+
printSuccess('Package installed into the running kernel');
245+
}
225246
printKV(' Package', String(data.manifestId ?? data.packageId ?? label));
226247
printKV(' Version', String(data.version ?? flags.version));
227248
printKV(' Runtime', runtime);
228249
if (data.installedAt) printKV(' Installed', String(data.installedAt));
250+
if (notHotLoaded && hotLoadError) printKV(' Hot-load error', hotLoadError);
229251
console.log('');
230252
// #6721: the cache directory is quoted from the RESPONSE, never from a
231253
// literal or a locally-resolved constant. The directory lives on the
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #22729 — `os package install` reads the install-local answer's `hotLoaded`
5+
* and `hotLoadError`, and says what the runtime actually did.
6+
*
7+
* `POST /api/v1/marketplace/install-local` answers `200` on its LENIENT path
8+
* too: a cloud-fetched manifest whose `manifest.register` throws is written to
9+
* the runtime's ledger and loads at the runtime's next restart, while the
10+
* running kernel does not hold it. Since #22695 that answer says so —
11+
* `hotLoaded: false`, with the register error's message as `hotLoadError` —
12+
* and before this change the command printed "Package installed into the
13+
* running kernel" over it, for any `200` alike.
14+
*
15+
* Four cases, because the read is additive and each half needs its own pin:
16+
* - the lenient answer is NOT reported as a hot install, and names the reason;
17+
* - CONTROL: a hot-loaded answer prints as it always did;
18+
* - an answer WITHOUT the keys (a host that predates #22695) prints as it
19+
* always did — absence is not read as `false`;
20+
* - a `hotLoaded: false` with no usable reason prints no invented one.
21+
*
22+
* Catalog mode only: the air-gapped file path sends an INLINE manifest, and
23+
* install-local refuses an inline manifest whose register throws with
24+
* `422 PLUGIN_REGISTER_FAILED` before anything is written, so that path never
25+
* reaches a `hotLoaded: false` answer. The reader is the same code either way.
26+
*/
27+
28+
import { describe, it, expect, afterEach, vi } from 'vitest';
29+
import PackageInstall from '../src/commands/package/install.js';
30+
31+
/** The register error the runtime passes through, verbatim. */
32+
const REGISTER_ERROR = "object 'crm_account' declares field 'owner' with unknown type 'lookupx'";
33+
34+
/** Stub the runtime's install POST with the given `data` block. */
35+
function stubRuntime(data: Record<string, unknown>): void {
36+
vi.stubGlobal('fetch', vi.fn(async () => ({
37+
ok: true,
38+
status: 200,
39+
statusText: 'OK',
40+
headers: { get: () => null },
41+
json: async () => ({ success: true, data }),
42+
}) as any));
43+
}
44+
45+
/** Run the command in catalog mode; return everything it printed and how it exited. */
46+
async function runInstall(): Promise<{ out: string; exit: number | undefined }> {
47+
const lines: string[] = [];
48+
const capture = (...args: unknown[]) => { lines.push(args.map(String).join(' ')); };
49+
vi.spyOn(console, 'log').mockImplementation(capture);
50+
vi.spyOn(console, 'error').mockImplementation(capture);
51+
let exit: number | undefined;
52+
try {
53+
await PackageInstall.run(['com.acme.crm', '--runtime', 'http://runtime.test']);
54+
} catch (err: any) {
55+
exit = err?.oclif?.exit ?? err?.code;
56+
}
57+
return { out: lines.join('\n'), exit };
58+
}
59+
60+
/** The answer's keys every install carries, whatever the hot-register did. */
61+
const INSTALLED = {
62+
manifestId: 'com.acme.crm',
63+
version: '1.0.0',
64+
versionId: 'ver_1',
65+
installedAt: '2026-10-10T00:00:00.000Z',
66+
};
67+
68+
const HOT_INSTALL_LINE = 'Package installed into the running kernel';
69+
70+
describe('os package install — reads hotLoaded / hotLoadError', () => {
71+
afterEach(() => {
72+
vi.unstubAllGlobals();
73+
vi.restoreAllMocks();
74+
});
75+
76+
it('on hotLoaded: false, says installed, loads at the next restart, and names hotLoadError', async () => {
77+
stubRuntime({ ...INSTALLED, hotLoaded: false, hotLoadError: REGISTER_ERROR });
78+
79+
const { out, exit } = await runInstall();
80+
81+
// The defect: a hot install claimed over a kernel that does not hold it.
82+
expect(out).not.toContain(HOT_INSTALL_LINE);
83+
// What happened instead, and when the package does load.
84+
expect(out).toMatch(/Package installed, but the running kernel could not load it/);
85+
expect(out).toMatch(/loads at the runtime's next restart/);
86+
// The reason, as the runtime answered it.
87+
expect(out).toContain(REGISTER_ERROR);
88+
// The install itself succeeded (ledger written, `200`): same exit as a hot one.
89+
expect(exit).toBeUndefined();
90+
expect(out).toContain('com.acme.crm');
91+
});
92+
93+
it('CONTROL: on hotLoaded: true, prints as before', async () => {
94+
stubRuntime({ ...INSTALLED, hotLoaded: true });
95+
96+
const { out, exit } = await runInstall();
97+
98+
expect(out).toContain(HOT_INSTALL_LINE);
99+
expect(out).not.toContain('next restart');
100+
expect(out).not.toContain('could not load');
101+
expect(exit).toBeUndefined();
102+
});
103+
104+
it('an answer without hotLoaded (a host that predates the key) prints as before', async () => {
105+
stubRuntime({ ...INSTALLED });
106+
107+
const { out, exit } = await runInstall();
108+
109+
expect(out).toContain(HOT_INSTALL_LINE);
110+
expect(out).not.toContain('next restart');
111+
expect(out).not.toContain('could not load');
112+
expect(exit).toBeUndefined();
113+
});
114+
115+
it.each([
116+
['absent', undefined],
117+
['an empty string', ''],
118+
['whitespace', ' '],
119+
['not a string', 42],
120+
])('on hotLoaded: false with hotLoadError %s, invents no reason', async (_label, value) => {
121+
stubRuntime({ ...INSTALLED, hotLoaded: false, ...(value === undefined ? {} : { hotLoadError: value }) });
122+
123+
const { out } = await runInstall();
124+
125+
expect(out).not.toContain(HOT_INSTALL_LINE);
126+
expect(out).toMatch(/loads at the runtime's next restart/);
127+
expect(out).not.toContain('Hot-load error');
128+
expect(out).not.toContain('undefined');
129+
expect(out).not.toContain('42');
130+
});
131+
});

‎packages/spec/package.json‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -264,7 +264,8 @@
264264
"protocol-upgrade-guide.md"
265265
],
266266
"scripts": {
267-
"build": "pnpm gen:schema && pnpm gen:openapi && tsup && if [ -z \"$OS_SKIP_DTS\" ]; then NODE_OPTIONS=\"--max-old-space-size=6144\" BUILD_DTS=true tsup; fi && node ../../scripts/check-dts-emitted.mjs && node ../../scripts/check-dts-references.mjs && node ../../scripts/check-dev-prereqs.mjs --stamp",
267+
"build": "pnpm gen:migration-registry && pnpm gen:schema && pnpm gen:openapi && tsup && if [ -z \"$OS_SKIP_DTS\" ]; then NODE_OPTIONS=\"--max-old-space-size=6144\" BUILD_DTS=true tsup; fi && node ../../scripts/check-dts-emitted.mjs && node ../../scripts/check-dts-references.mjs && node ../../scripts/check-dev-prereqs.mjs --stamp",
268+
"prepare": "pnpm gen:migration-registry",
268269
"dev": "tsc --watch",
269270
"clean": "rm -rf dist",
270271
"gen:schema": "OS_EAGER_SCHEMAS=1 tsx scripts/build-schemas.ts",
@@ -295,13 +296,12 @@
295296
"check:llms-txt": "tsx scripts/check-llms-txt.ts --self-test && tsx scripts/check-llms-txt.ts",
296297
"check:authorable-surface": "OS_EAGER_SCHEMAS=1 tsx scripts/build-schemas.ts --check",
297298
"gen:migration-registry": "tsx scripts/build-migration-registry.ts",
298-
"check:migration-registry": "tsx scripts/build-migration-registry.ts --self-test --check",
299299
"gen:spec-changes": "tsx scripts/build-spec-changes.ts",
300300
"check:spec-changes": "tsx scripts/build-spec-changes.ts --check",
301301
"gen:upgrade-guide": "tsx scripts/build-upgrade-guide.ts",
302302
"check:upgrade-guide": "tsx scripts/build-upgrade-guide.ts --check",
303-
"test": "vitest run --project local",
304-
"test:repo": "vitest run --project repo",
303+
"test": "pnpm gen:migration-registry && vitest run --project local",
304+
"test:repo": "pnpm gen:migration-registry && vitest run --project repo",
305305
"test:watch": "vitest",
306306
"test:coverage": "vitest run --coverage",
307307
"check:liveness": "tsx scripts/liveness/check-liveness.mts",
@@ -320,7 +320,7 @@
320320
"check:test-typecheck": "tsx ../../scripts/check-test-typecheck.mts --self-test && tsx ../../scripts/check-test-typecheck.mts --package packages/spec --project tsconfig.test.json",
321321
"gen:test-typecheck-debt": "tsx ../../scripts/check-test-typecheck.mts --update --package packages/spec --project tsconfig.test.json",
322322
"check:scripts-typecheck": "tsc --noEmit -p tsconfig.scripts.json",
323-
"typecheck": "tsc --noEmit && pnpm check:scripts-typecheck && pnpm check:test-typecheck"
323+
"typecheck": "pnpm gen:migration-registry && tsc --noEmit && pnpm check:scripts-typecheck && pnpm check:test-typecheck"
324324
},
325325
"keywords": [
326326
"objectstack",

‎packages/spec/scripts/build-migration-registry-entry.test.ts‎

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,9 @@
33
// `build-migration-registry.ts` has TWO kinds of caller now, and its entry guard
44
// is the only thing telling them apart:
55
//
6-
// - pnpm RUNS it (`gen:migration-registry`, `check:migration-registry`), and
7-
// write mode rewrites `src/migrations/registry.ts`;
6+
// - pnpm RUNS it (`gen:migration-registry`, on install, in `build`, before
7+
// `typecheck` and `test`), and every run rewrites `src/migrations/registry.ts`
8+
// from the template and the entries;
89
// - `build-schemas.ts` IMPORTS it, for `shardNameFor` — so the entry file each
910
// registration remedy names is the one name the generator accepts, read from
1011
// the rule `entries/README.md` documents instead of restated beside it.
@@ -66,8 +67,9 @@ describe('build-migration-registry.ts — run, it speaks; import, it runs nothin
6667
);
6768
const r = tsx([probe]);
6869
expect(r.status, r.stderr).toBe(0);
69-
// Write mode would have printed `✓ wrote src/migrations/registry.ts (…)`, and
70-
// check mode `✓ … is current` — any line beyond the probe's own is the defect.
70+
// A run would have printed its self-test line and then `✓ wrote
71+
// src/migrations/registry.ts (…)` or `✓ … is current` — any line beyond the
72+
// probe's own is the defect.
7173
expect(r.stdout).toBe('17.data__AggregationNode__distinct.ts\n');
7274
expect(r.stderr).toBe('');
7375
});

0 commit comments

Comments
 (0)