Skip to content

Commit ac1fc4c

Browse files
xuyushun441-sysos-zhuangclaude
authored
feat(metadata): draft/package lifecycle for publish-as-preview (overlay reads, teardown, discard, delete) (#1569)
* feat(metadata): draft-overlay reads (previewDrafts) for pre-publish preview ADR-0033's loop is build(draft) -> review -> publish, but review was only a JSON diff; the rendered object page / kanban / form / nav that actually confirms a change only existed AFTER publish. This adds a request-scoped draft-overlay read mode so an (admin) reviewer can render the console off pending drafts before publishing: - getMetaItems({previewDrafts}) overlays state='draft' rows on active (draft wins; draft-only surfaces; never hydrates the SchemaRegistry). - getMetaItem({previewDrafts}) is non-strict: draft if present, else active (vs strict state:'draft' which 404s no_draft). - overlaid items tagged _draft:true for UI badging. - dispatcher threads ?preview=draft on GET /metadata/:type[/:name]. Admin gating of the flag is a deliberate follow-up (step 2). The same overlay also unblocks the AI agent referencing its own drafts (follow-up). Tests: protocol-meta draft-overlay (draft wins, draft-only, _draft, getMetaItem fallback) + full objectql sweep green (509). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(metadata): optional storage teardown on delete (dropStorage) Object storage was create-only: publish creates a table, but deleteMetaItem only tombstones the metadata row, leaving the physical table behind. That made "publish an object just to preview it with data, then discard" leave residue. - engine.dropObjectSchema(name): inverse of syncObjectSchema; calls the driver's existing dropTable. - deleteMetaItem({dropStorage}): opt-in, DESTRUCTIVE, guarded — object + active only, never sys_; default false keeps delete non-destructive. Best-effort. - REST: DELETE /meta/:type/:name?dropStorage=true. Makes "publish to preview -> discard" cleanly reversible (the chosen approach: lean on publish-into-dev for data confirmation, made safely undoable). Tests: deleteMetaItem teardown (drops for object+active; not by default; not sys_; not non-object) + objectql sweep green (513). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(packages): discardPackageDrafts + deletePackage (one-click discard / delete) Two distinct package-level operations on the per-item delete primitive: - discardPackageDrafts(packageId): drop only pending DRAFT rows, revert to the published baseline. NON-destructive (active metadata + tables untouched). "I edited this app a while, it's worse than before — abandon all changes." sys_metadata path (no metadata-service dep, unlike POST /packages/:id/revert). REST: POST /packages/:id/discard-drafts. - deletePackage(packageId): remove ALL package rows (active + draft) + tear down each object's table by default (DESTRUCTIVE; keepData:true preserves tables, sys_ guard still applies). "I don't want this package anymore." DELETE /packages/:id now also does this persisted removal (previously only the in-memory registry unregister, leaving AI-package rows + tables behind); ?keepData=true opts out of teardown. Drafts deleted before active so each table is dropped once; per-item failures collected without aborting. Tests: orchestration for both + objectql sweep (519). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(ai): authoring tools see their own drafts; blueprint surfaces packageId (Fix 4) Two gaps that broke the multi-step "build app -> author a flow for it" path (found verifying the solution_design guardrail): 1. AI couldn't discover its own draft objects: list_objects/list_metadata read getMetaItems active-only, so a just-drafted object was "not found" when the agent then authored a flow against it. They now pass previewDrafts:true (older runtimes ignore the flag -> stay active-only). describe was already draft-first. 2. The auto-authored flow had no package to bind to: apply_blueprint now surfaces a top-level packageId + bindingHint so the agent passes it to create_metadata and the flow lands in the app package, not an orphan draft. Makes the guardrail's "model data, then proactively draft the approval flow bound to the app" executable end-to-end. service-ai suite green (ai-service embedder test fails only locally — env-dependent, untouched). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 4705fb8 commit ac1fc4c

16 files changed

Lines changed: 747 additions & 14 deletions

.changeset/ai-draft-aware-tools.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@objectstack/service-ai': patch
3+
---
4+
5+
fix(ai): authoring tools can see their own drafts; blueprint surfaces the package to bind to
6+
7+
Two gaps that broke the multi-step "build app → author a flow for it" path (found while verifying the new solution_design guardrail):
8+
9+
1. **The agent couldn't discover its own draft objects.** `list_objects` / `list_metadata` read `getMetaItems` **active-only**, so a brand-new object the agent had just drafted (never published) was reported as "not found" when it then tried to author an approval flow against it. They now pass `previewDrafts: true`, overlaying pending drafts on the active list (older runtimes ignore the flag → stay active-only). `describe_metadata` was already draft-first.
10+
11+
2. **The auto-authored flow had no package to bind to.** `apply_blueprint` already homes its artifacts in an app package, but its result only nested the id under `package`. It now also surfaces a top-level `packageId` and a `bindingHint` telling the agent to pass that `packageId` to `create_metadata` when it drafts follow-up automation (e.g. the approval flow) — so the flow lands in the app package instead of becoming an orphan draft.
12+
13+
Together with the solution_design process guardrail, this makes the "model the data, then proactively draft the approval flow bound to the app" flow actually executable end-to-end.

.changeset/delete-drop-storage.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
'@objectstack/objectql': minor
3+
'@objectstack/rest': patch
4+
---
5+
6+
feat(metadata): optional storage teardown on delete so "publish to preview" leaves no orphan table
7+
8+
Object storage was create-only: `publishMetaItem` creates a table (`ensureObjectStorage`) but nothing ever dropped one — `deleteMetaItem` only tombstones the metadata row, leaving the physical table behind. That made the pragmatic "publish an object just to preview it with real data, then discard if wrong" loop leave residue.
9+
10+
Adds the inverse path, opt-in and guarded:
11+
12+
- `engine.dropObjectSchema(name)` — inverse of `syncObjectSchema`; resolves the table name + driver and calls the driver's existing `dropTable` (DROP TABLE IF EXISTS / drop collection).
13+
- `deleteMetaItem({ …, dropStorage })` — when `true`, drops the object's physical table after the metadata is removed. **DESTRUCTIVE**, so it is gated: `object` type only (others have no table), `active` state only (drafts were never materialised), and never a `sys_`-prefixed platform table. Default `false` keeps delete non-destructive to data. Best-effort: a drop failure is logged, not thrown.
14+
- REST: `DELETE /meta/:type/:name?dropStorage=true` threads the flag.
15+
16+
This makes "publish to preview → discard" cleanly reversible. Combined with the draft-overlay read mode, it backs the team's chosen approach: lean on publish (into a dev sandbox) for data-level confirmation rather than building a full draft-data preview, and make that publish safely undoable.
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
'@objectstack/objectql': minor
3+
'@objectstack/runtime': patch
4+
---
5+
6+
feat(metadata): draft-overlay reads so an admin can render the console off pending drafts before publish
7+
8+
ADR-0033's loop is `build (draft) → review → publish`, but "review" was only a JSON diff — the one thing that actually confirms an AI/hand-authored change (the rendered object page / kanban / form / nav) only existed *after* publish. That forces publishing unreviewed metadata just to look at it, defeating the draft gate.
9+
10+
This adds a request-scoped **draft-overlay read mode** to the metadata resolution layer:
11+
12+
- `getMetaItems({ …, previewDrafts })` — after the active overlay, overlays `state='draft'` rows on top (draft WINS on name collision; draft-only items surface too). Drafts are never hydrated into the process-wide SchemaRegistry.
13+
- `getMetaItem({ …, previewDrafts })` — non-strict: prefers a draft row if one exists, else falls back to the active value (unlike the strict `state:'draft'` mode, which 404s `no_draft`).
14+
- Every overlaid item is tagged `_draft: true` so the UI can badge it and show a "preview" banner.
15+
- The runtime HTTP dispatcher threads `?preview=draft` on `GET /metadata/:type` and `GET /metadata/:type/:name` into these reads.
16+
17+
The same overlay also unblocks the AI authoring agent referencing its own just-drafted objects (a follow-up will point `list_metadata` at it). Admin gating of the `?preview=draft` flag is a deliberate follow-up step.
18+
19+
Note: a brand-new draft object has no physical table until publish, so preview renders its *shape* (form/view/kanban/nav) but shows no data; field-additions to existing objects preview fully.
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@objectstack/objectql': minor
3+
'@objectstack/runtime': patch
4+
---
5+
6+
feat(packages): one-click discard-drafts and full delete for a package
7+
8+
Two distinct package-level lifecycle operations, both built on the per-item delete primitive:
9+
10+
- **`discardPackageDrafts(packageId)`** — drop every pending DRAFT bound to the package, reverting it to its last published baseline. NON-destructive: active/published metadata and physical tables are untouched. Use case: "I edited this app for a while and it turned out worse than before — abandon all my changes." Routes through the sys_metadata path (no metadata-service dependency, unlike the existing `POST /packages/:id/revert`, which 503s without a metadata service). REST: `POST /packages/:id/discard-drafts`.
11+
12+
- **`deletePackage(packageId)`** — remove the ENTIRE package: every `sys_metadata` row (active + draft) and, by default, the physical table of each object it defined (DESTRUCTIVE). `keepData: true` removes metadata but preserves tables; the `sys_`-table guard still applies. Use case: "I don't want this package anymore." `DELETE /packages/:id` now performs this persisted removal in addition to the in-memory registry unregister it already did (previously it left AI/runtime packages' rows and tables behind); `?keepData=true` opts out of teardown.
13+
14+
Drafts are deleted before active rows so each object's table is torn down exactly once. Per-item failures are collected without aborting the rest.
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
# Draft-overlay preview — implementation plan
2+
3+
**Goal:** an admin flips a "preview drafts" switch and navigates the live console/app with
4+
every surface (object pages, list/kanban/form views, app nav, dashboards) rendered off
5+
**draft-overlaid** metadata — so AI/hand-authored changes can be *seen rendered* before publish,
6+
not just read as a JSON diff.
7+
8+
**Unifying insight:** the same "draft-overlay read mode" serves BOTH
9+
- the human (preview mode renders drafts), and
10+
- the AI agent (`list_metadata` can see its own just-drafted objects — the Fix-2 gap that made
11+
multi-step "build app → build flow" break because the flow step couldn't find draft objects).
12+
13+
One foundation, two payoffs. Drafts become a first-class **renderable** state.
14+
15+
## Hard limit (be honest)
16+
Brand-new draft objects have **no physical table until publish** (`ensureObjectStorage` runs on
17+
`publishMetaItem`). So preview of a *new* object shows **shape** (form layout, view columns, kanban
18+
groupBy, nav) but **no data / can't create records**. Field-additions to *existing* objects preview
19+
fully. Full data-preview for new objects needs draft-tables / a preview environment (`sys_metadata`
20+
already has `environment_id`) — deferred (ties to ADR-0027).
21+
22+
## PRs
23+
24+
### PR1 — backend draft-overlay foundation (framework) ← THIS PR
25+
- `getMetaItems(request + previewDrafts?)`: after the active overlay, if `previewDrafts`, query
26+
`state='draft'` rows (env-wide + org) and overlay them on top (draft WINS over active; draft-only
27+
items appear). Mark each `_draft: true`. (protocol.ts ~1175-1234)
28+
- `getMetaItem(request + previewDrafts?)`: non-strict — draft if it exists, else fall back to active
29+
(distinct from the existing strict `state:'draft'` which 404s). Mark `_draft: true`. (~1337)
30+
- Dispatcher `handleMetadata`: read `?preview=draft`, thread `previewDrafts` into both list + detail
31+
reads. (http-dispatcher.ts ~947, ~1004)
32+
- Tests (protocol): draft overlays active; draft-only surfaces; `_draft` flag; getMetaItem fallback.
33+
- Changeset (objectql minor, runtime patch).
34+
- **No admin gate yet** — deferred to PR3 per product call (step 2).
35+
36+
### PR1.5 — graceful no-table data path
37+
When `preview=draft` and a draft-only object has no table, the DATA query returns empty + a
38+
`draftNoTable` signal instead of "no such table". (data dispatcher)
39+
40+
### PR2 — objectui frontend (sibling repo)
41+
- Admin-only "Preview drafts" toggle in the app shell; persistent "PREVIEW — drafts" banner.
42+
- Metadata client threads `?preview=draft` on all metadata reads when the toggle is on.
43+
- `_draft` badge on overlaid items; tolerate empty draft-object lists.
44+
45+
### PR3 — admin gating (the user's explicit "step 2")
46+
Gate `preview=draft` to platform/org admins (reuse `isPlatformAdmin()`/`isActiveOrgAdmin()` from
47+
plugin-auth auth-manager.ts:961-1005). Non-admin flag → silently serve active (never leak drafts).
48+
49+
### PR4 — AI discovery reuse (Fix 2)
50+
Point the AI `list_metadata`/`describe_metadata` tools at the same draft-overlay read so the agent
51+
can reference its own drafts; thread `packageId` into `create_metadata` so AI-authored flows bind to
52+
the app package (the orphan-flow bug found in verification).

packages/objectql/src/engine.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2361,6 +2361,23 @@ export class ObjectQL implements IDataEngine {
23612361
await (driver as any).syncSchema(tableName, obj);
23622362
}
23632363

2364+
/**
2365+
* Drop the physical storage (table/collection) backing an object — the
2366+
* inverse of {@link syncObjectSchema}. DESTRUCTIVE: deletes all rows in the
2367+
* table. Used by the protocol delete path when the caller explicitly opts
2368+
* into storage teardown (e.g. discarding an object that was published only
2369+
* to preview it). No-op when the object's driver does not expose `dropTable`.
2370+
* Resolves the physical table name from the registered definition, falling
2371+
* back to the bare name if the def was already removed.
2372+
*/
2373+
async dropObjectSchema(objectName: string): Promise<void> {
2374+
const obj = this._registry.getObject(objectName) as any;
2375+
const driver = this.getDriverForObject(objectName);
2376+
if (!driver || typeof (driver as any).dropTable !== 'function') return;
2377+
const tableName = StorageNameMapping.resolveTableName(obj ?? ({ name: objectName } as any));
2378+
await (driver as any).dropTable(tableName);
2379+
}
2380+
23642381
/**
23652382
* Get a registered driver by datasource name.
23662383
* Alias matching @objectql/core datasource() API.

packages/objectql/src/protocol-meta.test.ts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,77 @@ describe('ObjectStackProtocolImplementation - Metadata Persistence', () => {
134134
});
135135
});
136136

137+
describe('getMetaItems draft-overlay preview (ADR-0033)', () => {
138+
const seedActiveAndDraft = () => mockEngine.find.mockImplementation((_t: string, opts: any) => {
139+
const w = opts?.where ?? {};
140+
if (w.type !== 'app') return Promise.resolve([]);
141+
if (w.state === 'active') {
142+
return Promise.resolve([
143+
{ type: 'app', name: 'shared', state: 'active', metadata: JSON.stringify({ name: 'shared', label: 'Active' }) },
144+
{ type: 'app', name: 'published_only', state: 'active', metadata: JSON.stringify({ name: 'published_only', label: 'Pub' }) },
145+
]);
146+
}
147+
if (w.state === 'draft') {
148+
return Promise.resolve([
149+
{ type: 'app', name: 'shared', state: 'draft', package_id: 'app.x', metadata: JSON.stringify({ name: 'shared', label: 'Draft' }) },
150+
{ type: 'app', name: 'draft_only', state: 'draft', package_id: 'app.x', metadata: JSON.stringify({ name: 'draft_only', label: 'New' }) },
151+
]);
152+
}
153+
return Promise.resolve([]);
154+
});
155+
156+
it('overlays drafts on active when previewDrafts is set (draft wins, draft-only surfaces, _draft tagged)', async () => {
157+
seedActiveAndDraft();
158+
const result = await protocol.getMetaItems({ type: 'app', previewDrafts: true });
159+
const items = result.items as any[];
160+
expect(items.map((i) => i.name).sort()).toEqual(['draft_only', 'published_only', 'shared']);
161+
const shared = items.find((i) => i.name === 'shared');
162+
expect(shared.label).toBe('Draft'); // draft wins over active
163+
expect(shared._draft).toBe(true);
164+
const draftOnly = items.find((i) => i.name === 'draft_only');
165+
expect(draftOnly._draft).toBe(true);
166+
expect(draftOnly._packageId).toBe('app.x');
167+
expect(items.find((i) => i.name === 'published_only')._draft).toBeUndefined(); // active untouched
168+
});
169+
170+
it('hides drafts by default (no previewDrafts)', async () => {
171+
seedActiveAndDraft();
172+
const result = await protocol.getMetaItems({ type: 'app' });
173+
const items = result.items as any[];
174+
expect(items.map((i) => i.name).sort()).toEqual(['published_only', 'shared']);
175+
expect(items.find((i) => i.name === 'shared').label).toBe('Active');
176+
expect(items.some((i) => i.name === 'draft_only')).toBe(false);
177+
});
178+
});
179+
180+
describe('getMetaItem draft-overlay preview (ADR-0033)', () => {
181+
it('returns the draft when previewDrafts and a draft exists (_draft tagged, non-strict)', async () => {
182+
mockEngine.findOne.mockImplementation((_t: string, opts: any) => {
183+
const w = opts?.where ?? {};
184+
if (w.state === 'draft' && w.name === 'lead') {
185+
return Promise.resolve({ type: 'object', name: 'lead', state: 'draft', package_id: 'app.x', metadata: JSON.stringify({ name: 'lead', label: 'Draft Lead' }) });
186+
}
187+
return Promise.resolve(null);
188+
});
189+
const res: any = await protocol.getMetaItem({ type: 'object', name: 'lead', previewDrafts: true });
190+
expect(res.item.label).toBe('Draft Lead');
191+
expect(res.item._draft).toBe(true);
192+
});
193+
194+
it('falls back to active when previewDrafts but no draft exists (no no_draft 404)', async () => {
195+
mockEngine.findOne.mockImplementation((_t: string, opts: any) => {
196+
const w = opts?.where ?? {};
197+
if (w.state === 'active' && w.name === 'lead') {
198+
return Promise.resolve({ type: 'object', name: 'lead', state: 'active', metadata: JSON.stringify({ name: 'lead', label: 'Active Lead' }) });
199+
}
200+
return Promise.resolve(null); // no draft row
201+
});
202+
const res: any = await protocol.getMetaItem({ type: 'object', name: 'lead', previewDrafts: true });
203+
expect(res.item.label).toBe('Active Lead');
204+
expect(res.item._draft).toBeUndefined();
205+
});
206+
});
207+
137208
describe('saveMetaItem', () => {
138209
it('should throw when item data is missing', async () => {
139210
await expect(

0 commit comments

Comments
 (0)