Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 93 additions & 0 deletions packages/objectql/src/protocol-save-meta-repo-path.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 = <code package>`, 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<Record<string, unknown>> {
const captured: Array<Record<string, unknown>> = [];
const orig = engine.insert.bind(engine);
engine.insert = async (t: string, data: Record<string, unknown>, 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 = <that package>`. 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');
});
});
72 changes: 72 additions & 0 deletions packages/objectql/src/protocol.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand DownExpand Up@@ -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 = {
Expand Down