From 7dbb61b11c53ee4d3411ce5c8ad93cc995a029fb Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Sun, 14 Jun 2026 10:51:36 +0500 Subject: [PATCH] fix(objectql): promoteDraft preserves the draft's package binding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit promoteDraft() called put() WITHOUT a packageId, so promoting a package-bound draft to active created the active row unbound (package_id = NULL). Every package-scoped reader then missed it — most visibly the ADR-0045 publish visibility flip (getMetaItems({ type:'app', packageId }) → unhide in publish-drafts), which never matched a just-published AI-built app and left it hidden:true forever (invisible in the app switcher / home "全部应用"). Read the raw draft row, carry its package_id onto the promoted active row, and read the current active through the same package scope so the optimistic-lock parentVersion still matches. Package-less drafts (packageId null) behave exactly as before. Adds a regression test asserting the promoted active row keeps the draft's package binding. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/sys-metadata-repository.test.ts | 30 +++++++++++++++++++ .../objectql/src/sys-metadata-repository.ts | 23 ++++++++++++-- 2 files changed, 50 insertions(+), 3 deletions(-) diff --git a/packages/objectql/src/sys-metadata-repository.test.ts b/packages/objectql/src/sys-metadata-repository.test.ts index af31ab58b9..f28e0be70e 100644 --- a/packages/objectql/src/sys-metadata-repository.test.ts +++ b/packages/objectql/src/sys-metadata-repository.test.ts @@ -631,6 +631,36 @@ describe('SysMetadataRepository', () => { expect(ops).toContain('publish'); }); + it('promoteDraft carries the draft package binding onto the promoted active row', async () => { + // A whole-app build stages every artifact bound to the app's workspace + // package and the app starts `hidden`. Promotion MUST preserve that + // binding — otherwise the active app row lands unbound (package_id NULL) + // and the ADR-0045 publish visibility flip (which looks up hidden apps + // by package: getMetaItems({ type:'app', packageId })) never matches it, + // leaving the freshly-built app hidden from the app switcher forever. + const ref = { org: 'org_alpha', type: 'app' as const, name: 'ticket_service_app' }; + await repo.put( + ref, + { name: 'ticket_service_app', label: 'Tickets', hidden: true }, + { parentVersion: null, actor: 'studio', state: 'draft', packageId: 'app.tickets' }, + ); + await repo.promoteDraft(ref, { actor: 'admin' }); + const activeRow = Array.from(engine.rows.values()).find( + (r) => (r as any).type === 'app' + && (r as any).name === 'ticket_service_app' + && (r as any).state === 'active', + ) as any; + expect(activeRow).toBeDefined(); + expect(activeRow.package_id).toBe('app.tickets'); + // The draft row is consumed by the promotion. + const draftRow = Array.from(engine.rows.values()).find( + (r) => (r as any).type === 'app' + && (r as any).name === 'ticket_service_app' + && (r as any).state === 'draft', + ); + expect(draftRow).toBeUndefined(); + }); + it('promoteDraft throws no_draft when nothing is pending', async () => { const ref = { org: 'org_alpha', type: 'view' as const, name: 'case_grid' }; await repo.put(ref, sampleView, { parentVersion: null, actor: 'studio' }); diff --git a/packages/objectql/src/sys-metadata-repository.ts b/packages/objectql/src/sys-metadata-repository.ts index 5980002177..d878d7d609 100644 --- a/packages/objectql/src/sys-metadata-repository.ts +++ b/packages/objectql/src/sys-metadata-repository.ts @@ -579,8 +579,19 @@ export class SysMetadataRepository implements MetadataRepository { opts: { actor: string; source?: string; message?: string; intent?: MetadataWriteIntent }, ): Promise<{ version: string; seq: number; item: MetadataItem }> { this.assertOpen(); - const draft = await this.get(ref, { state: 'draft' }); - if (!draft) { + // Read the RAW draft row (not just the body) so the promotion can carry + // the draft's package binding onto the active row. ADR-0048 keys overlay + // rows by `(org, type, name, package_id)`; promoteDraft historically + // called put() WITHOUT a packageId, so the freshly-created active row + // landed unbound (`package_id = NULL`). That silently broke every + // package-scoped reader — most visibly the ADR-0045 publish visibility + // flip (`getMetaItems({ type:'app', packageId })` → unhide), which then + // never matched the just-published app and left AI-built apps `hidden` + // (invisible in the app switcher / home) forever. + const draftRow = await this.engine.findOne('sys_metadata', { + where: this.whereFor(ref, 'draft'), + }); + if (!draftRow) { const err: any = new Error( `[no_draft] No pending draft exists for ${ref.type}/${ref.name} — nothing to publish.`, ); @@ -588,7 +599,12 @@ export class SysMetadataRepository implements MetadataRepository { err.status = 404; throw err; } - const currentActive = await this.get(ref, { state: 'active' }); + const draftPackageId = (draftRow as { package_id?: string | null }).package_id ?? null; + const draft = this.rowToItem(ref, draftRow); + // Read the active row through the SAME package scope we will write, so the + // optimistic-lock `parentVersion` matches the exact row `put` upserts. + // (Package-less drafts → packageId null → identical to the prior behaviour.) + const currentActive = await this.get(ref, { state: 'active', packageId: draftPackageId }); const result = await this.put(ref, draft.body, { parentVersion: currentActive?.hash ?? null, actor: opts.actor, @@ -597,6 +613,7 @@ export class SysMetadataRepository implements MetadataRepository { intent: opts.intent ?? 'override-artifact', state: 'active', opType: 'publish', + packageId: draftPackageId, }); // Drop the draft row — it has been promoted. Tolerate races where // a second publisher already drained it.