Skip to content

Commit 0cd2449

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/project-thread-7ter86
2 parents d5bcd30 + cd065d3 commit 0cd2449

16 files changed

Lines changed: 826 additions & 29 deletions
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
---
2+
'@objectstack/metadata-protocol': patch
3+
'@objectstack/rest': patch
4+
'@objectstack/runtime': patch
5+
---
6+
7+
fix(meta)!: the layered view naming no package refuses a name two packages hold with the package-less writes' own `409 RESOURCE_CONFLICT`, instead of serving one holder's stack picked silently (#28337)
8+
9+
Clause-②: no (narrowing) — `GET /meta/:type/:name/layers` and the deprecated `?layers=true` refuse a request with no `?package=` for a name whose active rows two or more packages hold, which they answered `200` before. Nothing is widened.
10+
Metadata-protocol: none
11+
12+
<!-- adr-0087: not-required (no-migration-prescription) No key of any schema is added, removed, renamed or re-shaped and no stored metadata changes: a read door now answers the refusal the package-less save and delete already answer for the same rows, with the same code, status and holder list. The remedy is the request's (name the package with ?package=), so there is nothing for a migration to rewrite and no tombstone. -->
13+
14+
**BREAKING** read narrowing at the layered view (`@objectstack/rest`'s shared layered chain, served by `RestServer` and the runtime dispatcher, both spellings).
15+
16+
- **Before.** A `sys_metadata` row is keyed by type, name and package, so two installed packages can each hold an item of one name. For such a name, a save or a delete naming no package was refused `409 RESOURCE_CONFLICT` ("Name the package this save is for…", naming the holders), while the layered view naming no package answered `200` with whichever holder's row a package-agnostic lookup met first. The Studio editor and an agent read one holder's layers with no sign that a second holder existed, and the save that followed was refused.
17+
- **Now.** The layered view naming no package answers that same refusal, worded for the read: `409 RESOURCE_CONFLICT`, "Name the package this layered read is for, then send it again: on the REST door add ?package= with the package id…", naming every holder. Each transport answers it in the envelope its own save door answers the writes' refusal in.
18+
- **Unchanged.** `?package=` with any holder's id reads that holder's stack. A name one package holds, a name only the package-unbound row holds, and a package's row with the package-unbound row beside it answer as before. Every type the writes refuse on is refused here, and no other. The plain item read, `/published` and the protocol's in-process `getMetaItemLayered` (read by the security catalog echo and the packaged-permission-set lock gate) still serve a name two packages hold as they did.
19+
- **New export.** `ObjectStackProtocolImplementation.packagelessLayeredReadRefusal(request)` (`@objectstack/metadata-protocol`) answers the refusal or `undefined`; `RestProtocol` and `MetaItemReadGateSources` gain it as an optional member, and `MetaLayeredRequest` gains an optional `member`.
20+
- **Who sees it.** A client that reads `/layers` with no `?package=` for a name two installed packages both hold.
21+
22+
**The remedy.** Send the read with `?package=` naming the package whose stack you want, as the writes already require.

‎packages/cli/src/commands/migrate/data-commands.absent-database.integration.test.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
// test-budget: 450 — spawns the real producer once per single-cause fixture: two seed children and 46 spawns of the source CLI (three #21529 commands and eleven #21552 doors, each in its JSON and human faces on the absent database and in JSON on a booted control, plus the four doors that already exited 0), every one a distinct triple; slowest CI reading 355.51 s (run 41271604267) of the five scheduled CI runs 41199450735, 41217827347, 41237094739, 41255489006, 41271604267, x 1.25, rounded up
23

34
/**
45
* [#21529] `os migrate resume`, `os migrate recorded-by` and `os migrate

‎packages/cli/src/commands/migrate/object-scope.integration.test.ts‎

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
// test-budget: 170 — spawns the real producer once per single-cause fixture: one seed child and 17 spawns of the source CLI (narrowed, full-scope and misspelled --object runs of four commands, the narrowed run over an earned flag, the human misspelled run, and the three repro runs), each over its own copy of the seeded database, every one a distinct triple; slowest CI reading 135.53 s (run 41271604267) of the five scheduled CI runs 41199450735, 41217827347, 41237094739, 41255489006, 41271604267, x 1.25, rounded up
23

34
/**
45
* [#21644] The `--object` scope of the `os migrate` data-migration family,
@@ -38,7 +39,9 @@
3839
* - The measured repro leaves the flag unrecorded.
3940
*
4041
* Every spawn runs in the hook, each against its own copy of the seeded
41-
* database; a case only reads what a run printed and what the database holds.
42+
* database (the narrowed run over an earned flag, against a copy of the
43+
* full-scope run's); a case only reads what a run printed and what the
44+
* database holds.
4245
*/
4346

4447
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
@@ -287,8 +290,11 @@ beforeAll(async () => {
287290
}
288291

289292
// A narrowed run over a deployment that already earned the flag leaves it as it was.
290-
const earnedDb = freshDb();
291-
await runCommand('value-shapes', ['--apply', '--yes', '--json'], earnedDb);
293+
// The deployment that earned it is the full-scope `value-shapes --apply` above: the
294+
// same command over the same seeded copy, so its database is copied rather than
295+
// earned a second time by an identical spawn. That run's own database stays as it was.
296+
const earnedDb = join(dir, 'data', 'earned.db');
297+
copyFileSync(full.get('value-shapes')!.db, earnedDb);
292298
const verifiedBefore = flagRow(readState(earnedDb), VALUE_SHAPES_FLAG);
293299
const after = await runCommand('value-shapes', ['--object', 'os21644_site', '--apply', '--yes', '--json'], earnedDb);
294300
narrowedAfterFull = { db: earnedDb, run: after, state: readState(earnedDb), verifiedBefore };

‎packages/cli/src/commands/migrate/plan.telemetry-sibling.integration.test.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
// test-budget: 80 — spawns the real producer once per single-cause fixture: two serving boots (development, and the no-sibling control) and five spawns of the source CLI (plan and apply after the development boot; the control plan, and the plan with the sibling on, after the control boot), every one a distinct triple; slowest CI reading 62.26 s (run 41217827347) of the five scheduled CI runs 41199450735, 41217827347, 41237094739, 41255489006, 41271604267, x 1.25, rounded up
23

34
import { describe, it, expect, afterAll } from 'vitest';
45
import { spawnSync } from 'node:child_process';

‎packages/cli/src/commands/migrate/unmapped-columns.integration.test.ts‎

Lines changed: 19 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
// test-budget: 110 — spawns the real producer once per single-cause fixture: one seed child and ten spawns of the source CLI over one upgraded fixture (unmapped-columns on four objects in its JSON and human faces, the misspelled object, the row cap, the platform object, and os migrate plan --json), every one a distinct triple; slowest CI reading 84.84 s (run 41255489006) of the five scheduled CI runs 41199450735, 41217827347, 41237094739, 41255489006, 41271604267, x 1.25, rounded up
23

34
/**
45
* `os migrate unmapped-columns` at the public door: the CLI spawned against a
@@ -53,6 +54,7 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
5354
import { tmpdir } from 'node:os';
5455
import { dirname, join, resolve } from 'node:path';
5556
import { fileURLToPath } from 'node:url';
57+
import Database from 'better-sqlite3';
5658

5759
const HERE = dirname(fileURLToPath(import.meta.url));
5860
const CLI_ROOT = resolve(HERE, '..', '..', '..');
@@ -149,22 +151,6 @@ process.stderr.write('[fixture] seeded\\n');
149151
process.exit(0);
150152
`;
151153

152-
/** The schema and every row, read on a connection of our own. */
153-
const READ_STATE_CHILD = `
154-
const { SqlDriver } = await import('@objectstack/driver-sql');
155-
const raw = new SqlDriver({ client: 'better-sqlite3', connection: { filename: process.env.FIXTURE_DB }, useNullAsDefault: true });
156-
const k = raw.knex;
157-
const schema = await k.raw('SELECT type, name, sql FROM sqlite_master ORDER BY type, name');
158-
const rows = {};
159-
for (const entry of schema) {
160-
if (entry.type !== 'table' || entry.name.startsWith('sqlite_')) continue;
161-
rows[entry.name] = await k.raw('SELECT * FROM "' + entry.name + '" ORDER BY rowid');
162-
}
163-
await raw.disconnect();
164-
process.stdout.write(JSON.stringify({ schema, rows }));
165-
process.exit(0);
166-
`;
167-
168154
interface Run {
169155
code: number | null;
170156
stdout: string;
@@ -184,10 +170,24 @@ function childNode(code: string, env: Record<string, string>): { status: number
184170
return { status: out.status, stdout: String(out.stdout), stderr: String(out.stderr) };
185171
}
186172

173+
/**
174+
* The schema and every row, read on a connection of our own, in this process:
175+
* a child `node` per read cost a process start and a driver import each time.
176+
*/
187177
function readState(): string {
188-
const out = childNode(READ_STATE_CHILD, { FIXTURE_DB: db });
189-
if (out.status !== 0) throw new Error(`could not read the fixture database (status ${out.status})\n${out.stderr}`);
190-
return out.stdout;
178+
// Opened as the child it replaces opened it (read-write, knex's default).
179+
const conn = new Database(db);
180+
try {
181+
const schema = conn.prepare('SELECT type, name, sql FROM sqlite_master ORDER BY type, name').all() as Array<{ type: string; name: string }>;
182+
const rows: Record<string, unknown> = {};
183+
for (const entry of schema) {
184+
if (entry.type !== 'table' || entry.name.startsWith('sqlite_')) continue;
185+
rows[entry.name] = conn.prepare(`SELECT * FROM "${entry.name}" ORDER BY rowid`).all();
186+
}
187+
return JSON.stringify({ schema, rows });
188+
} finally {
189+
conn.close();
190+
}
191191
}
192192

193193
/** One `os <argv…> --database-url file:<db>` run, in the fixture project. */
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#28337, ADR-0048] `packagelessLayeredReadRefusal`: the layered view's door
5+
* refusal for a name two or more packages hold, asked with no package. It is
6+
* the refusal the package-less save and delete answer for the same rows
7+
* (`packagelessAddressAmbiguityRefusal`, `409 RESOURCE_CONFLICT`, the holders
8+
* sorted and the `?package=` prescription), over the same resolution
9+
* (`packagelessAddress` at `active`).
10+
*
11+
* The layered read itself is unchanged: its in-process resolution readers
12+
* (the security catalog echo, the packaged-permission-set lock gate,
13+
* `/published`) read a name's layers with no package and keep the answer they
14+
* had. The door asks the judge (`createMetaLayeredAnswer` in `@objectstack/rest`).
15+
*/
16+
17+
import { describe, expect, it } from 'vitest';
18+
import { assertEngineDeleteDispatch, assertEngineFindOnePredicate, assertEngineUpdateDispatch } from '@objectstack/metadata-core';
19+
import { ObjectStackProtocolImplementation } from './protocol.js';
20+
21+
const PKG_A = 'app.alpha_demo';
22+
const PKG_B = 'app.beta_demo';
23+
24+
type Row = Record<string, any>;
25+
26+
const rowKey = (w: Row) =>
27+
[w.type, w.name, w.organization_id ?? '', w.package_id ?? '', w.state ?? 'active'].join('|');
28+
29+
const whereValueMatches = (actual: unknown, want: unknown): boolean =>
30+
want !== null && typeof want === 'object' && Array.isArray((want as { $in?: unknown }).$in)
31+
? (want as { $in: unknown[] }).$in.includes(actual)
32+
: actual === want;
33+
34+
const matchesWhere = (row: Row, where: Row) =>
35+
Object.entries(where).every(([k, v]) => {
36+
if (k.startsWith('$')) throw new Error(`fake driver: unsupported operator ${k}`);
37+
if (v === null) return row[k] === null || row[k] === undefined;
38+
return whereValueMatches(row[k], v);
39+
});
40+
41+
/** An in-memory `sys_metadata` behind the REAL protocol, with no registry items. */
42+
function makeProtocol() {
43+
const rows = new Map<string, Row>();
44+
let nextId = 0;
45+
const findRow = (w: Row) => {
46+
for (const [k, r] of rows) if (matchesWhere(r, w)) return { key: k, row: r };
47+
return null;
48+
};
49+
const registry: any = {
50+
getItem: () => undefined,
51+
getArtifactItem: () => undefined,
52+
listItems: () => [],
53+
getObject: () => undefined,
54+
isPackageDisabled: () => false,
55+
isObjectPackageDisabled: () => false,
56+
getAllPackages: () => [{ manifest: { id: PKG_A } }, { manifest: { id: PKG_B } }],
57+
getPackage: (id: string) => ({ manifest: { id } }),
58+
};
59+
const engine: any = {
60+
registry,
61+
async findOne(table: string, o: { where: Row }) {
62+
assertEngineFindOnePredicate(table, o);
63+
if (table !== 'sys_metadata') return null;
64+
return findRow(o.where)?.row ?? null;
65+
},
66+
async find(table: string, o: { where: Row; limit?: number }) {
67+
if (table !== 'sys_metadata') return [];
68+
const matched = Array.from(rows.values()).filter((r) => matchesWhere(r, o.where));
69+
return typeof o?.limit === 'number' ? matched.slice(0, o.limit) : matched;
70+
},
71+
async insert(table: string, data: Row) {
72+
if (table !== 'sys_metadata') return { id: `rec_${++nextId}` };
73+
const row = { id: `r_${++nextId}`, ...data };
74+
rows.set(rowKey(data), row);
75+
return { id: row.id };
76+
},
77+
async update(table: string, data: Row, o: { where: Row }) {
78+
assertEngineUpdateDispatch(data, o);
79+
if (table !== 'sys_metadata') return { id: null };
80+
const found = findRow(o.where);
81+
if (!found) return { id: null };
82+
const merged = { ...found.row, ...data };
83+
rows.delete(found.key);
84+
rows.set(rowKey(merged), merged);
85+
return { id: merged.id };
86+
},
87+
async delete(table: string, o?: Row) {
88+
assertEngineDeleteDispatch(o);
89+
if (table !== 'sys_metadata') return { deleted: 0 };
90+
const found = findRow((o?.where ?? {}) as Row);
91+
if (!found) return { deleted: 0 };
92+
rows.delete(found.key);
93+
return { deleted: 1 };
94+
},
95+
async syncObjectSchema() { /* no physical storage in this double */ },
96+
};
97+
return new ObjectStackProtocolImplementation(engine, undefined, 'env_test');
98+
}
99+
100+
/** Store one dashboard row per package named (`null`: the package-unbound row). */
101+
async function store(protocol: ObjectStackProtocolImplementation, name: string, packages: Array<string | null>) {
102+
for (const pkg of packages) {
103+
await protocol.saveMetaItem({
104+
type: 'dashboard', name, item: { name, label: `${pkg ?? 'local'} copy`, widgets: [] },
105+
...(pkg ? { packageId: pkg } : {}),
106+
} as any);
107+
}
108+
}
109+
110+
/** What a rejected promise threw. */
111+
const thrown = async (p: Promise<unknown>): Promise<any> => {
112+
try {
113+
await p;
114+
} catch (e) {
115+
return e;
116+
}
117+
throw new Error('expected a refusal; the call resolved');
118+
};
119+
120+
describe('[#28337] packagelessLayeredReadRefusal', () => {
121+
it('a name two packages hold: the package-less save\'s refusal, worded for the layered read', async () => {
122+
const protocol = makeProtocol();
123+
await store(protocol, 'twin_dash', [PKG_B, PKG_A]);
124+
const refusal = await protocol.packagelessLayeredReadRefusal({ type: 'dashboard', name: 'twin_dash' });
125+
const saveRefusal = await thrown(protocol.saveMetaItem({
126+
type: 'dashboard', name: 'twin_dash', item: { name: 'twin_dash', label: 'edited', widgets: [] },
127+
} as any));
128+
expect(refusal).toBeInstanceOf(Error);
129+
expect({ code: refusal!.code, status: refusal!.status }).toEqual({ code: saveRefusal.code, status: saveRefusal.status });
130+
expect({ code: refusal!.code, status: refusal!.status }).toEqual({ code: 'RESOURCE_CONFLICT', status: 409 });
131+
expect(refusal!.message).toBe(
132+
'Name the package this layered read is for, then send it again: on the REST door add ?package= with the '
133+
+ 'package id, in process pass packageId. Nothing was served. dashboard \'twin_dash\' belongs to 2 packages '
134+
+ `('${PKG_A}', '${PKG_B}'), and a layered read that names none has no single row to show.`,
135+
);
136+
// The holder list is the save's, sorted.
137+
expect(saveRefusal.message).toContain(`('${PKG_A}', '${PKG_B}')`);
138+
// The plural URL spelling is the same item.
139+
expect((await protocol.packagelessLayeredReadRefusal({ type: 'dashboards', name: 'twin_dash' }))?.message)
140+
.toBe(refusal!.message);
141+
});
142+
143+
it('no refusal where the package-less writes take an address: one package, the package-unbound row beside it, or none', async () => {
144+
const protocol = makeProtocol();
145+
await store(protocol, 'solo_dash', [PKG_A]);
146+
await store(protocol, 'local_beside', [PKG_A, null]);
147+
await store(protocol, 'local_dash', [null]);
148+
for (const name of ['solo_dash', 'local_beside', 'local_dash', 'no_such_dash']) {
149+
expect(await protocol.packagelessLayeredReadRefusal({ type: 'dashboard', name }), name).toBeUndefined();
150+
}
151+
});
152+
153+
it('the layered read itself still serves its in-process readers a name two packages hold', async () => {
154+
const protocol = makeProtocol();
155+
await store(protocol, 'twin_dash', [PKG_A, PKG_B]);
156+
const layered = await protocol.getMetaItemLayered({ type: 'dashboard', name: 'twin_dash' });
157+
expect(layered.overlay).toMatchObject({ name: 'twin_dash' });
158+
for (const pkg of [PKG_A, PKG_B]) {
159+
const scoped = await protocol.getMetaItemLayered({ type: 'dashboard', name: 'twin_dash', packageId: pkg });
160+
expect(scoped.overlay).toMatchObject({ name: 'twin_dash', label: `${pkg} copy` });
161+
}
162+
});
163+
});

‎packages/metadata-protocol/src/protocol.read-verb-canonical-fold.test.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -210,7 +210,7 @@ async function expectSpellingRefusal(run: () => Promise<unknown>) {
210210
}
211211

212212
describe('#9157 — the population, re-derived from the code rather than from the card', () => {
213-
it('every `/meta` request-boundary verb with a required `type` calls the fold — all eighteen', () => {
213+
it('every `/meta` request-boundary verb with a required `type` calls the fold — all nineteen', () => {
214214
// ⭐ The card hand-listed "nine fold, three do not". Hand-listed sets of
215215
// this shape have shipped short before, so the set is DERIVED here and
216216
// the derivation is the pin: a tenth verb arriving unfolded turns this
@@ -326,6 +326,9 @@ describe('#9157 — the population, re-derived from the code rather than from th
326326
// [#20679] The locked-base verdict a second write door asks — it
327327
// takes the type a caller names, so it folds at the producer too.
328328
'packagedBaseRefusal',
329+
// [#28337] The layered view's door refusal for a name several
330+
// packages hold — the door hands it the type the request named.
331+
'packagelessLayeredReadRefusal',
329332
'publishMetaItem',
330333
'rollbackMetaItem',
331334
'saveMetaItem',

0 commit comments

Comments
 (0)