Skip to content

Commit 9f124f1

Browse files
authored
feat: archive, recover and delete item versions (#13)
* feat: archive, recover and delete item versions Mirrors the new backend endpoints so SDK consumers (cloud components, CLI, scripts) can manage version lifecycle without crafting URLs. - listVersions(itemId, { archived }) - archiveVersion(itemId, versionTag) - recoverVersion(itemId, versionTag) - deleteVersion(itemId, versionTag) — requires the version to be archived first; backend rejects otherwise Both auth modes (accessToken query param and Bearer header) work through the existing requestApi helper. HTTP-contract tests added. * chore: add changeset for version lifecycle methods * fix: encodeURIComponent for path segments in version-management methods Addresses Sergio's review: archiveVersion, recoverVersion, deleteVersion and listVersions interpolated itemId / versionTag straight into the URL, so any tag with /, ?, # or whitespace would corrupt the path.
1 parent d4653c7 commit 9f124f1

3 files changed

Lines changed: 152 additions & 0 deletions

File tree

.changeset/twelve-wombats-peel.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'thatopen-services': minor
3+
---
4+
5+
Add per-version lifecycle methods so callers can list, archive, recover, and permanently delete a single version of an item.
6+
7+
- `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.
8+
- `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.
9+
- `recoverVersion(itemId, versionTag)``PUT /item/:itemId/version/:versionTag/recover`. Returns an archived version to the active list.
10+
- `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.
11+
12+
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).

src/core/client.test.ts

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -225,4 +225,84 @@ describe('EngineServicesClient — HTTP contract', () => {
225225
).rejects.toThrow(/403/);
226226
});
227227
});
228+
229+
describe('version archive / recover / delete', () => {
230+
it('listVersions GETs /item/:id/versions and forwards archived filter', async () => {
231+
fetchMock.mockResolvedValue(okResponse([]));
232+
const client = new EngineServicesClient(TOKEN, API);
233+
await client.listVersions('item-1', { archived: true });
234+
const { url, init } = getCall(fetchMock);
235+
const { pathname, params } = parseUrl(url);
236+
expect(init.method).toBe('GET');
237+
expect(pathname).toBe('/api/item/item-1/versions');
238+
expect(params.get('archived')).toBe('true');
239+
});
240+
241+
it('listVersions omits archived param when not provided', async () => {
242+
fetchMock.mockResolvedValue(okResponse([]));
243+
const client = new EngineServicesClient(TOKEN, API);
244+
await client.listVersions('item-1');
245+
const { params } = parseUrl(getCall(fetchMock).url);
246+
expect(params.get('archived')).toBeNull();
247+
});
248+
249+
it('archiveVersion PUTs /item/:id/version/:tag/archive', async () => {
250+
fetchMock.mockResolvedValue(okResponse({ tag: 'v2', archived: true }));
251+
const client = new EngineServicesClient(TOKEN, API);
252+
await client.archiveVersion('item-1', 'v2');
253+
const { url, init } = getCall(fetchMock);
254+
const { pathname } = parseUrl(url);
255+
expect(init.method).toBe('PUT');
256+
expect(pathname).toBe('/api/item/item-1/version/v2/archive');
257+
});
258+
259+
it('recoverVersion PUTs /item/:id/version/:tag/recover', async () => {
260+
fetchMock.mockResolvedValue(okResponse({ tag: 'v2', archived: false }));
261+
const client = new EngineServicesClient(TOKEN, API);
262+
await client.recoverVersion('item-1', 'v2');
263+
const { url, init } = getCall(fetchMock);
264+
const { pathname } = parseUrl(url);
265+
expect(init.method).toBe('PUT');
266+
expect(pathname).toBe('/api/item/item-1/version/v2/recover');
267+
});
268+
269+
it('deleteVersion DELETEs /item/:id/version/:tag', async () => {
270+
fetchMock.mockResolvedValue(okResponse({ success: true }));
271+
const client = new EngineServicesClient(TOKEN, API);
272+
await client.deleteVersion('item-1', 'v2');
273+
const { url, init } = getCall(fetchMock);
274+
const { pathname } = parseUrl(url);
275+
expect(init.method).toBe('DELETE');
276+
expect(pathname).toBe('/api/item/item-1/version/v2');
277+
});
278+
279+
it('archiveVersion in bearer mode uses Authorization header', async () => {
280+
fetchMock.mockResolvedValue(okResponse({ tag: 'v2', archived: true }));
281+
const client = new EngineServicesClient(TOKEN, API, { useBearer: true });
282+
await client.archiveVersion('item-1', 'v2');
283+
const { url, init } = getCall(fetchMock);
284+
const { params } = parseUrl(url);
285+
expect(params.get('accessToken')).toBeNull();
286+
expect((init.headers as Record<string, string>).Authorization).toBe(
287+
`Bearer ${TOKEN}`,
288+
);
289+
});
290+
291+
it('deleteVersion throws when the server responds with a non-2xx', async () => {
292+
fetchMock.mockResolvedValue(errorResponse(404, 'Not Found'));
293+
const client = new EngineServicesClient(TOKEN, API);
294+
await expect(client.deleteVersion('item-1', 'v2')).rejects.toThrow(/404/);
295+
});
296+
297+
it('encodes URL-unsafe characters in itemId and versionTag', async () => {
298+
fetchMock.mockResolvedValue(okResponse({ tag: 'v1?bug', archived: true }));
299+
const client = new EngineServicesClient(TOKEN, API);
300+
await client.archiveVersion('item/with slash', 'v1?bug');
301+
const { url } = getCall(fetchMock);
302+
const { pathname } = parseUrl(url);
303+
expect(pathname).toBe(
304+
'/api/item/item%2Fwith%20slash/version/v1%3Fbug/archive',
305+
);
306+
});
307+
});
228308
});

src/core/client.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1280,6 +1280,66 @@ export class EngineServicesClient {
12801280
);
12811281
}
12821282

1283+
/**
1284+
* Lists versions of an item. Pass `archived: true` to fetch only archived
1285+
* versions, `false` to fetch only active ones, or omit to receive both.
1286+
* @param itemId - The item's unique identifier.
1287+
* @param params - Optional `{ archived }` filter.
1288+
* @returns Array of versions, sorted by creation date descending.
1289+
*/
1290+
async listVersions(
1291+
itemId: string,
1292+
params: { archived?: boolean } = {},
1293+
): Promise<ItemVersion[]> {
1294+
return await this.#requestApi<ItemVersion[]>(
1295+
'GET',
1296+
`${ITEM_PATH}/${encodeURIComponent(itemId)}/versions`,
1297+
{ query: params },
1298+
);
1299+
}
1300+
1301+
/**
1302+
* Archives a version of an item. Archived versions remain available via
1303+
* `listVersions({ archived: true })` and can be recovered or permanently
1304+
* deleted. Cleanup runs daily and removes archived versions older than the
1305+
* platform retention period.
1306+
* @param itemId - The item's unique identifier.
1307+
* @param versionTag - The version's tag (e.g. "v2").
1308+
* @returns The archived version.
1309+
*/
1310+
async archiveVersion(itemId: string, versionTag: string) {
1311+
return await this.#requestApi<ItemVersion>(
1312+
'PUT',
1313+
`${ITEM_PATH}/${encodeURIComponent(itemId)}/version/${encodeURIComponent(versionTag)}/archive`,
1314+
);
1315+
}
1316+
1317+
/**
1318+
* Recovers a previously archived version, restoring it to the active list.
1319+
* @param itemId - The item's unique identifier.
1320+
* @param versionTag - The version's tag (e.g. "v2").
1321+
* @returns The recovered version.
1322+
*/
1323+
async recoverVersion(itemId: string, versionTag: string) {
1324+
return await this.#requestApi<ItemVersion>(
1325+
'PUT',
1326+
`${ITEM_PATH}/${encodeURIComponent(itemId)}/version/${encodeURIComponent(versionTag)}/recover`,
1327+
);
1328+
}
1329+
1330+
/**
1331+
* Permanently deletes a version, including its file in object storage.
1332+
* The version must be archived first; otherwise the call is rejected.
1333+
* @param itemId - The item's unique identifier.
1334+
* @param versionTag - The version's tag (e.g. "v2").
1335+
*/
1336+
async deleteVersion(itemId: string, versionTag: string) {
1337+
return await this.#requestApi<{ success: boolean }>(
1338+
'DELETE',
1339+
`${ITEM_PATH}/${encodeURIComponent(itemId)}/version/${encodeURIComponent(versionTag)}`,
1340+
);
1341+
}
1342+
12831343
// Project-scoped listings happen via the main list methods — e.g.
12841344
// `listFiles({ projectId })`, `listFolders({ projectId })`,
12851345
// `listApps({ projectId })`, `listComponents({ projectId })`. Those call

0 commit comments

Comments
 (0)