Skip to content

Commit 9ddffc0

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-23050-s7-discovery-endpoints
2 parents a9073d8 + 2906cd8 commit 9ddffc0

26 files changed

Lines changed: 2049 additions & 37 deletions
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
---
2+
'@objectstack/metadata-protocol': minor
3+
---
4+
5+
fix(metadata-protocol)!: a save naming a package of a runtime object whose owner is its package-less definition is refused before any write, with a way out that moves the object into that package (ADR-0029 D9.9)
6+
7+
Clause-②: no (narrowing)
8+
9+
<!-- adr-0087: not-required (no-migration-prescription) No metadata moves: no spec key, authorable spelling, object definition or stored shape is added, removed, renamed or re-shaped, no stored row is rewritten, converted or deleted by this change, and no exported declaration is added, removed or narrowed, so there is nothing for `objectstack migrate meta` to rewrite. What narrows is the accept set of the object save door for one shape: a save naming a package of an object whose registry owner is its package-less definition (the `sys_metadata` sentinel), or a save naming no package that the door addresses to such a package's row, is refused, where it was written and then discarded by the registry. The remedy is in the requests a caller sends (deletes and saves through the same doors), never a rewrite of a package source or a stored row. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id is named or touched (not registered or already-registered); and nothing exported is removed or narrowed (not runtime-interface-only, type-surface-only or runtime-export-retired). -->
10+
11+
**BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes.
12+
13+
An object has exactly one registry entry, so it has exactly one owner (ADR-0029 D3), and `sys_metadata` must not describe a shape the registry cannot hold (D9.9). A runtime object first saved naming no package is owned by that package-less row. A save naming package `Q` beside it was written and answered `200`, but the registry refused `Q`'s row into a warning and kept the package-less definition. The metadata read then served `Q`'s row while the data plane served the package-less one, before and after a boot, and at boot `Q`'s row was counted in `errors`. The package-less state is no authoring destination (ADR-0070), so the refusal does not say "bind it to `sys_metadata`". It names the way into `Q` that is true in the state it answers.
14+
15+
What changes for you, where object `NAME` is owned by its package-less definition and `Q` is any package. Every refusal below is `422 OBJECT_OVERLAY_PACKAGE_MISMATCH` with nothing written. The error carries `packageId: Q` and `ownerPackageId: null`.
16+
17+
- With no package's row stored beside the package-less one: FROM `PUT /api/v1/meta/object/NAME?package=Q` (`saveMetaItem({ type: 'object', name: NAME, packageId: Q, … })`) answering `200` "Saved object … package=Q" with a `Q`-bound row stored that nothing served, TO the refusal: "Move object 'NAME' into package 'Q' first: delete its package-less definition without dropStorage, which keeps its table and records … then send this save again. …".
18+
- FROM the same save as a draft (`PUT /api/v1/meta/object/NAME?package=Q&mode=draft`, Studio's designer) answering `200` with a `Q` draft that could never go live, TO the refusal: "Move object 'NAME' into package 'Q' first: … then save it naming 'Q' in publish mode, which serves it at once, and draft after that. …".
19+
- Where rows bound to packages are already stored beside the package-less one (the rows this defect wrote before the refusal existed, bound to `Q` or to any other package `P`), every refusal names each such row's own `DELETE …?package=` before the package-less `DELETE`. A `DELETE` naming no package over those rows does not reach the package-less definition: over one package's row it removes that row (its receipt says "(package=P), and it still exists"), and over two it is refused `409 RESOURCE_CONFLICT` ("Name the package this delete is for …"). Two saves change:
20+
- FROM `PUT /api/v1/meta/object/NAME?package=Q` (publish or draft) answering `200`, TO the refusal: "Object 'NAME' is served by its package-less definition; its definitions bound to 'P', 'Q' are never served. To move it into 'Q': DELETE /api/v1/meta/object/NAME?package=P, then ?package=Q, then the same DELETE with no package and no dropStorage (the table and records stay), then save it naming 'Q' in publish mode. …" (with one row, its own package only).
21+
- FROM `PUT /api/v1/meta/object/NAME` naming NO package answering `200`, TO the refusal, where one package's row is stored. The door addresses a save naming no package to the item's one package binding, so the edit was written to that row and never served. Over two bindings this save is refused `409` before, as now. The refusal reads: "A save naming no package of object 'NAME' is addressed to its stored definition bound to 'Q', which is never served: … Delete the 'Q' row first (REST: DELETE /api/v1/meta/object/NAME?package=Q), then send this save again: it then saves the package-less definition. …".
22+
- FROM promoting `Q`'s draft of the object (`POST /api/v1/meta/object/NAME/publish?package=Q`, or `POST /api/v1/packages/Q/publish-drafts`) answering success with `Q`'s active row stored beside the package-less one and never served, TO the refusal. This reaches a draft stored before the package-less row existed, or one a draft save naming no package wrote over the rows above. The per-item publish answers `422`; `publish-drafts` answers `200` with `outcome: 'refused'` and the draft in `failed[]` with this code (ADR-0067 D2). The text reads "Move object 'NAME' into package 'Q' first: delete its package-less definition without dropStorage … then POST /api/v1/meta/object/NAME/publish?package=Q: the draft then serves the object at once. …". Where any package's active row is also stored, it names deleting each such row first, as above, then the same `POST …/publish?package=Q`. The publish step always names the draft's package: a publish naming none resolves by the rows the deletes leave, and over `[package-less, P]` with both `P`'s draft and a draft bound to no package it would then promote the other draft. The draft stays stored.
23+
- A publish naming NO package of a draft bound to no package, over one package's row `P`, is addressed into `P` (the draft doors adopt the unbound draft into the one binding), and is refused the same way. No publish moves that draft into `P`: with `P`'s row deleted it lands in the package-less definition, and a publish naming `P` answers `404 NO_DRAFT`. So its text says that: "This draft of object 'NAME' is bound to no package; a publish naming no package is addressed into 'P', whose row is never served. DELETE /api/v1/meta/object/NAME?package=P, then publish again: the draft lands in the package-less definition. To move it into 'P' then: the same DELETE with no package and no dropStorage (records stay), then save it naming 'P' in publish mode. …".
24+
- The boot's reason for those rows changes. `loadMetaFromDb` still keeps the package-less owner and counts each package's row in `errors`. Its warning, `[Protocol] Failed to hydrate object/NAME: … (code=OBJECT_OVERLAY_PACKAGE_MISMATCH)`, now carries the move above, naming every package's row, and ends "The row bound to 'Q' is not registered.". It carried the registry's "already owned by package \"sys_metadata\"" text.
25+
26+
**Remedy, in one line per state.** Do not send the request again unchanged; follow its order. With no package's row stored: `DELETE /api/v1/meta/object/NAME` (no `package`, no `dropStorage`), then `PUT /api/v1/meta/object/NAME?package=Q` in publish mode. That publish-mode save makes `Q`'s row the owner at once and at every boot; a draft save registers nothing, so draft only after it. With rows bound to packages stored (`Q` or any other): first `DELETE /api/v1/meta/object/NAME?package=PKG` for each such package, then as above. Where the one stored row is the one a save naming no package was addressed to, after its delete that save may instead be sent again, and it edits the package-less definition. For a refused publish of `Q`'s draft (whether the request named `Q` or the address resolved to it): the same deletes for its state, then `POST /api/v1/meta/object/NAME/publish?package=Q`; the draft's promotion makes `Q`'s row the owner. For a refused publish naming no package of a draft bound to no package: `DELETE …?package=P`, then the same publish (the draft lands package-less), then the package-less `DELETE` and a publish-mode save naming `P` if the object belongs in `P`. Never rely on a `DELETE` naming no package while a package's row is stored: it removes that row, or is refused over two. No step drops a table or a record.
27+
28+
**Unchanged.**
29+
30+
- A save naming no package where only the package-less row is stored (its own re-save), in either mode.
31+
- The D9.9 refusal for an owner bound to a real package: same code, status and prescription.
32+
- An object with no owner, and an object a code package ships.
33+
- Every type except `object`.
34+
- Stored rows are not rewritten on upgrade.
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@objectstack/cli': patch
3+
---
4+
5+
fix(cli): `os serve`'s ready banner prints an `API:` or MCP address only when an HTTP server is composed, and otherwise says once that none is served
6+
7+
Clause-②: no
8+
9+
- **Before.** `os serve --no-server` printed `➜ API: http://localhost:PORT/` and `➜ MCP: http://localhost:PORT/api/v1/mcp` with its "connect an AI client" hint, though `--no-server` had started no HTTP server and nothing listened on that port. An operator, or an AI client configured from the banner, was sent to an address that refused the connection.
10+
- **After, with no HTTP server composed.** The banner prints no `API:`, `Console:` or `MCP:` row, and no connect hint or paths-only note. It prints one line instead: `➜ No HTTP server: --no-server was given and no plugin composed one, so no API or MCP address is served`. The line names `--no-server` only when that flag is the reason; a boot that asked for a server but composed none says `no plugin composed one`.
11+
- **After, with an HTTP server composed.** The address rows are unchanged. That includes a `--no-server` boot whose config mounts its own `HonoServerPlugin`, because the banner checks the booted kernel for a registered `http.server` service rather than reading the flag.
12+
13+
The ready line (`✓ Server is ready` or `⚠ … DEGRADED`) and every row that names no address are unchanged. So are the `objectstack:listening` IPC message and the `runtime.<environment>.<project>.json` state file.
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
'@objectstack/driver-sql': patch
3+
---
4+
5+
fix(driver-sql): a SQLite database file the driver cannot create or open fails `connect()` at once, naming the file and the operating system's error (#23696)
6+
7+
Clause-②: no
8+
9+
A declared SQLite datasource whose `config.filename` lay under a directory the operating system will not make (for example `/proc/x/ds.db`) never finished connecting. The driver made the directory with Node's recursive `mkdir`, which loops without end on such a path: `mkdir` of the level answers `ENOENT`, its parent answers `EEXIST`, and it retries, about 1,600 failing calls a second, all system CPU. The boot was then refused only by a plugin start timeout (30 s, or 120 s when a `datasourceMapping` rule routes to the datasource), and that message named neither the datasource, the path nor the error. The same happened to the host's own database when `OS_DATABASE_URL=file:` named such a path.
10+
11+
**What changes**
12+
13+
- The directory is made by a walk bounded by the path's depth, one non-recursive `mkdir` per missing level. The first level the operating system refuses fails `connect()` with a `SqliteDatabaseFileError` that names the file, the directory and the cause (`ENOENT`, `EACCES`, `ENOTDIR`, …).
14+
- A file SQLite itself cannot open (`SQLITE_CANTOPEN`, for example a filename that is a directory) now fails `connect()` the same way. Before, the error was logged as a warning and `connect()` resolved, so the datasource counted as connected while it could not answer one query. A native-addon load failure keeps its warning and the step-down to wasm SQLite.
15+
16+
The boot's existing connect refusal (ADR-0062 D5) carries this message, so `objectstack serve` on such a config now prints, at once, `datasource 'primary': connect failed — SQLite database file '/proc/x/ds.db' cannot be created: its directory '/proc/x' could not be made: ENOENT: no such file or directory, mkdir '/proc/x'`. No configuration key or accepted value changes. A path the operating system can create still connects.

‎packages/cli/src/commands/dev-mcp-connect-hint-origin.test.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ const HERE = dirname(fileURLToPath(import.meta.url));
4242
const DEV_SOURCE = readFileSync(resolve(HERE, 'dev.ts'), 'utf8');
4343

4444
const bannerOpts: Omit<ServerReadyOptions, 'externalBaseOrigin'> = {
45+
httpServer: 'composed',
4546
configFile: 'objectstack.config.ts',
4647
isDev: true,
4748
pluginCount: 1,

‎packages/cli/src/commands/serve-automation-shadowing.test.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,7 @@ describe('collectAutomationSummary — flow-name shadowing (#12028)', () => {
167167
describe('startup banner — what an operator reads when a flow name is contested (#12028)', () => {
168168
const base: ServerReadyOptions = {
169169
externalBaseOrigin: 'http://localhost:3000',
170+
httpServer: 'composed',
170171
configFile: 'objectstack.config.ts',
171172
isDev: true,
172173
pluginCount: 1,

‎packages/cli/src/commands/serve-banner-external-base-url.test.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ describe('server-ready banner external base URL (#10646)', () => {
2525
const saved: Partial<Record<(typeof TOUCHED)[number], string | undefined>> = {};
2626

2727
const bannerOpts: Omit<ServerReadyOptions, 'externalBaseOrigin'> = {
28+
httpServer: 'composed',
2829
configFile: 'objectstack.config.ts',
2930
isDev: false,
3031
pluginCount: 1,
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { describe, expect, it } from 'vitest';
4+
import { readComposedHttpServer, resolveBoundPort, resolveHttpServerComposition } from './serve.js';
5+
6+
/**
7+
* #23623 — whether a boot serves HTTP is read off the booted kernel, never off
8+
* the `--server` flag.
9+
*
10+
* The flag says whether SERVE composes its own `HonoServerPlugin`. A config
11+
* can mount one itself under `--no-server`, and then an `http.server` service
12+
* is registered and the banner's addresses must still print. The flag only
13+
* says WHY there is none, so the banner names `--no-server` when that is the
14+
* cause and not otherwise.
15+
*/
16+
17+
/** A kernel whose `getService` behaves like `ObjectKernel`'s: it THROWS for an unregistered name. */
18+
function kernelWith(services: Record<string, unknown>) {
19+
const asked: string[] = [];
20+
return {
21+
asked,
22+
kernel: {
23+
getService(name: string): unknown {
24+
asked.push(name);
25+
if (!(name in services)) throw new Error(`Service '${name}' not found`);
26+
return services[name];
27+
},
28+
},
29+
};
30+
}
31+
32+
const TRANSPORT = { getPort: () => 41234 };
33+
34+
describe('#23623 resolveHttpServerComposition', () => {
35+
it('a registered http.server is composed, whatever the flag says', () => {
36+
// `--no-server` with a config-mounted transport: the address is real.
37+
expect(resolveHttpServerComposition(kernelWith({ 'http.server': TRANSPORT }).kernel, false)).toBe('composed');
38+
expect(resolveHttpServerComposition(kernelWith({ 'http.server': TRANSPORT }).kernel, true)).toBe('composed');
39+
});
40+
41+
it('none registered under --no-server is the flag\'s doing', () => {
42+
expect(resolveHttpServerComposition(kernelWith({}).kernel, false)).toBe('skipped-by-no-server');
43+
});
44+
45+
it('none registered with the flag on is NOT blamed on the flag', () => {
46+
expect(resolveHttpServerComposition(kernelWith({}).kernel, true)).toBe('not-composed');
47+
});
48+
49+
it('a kernel that answers undefined, or has no getService, composes nothing', () => {
50+
expect(resolveHttpServerComposition({ getService: () => undefined }, true)).toBe('not-composed');
51+
expect(resolveHttpServerComposition(undefined, false)).toBe('skipped-by-no-server');
52+
expect(resolveHttpServerComposition({}, true)).toBe('not-composed');
53+
});
54+
55+
it('asks for the canonical service name only, as resolveBoundPort does', () => {
56+
const a = kernelWith({ 'http.server': TRANSPORT });
57+
resolveHttpServerComposition(a.kernel, false);
58+
const b = kernelWith({ 'http.server': TRANSPORT });
59+
resolveBoundPort(b.kernel, 0);
60+
expect(a.asked).toEqual(['http.server']);
61+
expect(b.asked).toEqual(a.asked);
62+
// Registered only under the deprecated alias (#4251) is not composed.
63+
expect(resolveHttpServerComposition(kernelWith({ 'http-server': TRANSPORT }).kernel, true)).toBe('not-composed');
64+
});
65+
66+
it('the one read hands back the registered instance, so the port and the decision share it', () => {
67+
expect(readComposedHttpServer(kernelWith({ 'http.server': TRANSPORT }).kernel)).toBe(TRANSPORT);
68+
expect(readComposedHttpServer(kernelWith({}).kernel)).toBeUndefined();
69+
expect(resolveBoundPort(kernelWith({ 'http.server': TRANSPORT }).kernel, 0)).toBe(41234);
70+
});
71+
});

0 commit comments

Comments
 (0)