Skip to content

Commit 1358d4c

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/dead-dashboard-header-refs-7zo1ut
# Conflicts: # content/docs/getting-started/validating-metadata.mdx # packages/cli/src/commands/validate.ts
2 parents 89ad192 + 9e45b63 commit 1358d4c

31 files changed

Lines changed: 1521 additions & 35 deletions
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/cli": minor
4+
---
5+
6+
feat(cli): preflight that every `requires` capability has an installable provider
7+
in the current edition (#3366)
8+
9+
A capability listed in `requires: [...]` was only checked at `serve`/`start` time,
10+
and a missing provider produced a generic "not installed — add it to your
11+
dependencies" error even when the provider has **no installable version in the
12+
current edition**. `os validate` (token-vocabulary only) and `os build` (never
13+
resolved providers) both passed, so a `validate && build && test` CI script never
14+
caught it — it surfaced only as an opaque boot crash. Seen upgrading an
15+
open-edition app from `14.7` to `16` after `@objectstack/service-ai` went
16+
cloud-only (ADR-0025).
17+
18+
- `@objectstack/spec/kernel` now exports `PLATFORM_CAPABILITY_PROVIDERS`
19+
(token → provider package + edition) and a pure `classifyRequiredCapability()`
20+
one machine-readable source of truth for the provider/edition knowledge the
21+
serve resolver previously encoded informally.
22+
- `os build` and `os validate` gained a provider preflight. A `requires` entry
23+
whose provider has **no installable version in the active edition** (e.g. `ai`
24+
`@objectstack/service-ai`, cloud-only) now fails fast with an edition-aware
25+
message; an absent-but-installable provider is an advisory `pnpm add` hint, not
26+
a hard error; a satisfied `requires` list passes unchanged.
27+
- The `os serve` boot error now renders the same classification, so preflight and
28+
boot read identically.
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
---
2+
"@objectstack/lint": minor
3+
---
4+
5+
Validate dashboard filter field-existence at build time (extend ADR-0021, #3365).
6+
7+
`validateWidgetBindings` now checks that every dashboard-level filter (`dateRange`
8+
+ each `globalFilters[]`) resolves to a real field on each bound widget's dataset
9+
object. Since #2501 wired these filters into every widget's analytics query, a
10+
filter field absent on a widget's object — e.g. a `dateRange` bound to
11+
`close_date` inherited by an account/contact widget over a different object —
12+
emitted invalid SQL (`no such column: close_date`) and crashed the widget at
13+
render time. That build-decidable invariant previously escaped `os validate` /
14+
`os build` and failed only when a user opened the dashboard.
15+
16+
It now fails the build (new rule `dashboard-filter-field-unknown`) with a message
17+
naming the dashboard, widget, filter, field, and object, unless the widget opts
18+
out via `filterBindings: { <name>: false }` or re-targets to an existing field —
19+
mirroring the field-existence invariant ADR-0032 enforces for CEL references.
20+
Effective-field resolution matches the runtime (`filterBindings` re-target /
21+
opt-out, legacy `targetWidgets` allow-list, filter default). Registry-injected
22+
system fields (e.g. `created_at`, the `dateRange` default) and objects outside
23+
the validated stack never false-positive.
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
---
2+
---
3+
4+
Add an end-to-end regression test that boots a real hono server (ObjectQL + messaging + dispatcher) and drives the in-app notifications mark-read flow over HTTP, asserting `sys_notification_receipt` rows flip to `read` and the unread count drops (#3362). Test-only (plus a test-scoped devDependency); no runtime code changes, so this releases nothing.
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
---
2+
"@objectstack/observability": patch
3+
"@objectstack/rest": patch
4+
"@objectstack/plugin-hono-server": patch
5+
"@objectstack/runtime": patch
6+
---
7+
8+
fix(server-timing): emit the per-request, admin-gated `Server-Timing` header on the standard server (`os serve`/`dev`) (#3361)
9+
10+
The per-request `Server-Timing` path (#2408) — where an admin sends
11+
`X-OS-Debug-Timing: 1` (or `json`) and gets phase timings while an ordinary user
12+
gets nothing — never emitted on the shipped Hono server. The disclosure gate the
13+
Hono middleware opens is only ever flipped by the runtime dispatcher's
14+
`timedResolveExecutionContext`, but the data (`/api/v1/data/*`) and metadata
15+
(`/api/v1/meta/*`) routes on `os serve`/`dev` are served by `@objectstack/rest`'s
16+
`RestServer` (which shadows the Hono plugin's own CRUD), and its identity
17+
resolver never opened the gate. Only global mode (`OS_SERVER_TIMING=true`) — which
18+
discloses to *every* caller, not just admins — worked.
19+
20+
- **observability**: the disclosure predicate `isPerfDisclosurePrincipal(ec)` now
21+
lives here (the home of the gate), the single definition of "who may pull
22+
per-request timings" shared by every HTTP entry point. `@objectstack/runtime`
23+
re-exports it for back-compat.
24+
- **rest**: `RestServer.resolveExecCtx` opens the gate for an admin/service
25+
principal (via the carried `posture` rung), the REST-server analog of the
26+
dispatcher — this is the fix that makes `os serve`/`dev` emit.
27+
- **plugin-hono-server**: the standalone CRUD surface's self-contained
28+
`resolveCtx` opens the gate too (deriving the rung for the gate decision only,
29+
never writing it onto the enforcement context). Adds an e2e test that boots the
30+
Hono app and asserts an admin gets `Server-Timing` while a member/anon does not.

content/docs/getting-started/validating-metadata.mdx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,16 @@ If a name doesn't resolve, the chart renders **empty** — no error (ADR-0021).
4848
`os validate` resolves every binding against the declared datasets and fails on a
4949
dangling one.
5050

51+
The same gate also checks **dashboard-level filter fields**. A dashboard's
52+
`dateRange` and each `globalFilters[]` entry are broadcast into every widget's
53+
analytics query, so a filter field that doesn't exist on a bound widget's object
54+
emits invalid SQL (`no such column: …`) and crashes that widget at render time.
55+
`os validate` fails when a filter's effective field — after any per-widget
56+
`filterBindings` re-target — is absent on the widget's dataset object, naming the
57+
dashboard, widget, filter, field, and object. Opt a widget out with
58+
`filterBindings: { <name>: false }`, or re-target the filter to one of that
59+
object's own fields.
60+
5161
### 3. Dead action/route references
5262

5363
A dashboard `header.actions[]` button (and a widget's `actionUrl`) names a target:

packages/cli/src/commands/compile.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { lintFlowPatterns } from '../utils/lint-flow-patterns.js';
1818
import { lintAutonumberFormats } from '../utils/lint-autonumber-formats.js';
1919
import { lintLivenessProperties } from '../utils/lint-liveness-properties.js';
2020
import { lintViewRefs } from '../utils/lint-view-refs.js';
21+
import { preflightRequiredCapabilities, renderCapabilityMessage } from '../utils/capability-preflight.js';
2122
import { collectAndLintDocs } from '../utils/collect-docs.js';
2223
import { buildRuntimeBundle, cleanupOldRuntimeBundles } from '../utils/build-runtime.js';
2324
import {
@@ -171,6 +172,45 @@ export default class Compile extends Command {
171172
}
172173
}
173174

175+
// 3b-ter. [#3366] Installable-provider preflight. Every capability the app
176+
// DECLARES in `requires: [...]` must have a provider resolvable in the
177+
// active edition. `os validate` only checks the token vocabulary and
178+
// `os build` never resolved providers, so a `requires` entry whose
179+
// provider has NO installable version in this edition (e.g. `ai` →
180+
// @objectstack/service-ai, cloud-only since ADR-0025) slipped through
181+
// to a generic `os start` crash. Fail the build with the edition-aware
182+
// message instead; an absent-but-installable provider is a `pnpm add`
183+
// hint (advisory), and a satisfied list passes silently.
184+
if (!flags.json) printStep('Checking capability providers (#3366)...');
185+
const capPreflight = preflightRequiredCapabilities({
186+
requires: Array.isArray((config as { requires?: unknown[] }).requires)
187+
? ((config as { requires?: unknown[] }).requires as unknown[])
188+
: [],
189+
projectDir: path.dirname(absolutePath),
190+
});
191+
if (capPreflight.errors.length > 0) {
192+
if (flags.json) {
193+
console.log(JSON.stringify({
194+
success: false,
195+
error: 'capability provider preflight failed',
196+
issues: capPreflight.errors.map((c) => ({ token: c.token, message: renderCapabilityMessage(c) })),
197+
}));
198+
this.exit(1);
199+
}
200+
console.log('');
201+
printError(`Capability provider check failed (${capPreflight.errors.length} issue${capPreflight.errors.length > 1 ? 's' : ''})`);
202+
for (const c of capPreflight.errors) {
203+
console.log(` • ${renderCapabilityMessage(c)}`);
204+
}
205+
this.exit(1);
206+
}
207+
if (capPreflight.warnings.length > 0 && !flags.json) {
208+
console.log('');
209+
for (const c of capPreflight.warnings) {
210+
printWarning(renderCapabilityMessage(c));
211+
}
212+
}
213+
174214
// 3b-bis. ADR-0089 D3b — deprecated visibility aliases + mis-layered
175215
// binding root. Checked on `normalized` (PRE-parse): the schema folds
176216
// `visibleOn`/`visibility` into `visibleWhen` at parse, so `result.data`

packages/cli/src/commands/serve.ts

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { isHostConfig, shouldBootWithLibrary } from '../utils/plugin-detection.j
1111
import { resolveDriverType, createStorageDriver, UnsupportedDriverError } from '../utils/storage-driver.js';
1212
import { readEnvWithDeprecation, resolveMultiOrgEnabled, resolveAllowDegradedTenancy, isMcpServerEnabled, resolveSearchPinyinEnabled, isModuleNotFoundError } from '@objectstack/types';
1313
import { PLATFORM_CAPABILITY_TOKENS } from '@objectstack/spec/kernel';
14+
import { missingProviderMessage } from '../utils/capability-preflight.js';
1415
import { resolveObjectStackHome } from '@objectstack/runtime';
1516
import { LOG_LEVELS, resolveLogLevel, readLogLevelEnv } from '../utils/log-level.js';
1617
import {
@@ -1840,7 +1841,7 @@ export default class Serve extends Command {
18401841
const loadOptionalServicePlugin = async (
18411842
pkg: string,
18421843
exportName: string,
1843-
opts: { required: boolean; label: string; track: string },
1844+
opts: { required: boolean; label: string; track: string; capability: string },
18441845
): Promise<boolean> => {
18451846
try {
18461847
const mod: any = await importFromHost(pkg);
@@ -1860,9 +1861,12 @@ export default class Serve extends Command {
18601861
if (opts.required) {
18611862
const msg = err instanceof Error ? err.message : String(err);
18621863
throw new Error(
1864+
// #3366 — an absent provider is classified by edition: a cloud-only
1865+
// capability (e.g. `ai` → @objectstack/service-ai) says so instead of
1866+
// the un-followable "add it to your dependencies". Same wording the
1867+
// `os build` preflight prints, so boot and preflight read identically.
18631868
Serve.isModuleNotFoundError(err)
1864-
? `[${opts.label}] required but ${pkg} is not installed. `
1865-
+ `Add it to the app's dependencies, or drop the capability from \`requires\`.`
1869+
? missingProviderMessage(opts.capability)
18661870
: `[${opts.label}] failed to start: ${msg}`,
18671871
);
18681872
}
@@ -1900,7 +1904,7 @@ export default class Serve extends Command {
19001904
const aiLoaded = await loadOptionalServicePlugin(
19011905
'@objectstack/service-ai',
19021906
'AIServicePlugin',
1903-
{ required: aiDecision === 'required', label: 'AI', track: 'AIService' },
1907+
{ required: aiDecision === 'required', label: 'AI', track: 'AIService', capability: 'ai' },
19041908
);
19051909

19061910
// AI Studio (AI-driven metadata authoring / "online development") builds on
@@ -1927,7 +1931,7 @@ export default class Serve extends Command {
19271931
await loadOptionalServicePlugin(
19281932
'@objectstack/service-ai-studio',
19291933
'AIStudioPlugin',
1930-
{ required: studioDecision === 'required', label: 'AI Studio', track: 'AIStudio' },
1934+
{ required: studioDecision === 'required', label: 'AI Studio', track: 'AIStudio', capability: 'ai-studio' },
19311935
);
19321936
}
19331937
}
@@ -2100,9 +2104,12 @@ export default class Serve extends Command {
21002104
// best-effort: absent ⇒ warn, crash ⇒ error, boot continues.
21012105
if (declaredRequires.has(cap)) {
21022106
throw new Error(
2107+
// #3366 — edition-aware message via the shared classifier (same
2108+
// wording the `os build` preflight prints). For these CAPABILITY_
2109+
// PROVIDERS tokens (all open-edition) that's the `pnpm add` hint;
2110+
// the cloud-only tokens surface their edition boundary instead.
21032111
missing
2104-
? `Capability "${cap}" is required but ${spec.pkg} is not installed. `
2105-
+ `Add it to the app's dependencies, or remove "${cap}" from \`requires\`.`
2112+
? missingProviderMessage(cap)
21062113
: `Capability "${cap}" (${spec.pkg}) failed to start: ${msg}`,
21072114
);
21082115
}

packages/cli/src/commands/validate.ts

Lines changed: 45 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import { Args, Command, Flags } from '@oclif/core';
44
import { existsSync, readFileSync } from 'node:fs';
55
import { createRequire } from 'node:module';
6-
import { join } from 'node:path';
6+
import { join, dirname } from 'node:path';
77
import chalk from 'chalk';
88
import { ZodError } from 'zod';
99
import { ObjectStackDefinitionSchema, normalizeStackInput, type ConversionNotice } from '@objectstack/spec';
@@ -19,6 +19,7 @@ import { validateCapabilityReferences } from '@objectstack/lint';
1919
import { validateVisibilityPredicates } from '@objectstack/lint';
2020
import { validateSecurityPosture } from '@objectstack/lint';
2121
import { validateFlowTriggerReadiness } from '@objectstack/lint';
22+
import { preflightRequiredCapabilities, renderCapabilityMessage } from '../utils/capability-preflight.js';
2223
import {
2324
printHeader,
2425
printKV,
@@ -460,6 +461,42 @@ export default class Validate extends Command {
460461
}
461462
}
462463

464+
// 3h. [#3366] Installable-provider preflight — the shift-left of the
465+
// `serve`-time capability check. `os validate` previously only checked
466+
// the `requires` tokens against the vocabulary (ADR-0066), never
467+
// whether each token's provider is resolvable in the active edition. A
468+
// token whose provider has NO installable version here (e.g. `ai` →
469+
// @objectstack/service-ai, cloud-only) fails; absent-but-installable is
470+
// an advisory `pnpm add` hint. Mirrors the `os build` gate exactly.
471+
if (!flags.json) printStep('Checking capability providers (#3366)...');
472+
const capProviderPreflight = preflightRequiredCapabilities({
473+
requires: Array.isArray((config as { requires?: unknown[] }).requires)
474+
? ((config as { requires?: unknown[] }).requires as unknown[])
475+
: [],
476+
projectDir: dirname(absolutePath),
477+
});
478+
const capProviderErrors = capProviderPreflight.errors;
479+
const capProviderWarnings = capProviderPreflight.warnings.map((c) => ({
480+
token: c.token,
481+
message: renderCapabilityMessage(c),
482+
}));
483+
if (capProviderErrors.length > 0) {
484+
if (flags.json) {
485+
console.log(JSON.stringify({
486+
valid: false,
487+
errors: capProviderErrors.map((c) => ({ token: c.token, message: renderCapabilityMessage(c) })),
488+
duration: timer.elapsed(),
489+
}, null, 2));
490+
this.exit(1);
491+
}
492+
console.log('');
493+
printError(`Capability provider check failed (${capProviderErrors.length} issue${capProviderErrors.length > 1 ? 's' : ''})`);
494+
for (const c of capProviderErrors) {
495+
console.log(` • ${renderCapabilityMessage(c)}`);
496+
}
497+
this.exit(1);
498+
}
499+
463500
// 4. Collect and display stats
464501
const stats = collectMetadataStats(config);
465502

@@ -472,7 +509,7 @@ export default class Validate extends Command {
472509
valid: true,
473510
manifest: config.manifest,
474511
stats,
475-
warnings: [...exprWarnings, ...widgetWarnings, ...actionRefWarnings, ...styleWarnings, ...jsxWarnings, ...capWarnings, ...flowReadinessWarnings, ...securityAdvisories],
512+
warnings: [...exprWarnings, ...widgetWarnings, ...actionRefWarnings, ...styleWarnings, ...jsxWarnings, ...capWarnings, ...flowReadinessWarnings, ...securityAdvisories, ...capProviderWarnings],
476513
conversions: conversionNotices,
477514
specVersionGap: specGap,
478515
duration: timer.elapsed(),
@@ -483,6 +520,12 @@ export default class Validate extends Command {
483520
// 5. Warnings (non-blocking)
484521
const warnings: string[] = [];
485522

523+
// [#3366] Installable-provider hints — a declared capability whose provider
524+
// is absent but addable (`pnpm add`), or an unknown token (typo).
525+
for (const w of capProviderWarnings) {
526+
warnings.push(w.message);
527+
}
528+
486529
// ADR-0089 D3b — deprecated visibility aliases + mis-layered binding root.
487530
// Checked on `normalized` (PRE-parse): the schema folds `visibleOn`/
488531
// `visibility` into `visibleWhen` during parse, so `result.data` no longer

0 commit comments

Comments
 (0)