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
22 changes: 22 additions & 0 deletions .changeset/adr-0033-publish-package-drafts.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
---
"@objectstack/objectql": minor
"@objectstack/runtime": patch
---

feat(metadata): publish a whole app's drafts in one shot (ADR-0033)

After an AI builds an app, its metadata is drafted (bound to an app package) and
had to be published one item at a time. The package-level `POST /packages/:id/publish`
needs the `metadata` service (503 when absent, e.g. the showcase) and reads the
in-memory registry, not the drafts.

- `protocol.publishPackageDrafts({ packageId })` promotes every `sys_metadata`
draft row bound to the package to active by reusing the per-item
`publishMetaItem` primitive (overridable/lock guards + runtime registry
refresh). Per-item failures are collected, not fatal. No `metadata`-service
dependency.
- `POST /api/v1/packages/:id/publish-drafts` exposes it (distinct from the
registry-based `/publish`), returning `{ success, publishedCount, failedCount, published, failed }`.

Verified live: an AI-built `app.asset_management` (4 drafts) published in one call —
all 4 promoted to active, drafts cleared, draft objects became queryable.
67 changes: 67 additions & 0 deletions packages/objectql/src/protocol-publish-package-drafts.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
// 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 — `publishPackageDrafts` promotes every pending draft bound to a
* package in one shot ("publish whole app"), reusing the per-item
* `publishMetaItem` primitive (no metadata-service dependency). These tests
* cover the orchestration: it publishes each listed draft, collects per-item
* failures without aborting, and reports an accurate success flag.
*/
function makeProtocol(drafts: Array<{ type: string; name: string }>) {
const protocol = new ObjectStackProtocolImplementation({} as never);
// Stub the bits that need a real engine/overlay so we can exercise the loop.
(protocol as any).ensureOverlayIndex = async () => {};
(protocol as any).getOverlayRepo = () => ({ listDrafts: async () => drafts });
const publishMetaItem = vi.spyOn(protocol, 'publishMetaItem' as never);
return { protocol, publishMetaItem };
}

describe('protocol.publishPackageDrafts (ADR-0033)', () => {
it('publishes every draft of the package and reports success', async () => {
const drafts = [
{ type: 'object', name: 'course' },
{ type: 'object', name: 'student' },
{ type: 'view', name: 'course_list' },
];
const { protocol, publishMetaItem } = makeProtocol(drafts);
publishMetaItem.mockResolvedValue({ success: true, version: 'h', seq: 1 } as never);

const res = await protocol.publishPackageDrafts({ packageId: 'app.edu' });

expect(publishMetaItem).toHaveBeenCalledTimes(3);
expect((publishMetaItem.mock.calls[0][0] as any)).toMatchObject({ type: 'object', name: 'course' });
expect(res).toMatchObject({ success: true, publishedCount: 3, failedCount: 0 });
expect(res.published.map((p) => p.name)).toEqual(['course', 'student', 'course_list']);
});

it('collects per-item failures without aborting the rest', async () => {
const { protocol, publishMetaItem } = makeProtocol([
{ type: 'object', name: 'course' },
{ type: 'object', name: 'student' },
{ type: 'view', name: 'course_list' },
]);
publishMetaItem.mockImplementation((async (req: any) => {
if (req.name === 'student') throw Object.assign(new Error('locked'), { code: 'locked' });
return { success: true, version: 'h', seq: 1 };
}) as never);

const res = await protocol.publishPackageDrafts({ packageId: 'app.edu' });

expect(res.publishedCount).toBe(2);
expect(res.failedCount).toBe(1);
expect(res.failed[0]).toMatchObject({ type: 'object', name: 'student', code: 'locked' });
expect(res.success).toBe(false); // any failure → not a clean success
});

it('returns publishedCount 0 / success false for an empty package', async () => {
const { protocol, publishMetaItem } = makeProtocol([]);

const res = await protocol.publishPackageDrafts({ packageId: 'app.empty' });

expect(publishMetaItem).not.toHaveBeenCalled();
expect(res).toMatchObject({ success: false, publishedCount: 0, failedCount: 0 });
});
});
57 changes: 57 additions & 0 deletions packages/objectql/src/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3717,6 +3717,63 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol {
return { drafts };
}

/**
* Publish every pending DRAFT bound to a package in one shot (ADR-0033) —
* the "publish whole app" action. Promotes each draft→active by reusing the
* per-item {@link publishMetaItem} primitive (which runs the overridable /
* lock guards and refreshes the runtime registry), so this needs NO
* `metadata` service (unlike `MetadataService.publishPackage`, which reads
* the in-memory registry and 503s when that service is absent). Per-item
* failures are collected and do NOT abort the rest.
*/
async publishPackageDrafts(request: {
packageId: string;
organizationId?: string;
actor?: string;
}): Promise<{
success: boolean;
publishedCount: number;
failedCount: number;
published: Array<{ type: string; name: string; version: 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 published: Array<{ type: string; name: string; version: string }> = [];
const failed: Array<{ type: string; name: string; error: string; code?: string }> = [];

for (const d of drafts) {
try {
const r = await this.publishMetaItem({
type: d.type,
name: d.name,
...(request.organizationId ? { organizationId: request.organizationId } : {}),
...(request.actor ? { actor: request.actor } : {}),
message: `publish app package '${request.packageId}'`,
});
published.push({ type: d.type, name: d.name, version: r.version });
} catch (e: any) {
failed.push({
type: d.type,
name: d.name,
error: e?.message ?? 'publish failed',
...(e?.code ? { code: e.code } : {}),
});
}
}

return {
success: failed.length === 0 && published.length > 0,
publishedCount: published.length,
failedCount: failed.length,
published,
failed,
};
}

/**
* Restore the body recorded at history `toVersion` as the new
* live row. Writes a history event with `op='revert'`. 404
Expand Down
12 changes: 12 additions & 0 deletions packages/runtime/src/dispatcher-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -614,6 +614,18 @@ export function createDispatcherPlugin(config: DispatcherPluginConfig = {}): Plu
}
});

// ADR-0033 — publish every pending draft bound to a package ("publish
// whole app"). Distinct from /publish (which needs the metadata
// service): this promotes sys_metadata draft rows via the protocol.
server.post(`${prefix}/packages/:id/publish-drafts`, async (req: any, res: any) => {
try {
const result = await dispatcher.handlePackages(`/${req.params.id}/publish-drafts`, 'POST', req.body, {}, { request: req });
sendResult(result, res);
} catch (err: any) {
errorResponse(err, res);
}
});

server.post(`${prefix}/packages/:id/revert`, async (req: any, res: any) => {
try {
const result = await dispatcher.handlePackages(`/${req.params.id}/revert`, 'POST', req.body, {}, { request: req });
Expand Down
30 changes: 30 additions & 0 deletions packages/runtime/src/http-dispatcher.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -952,6 +952,36 @@ describe('HttpDispatcher', () => {
expect(result.handled).toBe(true);
expect(result.response?.status).toBe(503);
});

it('POST /packages/:id/publish-drafts routes to protocol.publishPackageDrafts', async () => {
const publishPackageDrafts = vi.fn().mockResolvedValue({
success: true, publishedCount: 3, failedCount: 0, published: [], failed: [],
});
(kernel as any).getService = vi.fn().mockImplementation((name: string) => {
if (name === 'protocol') return Promise.resolve({ publishPackageDrafts });
if (name === 'objectql') return Promise.resolve({ registry: { getAllPackages: vi.fn().mockReturnValue([]) } });
return null;
});

const result = await dispatcher.handlePackages('/app.edu/publish-drafts', 'POST', {}, {}, { request: {} });

expect(result.handled).toBe(true);
expect(result.response?.status).toBe(200);
expect(publishPackageDrafts).toHaveBeenCalledWith(expect.objectContaining({ packageId: 'app.edu' }));
expect((result.response as any)?.body?.data?.publishedCount).toBe(3);
});

it('POST /packages/:id/publish-drafts returns 501 when protocol lacks the method', async () => {
(kernel as any).getService = vi.fn().mockImplementation((name: string) => {
if (name === 'protocol') return Promise.resolve({});
if (name === 'objectql') return Promise.resolve({ registry: { getAllPackages: vi.fn().mockReturnValue([]) } });
return null;
});

const result = await dispatcher.handlePackages('/app.edu/publish-drafts', 'POST', {}, {}, { request: {} });
expect(result.handled).toBe(true);
expect(result.response?.status).toBe(501);
});
});

// ═══════════════════════════════════════════════════════════════
Expand Down
24 changes: 24 additions & 0 deletions packages/runtime/src/http-dispatcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1463,6 +1463,30 @@ export class HttpDispatcher {
return { handled: true, response: this.error('Metadata service not available', 503) };
}

// POST /packages/:id/publish-drafts → promote every pending DRAFT
// bound to the package to active in one shot ("publish whole app",
// ADR-0033). Routes through protocol.publishPackageDrafts (which
// reuses the per-item publish primitive) — no metadata service
// dependency, unlike /publish above.
if (parts.length === 2 && parts[1] === 'publish-drafts' && m === 'POST') {
const id = decodeURIComponent(parts[0]);
const protocol = await this.resolveService('protocol');
if (protocol && typeof (protocol as any).publishPackageDrafts === 'function') {
try {
const organizationId = await this.resolveActiveOrganizationId(_context);
const result = await (protocol as any).publishPackageDrafts({
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 publishing 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]);
Expand Down