From 86645c763af3face982078c1c7b8120ee672bee3 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Jul 2026 07:39:15 +0000 Subject: [PATCH] feat(client): the eleven package-lifecycle methods (#3563 PR-4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01LX9ut3MK3KykE11S9bJmv5 --- .changeset/client-packages-lifecycle.md | 17 +++ content/docs/api/client-sdk.mdx | 2 +- packages/client/src/index.ts | 127 ++++++++++++++++++ .../client/src/packages-lifecycle.test.ts | 94 +++++++++++++ .../src/route-ledger.conformance.test.ts | 5 +- packages/runtime/src/route-ledger.ts | 22 +-- 6 files changed, 253 insertions(+), 14 deletions(-) create mode 100644 .changeset/client-packages-lifecycle.md create mode 100644 packages/client/src/packages-lifecycle.test.ts diff --git a/.changeset/client-packages-lifecycle.md b/.changeset/client-packages-lifecycle.md new file mode 100644 index 0000000000..b1d194ecfb --- /dev/null +++ b/.changeset/client-packages-lifecycle.md @@ -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). diff --git a/content/docs/api/client-sdk.mdx b/content/docs/api/client-sdk.mdx index 7676d6a0de..aa8b947058 100644 --- a/content/docs/api/client-sdk.mdx +++ b/content/docs/api/client-sdk.mdx @@ -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 | diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 45fa65f0c5..66c9dba6cb 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -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(res); + }, + + /** Publish the package's metadata snapshot. */ + publish: async (id: string, opts?: Record) => { + 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(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(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(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(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(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(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(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(res); + }, }; /** diff --git a/packages/client/src/packages-lifecycle.test.ts b/packages/client/src/packages-lifecycle.test.ts new file mode 100644 index 0000000000..a5686b2ede --- /dev/null +++ b/packages/client/src/packages-lifecycle.test.ts @@ -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', + }); + }); +}); diff --git a/packages/runtime/src/route-ledger.conformance.test.ts b/packages/runtime/src/route-ledger.conformance.test.ts index 4bf67c5a47..a8e66ed7b4 100644 --- a/packages/runtime/src/route-ledger.conformance.test.ts +++ b/packages/runtime/src/route-ledger.conformance.test.ts @@ -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); }); }); diff --git a/packages/runtime/src/route-ledger.ts b/packages/runtime/src/route-ledger.ts index 04adad1d92..1675335cba 100644 --- a/packages/runtime/src/route-ledger.ts +++ b/packages/runtime/src/route-ledger.ts @@ -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',