diff --git a/.changeset/adr-0033-list-drafts.md b/.changeset/adr-0033-list-drafts.md new file mode 100644 index 000000000..699f28a9e --- /dev/null +++ b/.changeset/adr-0033-list-drafts.md @@ -0,0 +1,15 @@ +--- +"@objectstack/objectql": minor +"@objectstack/rest": patch +"@objectstack/runtime": patch +--- + +feat(metadata): expose pending DRAFT metadata (ADR-0033 draft discoverability) + +AI-authored metadata lands as drafts (`sys_metadata` rows with `state='draft'`, bound to an app package), but the only list path — `getMetaItems` — reads the active registry, so drafts were invisible: a just-built app package looked empty and there was no "pending changes" surface. + +- `SysMetadataRepository.listDrafts({type?, packageId?})` lists draft rows (mirrors `list()` but scoped to `state='draft'`, optionally narrowed by package), returning a light header projection (no body) with `packageId`. +- `protocol.listDrafts({packageId?, type?, organizationId?})` exposes it over the overlay repo. +- `GET /api/v1/meta/_drafts?packageId=&type=` surfaces it to the console. Registered in the REST server before the greedy `/meta/:type` route (and mirrored in the dispatcher) so `_drafts` is never captured as a metadata type name. + +Read-only; no behavior change to existing list/publish paths. Powers the upcoming Studio "drafts/pending changes" view and draft-aware package contents. diff --git a/.claude/launch.json b/.claude/launch.json index 900804cdf..10f15567f 100644 --- a/.claude/launch.json +++ b/.claude/launch.json @@ -21,6 +21,13 @@ "port": 3001, "autoPort": false }, + { + "name": "app-showcase (3000)", + "runtimeExecutable": "pnpm", + "runtimeArgs": ["--filter", "@objectstack/example-showcase", "dev"], + "port": 3000, + "autoPort": false + }, { "name": "docs (next.js)", "runtimeExecutable": "pnpm", diff --git a/packages/objectql/src/protocol.ts b/packages/objectql/src/protocol.ts index 9db19ab7c..4fa754312 100644 --- a/packages/objectql/src/protocol.ts +++ b/packages/objectql/src/protocol.ts @@ -3687,6 +3687,36 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol { } } + /** + * List pending DRAFT metadata (ADR-0033) for the org, optionally narrowed + * by `packageId` and/or `type`. The list reads of `getMetaItems` only see + * the ACTIVE registry; this exposes what an AI authored but a human hasn't + * published yet, so the console can show a "pending changes" surface and a + * just-built app package isn't displayed as empty. No body is returned. + */ + async listDrafts(request?: { + packageId?: string; + type?: string; + organizationId?: string; + }): Promise<{ + drafts: Array<{ + type: string; + name: string; + packageId: string | null; + updatedAt: string | null; + updatedBy: string | null; + }>; + }> { + await this.ensureOverlayIndex(); + const orgId = request?.organizationId ?? null; + const repo = this.getOverlayRepo(orgId); + const drafts = await repo.listDrafts({ + ...(request?.type ? { type: PLURAL_TO_SINGULAR[request.type] ?? request.type } : {}), + ...(request?.packageId ? { packageId: request.packageId } : {}), + }); + return { drafts }; + } + /** * Restore the body recorded at history `toVersion` as the new * live row. Writes a history event with `op='revert'`. 404 diff --git a/packages/objectql/src/sys-metadata-repository-list-drafts.test.ts b/packages/objectql/src/sys-metadata-repository-list-drafts.test.ts new file mode 100644 index 000000000..621885642 --- /dev/null +++ b/packages/objectql/src/sys-metadata-repository-list-drafts.test.ts @@ -0,0 +1,66 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect, vi } from 'vitest'; +import { SysMetadataRepository } from './sys-metadata-repository.js'; + +/** + * ADR-0033 — `listDrafts` surfaces pending DRAFT rows (what an AI authored but + * a human hasn't published). Unlike `list()` (hard-scoped to state='active'), + * it reads state='draft' and can narrow by packageId, so the console's + * "pending changes" view and a just-built app package aren't shown as empty. + */ +const ROWS = [ + { type: 'object', name: 'course', state: 'draft', package_id: 'app.edu', organization_id: null, updated_at: 't1', updated_by: 'ai' }, + { type: 'object', name: 'student', state: 'draft', package_id: 'app.edu', organization_id: null, updated_at: 't2', updated_by: 'ai' }, + { type: 'object', name: 'legacy', state: 'draft', package_id: null, organization_id: null, updated_at: 't3' }, + { type: 'view', name: 'course_list', state: 'draft', package_id: 'app.edu', organization_id: null, updated_at: 't5', updated_by: 'ai' }, + { type: 'object', name: 'live', state: 'active', package_id: 'app.edu', organization_id: null, updated_at: 't4' }, +]; + +function makeRepo(rows = ROWS) { + // Minimal engine whose find() does equality-only WHERE matching. + const find = vi.fn(async (_table: string, q: any) => { + const where = q?.where ?? {}; + return rows.filter((r) => Object.entries(where).every(([k, v]) => (r as any)[k] === v)); + }); + const engine = { find } as any; + const repo = new SysMetadataRepository({ engine, organizationId: null, orgLabel: 'env' }); + return { repo, find }; +} + +describe('SysMetadataRepository.listDrafts (ADR-0033)', () => { + it('returns only draft rows, projected with packageId (active rows excluded)', async () => { + const { repo } = makeRepo(); + const out = await repo.listDrafts(); + expect(out.map((d) => d.name).sort()).toEqual(['course', 'course_list', 'legacy', 'student']); + expect(out.find((d) => d.name === 'live')).toBeUndefined(); + expect(out.find((d) => d.name === 'course')).toMatchObject({ + type: 'object', + packageId: 'app.edu', + updatedAt: 't1', + updatedBy: 'ai', + }); + // legacy draft (no package) surfaces with packageId null + expect(out.find((d) => d.name === 'legacy')).toMatchObject({ packageId: null }); + }); + + it('filters by packageId', async () => { + const { repo } = makeRepo(); + const out = await repo.listDrafts({ packageId: 'app.edu' }); + expect(out.map((d) => d.name).sort()).toEqual(['course', 'course_list', 'student']); + }); + + it('filters by type', async () => { + const { repo } = makeRepo(); + const out = await repo.listDrafts({ type: 'view' }); + expect(out.map((d) => d.name)).toEqual(['course_list']); + }); + + it('queries state=draft scoped to org, threading type + packageId into WHERE', async () => { + const { repo, find } = makeRepo(); + await repo.listDrafts({ type: 'object', packageId: 'app.edu' }); + expect(find).toHaveBeenCalledWith('sys_metadata', { + where: { organization_id: null, state: 'draft', type: 'object', package_id: 'app.edu' }, + }); + }); +}); diff --git a/packages/objectql/src/sys-metadata-repository.ts b/packages/objectql/src/sys-metadata-repository.ts index 1dd77f06f..cd3ab6d6d 100644 --- a/packages/objectql/src/sys-metadata-repository.ts +++ b/packages/objectql/src/sys-metadata-repository.ts @@ -684,6 +684,42 @@ export class SysMetadataRepository implements MetadataRepository { } } + /** + * List pending DRAFT rows (ADR-0033) for this org, optionally narrowed by + * `type` and/or `packageId`. Unlike {@link list} (which is hard-scoped to + * `state='active'`), this reads `state='draft'` so the console can surface + * what an AI authored but a human hasn't published yet. Returns a light + * header projection (no body) suitable for a "pending changes" list. + */ + async listDrafts(filter?: { + type?: string; + packageId?: string; + }): Promise< + Array<{ + type: string; + name: string; + packageId: string | null; + updatedAt: string | null; + updatedBy: string | null; + }> + > { + this.assertOpen(); + const where: Record = { + organization_id: this.organizationId, + state: 'draft', + }; + if (filter?.type) where.type = filter.type; + if (filter?.packageId) where.package_id = filter.packageId; + const rows = await this.engine.find('sys_metadata', { where }); + return (rows as any[]).map((row) => ({ + type: row.type, + name: row.name, + packageId: row.package_id ?? null, + updatedAt: row.updated_at ?? row.created_at ?? null, + updatedBy: row.updated_by ?? row.created_by ?? null, + })); + } + /** * Yield every history event for `(org, type?, name?)` from the * durable log, ordered by per-(type,name) `version` ascending. When diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index 4c3bdac29..e530f2f4c 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -1524,6 +1524,47 @@ export class RestServer { }); } + // GET /meta/_drafts - Pending DRAFT items (ADR-0033) + // + // Surfaces draft-state metadata that the active-only `/meta/:type` + // list hides, so the console can show a "pending changes" view and + // draft-aware package contents (a just-built app package no longer + // looks empty). Optionally narrowed by `?packageId=` and/or `?type=`. + // + // Registered BEFORE `/meta/:type` so the `_drafts` segment is not + // captured as a `:type` parameter. + if (metadata.endpoints.items !== false) { + this.routeManager.register({ + method: 'GET', + path: `${metaPath}/_drafts`, + handler: async (req: any, res: any) => { + try { + const environmentId = isScoped ? req.params?.environmentId : undefined; + const p = await this.resolveProtocol(environmentId, req); + if (typeof (p as any).listDrafts !== 'function') { + res.status(501).json({ + error: 'not_implemented', + message: 'protocol.listDrafts() is not available in this kernel', + }); + return; + } + const result = await (p as any).listDrafts({ + packageId: (req.query?.packageId as string | undefined) || undefined, + type: (req.query?.type as string | undefined) || undefined, + }); + res.json(result); + } catch (error: any) { + logError("[REST] Unhandled error:", error); + sendError(res, error); + } + }, + metadata: { + summary: 'List pending draft metadata items', + tags: ['metadata'], + }, + }); + } + // GET /meta/:type - List items of a type if (metadata.endpoints.items !== false) { this.routeManager.register({ diff --git a/packages/rest/src/rest.test.ts b/packages/rest/src/rest.test.ts index 7e079545c..f68b6e342 100644 --- a/packages/rest/src/rest.test.ts +++ b/packages/rest/src/rest.test.ts @@ -1185,6 +1185,10 @@ describe('RestServer project-scoped routing', () => { const paths = rest.getRoutes().map(r => r.path); expect(paths).toContain('/api/v1/data/:object'); expect(paths.some(p => p.includes('/environments/:environmentId'))).toBe(false); + // ADR-0033 drafts list endpoint, registered BEFORE the greedy `/meta/:type` + // param route so `_drafts` isn't captured as a type name. + expect(paths).toContain('/api/v1/meta/_drafts'); + expect(paths.indexOf('/api/v1/meta/_drafts')).toBeLessThan(paths.indexOf('/api/v1/meta/:type')); }); it("registers both unscoped and scoped routes in 'auto' mode", () => { diff --git a/packages/runtime/src/http-dispatcher.test.ts b/packages/runtime/src/http-dispatcher.test.ts index 82ad7e876..1eeef9750 100644 --- a/packages/runtime/src/http-dispatcher.test.ts +++ b/packages/runtime/src/http-dispatcher.test.ts @@ -1002,6 +1002,58 @@ describe('HttpDispatcher', () => { }); }); + // ═══════════════════════════════════════════════════════════════ + // GET /metadata/_drafts — ADR-0033 pending-changes list + // ═══════════════════════════════════════════════════════════════ + + describe('GET /metadata/_drafts', () => { + it('routes to protocol.listDrafts with packageId + type and returns drafts', async () => { + const listDrafts = vi.fn().mockResolvedValue({ + drafts: [{ type: 'object', name: 'course', packageId: 'app.edu', updatedAt: 't1', updatedBy: 'ai' }], + }); + (kernel as any).getService = vi.fn().mockImplementation((name: string) => { + if (name === 'protocol') return Promise.resolve({ listDrafts }); + return null; + }); + + const result = await dispatcher.handleMetadata('_drafts', { request: {} }, 'GET', undefined, { + packageId: 'app.edu', + type: 'object', + }); + + expect(result.handled).toBe(true); + expect(result.response?.status).toBe(200); + expect(listDrafts).toHaveBeenCalledWith( + expect.objectContaining({ packageId: 'app.edu', type: 'object' }), + ); + expect((result.response as any)?.body?.data?.drafts?.[0]?.name).toBe('course'); + }); + + it('returns 501 when the protocol does not implement listDrafts', async () => { + (kernel as any).getService = vi.fn().mockImplementation((name: string) => { + if (name === 'protocol') return Promise.resolve({}); + return null; + }); + + const result = await dispatcher.handleMetadata('_drafts', { request: {} }, 'GET', undefined, {}); + expect(result.handled).toBe(true); + expect(result.response?.status).toBe(501); + }); + + it('is not mistaken for a metadata type (does not hit getMetaItems)', async () => { + const getMetaItems = vi.fn().mockResolvedValue({ items: [] }); + const listDrafts = vi.fn().mockResolvedValue({ drafts: [] }); + (kernel as any).getService = vi.fn().mockImplementation((name: string) => { + if (name === 'protocol') return Promise.resolve({ getMetaItems, listDrafts }); + return null; + }); + + await dispatcher.handleMetadata('_drafts', { request: {} }, 'GET', undefined, {}); + expect(listDrafts).toHaveBeenCalledTimes(1); + expect(getMetaItems).not.toHaveBeenCalled(); + }); + }); + // ═══════════════════════════════════════════════════════════════ // Metadata getPublished Endpoint // ═══════════════════════════════════════════════════════════════ diff --git a/packages/runtime/src/http-dispatcher.ts b/packages/runtime/src/http-dispatcher.ts index 1355afae2..27aafbed2 100644 --- a/packages/runtime/src/http-dispatcher.ts +++ b/packages/runtime/src/http-dispatcher.ts @@ -970,6 +970,29 @@ export class HttpDispatcher { } } + // GET /metadata/_drafts?packageId=&type= (ADR-0033 pending-changes list) + // Surfaces draft-state metadata the active-only `getMetaItems` list hides, + // so the console can show what an AI authored but nobody published yet. + // `_drafts` is intercepted before the generic `:type` handler below so it + // is never mistaken for a metadata type name. + if (parts.length === 1 && parts[0] === '_drafts' && (!method || method.toUpperCase() === 'GET')) { + const protocol = await this.resolveService('protocol'); + if (protocol && typeof protocol.listDrafts === 'function') { + try { + const organizationId = await this.resolveActiveOrganizationId(_context); + const data = await protocol.listDrafts({ + packageId: query?.packageId || undefined, + type: query?.type || undefined, + organizationId, + }); + return { handled: true, response: this.success(data) }; + } catch (e: any) { + return { handled: true, response: this.error(e.message, 500) }; + } + } + return { handled: true, response: this.error('Draft listing not supported', 501) }; + } + // GET /metadata/:type (List items of type) OR /metadata/:objectName (Legacy) if (parts.length === 1) { const typeOrName = parts[0];