Skip to content

Commit e295ad1

Browse files
os-zhuangclaude
andauthored
feat(client): the eleven package-lifecycle methods (#3563 PR-4) (#3577)
client.packages grows from install/enable to the full lifecycle the server has shipped across three ADR generations, every shape mirrored from domains/packages.ts: - update(id, patch) — PATCH /packages/:id (manifest: name / description / version; identity and lifecycle state not editable) - publish(id, opts?) — POST /:id/publish - publishDrafts(id, opts?) — POST /:id/publish-drafts (ADR-0033 whole-app promotion; seed drafts materialize rows via seedApplied) - discardDrafts(id, opts?) — POST /:id/discard-drafts - listCommits(id) — GET /:id/commits (ADR-0067, newest-first) - revertCommit(id, commitId) — POST /:id/commits/:commitId/revert - rollback(id, commitId) — POST /:id/rollback (commitId required — server 400s without it) - revert(id) — POST /:id/revert (to last published) - export(id) — GET /:id/export (ADR-0070 portable manifest, marketplace-install-local shape) - adoptOrphans(id, opts?) — POST /:id/adopt-orphans (ADR-0070 D5) - duplicate(id, targetPackageId, opts?) — POST /:id/duplicate (ADR-0070 D4) All eleven routes existed with no SDK expression — Studio reached them via raw fetch. Ledger flips all eleven rows to sdk; the gap ratchet drops 17 → 6 (from 27 at the start of the audit). Docs-site packages row now reflects the real method count. Verified: client 140 passed (11 new), ledger guard both halves green, eslint clean. Claude-Session: https://claude.ai/code/session_01LX9ut3MK3KykE11S9bJmv5 Co-authored-by: Claude <noreply@anthropic.com>
1 parent 8e5def1 commit e295ad1

6 files changed

Lines changed: 253 additions & 14 deletions

File tree

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
"@objectstack/client": minor
3+
"@objectstack/runtime": patch
4+
---
5+
6+
feat(client): the eleven package-lifecycle methods (#3563 PR-4)
7+
8+
`client.packages` grows from install/enable to the full lifecycle the server
9+
has shipped for three ADR generations: `update` (manifest edit),
10+
`publish`, `publishDrafts` / `discardDrafts` (ADR-0033 whole-app draft
11+
promotion), `listCommits` / `revertCommit` / `rollback` (ADR-0067 commit
12+
timeline), `revert`, `export`, `adoptOrphans`, `duplicate` (ADR-0070
13+
portability). All eleven routes existed with no SDK expression — Studio
14+
reached them via raw fetch.
15+
16+
The route ledger flips all eleven rows to `sdk` and the gap ratchet drops
17+
17 → 6 (from 27 at the start of the audit).

content/docs/api/client-sdk.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,7 @@ The `@objectstack/client` SDK aims to implement the ObjectStack API protocol spe
113113
| **data** || 10 | CRUD & query operations |
114114
| **auth** || 5 | Authentication & user management |
115115
| **permissions** || 3 | Access control checks |
116-
| **packages** || 6 | Plugin/package lifecycle management |
116+
| **packages** || 17 | Package lifecycle: install/enable, drafts (ADR-0033), commits & rollback (ADR-0067), export/duplicate (ADR-0070) |
117117
| **views** || 5 | UI view definitions |
118118
| **workflow** || 3 | Workflow state transitions |
119119
| **analytics** || 3 | Analytics queries |

packages/client/src/index.ts

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -651,6 +651,133 @@ export class ObjectStackClient {
651651
});
652652
return this.unwrapResponse<{ package: any; message?: string }>(res);
653653
},
654+
655+
/* [#3563 PR-4] Lifecycle beyond install/enable — these eleven routes
656+
* existed server-side (ADR-0033 drafts, ADR-0067 commits, ADR-0070
657+
* portability) with no SDK expression; Studio reached them via raw
658+
* fetch. Shapes mirror `domains/packages.ts` exactly. */
659+
660+
/**
661+
* Edit a package's manifest (partial: name / description / version).
662+
* Identity (`id` / `scope` / `type`) and lifecycle state are not editable
663+
* here; the server rejects an empty patch and non-semantic versions.
664+
*/
665+
update: async (id: string, patch: { name?: string; description?: string; version?: string }) => {
666+
const route = this.getRoute('packages');
667+
const res = await this.fetch(`${this.baseUrl}${route}/${encodeURIComponent(id)}`, {
668+
method: 'PATCH',
669+
body: JSON.stringify(patch),
670+
});
671+
return this.unwrapResponse<any>(res);
672+
},
673+
674+
/** Publish the package's metadata snapshot. */
675+
publish: async (id: string, opts?: Record<string, unknown>) => {
676+
const route = this.getRoute('packages');
677+
const res = await this.fetch(`${this.baseUrl}${route}/${encodeURIComponent(id)}/publish`, {
678+
method: 'POST',
679+
body: JSON.stringify(opts ?? {}),
680+
});
681+
return this.unwrapResponse<any>(res);
682+
},
683+
684+
/**
685+
* ADR-0033 "publish whole app": promote every pending draft bound to the
686+
* package to active in one shot. Published `seed` drafts also materialize
687+
* their rows (reported under `seedApplied`).
688+
*/
689+
publishDrafts: async (id: string, opts?: { actor?: string }) => {
690+
const route = this.getRoute('packages');
691+
const res = await this.fetch(`${this.baseUrl}${route}/${encodeURIComponent(id)}/publish-drafts`, {
692+
method: 'POST',
693+
body: JSON.stringify(opts ?? {}),
694+
});
695+
return this.unwrapResponse<any>(res);
696+
},
697+
698+
/** ADR-0033: drop every pending draft bound to the package. */
699+
discardDrafts: async (id: string, opts?: { actor?: string }) => {
700+
const route = this.getRoute('packages');
701+
const res = await this.fetch(`${this.baseUrl}${route}/${encodeURIComponent(id)}/discard-drafts`, {
702+
method: 'POST',
703+
body: JSON.stringify(opts ?? {}),
704+
});
705+
return this.unwrapResponse<any>(res);
706+
},
707+
708+
/** ADR-0067: the package's commit timeline (newest-first). */
709+
listCommits: async (id: string) => {
710+
const route = this.getRoute('packages');
711+
const res = await this.fetch(`${this.baseUrl}${route}/${encodeURIComponent(id)}/commits`);
712+
return this.unwrapResponse<{ commits: any[] }>(res);
713+
},
714+
715+
/** ADR-0067: revert ONE commit (the revert is itself a commit). */
716+
revertCommit: async (id: string, commitId: string, opts?: { actor?: string }) => {
717+
const route = this.getRoute('packages');
718+
const res = await this.fetch(
719+
`${this.baseUrl}${route}/${encodeURIComponent(id)}/commits/${encodeURIComponent(commitId)}/revert`,
720+
{ method: 'POST', body: JSON.stringify(opts ?? {}) },
721+
);
722+
return this.unwrapResponse<any>(res);
723+
},
724+
725+
/** ADR-0067: roll back through all commits newer than `commitId`. */
726+
rollback: async (id: string, commitId: string, opts?: { actor?: string }) => {
727+
const route = this.getRoute('packages');
728+
const res = await this.fetch(`${this.baseUrl}${route}/${encodeURIComponent(id)}/rollback`, {
729+
method: 'POST',
730+
body: JSON.stringify({ commitId, ...(opts ?? {}) }),
731+
});
732+
return this.unwrapResponse<any>(res);
733+
},
734+
735+
/** Revert the package to its last published state. */
736+
revert: async (id: string) => {
737+
const route = this.getRoute('packages');
738+
const res = await this.fetch(`${this.baseUrl}${route}/${encodeURIComponent(id)}/revert`, {
739+
method: 'POST',
740+
});
741+
return this.unwrapResponse<{ success: boolean }>(res);
742+
},
743+
744+
/**
745+
* ADR-0070: assemble the package's portable manifest (offline export) —
746+
* the same shape `marketplace-install-local` consumes.
747+
*/
748+
export: async (id: string) => {
749+
const route = this.getRoute('packages');
750+
const res = await this.fetch(`${this.baseUrl}${route}/${encodeURIComponent(id)}/export`);
751+
return this.unwrapResponse<any>(res);
752+
},
753+
754+
/** ADR-0070 D5: bulk-rebind package-less (orphaned) metadata into this base. */
755+
adoptOrphans: async (id: string, opts?: { actor?: string }) => {
756+
const route = this.getRoute('packages');
757+
const res = await this.fetch(`${this.baseUrl}${route}/${encodeURIComponent(id)}/adopt-orphans`, {
758+
method: 'POST',
759+
body: JSON.stringify(opts ?? {}),
760+
});
761+
return this.unwrapResponse<any>(res);
762+
},
763+
764+
/**
765+
* ADR-0070 D4: clone this base into a NEW writable package,
766+
* re-namespacing objects and rewriting references.
767+
* `targetPackageId` is required (the server 400s without it).
768+
*/
769+
duplicate: async (
770+
id: string,
771+
targetPackageId: string,
772+
opts?: { targetName?: string; targetNamespace?: string; actor?: string },
773+
) => {
774+
const route = this.getRoute('packages');
775+
const res = await this.fetch(`${this.baseUrl}${route}/${encodeURIComponent(id)}/duplicate`, {
776+
method: 'POST',
777+
body: JSON.stringify({ targetPackageId, ...(opts ?? {}) }),
778+
});
779+
return this.unwrapResponse<any>(res);
780+
},
654781
};
655782

656783
/**
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* `client.packages` lifecycle methods — #3563 PR-4 gap closures. The eleven
5+
* routes beyond install/enable (ADR-0033 drafts, ADR-0067 commits, ADR-0070
6+
* portability) existed server-side with no SDK expression; Studio reached
7+
* them via raw fetch.
8+
*/
9+
10+
import { describe, it, expect, vi } from 'vitest';
11+
import { ObjectStackClient } from './index';
12+
13+
function createMockClient(body: any, status = 200) {
14+
const fetchMock = vi.fn().mockResolvedValue({
15+
ok: status >= 200 && status < 300,
16+
status,
17+
statusText: status === 200 ? 'OK' : 'Error',
18+
json: async () => body,
19+
headers: new Headers()
20+
});
21+
const client = new ObjectStackClient({
22+
baseUrl: 'http://localhost:3000',
23+
fetch: fetchMock
24+
});
25+
return { client, fetchMock };
26+
}
27+
28+
const BASE = 'http://localhost:3000/api/v1/packages';
29+
30+
describe('client.packages lifecycle (#3563 PR-4)', () => {
31+
it('update PATCHes the manifest fields to /packages/:id', async () => {
32+
const { client, fetchMock } = createMockClient({ success: true, data: { id: 'com.acme.crm' } });
33+
await client.packages.update('com.acme.crm', { name: 'Acme CRM', version: '2.0.0' });
34+
const [url, init] = fetchMock.mock.calls[0];
35+
expect(String(url)).toBe(`${BASE}/com.acme.crm`);
36+
expect(init.method).toBe('PATCH');
37+
expect(JSON.parse(init.body)).toEqual({ name: 'Acme CRM', version: '2.0.0' });
38+
});
39+
40+
it.each([
41+
['publish', (c: ObjectStackClient) => c.packages.publish('p1'), 'POST', `${BASE}/p1/publish`, {}],
42+
['publishDrafts', (c: ObjectStackClient) => c.packages.publishDrafts('p1'), 'POST', `${BASE}/p1/publish-drafts`, {}],
43+
['discardDrafts', (c: ObjectStackClient) => c.packages.discardDrafts('p1', { actor: 'admin' }), 'POST', `${BASE}/p1/discard-drafts`, { actor: 'admin' }],
44+
['revert', (c: ObjectStackClient) => c.packages.revert('p1'), 'POST', `${BASE}/p1/revert`, undefined],
45+
['adoptOrphans', (c: ObjectStackClient) => c.packages.adoptOrphans('p1'), 'POST', `${BASE}/p1/adopt-orphans`, {}],
46+
] as const)('%s hits its lifecycle route', async (_name, call, method, url, expectedBody) => {
47+
const { client, fetchMock } = createMockClient({ success: true, data: { ok: true } });
48+
await call(client);
49+
const [gotUrl, init] = fetchMock.mock.calls[0];
50+
expect(String(gotUrl)).toBe(url);
51+
expect(init.method).toBe(method);
52+
if (expectedBody !== undefined) expect(JSON.parse(init.body)).toEqual(expectedBody);
53+
else expect(init.body).toBeUndefined();
54+
});
55+
56+
it('listCommits GETs the ADR-0067 timeline', async () => {
57+
const { client, fetchMock } = createMockClient({ success: true, data: { commits: [{ id: 'c1' }] } });
58+
const out = await client.packages.listCommits('p1');
59+
expect(String(fetchMock.mock.calls[0][0])).toBe(`${BASE}/p1/commits`);
60+
expect(out.commits).toHaveLength(1);
61+
});
62+
63+
it('revertCommit POSTs to the commit-scoped subroute, URL-encoding both ids', async () => {
64+
const { client, fetchMock } = createMockClient({ success: true, data: { ok: true } });
65+
await client.packages.revertCommit('p one', 'c/1');
66+
expect(String(fetchMock.mock.calls[0][0])).toBe(`${BASE}/p%20one/commits/c%2F1/revert`);
67+
expect(fetchMock.mock.calls[0][1].method).toBe('POST');
68+
});
69+
70+
it('rollback sends the required commitId in the body', async () => {
71+
const { client, fetchMock } = createMockClient({ success: true, data: { ok: true } });
72+
await client.packages.rollback('p1', 'c9', { actor: 'admin' });
73+
expect(String(fetchMock.mock.calls[0][0])).toBe(`${BASE}/p1/rollback`);
74+
expect(JSON.parse(fetchMock.mock.calls[0][1].body)).toEqual({ commitId: 'c9', actor: 'admin' });
75+
});
76+
77+
it('export GETs the portable manifest', async () => {
78+
const { client, fetchMock } = createMockClient({ success: true, data: { id: 'p1', objects: [] } });
79+
const manifest = await client.packages.export('p1');
80+
expect(String(fetchMock.mock.calls[0][0])).toBe(`${BASE}/p1/export`);
81+
expect(manifest.id).toBe('p1');
82+
});
83+
84+
it('duplicate requires targetPackageId and forwards the renaming options', async () => {
85+
const { client, fetchMock } = createMockClient({ success: true, data: { ok: true } });
86+
await client.packages.duplicate('p1', 'p2', { targetName: 'Copy', targetNamespace: 'copy' });
87+
expect(String(fetchMock.mock.calls[0][0])).toBe(`${BASE}/p1/duplicate`);
88+
expect(JSON.parse(fetchMock.mock.calls[0][1].body)).toEqual({
89+
targetPackageId: 'p2',
90+
targetName: 'Copy',
91+
targetNamespace: 'copy',
92+
});
93+
});
94+
});

packages/runtime/src/route-ledger.conformance.test.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -82,10 +82,11 @@ describe('route ledger hygiene', () => {
8282

8383
it('gap count only shrinks — update the ledger (and this number) when closing gaps', () => {
8484
// Ratchet, not aspiration: 27 audited gaps at #3563 PR-1; 24 after PR-2
85-
// (actions surface); 17 after PR-3 (keys / share-links / security).
85+
// (actions surface); 17 after PR-3 (keys / share-links / security);
86+
// 6 after PR-4 (packages lifecycle).
8687
// Closing a gap = reclassify to `sdk` AND lower this bound. Raising it
8788
// demands an explicit, reviewed decision.
8889
const gaps = ROUTE_LEDGER.filter((e) => e.disposition === 'gap').length;
89-
expect(gaps).toBeLessThanOrEqual(17);
90+
expect(gaps).toBeLessThanOrEqual(6);
9091
});
9192
});

packages/runtime/src/route-ledger.ts

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -128,17 +128,17 @@ export const ROUTE_LEDGER: readonly RouteLedgerEntry[] = [
128128
{ route: 'DELETE /packages/:id', domain: '/packages', disposition: 'sdk', client: 'packages.uninstall' },
129129
{ route: 'PATCH /packages/:id/enable', domain: '/packages', disposition: 'sdk', client: 'packages.enable' },
130130
{ route: 'PATCH /packages/:id/disable', domain: '/packages', disposition: 'sdk', client: 'packages.disable' },
131-
{ route: 'PATCH /packages/:id', domain: '/packages', disposition: 'gap', note: 'edit manifest — Studio-only via raw fetch today' },
132-
{ route: 'POST /packages/:id/publish', domain: '/packages', disposition: 'gap', note: 'ADR-0033 publish — no client expression' },
133-
{ route: 'POST /packages/:id/publish-drafts', domain: '/packages', disposition: 'gap', note: 'ADR-0033 — no client expression' },
134-
{ route: 'POST /packages/:id/discard-drafts', domain: '/packages', disposition: 'gap', note: 'ADR-0033 — no client expression' },
135-
{ route: 'GET /packages/:id/commits', domain: '/packages', disposition: 'gap', note: 'ADR-0067 timeline — no client expression' },
136-
{ route: 'POST /packages/:id/commits/:commitId/revert', domain: '/packages', disposition: 'gap', note: 'ADR-0067 — no client expression' },
137-
{ route: 'POST /packages/:id/rollback', domain: '/packages', disposition: 'gap', note: 'ADR-0067 — no client expression' },
138-
{ route: 'POST /packages/:id/revert', domain: '/packages', disposition: 'gap', note: 'revert to published — no client expression' },
139-
{ route: 'GET /packages/:id/export', domain: '/packages', disposition: 'gap', note: 'ADR-0070 export — no client expression' },
140-
{ route: 'POST /packages/:id/adopt-orphans', domain: '/packages', disposition: 'gap', note: 'ADR-0070 D5 — no client expression' },
141-
{ route: 'POST /packages/:id/duplicate', domain: '/packages', disposition: 'gap', note: 'ADR-0070 D4 — no client expression' },
131+
{ route: 'PATCH /packages/:id', domain: '/packages', disposition: 'sdk', client: 'packages.update' },
132+
{ route: 'POST /packages/:id/publish', domain: '/packages', disposition: 'sdk', client: 'packages.publish' },
133+
{ route: 'POST /packages/:id/publish-drafts', domain: '/packages', disposition: 'sdk', client: 'packages.publishDrafts' },
134+
{ route: 'POST /packages/:id/discard-drafts', domain: '/packages', disposition: 'sdk', client: 'packages.discardDrafts' },
135+
{ route: 'GET /packages/:id/commits', domain: '/packages', disposition: 'sdk', client: 'packages.listCommits' },
136+
{ route: 'POST /packages/:id/commits/:commitId/revert', domain: '/packages', disposition: 'sdk', client: 'packages.revertCommit' },
137+
{ route: 'POST /packages/:id/rollback', domain: '/packages', disposition: 'sdk', client: 'packages.rollback' },
138+
{ route: 'POST /packages/:id/revert', domain: '/packages', disposition: 'sdk', client: 'packages.revert' },
139+
{ route: 'GET /packages/:id/export', domain: '/packages', disposition: 'sdk', client: 'packages.export' },
140+
{ route: 'POST /packages/:id/adopt-orphans', domain: '/packages', disposition: 'sdk', client: 'packages.adoptOrphans' },
141+
{ route: 'POST /packages/:id/duplicate', domain: '/packages', disposition: 'sdk', client: 'packages.duplicate' },
142142

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

0 commit comments

Comments
 (0)