diff --git a/.changeset/twelve-wombats-peel.md b/.changeset/twelve-wombats-peel.md new file mode 100644 index 0000000..7cd282a --- /dev/null +++ b/.changeset/twelve-wombats-peel.md @@ -0,0 +1,12 @@ +--- +'thatopen-services': minor +--- + +Add per-version lifecycle methods so callers can list, archive, recover, and permanently delete a single version of an item. + +- `listVersions(itemId, { archived })` — `GET /item/:itemId/versions`. Pass `archived: true` to receive only archived versions, `false` for active only, or omit the option to receive both. Sorted by creation date descending. +- `archiveVersion(itemId, versionTag)` — `PUT /item/:itemId/version/:versionTag/archive`. Archived versions are hidden from the active list and queued for cleanup after the platform's retention window. +- `recoverVersion(itemId, versionTag)` — `PUT /item/:itemId/version/:versionTag/recover`. Returns an archived version to the active list. +- `deleteVersion(itemId, versionTag)` — `DELETE /item/:itemId/version/:versionTag`. The version must be archived first; the backend rejects the call otherwise. Removes the underlying object from S3 in addition to the database row. + +All four go through the existing request layer, so they work with both auth modes (`accessToken` query string for API tokens, `Authorization: Bearer …` for `PlatformClient` JWTs). diff --git a/src/core/client.test.ts b/src/core/client.test.ts index 74bf5a1..5d2109a 100644 --- a/src/core/client.test.ts +++ b/src/core/client.test.ts @@ -225,4 +225,84 @@ describe('EngineServicesClient — HTTP contract', () => { ).rejects.toThrow(/403/); }); }); + + describe('version archive / recover / delete', () => { + it('listVersions GETs /item/:id/versions and forwards archived filter', async () => { + fetchMock.mockResolvedValue(okResponse([])); + const client = new EngineServicesClient(TOKEN, API); + await client.listVersions('item-1', { archived: true }); + const { url, init } = getCall(fetchMock); + const { pathname, params } = parseUrl(url); + expect(init.method).toBe('GET'); + expect(pathname).toBe('/api/item/item-1/versions'); + expect(params.get('archived')).toBe('true'); + }); + + it('listVersions omits archived param when not provided', async () => { + fetchMock.mockResolvedValue(okResponse([])); + const client = new EngineServicesClient(TOKEN, API); + await client.listVersions('item-1'); + const { params } = parseUrl(getCall(fetchMock).url); + expect(params.get('archived')).toBeNull(); + }); + + it('archiveVersion PUTs /item/:id/version/:tag/archive', async () => { + fetchMock.mockResolvedValue(okResponse({ tag: 'v2', archived: true })); + const client = new EngineServicesClient(TOKEN, API); + await client.archiveVersion('item-1', 'v2'); + const { url, init } = getCall(fetchMock); + const { pathname } = parseUrl(url); + expect(init.method).toBe('PUT'); + expect(pathname).toBe('/api/item/item-1/version/v2/archive'); + }); + + it('recoverVersion PUTs /item/:id/version/:tag/recover', async () => { + fetchMock.mockResolvedValue(okResponse({ tag: 'v2', archived: false })); + const client = new EngineServicesClient(TOKEN, API); + await client.recoverVersion('item-1', 'v2'); + const { url, init } = getCall(fetchMock); + const { pathname } = parseUrl(url); + expect(init.method).toBe('PUT'); + expect(pathname).toBe('/api/item/item-1/version/v2/recover'); + }); + + it('deleteVersion DELETEs /item/:id/version/:tag', async () => { + fetchMock.mockResolvedValue(okResponse({ success: true })); + const client = new EngineServicesClient(TOKEN, API); + await client.deleteVersion('item-1', 'v2'); + const { url, init } = getCall(fetchMock); + const { pathname } = parseUrl(url); + expect(init.method).toBe('DELETE'); + expect(pathname).toBe('/api/item/item-1/version/v2'); + }); + + it('archiveVersion in bearer mode uses Authorization header', async () => { + fetchMock.mockResolvedValue(okResponse({ tag: 'v2', archived: true })); + const client = new EngineServicesClient(TOKEN, API, { useBearer: true }); + await client.archiveVersion('item-1', 'v2'); + const { url, init } = getCall(fetchMock); + const { params } = parseUrl(url); + expect(params.get('accessToken')).toBeNull(); + expect((init.headers as Record).Authorization).toBe( + `Bearer ${TOKEN}`, + ); + }); + + it('deleteVersion throws when the server responds with a non-2xx', async () => { + fetchMock.mockResolvedValue(errorResponse(404, 'Not Found')); + const client = new EngineServicesClient(TOKEN, API); + await expect(client.deleteVersion('item-1', 'v2')).rejects.toThrow(/404/); + }); + + it('encodes URL-unsafe characters in itemId and versionTag', async () => { + fetchMock.mockResolvedValue(okResponse({ tag: 'v1?bug', archived: true })); + const client = new EngineServicesClient(TOKEN, API); + await client.archiveVersion('item/with slash', 'v1?bug'); + const { url } = getCall(fetchMock); + const { pathname } = parseUrl(url); + expect(pathname).toBe( + '/api/item/item%2Fwith%20slash/version/v1%3Fbug/archive', + ); + }); + }); }); diff --git a/src/core/client.ts b/src/core/client.ts index 2056b43..f506136 100644 --- a/src/core/client.ts +++ b/src/core/client.ts @@ -1280,6 +1280,66 @@ export class EngineServicesClient { ); } + /** + * Lists versions of an item. Pass `archived: true` to fetch only archived + * versions, `false` to fetch only active ones, or omit to receive both. + * @param itemId - The item's unique identifier. + * @param params - Optional `{ archived }` filter. + * @returns Array of versions, sorted by creation date descending. + */ + async listVersions( + itemId: string, + params: { archived?: boolean } = {}, + ): Promise { + return await this.#requestApi( + 'GET', + `${ITEM_PATH}/${encodeURIComponent(itemId)}/versions`, + { query: params }, + ); + } + + /** + * Archives a version of an item. Archived versions remain available via + * `listVersions({ archived: true })` and can be recovered or permanently + * deleted. Cleanup runs daily and removes archived versions older than the + * platform retention period. + * @param itemId - The item's unique identifier. + * @param versionTag - The version's tag (e.g. "v2"). + * @returns The archived version. + */ + async archiveVersion(itemId: string, versionTag: string) { + return await this.#requestApi( + 'PUT', + `${ITEM_PATH}/${encodeURIComponent(itemId)}/version/${encodeURIComponent(versionTag)}/archive`, + ); + } + + /** + * Recovers a previously archived version, restoring it to the active list. + * @param itemId - The item's unique identifier. + * @param versionTag - The version's tag (e.g. "v2"). + * @returns The recovered version. + */ + async recoverVersion(itemId: string, versionTag: string) { + return await this.#requestApi( + 'PUT', + `${ITEM_PATH}/${encodeURIComponent(itemId)}/version/${encodeURIComponent(versionTag)}/recover`, + ); + } + + /** + * Permanently deletes a version, including its file in object storage. + * The version must be archived first; otherwise the call is rejected. + * @param itemId - The item's unique identifier. + * @param versionTag - The version's tag (e.g. "v2"). + */ + async deleteVersion(itemId: string, versionTag: string) { + return await this.#requestApi<{ success: boolean }>( + 'DELETE', + `${ITEM_PATH}/${encodeURIComponent(itemId)}/version/${encodeURIComponent(versionTag)}`, + ); + } + // Project-scoped listings happen via the main list methods — e.g. // `listFiles({ projectId })`, `listFolders({ projectId })`, // `listApps({ projectId })`, `listComponents({ projectId })`. Those call