Skip to content

Commit 245e0c4

Browse files
os-zhuangclaude
andcommitted
fix(objectql): listDrafts surfaces env-wide drafts for a non-null org
The pending-changes list (`GET /meta/_drafts`, the Publish CTA's source) queried `organization_id = this.organizationId AND state = 'draft'`. AI- authored metadata is written ENV-WIDE (`organization_id IS NULL`), so under a non-null active org (every multi-tenant / cloud request) the strict equality dropped those drafts: the change showed in `?preview=draft` and `publish-drafts` would promote it, but the pending-changes list returned nothing — so the Publish CTA never appeared and the user couldn't publish their AI-made change ("orphaned draft"). Surface both org-scoped and env-wide drafts: when the active org is non-null, match `organization_id = org OR organization_id IS NULL`; when null (single-env), keep the env-wide query. This mirrors how `getMetaItems`/preview already overlays env-wide drafts. Tests: non-null-org surfaces env-wide drafts; mixed org-scoped + env-wide returned while other orgs' drafts excluded. Suite green (6). Mock engine extended to evaluate `$or`. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 9327cd8 commit 245e0c4

2 files changed

Lines changed: 50 additions & 8 deletions

File tree

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

Lines changed: 33 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,14 +17,21 @@ const ROWS = [
1717
{ type: 'object', name: 'live', state: 'active', package_id: 'app.edu', organization_id: null, updated_at: 't4' },
1818
];
1919

20-
function makeRepo(rows = ROWS) {
21-
// Minimal engine whose find() does equality-only WHERE matching.
20+
const matchesWhere = (r: any, clause: any): boolean =>
21+
Object.entries(clause).every(([k, v]) =>
22+
k === '$or'
23+
? (v as any[]).some((c) => matchesWhere(r, c))
24+
: (r as any)[k] === v,
25+
);
26+
27+
function makeRepo(rows = ROWS, organizationId: string | null = null) {
28+
// Minimal engine whose find() does equality WHERE matching, plus `$or`.
2229
const find = vi.fn(async (_table: string, q: any) => {
2330
const where = q?.where ?? {};
24-
return rows.filter((r) => Object.entries(where).every(([k, v]) => (r as any)[k] === v));
31+
return rows.filter((r) => matchesWhere(r, where));
2532
});
2633
const engine = { find } as any;
27-
const repo = new SysMetadataRepository({ engine, organizationId: null, orgLabel: 'env' });
34+
const repo = new SysMetadataRepository({ engine, organizationId, orgLabel: 'env' });
2835
return { repo, find };
2936
}
3037

@@ -50,6 +57,28 @@ describe('SysMetadataRepository.listDrafts (ADR-0033)', () => {
5057
expect(out.map((d) => d.name).sort()).toEqual(['course', 'course_list', 'student']);
5158
});
5259

60+
it('surfaces env-wide (org IS NULL) drafts even when the repo has a non-null active org', async () => {
61+
// Regression (orphaned-draft bug): AI-authored metadata is written env-wide
62+
// (organization_id NULL). A non-null active org used a strict equality that
63+
// dropped those drafts → they showed in preview but the Publish CTA never
64+
// appeared, so the user could not publish them.
65+
const { repo } = makeRepo(ROWS, 'org_acme');
66+
const names = (await repo.listDrafts()).map((d) => d.name).sort();
67+
expect(names).toEqual(['course', 'course_list', 'legacy', 'student']);
68+
expect(names).not.toContain('live');
69+
});
70+
71+
it('returns both org-scoped and env-wide drafts for a non-null org, excluding other orgs', async () => {
72+
const rows = [
73+
{ type: 'object', name: 'env_wide', state: 'draft', package_id: 'p', organization_id: null, updated_at: 't1' },
74+
{ type: 'object', name: 'org_scoped', state: 'draft', package_id: 'p', organization_id: 'org_acme', updated_at: 't2' },
75+
{ type: 'object', name: 'other_org', state: 'draft', package_id: 'p', organization_id: 'org_other', updated_at: 't3' },
76+
];
77+
const { repo } = makeRepo(rows, 'org_acme');
78+
const names = (await repo.listDrafts()).map((d) => d.name).sort();
79+
expect(names).toEqual(['env_wide', 'org_scoped']);
80+
});
81+
5382
it('filters by type', async () => {
5483
const { repo } = makeRepo();
5584
const out = await repo.listDrafts({ type: 'view' });

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

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -730,10 +730,23 @@ export class SysMetadataRepository implements MetadataRepository {
730730
}>
731731
> {
732732
this.assertOpen();
733-
const where: Record<string, unknown> = {
734-
organization_id: this.organizationId,
735-
state: 'draft',
736-
};
733+
const where: Record<string, unknown> = { state: 'draft' };
734+
// Surface BOTH org-scoped drafts and env-wide (`organization_id IS NULL`)
735+
// drafts. Env-wide drafts are real pending changes — `getMetaItems`/preview
736+
// overlays them and `publish-drafts` promotes them — but a strict
737+
// `organization_id = this.organizationId` equality silently dropped them
738+
// whenever the active org was non-null. AI-authored metadata is written
739+
// env-wide, so its drafts vanished from the pending-changes list and the
740+
// Publish CTA never appeared: the change showed in preview yet could not be
741+
// published from the UI (the "orphaned draft" bug).
742+
if (this.organizationId != null) {
743+
where.$or = [
744+
{ organization_id: this.organizationId },
745+
{ organization_id: null },
746+
];
747+
} else {
748+
where.organization_id = null;
749+
}
737750
if (filter?.type) where.type = filter.type;
738751
if (filter?.packageId) where.package_id = filter.packageId;
739752
const rows = await this.engine.find('sys_metadata', { where });

0 commit comments

Comments
 (0)