Skip to content

Commit 7987b5b

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-14141-hook-selftest-deps-comment
2 parents 7330dfa + ab540ed commit 7987b5b

12 files changed

Lines changed: 1147 additions & 102 deletions
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
"@objectstack/cli": patch
3+
---
4+
5+
fix(cli): close the declaration-boot write guard's two named boundaries — engine-held drivers and immediate DDL (#14126)
6+
7+
`os migrate plan` / `os migrate apply` boot host plugins for their declarations behind a guard that refuses the contract's row writes and, since #13332 / #14053, prints "a plan writes nothing" only when that held. Two boundaries were left open and named in the guard's own census; both are now covered, under ONE outcome-line rule:
8+
9+
- **Engine-held drivers.** Only the default datasource is published as `driver.*`; every other driver reaches the engine through `engine.registerDriver` alone (`DatasourceConnectionService.connect()`, `AppPlugin`'s `drivers.register`, `ObjectQL.create`), so a hook writing to an object bound to a second datasource landed during a plan. The guard now shadows `registerDriver` on the engine instance the kernel publishes (`objectql` / `data`) for the length of the boot, arms each driver instance in place as it is registered — forwarding the SAME instance, never a wrapper, never a second registration under a held name — reaches drivers the engine already held through its public accessors, and restores the engine on `disarm()`. Such a write is now refused and reported as `via engine.<datasource>`.
10+
- **Immediate DDL.** `dropTable()` / `rotateShards()` are not held back by the schema deferral and execute immediately. They are still not refused (refusing DDL an operator's own hook asked for is out of this guard's scope) — but they now get `execute()`'s treatment: forwarded, counted per driver/method/object, warned once per driver on stderr, named in the notes, and the run no longer claims "a plan writes nothing".
11+
12+
The rule, decided once: the claim prints only when it held across everything the guard can see — every write refused, nothing forwarded (raw `execute()`, immediate DDL), and no instance that refused the override (a frozen driver, an engine that could not be shadowed). Each of those is named in the notes and withholds the line. An embedder with no data plane, and read/log-only hooks, are untouched: a quiet boot still renders byte-identically.
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
---
2+
"@objectstack/lint": patch
3+
---
4+
5+
fix(lint): wire `packages/lint`'s test layer into `check:test-typecheck`, so its 2,700-line rule pin file is actually type-checked (#14173)
6+
7+
`packages/lint/tsconfig.json` excluded `**/*.test.ts` and `**/*.spec.ts`, and
8+
the package's `typecheck` script was a bare `tsc --noEmit` against that very
9+
config — so no gate anywhere read a lint test file with a type checker.
10+
`src/validate-expressions.test.ts` alone is ~2,700 lines built almost entirely
11+
out of compile-time and meta pins (the #5017 receiver scan, the
12+
`TRACKED_UNDECLARED_READS` shrink-only list, the residual-root table), and none
13+
of it was type-checked by anything: vitest transpiles through esbuild (types
14+
stripped, never resolved), so a wrong key or a signature drift in a pin's own
15+
scaffolding was caught by nobody.
16+
17+
Onboarded by *wiring* to the mechanism #14062 (PR #14420) landed on
18+
(`scripts/check-test-typecheck.mts`), per the triage ruling on this card: a
19+
sibling `tsconfig.test.json` matching vitest's real module semantics
20+
(`module: esnext`, `moduleResolution: bundler`, `lib: ["ES2022"]`; strictness
21+
and `rootDir` untouched, inherited), named by `typecheck`. Measured (workspace
22+
closure built first): 6 residual errors over 2 files, all TS6059 (imports from
23+
`examples/app-showcase`, outside this package's `rootDir` — pre-existing,
24+
config-tier, not a lint defect), recorded EXACT and shrink-only in the new
25+
`test-typecheck-debt.json`.
26+
27+
This is a CONVERSION of the coverage gate's existing `@objectstack/lint`
28+
TEST_DEBT entry (`errors: 16`), not a new debt-opening decision: the same
29+
authority that recorded the 16 now holds the residue one level finer, per file
30+
and per signature, and the coverage-gate entry is deleted as the graduation
31+
that pairing forces. No test file is edited — opening the ratchet is not the
32+
same job as paying it down.

.github/workflows/publish-smoke.yml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,9 +47,18 @@
4747
# check (branch protection on main requires TypeScript Type Check, Build Core,
4848
# Test Core and Dogfood Regression Gate), so an absent one blocks nothing and is
4949
# simply invisible. resolve-guard turns that silence into an explicit red.
50+
#
51+
# "How long was publish broken?" is answered by walking the `publish-smoke /
52+
# packed-tarballs` commit-status history on the release-branch head, never by
53+
# scanning this workflow's run list, because the list interleaves the weekly
54+
# registry canary (schedule-triggered, unrelated to any release candidate)
55+
# with release-candidate smokes, and an unlabeled canary green reads as "the
56+
# RC smoke passed" (#14190 — a six-day outage misread from exactly that).
5057

5158
name: Publish Smoke
5259

60+
run-name: ${{ github.event_name == 'schedule' && 'Registry canary (published latest) — scheduled' || github.event_name == 'workflow_dispatch' && format('Publish smoke — manual dispatch on {0}', github.ref_name) || format('Packed-tarball smoke (release candidate) — after Release run {0}', github.event.workflow_run.head_sha) }}
61+
5362
on:
5463
workflow_run:
5564
workflows: [Release]

docs/qa/platform-checklist/areas/cli.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@
3939
{
4040
"clause": "--seed-admin provisions the FIXED well-known dev admin (admin@objectos.ai / admin123) loginable via the real auth endpoint, and the seed is idempotent — a later boot with a different --admin-password does NOT overwrite the existing account",
4141
"oracle": "api",
42-
"verify": "login succeeds with the original credentials after the changed-password restart (flag contract: 'only acts on a zero-user DB, never overwrites an existing account')",
42+
"verify": "login succeeds with the original credentials after the changed-password restart (flag contract: 'it acts only while no account holds the seed address and no local password login exists anywhere, never overwrites an existing account')",
4343
"evidence": "the two auth responses"
4444
},
4545
{
@@ -794,7 +794,7 @@
794794
],
795795
"knownGaps": [
796796
"This item deliberately does NOT re-prove what two existing items already cover. The scaffold→install→validate→build→artifact-boot chain belongs to cli.scaffold-first-run (automated by .github/workflows/scaffold-e2e.yml); the console-on-a-populated-app half belongs to cli.dev-boot-contract, which drives examples/app-showcase. What is untested is the SEAM: the Console rendering against a freshly scaffolded BLANK project — one object and no seed data. Score only the seam; cite the two siblings for the halves.",
797-
"A blank scaffold DOES have a loginable identity out of the box: `seed-admin` is default-ON in dev (commands/dev.ts: flags['seed-admin'] ?? true, 'Default: on (idempotent — only acts on a zero-user DB, never overwrites an existing account)'), so bare `objectstack dev` seeds admin@objectos.ai / admin123 on an empty DB and the banner prints the credentials. Clause 4 therefore scores whether the newcomer can COMPLETE the documented visual check with that identity, not whether one exists. Still do not import showcase credentials."
797+
"A blank scaffold DOES have a loginable identity out of the box: `seed-admin` is default-ON in dev (commands/dev.ts: flags['seed-admin'] ?? true, 'Default: on (idempotent — it acts only while no account holds the seed address and no local password login exists anywhere, never overwrites an existing account)'), so bare `objectstack dev` seeds admin@objectos.ai / admin123 on an empty DB and the banner prints the credentials. Clause 4 therefore scores whether the newcomer can COMPLETE the documented visual check with that identity, not whether one exists. Still do not import showcase credentials."
798798
]
799799
},
800800
"steps": [

packages/cli/src/utils/schema-migrate.deferred-ddl.integration.test.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,9 @@ import { mkdtempSync, writeFileSync, mkdirSync, rmSync, existsSync, statSync } f
1818
import { tmpdir } from 'node:os';
1919
import { join } from 'node:path';
2020
import { SqlDriver } from '@objectstack/driver-sql';
21+
import type { Plugin, PluginContext } from '@objectstack/core';
2122
import { bootSchemaStack } from './schema-migrate.js';
23+
import { composeForDeclarations } from './schema-migration-plugins.js';
2224

2325
const ARTIFACT = {
2426
// #8687: manifest fields under `manifest:` — the flat spelling is refused.
@@ -166,4 +168,62 @@ describe('bootSchemaStack({ deferSchemaDdl }) — the boot writes nothing (#3917
166168
await stack.shutdown();
167169
}
168170
}, 60_000);
171+
172+
it('a hook that drops a table during the declaration boot: the DDL EXECUTES (forwarded), and the notes say so instead of claiming the boot wrote nothing (#14126)', async () => {
173+
// The guard's census names `dropTable()` as immediate DDL: it runs
174+
// `assertSchemaMutable`, not the deferral, and executes. Measured here on
175+
// the real driver over the real file — the deferral held back every
176+
// CREATE/ALTER of the two cases above, and this DROP goes straight
177+
// through. What #14126 changes is not the execution but its silence: the
178+
// call is counted and named, and the run does not print "a plan writes
179+
// nothing" over a table it dropped.
180+
const before = await inspect();
181+
expect(before.tables).toEqual(['defer_widget']);
182+
183+
let via = '';
184+
const dropsATable: Plugin = {
185+
name: 'com.example.drops-a-table-from-a-hook',
186+
version: '1.0.0',
187+
init: async (ctx: PluginContext) => {
188+
ctx.hook('kernel:ready', async () => {
189+
for (const [name, service] of ctx.getServices().entries()) {
190+
if (name.startsWith('driver.') && typeof service?.dropTable === 'function') {
191+
via = name;
192+
await service.dropTable('defer_widget');
193+
return;
194+
}
195+
}
196+
throw new Error('no driver.* service with dropTable() to drop through');
197+
});
198+
},
199+
};
200+
201+
const stack = await bootSchemaStack({
202+
jsonOutput: false,
203+
databaseUrl: `file:${dbFile}`,
204+
deferSchemaDdl: true,
205+
projectRoot: dir,
206+
// The artifact makes this a COMPOSED boot, which is what arms the guard.
207+
composeHostStack: true,
208+
extraPlugins: [composeForDeclarations(dropsATable)],
209+
});
210+
try {
211+
const after = await inspect();
212+
expect(via).not.toBe('');
213+
// FORWARDED: the table is really gone.
214+
expect(after.tables).not.toContain('defer_widget');
215+
// COUNTED, on the guard's own surface…
216+
expect(stack.composition.writeGuard?.immediateDdl).toEqual([
217+
{ driver: via, method: 'dropTable', object: 'defer_widget', count: 1 },
218+
]);
219+
// …and REPORTED in the notes the plan prints, with the claim withheld.
220+
const notes = stack.composition.notes.join('\n');
221+
expect(notes).toContain(
222+
`Immediate DDL was called 1 time(s) during the declaration boot (dropTable() on defer_widget via ${via})`,
223+
);
224+
expect(notes).not.toContain('a plan writes nothing');
225+
} finally {
226+
await stack.shutdown();
227+
}
228+
}, 60_000);
169229
});

0 commit comments

Comments
 (0)