Skip to content

Commit f78457d

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/project-thread-xry5up
2 parents d305bbf + 8c9f48f commit f78457d

32 files changed

Lines changed: 992 additions & 138 deletions
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
'@objectstack/cli': patch
3+
---
4+
5+
fix(cli): `os dev` on an `os init -t app` scaffold no longer composes a second `MetadataPlugin` and warns "Plugin superseded: 'com.objectstack.metadata'"
6+
7+
Clause-②: no — boot diagnostics only; nothing accepted or published changes
8+
Metadata-protocol: none
9+
10+
The dev artifact door in `os serve` / `os dev` added its own `MetadataPlugin` unless the stack already held one, but it recognised that plugin only by its class name. The published `@objectstack/metadata` bundle names the class `_MetadataPlugin`, so on an installed CLI the check missed the `MetadataPlugin` the standalone stack had already composed, added a second one, and every dev boot of an untouched scaffold printed a "Plugin superseded" diagnostic blaming a composition the user never wrote. The dev door now asks the same question the host-config door asks: a plugin named `com.objectstack.metadata` (or, as before, a class named `MetadataPlugin`) means the stack has one. A host config with no `MetadataPlugin` of its own still gets the dev door, as before.
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
'@objectstack/runtime': patch
3+
---
4+
5+
fix(runtime): the empty kernel's own stub declares its protocol range, so `os start` in an empty directory boots with no compatibility warning
6+
7+
Clause-②: no — boot diagnostics only; nothing accepted or published changes
8+
Metadata-protocol: none
9+
10+
The empty-boot stub `createDefaultHostConfig({ requireArtifact: false })` writes to `~/.objectstack/dist/objectstack.json` (manifest id `com.objectstack.empty`) now declares `engines: { protocol: '^<major>' }`, the major of the runtime that writes it. Before, it declared no range, so the ADR-0087 handshake warned "package 'com.objectstack.empty' declares no engines.protocol range" on the first boot of the docs' one-line Community quickstart (`npm i -g @objectstack/cli && os start`), about a package the user never wrote. A stub already on disk from an older CLI is refreshed in place on the next `os start`, including when the start supervisor hands it down by path (it resolves `<home>/dist/objectstack.json` as an artifact once that file exists): only `manifest.engines.protocol` is rewritten, and only while the file at the home location is still the stub. The same refresh re-stamps a stub written under another protocol major, which would otherwise be refused as `OS_PROTOCOL_INCOMPATIBLE` once the runtime moves to the next major. An artifact that is not the stub, or a file that is not JSON, is left alone.
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
'@objectstack/metadata-protocol': minor
3+
---
4+
5+
fix(metadata-protocol): an email template at a locale the base does not ship keeps its package-less write on a fresh install; the named-base refusal names `os migrate packageless-overlays`
6+
7+
Clause-②: no
8+
Metadata-protocol: semantic
9+
10+
- **An added locale is writable before the pair exists.** Both overlay-write closes (the package-less write, and the write naming the read-only base with `?package=<base>`) stay open where the fix they would name cannot be taken: an `email_template` member at a locale the base does not ship is refused as a declared override, so its write stays open (ADR-0146 D11). The close asked the override write gate about the pair; on a fresh install the pair is not ensured yet, the gate's closure read answers nothing for a package that does not exist, and the close read that "not concerned" as "admitted". So `PUT /api/v1/meta/email_template/<name>` with a zh-CN body beside a base that ships en-US only answered `403 NOT_OVERRIDABLE`, naming a pair write that was refused too (`declared_package_ships_no_such_item`). The close now judges a pair that is not installed with the closure the ensure door gives it (the base and what the base reaches), so the write is accepted, as it is once the pair exists. A member the base ships is closed exactly as before.
11+
- **The named-base refusal names the ceremony.** `os migrate packageless-overlays` moves a row stored bound to the read-only base into the pair with its declaration. The sentence for a save, publish or rollback naming the base now says such a row "is served until moved by `os migrate packageless-overlays`, overridden by the pair, or deleted naming the base". One spelling, every door; the code and status (`403 NOT_OVERRIDABLE`) are unchanged.
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
'@object-ui/app-shell': patch
3+
---
4+
5+
fix(app-shell): the Console header, the app report index and the recent list draw a served report, page or dashboard label as served
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
- **Now.** A report's crumb and a page's phone title in the header, a report's row in the app's report index, and a report, page or dashboard row in the recent-items list (and an unlabelled sidebar entry that opens a dashboard) draw the label the `/meta` read or the app bundle served as given. The server has already translated it, by its label order for an overridden item: a `.custom` override's or a published edit's own text is kept over the base package's translation. Before, the served label reached the client's catalog lookup only as its fallback, so the base package's translation of the old name won.
11+
- **Unchanged.** An item the read did not serve, or served with no label, is still named by the catalog key under its name (`{ns}.reports.<name>.label`, `pages`, `dashboards`), then by its humanized name or machine name as before. An unedited item shows the same text as before, because the server serves the catalog's translation for it. A language switch reads the metadata again, so a served label is never left in the old language.
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@objectstack/spec': patch
3+
---
4+
5+
feat(spec): `IOwnershipQueueService` lets a caller ask for a refusal on a failed member read (`onFailedRead`), the call-time signal ADR-0142 D2 asks each consumer to send
6+
7+
Clause-②: yes (widening: an optional `onFailedRead` on `expandMembers`' options and an optional options argument on `isMember`; precedent PR #30015)
8+
Metadata-protocol: none
9+
10+
- **New type `OwnershipQueueFailedReadAnswer`** (`'nobody' | 'refuse'`, `@objectstack/spec/contracts`): what a membership read answers when a read behind the queue fails (`sys_queue`, `sys_queue_member`, `sys_team_member`, `sys_user_position`, `sys_member` through the position transition limb, the business-unit membership and tree).
11+
- **`expandMembers(name, organizationId, { purpose?, onFailedRead? })`** and **`isMember(userId, name, organizationId, { onFailedRead? })`**. `'nobody'` is the default and the answer every caller had before: a failed read is logged at `warn` and contributes nobody. Under `'refuse'` a failed read rejects with a failure that names the read, never `[]`, a partial set or `false`; an empty queue is still `[]`. The docblock names who takes which: the access path (D3), the claim admission (D4) and the assignment pool (D5) take `'nobody'`; the approver slate (D6) and the sharing-recipient grant recompute (D7) take `'refuse'`. The key is independent of `purpose`.
12+
- **Contract only.** Every existing call and implementation compiles unchanged and behaves as today; the producer in `@objectstack/plugin-sharing` and the D6/D7 callers that pass `'refuse'` land with #29604.

‎packages/cli/src/commands/serve-host-config-metadata-door.pin.test.ts‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -71,9 +71,13 @@ describe('#22989 A — `os serve` of a HOST config composes an artifact-less Met
7171
const gate = source.slice(gateAt, source.indexOf('hostConfigMetadataDoor = new MetadataPlugin({'));
7272
expect(gate).toContain('!devArtifactDoor');
7373
expect(gate).toContain('isHostConfig(config)');
74-
expect(gate).toContain(
75-
"!plugins.some((p: any) => p?.name === 'com.objectstack.metadata' || p?.constructor?.name === 'MetadataPlugin')",
76-
);
74+
expect(gate).toContain('!hasMetadataPlugin(plugins)');
75+
// #29936 — both doors ask the ONE predicate, and no class-name-only
76+
// spelling is left: the published bundle names the class
77+
// `_MetadataPlugin`, so such a check missed the standalone stack's own
78+
// plugin and composed a second one on every `os dev` of a scaffold.
79+
expect(count('!hasMetadataPlugin(plugins)')).toBe(2);
80+
expect(source).not.toContain("constructor?.name === 'MetadataPlugin'");
7781
// Decided after the dev door (it reads that value) and before the wrap.
7882
expect(source.indexOf('let devArtifactDoor: any;')).toBeLessThan(gateAt);
7983
expect(gateAt).toBeLessThan(

‎packages/cli/src/commands/serve-host-config-security-registrar.pin.test.ts‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -113,9 +113,11 @@ describe('#14397 — `os dev` over a HOST config composes ONE registrar for stac
113113
expect(source, 'the door decision must be a single named value').toContain(
114114
'let devArtifactDoor: any;',
115115
);
116-
// The gate is the same one the composition has always used.
116+
// The gate asks the one `hasMetadataPlugin` predicate the host-config
117+
// door asks too (#29936: a class-name-only spelling missed the
118+
// published bundle's `_MetadataPlugin` and composed a second door).
117119
expect(source).toContain(
118-
"if (isDev && flags.server && !plugins.some((p: any) => p?.constructor?.name === 'MetadataPlugin')) {",
120+
'if (isDev && flags.server && !hasMetadataPlugin(plugins)) {',
119121
);
120122
// Exactly one ARTIFACT door is constructed in this file, and it is that
121123
// value — a second artifact-door construction site is a second

‎packages/cli/src/commands/serve.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,7 @@ import {
125125
resolveStackCollection,
126126
stackDeclaredCapabilities,
127127
stackBootPlugins,
128+
hasMetadataPlugin,
128129
} from '../utils/stack-collections.js';
129130
import { redactConnectionUrl, describeDriverConnection, describeDriverSqliteFile } from '../utils/connection-display.js';
130131
import { captureServedDatabaseFile, watchServedDatabaseFile } from '../utils/served-database-file.js';
@@ -3511,7 +3512,7 @@ export default class Serve extends Command {
35113512
// points except by the AppPlugin append below, and an AppPlugin is not a
35123513
// MetadataPlugin.
35133514
let devArtifactDoor: any;
3514-
if (isDev && flags.server && !plugins.some((p: any) => p?.constructor?.name === 'MetadataPlugin')) {
3515+
if (isDev && flags.server && !hasMetadataPlugin(plugins)) {
35153516
try {
35163517
const { resolveDefaultArtifactPath } = await import('@objectstack/runtime');
35173518
// `os dev` is the only caller that reaches here, and it is a
@@ -3652,7 +3653,7 @@ export default class Serve extends Command {
36523653
if (
36533654
!devArtifactDoor
36543655
&& isHostConfig(config)
3655-
&& !plugins.some((p: any) => p?.name === 'com.objectstack.metadata' || p?.constructor?.name === 'MetadataPlugin')
3656+
&& !hasMetadataPlugin(plugins)
36563657
) {
36573658
const { MetadataPlugin } = await import('@objectstack/metadata');
36583659
hostConfigMetadataDoor = new MetadataPlugin({

‎packages/cli/src/utils/boot-preparation-parity.test.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,10 @@ const SERVE_ONLY: Record<string, string> = {
155155
'decides serve\'s artifact-less MetadataPlugin for a host config that no artifact door serves ([#22989]); '
156156
+ 'the one-shot boot boots the standalone data stack, whose MetadataPlugin is always composed, and lays a '
157157
+ 'host config\'s plugins over it (buildSchemaMigrationPlugins)',
158+
hasMetadataPlugin:
159+
'decides whether serve adds its own MetadataPlugin, the dev artifact door or the artifact-less host-config '
160+
+ 'door, beside the one a composition already holds ([#29936]); the one-shot boot boots the standalone data '
161+
+ 'stack, whose MetadataPlugin is always composed, and adds none of its own',
158162
bundleDeclaresTranslations:
159163
'decides serve\'s tier-gated i18n service, which the declaration boot does not compose '
160164
+ '(schema-migration-plugins.ts, "What this does NOT compose")',

‎packages/cli/src/utils/stack-collections.test.ts‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
artifactObjectNames,
1818
authoringRuleUnionStack,
1919
bundleDeclaresTranslations,
20+
hasMetadataPlugin,
2021
resolveStackCollection,
2122
shouldAutoRegisterObjectQL,
2223
shouldAutoRegisterStorageDriver,
@@ -268,3 +269,28 @@ describe('#22371 — stackBootPlugins, `os serve`\'s `plugins` and its `--dev` m
268269
expect(stackBootPlugins(undefined, true)).toEqual([]);
269270
});
270271
});
272+
273+
describe('#29936 — hasMetadataPlugin, the one gate both `os serve` metadata doors ask', () => {
274+
// The shape the published `@objectstack/metadata` bundle emits (18.3.0
275+
// dist/index.js: `var MetadataPlugin = class _MetadataPlugin {`): the class
276+
// name is `_MetadataPlugin`, only `name` still says what the plugin is.
277+
const BundledMetadataPlugin = class _MetadataPlugin {
278+
name = 'com.objectstack.metadata';
279+
};
280+
281+
it('finds the plugin by its name when the bundle renamed the class', () => {
282+
const bundled = new BundledMetadataPlugin();
283+
expect(bundled.constructor.name).toBe('_MetadataPlugin');
284+
expect(hasMetadataPlugin([{ name: 'com.objectstack.runtime.default-datasource' }, bundled])).toBe(true);
285+
});
286+
287+
it('finds the source-built class by its class name', () => {
288+
class MetadataPlugin {}
289+
expect(hasMetadataPlugin([new MetadataPlugin()])).toBe(true);
290+
});
291+
292+
it('answers false for a stack with no metadata plugin', () => {
293+
expect(hasMetadataPlugin([])).toBe(false);
294+
expect(hasMetadataPlugin([{ name: 'com.objectstack.engine.objectql' }, null, undefined, 'x'])).toBe(false);
295+
});
296+
});

0 commit comments

Comments
 (0)