Skip to content

Commit f8821b6

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-14195-workspace-cycle-gate
2 parents 8c412a5 + fed4fa4 commit f8821b6

166 files changed

Lines changed: 5368 additions & 287 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
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: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
---
2+
"@objectstack/plugin-email": patch
3+
---
4+
5+
fix(plugin-email): judge the default sender where it is configured, and record a send rejected before delivery (#14318)
6+
7+
Measured on a local rig with `OS_EMAIL_FROM="ObjectOS Local <noreply@localhost>"`,
8+
`OS_EMAIL_PROVIDER=log` and `OS_AUTH_REQUIRE_EMAIL_VERIFICATION=true`: the server
9+
booted clean, the first sign-up answered `200`, the UI said a verification email
10+
had been sent, and nothing had been. `EMAIL_REGEX` requires a dotted domain and
11+
correctly refuses `noreply@localhost` — but it refused it inside
12+
`normalizeMessage`, on the **first send**, which for a fresh deployment is the
13+
first user's sign-up. better-auth runs `sendVerificationEmail` through
14+
`runInBackgroundOrAwait`, which logs `Failed to run background task` and returns
15+
normally, so the account was created and the user was parked on a verify screen
16+
whose Resend repeated the whole sequence. `sys_email` held no row for any of it:
17+
the throw happened *before* the row insert, and every other failure shape in the
18+
service is a row at `status:'failed'`.
19+
20+
Two changes, one per half.
21+
22+
**The address is judged where it is configured.** `EmailServicePlugin.init()`
23+
now refuses a declared `defaultFrom` that no message could ever be sent from, so
24+
a deployment that names an unsendable sender fails its boot instead of failing
25+
every send — the same trade `resolveTransport` already makes for an SMTP
26+
provider with no host, and the error names the consequence and the fix
27+
(`OS_EMAIL_FROM` / `config.email.defaultFrom`). An **absent** sender is still
28+
accepted: callers that always pass `input.from` are a complete configuration,
29+
and `normalizeMessage` already refuses a send that has neither.
30+
31+
The `mail` settings channel takes that method's opposite, stated trade — a save
32+
must not kill a running server — so an unsendable saved From address is
33+
**refused, the previous sender kept**, and the consequence stated at `error`.
34+
`error` and not `warn` because nothing looks broken afterwards: the save
35+
succeeds and the settings page shows the address the operator typed.
36+
37+
**A send rejected before delivery now leaves a `sys_email` row.** The
38+
`normalizeMessage` window was the one path on which a send produced no record at
39+
all. It now writes `status:'failed'` with the reason, prefixed
40+
`rejected before delivery:` so the column distinguishes a message that never
41+
reached a transport from one an SMTP host refused. The envelope columns carry
42+
what the caller actually passed (never re-canonicalised — canonicalisation is
43+
what threw), `(none)` where the input named nothing, since `from_address` /
44+
`to_addresses` / `subject` are required. The row is safe by construction: both
45+
re-delivery paths — the `afterInsert` outbox drain hook and the boot outbox
46+
sweep — gate on `status === 'queued'`, so a rejection record can never be
47+
mistaken for an outbox entry. Persisting it is best-effort and never replaces
48+
the caller's error.
49+
50+
Unchanged: `formatAddress`, `EMAIL_REGEX` and `normalizeMessage` keep their
51+
exact verdicts (the new `isSendableAddress` predicate shares the one regex, so a
52+
boot cannot pass a check the send path then fails), `send()` still throws on
53+
validation failure rather than answering `failed`, and the auth layer's
54+
propagation is as it was — `sendVerificationEmail` already rejects on both a
55+
throw and a returned `status:'failed'`, which is now pinned by a test.
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
---
2+
'@objectstack/metadata': patch
3+
---
4+
5+
`FilesystemLoader` now implements `loadManyKeyed()`, so a metadata file whose
6+
body has no top-level `name` is no longer invisible to `MetadataManager.list()`.
7+
`loadMany()` globbed files and pushed bodies, discarding the path it had just
8+
read; the manager then fell back to keying by `body.name`, which drops every
9+
nameless body — an aggregated `defineView` container has none by design. That is
10+
the #14205 defect, unrepaired for this loader until now.
11+
12+
The key is this loader's own name-to-path derivation — the basename minus
13+
extension, the same one `list()` reports — but only where that derivation is a
14+
bijection for the file: it sits directly under `ROOT/TYPE/` and carries an
15+
extension `findFile()` tries, so `findFile(type, key)` resolves back to that same
16+
file. Every other shape (a nested path, an extension-less file) keeps the
17+
previous behaviour verbatim: keyed by `body.name` when it has one, dropped when
18+
it has none. `list()` and `findFile()` disagree outside the flat shape — `list()`
19+
reports the bare basename for a nested file and `findFile()` cannot resolve it —
20+
so keying those by the basename would mint names `get()` and `exists()` cannot
21+
open, and two directories holding one basename would collide silently. Repairing
22+
the derivation itself is tracked separately.
23+
24+
One deliberate consequence: a flat file whose `body.name` disagrees with its
25+
basename is now keyed by the basename. That is #14205's rule (identity is the
26+
key the store holds an item under, not `body.name`) applied to this loader, and
27+
it aligns `list()` with `listNames()` for that shape. `loadMany()`'s own
28+
signature and answer are unchanged; both methods now share one file walk so
29+
their bodies cannot drift.
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)