From 8df2244b8930f4fe764ecea5318c29645605ec00 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 03:26:51 +0000 Subject: [PATCH 1/4] fix(metadata-protocol): the code package that ships a name is a candidate of the package-less read's content Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01FEkQ5nQHuymasPwjX3mAr2 --- .../src/protocol-environment-rows.ts | 51 +++++++++++++++++++ .../src/protocol-lock-version-gates.ts | 9 ++-- packages/metadata-protocol/src/protocol.ts | 31 +++++++---- 3 files changed, 79 insertions(+), 12 deletions(-) diff --git a/packages/metadata-protocol/src/protocol-environment-rows.ts b/packages/metadata-protocol/src/protocol-environment-rows.ts index 0a06fe920c2..1a530197d93 100644 --- a/packages/metadata-protocol/src/protocol-environment-rows.ts +++ b/packages/metadata-protocol/src/protocol-environment-rows.ts @@ -11,6 +11,11 @@ * - `findServedOverlayRow` — the stored row a by-name read serves; * - `packagelessAddress` — the package a write that names none addresses. * + * [#28238] Beside them, `packagelessContentPackage`: the package a by-name + * read that names none takes its stored content from, given that address and + * the code packages that ship the name. Not a moved body: the rule the two + * by-name reads share. + * * A MOVE, not a redesign. Each function below is the body of the * `ObjectStackProtocolImplementation` method of the same name as it stood, * re-indented one level and otherwise unedited. @@ -159,3 +164,49 @@ export async function packagelessAddress( const candidates = [...bindings].sort(); return { packageId: candidates.length === 1 ? candidates[0] ?? null : null, candidates, unbound }; } + +/** + * [#28238, ADR-0048 §3.3] The package a by-name read that names no package + * takes its stored CONTENT from: {@link findServedOverlayRow}'s `packageId` + * for {@link ObjectStackProtocolImplementation.getMetaItem} and + * {@link ObjectStackProtocolImplementation.getMetaItemLayered}, so the plain + * read and the layered read's `effective` serve one row. + * + * `address` is the item's package-less write address + * ({@link packagelessAddress}, at `active`); `shipped` is every artifact an + * installed code package registered under the name + * (`ObjectStackProtocolImplementation#shippedArtifactsOf`). The code packages + * that ship the name are candidates of the address beside the stored rows' + * bindings: + * + * - no code package ships the name, or the one package the stored rows are + * bound to ships it: the write address's package, as before (#23291) — + * else `undefined`, any row, the best-effort first match; + * - exactly one code package ships it: that package. Its own stored row, + * else the package-unbound row (ADR-0005), else its code item. A row bound + * to another package, the only stored row of a dependent package that saved + * the same name, is never served for it: that row stood in for the base's + * item on every read naming no package and in the base's own app; + * - several ship it, and no stored row is bound to one of them: `null`, the + * package-unbound row alone, else the code item the registry answers; + * several ship it and a row is bound to one: `undefined`, as before. + * + * Content only, and the read's alone. The write address does not move: a + * save, a delete and the read's `version` still resolve + * {@link packagelessAddress}'s `packageId`. + */ +export function packagelessContentPackage( + address: { readonly packageId: string | null; readonly candidates: readonly string[] }, + shipped: readonly unknown[], +): string | null | undefined { + const shippers = new Set(); + for (const artifact of shipped) { + const id = (artifact as { _packageId?: unknown } | null | undefined)?._packageId; + if (typeof id === 'string' && id !== '' && id !== 'sys_metadata') shippers.add(id); + } + if (shippers.size === 0 || (address.packageId !== null && shippers.has(address.packageId))) { + return address.packageId ?? undefined; + } + if (shippers.size === 1) return [...shippers][0]; + return address.candidates.some((id) => shippers.has(id)) ? undefined : null; +} diff --git a/packages/metadata-protocol/src/protocol-lock-version-gates.ts b/packages/metadata-protocol/src/protocol-lock-version-gates.ts index 40e6596fc09..d22a105b9e1 100644 --- a/packages/metadata-protocol/src/protocol-lock-version-gates.ts +++ b/packages/metadata-protocol/src/protocol-lock-version-gates.ts @@ -50,6 +50,7 @@ import type { ObjectStackProtocolImplementation, MetadataAuditEntry } from './protocol.js'; import { canonicalMetaType } from './protocol.js'; import { SysMetadataRepository, unboundItemDraftAddress } from './sys-metadata-repository.js'; +import { packagelessContentPackage } from './protocol-environment-rows.js'; import { resolveItemLockLazily } from './item-lock.js'; import { ConflictError } from '@objectstack/metadata-core'; import { PLURAL_TO_SINGULAR } from '@objectstack/spec/shared'; @@ -111,6 +112,7 @@ export interface LockVersionGateHost { receiptVersion: ObjectStackProtocolImplementation['receiptVersion']; recordMetadataAudit: ObjectStackProtocolImplementation['recordMetadataAudit']; rethrowUnlessMetadataStoreUnprovisioned: ObjectStackProtocolImplementation['rethrowUnlessMetadataStoreUnprovisioned']; + shippedArtifactsOf: ObjectStackProtocolImplementation['shippedArtifactsOf']; storedHashDigest: ObjectStackProtocolImplementation['storedHashDigest']; storedHeadAt: ObjectStackProtocolImplementation['storedHeadAt']; } @@ -437,9 +439,10 @@ export async function readVersionToken( /** The body of `ObjectStackProtocolImplementation#packagelessReadAddress`; the method's docblock there is its contract. */ export async function packagelessReadAddress( - this: Pick, - type: string, name: string, member?: string): Promise { - return (await this.packagelessAddress(PLURAL_TO_SINGULAR[type] ?? type, name, member)).packageId ?? undefined; + this: Pick, + type: string, name: string, member?: string): Promise { + const singular = PLURAL_TO_SINGULAR[type] ?? type; + return packagelessContentPackage(await this.packagelessAddress(singular, name, member), this.shippedArtifactsOf(singular, name)); } /** The body of `ObjectStackProtocolImplementation#strictDraftReadAddress`; the method's docblock there is its contract. */ diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index a272593a710..662d76fbf31 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -438,7 +438,7 @@ import { refuseUnmintableMetaType } from './protocol-unmintable-meta-type.js'; // [#25467 cut 33] The deployment's SDUI component manifest, and the load-time page `requires` report against it. import { reportPageRequiresAbsentAtLoad, resolveSduiManifest } from './protocol-sdui-manifest.js'; // [#25467 cut 35] The environment's own `sys_metadata` rows: the family read, the one-row read, the served row and the package-less address. -import { findEnvironmentRow, findEnvironmentRows, findServedOverlayRow, packagelessAddress } from './protocol-environment-rows.js'; +import { findEnvironmentRow, findEnvironmentRows, findServedOverlayRow, packagelessAddress, packagelessContentPackage } from './protocol-environment-rows.js'; // [#25467 cut 35] The stored rows of a collection, folded over the registered ones. import { foldStoredCollection } from './protocol-stored-collection-fold.js'; // [#25467 cut 35] The environment-wide package uninstall verb. @@ -10844,12 +10844,18 @@ export class ObjectStackProtocolImplementation implements // The package-less binding ({@link packagelessReadAddress}'s), selected // from those rows; the `version` below ({@link readVersionToken}) // takes the same binding instead of reading it again. - const packagelessBinding = readState === 'active' && !request.packageId - ? (await this.packagelessAddress( + const packagelessAddressed = readState === 'active' && !request.packageId + ? await this.packagelessAddress( PLURAL_TO_SINGULAR[request.type] ?? request.type, request.name, member, 'active', activeRows, - )).packageId + ) : undefined; - const contentPackageId = request.packageId || (packagelessBinding ?? undefined); + const packagelessBinding = packagelessAddressed?.packageId; + // [#28238] The content, though, is {@link packagelessContentPackage}'s: + // the code package that ships the name is a candidate too, so a row + // another package stored under the name is not served for it. + const contentPackageId = request.packageId || (packagelessAddressed + ? packagelessContentPackage(packagelessAddressed, this.shippedArtifactsOf(request.type, request.name)) + : undefined); // ADR-0033 draft-overlay preview (non-strict): when the caller opts in // (admin-gated upstream), prefer a `state='draft'` row if one exists, else @@ -10868,7 +10874,7 @@ export class ObjectStackProtocolImplementation implements state: 'draft', ...(draftContentAddress ? { packageId: draftContentAddress.packageId } - : contentPackageId ? { packageId: contentPackageId } : {}), + : contentPackageId !== undefined ? { packageId: contentPackageId } : {}), ...memberOption(member), }))?.row; // [#23715] Two or more packages and no package-unbound draft: @@ -10928,7 +10934,7 @@ export class ObjectStackProtocolImplementation implements state: readState, ...(readState === 'draft' && draftContentAddress ? { packageId: draftContentAddress.packageId } - : contentPackageId ? { packageId: contentPackageId } : {}), + : contentPackageId !== undefined ? { packageId: contentPackageId } : {}), ...memberOption(member), }, activeRows))?.row; // [#23715] Two or more packages and no package-unbound draft: the @@ -11559,7 +11565,7 @@ export class ObjectStackProtocolImplementation implements type: request.type, name: request.name, state: 'active', - ...(contentPackageId ? { packageId: contentPackageId } : {}), + ...(contentPackageId !== undefined ? { packageId: contentPackageId } : {}), ...memberOption(member), }); overlayLockLayer = await this.overlayLockLayerAt({ @@ -17077,11 +17083,18 @@ export class ObjectStackProtocolImplementation implements * layered read's `effective` and `/published` serve one row. [#23715] The * strict draft read asks {@link strictDraftReadAddress} instead: the draft * its `version` names. + * + * [#28238] The code packages that ship the name are candidates too + * ({@link packagelessContentPackage}, the rule {@link getMetaItem} applies + * to the rows it already read): a row bound to another package is not + * served for a name one code package ships, and `null` is the + * package-unbound row alone. The write address does not move. */ - private packagelessReadAddress(type: string, name: string, member?: string): Promise { + private packagelessReadAddress(type: string, name: string, member?: string): Promise { // [#25467 cut 5] The body moved to `protocol-lock-version-gates.ts` (see `LockVersionGateHost`). const host: ThisParameterType = { packagelessAddress: (...a) => this.packagelessAddress(...a), + shippedArtifactsOf: (...a) => this.shippedArtifactsOf(...a), }; return packagelessReadAddress.call(host, type, name, member); } From 85b1c1c73b6f21a21b4c4c1a4de5967399f93b4b Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 03:37:35 +0000 Subject: [PATCH 2/4] test(objectql): pin the package-less read against a dependent package's same-named row over the real engine Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01FEkQ5nQHuymasPwjX3mAr2 --- ...packageless-read-dependent-package.test.ts | 291 ++++++++++++++++++ 1 file changed, 291 insertions(+) create mode 100644 packages/objectql/src/protocol-packageless-read-dependent-package.test.ts diff --git a/packages/objectql/src/protocol-packageless-read-dependent-package.test.ts b/packages/objectql/src/protocol-packageless-read-dependent-package.test.ts new file mode 100644 index 00000000000..8c2c9f57a7a --- /dev/null +++ b/packages/objectql/src/protocol-packageless-read-dependent-package.test.ts @@ -0,0 +1,291 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#28238, ADR-0048 §3.3] A same-named item in a DEPENDENT package does not + * replace the base package's item for a read that names no package. + * + * Measured on a booted `examples/app-showcase`: a writable package that + * depends on the showcase saved and published `showcase_task.default` with + * two columns. The read naming the showcase answered its own columns, the + * read naming the dependent package its two, and the read naming NO package + * answered the two as well, under the showcase's `_packageId`. The + * package-less content address was the stored rows' one binding + * ({@link packagelessAddress}), and the code package that ships the name was + * no candidate of it, so the dependent package's row won by being the only + * stored row. + * + * Pinned here, through the real protocol over the real `ObjectQL` engine and + * an in-memory driver (the harness of `environment-overlay-five-types.test.ts`), + * the dependent package installed through the package door with its + * dependency declared and its row written through the draft save and the + * per-item publish, as the card's steps wrote it: + * + * - the card's three reads answer base / dependent / base, and the layered + * read's `effective` and the list scoped to the base agree with them; + * - the write address does not move: a save naming no package still writes + * the dependent package's row, as {@link packagelessAddress} resolves it; + * - CONTROL: a writable package's OWN item, a name no code package ships, + * is still served to the read naming no package (#23291); + * - CONTROL: a package-unbound row (the ADR-0005 overlay Setup writes) still + * wins over the code package, beside the dependent package's row too. + * + * Not pinned: a declared override (the epic's override core, #28236, has no + * declaration on `main` to leave untouched), and the env-wide list, which + * keeps one slot per package that holds the name (#21934). + * + * Test-path resolution: the protocol is `@objectstack/metadata-protocol`'s + * `dist/`, so a change there is read here after that package is rebuilt. + */ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import type { ServiceObject } from '@objectstack/spec/data'; +import { ObjectStackProtocolImplementation, resetEnvWritableMetadataTypes } from '@objectstack/metadata-protocol'; +import { SysMetadataAuditObject, SysMetadataCommitObject, SysMetadataHistoryObject } from '@objectstack/metadata-core'; +import { ObjectQL } from './engine.js'; + +const BASE = 'com.example.showcase'; +const DEPENDENT = 'com.default.acme-custom'; +const VIEW = 'showcase_task.default'; +const BASE_COLUMNS = ['title', 'project', 'assignee', 'status', 'priority', 'due_date']; +const DEPENDENT_COLUMNS = ['title', 'status']; + +const sysMetadataObject: ServiceObject = { + name: 'sys_metadata', + label: 'System Metadata', + fields: { + id: { name: 'id', label: 'ID', type: 'text' as const }, + type: { name: 'type', label: 'Type', type: 'text' as const, required: true }, + name: { name: 'name', label: 'Name', type: 'text' as const, required: true }, + organization_id: { name: 'organization_id', label: 'Org', type: 'text' as const }, + package_id: { name: 'package_id', label: 'Package', type: 'text' as const }, + bundle_member: { name: 'bundle_member', label: 'Bundle Member', type: 'text' as const, maxLength: 100 }, + metadata: { name: 'metadata', label: 'Body', type: 'textarea' as const }, + draft_changes: { name: 'draft_changes', label: 'Draft Changes', type: 'textarea' as const }, + checksum: { name: 'checksum', label: 'Checksum', type: 'text' as const, maxLength: 71 }, + state: { name: 'state', label: 'State', type: 'text' as const }, + version: { name: 'version', label: 'Version', type: 'number' as const }, + created_at: { name: 'created_at', label: 'Created', type: 'datetime' as const }, + updated_at: { name: 'updated_at', label: 'Updated', type: 'datetime' as const }, + }, +}; + +function makeMemoryDriver() { + const stores = new Map>>(); + const storeFor = (obj: string) => { + let s = stores.get(obj); + if (!s) { s = new Map(); stores.set(obj, s); } + return s; + }; + let nextId = 0; + const matchesWhere = (row: Record, where: any): boolean => { + if (!where || typeof where !== 'object') return true; + for (const [k, v] of Object.entries(where)) { + if (k === '$and' && Array.isArray(v)) { + if (!v.every((w: any) => matchesWhere(row, w))) return false; + continue; + } + if (k === '$or' && Array.isArray(v)) { + if (!v.some((w: any) => matchesWhere(row, w))) return false; + continue; + } + if (k.startsWith('$')) continue; + const rowVal = row[k]; + if (v && typeof v === 'object' && Array.isArray((v as any).$in)) { + if (!(v as any).$in.includes(rowVal === undefined ? null : rowVal)) return false; + continue; + } + const expected = (v && typeof v === 'object' && '$eq' in (v as any)) ? (v as any).$eq : v; + const a = rowVal === undefined ? null : rowVal; + const b = expected === undefined ? null : expected; + if (a !== b) return false; + } + return true; + }; + const driver: any = { + name: 'memory', version: '0.0.0', supports: {} as any, + async connect() {}, async disconnect() {}, async checkHealth() { return true; }, + async execute() { return null; }, + async find(object: string, ast: any) { + const rows = Array.from(storeFor(object).values()).filter((r) => matchesWhere(r, ast?.where)); + return typeof ast?.limit === 'number' ? rows.slice(0, ast.limit) : rows; + }, + async findOne(object: string, ast: any) { + for (const r of storeFor(object).values()) if (matchesWhere(r, ast?.where)) return r; + return null; + }, + async create(object: string, data: Record) { + nextId += 1; + const id = (data.id as string) ?? `r_${nextId}`; + const row = { ...data, id }; + storeFor(object).set(id, row); + return row; + }, + async update(object: string, id: string, data: Record) { + const s = storeFor(object); + const cur = s.get(id); + if (!cur) throw new Error(`not found: ${object}/${id}`); + const updated = { ...cur, ...data, id }; + s.set(id, updated); + return updated; + }, + async upsert(object: string, data: Record) { + const id = data.id as string | undefined; + if (id && storeFor(object).has(id)) return this.update(object, id, data); + return this.create(object, data); + }, + async delete(object: string, id: string) { return storeFor(object).delete(id); }, + async count(object: string, ast: any) { return (await this.find(object, ast)).length; }, + async bulkCreate(object: string, rows: Record[]) { + return Promise.all(rows.map((r) => this.create(object, r))); + }, + async bulkUpdate() { return []; }, async bulkDelete() {}, + async beginTransaction() { return { commit: async () => {}, rollback: async () => {} }; }, + async commit() {}, async rollback() {}, + }; + return { driver, stores }; +} + +const listBody = (columns: readonly string[]) => ({ + name: VIEW, + object: 'showcase_task', + viewKind: 'list', + label: 'All Tasks', + config: { + type: 'grid', + data: { provider: 'object', object: 'showcase_task' }, + columns: columns.map((field) => ({ field })), + }, +}); + +function baseManifest(): Record { + return { + id: BASE, + name: 'showcase', + version: '1.0.0', + objects: [{ + name: 'showcase_task', + label: 'Task', + sharingModel: 'private', + fields: Object.fromEntries(BASE_COLUMNS.map((f) => [f, { name: f, label: f, type: 'text' }])), + }], + views: [{ + object: 'showcase_task', + list: { + label: 'All Tasks', + type: 'grid', + data: { provider: 'object', object: 'showcase_task' }, + columns: BASE_COLUMNS.map((field) => ({ field })), + }, + }], + }; +} + +async function boot() { + const engine = new ObjectQL(); + const { driver, stores } = makeMemoryDriver(); + engine.registerDriver(driver, true); + await engine.init(); + engine.registry.registerObject(sysMetadataObject, 'test-package'); + for (const o of [SysMetadataHistoryObject, SysMetadataAuditObject, SysMetadataCommitObject]) { + engine.registry.registerObject(o as any, 'test-package'); + } + engine.registerApp(baseManifest()); + const protocol = new ObjectStackProtocolImplementation(engine, () => new Map()); + const rowsOf = (name: string) => Array.from(stores.get('sys_metadata')?.values() ?? []) + .filter((r) => r.type === 'view' && r.name === name); + return { engine, protocol, rowsOf }; +} + +const columnsOf = (item: any): string[] => (item?.config?.columns ?? item?.columns ?? []).map((c: any) => c?.field ?? c); + +const savedWritable = process.env.OS_METADATA_WRITABLE; +beforeEach(() => { + delete process.env.OS_METADATA_WRITABLE; + ObjectStackProtocolImplementation.resetEnvWritableCache(); + resetEnvWritableMetadataTypes(); +}); +afterEach(() => { + if (savedWritable === undefined) delete process.env.OS_METADATA_WRITABLE; + else process.env.OS_METADATA_WRITABLE = savedWritable; + ObjectStackProtocolImplementation.resetEnvWritableCache(); + resetEnvWritableMetadataTypes(); +}); + +/** Installs the dependent package through the package door, its dependency on the base declared. */ +async function installDependent(protocol: ObjectStackProtocolImplementation): Promise { + await (protocol as any).installPackage({ + manifest: { id: DEPENDENT, name: 'acme-custom', version: '0.1.0', type: 'app', dependencies: { [BASE]: '*' } }, + }); +} + +/** The card's step 2: a draft save naming the dependent package, then the per-item publish naming it. */ +async function saveIntoDependent(protocol: ObjectStackProtocolImplementation, name: string, columns: readonly string[]): Promise { + const body = { ...listBody(columns), name }; + await protocol.saveMetaItem({ type: 'view', name, item: body, mode: 'draft', packageId: DEPENDENT } as any); + await protocol.publishMetaItem({ type: 'view', name, packageId: DEPENDENT } as any); +} + +const read = async (protocol: ObjectStackProtocolImplementation, packageId?: string, name = VIEW) => + (await protocol.getMetaItem({ type: 'view', name, ...(packageId ? { packageId } : {}) })) as any; + +describe('[#28238] a dependent package\'s row under a name the base ships is not served to the read naming no package', () => { + it('the card\'s three steps answer base / dependent / base, and the layered read and the base\'s list agree', async () => { + const { protocol, rowsOf } = await boot(); + await installDependent(protocol); + await saveIntoDependent(protocol, VIEW, DEPENDENT_COLUMNS); + expect(rowsOf(VIEW).map((r) => [r.package_id, r.state])).toEqual([[DEPENDENT, 'active']]); + + expect(columnsOf((await read(protocol, BASE)).item)).toEqual(BASE_COLUMNS); + expect(columnsOf((await read(protocol, DEPENDENT)).item)).toEqual(DEPENDENT_COLUMNS); + const packageless = await read(protocol); + expect(columnsOf(packageless.item)).toEqual(BASE_COLUMNS); + expect(packageless.item._packageId).toBe(BASE); + + const layered: any = await (protocol as any).getMetaItemLayered({ type: 'view', name: VIEW }); + expect(columnsOf(layered.effective)).toEqual(BASE_COLUMNS); + expect(layered.overlay).toBeNull(); + + const listed: any = await protocol.getMetaItems({ type: 'view', packageId: BASE }); + expect(listed.items.filter((i: any) => i.name === VIEW).map((i: any) => columnsOf(i))).toEqual([BASE_COLUMNS]); + }); + + it('the write address does not move: a save naming no package still writes the dependent package\'s row', async () => { + const { protocol, rowsOf } = await boot(); + await installDependent(protocol); + await saveIntoDependent(protocol, VIEW, DEPENDENT_COLUMNS); + expect((await (protocol as any).packagelessAddress('view', VIEW)).packageId).toBe(DEPENDENT); + + await protocol.saveMetaItem({ type: 'view', name: VIEW, item: listBody(['title']) } as any); + + const rows = rowsOf(VIEW).filter((r) => r.state === 'active'); + expect(rows.map((r) => r.package_id)).toEqual([DEPENDENT]); + expect(columnsOf(JSON.parse(String(rows[0].metadata)))).toEqual(['title']); + expect(columnsOf((await read(protocol, DEPENDENT)).item)).toEqual(['title']); + expect(columnsOf((await read(protocol)).item)).toEqual(BASE_COLUMNS); + }); + + it('CONTROL: a writable package\'s own item, a name no code package ships, is still served to the read naming no package', async () => { + const { protocol } = await boot(); + await installDependent(protocol); + const own = 'showcase_task.acme_board'; + await saveIntoDependent(protocol, own, DEPENDENT_COLUMNS); + + const packageless = await read(protocol, undefined, own); + expect(columnsOf(packageless.item)).toEqual(DEPENDENT_COLUMNS); + expect(packageless.item._packageId).toBe(DEPENDENT); + }); + + it('CONTROL: a package-unbound row still wins over the code package, beside the dependent package\'s row too', async () => { + const { engine, protocol } = await boot(); + await installDependent(protocol); + await saveIntoDependent(protocol, VIEW, DEPENDENT_COLUMNS); + await engine.insert('sys_metadata', { + type: 'view', name: VIEW, organization_id: null, package_id: null, + metadata: JSON.stringify(listBody(['title', 'priority'])), state: 'active', version: 1, + }, { context: { isSystem: true } }); + + expect(columnsOf((await read(protocol)).item)).toEqual(['title', 'priority']); + const layered: any = await (protocol as any).getMetaItemLayered({ type: 'view', name: VIEW }); + expect(columnsOf(layered.effective)).toEqual(['title', 'priority']); + expect(columnsOf((await read(protocol, DEPENDENT)).item)).toEqual(DEPENDENT_COLUMNS); + }); +}); From 0878604d76ef49aad8d133228404fa6c6b3c534c Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 03:38:34 +0000 Subject: [PATCH 3/4] test(objectql): the package-unbound row's two cases, the plain control and beside the dependent package's row Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01FEkQ5nQHuymasPwjX3mAr2 --- ...packageless-read-dependent-package.test.ts | 24 ++++++++++++++----- 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/packages/objectql/src/protocol-packageless-read-dependent-package.test.ts b/packages/objectql/src/protocol-packageless-read-dependent-package.test.ts index 8c2c9f57a7a..1df7f844c01 100644 --- a/packages/objectql/src/protocol-packageless-read-dependent-package.test.ts +++ b/packages/objectql/src/protocol-packageless-read-dependent-package.test.ts @@ -27,7 +27,8 @@ * - CONTROL: a writable package's OWN item, a name no code package ships, * is still served to the read naming no package (#23291); * - CONTROL: a package-unbound row (the ADR-0005 overlay Setup writes) still - * wins over the code package, beside the dependent package's row too. + * wins over the code package; beside the dependent package's row it is now + * what that read serves (the dependent package's row was, before). * * Not pinned: a declared override (the epic's override core, #28236, has no * declaration on `main` to leave untouched), and the env-wide list, which @@ -274,14 +275,25 @@ describe('[#28238] a dependent package\'s row under a name the base ships is not expect(packageless.item._packageId).toBe(DEPENDENT); }); - it('CONTROL: a package-unbound row still wins over the code package, beside the dependent package\'s row too', async () => { + /** A package-unbound active row, laid down through the engine as the repository's write lays one down. */ + const storeUnboundRow = (engine: ObjectQL, columns: readonly string[]) => engine.insert('sys_metadata', { + type: 'view', name: VIEW, organization_id: null, package_id: null, + metadata: JSON.stringify(listBody(columns)), state: 'active', version: 1, + }, { context: { isSystem: true } }); + + it('CONTROL: a package-unbound row still wins over the code package on the read naming no package', async () => { + const { engine, protocol } = await boot(); + await storeUnboundRow(engine, ['title', 'priority']); + + expect(columnsOf((await read(protocol)).item)).toEqual(['title', 'priority']); + expect(columnsOf((await read(protocol, BASE)).item)).toEqual(['title', 'priority']); + }); + + it('beside the dependent package\'s row, the package-unbound row is what the read naming no package serves', async () => { const { engine, protocol } = await boot(); await installDependent(protocol); await saveIntoDependent(protocol, VIEW, DEPENDENT_COLUMNS); - await engine.insert('sys_metadata', { - type: 'view', name: VIEW, organization_id: null, package_id: null, - metadata: JSON.stringify(listBody(['title', 'priority'])), state: 'active', version: 1, - }, { context: { isSystem: true } }); + await storeUnboundRow(engine, ['title', 'priority']); expect(columnsOf((await read(protocol)).item)).toEqual(['title', 'priority']); const layered: any = await (protocol as any).getMetaItemLayered({ type: 'view', name: VIEW }); From 21fa3e7fcbe7f151b62a90cc594310bb58a83bd6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 03:39:54 +0000 Subject: [PATCH 4/4] chore(changeset): metadata-protocol patch for the package-less read's content address Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01FEkQ5nQHuymasPwjX3mAr2 --- .changeset/28238-packageless-read-base-package.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 .changeset/28238-packageless-read-base-package.md diff --git a/.changeset/28238-packageless-read-base-package.md b/.changeset/28238-packageless-read-base-package.md new file mode 100644 index 00000000000..ba56f93c4c0 --- /dev/null +++ b/.changeset/28238-packageless-read-base-package.md @@ -0,0 +1,12 @@ +--- +'@objectstack/metadata-protocol': patch +--- + +fix(metadata-protocol): a same-named row in a dependent package no longer replaces the base package's item for a read that names no package + +Clause-②: no +Metadata-protocol: none + +When a writable package that depends on a code package saved an item under a name the code package ships (`showcase_task.default` saved into `com.default.acme-custom`), `GET /api/v1/meta/view/showcase_task.default` with no `package` served the dependent package's copy, under the code package's `_packageId`, and so did the layered read's `effective`. The code package that ships the name was not a candidate of that read: the dependent package's row won by being the only stored row. + +The by-name read and the layered read that name no package now take their stored content from the code package that ships the name: its own stored row, else the package-unbound row (the overlay Setup writes, which still wins over the code item), else the code item. A read naming the dependent package still serves its own row, and a writable package's own item, a name no code package ships, is still served to the read naming no package. Where a package-less save, delete and the read's `version` land does not move.