diff --git a/.changeset/ai-draft-aware-tools.md b/.changeset/ai-draft-aware-tools.md new file mode 100644 index 000000000..046872ed1 --- /dev/null +++ b/.changeset/ai-draft-aware-tools.md @@ -0,0 +1,13 @@ +--- +'@objectstack/service-ai': patch +--- + +fix(ai): authoring tools can see their own drafts; blueprint surfaces the package to bind to + +Two gaps that broke the multi-step "build app → author a flow for it" path (found while verifying the new solution_design guardrail): + +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. + +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. + +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. diff --git a/.changeset/delete-drop-storage.md b/.changeset/delete-drop-storage.md new file mode 100644 index 000000000..1fc2c36ce --- /dev/null +++ b/.changeset/delete-drop-storage.md @@ -0,0 +1,16 @@ +--- +'@objectstack/objectql': minor +'@objectstack/rest': patch +--- + +feat(metadata): optional storage teardown on delete so "publish to preview" leaves no orphan table + +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. + +Adds the inverse path, opt-in and guarded: + +- `engine.dropObjectSchema(name)` — inverse of `syncObjectSchema`; resolves the table name + driver and calls the driver's existing `dropTable` (DROP TABLE IF EXISTS / drop collection). +- `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. +- REST: `DELETE /meta/:type/:name?dropStorage=true` threads the flag. + +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. diff --git a/.changeset/draft-overlay-preview.md b/.changeset/draft-overlay-preview.md new file mode 100644 index 000000000..8b2357fa0 --- /dev/null +++ b/.changeset/draft-overlay-preview.md @@ -0,0 +1,19 @@ +--- +'@objectstack/objectql': minor +'@objectstack/runtime': patch +--- + +feat(metadata): draft-overlay reads so an admin can render the console off pending drafts before publish + +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. + +This adds a request-scoped **draft-overlay read mode** to the metadata resolution layer: + +- `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. +- `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`). +- Every overlaid item is tagged `_draft: true` so the UI can badge it and show a "preview" banner. +- The runtime HTTP dispatcher threads `?preview=draft` on `GET /metadata/:type` and `GET /metadata/:type/:name` into these reads. + +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. + +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. diff --git a/.changeset/package-discard-delete.md b/.changeset/package-discard-delete.md new file mode 100644 index 000000000..1b3240517 --- /dev/null +++ b/.changeset/package-discard-delete.md @@ -0,0 +1,14 @@ +--- +'@objectstack/objectql': minor +'@objectstack/runtime': patch +--- + +feat(packages): one-click discard-drafts and full delete for a package + +Two distinct package-level lifecycle operations, both built on the per-item delete primitive: + +- **`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`. + +- **`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. + +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. diff --git a/docs/notes/draft-overlay-preview-plan.md b/docs/notes/draft-overlay-preview-plan.md new file mode 100644 index 000000000..267cd3675 --- /dev/null +++ b/docs/notes/draft-overlay-preview-plan.md @@ -0,0 +1,52 @@ +# Draft-overlay preview — implementation plan + +**Goal:** an admin flips a "preview drafts" switch and navigates the live console/app with +every surface (object pages, list/kanban/form views, app nav, dashboards) rendered off +**draft-overlaid** metadata — so AI/hand-authored changes can be *seen rendered* before publish, +not just read as a JSON diff. + +**Unifying insight:** the same "draft-overlay read mode" serves BOTH +- the human (preview mode renders drafts), and +- the AI agent (`list_metadata` can see its own just-drafted objects — the Fix-2 gap that made + multi-step "build app → build flow" break because the flow step couldn't find draft objects). + +One foundation, two payoffs. Drafts become a first-class **renderable** state. + +## Hard limit (be honest) +Brand-new draft objects have **no physical table until publish** (`ensureObjectStorage` runs on +`publishMetaItem`). So preview of a *new* object shows **shape** (form layout, view columns, kanban +groupBy, nav) but **no data / can't create records**. Field-additions to *existing* objects preview +fully. Full data-preview for new objects needs draft-tables / a preview environment (`sys_metadata` +already has `environment_id`) — deferred (ties to ADR-0027). + +## PRs + +### PR1 — backend draft-overlay foundation (framework) ← THIS PR +- `getMetaItems(request + previewDrafts?)`: after the active overlay, if `previewDrafts`, query + `state='draft'` rows (env-wide + org) and overlay them on top (draft WINS over active; draft-only + items appear). Mark each `_draft: true`. (protocol.ts ~1175-1234) +- `getMetaItem(request + previewDrafts?)`: non-strict — draft if it exists, else fall back to active + (distinct from the existing strict `state:'draft'` which 404s). Mark `_draft: true`. (~1337) +- Dispatcher `handleMetadata`: read `?preview=draft`, thread `previewDrafts` into both list + detail + reads. (http-dispatcher.ts ~947, ~1004) +- Tests (protocol): draft overlays active; draft-only surfaces; `_draft` flag; getMetaItem fallback. +- Changeset (objectql minor, runtime patch). +- **No admin gate yet** — deferred to PR3 per product call (step 2). + +### PR1.5 — graceful no-table data path +When `preview=draft` and a draft-only object has no table, the DATA query returns empty + a +`draftNoTable` signal instead of "no such table". (data dispatcher) + +### PR2 — objectui frontend (sibling repo) +- Admin-only "Preview drafts" toggle in the app shell; persistent "PREVIEW — drafts" banner. +- Metadata client threads `?preview=draft` on all metadata reads when the toggle is on. +- `_draft` badge on overlaid items; tolerate empty draft-object lists. + +### PR3 — admin gating (the user's explicit "step 2") +Gate `preview=draft` to platform/org admins (reuse `isPlatformAdmin()`/`isActiveOrgAdmin()` from +plugin-auth auth-manager.ts:961-1005). Non-admin flag → silently serve active (never leak drafts). + +### PR4 — AI discovery reuse (Fix 2) +Point the AI `list_metadata`/`describe_metadata` tools at the same draft-overlay read so the agent +can reference its own drafts; thread `packageId` into `create_metadata` so AI-authored flows bind to +the app package (the orphan-flow bug found in verification). diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index 6fd64ddc0..0a6ddf8c9 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -2361,6 +2361,23 @@ export class ObjectQL implements IDataEngine { await (driver as any).syncSchema(tableName, obj); } + /** + * Drop the physical storage (table/collection) backing an object — the + * inverse of {@link syncObjectSchema}. DESTRUCTIVE: deletes all rows in the + * table. Used by the protocol delete path when the caller explicitly opts + * into storage teardown (e.g. discarding an object that was published only + * to preview it). No-op when the object's driver does not expose `dropTable`. + * Resolves the physical table name from the registered definition, falling + * back to the bare name if the def was already removed. + */ + async dropObjectSchema(objectName: string): Promise { + const obj = this._registry.getObject(objectName) as any; + const driver = this.getDriverForObject(objectName); + if (!driver || typeof (driver as any).dropTable !== 'function') return; + const tableName = StorageNameMapping.resolveTableName(obj ?? ({ name: objectName } as any)); + await (driver as any).dropTable(tableName); + } + /** * Get a registered driver by datasource name. * Alias matching @objectql/core datasource() API. diff --git a/packages/objectql/src/protocol-meta.test.ts b/packages/objectql/src/protocol-meta.test.ts index e32fb3c89..975b6c330 100644 --- a/packages/objectql/src/protocol-meta.test.ts +++ b/packages/objectql/src/protocol-meta.test.ts @@ -134,6 +134,77 @@ describe('ObjectStackProtocolImplementation - Metadata Persistence', () => { }); }); + describe('getMetaItems draft-overlay preview (ADR-0033)', () => { + const seedActiveAndDraft = () => mockEngine.find.mockImplementation((_t: string, opts: any) => { + const w = opts?.where ?? {}; + if (w.type !== 'app') return Promise.resolve([]); + if (w.state === 'active') { + return Promise.resolve([ + { type: 'app', name: 'shared', state: 'active', metadata: JSON.stringify({ name: 'shared', label: 'Active' }) }, + { type: 'app', name: 'published_only', state: 'active', metadata: JSON.stringify({ name: 'published_only', label: 'Pub' }) }, + ]); + } + if (w.state === 'draft') { + return Promise.resolve([ + { type: 'app', name: 'shared', state: 'draft', package_id: 'app.x', metadata: JSON.stringify({ name: 'shared', label: 'Draft' }) }, + { type: 'app', name: 'draft_only', state: 'draft', package_id: 'app.x', metadata: JSON.stringify({ name: 'draft_only', label: 'New' }) }, + ]); + } + return Promise.resolve([]); + }); + + it('overlays drafts on active when previewDrafts is set (draft wins, draft-only surfaces, _draft tagged)', async () => { + seedActiveAndDraft(); + const result = await protocol.getMetaItems({ type: 'app', previewDrafts: true }); + const items = result.items as any[]; + expect(items.map((i) => i.name).sort()).toEqual(['draft_only', 'published_only', 'shared']); + const shared = items.find((i) => i.name === 'shared'); + expect(shared.label).toBe('Draft'); // draft wins over active + expect(shared._draft).toBe(true); + const draftOnly = items.find((i) => i.name === 'draft_only'); + expect(draftOnly._draft).toBe(true); + expect(draftOnly._packageId).toBe('app.x'); + expect(items.find((i) => i.name === 'published_only')._draft).toBeUndefined(); // active untouched + }); + + it('hides drafts by default (no previewDrafts)', async () => { + seedActiveAndDraft(); + const result = await protocol.getMetaItems({ type: 'app' }); + const items = result.items as any[]; + expect(items.map((i) => i.name).sort()).toEqual(['published_only', 'shared']); + expect(items.find((i) => i.name === 'shared').label).toBe('Active'); + expect(items.some((i) => i.name === 'draft_only')).toBe(false); + }); + }); + + describe('getMetaItem draft-overlay preview (ADR-0033)', () => { + it('returns the draft when previewDrafts and a draft exists (_draft tagged, non-strict)', async () => { + mockEngine.findOne.mockImplementation((_t: string, opts: any) => { + const w = opts?.where ?? {}; + if (w.state === 'draft' && w.name === 'lead') { + return Promise.resolve({ type: 'object', name: 'lead', state: 'draft', package_id: 'app.x', metadata: JSON.stringify({ name: 'lead', label: 'Draft Lead' }) }); + } + return Promise.resolve(null); + }); + const res: any = await protocol.getMetaItem({ type: 'object', name: 'lead', previewDrafts: true }); + expect(res.item.label).toBe('Draft Lead'); + expect(res.item._draft).toBe(true); + }); + + it('falls back to active when previewDrafts but no draft exists (no no_draft 404)', async () => { + mockEngine.findOne.mockImplementation((_t: string, opts: any) => { + const w = opts?.where ?? {}; + if (w.state === 'active' && w.name === 'lead') { + return Promise.resolve({ type: 'object', name: 'lead', state: 'active', metadata: JSON.stringify({ name: 'lead', label: 'Active Lead' }) }); + } + return Promise.resolve(null); // no draft row + }); + const res: any = await protocol.getMetaItem({ type: 'object', name: 'lead', previewDrafts: true }); + expect(res.item.label).toBe('Active Lead'); + expect(res.item._draft).toBeUndefined(); + }); + }); + describe('saveMetaItem', () => { it('should throw when item data is missing', async () => { await expect( diff --git a/packages/objectql/src/protocol-package-lifecycle.test.ts b/packages/objectql/src/protocol-package-lifecycle.test.ts new file mode 100644 index 000000000..250488534 --- /dev/null +++ b/packages/objectql/src/protocol-package-lifecycle.test.ts @@ -0,0 +1,105 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect, vi } from 'vitest'; +import { ObjectStackProtocolImplementation } from './protocol.js'; + +/** + * ADR-0033 — package-level lifecycle beyond publish: + * - `discardPackageDrafts` — abandon all pending edits, revert to the published + * baseline (NON-destructive: drafts only, no table teardown). + * - `deletePackage` — remove the whole package (active + draft) and, by + * default, tear down each object's physical table (DESTRUCTIVE). + * + * These tests cover the orchestration (which per-item `deleteMetaItem` calls are + * made, with which flags) — the teardown itself is covered in + * protocol-publish-rollback.test.ts. + */ + +describe('protocol.discardPackageDrafts', () => { + function makeProtocol(drafts: Array<{ type: string; name: string }>) { + const protocol = new ObjectStackProtocolImplementation({} as never); + (protocol as any).ensureOverlayIndex = async () => {}; + (protocol as any).getOverlayRepo = () => ({ listDrafts: async () => drafts }); + const deleteMetaItem = vi.spyOn(protocol, 'deleteMetaItem' as never); + deleteMetaItem.mockResolvedValue({ success: true } as never); + return { protocol, deleteMetaItem }; + } + + it('discards every draft (state:draft, NO teardown) and reports success', async () => { + const { protocol, deleteMetaItem } = makeProtocol([ + { type: 'object', name: 'course' }, + { type: 'view', name: 'course_list' }, + ]); + const res = await protocol.discardPackageDrafts({ packageId: 'app.edu' }); + expect(deleteMetaItem).toHaveBeenCalledTimes(2); + const first = deleteMetaItem.mock.calls[0][0] as any; + expect(first).toMatchObject({ type: 'object', name: 'course', state: 'draft' }); + expect(first).not.toHaveProperty('dropStorage'); // never tears down published data + expect(res).toMatchObject({ success: true, discardedCount: 2, failedCount: 0 }); + }); + + it('collects per-item failures without aborting', async () => { + const { protocol, deleteMetaItem } = makeProtocol([ + { type: 'object', name: 'course' }, + { type: 'view', name: 'course_list' }, + ]); + (deleteMetaItem as any).mockImplementation(async (req: any) => { + if (req.name === 'course_list') throw Object.assign(new Error('locked'), { code: 'locked' }); + return { success: true }; + }); + const res = await protocol.discardPackageDrafts({ packageId: 'app.edu' }); + expect(res.discardedCount).toBe(1); + expect(res.failedCount).toBe(1); + expect(res.failed[0]).toMatchObject({ name: 'course_list', code: 'locked' }); + expect(res.success).toBe(false); + }); + + it('empty package → discardedCount 0, success false', async () => { + const { protocol, deleteMetaItem } = makeProtocol([]); + const res = await protocol.discardPackageDrafts({ packageId: 'app.empty' }); + expect(deleteMetaItem).not.toHaveBeenCalled(); + expect(res).toMatchObject({ success: false, discardedCount: 0 }); + }); +}); + +describe('protocol.deletePackage', () => { + function makeProtocol(rows: Array<{ type: string; name: string; state: string; organization_id?: string | null }>) { + const engine = { find: vi.fn(async () => rows) }; + const protocol = new ObjectStackProtocolImplementation(engine as never); + const deleteMetaItem = vi.spyOn(protocol, 'deleteMetaItem' as never); + deleteMetaItem.mockResolvedValue({ success: true } as never); + return { protocol, deleteMetaItem }; + } + + it('deletes all rows, tears down active objects (dropStorage), drafts before active', async () => { + const { protocol, deleteMetaItem } = makeProtocol([ + { type: 'object', name: 'course', state: 'active', organization_id: null }, + { type: 'object', name: 'course', state: 'draft', organization_id: null }, + { type: 'view', name: 'course_list', state: 'active', organization_id: null }, + ]); + const res = await protocol.deletePackage({ packageId: 'app.edu' }); + expect(res).toMatchObject({ success: true, deletedCount: 3, failedCount: 0 }); + + const calls = deleteMetaItem.mock.calls.map((c) => c[0] as any); + const courseActive = calls.find((c) => c.name === 'course' && c.state === 'active'); + expect(courseActive).toMatchObject({ dropStorage: true }); + + const order = calls.map((c) => `${c.name}:${c.state}`); + expect(order.indexOf('course:draft')).toBeLessThan(order.indexOf('course:active')); + }); + + it('keepData:true removes metadata but does NOT request teardown', async () => { + const { protocol, deleteMetaItem } = makeProtocol([ + { type: 'object', name: 'course', state: 'active', organization_id: null }, + ]); + await protocol.deletePackage({ packageId: 'app.edu', keepData: true }); + expect((deleteMetaItem.mock.calls[0][0] as any)).not.toHaveProperty('dropStorage'); + }); + + it('empty package → deletedCount 0, success false', async () => { + const { protocol, deleteMetaItem } = makeProtocol([]); + const res = await protocol.deletePackage({ packageId: 'app.empty' }); + expect(deleteMetaItem).not.toHaveBeenCalled(); + expect(res).toMatchObject({ success: false, deletedCount: 0 }); + }); +}); diff --git a/packages/objectql/src/protocol-publish-rollback.test.ts b/packages/objectql/src/protocol-publish-rollback.test.ts index 0be4288d3..29be8765c 100644 --- a/packages/objectql/src/protocol-publish-rollback.test.ts +++ b/packages/objectql/src/protocol-publish-rollback.test.ts @@ -1,6 +1,6 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { ObjectStackProtocolImplementation } from './protocol'; /** @@ -319,3 +319,43 @@ describe('publishMetaItem / rollbackMetaItem / diffMetaItem', () => { expect((diff as any).changed.map((e: any) => e.path).sort()).toEqual(['columns', 'label']); }); }); + +describe('deleteMetaItem — storage teardown (dropStorage)', () => { + const seedActiveObject = async (name: string) => { + const { engine, rows } = makeStubEngine(); + engine.syncObjectSchema = vi.fn(); + engine.dropObjectSchema = vi.fn(); + const protocol = new ObjectStackProtocolImplementation(engine); + await protocol.saveMetaItem({ type: 'object', name, item: { name, label: name, fields: { title: { type: 'text' } } } }); + return { engine, rows, protocol }; + }; + + it('drops the physical table when dropStorage is set (object + active)', async () => { + const { engine, protocol } = await seedActiveObject('expense_claim'); + const res = await protocol.deleteMetaItem({ type: 'object', name: 'expense_claim', dropStorage: true }); + expect(res.success).toBe(true); + expect(engine.dropObjectSchema).toHaveBeenCalledTimes(1); + expect(engine.dropObjectSchema).toHaveBeenCalledWith('expense_claim'); + }); + + it('does NOT drop the table by default (delete stays non-destructive to data)', async () => { + const { engine, protocol } = await seedActiveObject('expense_claim'); + await protocol.deleteMetaItem({ type: 'object', name: 'expense_claim' }); + expect(engine.dropObjectSchema).not.toHaveBeenCalled(); + }); + + it('never drops a sys_-prefixed platform table even with dropStorage', async () => { + const { engine, protocol } = await seedActiveObject('sys_secret'); + await protocol.deleteMetaItem({ type: 'object', name: 'sys_secret', dropStorage: true }); + expect(engine.dropObjectSchema).not.toHaveBeenCalled(); + }); + + it('does not drop storage for a non-object type even with dropStorage', async () => { + const { engine, rows } = makeStubEngine(); + engine.dropObjectSchema = vi.fn(); + const protocol = new ObjectStackProtocolImplementation(engine); + await protocol.saveMetaItem({ type: 'dashboard', name: 'sales', item: { name: 'sales', label: 'Sales', widgets: [] } }); + await protocol.deleteMetaItem({ type: 'dashboard', name: 'sales', dropStorage: true }); + expect(engine.dropObjectSchema).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/objectql/src/protocol.ts b/packages/objectql/src/protocol.ts index c89aa0893..a673e5caa 100644 --- a/packages/objectql/src/protocol.ts +++ b/packages/objectql/src/protocol.ts @@ -1131,7 +1131,7 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol { }; } - async getMetaItems(request: { type: string; packageId?: string; organizationId?: string }) { + async getMetaItems(request: { type: string; packageId?: string; organizationId?: string; previewDrafts?: boolean }) { const { packageId } = request; let items: unknown[] = []; @@ -1233,6 +1233,55 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol { // DB not available — fall through with whatever we already have. } + // ADR-0033 draft-overlay preview: when the caller opts in (admin-gated + // upstream — see http-dispatcher), overlay `state='draft'` rows on top of + // the active result so the rendered console can preview pending changes + // BEFORE publish (instead of only reading them as a JSON diff). Draft rows + // WIN over active on name collision, and draft-only items (e.g. a brand-new + // AI-authored object) surface too. Each overlaid item is tagged `_draft:true` + // so the UI can badge it and show the "PREVIEW — drafts" banner. We do NOT + // hydrate the SchemaRegistry from drafts — drafts must never leak into the + // process-wide registry or to non-preview reads. + if (request.previewDrafts) { + try { + const orgId = (request as any).organizationId as string | undefined; + const queryDrafts = async (oid: string | null): Promise => { + const whereClause: Record = { type: request.type, state: 'draft', organization_id: oid }; + if (packageId) whereClause.package_id = packageId; + let rs = await this.engine.find('sys_metadata', { where: whereClause }); + if (!rs || rs.length === 0) { + const alt = PLURAL_TO_SINGULAR[request.type] ?? SINGULAR_TO_PLURAL[request.type]; + if (alt) { + const altWhere: Record = { type: alt, state: 'draft', organization_id: oid }; + if (packageId) altWhere.package_id = packageId; + rs = await this.engine.find('sys_metadata', { where: altWhere }); + } + } + return rs ?? []; + }; + const draftRecords = [...(await queryDrafts(null)), ...(orgId ? await queryDrafts(orgId) : [])]; + if (draftRecords.length > 0) { + const byName = new Map(); + for (const existing of items) { + const entry = existing as any; + if (entry && typeof entry === 'object' && 'name' in entry) byName.set(entry.name, entry); + } + for (const record of draftRecords) { + const data = typeof record.metadata === 'string' ? JSON.parse(record.metadata) : record.metadata; + if (data && typeof data === 'object' && 'name' in data) { + const recPkg = (record as { package_id?: string | null }).package_id ?? undefined; + if (recPkg && (data as any)._packageId === undefined) (data as any)._packageId = recPkg; + (data as any)._draft = true; + byName.set(data.name, data); + } + } + items = Array.from(byName.values()); + } + } catch { + // DB unavailable — serve the active result unchanged. + } + } + // Merge with MetadataService (runtime-registered items: agents, tools, etc.) try { const services = this.getServicesRegistry?.(); @@ -1334,13 +1383,51 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol { }; } - async getMetaItem(request: { type: string, name: string, packageId?: string, organizationId?: string, state?: 'active' | 'draft' }) { + async getMetaItem(request: { type: string, name: string, packageId?: string, organizationId?: string, state?: 'active' | 'draft', previewDrafts?: boolean }) { let item: unknown; const orgId = request.organizationId; // Studio's editor opens a draft buffer with `state: 'draft'`; // runtime loaders omit it and get the live published row. const readState: 'active' | 'draft' = request.state === 'draft' ? 'draft' : 'active'; + // ADR-0033 draft-overlay preview (non-strict): when the caller opts in + // (admin-gated upstream), prefer a `state='draft'` row if one exists, else + // fall back to the active read below. This differs from the strict + // `state:'draft'` mode, which 404s (`no_draft`) when no draft exists — the + // render path must degrade to the published value, not error. The draft + // item is tagged `_draft:true` so the UI can badge it. + if (request.previewDrafts && readState !== 'draft') { + try { + const findDraft = async (oid: string | null): Promise => { + const rec = await this.engine.findOne('sys_metadata', { + where: { type: request.type, name: request.name, state: 'draft', organization_id: oid }, + }); + if (rec) return rec; + const alt = PLURAL_TO_SINGULAR[request.type] ?? SINGULAR_TO_PLURAL[request.type]; + if (alt) { + return await this.engine.findOne('sys_metadata', { + where: { type: alt, name: request.name, state: 'draft', organization_id: oid }, + }); + } + return undefined; + }; + const draftRec = (orgId ? await findDraft(orgId) : undefined) ?? await findDraft(null); + if (draftRec) { + const draftItem = typeof draftRec.metadata === 'string' + ? JSON.parse(draftRec.metadata) + : draftRec.metadata; + if (draftItem && typeof draftItem === 'object') { + const recPkg = (draftRec as { package_id?: string | null }).package_id ?? undefined; + if (recPkg && (draftItem as any)._packageId === undefined) (draftItem as any)._packageId = recPkg; + (draftItem as any)._draft = true; + } + return { type: request.type, name: request.name, item: decorateMetadataItem(request.type, draftItem) }; + } + } catch { + // DB unavailable — fall through to the active read. + } + } + // 1. Customization overlay lookup (sys_metadata). // Per ADR-0005 (revised), org-scoped row wins; env-wide // (organization_id IS NULL) row is the fallback before falling @@ -3234,6 +3321,39 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol { } } + /** + * Inverse of {@link ensureObjectStorage}: drop an object's physical table. + * DESTRUCTIVE — deletes the table and all its rows. Only invoked when a + * delete explicitly opts into storage teardown (see {@link deleteMetaItem}'s + * `dropStorage`), so publishing an object solely to preview it can be undone + * without leaving an orphan table. Best-effort: a failure is logged, not + * thrown — the metadata delete already succeeded, and a stray table is + * reclaimed by the next sync/drop rather than blocking the delete. + */ + private async dropObjectStorage(type: string, name: string): Promise { + if (type !== 'object' && type !== 'objects') return; + try { + await this.engine.dropObjectSchema(name); + } catch (err: any) { + console.warn(`[Protocol] table drop failed for object '${name}': ${err?.message ?? err}`); + } + } + + /** + * Guard for storage teardown on delete. Drops a physical table only when + * the caller opted in AND it is safe: object types only (others have no + * table), active state only (drafts were never materialised), and never a + * `sys_`-prefixed platform table. + */ + private shouldDropStorage(type: string, name: string, dropStorage: boolean | undefined, state: 'active' | 'draft'): boolean { + if (!dropStorage) return false; + const singular = PLURAL_TO_SINGULAR[type] ?? type; + if (singular !== 'object') return false; + if (state !== 'active') return false; + if (name.startsWith('sys_')) return false; + return true; + } + async saveMetaItem(request: { type: string, name: string, item?: any, organizationId?: string, parentVersion?: string | null, actor?: string, force?: boolean, mode?: 'draft' | 'publish', packageId?: string | null }) { if (!request.item) { throw new Error('Item data is required'); @@ -3829,6 +3949,132 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol { }; } + /** + * Discard every pending DRAFT bound to a package — the NON-destructive + * inverse of {@link publishPackageDrafts}. Drops only `state='draft'` rows + * (via the per-item delete primitive), reverting the package to its last + * published baseline; active/published metadata and physical tables are + * left 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 `POST /packages/:id/revert`). + */ + async discardPackageDrafts(request: { + packageId: string; + organizationId?: string; + actor?: string; + }): Promise<{ + success: boolean; + discardedCount: number; + failedCount: number; + discarded: Array<{ type: string; name: string }>; + failed: Array<{ type: string; name: string; error: string; code?: string }>; + }> { + await this.ensureOverlayIndex(); + const orgId = request.organizationId ?? null; + const repo = this.getOverlayRepo(orgId); + const drafts = await repo.listDrafts({ packageId: request.packageId }); + + const discarded: Array<{ type: string; name: string }> = []; + const failed: Array<{ type: string; name: string; error: string; code?: string }> = []; + + for (const d of drafts) { + try { + await this.deleteMetaItem({ + type: d.type, + name: d.name, + state: 'draft', + ...(request.organizationId ? { organizationId: request.organizationId } : {}), + ...(request.actor ? { actor: request.actor } : {}), + }); + discarded.push({ type: d.type, name: d.name }); + } catch (e: any) { + failed.push({ + type: d.type, + name: d.name, + error: e?.message ?? 'discard failed', + ...(e?.code ? { code: e.code } : {}), + }); + } + } + + return { + success: failed.length === 0 && discarded.length > 0, + discardedCount: discarded.length, + failedCount: failed.length, + discarded, + failed, + }; + } + + /** + * Delete an ENTIRE package: every `sys_metadata` row bound to it (active + * AND draft) and — by default — the physical table of each object it + * defined. DESTRUCTIVE: removes the app and its data. Use case: "I don't + * want this package anymore." + * + * Set `keepData: true` to remove the metadata but preserve object tables. + * The `sys_`-table guard in {@link deleteMetaItem} still applies, so + * platform storage is never dropped. Drafts are removed before active rows + * so each object's table is torn down once. Per-item failures are collected + * without aborting the rest. + */ + async deletePackage(request: { + packageId: string; + organizationId?: string; + actor?: string; + keepData?: boolean; + }): Promise<{ + success: boolean; + deletedCount: number; + failedCount: number; + deleted: Array<{ type: string; name: string; state: string }>; + failed: Array<{ type: string; name: string; error: string; code?: string }>; + }> { + const where: Record = { package_id: request.packageId }; + if (request.organizationId) where.organization_id = request.organizationId; + const rows = (await this.engine.find('sys_metadata', { where })) as any[]; + + const dropStorage = request.keepData !== true; + // Delete drafts before active so an object's table is dropped once (on + // the active delete), not pre-empted by a draft delete. + const ordered = [...rows].sort((a, b) => (a.state === 'draft' ? 0 : 1) - (b.state === 'draft' ? 0 : 1)); + + const deleted: Array<{ type: string; name: string; state: string }> = []; + const failed: Array<{ type: string; name: string; error: string; code?: string }> = []; + + for (const row of ordered) { + const state: 'active' | 'draft' = row.state === 'draft' ? 'draft' : 'active'; + try { + await this.deleteMetaItem({ + type: row.type, + name: row.name, + state, + ...(row.organization_id ? { organizationId: row.organization_id } : {}), + ...(request.actor ? { actor: request.actor } : {}), + ...(dropStorage ? { dropStorage: true } : {}), + }); + deleted.push({ type: row.type, name: row.name, state }); + } catch (e: any) { + failed.push({ + type: row.type, + name: row.name, + error: e?.message ?? 'delete failed', + ...(e?.code ? { code: e.code } : {}), + }); + } + } + + return { + success: failed.length === 0 && deleted.length > 0, + deletedCount: deleted.length, + failedCount: failed.length, + deleted, + failed, + }; + } + /** * Restore the body recorded at history `toVersion` as the new * live row. Writes a history event with `op='revert'`. 404 @@ -4038,6 +4284,13 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol { parentVersion?: string | null; actor?: string; state?: 'active' | 'draft'; + /** + * When true, also drop the object's physical table after the metadata + * is removed (object + active only; never `sys_`). Default false keeps + * delete non-destructive to data. Used by the "discard a previewed + * object" flow so a publish-to-preview leaves no orphan table. + */ + dropStorage?: boolean; }): Promise<{ success: boolean; message?: string; @@ -4154,6 +4407,12 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol { } } + // Storage teardown (opt-in): drop the now-orphaned physical table + // for a discarded object so a publish-to-preview leaves no residue. + if (this.shouldDropStorage(request.type, request.name, request.dropStorage, targetState)) { + await this.dropObjectStorage(singularTypeForRepo, request.name); + } + // ADR-0010 — success audit (best-effort). await this.recordMetadataAudit({ type: request.type, @@ -4214,6 +4473,14 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol { } await this.engine.delete('sys_metadata', { where: { id: existing.id } }); + // Storage teardown (opt-in) — see the repo-path branch above. + { + const targetState: 'active' | 'draft' = request.state === 'draft' ? 'draft' : 'active'; + if (this.shouldDropStorage(request.type, request.name, request.dropStorage, targetState)) { + await this.dropObjectStorage(PLURAL_TO_SINGULAR[request.type] ?? request.type, request.name); + } + } + if (this.environmentId === undefined) { try { const services = this.getServicesRegistry?.(); diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index e530f2f4c..3a3796a2d 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -1933,6 +1933,12 @@ export class RestServer { ? 'draft' as const : undefined; + // `?dropStorage=true` also tears down the object's physical + // table (object + active only). Used by the "discard a + // previewed object" flow so a publish-to-preview leaves no + // orphan table. Destructive — opt-in, defaults off. + const dropStorage = req.query?.dropStorage === 'true' || req.query?.dropStorage === '1'; + const result = await (p as any).deleteMetaItem({ type: req.params.type, name: req.params.name, @@ -1940,6 +1946,7 @@ export class RestServer { ...(parentVersion !== undefined ? { parentVersion } : {}), ...(actor ? { actor } : {}), ...(stateParam ? { state: stateParam } : {}), + ...(dropStorage ? { dropStorage: true } : {}), }); res.json(result); } catch (error: any) { diff --git a/packages/runtime/src/http-dispatcher.ts b/packages/runtime/src/http-dispatcher.ts index eceab5f26..3f5ce04ff 100644 --- a/packages/runtime/src/http-dispatcher.ts +++ b/packages/runtime/src/http-dispatcher.ts @@ -947,7 +947,11 @@ export class HttpDispatcher { if (protocol && typeof protocol.getMetaItem === 'function') { try { const organizationId = await this.resolveActiveOrganizationId(_context); - const data = await protocol.getMetaItem({ type: singularType, name, packageId, organizationId }); + // ADR-0033 draft-overlay preview: `?preview=draft` makes the + // detail read prefer a pending draft (falling back to active). + // Admin gating is layered on top in a follow-up (step 2). + const previewDrafts = query?.preview === 'draft'; + const data = await protocol.getMetaItem({ type: singularType, name, packageId, organizationId, previewDrafts }); return { handled: true, response: this.success(data) }; } catch (e: any) { // Protocol might throw if not found or not supported @@ -1004,7 +1008,11 @@ export class HttpDispatcher { if (protocol && typeof protocol.getMetaItems === 'function') { try { const organizationId = await this.resolveActiveOrganizationId(_context); - const data = await protocol.getMetaItems({ type: typeOrName, packageId, organizationId }); + // ADR-0033 draft-overlay preview: `?preview=draft` overlays + // pending drafts on the active list so an (admin) reviewer can + // render the console off drafts before publishing. + const previewDrafts = query?.preview === 'draft'; + const data = await protocol.getMetaItems({ type: typeOrName, packageId, organizationId, previewDrafts }); // Return any valid response from protocol (including empty items arrays) if (data && (data.items !== undefined || Array.isArray(data))) { return { handled: true, response: this.success(data) }; @@ -1487,6 +1495,30 @@ export class HttpDispatcher { return { handled: true, response: this.error('Draft publishing not supported', 501) }; } + // POST /packages/:id/discard-drafts → drop every pending DRAFT bound + // to the package, reverting it to its last published baseline + // ("abandon all my changes"). NON-destructive: active metadata and + // physical tables are untouched. Routes through the sys_metadata + // path (no metadata-service dependency, unlike /revert below). + if (parts.length === 2 && parts[1] === 'discard-drafts' && m === 'POST') { + const id = decodeURIComponent(parts[0]); + const protocol = await this.resolveService('protocol'); + if (protocol && typeof (protocol as any).discardPackageDrafts === 'function') { + try { + const organizationId = await this.resolveActiveOrganizationId(_context); + const result = await (protocol as any).discardPackageDrafts({ + packageId: id, + ...(organizationId ? { organizationId } : {}), + ...(body?.actor ? { actor: body.actor } : {}), + }); + return { handled: true, response: this.success(result) }; + } catch (e: any) { + return { handled: true, response: this.error(e.message, e.statusCode || 500) }; + } + } + return { handled: true, response: this.error('Draft discarding not supported', 501) }; + } + // POST /packages/:id/revert → revert package to last published state if (parts.length === 2 && parts[1] === 'revert' && m === 'POST') { const id = decodeURIComponent(parts[0]); @@ -1517,12 +1549,39 @@ export class HttpDispatcher { return { handled: true, response: this.success(pkg) }; } - // DELETE /packages/:id → uninstall package + // DELETE /packages/:id → delete the package. Unregisters it from the + // in-memory registry AND removes its persisted sys_metadata rows + // (active + draft), tearing down each object's physical table by + // default. `?keepData=true` preserves object tables (metadata-only + // delete). Use case: "I don't want this package anymore." if (parts.length === 1 && m === 'DELETE') { const id = decodeURIComponent(parts[0]); - const success = registry.uninstallPackage(id); - if (!success) return { handled: true, response: this.error(`Package '${id}' not found`, 404) }; - return { handled: true, response: this.success({ success: true }) }; + const registryRemoved = registry.uninstallPackage(id); + + // Persisted removal (AI/runtime packages live in sys_metadata, not + // just the in-memory registry — the registry uninstall alone would + // leave the rows and tables behind). + let persisted: unknown = undefined; + const protocol = await this.resolveService('protocol'); + if (protocol && typeof (protocol as any).deletePackage === 'function') { + try { + const organizationId = await this.resolveActiveOrganizationId(_context); + const keepData = query?.keepData === 'true' || query?.keepData === '1'; + persisted = await (protocol as any).deletePackage({ + packageId: id, + ...(organizationId ? { organizationId } : {}), + ...(keepData ? { keepData: true } : {}), + }); + } catch (e: any) { + return { handled: true, response: this.error(e.message, e.statusCode || 500) }; + } + } + + const deletedCount = (persisted as any)?.deletedCount ?? 0; + if (!registryRemoved && deletedCount === 0) { + return { handled: true, response: this.error(`Package '${id}' not found`, 404) }; + } + return { handled: true, response: this.success({ success: true, registryRemoved, persisted }) }; } } catch (e: any) { return { handled: true, response: this.error(e.message, e.statusCode || 500) }; diff --git a/packages/services/service-ai/src/__tests__/blueprint-tools.test.ts b/packages/services/service-ai/src/__tests__/blueprint-tools.test.ts index 80d499bf5..9ce9f018d 100644 --- a/packages/services/service-ai/src/__tests__/blueprint-tools.test.ts +++ b/packages/services/service-ai/src/__tests__/blueprint-tools.test.ts @@ -196,6 +196,24 @@ describe('apply_blueprint handler', () => { expect(view.config.columns).toEqual(['title']); }); + it('surfaces packageId + bindingHint so follow-up automation (a flow) binds to the app package', async () => { + const reg = new ToolRegistry(); + const proto = createMockProtocol(); + // installPackage present → ensureAppPackage materialises an app package. + (proto.protocol as any).installPackage = vi.fn(async () => ({ success: true })); + registerBlueprintTools(reg, { ai: createMockAi().ai, protocol: proto.protocol, metadataService: createMockMetadataService() }); + + const parsed = parse(await reg.execute(call('apply_blueprint', { + blueprint: { ...SAMPLE_BLUEPRINT, app: { name: 'pm_app', label: 'PM' } }, + }))); + + // Without these, the agent's follow-up create_metadata(flow) had no package + // to bind to and produced an ORPHAN flow draft. + expect(parsed.packageId).toBe('app.pm_app'); + expect(parsed.bindingHint).toContain('app.pm_app'); + expect(parsed.bindingHint).toMatch(/create_metadata/); + }); + it('emits kanban config (groupByField + columns) — explicit groupBy wins, else infers the select field', async () => { const blueprint = { summary: 'recruiting', diff --git a/packages/services/service-ai/src/__tests__/metadata-tools.test.ts b/packages/services/service-ai/src/__tests__/metadata-tools.test.ts index f305a5da3..d1f6f7c35 100644 --- a/packages/services/service-ai/src/__tests__/metadata-tools.test.ts +++ b/packages/services/service-ai/src/__tests__/metadata-tools.test.ts @@ -85,9 +85,18 @@ function createMockProtocol(seedActive: Record = {}) { // `unknown[]` and `{ items }`, but the declared protocol contract is // `Promise`). const getMetaItems = vi.fn(async (req: any) => { - return [...active.entries()] + const fromActive = [...active.entries()] .filter(([k]) => k.startsWith(`${req.type}:`)) .map(([, v]) => v); + if (!req.previewDrafts) return fromActive; + // Mirror protocol.getMetaItems({ previewDrafts }): overlay draft rows on top + // of active (draft wins by name; draft-only surfaces). + const byName = new Map(); + for (const v of fromActive) byName.set((v as any)?.name, v); + for (const [k, v] of drafts.entries()) { + if (k.startsWith(`${req.type}:`)) byName.set((v as any)?.name ?? k, v); + } + return [...byName.values()]; }); const protocol: NonNullable = { @@ -786,6 +795,15 @@ describe('create_metadata / update_metadata / describe_metadata / list_metadata' const filtered = parse(await registry.execute(call('list_metadata', { type: 'view', filter: 'zzz' }))); expect(filtered.totalCount).toBe(0); }); + + it('list_metadata surfaces a draft-only item (previewDrafts) so the agent sees its own pending work', async () => { + // A brand-new object the agent just drafted (never published). Active-only + // reads hide it, so the agent reports its own object as "not found" when it + // later tries to author a flow against it. previewDrafts overlays it. + drafts.set('object:expense_claim', { name: 'expense_claim', label: 'Expense Claim' }); + const res = parse(await registry.execute(call('list_metadata', { type: 'object' }))); + expect(res.items.map((i: any) => i.name)).toContain('expense_claim'); + }); }); // ═══════════════════════════════════════════════════════════════════ diff --git a/packages/services/service-ai/src/tools/blueprint-tools.ts b/packages/services/service-ai/src/tools/blueprint-tools.ts index d271a3cb0..8d991e6fb 100644 --- a/packages/services/service-ai/src/tools/blueprint-tools.ts +++ b/packages/services/service-ai/src/tools/blueprint-tools.ts @@ -460,6 +460,15 @@ function createApplyBlueprintHandler(ctx: BlueprintToolContext): ToolHandler { // The app's artifacts were auto-homed in a writable package (zero user // package steps); informational only — no action required. ...(appPackage ? { package: appPackage } : {}), + // Surface the package id at the top level (not just nested under `package`) + // and spell out the binding rule, so when the agent proceeds to draft + // automation for this app (e.g. an approval `flow`) it passes this + // `packageId` to create_metadata and the new artifact lands in the app + // package instead of becoming an orphan draft. + ...(packageId ? { packageId } : {}), + ...(packageId + ? { bindingHint: `To add automation (e.g. an approval flow) for this app, pass packageId="${packageId}" to create_metadata so it is grouped under the app — do not leave it unbound.` } + : {}), // Phase C does not auto-apply seed data — no runtime-draftable `dataset` // type exists; surface it so a human can wire it deliberately. seedDataProposed, diff --git a/packages/services/service-ai/src/tools/metadata-tools.ts b/packages/services/service-ai/src/tools/metadata-tools.ts index 89d58f657..39c045fbd 100644 --- a/packages/services/service-ai/src/tools/metadata-tools.ts +++ b/packages/services/service-ai/src/tools/metadata-tools.ts @@ -205,7 +205,15 @@ export interface MetadataToolContext { * complete set of available objects. */ protocol?: { - getMetaItems(request: { type: string; packageId?: string; organizationId?: string }): Promise; + /** + * `previewDrafts` overlays pending `state='draft'` rows on the active list + * so the authoring agent can DISCOVER metadata it (or a prior turn) just + * drafted but nobody has published yet — e.g. referencing a just-drafted + * object when authoring a flow. Without it, `getMetaItems` is active-only + * and the agent reports its own draft objects as "not found". Older runtimes + * ignore the unknown property (graceful: stays active-only). + */ + getMetaItems(request: { type: string; packageId?: string; organizationId?: string; previewDrafts?: boolean }): Promise; /** * Read a single metadata item. With `state:'draft'` returns the pending * draft row and throws `no_draft` (404) when none exists — it does NOT @@ -765,7 +773,7 @@ function createListObjectsHandler(ctx: MetadataToolContext): ToolHandler { let objects: unknown[] = []; if (ctx.protocol?.getMetaItems) { try { - const fromProtocol = await ctx.protocol.getMetaItems({ type: 'object' }); + const fromProtocol = await ctx.protocol.getMetaItems({ type: 'object', previewDrafts: true }); // Protocol can return either a plain array OR a wrapped envelope // `{ type, items: [] }` (the shape returned by the protocol shim // backing `GET /api/v1/meta/object`). Normalize both. @@ -833,7 +841,7 @@ function createDescribeObjectHandler(ctx: MetadataToolContext): ToolHandler { let objectDef: unknown | undefined = await ctx.metadataService.getObject(objectName); if (!objectDef && ctx.protocol?.getMetaItems) { try { - const all = await ctx.protocol.getMetaItems({ type: 'object' }); + const all = await ctx.protocol.getMetaItems({ type: 'object', previewDrafts: true }); const arr: ObjectDef[] = Array.isArray(all) ? (all as ObjectDef[]) : (all && typeof all === 'object' && Array.isArray((all as any).items) @@ -991,7 +999,7 @@ function createListMetadataHandler(ctx: MetadataToolContext): ToolHandler { let items: unknown[] = []; if (ctx.protocol?.getMetaItems) { try { - const res = await ctx.protocol.getMetaItems({ type }); + const res = await ctx.protocol.getMetaItems({ type, previewDrafts: true }); items = Array.isArray(res) ? res : res && typeof res === 'object' && Array.isArray((res as { items?: unknown[] }).items)