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
17 changes: 17 additions & 0 deletions .changeset/client-packages-lifecycle.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
"@objectstack/client": minor
"@objectstack/runtime": patch
---

feat(client): the eleven package-lifecycle methods (#3563 PR-4)

`client.packages` grows from install/enable to the full lifecycle the server
has shipped for three ADR generations: `update` (manifest edit),
`publish`, `publishDrafts` / `discardDrafts` (ADR-0033 whole-app draft
promotion), `listCommits` / `revertCommit` / `rollback` (ADR-0067 commit
timeline), `revert`, `export`, `adoptOrphans`, `duplicate` (ADR-0070
portability). All eleven routes existed with no SDK expression — Studio
reached them via raw fetch.

The route ledger flips all eleven rows to `sdk` and the gap ratchet drops
17 → 6 (from 27 at the start of the audit).
2 changes: 1 addition & 1 deletion content/docs/api/client-sdk.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ The `@objectstack/client` SDK aims to implement the ObjectStack API protocol spe
| **data** | ✅ | 10 | CRUD & query operations |
| **auth** | ✅ | 5 | Authentication & user management |
| **permissions** | ✅ | 3 | Access control checks |
| **packages** | ✅ | 6 | Plugin/package lifecycle management |
| **packages** | ✅ | 17 | Package lifecycle: install/enable, drafts (ADR-0033), commits & rollback (ADR-0067), export/duplicate (ADR-0070) |
| **views** | ✅ | 5 | UI view definitions |
| **workflow** | ✅ | 3 | Workflow state transitions |
| **analytics** | ✅ | 3 | Analytics queries |
Expand Down
127 changes: 127 additions & 0 deletions packages/client/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -651,6 +651,133 @@ export class ObjectStackClient {
});
return this.unwrapResponse<{ package: any; message?: string }>(res);
},

/* [#3563 PR-4] Lifecycle beyond install/enable — these eleven routes
* existed server-side (ADR-0033 drafts, ADR-0067 commits, ADR-0070
* portability) with no SDK expression; Studio reached them via raw
* fetch. Shapes mirror `domains/packages.ts` exactly. */

/**
* Edit a package's manifest (partial: name / description / version).
* Identity (`id` / `scope` / `type`) and lifecycle state are not editable
* here; the server rejects an empty patch and non-semantic versions.
*/
update: async (id: string, patch: { name?: string; description?: string; version?: string }) => {
const route = this.getRoute('packages');
const res = await this.fetch(`${this.baseUrl}${route}/${encodeURIComponent(id)}`, {
method: 'PATCH',
body: JSON.stringify(patch),
});
return this.unwrapResponse<any>(res);
},

/** Publish the package's metadata snapshot. */
publish: async (id: string, opts?: Record<string, unknown>) => {
const route = this.getRoute('packages');
const res = await this.fetch(`${this.baseUrl}${route}/${encodeURIComponent(id)}/publish`, {
method: 'POST',
body: JSON.stringify(opts ?? {}),
});
return this.unwrapResponse<any>(res);
},

/**
* ADR-0033 "publish whole app": promote every pending draft bound to the
* package to active in one shot. Published `seed` drafts also materialize
* their rows (reported under `seedApplied`).
*/
publishDrafts: async (id: string, opts?: { actor?: string }) => {
const route = this.getRoute('packages');
const res = await this.fetch(`${this.baseUrl}${route}/${encodeURIComponent(id)}/publish-drafts`, {
method: 'POST',
body: JSON.stringify(opts ?? {}),
});
return this.unwrapResponse<any>(res);
},

/** ADR-0033: drop every pending draft bound to the package. */
discardDrafts: async (id: string, opts?: { actor?: string }) => {
const route = this.getRoute('packages');
const res = await this.fetch(`${this.baseUrl}${route}/${encodeURIComponent(id)}/discard-drafts`, {
method: 'POST',
body: JSON.stringify(opts ?? {}),
});
return this.unwrapResponse<any>(res);
},

/** ADR-0067: the package's commit timeline (newest-first). */
listCommits: async (id: string) => {
const route = this.getRoute('packages');
const res = await this.fetch(`${this.baseUrl}${route}/${encodeURIComponent(id)}/commits`);
return this.unwrapResponse<{ commits: any[] }>(res);
},

/** ADR-0067: revert ONE commit (the revert is itself a commit). */
revertCommit: async (id: string, commitId: string, opts?: { actor?: string }) => {
const route = this.getRoute('packages');
const res = await this.fetch(
`${this.baseUrl}${route}/${encodeURIComponent(id)}/commits/${encodeURIComponent(commitId)}/revert`,
{ method: 'POST', body: JSON.stringify(opts ?? {}) },
);
return this.unwrapResponse<any>(res);
},

/** ADR-0067: roll back through all commits newer than `commitId`. */
rollback: async (id: string, commitId: string, opts?: { actor?: string }) => {
const route = this.getRoute('packages');
const res = await this.fetch(`${this.baseUrl}${route}/${encodeURIComponent(id)}/rollback`, {
method: 'POST',
body: JSON.stringify({ commitId, ...(opts ?? {}) }),
});
return this.unwrapResponse<any>(res);
},

/** Revert the package to its last published state. */
revert: async (id: string) => {
const route = this.getRoute('packages');
const res = await this.fetch(`${this.baseUrl}${route}/${encodeURIComponent(id)}/revert`, {
method: 'POST',
});
return this.unwrapResponse<{ success: boolean }>(res);
},

/**
* ADR-0070: assemble the package's portable manifest (offline export) —
* the same shape `marketplace-install-local` consumes.
*/
export: async (id: string) => {
const route = this.getRoute('packages');
const res = await this.fetch(`${this.baseUrl}${route}/${encodeURIComponent(id)}/export`);
return this.unwrapResponse<any>(res);
},

/** ADR-0070 D5: bulk-rebind package-less (orphaned) metadata into this base. */
adoptOrphans: async (id: string, opts?: { actor?: string }) => {
const route = this.getRoute('packages');
const res = await this.fetch(`${this.baseUrl}${route}/${encodeURIComponent(id)}/adopt-orphans`, {
method: 'POST',
body: JSON.stringify(opts ?? {}),
});
return this.unwrapResponse<any>(res);
},

/**
* ADR-0070 D4: clone this base into a NEW writable package,
* re-namespacing objects and rewriting references.
* `targetPackageId` is required (the server 400s without it).
*/
duplicate: async (
id: string,
targetPackageId: string,
opts?: { targetName?: string; targetNamespace?: string; actor?: string },
) => {
const route = this.getRoute('packages');
const res = await this.fetch(`${this.baseUrl}${route}/${encodeURIComponent(id)}/duplicate`, {
method: 'POST',
body: JSON.stringify({ targetPackageId, ...(opts ?? {}) }),
});
return this.unwrapResponse<any>(res);
},
};

/**
Expand Down
94 changes: 94 additions & 0 deletions packages/client/src/packages-lifecycle.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

/**
* `client.packages` lifecycle methods — #3563 PR-4 gap closures. The eleven
* routes beyond install/enable (ADR-0033 drafts, ADR-0067 commits, ADR-0070
* portability) existed server-side with no SDK expression; Studio reached
* them via raw fetch.
*/

import { describe, it, expect, vi } from 'vitest';
import { ObjectStackClient } from './index';

function createMockClient(body: any, status = 200) {
const fetchMock = vi.fn().mockResolvedValue({
ok: status >= 200 && status < 300,
status,
statusText: status === 200 ? 'OK' : 'Error',
json: async () => body,
headers: new Headers()
});
const client = new ObjectStackClient({
baseUrl: 'http://localhost:3000',
fetch: fetchMock
});
return { client, fetchMock };
}

const BASE = 'http://localhost:3000/api/v1/packages';

describe('client.packages lifecycle (#3563 PR-4)', () => {
it('update PATCHes the manifest fields to /packages/:id', async () => {
const { client, fetchMock } = createMockClient({ success: true, data: { id: 'com.acme.crm' } });
await client.packages.update('com.acme.crm', { name: 'Acme CRM', version: '2.0.0' });
const [url, init] = fetchMock.mock.calls[0];
expect(String(url)).toBe(`${BASE}/com.acme.crm`);
expect(init.method).toBe('PATCH');
expect(JSON.parse(init.body)).toEqual({ name: 'Acme CRM', version: '2.0.0' });
});

it.each([
['publish', (c: ObjectStackClient) => c.packages.publish('p1'), 'POST', `${BASE}/p1/publish`, {}],
['publishDrafts', (c: ObjectStackClient) => c.packages.publishDrafts('p1'), 'POST', `${BASE}/p1/publish-drafts`, {}],
['discardDrafts', (c: ObjectStackClient) => c.packages.discardDrafts('p1', { actor: 'admin' }), 'POST', `${BASE}/p1/discard-drafts`, { actor: 'admin' }],
['revert', (c: ObjectStackClient) => c.packages.revert('p1'), 'POST', `${BASE}/p1/revert`, undefined],
['adoptOrphans', (c: ObjectStackClient) => c.packages.adoptOrphans('p1'), 'POST', `${BASE}/p1/adopt-orphans`, {}],
] as const)('%s hits its lifecycle route', async (_name, call, method, url, expectedBody) => {
const { client, fetchMock } = createMockClient({ success: true, data: { ok: true } });
await call(client);
const [gotUrl, init] = fetchMock.mock.calls[0];
expect(String(gotUrl)).toBe(url);
expect(init.method).toBe(method);
if (expectedBody !== undefined) expect(JSON.parse(init.body)).toEqual(expectedBody);
else expect(init.body).toBeUndefined();
});

it('listCommits GETs the ADR-0067 timeline', async () => {
const { client, fetchMock } = createMockClient({ success: true, data: { commits: [{ id: 'c1' }] } });
const out = await client.packages.listCommits('p1');
expect(String(fetchMock.mock.calls[0][0])).toBe(`${BASE}/p1/commits`);
expect(out.commits).toHaveLength(1);
});

it('revertCommit POSTs to the commit-scoped subroute, URL-encoding both ids', async () => {
const { client, fetchMock } = createMockClient({ success: true, data: { ok: true } });
await client.packages.revertCommit('p one', 'c/1');
expect(String(fetchMock.mock.calls[0][0])).toBe(`${BASE}/p%20one/commits/c%2F1/revert`);
expect(fetchMock.mock.calls[0][1].method).toBe('POST');
});

it('rollback sends the required commitId in the body', async () => {
const { client, fetchMock } = createMockClient({ success: true, data: { ok: true } });
await client.packages.rollback('p1', 'c9', { actor: 'admin' });
expect(String(fetchMock.mock.calls[0][0])).toBe(`${BASE}/p1/rollback`);
expect(JSON.parse(fetchMock.mock.calls[0][1].body)).toEqual({ commitId: 'c9', actor: 'admin' });
});

it('export GETs the portable manifest', async () => {
const { client, fetchMock } = createMockClient({ success: true, data: { id: 'p1', objects: [] } });
const manifest = await client.packages.export('p1');
expect(String(fetchMock.mock.calls[0][0])).toBe(`${BASE}/p1/export`);
expect(manifest.id).toBe('p1');
});

it('duplicate requires targetPackageId and forwards the renaming options', async () => {
const { client, fetchMock } = createMockClient({ success: true, data: { ok: true } });
await client.packages.duplicate('p1', 'p2', { targetName: 'Copy', targetNamespace: 'copy' });
expect(String(fetchMock.mock.calls[0][0])).toBe(`${BASE}/p1/duplicate`);
expect(JSON.parse(fetchMock.mock.calls[0][1].body)).toEqual({
targetPackageId: 'p2',
targetName: 'Copy',
targetNamespace: 'copy',
});
});
});
5 changes: 3 additions & 2 deletions packages/runtime/src/route-ledger.conformance.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,10 +82,11 @@ describe('route ledger hygiene', () => {

it('gap count only shrinks — update the ledger (and this number) when closing gaps', () => {
// Ratchet, not aspiration: 27 audited gaps at #3563 PR-1; 24 after PR-2
// (actions surface); 17 after PR-3 (keys / share-links / security).
// (actions surface); 17 after PR-3 (keys / share-links / security);
// 6 after PR-4 (packages lifecycle).
// Closing a gap = reclassify to `sdk` AND lower this bound. Raising it
// demands an explicit, reviewed decision.
const gaps = ROUTE_LEDGER.filter((e) => e.disposition === 'gap').length;
expect(gaps).toBeLessThanOrEqual(17);
expect(gaps).toBeLessThanOrEqual(6);
});
});
22 changes: 11 additions & 11 deletions packages/runtime/src/route-ledger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,17 +128,17 @@ export const ROUTE_LEDGER: readonly RouteLedgerEntry[] = [
{ route: 'DELETE /packages/:id', domain: '/packages', disposition: 'sdk', client: 'packages.uninstall' },
{ route: 'PATCH /packages/:id/enable', domain: '/packages', disposition: 'sdk', client: 'packages.enable' },
{ route: 'PATCH /packages/:id/disable', domain: '/packages', disposition: 'sdk', client: 'packages.disable' },
{ route: 'PATCH /packages/:id', domain: '/packages', disposition: 'gap', note: 'edit manifest — Studio-only via raw fetch today' },
{ route: 'POST /packages/:id/publish', domain: '/packages', disposition: 'gap', note: 'ADR-0033 publish — no client expression' },
{ route: 'POST /packages/:id/publish-drafts', domain: '/packages', disposition: 'gap', note: 'ADR-0033 — no client expression' },
{ route: 'POST /packages/:id/discard-drafts', domain: '/packages', disposition: 'gap', note: 'ADR-0033 — no client expression' },
{ route: 'GET /packages/:id/commits', domain: '/packages', disposition: 'gap', note: 'ADR-0067 timeline — no client expression' },
{ route: 'POST /packages/:id/commits/:commitId/revert', domain: '/packages', disposition: 'gap', note: 'ADR-0067 — no client expression' },
{ route: 'POST /packages/:id/rollback', domain: '/packages', disposition: 'gap', note: 'ADR-0067 — no client expression' },
{ route: 'POST /packages/:id/revert', domain: '/packages', disposition: 'gap', note: 'revert to published — no client expression' },
{ route: 'GET /packages/:id/export', domain: '/packages', disposition: 'gap', note: 'ADR-0070 export — no client expression' },
{ route: 'POST /packages/:id/adopt-orphans', domain: '/packages', disposition: 'gap', note: 'ADR-0070 D5 — no client expression' },
{ route: 'POST /packages/:id/duplicate', domain: '/packages', disposition: 'gap', note: 'ADR-0070 D4 — no client expression' },
{ route: 'PATCH /packages/:id', domain: '/packages', disposition: 'sdk', client: 'packages.update' },
{ route: 'POST /packages/:id/publish', domain: '/packages', disposition: 'sdk', client: 'packages.publish' },
{ route: 'POST /packages/:id/publish-drafts', domain: '/packages', disposition: 'sdk', client: 'packages.publishDrafts' },
{ route: 'POST /packages/:id/discard-drafts', domain: '/packages', disposition: 'sdk', client: 'packages.discardDrafts' },
{ route: 'GET /packages/:id/commits', domain: '/packages', disposition: 'sdk', client: 'packages.listCommits' },
{ route: 'POST /packages/:id/commits/:commitId/revert', domain: '/packages', disposition: 'sdk', client: 'packages.revertCommit' },
{ route: 'POST /packages/:id/rollback', domain: '/packages', disposition: 'sdk', client: 'packages.rollback' },
{ route: 'POST /packages/:id/revert', domain: '/packages', disposition: 'sdk', client: 'packages.revert' },
{ route: 'GET /packages/:id/export', domain: '/packages', disposition: 'sdk', client: 'packages.export' },
{ route: 'POST /packages/:id/adopt-orphans', domain: '/packages', disposition: 'sdk', client: 'packages.adoptOrphans' },
{ route: 'POST /packages/:id/duplicate', domain: '/packages', disposition: 'sdk', client: 'packages.duplicate' },

// ── automation ────────────────────────────────────────────────────────────
{ route: 'POST /automation/trigger/:name', domain: '/automation', disposition: 'sdk', client: 'automation.trigger',
Expand Down