Skip to content

Commit 3337cf5

Browse files
xuyushun441-sysos-zhuangclaude
authored
fix(objectql): promoteDraft preserves the draft's package binding (#1834)
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: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 4b01250 commit 3337cf5

2 files changed

Lines changed: 50 additions & 3 deletions

File tree

packages/objectql/src/sys-metadata-repository.test.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -631,6 +631,36 @@ describe('SysMetadataRepository', () => {
631631
expect(ops).toContain('publish');
632632
});
633633

634+
it('promoteDraft carries the draft package binding onto the promoted active row', async () => {
635+
// A whole-app build stages every artifact bound to the app's workspace
636+
// package and the app starts `hidden`. Promotion MUST preserve that
637+
// binding — otherwise the active app row lands unbound (package_id NULL)
638+
// and the ADR-0045 publish visibility flip (which looks up hidden apps
639+
// by package: getMetaItems({ type:'app', packageId })) never matches it,
640+
// leaving the freshly-built app hidden from the app switcher forever.
641+
const ref = { org: 'org_alpha', type: 'app' as const, name: 'ticket_service_app' };
642+
await repo.put(
643+
ref,
644+
{ name: 'ticket_service_app', label: 'Tickets', hidden: true },
645+
{ parentVersion: null, actor: 'studio', state: 'draft', packageId: 'app.tickets' },
646+
);
647+
await repo.promoteDraft(ref, { actor: 'admin' });
648+
const activeRow = Array.from(engine.rows.values()).find(
649+
(r) => (r as any).type === 'app'
650+
&& (r as any).name === 'ticket_service_app'
651+
&& (r as any).state === 'active',
652+
) as any;
653+
expect(activeRow).toBeDefined();
654+
expect(activeRow.package_id).toBe('app.tickets');
655+
// The draft row is consumed by the promotion.
656+
const draftRow = Array.from(engine.rows.values()).find(
657+
(r) => (r as any).type === 'app'
658+
&& (r as any).name === 'ticket_service_app'
659+
&& (r as any).state === 'draft',
660+
);
661+
expect(draftRow).toBeUndefined();
662+
});
663+
634664
it('promoteDraft throws no_draft when nothing is pending', async () => {
635665
const ref = { org: 'org_alpha', type: 'view' as const, name: 'case_grid' };
636666
await repo.put(ref, sampleView, { parentVersion: null, actor: 'studio' });

packages/objectql/src/sys-metadata-repository.ts

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -579,16 +579,32 @@ export class SysMetadataRepository implements MetadataRepository {
579579
opts: { actor: string; source?: string; message?: string; intent?: MetadataWriteIntent },
580580
): Promise<{ version: string; seq: number; item: MetadataItem }> {
581581
this.assertOpen();
582-
const draft = await this.get(ref, { state: 'draft' });
583-
if (!draft) {
582+
// Read the RAW draft row (not just the body) so the promotion can carry
583+
// the draft's package binding onto the active row. ADR-0048 keys overlay
584+
// rows by `(org, type, name, package_id)`; promoteDraft historically
585+
// called put() WITHOUT a packageId, so the freshly-created active row
586+
// landed unbound (`package_id = NULL`). That silently broke every
587+
// package-scoped reader — most visibly the ADR-0045 publish visibility
588+
// flip (`getMetaItems({ type:'app', packageId })` → unhide), which then
589+
// never matched the just-published app and left AI-built apps `hidden`
590+
// (invisible in the app switcher / home) forever.
591+
const draftRow = await this.engine.findOne('sys_metadata', {
592+
where: this.whereFor(ref, 'draft'),
593+
});
594+
if (!draftRow) {
584595
const err: any = new Error(
585596
`[no_draft] No pending draft exists for ${ref.type}/${ref.name} — nothing to publish.`,
586597
);
587598
err.code = 'no_draft';
588599
err.status = 404;
589600
throw err;
590601
}
591-
const currentActive = await this.get(ref, { state: 'active' });
602+
const draftPackageId = (draftRow as { package_id?: string | null }).package_id ?? null;
603+
const draft = this.rowToItem(ref, draftRow);
604+
// Read the active row through the SAME package scope we will write, so the
605+
// optimistic-lock `parentVersion` matches the exact row `put` upserts.
606+
// (Package-less drafts → packageId null → identical to the prior behaviour.)
607+
const currentActive = await this.get(ref, { state: 'active', packageId: draftPackageId });
592608
const result = await this.put(ref, draft.body, {
593609
parentVersion: currentActive?.hash ?? null,
594610
actor: opts.actor,
@@ -597,6 +613,7 @@ export class SysMetadataRepository implements MetadataRepository {
597613
intent: opts.intent ?? 'override-artifact',
598614
state: 'active',
599615
opType: 'publish',
616+
packageId: draftPackageId,
600617
});
601618
// Drop the draft row — it has been promoted. Tolerate races where
602619
// a second publisher already drained it.

0 commit comments

Comments
 (0)