Skip to content

Commit af18267

Browse files
committed
merge origin/main into claude/issue-14744-multi-update-per-row-value-census
2 parents 816b25e + 5b09356 commit af18267

48 files changed

Lines changed: 3448 additions & 417 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: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
---
2+
"@objectstack/cli": patch
3+
---
4+
5+
fix(cli): `os serve` / `os dev` / `os build` / `os migrate` resolve `packages[]` when a stack carries no flattened top level
6+
7+
The CLI holds four independent config-load boundaries, and every read of a
8+
package-owned collection behind them was an inline expression against the
9+
FLATTENED top level. A multi-package stack that carries each definition once
10+
under `packages[]` — the shape ADR-0130 D4's option B produces — reached those
11+
expressions with the key simply absent, and nothing threw:
12+
13+
- `os serve` / `os dev` auto-register the ObjectQL engine and the storage driver
14+
when the stack declares objects. Both gates read `config.objects`, so the app
15+
booted with **no query engine and no storage driver** and reported healthy.
16+
Nothing between the artifact and the gate could notice: the standalone stack
17+
omits the `objects` key entirely when the array is absent rather than setting
18+
`[]`, and the boot-config merge is a plain spread.
19+
- `os serve` auto-registers the i18n service plugin when the stack carries
20+
translations. `translations` is package-owned while `i18n` is an envelope key a
21+
translations-only stack never sets, so the REST i18n routes silently did not
22+
exist.
23+
- `os dev` diffs the artifact's object inventory across recompiles to name a
24+
newly added `*.object.ts`. It went permanently empty, so every recompile read
25+
as all-green.
26+
- `os build` runs the author-time rule table twice — once over the union, once
27+
per package. The per-package run already read `packages[]`; the union run,
28+
which is the only one of the two that can see a finding spanning packages,
29+
judged an empty stack and published green.
30+
31+
All of them now resolve through one seam, in the dependency-topological order
32+
`resolveArtifactPackageOrder` gives. Each answer starts from the expression it
33+
replaced, so every stack that boots or builds today takes the identical branch —
34+
including a stack declaring an empty `objects: []`, which stays a stack that gets
35+
an engine — and `packages[]` is consulted only where the old read returned
36+
nothing. A malformed `packages` list is refused with its ADR-0112 envelope on
37+
that leg instead of resolving to the silent empty.
38+
39+
The predicate `os serve` and `os migrate` each carried their own copy of — "does
40+
this config carry app metadata that needs an `AppPlugin` wrap" — is now one
41+
function. Measured, it does not lose under the new shape; it is folded in because
42+
it is the master gate for everything `AppPlugin` then reads.
43+
44+
No command emits anything different: the compiled artifact still carries both
45+
copies, and the folded stack is a rule INPUT that reaches no writer.
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
---
2+
"@objectstack/plugin-security": patch
3+
---
4+
5+
fix(plugin-security): the app default permission set resolves from `packages[]`, not only the flattened top level (#15007)
6+
7+
`appSecurityPluginOptions(config)` read `config.permissions` and nothing else.
8+
For a multi-package artifact under the ADR-0130 D4 option-B shape — where
9+
`packages[]` carries each definition exactly once and the flattened top-level
10+
copy is gone — that read returns `undefined`, the reader concludes "this app
11+
declared no default profile", and the boot continues. Nothing throws and
12+
nothing logs.
13+
14+
That silence has a security posture attached. The name this resolves becomes
15+
the `SecurityPlugin`'s `fallbackPermissionSet`, i.e. the app's half of every
16+
authenticated human principal's additive baseline
17+
(`composeHumanBaselinePermissionSets`, ADR-0090 D5). Losing it does not deny
18+
anyone the boot — the deployment simply runs on the platform floor alone, and
19+
every member of a multi-package app quietly holds less access than the app
20+
declared for them. #7555 measured what that looks like from the outside: nav
21+
entries served, 403 behind them.
22+
23+
The resolution now reads the flattened top level FIRST and then each package
24+
body, in the order `resolveArtifactPackageOrder` (`@objectstack/core`,
25+
ADR-0130 D4+D5) registers them:
26+
27+
- **Every artifact the platform emits today answers bit-identically.** The
28+
flattened level still answers first, so the `packages[]` pass can only supply
29+
a set where the top level had none. This is the reader half of the ruled
30+
order (readers first, emitter last, artifact stays additive throughout), so
31+
it lands with no change to what any command emits.
32+
- **Order is the platform's one package order, not the array's.**
33+
`appDefaultPermissionSetName` resolves the FIRST `isDefault` set, so with two
34+
packages declaring one, "first" has to mean here what it means at every other
35+
artifact reader: dependency-topological, so a package that extends another is
36+
read after it whichever array slot it occupies.
37+
- **The singular `manifest` is still not consulted** (#7001 — the harness must
38+
not honour a declaration `serve.ts` ignores). That is not a special case: an
39+
artifact carrying no `packages` key makes `resolveArtifactPackageOrder`
40+
return the caller's own object as the single package body, so that branch
41+
reads `permissions` from exactly where the old code read it.
42+
- **A malformed `packages` is refused, not skipped.** A non-array `packages`,
43+
an entry inlined instead of wrapped under `manifest:`, or a duplicate package
44+
id raises the same ADR-0112 envelope (`code` + `status: 422`) the manifest
45+
service raises when it registers that artifact. Catching it would resolve a
46+
permission surface out of an artifact the loader refuses to load.
47+
48+
Every boot path that already funnelled through this one function picks the fix
49+
up unchanged: `objectstack serve`'s artifact and from-source paths, and
50+
`@objectstack/verify`'s `bootStack` / RLS harness.
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
---
2+
"@objectstack/service-storage": patch
3+
---
4+
5+
fix(service-storage): put the test layer in front of tsc, and repair what it was hiding (#15050)
6+
7+
`packages/services/service-storage` had **no `typecheck` script at all** — its
8+
scripts were `build` and `test` — so no tsc program anywhere read this
9+
package's test layer, and its errors were carried instead as a 51-error DEBT
10+
entry in `scripts/check-type-check-coverage.mjs`. Gives it the #14062 /
11+
#14181 "checked test zone" shape: a sibling `tsconfig.test.json` (module
12+
semantics only — `esnext` / `bundler` / `lib: ES2022` — matching how vitest
13+
actually executes these files; strictness inherited and untouched) plus a
14+
`tsconfig.scripts.json` for `scripts/i18n-extract.config.ts` (the ninth
15+
instance of #11351, previously excluded from that ledger only because this
16+
package had no `typecheck` script to hang it on), both named by a new
17+
`typecheck` script.
18+
19+
Measured before repair: 51 errors under BUILD semantics (`tsc --noEmit -p
20+
tsconfig.json`, which already includes the tests — matching the DEBT entry's
21+
recorded number exactly), 10 under the split. Unlike `service-cluster`
22+
(#14181), this package's BUILD reading was *not* already clean, so both
23+
programs needed genuine repair, not just the test-only split: 23 `TS2835`
24+
(relative imports missing their `.js` extension, required under BUILD's
25+
NodeNext resolution) were fixed by *adding* the extension — which resolves
26+
correctly under both NodeNext and the split's bundler mode — and clearing
27+
that also cleared all 15 `TS7006` "implicitly any" as a downstream cascade
28+
from the same unresolved imports (the shape `@objectstack/core` reported at
29+
98 → 4). The remaining 3 `TS2550` (`Array.prototype.at` needing `lib`
30+
es2022) are rewritten to indexed access rather than widening the shared
31+
BUILD `tsconfig.json`. The 8 code-tier errors (`TS2339` × 4 — a test
32+
helper's object-spread dropped its `Record<string, unknown>` index
33+
signature, fixed with an explicit return-shape annotation; `TS2347` × 4 — a
34+
fake `ctx: any`'s `getService<T>(...)` calls converted to `getService(...)
35+
as T`, the pattern one call site in the same file had already adopted for
36+
exactly this reason) are genuine test-file fixes. Both readings now agree at
37+
0/0 — the same result `service-cluster` reported, reached by a longer road.
38+
39+
The package's DEBT entry (51 errors) is **deleted**, not lowered — the
40+
graduation this ratchet's invariant requires. No `test-typecheck-debt.json`
41+
is added: residue is 0, so none is owed (#5286, maintainer-only to open).
42+
`check:type-source-resolution` went red from onboarding the two new
43+
programs (the documented onboarding-limb case): a registry entry is added
44+
rather than `paths`, measured both ways — `paths` takes this package's test
45+
layer from 0 errors to 306, all in other packages' source.
46+
47+
No runtime code changes: `src/**` excluding tests is byte-identical, so no
48+
shipped behaviour moves. The `patch` level reflects the published
49+
`package.json` gaining `typecheck` / `check:test-typecheck` scripts and a
50+
`tsx` devDependency.
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
---
2+
"@objectstack/platform-objects": patch
3+
---
4+
5+
fix(platform-objects): `sys_email.error` field help now covers pre-delivery rejections, not only transport failures
6+
7+
`sys_email.error` was declared as *"Transport error message when status=failed"*.
8+
Since `EmailService.recordRejectedMessage` landed, the same column also carries
9+
the reason a message was rejected by `normalizeMessage` **before** it reached a
10+
transport (an unsendable `from`, no recipient, no subject, no body) — those rows
11+
are written with `status: 'failed'` too, prefixed `rejected before delivery: `.
12+
13+
Nothing was misleading in the *data*: the row prefixes its own reason, so an
14+
operator reading a failed row is never sent chasing an SMTP host for a message
15+
that never reached one. What was stale was the field's declared `description`,
16+
which Studio surfaces as the field's help text — it named only the transport
17+
case, narrower than what the column has held since that change landed.
18+
19+
The description now reads: *"Why the message failed — a transport error, or the
20+
validation that rejected it before delivery."* It stays true under both row
21+
shapes and deliberately does not name the row's own `rejected before delivery:`
22+
prefix, so it will not go stale again if that prefix's wording changes.

packages/cli/src/commands/compile.ts

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
} from '@objectstack/spec';
1616
import { loadConfig } from '../utils/config.js';
1717
import { lowerCallables } from '../utils/lower-callables.js';
18+
import { authoringRuleUnionStack } from '../utils/stack-collections.js';
1819
import { buildAccessMatrix, diffAccessMatrix } from '@objectstack/lint';
1920
import { runAuthoringRules, splitBySeverity, authoringRulesFor } from '@objectstack/lint';
2021
import { resolveSduiManifest } from '../utils/sdui-manifest.js';
@@ -356,9 +357,24 @@ export default class Compile extends Command {
356357
// is declared in `lint/authoring-rules.ts`. Do not add a call site here.
357358
const registered = authoringRulesFor('build');
358359
if (!flags.json) printStep(`Running author-time rules (${registered.length})...`);
360+
// [ADR-0130 D4 / option B, #15006] The UNION run judges the flattened
361+
// top level. Under option B that top level is gone — `packages[]`
362+
// carries every definition once — so this run's input would be an
363+
// EMPTY stack and `os build` would publish green having judged
364+
// nothing. `authoringRuleUnionStack` folds each absent collection
365+
// back in from `packages[]`, in `resolveArtifactPackageOrder`'s
366+
// dependency order. It changes what the rules JUDGE and nothing this
367+
// command EMITS: the artifact is written from `lowering.lowered` /
368+
// `result.data`, which this does not touch, and a stack that still
369+
// carries its collections is returned by identity.
370+
//
371+
// The per-package run below needs no such fold — it already reads
372+
// `packages[]`. The union run is the only one of the two that can see
373+
// a finding spanning packages, which is exactly what an empty input
374+
// silently stops reporting.
359375
const findings = runAuthoringRules('build', {
360-
normalized: normalized as Record<string, unknown>,
361-
parsed: result.data as Record<string, unknown>,
376+
normalized: authoringRuleUnionStack(normalized as Record<string, unknown>),
377+
parsed: authoringRuleUnionStack(result.data as Record<string, unknown>),
362378
sduiManifest: resolveSduiManifest(),
363379
});
364380
const { errors: ruleErrors, advisories } = splitBySeverity(findings);

packages/cli/src/commands/dev.ts

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
formatMtimeGap,
1818
} from '../utils/dev-restart.js';
1919
import { childEnvWithResolvedArtifact } from '../utils/internal-artifact-channel.js';
20+
import { artifactObjectNames } from '../utils/stack-collections.js';
2021
import { readEnvWithDeprecation, isMcpServerEnabled } from '@objectstack/types';
2122
// The ONE port contract, shared with `start` and with the `serve` child this
2223
// command spawns (#12673). ⛔ Nothing about ports is declared in this file —
@@ -650,16 +651,16 @@ export default class Dev extends Command {
650651
// newly added *.object.ts is called out explicitly (15.1 third-party
651652
// eval: "recompiled" alone read as all-green while the new object's
652653
// table/seed sync was invisible to the user).
654+
// [ADR-0130 D4 / option B, #15006] The envelope unwrap and the object
655+
// read are `artifactObjectNames` — one of this package's four reads of a
656+
// PACKAGE-OWNED collection, and the only one whose loss is non-fatal: with
657+
// the flattened top level gone this inventory went permanently EMPTY, so
658+
// `os dev` stopped naming a newly added *.object.ts and every recompile
659+
// read as all-green. The file read and the `null`-on-failure contract stay
660+
// here; the seam is what the acceptance probe can call.
653661
const readArtifactObjects = (): Set<string> | null => {
654662
try {
655-
const raw = JSON.parse(fs.readFileSync(opts.artifactPath, 'utf8'));
656-
const meta = raw?.metadata ?? raw?.data?.metadata ?? raw;
657-
const objects = Array.isArray(meta?.objects) ? meta.objects : [];
658-
return new Set(
659-
objects
660-
.map((o: any) => o?.name)
661-
.filter((n: any): n is string => typeof n === 'string'),
662-
);
663+
return new Set(artifactObjectNames(JSON.parse(fs.readFileSync(opts.artifactPath, 'utf8'))));
663664
} catch {
664665
return null;
665666
}

packages/cli/src/commands/serve.ts

Lines changed: 31 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,17 @@ import {
6363
} from '../utils/port-contract.js';
6464
import { BootLogCapture, isVerboseBootLevel } from '../utils/boot-log-capture.js';
6565
import { graftAuthoredRuntimeMembers, isAppPluginLike } from '../utils/graft-runtime-hooks.js';
66+
// [ADR-0130 D4 / option B, #15006] Every read below that keys off a
67+
// PACKAGE-OWNED collection goes through this seam, so an option-B artifact
68+
// (flattened top level gone, `packages[]` carrying everything once) reaches
69+
// the same decision — and so the acceptance probe can CALL the decision
70+
// instead of re-implementing it.
71+
import {
72+
shouldAutoRegisterObjectQL,
73+
shouldAutoRegisterStorageDriver,
74+
stackDeclaresMetadata,
75+
bundleDeclaresTranslations,
76+
} from '../utils/stack-collections.js';
6677
import { redactConnectionUrl, describeDriverConnection } from '../utils/connection-display.js';
6778
// The posture prose `os serve` and `os doctor` BOTH print, declared once
6879
// (#12492) — and, since #12579, the multi-org runtime SPELLING those two
@@ -2628,8 +2639,12 @@ export default class Serve extends Command {
26282639
}
26292640

26302641
// 1. Auto-register ObjectQL Plugin if objects define but plugins missing
2631-
const hasObjectQL = plugins.some((p: any) => p.name?.includes('objectql') || p.constructor?.name?.includes('ObjectQL'));
2632-
if (config.objects && !hasObjectQL) {
2642+
// [#15006] The whole gate — the `objects` read AND the already-composed
2643+
// check — is `shouldAutoRegisterObjectQL`. It answers exactly as the two
2644+
// inline expressions did for every stack that boots today, and resolves
2645+
// `packages[]` when the flattened top level is absent, which is the shape
2646+
// that used to boot with NO QUERY ENGINE and throw nothing.
2647+
if (shouldAutoRegisterObjectQL(config, plugins)) {
26332648
try {
26342649
const { ObjectQLPlugin } = await import('@objectstack/objectql');
26352650
await kernel.use(new ObjectQLPlugin());
@@ -2661,12 +2676,9 @@ export default class Serve extends Command {
26612676
// at boot through the datasource connection service, so building a
26622677
// storage driver here would construct a duplicate pool the engine then
26632678
// discards as already-registered.
2664-
const hasDriver = plugins.some((p: any) =>
2665-
p.name?.includes('driver') ||
2666-
p.constructor?.name?.includes('Driver') ||
2667-
p.name === 'com.objectstack.runtime.default-datasource' ||
2668-
p.constructor?.name === 'DefaultDatasourcePlugin');
2669-
if (!hasDriver && config.objects) {
2679+
// [#15006] Same seam, same reason — see the ObjectQL gate above. The
2680+
// driver-provider duck-typing moved into it with the read it guards.
2681+
if (shouldAutoRegisterStorageDriver(config, plugins)) {
26702682
const databaseUrl = process.env.OS_DATABASE_URL;
26712683
const driverType = resolveDriverType(process.env.OS_DATABASE_DRIVER, databaseUrl);
26722684
// libSQL/Turso's credential is the only one that does NOT ride inside the
@@ -2799,9 +2811,10 @@ export default class Serve extends Command {
27992811
// already holds an AppPlugin instance — and never on a named app, so it
28002812
// is checked structurally below.
28012813
const hasAppPluginAlready = plugins.some(isAppPluginLike);
2802-
const configHasMetadata = !!(
2803-
config.objects || config.manifest || config.apps || config.flows || config.apis
2804-
);
2814+
// [#15006] The same predicate `schema-migration-plugins.ts` runs after its
2815+
// own second `loadConfig` (B4) — folded into one seam rather than left as
2816+
// two copies whose comment already said they were the same.
2817+
const configHasMetadata = stackDeclaresMetadata(config);
28052818

28062819
// ── Decide the dev-only artifact door BEFORE the wrap (#14397) ────
28072820
// On a HOST config `os dev` composes TWO writers over ONE stack: the
@@ -2973,24 +2986,19 @@ export default class Serve extends Command {
29732986
// `plugins` array — a host/aggregator config may define no translations
29742987
// of its own and instead compose several `new AppPlugin(...)` entries,
29752988
// each carrying its own. Keyed on that shape, not on a named app.
2976-
const pluginBundleHasTranslations = (bundle: any): boolean => {
2977-
if (!bundle || typeof bundle !== 'object') return false;
2978-
if (Array.isArray(bundle.translations) && bundle.translations.length > 0) return true;
2979-
if (bundle.i18n) return true;
2980-
if (bundle.manifest && (
2981-
(Array.isArray(bundle.manifest.translations) && bundle.manifest.translations.length > 0)
2982-
|| bundle.manifest.i18n
2983-
)) return true;
2984-
return false;
2985-
};
2989+
// [#15006] `bundleDeclaresTranslations` is that same shape-keyed check plus
2990+
// the `packages[]` leg: `translations` is package-owned and `i18n` is an
2991+
// envelope key a translations-only stack never sets, so an option-B artifact
2992+
// reached this gate with neither and the REST i18n routes silently did not
2993+
// exist. MEASURED on the acceptance probe, not inferred.
29862994
const anyAppPluginHasTranslations = plugins.some((p: any) => {
29872995
if (!p) return false;
29882996
// AppPlugin instances expose their bundle on `.bundle`
2989-
if (p.bundle && pluginBundleHasTranslations(p.bundle)) return true;
2997+
if (p.bundle && bundleDeclaresTranslations(p.bundle)) return true;
29902998
return false;
29912999
});
29923000
const configHasTranslations = (
2993-
pluginBundleHasTranslations(config)
3001+
bundleDeclaresTranslations(config)
29943002
|| anyAppPluginHasTranslations
29953003
);
29963004
if (!hasI18nPlugin && configHasTranslations && tierEnabled('i18n')) {

0 commit comments

Comments
 (0)