Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .changeset/adr-0033-list-drafts.md
Original file line number Diff line number Diff line change
@@ -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.
7 changes: 7 additions & 0 deletions .claude/launch.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
30 changes: 30 additions & 0 deletions packages/objectql/src/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
66 changes: 66 additions & 0 deletions packages/objectql/src/sys-metadata-repository-list-drafts.test.ts
Original file line number Diff line number Diff line change
@@ -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' },
});
});
});
36 changes: 36 additions & 0 deletions packages/objectql/src/sys-metadata-repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> = {
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
Expand Down
41 changes: 41 additions & 0 deletions packages/rest/src/rest-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
4 changes: 4 additions & 0 deletions packages/rest/src/rest.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
52 changes: 52 additions & 0 deletions packages/runtime/src/http-dispatcher.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
// ═══════════════════════════════════════════════════════════════
Expand Down
23 changes: 23 additions & 0 deletions packages/runtime/src/http-dispatcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand Down