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
12 changes: 12 additions & 0 deletions .changeset/twelve-wombats-peel.md
Original file line number Diff line number Diff line change
@@ -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).
80 changes: 80 additions & 0 deletions src/core/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>).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',
);
});
});
});
60 changes: 60 additions & 0 deletions src/core/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ItemVersion[]> {
return await this.#requestApi<ItemVersion[]>(
'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<ItemVersion>(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think encodeURIComponent() is missing in archiveVersion, recoverVersion, and deleteVersion

'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<ItemVersion>(
'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
Expand Down
Loading