From efdeb061aa35cbebb773a63c67b9fe5d38b13a08 Mon Sep 17 00:00:00 2001 From: os-zhuang Date: Wed, 24 Jun 2026 02:05:15 +0800 Subject: [PATCH] fix(objectql): don't stamp runtime-authored metadata into a loaded code package MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Studio sends the currently-selected package via `?package=` as a *viewing* context. When a user authored a brand-new object while browsing a code package (e.g. `com.example.showcase`), `saveMetaItem` persisted that id as the new row's `package_id`. The object then read back as "provided by a code package" and became read-only immediately after publish — the user could no longer edit what they had just created (the `sys_metadata` row was stamped `package_id = `). `saveMetaItem` now drops the package binding for a runtime-only (DB-only, non-artifact-backed) write whose target is a *loaded* code package, persisting it as a plain org overlay (`package_id = null`, editable). Left untouched: - `override-artifact` writes — an org overlay OF a packaged item keeps its binding; and - writes into a non-loaded "authoring workspace" package id, so ADR-0048 #1824 per-package scoping survives. `isLoadedPackage` distinguishes the two via the engine manifest map (every `registerApp` records it) plus a registered-objects/apps fallback. The binding is dropped by mutating `request.packageId` so every downstream consumer (repo write, parent-version lookup, the live registry mutation on publish, and the audit record) sees the coerced value — coercing only the repo write left the in-memory object stamped and still read-only. Found by dogfooding Studio as a business user: a freshly-created custom object locked itself read-only after publish. Verified end-to-end against a live showcase server (object now org-owned + editable) and with 3 new unit tests; full objectql suite (695) green. Co-Authored-By: Claude Opus 4.8 --- .../src/protocol-save-meta-repo-path.test.ts | 93 +++++++++++++++++++ packages/objectql/src/protocol.ts | 72 ++++++++++++++ 2 files changed, 165 insertions(+) diff --git a/packages/objectql/src/protocol-save-meta-repo-path.test.ts b/packages/objectql/src/protocol-save-meta-repo-path.test.ts index 5ae32ef463..62d17712c8 100644 --- a/packages/objectql/src/protocol-save-meta-repo-path.test.ts +++ b/packages/objectql/src/protocol-save-meta-repo-path.test.ts @@ -262,4 +262,97 @@ describe('saveMetaItem — repository write path (post PR-10d.6)', () => { const afterBody = (Array.from(rows.values())[0] as any).metadata; expect(afterBody).toBe(beforeBody); }); + + // ── runtime-only writes must not be stamped into a code package ────── + // Regression: a custom object authored in Studio while a CODE package was + // selected in the dropdown (`?package=`) was persisted with + // `package_id = `, then read back as "code-provided" and + // locked read-only after publish. saveMetaItem now coerces such a + // runtime-only write to an org-owned overlay (`package_id = null`). + // + // We spy on the `sys_metadata` insert rather than reading back the stub + // row map (whose key ignores the table, so lineage/history inserts can + // collide with — and clobber — the parent row's `package_id`). + function spyInserts(engine: any): Array> { + const captured: Array> = []; + const orig = engine.insert.bind(engine); + engine.insert = async (t: string, data: Record, opts?: unknown) => { + if (t === 'sys_metadata') captured.push(data); + return orig(t, data, opts); + }; + return captured; + } + + it('runtime-only create while a LOADED code package is selected drops the binding (package_id = null)', async () => { + // The bug: a brand-new object authored while a loaded code package was + // selected in Studio's dropdown got `package_id = `. The + // object is NOT artifact-backed (intent = 'runtime-only') and the + // package IS loaded (its manifest is registered), so the binding must + // be dropped — otherwise it reads back as code-provided / read-only. + const { engine } = makeStubEngine(); + engine.manifests = new Map([['app.objectstack.hotcrm', { id: 'app.objectstack.hotcrm' }]]); + const inserts = spyInserts(engine); + const protocol = new ObjectStackProtocolImplementation(engine); + const result = await protocol.saveMetaItem({ + type: 'object', + name: 'maint_asset', + organizationId: 'org_alpha', + packageId: 'app.objectstack.hotcrm', // Studio had a code package selected + mode: 'draft', + item: { name: 'maint_asset', label: 'Asset', fields: { name: { type: 'text', label: 'Name' } } }, + }); + expect(result.success).toBe(true); + const create = inserts.find((d) => d.type === 'object' && d.name === 'maint_asset'); + expect(create).toBeTruthy(); + // brand-new org object must NOT carry the package binding → stays editable. + expect(create!.package_id ?? null).toBeNull(); + expect(create!.package_id).not.toBe('app.objectstack.hotcrm'); + }); + + it('runtime-only create into a NON-loaded package keeps its binding (ADR-0048 #1824 authoring scope)', async () => { + // A package *authoring workspace* is a bare id with no booted manifest + // and no registered metadata. Per-package authoring scope must survive, + // so the guard must leave such a binding intact. + const { engine } = makeStubEngine(); + // No manifests map / empty → isLoadedPackage('com.acme.beta') is false. + const inserts = spyInserts(engine); + const protocol = new ObjectStackProtocolImplementation(engine); + await protocol.saveMetaItem({ + type: 'object', + name: 'maint_ticket', + organizationId: 'org_alpha', + packageId: 'com.acme.beta', + mode: 'draft', + item: { name: 'maint_ticket', label: 'Ticket', fields: { name: { type: 'text', label: 'Name' } } }, + }); + const create = inserts.find((d) => d.type === 'object' && d.name === 'maint_ticket'); + expect(create).toBeTruthy(); + expect(create!.package_id).toBe('com.acme.beta'); + }); + + it('override-artifact write keeps its package binding (guard only touches runtime-only creates)', async () => { + // An org overlay OF a packaged item (intent = 'override-artifact') must + // stay bound to that package even though the package is loaded. Make the + // packaged view artifact-backed so the write is an override, not a fresh + // create. (Objects are not override-allowed, so the control uses a view.) + const { engine } = makeStubEngine(); + engine.manifests = new Map([['app.objectstack.hotcrm', { id: 'app.objectstack.hotcrm' }]]); + engine.registry.getArtifactItem = (type: string, name: string) => + type === 'view' && name === 'case_grid' + ? { _packageId: 'app.objectstack.hotcrm', name, type: 'grid' } + : undefined; + const inserts = spyInserts(engine); + const protocol = new ObjectStackProtocolImplementation(engine); + await protocol.saveMetaItem({ + type: 'view', + name: 'case_grid', + organizationId: 'org_alpha', + packageId: 'app.objectstack.hotcrm', + mode: 'draft', + item: { name: 'case_grid', type: 'grid', label: 'Cases (org overlay)', columns: ['id', 'title'] }, + }); + const create = inserts.find((d) => d.type === 'view' && d.name === 'case_grid'); + expect(create).toBeTruthy(); + expect(create!.package_id).toBe('app.objectstack.hotcrm'); + }); }); diff --git a/packages/objectql/src/protocol.ts b/packages/objectql/src/protocol.ts index f7a981b4e7..588a336d69 100644 --- a/packages/objectql/src/protocol.ts +++ b/packages/objectql/src/protocol.ts @@ -3147,6 +3147,44 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol { return item; } + /** + * True when `packageId` refers to a **loaded code package** — one that + * booted and registered a manifest (and typically objects/apps) into the + * engine. Such packages are read-only artifacts; runtime-authored items + * must not be bound to them (see {@link saveMetaItem}). + * + * A bare ADR-0048 *authoring-workspace* package id (no booted manifest, + * no registered metadata) returns `false`, so per-package authoring scope + * is preserved. Reads the engine's manifest map first (authoritative, + * O(1) — every `registerApp` call records the manifest there), then falls + * back to "owns ≥1 registered object" for defense in depth. + */ + private isLoadedPackage(packageId: string): boolean { + const engine = this.engine as any; + if (engine?.manifests?.has?.(packageId)) return true; + const registry = engine?.registry; + if (!registry) return false; + try { + // Objects contributed by the package (real data packages). + if (typeof registry.getAllObjects === 'function' + && registry.getAllObjects(packageId).length > 0) { + return true; + } + // UI / logic metadata bound to the package id. ADR-0048 — a code + // package registers its app via `registerApp(app, packageId)`, so + // the app item's `_packageId` is the package id; UI-only packages + // (no objects) are still detected here. + if (typeof registry.listItems === 'function') { + for (const t of ['app', 'view', 'page', 'flow', 'report', 'dashboard', 'agent', 'skill', 'role', 'permission']) { + if (registry.listItems(t, packageId).length > 0) return true; + } + } + } catch { + // A partial registry (test mocks) → treat as "not loaded". + } + return false; + } + /** * Resolve the effective `_lock` for an item by consulting the * artifact registry first, then the persisted overlay row. Artifact @@ -3672,6 +3710,40 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol { const intent: 'override-artifact' | 'runtime-only' = artifactBacked ? 'override-artifact' : 'runtime-only'; + // GUARD — a brand-new, DB-only ("runtime-only") metadata item must + // not be bound to a *loaded code package*. Studio sends the + // currently-selected package via `?package=`; when a user authors a + // new object while browsing a code package (e.g. `app.objectstack.hotcrm`), + // persisting that id as the new row's `package_id` makes the org + // object read back as "provided by a code package" and become + // read-only after publish — the user can no longer edit what they + // just created. Drop the binding so it is a plain org overlay + // (`package_id = null`, editable). + // + // Two scopes are deliberately left bound: + // • `override-artifact` writes — an org overlay OF a packaged item + // must keep pointing at that package. + // • runtime writes into a package that is NOT loaded as code — an + // ADR-0048 #1824 package *authoring workspace* is a bare id with + // no registered manifest, and per-package scoping must survive. + // `isLoadedPackage` distinguishes the two: only a booted code + // package has a manifest / registered artifacts. + // Mutate `request.packageId` (not a local copy) so every downstream + // consumer — the repo write, the parent-version lookup, the live + // registry mutation on publish, and the audit record — sees the + // coerced value consistently. Coercing only the repo write left the + // in-memory object stamped with the code package, so it still read + // back as code-provided / read-only. + if ( + intent === 'runtime-only' && + request.packageId != null && + this.isLoadedPackage(request.packageId) + ) { + console.warn( + `[Protocol] dropping package binding '${request.packageId}' from runtime-authored ${singularTypeForRepo}/${request.name} (it belongs to a loaded code package); persisting as an org overlay (package_id = null) so it stays editable.`, + ); + request.packageId = null; + } const orgId = request.organizationId ?? null; const repo = this.getOverlayRepo(orgId); const ref = {