Skip to content

Commit 05ac40c

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-24024-anonymous-floor-notifications-share-links
2 parents 521c8e3 + 5c92b01 commit 05ac40c

11 files changed

Lines changed: 513 additions & 57 deletions
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@objectstack/cli': patch
3+
---
4+
5+
`os serve --no-server` no longer reports an ordering fault for the datasource admin routes or for the unknown-hostname guard. A boot that composes no HTTP server used to print, in its boot diagnostics, `WARN [datasource-admin] route registration failed: [Kernel] Service 'http.server' not found (… a composition/ordering fault; registration order is not a contract.)`, although nothing was at fault. With `OS_ROOT_DOMAIN` set, the same boot also printed `WARN [unknown-hostname-guard] install failed` with the same kernel text. Each now prints its own degrade line, beside the same boot's other "not mounted" lines: `WARN [datasource-admin] http.server unavailable; REST routes not installed`, and `WARN [unknown-hostname-guard] http.server unavailable; guard not installed`.
6+
7+
What still warns: on a boot that does compose an HTTP server, a route registration that throws still prints `[datasource-admin] route registration failed` with the error, and a guard install that throws still prints `[unknown-hostname-guard] install failed` with the error. A composed boot mounts `/api/v1/datasources` and installs the hostname guard as before, including under `--no-server` when the config mounts its own `HonoServerPlugin`. Both plugins now read only the canonical `http.server` service. Their old fallback to the deprecated `http-server` alias never ran: on a boot without `http.server` the canonical read threw first. So every boot that mounted these routes or installed the guard before still does.
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@objectstack/spec': patch
3+
---
4+
5+
Clause-②: no
6+
7+
The `ActionParam` `options` docblock in `src/ui/action.zod.ts`, and the comment above that option
8+
shape's `guidance` table, no longer say that objectui's `normaliseOptions` rebuilds each option a
9+
field-backed param inherits as a bare `{ label, value }` pair. At the console pin (`.objectui-sha`
10+
`f8267df93aba`) it keeps every key of an inherited option by spread
11+
(`packages/fields/src/resolveActionParams.ts:515` in objectui). The comments now give the reason
12+
that remains for the `guidance` texts not telling an author to make a param field-backed and
13+
inherit `color`, `icon` or `disabled`: the keys survive, but no reader of them on that path is
14+
measured. No key, schema, export, accepted value or message text changes.

‎examples/app-showcase/src/ui/pages/invoice-document.page.ts‎

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,19 @@ export const InvoiceDocumentPage = definePage({
5757
properties: {
5858
childObject: 'showcase_invoice_line',
5959
relationshipField: 'invoice',
60-
columns: [{ name: 'description' }, { name: 'quantity' }, { name: 'unit_price' }, { name: 'amount' }],
60+
// Each column declares its heading. Unlike the master-detail grid
61+
// (`showcase_invoice.lines.inlineColumns`), this block does not
62+
// fill a column from the child field, so an identity-only
63+
// `{ name }` column prints its raw field name. The labels are the
64+
// invoice line fields' own. They read in English in every locale:
65+
// a column `label` is a plain string, and no translation bundle
66+
// key reaches a `record:line_items` column.
67+
columns: [
68+
{ name: 'description', label: 'Description' },
69+
{ name: 'quantity', label: 'Qty' },
70+
{ name: 'unit_price', label: 'Unit Price' },
71+
{ name: 'amount', label: 'Amount' },
72+
],
6173
readonly: true,
6274
},
6375
},
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { describe, it, expect } from 'vitest';
4+
import { RecordLineItemsProps } from '@objectstack/spec/ui';
5+
6+
import { InvoiceDocumentPage } from '../src/ui/pages/index.js';
7+
import { InvoiceLine } from '../src/data/objects/invoice.object.js';
8+
9+
/**
10+
* The invoice print page heads its line-items table with labels.
11+
*
12+
* `record:line_items` draws a column exactly as declared. Unlike the
13+
* master-detail grid, it does not fill a column from the child object's
14+
* field, so a column with no `label` is headed by its raw field name: the
15+
* printed invoice read `description` / `quantity` / `unit_price` / `amount`.
16+
*
17+
* The pin holds each column to the label its own child field declares, and
18+
* never to the field name. Deriving the expected heading from the field keeps
19+
* the print in step with the master-detail grid, which hydrates the same
20+
* labels for the same fields.
21+
*/
22+
23+
type Column = Record<string, unknown>;
24+
type Component = { type?: unknown; properties?: Record<string, unknown> };
25+
26+
const page = InvoiceDocumentPage as unknown as { regions?: { components?: Component[] }[] };
27+
28+
const lineItemBlocks = (page.regions ?? [])
29+
.flatMap((region) => region.components ?? [])
30+
.filter((component) => component.type === 'record:line_items');
31+
32+
const columnsOf = (block: Component | undefined): Column[] =>
33+
(Array.isArray(block?.properties?.columns) ? block!.properties!.columns : []) as Column[];
34+
35+
const childFields = (InvoiceLine.fields ?? {}) as Record<string, { label?: unknown }>;
36+
37+
describe('Invoice print page — the line-items table is headed by labels, not field names', () => {
38+
const [block, ...rest] = lineItemBlocks;
39+
40+
it('carries one line-items block over the invoice lines', () => {
41+
expect(block, 'the invoice print page must carry its line-items table').toBeTruthy();
42+
expect(rest).toHaveLength(0);
43+
expect(block.properties?.childObject).toBe(InvoiceLine.name);
44+
expect(columnsOf(block).map((c) => c.name)).toEqual(['description', 'quantity', 'unit_price', 'amount']);
45+
});
46+
47+
it('heads every column with its child field\'s own label, never the field name', () => {
48+
for (const column of columnsOf(block)) {
49+
const name = column.name as string;
50+
const fieldLabel = childFields[name]?.label;
51+
expect(typeof fieldLabel, `showcase_invoice_line.${name} must declare a label`).toBe('string');
52+
expect(column.label, `column '${name}' must declare its heading`).toBe(fieldLabel);
53+
expect(column.label, `column '${name}' must not be headed by its field name`).not.toBe(name);
54+
}
55+
});
56+
57+
it('parses against the spec\'s `record:line_items` row with the labels intact', () => {
58+
const result = RecordLineItemsProps.safeParse(block.properties);
59+
expect(result.success, JSON.stringify(result.error?.issues)).toBe(true);
60+
expect(result.data).toEqual(block.properties);
61+
});
62+
});
Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #24022 — a boot with no HTTP server takes the datasource admin routes
5+
* plugin's own degrade line, never "route registration failed" with the
6+
* kernel's "composition/ordering fault" text.
7+
*
8+
* ## The defect
9+
*
10+
* `os serve <host config> --no-server --preset minimal` printed, in its boot
11+
* diagnostics, `WARN [datasource-admin] route registration failed: [Kernel]
12+
* Service 'http.server' not found (while plugin
13+
* 'com.objectstack.cli.datasource-admin-routes' was initializing — a
14+
* composition/ordering fault; …)`. Nothing was at fault: `--no-server` composed
15+
* no server on purpose. The plugin read the server with
16+
* `getService?.('http.server') ?? getService?.('http-server')`, and the
17+
* kernel's `getService` THROWS on a name nothing registered, so the `??` never
18+
* ran and the throw landed in the registration `catch`.
19+
*
20+
* ## What this file pins
21+
*
22+
* The plugin's `init` runs inside a REAL `ObjectKernel`, so the throw it must
23+
* survive is the kernel's own, with the kernel's own wording:
24+
*
25+
* 1. no server composed: one `[datasource-admin]` warning, the degrade line,
26+
* and no warning anywhere in the boot says "route registration failed" or
27+
* "ordering fault";
28+
* 2. CONTROL: a composed server gets the real routes under
29+
* `/api/v1/datasources`, and no `[datasource-admin]` warning;
30+
* 3. CONTROL: a composed server whose registration throws still warns
31+
* "route registration failed".
32+
*
33+
* The wiring inside `run()` is a source pin with comments masked, as
34+
* `serve-no-transport-announcements.test.ts` pins its call site. A request to
35+
* the mounted route on a real boot is pinned in
36+
* `serve-no-server-banner.integration.test.ts`.
37+
*/
38+
39+
import { afterEach, describe, expect, it, vi } from 'vitest';
40+
import { readFileSync } from 'node:fs';
41+
import { resolve } from 'node:path';
42+
import { fileURLToPath } from 'node:url';
43+
44+
import { ObjectKernel } from '@objectstack/core';
45+
import { registerDatasourceAdminRoutes } from '@objectstack/service-datasource';
46+
47+
// The repo's ONE code/prose separator: a comment naming the factory must not
48+
// answer for code that does not call it.
49+
import { maskComments } from '../../../../scripts/js-comment-mask.mjs';
50+
51+
import { createDatasourceAdminRoutesPlugin } from './serve.js';
52+
53+
/** …/packages/cli/src/commands — seeded from `import.meta.url`. */
54+
const HERE = resolve(fileURLToPath(import.meta.url), '..');
55+
/** `serve.ts`'s CODE, with every comment span blanked. */
56+
const SERVE = maskComments(readFileSync(resolve(HERE, 'serve.ts'), 'utf8'));
57+
58+
/** The one service a kernel refuses to start without. Nothing here reads data. */
59+
const DATA_STUB = {
60+
name: 'com.example.fx24022.data-stub',
61+
version: '1.0.0',
62+
init: async (ctx: { registerService(name: string, service: unknown): void }) => { ctx.registerService('data', {}); },
63+
start: async () => {},
64+
};
65+
66+
/** An `IHttpServer` stand-in that records every route registered on it. */
67+
function recordingServer(opts: { refuse?: boolean } = {}) {
68+
const routes: string[] = [];
69+
const add = (method: string) => (path: string) => {
70+
if (opts.refuse) throw new Error('route table sealed');
71+
routes.push(`${method} ${path}`);
72+
};
73+
return { routes, server: { get: add('GET'), post: add('POST'), patch: add('PATCH'), delete: add('DELETE') } };
74+
}
75+
76+
/** A stand-in for the composed transport, under the Hono plugin's name, registering `http.server`. */
77+
function serverPlugin(server: unknown) {
78+
return {
79+
name: 'com.objectstack.server.hono',
80+
version: '1.0.0',
81+
init: async (ctx: { registerService(name: string, service: unknown): void }) => { ctx.registerService('http.server', server); },
82+
start: async () => {},
83+
};
84+
}
85+
86+
/**
87+
* Boot a real kernel holding the routes plugin (and `extra`, registered AFTER
88+
* it), and return every string the kernel logger was asked to `warn`. The
89+
* plugin's `ctx.logger` IS the kernel's logger, so the spy sees the plugin's
90+
* own lines as well as the kernel's.
91+
*/
92+
async function bootWarnings(
93+
registerRoutes: (server: any, ctx: any, basePath: string) => void,
94+
extra: Array<ReturnType<typeof serverPlugin>> = [],
95+
): Promise<{ warnings: string[] }> {
96+
const kernel = new ObjectKernel({ logger: { level: 'silent' }, gracefulShutdown: false });
97+
const logger = (kernel as unknown as { logger: { warn: (...args: unknown[]) => void } }).logger;
98+
const warn = vi.spyOn(logger, 'warn');
99+
await kernel.use(DATA_STUB);
100+
await kernel.use(createDatasourceAdminRoutesPlugin(registerRoutes));
101+
for (const plugin of extra) await kernel.use(plugin);
102+
await kernel.bootstrap();
103+
const warnings = warn.mock.calls.map((args) => args.map((a) => (typeof a === 'string' ? a : JSON.stringify(a))).join(' '));
104+
await kernel.shutdown();
105+
return { warnings };
106+
}
107+
108+
const ours = (warnings: string[]) => warnings.filter((w) => w.includes('[datasource-admin]'));
109+
110+
afterEach(() => {
111+
vi.restoreAllMocks();
112+
});
113+
114+
describe('createDatasourceAdminRoutesPlugin — a boot with no HTTP server (#24022)', () => {
115+
it('the kernel this runs in THROWS for an unregistered http.server — the premise the read must survive', () => {
116+
const kernel = new ObjectKernel({ logger: { level: 'silent' }, gracefulShutdown: false });
117+
expect(() => kernel.getService('http.server')).toThrow(/not found/);
118+
});
119+
120+
it('takes its own degrade line: no "route registration failed", no "ordering fault"', async () => {
121+
const register = vi.fn();
122+
const { warnings } = await bootWarnings(register);
123+
const seen = `\n--- warnings ---\n${warnings.join('\n')}`;
124+
125+
expect(register, `routes registered on a boot with no server${seen}`).not.toHaveBeenCalled();
126+
expect(warnings.filter((w) => w.includes('route registration failed')), seen).toEqual([]);
127+
expect(warnings.filter((w) => w.includes('ordering fault')), seen).toEqual([]);
128+
// Kept on purpose (see the factory's docblock): one line naming the
129+
// surface left unmounted, beside the boot's other "not mounted" lines.
130+
expect(ours(warnings), `not exactly one [datasource-admin] line${seen}`).toHaveLength(1);
131+
});
132+
133+
it('asks for the canonical service name only — never the deprecated http-server alias', async () => {
134+
const asked: string[] = [];
135+
const warnings: string[] = [];
136+
await createDatasourceAdminRoutesPlugin(vi.fn()).init({
137+
getService: (name: string) => {
138+
asked.push(name);
139+
throw new Error(`[Kernel] Service '${name}' not found`);
140+
},
141+
logger: { warn: (msg: string) => { warnings.push(msg); } },
142+
});
143+
expect(asked).toEqual(['http.server']);
144+
expect(warnings).toHaveLength(1);
145+
});
146+
});
147+
148+
describe('createDatasourceAdminRoutesPlugin — a composed HTTP server (controls)', () => {
149+
it('CONTROL: mounts the real routes under /api/v1/datasources, ordered after the server plugin', async () => {
150+
const { routes, server } = recordingServer();
151+
// Registered AFTER the routes plugin: `optionalDependencies` orders it ahead.
152+
const { warnings } = await bootWarnings(registerDatasourceAdminRoutes, [serverPlugin(server)]);
153+
const seen = `\n--- warnings ---\n${warnings.join('\n')}`;
154+
155+
expect(routes, 'the list route was not mounted').toContain('GET /api/v1/datasources');
156+
expect(routes.every((r) => r.includes(' /api/v1/datasources')), routes.join('\n')).toBe(true);
157+
expect(ours(warnings), `a [datasource-admin] warning on a composed boot${seen}`).toEqual([]);
158+
});
159+
160+
it('CONTROL: a registration that genuinely throws still warns "route registration failed"', async () => {
161+
const { routes, server } = recordingServer({ refuse: true });
162+
const { warnings } = await bootWarnings(registerDatasourceAdminRoutes, [serverPlugin(server)]);
163+
const seen = `\n--- warnings ---\n${warnings.join('\n')}`;
164+
165+
expect(routes).toEqual([]);
166+
const said = ours(warnings);
167+
expect(said, `not exactly one [datasource-admin] line${seen}`).toHaveLength(1);
168+
expect(said[0], seen).toContain('route registration failed');
169+
expect(said[0], `the error itself is not named${seen}`).toContain('route table sealed');
170+
});
171+
});
172+
173+
describe('the wiring inside `run()` (#24022)', () => {
174+
it('serve mounts the routes through the factory, handing it the real registrar', () => {
175+
expect(SERVE).toContain('await kernel.use(createDatasourceAdminRoutesPlugin(registerDatasourceAdminRoutes));');
176+
expect(SERVE.match(/'com\.objectstack\.cli\.datasource-admin-routes'/g) ?? [], 'a second inline copy of the plugin').toHaveLength(1);
177+
});
178+
});

0 commit comments

Comments
 (0)