From 8d6078d9d36bb2410a114b720b0e21545183fd32 Mon Sep 17 00:00:00 2001 From: afonso pinto Date: Tue, 26 May 2026 23:48:06 +0100 Subject: [PATCH 1/8] feat: Update catmaid implementation to work with latest image --- src/datasource/catmaid/api.spec.ts | 252 +++++++++-------------------- src/datasource/catmaid/api.ts | 160 ++++++------------ 2 files changed, 128 insertions(+), 284 deletions(-) diff --git a/src/datasource/catmaid/api.spec.ts b/src/datasource/catmaid/api.spec.ts index 0fe0d511c..d8814c33a 100644 --- a/src/datasource/catmaid/api.spec.ts +++ b/src/datasource/catmaid/api.spec.ts @@ -300,81 +300,57 @@ describe("CatmaidClient skeleton editing methods", () => { ); }); - it("parses live compact-detail history rows and current label maps", async () => { + it("parses compact-detail rows with edition times and labels in one request", async () => { const client = new CatmaidClient("https://example.invalid", 1); - const fetchMock = vi - .fn() - .mockResolvedValueOnce([ + const fetchMock = vi.fn().mockResolvedValue([ + [ [ - [ - 22107946, - null, - 2, - 23697030.0, - 15055839.0, - 16651262.0, - 2000.0, - 5, - "2026-03-29T10:15:00Z", - "2026-03-29T10:15:00Z", - ], - [ - 22107946, - null, - 2, - 23697030.0, - 15055839.0, - 16651262.0, - 2000.0, - 5, - "2026-03-28T08:00:00Z", - "2026-03-29T10:15:00Z", - ], - [ - 22107955, - 22107954, - 2, - 23705874.0, - 15093672.0, - 16682375.0, - 2000.0, - 5, - "2026-03-29T10:16:00Z", - "2026-03-29T10:15:00Z", - ], - [ - 22107959, - 22107958, - 2, - 23704520.0, - 15085237.0, - 16708998.0, - 2000.0, - 5, - "2026-03-29T10:17:00Z", - "2026-03-29T10:16:00Z", - ], + 22107946, + null, + 2, + 23697030.0, + 15055839.0, + 16651262.0, + 2000.0, + 5, + "2026-03-29T10:15:00Z", ], - [], - {}, - [], - [], - ]) - .mockResolvedValueOnce([ - [], - [], - { - "afonso reviewed it": [22107946], - "test 123 4": [ - [22107955, "2026-03-29 10:16:00.000000+00:00"], - [22107955, "2026-03-29 10:15:30.000000+00:00"], - ], - "stale description": [[22107955, "2026-03-29 10:15:45.000000+00:00"]], - ends: [[22107959, "2026-03-29 10:17:00.000000+00:00"]], - }, - [], - [], - ]); + [ + 22107955, + 22107954, + 2, + 23705874.0, + 15093672.0, + 16682375.0, + 2000.0, + 5, + "2026-03-29T10:16:00Z", + ], + [ + 22107959, + 22107958, + 2, + 23704520.0, + 15085237.0, + 16708998.0, + 2000.0, + 5, + "2026-03-29T10:17:00Z", + ], + ], + [], + { + "afonso reviewed it": [22107946], + "test 123 4": [ + [22107955, "2026-03-29 10:16:00.000000+00:00"], + [22107955, "2026-03-29 10:15:30.000000+00:00"], + ], + "stale description": [[22107955, "2026-03-29 10:15:45.000000+00:00"]], + ends: [[22107959, "2026-03-29 10:17:00.000000+00:00"]], + }, + [], + [], + ]); (client as any).fetch = fetchMock; await expect(client.getSkeleton(2)).resolves.toEqual([ @@ -412,90 +388,16 @@ describe("CatmaidClient skeleton editing methods", () => { sourceState: testSourceState("2026-03-29T10:17:00Z"), }, ]); - expect(getFetchPath(fetchMock, 0)).toBe( - "skeletons/2/compact-detail?with_tags=true&with_history=true", - ); - expect(getFetchPath(fetchMock, 1)).toBe( - "skeletons/2/compact-detail?with_tags=true", + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(getFetchPath(fetchMock)).toBe( + "skeletons/2/compact-detail?with_tags=true&with_edition_times=true", ); }); - it("ignores historical compact-detail labels that are not current", async () => { - const client = new CatmaidClient("https://example.invalid", 1); - const fetchMock = vi - .fn() - .mockResolvedValueOnce([ - [ - [ - 23218380, - null, - 1, - 24233266, - 13917594, - 15605623, - 0, - 5, - "2026-05-06 20:17:31.181383+00:00", - "2026-04-20 14:56:29.593124+00:00", - 1, - ], - ], - [], - { - ends: [[23218380, "2026-04-22 15:11:58.824455+00:00"]], - }, - [], - [], - ]) - .mockResolvedValueOnce([[], [], {}, [], []]); - (client as any).fetch = fetchMock; - - await expect(client.getSkeleton(2974940)).resolves.toEqual([ - { - nodeId: 23218380, - parentNodeId: undefined, - position: new Float32Array([24233266, 13917594, 15605623]), - segmentId: 2974940, - radius: 0, - confidence: 100, - description: undefined, - isTrueEnd: false, - sourceState: testSourceState("2026-05-06 20:17:31.181383+00:00"), - }, - ]); - }); - - it("ignores zero-width history rows when compact-detail includes ordering", async () => { + it("rejects compact-detail rows missing edition times", async () => { const client = new CatmaidClient("https://example.invalid", 1); const fetchMock = vi.fn().mockResolvedValue([ - [ - [ - 11422971, - 11422970, - 2, - 24313028.0, - 14983333.0, - 6761820.5, - 2000.0, - 5, - "2026-04-14 08:56:49.985049+00:00", - "2026-04-14 08:56:49.985049+00:00", - 2, - ], - [ - 11422972, - 11422971, - 2, - 24318870.0, - 14984255.0, - 6765134.0, - 2000.0, - 5, - "2026-04-14 08:56:49.985049+00:00", - "2026-04-14 08:56:49.985049+00:00", - 2, - ], - ], + [[22107946, null, 2, 23697030.0, 15055839.0, 16651262.0, 2000.0, 5]], [], {}, [], @@ -503,7 +405,10 @@ describe("CatmaidClient skeleton editing methods", () => { ]); (client as any).fetch = fetchMock; - await expect(client.getSkeleton(1140285)).resolves.toEqual([]); + await expect(client.getSkeleton(2)).rejects.toThrow( + "CATMAID skeletons/compact-detail did not return node edition times. The server must support with_edition_times=true.", + ); + expect(fetchMock).toHaveBeenCalledTimes(1); }); it("merges skeletons using from/to treenode ids", async () => { @@ -766,13 +671,11 @@ describe("CatmaidClient skeleton editing methods", () => { it("reroots skeletons using treenode ids", async () => { const client = new CatmaidClient("https://example.invalid", 1); - const fetchMock = vi - .fn() - .mockResolvedValueOnce({ newroot: 202, skeleton_id: 17 }) - .mockResolvedValueOnce([ - [201, 200, 1, 2, 3, 5, 2000, 13, 1711711711.25, 9], - [202, 201, 4, 5, 6, 5, 2000, 13, 1711711712.5, 9], - ]); + const fetchMock = vi.fn().mockResolvedValue({ + newroot: 202, + skeleton_id: 17, + edition_time: "2026-03-29T12:08:00Z", + }); (client as any).fetch = fetchMock; await expect( @@ -798,18 +701,18 @@ describe("CatmaidClient skeleton editing methods", () => { ).resolves.toEqual({ nodeSourceStateUpdates: [ { - nodeId: 201, - sourceState: testSourceState("2024-03-29T11:28:31.250Z"), + nodeId: 202, + sourceState: testSourceState("2026-03-29T12:08:00Z"), }, { - nodeId: 202, - sourceState: testSourceState("2024-03-29T11:28:32.500Z"), + nodeId: 201, + sourceState: testSourceState("2026-03-29T12:08:00Z"), }, ], }); - expect(fetchMock).toHaveBeenCalledTimes(2); - const requestBody = getFetchBody(fetchMock, 0); + expect(fetchMock).toHaveBeenCalledTimes(1); + const requestBody = getFetchBody(fetchMock); expect(getFetchPath(fetchMock)).toBe("skeleton/reroot"); expect(requestBody.get("treenode_id")).toBe("202"); expect(requestBody.get("state")).toBe( @@ -823,10 +726,6 @@ describe("CatmaidClient skeleton editing methods", () => { links: [], }), ); - expect(getFetchPath(fetchMock, 1)).toBe("treenodes/compact-detail"); - expect(getFetchBody(fetchMock, 1).toString()).toBe( - "treenode_ids%5B0%5D=201&treenode_ids%5B1%5D=202", - ); }); it("rejects reroot state when the parent neighborhood is incomplete", async () => { @@ -890,14 +789,12 @@ describe("CatmaidClient skeleton editing methods", () => { ); }); - it("rejects reroot when the follow-up revision refresh is incomplete", async () => { + it("rejects reroot when the response is missing edition_time", async () => { const client = new CatmaidClient("https://example.invalid", 1); - const fetchMock = vi - .fn() - .mockResolvedValueOnce({ newroot: 202, skeleton_id: 17 }) - .mockResolvedValueOnce([ - [201, 200, 1, 2, 3, 5, 2000, 13, 1711711711.25, 9], - ]); + const fetchMock = vi.fn().mockResolvedValue({ + newroot: 202, + skeleton_id: 17, + }); (client as any).fetch = fetchMock; await expect( @@ -917,8 +814,9 @@ describe("CatmaidClient skeleton editing methods", () => { ], }), ).rejects.toThrow( - "CATMAID treenodes/compact-detail did not return revision metadata for node(s) 202.", + "CATMAID skeleton/reroot did not return the new root edition_time.", ); + expect(fetchMock).toHaveBeenCalledTimes(1); }); it("moves nodes using node revision state and returns the updated revision", async () => { diff --git a/src/datasource/catmaid/api.ts b/src/datasource/catmaid/api.ts index 0d5462815..5a9d17ebf 100644 --- a/src/datasource/catmaid/api.ts +++ b/src/datasource/catmaid/api.ts @@ -766,28 +766,6 @@ function getComparableCatmaidRevisionTime(value: unknown) { return Number.isFinite(parsedValue) ? parsedValue : undefined; } -function isCatmaidLiveHistoryRow(row: readonly unknown[]) { - const ordering = Number(row[10]); - if (Number.isFinite(ordering)) { - return Math.round(ordering) === 1; - } - if (row.length < 10) { - return true; - } - const lowerBound = getComparableCatmaidRevisionTime(row[8]); - const upperBound = getComparableCatmaidRevisionTime(row[9]); - if (lowerBound === undefined || upperBound === undefined) { - return true; - } - return upperBound <= lowerBound; -} - -function getCatmaidHistoryRevisionToken( - row: readonly unknown[], -): string | undefined { - return normalizeCatmaidRevisionToken(row[8]); -} - function parseCatmaidSkeletonRootTarget( response: any, ): SpatiallyIndexedSkeletonNavigationTarget { @@ -1041,26 +1019,16 @@ function getCatmaidSingleNodeRevisionResult( return sourceState === undefined ? {} : { sourceState }; } -function parseCatmaidNodeRevisionUpdates( - rows: unknown, -): CatmaidSkeletonNodeSourceStateUpdate[] { - if (!Array.isArray(rows)) { +function requireCatmaidCompactDetailNodeRevisionToken( + row: readonly unknown[], +): string { + const revisionToken = normalizeCatmaidRevisionToken(row[8]); + if (revisionToken === undefined) { throw new Error( - "CATMAID treenodes/compact-detail endpoint returned an unexpected response format.", + "CATMAID skeletons/compact-detail did not return node edition times. The server must support with_edition_times=true.", ); } - const revisionUpdates: CatmaidSkeletonNodeSourceStateUpdate[] = []; - for (const row of rows) { - if (!Array.isArray(row) || row.length < 9) continue; - const nodeId = Number(row[0]); - const revisionToken = normalizeCatmaidRevisionToken(row[8]); - if (!Number.isFinite(nodeId) || revisionToken === undefined) continue; - revisionUpdates.push({ - nodeId: Math.round(nodeId), - sourceState: { revisionToken }, - }); - } - return revisionUpdates; + return revisionToken; } function parseCatmaidMoveRevisionToken( @@ -1142,6 +1110,30 @@ function parseCatmaidDeleteRevisionUpdates( return parseCatmaidChildRevisionUpdates(response?.children); } +function makeCatmaidNodeRevisionUpdates( + nodes: readonly CatmaidEditParentContext[] | undefined, + revisionToken: string, +): readonly CatmaidSkeletonNodeSourceStateUpdate[] { + if (nodes === undefined) { + return []; + } + const sourceState = { revisionToken }; + const seen = new Set(); + const revisionUpdates: CatmaidSkeletonNodeSourceStateUpdate[] = []; + for (const node of nodes) { + const nodeId = Number(node.nodeId); + if (!Number.isFinite(nodeId)) continue; + const normalizedNodeId = Math.round(nodeId); + if (seen.has(normalizedNodeId)) continue; + seen.add(normalizedNodeId); + revisionUpdates.push({ + nodeId: normalizedNodeId, + sourceState, + }); + } + return revisionUpdates; +} + function fetchWithCatmaidCredentials( credentialsProvider: CredentialsProvider, input: string, @@ -1400,17 +1392,13 @@ export class CatmaidClient implements CatmaidSpatialSkeletonEditApi { ): Promise { const { signal } = options; let data: any; - let currentData: any; try { - [data, currentData] = await Promise.all([ - this.fetch( - `skeletons/${skeletonId}/compact-detail?with_tags=true&with_history=true`, - { signal }, - ), - this.fetch(`skeletons/${skeletonId}/compact-detail?with_tags=true`, { + data = await this.fetch( + `skeletons/${skeletonId}/compact-detail?with_tags=true&with_edition_times=true`, + { signal, - }), - ]); + }, + ); } catch (error) { if (error instanceof CatmaidNotFoundError) { return []; @@ -1419,25 +1407,13 @@ export class CatmaidClient implements CatmaidSpatialSkeletonEditApi { } } const rawNodes = Array.isArray(data?.[0]) ? data[0] : []; - const labelsByNodeId = parseCatmaidNodeLabels(currentData?.[2]); + const labelsByNodeId = parseCatmaidNodeLabels(data?.[2]); const descriptionByNodeId = getCatmaidNodeDescriptions(labelsByNodeId); const trueEndByNodeId = getCatmaidTrueEndNodes(labelsByNodeId); - const liveNodes = new Map(); - for (const node of rawNodes) { - if ( - !Array.isArray(node) || - node.length < 8 || - !isCatmaidLiveHistoryRow(node) - ) { - continue; - } - const nodeId = Number(node[0]); - if (!Number.isFinite(nodeId) || liveNodes.has(Math.round(nodeId))) { - continue; - } - liveNodes.set(Math.round(nodeId), node); - } - return [...liveNodes.values()].map((n) => ({ + const currentNodes = rawNodes.filter( + (node): node is any[] => Array.isArray(node) && node.length >= 8, + ); + return currentNodes.map((n) => ({ nodeId: n[0], parentNodeId: n[1] ?? undefined, position: new Float32Array([n[3], n[4], n[5]]), @@ -1449,7 +1425,7 @@ export class CatmaidClient implements CatmaidSpatialSkeletonEditApi { description: descriptionByNodeId.get(Number(n[0])), isTrueEnd: trueEndByNodeId.has(Number(n[0])), sourceState: makeCatmaidNodeSourceState( - getCatmaidHistoryRevisionToken(n), + requireCatmaidCompactDetailNodeRevisionToken(n), ), })); } @@ -1597,54 +1573,24 @@ export class CatmaidClient implements CatmaidSpatialSkeletonEditApi { expectedNodeId: nodeId, }), ); - await this.fetch(`skeleton/reroot`, { - method: "POST", - body, - }); - const rerootedNodeIds = - editContext?.nodes - ?.map((value) => Number(value.nodeId)) - .filter((value) => Number.isFinite(value)) - .map((value) => Math.round(value)) ?? []; - return { - nodeSourceStateUpdates: - await this.fetchNodeRevisionUpdates(rerootedNodeIds), - }; - } - - private async fetchNodeRevisionUpdates( - nodeIds: readonly number[], - ): Promise { - const normalizedNodeIds = [ - ...new Set( - nodeIds - .map((value) => Number(value)) - .filter((value) => Number.isFinite(value)) - .map((value) => Math.round(value)), - ), - ].sort((a, b) => a - b); - if (normalizedNodeIds.length === 0) { - return []; - } - const body = new URLSearchParams(); - appendScalarList(body, "treenode_ids", normalizedNodeIds); - const response = await this.fetch(`treenodes/compact-detail`, { + const response = await this.fetch(`skeleton/reroot`, { method: "POST", body, }); - const revisionUpdates = parseCatmaidNodeRevisionUpdates(response); - const returnedNodeIds = new Set( - revisionUpdates.map((update) => update.nodeId), - ); - const missingNodeIds = normalizedNodeIds.filter( - (nodeId) => !returnedNodeIds.has(nodeId), + const revisionToken = normalizeCatmaidRevisionToken( + response?.edition_time, ); - if (missingNodeIds.length > 0) { + if (revisionToken === undefined) { throw new Error( - `CATMAID treenodes/compact-detail did not return revision metadata for node(s) ${missingNodeIds.join(", ")}.`, + "CATMAID skeleton/reroot did not return the new root edition_time.", ); } - return revisionUpdates; + return { + nodeSourceStateUpdates: makeCatmaidNodeRevisionUpdates( + editContext?.nodes, + revisionToken, + ), + }; } async deleteNode( From 0609ebd47331c05110b0d0f6ba1030a2737d176a Mon Sep 17 00:00:00 2001 From: afonso pinto Date: Wed, 27 May 2026 00:16:57 +0100 Subject: [PATCH 2/8] docs: Update skeleton editing user guide --- docs/user-guide/skeleton_editing.rst | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/user-guide/skeleton_editing.rst b/docs/user-guide/skeleton_editing.rst index 328630109..a74ca1dc5 100644 --- a/docs/user-guide/skeleton_editing.rst +++ b/docs/user-guide/skeleton_editing.rst @@ -12,6 +12,9 @@ Supported Sources Skeleton editing is currently only supported on CATMAID data sources. See the CATMAID documentation to set up a CATMAID server. At minimum you will need: +- CATMAID commit ``ed261715a0c4b2b1698dfd9a0e2d6f4233533f19`` or newer. This + version adds the edition-time metadata that Neuroglancer uses to validate + edits against the current CATMAID skeleton state. - A CATMAID project - A linked project stack - ``AnonymousUser`` permissions to read and edit the data on that project From db16b6e8bf33f8eedaf7b82d46bb7e9097a2f0c3 Mon Sep 17 00:00:00 2001 From: afonso pinto Date: Wed, 27 May 2026 15:28:05 +0100 Subject: [PATCH 3/8] feat: Reject old catmaid instances --- src/datasource/catmaid/api.spec.ts | 82 ++++++++++++------- src/datasource/catmaid/api.ts | 123 +++++++++++++++++++++++------ src/datasource/catmaid/frontend.ts | 6 ++ 3 files changed, 157 insertions(+), 54 deletions(-) diff --git a/src/datasource/catmaid/api.spec.ts b/src/datasource/catmaid/api.spec.ts index d8814c33a..fadd4eea5 100644 --- a/src/datasource/catmaid/api.spec.ts +++ b/src/datasource/catmaid/api.spec.ts @@ -67,6 +67,30 @@ function getFetchInit(fetchMock: FetchMock, callIndex = 0) { } describe("CatmaidClient skeleton editing methods", () => { + it("accepts supported CATMAID server version dates", async () => { + const client = new CatmaidClient("https://example.invalid", 1); + const fetchServerEndpointMock = vi + .fn() + .mockResolvedValue({ SERVER_VERSION: "2026.05.21.dev1+gabcdef0" }); + (client as any).fetchServerEndpoint = fetchServerEndpointMock; + + await expect(client.validateServerVersion()).resolves.toBeUndefined(); + expect(fetchServerEndpointMock).toHaveBeenCalledWith("version"); + }); + + it("rejects unsupported CATMAID server version dates", async () => { + const client = new CatmaidClient("https://example.invalid", 1); + const fetchServerEndpointMock = vi + .fn() + .mockResolvedValue({ SERVER_VERSION: "2026.05.20.dev12+gabcdef0" }); + (client as any).fetchServerEndpoint = fetchServerEndpointMock; + + await expect(client.validateServerVersion()).rejects.toThrow( + "CATMAID server https://example.invalid version 2026.05.20.dev12+gabcdef0 is not supported. Version 2026.05.21 or newer is required.", + ); + expect(fetchServerEndpointMock).toHaveBeenCalledWith("version"); + }); + it("does not cache transient metadata discovery failures as null", async () => { const client = new CatmaidClient("https://example.invalid", 1); const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); @@ -351,7 +375,7 @@ describe("CatmaidClient skeleton editing methods", () => { [], [], ]); - (client as any).fetch = fetchMock; + (client as any).fetchProjectEndpoint = fetchMock; await expect(client.getSkeleton(2)).resolves.toEqual([ { @@ -396,14 +420,16 @@ describe("CatmaidClient skeleton editing methods", () => { it("rejects compact-detail rows missing edition times", async () => { const client = new CatmaidClient("https://example.invalid", 1); - const fetchMock = vi.fn().mockResolvedValue([ - [[22107946, null, 2, 23697030.0, 15055839.0, 16651262.0, 2000.0, 5]], - [], - {}, - [], - [], - ]); - (client as any).fetch = fetchMock; + const fetchMock = vi + .fn() + .mockResolvedValue([ + [[22107946, null, 2, 23697030.0, 15055839.0, 16651262.0, 2000.0, 5]], + [], + {}, + [], + [], + ]); + (client as any).fetchProjectEndpoint = fetchMock; await expect(client.getSkeleton(2)).rejects.toThrow( "CATMAID skeletons/compact-detail did not return node edition times. The server must support with_edition_times=true.", @@ -418,7 +444,7 @@ describe("CatmaidClient skeleton editing methods", () => { deleted_skeleton_id: 21, stable_annotation_swap: false, }); - (client as any).fetch = fetchMock; + (client as any).fetchProjectEndpoint = fetchMock; await expect( client.mergeSkeletons(101, 202, { @@ -459,7 +485,7 @@ describe("CatmaidClient skeleton editing methods", () => { [], [], ]); - (client as any).fetch = fetchMock; + (client as any).fetchProjectEndpoint = fetchMock; await expect( client.fetchNodes({ @@ -493,7 +519,7 @@ describe("CatmaidClient skeleton editing methods", () => { it("passes the CATMAID source-associated lod to node/list", async () => { const client = new CatmaidClient("https://example.invalid", 1); const fetchMock = vi.fn().mockResolvedValue([[], [], {}, false, [], []]); - (client as any).fetch = fetchMock; + (client as any).fetchProjectEndpoint = fetchMock; await client.fetchNodes( { @@ -520,7 +546,7 @@ describe("CatmaidClient skeleton editing methods", () => { it("rejects CATMAID node-list bounds with fewer than three coordinates", async () => { const client = new CatmaidClient("https://example.invalid", 1); const fetchMock = vi.fn(); - (client as any).fetch = fetchMock; + (client as any).fetchProjectEndpoint = fetchMock; await expect( client.fetchNodes({ @@ -539,7 +565,7 @@ describe("CatmaidClient skeleton editing methods", () => { y: 2, z: 3, }); - (client as any).fetch = fetchMock; + (client as any).fetchProjectEndpoint = fetchMock; await expect(client.getSkeletonRootNode(17)).resolves.toEqual({ nodeId: 303, @@ -552,7 +578,7 @@ describe("CatmaidClient skeleton editing methods", () => { it("rejects merge state when the provided node ids do not match the request", async () => { const client = new CatmaidClient("https://example.invalid", 1); const fetchMock = vi.fn(); - (client as any).fetch = fetchMock; + (client as any).fetchProjectEndpoint = fetchMock; await expect( client.mergeSkeletons(101, 202, { @@ -573,7 +599,7 @@ describe("CatmaidClient skeleton editing methods", () => { edition_time: "2026-03-29T12:00:00Z", parent_edition_time: "2026-03-29T12:00:01Z", }); - (client as any).fetch = fetchMock; + (client as any).fetchProjectEndpoint = fetchMock; await expect( client.addNode(13, 1, 2, 3, 7, { @@ -601,7 +627,7 @@ describe("CatmaidClient skeleton editing methods", () => { skeleton_id: 13, edition_time: "2026-03-29T12:00:00Z", }); - (client as any).fetch = fetchMock; + (client as any).fetchProjectEndpoint = fetchMock; await expect(client.addNode(13, 1, 2, 3)).resolves.toEqual({ nodeId: 88, @@ -627,7 +653,7 @@ describe("CatmaidClient skeleton editing methods", () => { [12, "2026-03-29T12:01:03Z"], ], }); - (client as any).fetch = fetchMock; + (client as any).fetchProjectEndpoint = fetchMock; await expect( client.insertNode(13, 1, 2, 3, 7, [11, 12], { @@ -676,7 +702,7 @@ describe("CatmaidClient skeleton editing methods", () => { skeleton_id: 17, edition_time: "2026-03-29T12:08:00Z", }); - (client as any).fetch = fetchMock; + (client as any).fetchProjectEndpoint = fetchMock; await expect( client.rerootSkeleton(202, { @@ -731,7 +757,7 @@ describe("CatmaidClient skeleton editing methods", () => { it("rejects reroot state when the parent neighborhood is incomplete", async () => { const client = new CatmaidClient("https://example.invalid", 1); const fetchMock = vi.fn(); - (client as any).fetch = fetchMock; + (client as any).fetchProjectEndpoint = fetchMock; await expect( client.rerootSkeleton(202, { @@ -755,7 +781,7 @@ describe("CatmaidClient skeleton editing methods", () => { existing_skeleton_id: 17, new_skeleton_id: 21, }); - (client as any).fetch = fetchMock; + (client as any).fetchProjectEndpoint = fetchMock; await expect( client.splitSkeleton(202, { @@ -795,7 +821,7 @@ describe("CatmaidClient skeleton editing methods", () => { newroot: 202, skeleton_id: 17, }); - (client as any).fetch = fetchMock; + (client as any).fetchProjectEndpoint = fetchMock; await expect( client.rerootSkeleton(202, { @@ -826,7 +852,7 @@ describe("CatmaidClient skeleton editing methods", () => { old_treenodes: [[42, "2026-03-29T12:10:00Z", 1, 2, 3]], old_connectors: [], }); - (client as any).fetch = fetchMock; + (client as any).fetchProjectEndpoint = fetchMock; await expect( client.moveNode(42, 10, 11, 12, { @@ -853,7 +879,7 @@ describe("CatmaidClient skeleton editing methods", () => { [13, "2026-03-29T12:20:01Z"], ], }); - (client as any).fetch = fetchMock; + (client as any).fetchProjectEndpoint = fetchMock; await expect( client.deleteNode(11, { @@ -899,7 +925,7 @@ describe("CatmaidClient skeleton editing methods", () => { const fetchMock = vi .fn() .mockResolvedValue({ edition_time: "2026-03-29T13:00:00Z" }); - (client as any).fetch = fetchMock; + (client as any).fetchProjectEndpoint = fetchMock; await expect( client.updateDescription(11, "updated description"), @@ -919,7 +945,7 @@ describe("CatmaidClient skeleton editing methods", () => { const fetchMock = vi .fn() .mockResolvedValue({ edition_time: "2026-03-29T13:05:00Z" }); - (client as any).fetch = fetchMock; + (client as any).fetchProjectEndpoint = fetchMock; await expect( client.updateDescription(11, "updated description\nends", { @@ -941,7 +967,7 @@ describe("CatmaidClient skeleton editing methods", () => { .fn() .mockResolvedValueOnce({ edition_time: "2026-03-29T13:10:00Z" }) .mockResolvedValueOnce({ edition_time: "2026-03-29T13:11:00Z" }); - (client as any).fetch = fetchMock; + (client as any).fetchProjectEndpoint = fetchMock; await expect(client.toggleTrueEnd(11, true)).resolves.toEqual({ sourceState: testSourceState("2026-03-29T13:10:00Z"), @@ -964,7 +990,7 @@ describe("CatmaidClient skeleton editing methods", () => { const fetchMock = vi.fn().mockResolvedValue({ updated_partners: { "11": { edition_time: "2026-03-29T13:20:00Z" } }, }); - (client as any).fetch = fetchMock; + (client as any).fetchProjectEndpoint = fetchMock; await expect( client.updateConfidence(11, 75, { diff --git a/src/datasource/catmaid/api.ts b/src/datasource/catmaid/api.ts index 5a9d17ebf..98de0993c 100644 --- a/src/datasource/catmaid/api.ts +++ b/src/datasource/catmaid/api.ts @@ -57,6 +57,7 @@ export const credentialsKey = "CATMAID"; const CATMAID_NO_MATCHING_NODE_PROVIDER_ERROR = "Could not find matching node provider for request"; const CATMAID_STATE_MATCHING_ERROR_TYPE = "StateMatchingError"; +const CATMAID_MIN_SUPPORTED_VERSION_DATE = "2026.05.21"; type CatmaidStatePayload = object; type CatmaidFetchPriority = "high" | "low" | "auto"; @@ -1110,6 +1111,31 @@ function parseCatmaidDeleteRevisionUpdates( return parseCatmaidChildRevisionUpdates(response?.children); } +function parseCatmaidServerVersionFromResponse( + response: unknown, +): string | undefined { + if (response === null || typeof response !== "object") { + return undefined; + } + const version = (response as Record).SERVER_VERSION; + return typeof version === "string" && version.trim().length > 0 + ? version.trim() + : undefined; +} + +function parseCatmaidVersionDate(version: string | undefined) { + const match = version?.match(/^(\d{4})\.(\d{2})\.(\d{2})/); + return match == null ? undefined : `${match[1]}.${match[2]}.${match[3]}`; +} + +function isCatmaidServerVersionSupported(version: string | undefined) { + const versionDate = parseCatmaidVersionDate(version); + return ( + versionDate !== undefined && + versionDate.localeCompare(CATMAID_MIN_SUPPORTED_VERSION_DATE) >= 0 + ); +} + function makeCatmaidNodeRevisionUpdates( nodes: readonly CatmaidEditParentContext[] | undefined, revisionToken: string, @@ -1192,7 +1218,7 @@ export class CatmaidClient implements CatmaidSpatialSkeletonEditApi { return error; } - private async fetch( + private async fetchProjectEndpoint( endpoint: string, options: CatmaidRequestInit = {}, expectMsgpack: boolean = false, @@ -1245,6 +1271,31 @@ export class CatmaidClient implements CatmaidSpatialSkeletonEditApi { return response.json(); } + private async fetchServerEndpoint(endpoint: string): Promise { + const baseUrl = this.baseUrl.replace(/\/$/, ""); + const url = `${baseUrl}/${endpoint}`; + + let response: Response; + try { + if (this.credentialsProvider) { + response = await fetchWithCatmaidCredentials( + this.credentialsProvider, + url, + {}, + ); + } else { + response = await fetch(url); + if (!response.ok) { + throw HttpError.fromResponse(response); + } + } + } catch (error) { + throw await this.normalizeFetchError(error); + } + + return response.json(); + } + private async isNoMatchingNodeProviderHttpError( error: unknown, ): Promise { @@ -1256,15 +1307,29 @@ export class CatmaidClient implements CatmaidSpatialSkeletonEditApi { } async listSkeletons(): Promise { - return this.fetch("skeletons/"); + return this.fetchProjectEndpoint("skeletons/"); + } + + async validateServerVersion(): Promise { + const version = parseCatmaidServerVersionFromResponse( + await this.fetchServerEndpoint("version"), + ); + if (isCatmaidServerVersionSupported(version)) { + return; + } + throw new Error( + `CATMAID server ${this.baseUrl} version ${ + version ?? "unknown" + } is not supported. Version ${CATMAID_MIN_SUPPORTED_VERSION_DATE} or newer is required.`, + ); } private async listStacks(): Promise<{ id: number }[]> { - return this.fetch("stacks"); + return this.fetchProjectEndpoint("stacks"); } private async getStackInfo(stackId: number): Promise { - return this.fetch(`stack/${stackId}/info`); + return this.fetchProjectEndpoint(`stack/${stackId}/info`); } private async loadMetadataInfo(): Promise { @@ -1393,7 +1458,7 @@ export class CatmaidClient implements CatmaidSpatialSkeletonEditApi { const { signal } = options; let data: any; try { - data = await this.fetch( + data = await this.fetchProjectEndpoint( `skeletons/${skeletonId}/compact-detail?with_tags=true&with_edition_times=true`, { signal, @@ -1459,7 +1524,7 @@ export class CatmaidClient implements CatmaidSpatialSkeletonEditApi { let data: any; try { - data = await this.fetch( + data = await this.fetchProjectEndpoint( `node/list?${params.toString()}`, { signal, priority: "low" }, true, @@ -1544,7 +1609,7 @@ export class CatmaidClient implements CatmaidSpatialSkeletonEditApi { buildCatmaidMultiNodeState("move-node", editContext, [nodeId]), ); - const response = await this.fetch(`node/update`, { + const response = await this.fetchProjectEndpoint(`node/update`, { method: "POST", body: body, }); @@ -1556,7 +1621,9 @@ export class CatmaidClient implements CatmaidSpatialSkeletonEditApi { async getSkeletonRootNode( skeletonId: number, ): Promise { - const response = await this.fetch(`skeletons/${skeletonId}/root`); + const response = await this.fetchProjectEndpoint( + `skeletons/${skeletonId}/root`, + ); return parseCatmaidSkeletonRootTarget(response); } @@ -1573,13 +1640,11 @@ export class CatmaidClient implements CatmaidSpatialSkeletonEditApi { expectedNodeId: nodeId, }), ); - const response = await this.fetch(`skeleton/reroot`, { + const response = await this.fetchProjectEndpoint(`skeleton/reroot`, { method: "POST", body, }); - const revisionToken = normalizeCatmaidRevisionToken( - response?.edition_time, - ); + const revisionToken = normalizeCatmaidRevisionToken(response?.edition_time); if (revisionToken === undefined) { throw new Error( "CATMAID skeleton/reroot did not return the new root edition_time.", @@ -1616,7 +1681,7 @@ export class CatmaidClient implements CatmaidSpatialSkeletonEditApi { expectedChildIds: normalizedChildIds, }), ); - const response = await this.fetch(`treenode/delete`, { + const response = await this.fetchProjectEndpoint(`treenode/delete`, { method: "POST", body: body, }); @@ -1647,7 +1712,7 @@ export class CatmaidClient implements CatmaidSpatialSkeletonEditApi { } appendCatmaidState(body, buildCatmaidAddNodeState(parentId, editContext)); - const res = await this.fetch(`treenode/create`, { + const res = await this.fetchProjectEndpoint(`treenode/create`, { method: "POST", body: body, }); @@ -1713,7 +1778,7 @@ export class CatmaidClient implements CatmaidSpatialSkeletonEditApi { buildCatmaidInsertNodeState(parentId, normalizedChildIds, editContext), ); - const response = await this.fetch(`treenode/insert`, { + const response = await this.fetchProjectEndpoint(`treenode/insert`, { method: "POST", body, }); @@ -1749,7 +1814,7 @@ export class CatmaidClient implements CatmaidSpatialSkeletonEditApi { endpoint: "update" | "remove", body: URLSearchParams, ) { - return this.fetch(`label/treenode/${nodeId}/${endpoint}`, { + return this.fetchProjectEndpoint(`label/treenode/${nodeId}/${endpoint}`, { method: "POST", body, }); @@ -1884,10 +1949,13 @@ export class CatmaidClient implements CatmaidSpatialSkeletonEditApi { body, buildCatmaidNodeState("update-radius", editContext, nodeId), ); - const response = await this.fetch(`treenode/${nodeId}/radius`, { - method: "POST", - body, - }); + const response = await this.fetchProjectEndpoint( + `treenode/${nodeId}/radius`, + { + method: "POST", + body, + }, + ); return getCatmaidSingleNodeRevisionResult( parseCatmaidUpdatedNodesRevisionToken(response, nodeId), ); @@ -1908,10 +1976,13 @@ export class CatmaidClient implements CatmaidSpatialSkeletonEditApi { body, buildCatmaidNodeState("update-confidence", editContext, nodeId), ); - const response = await this.fetch(`treenodes/${nodeId}/confidence`, { - method: "POST", - body, - }); + const response = await this.fetchProjectEndpoint( + `treenodes/${nodeId}/confidence`, + { + method: "POST", + body, + }, + ); return getCatmaidSingleNodeRevisionResult( parseCatmaidConfidenceRevisionToken(response, nodeId), ); @@ -1933,7 +2004,7 @@ export class CatmaidClient implements CatmaidSpatialSkeletonEditApi { toNodeId, ]), ); - const response = await this.fetch(`skeleton/join`, { + const response = await this.fetchProjectEndpoint(`skeleton/join`, { method: "POST", body, }); @@ -1963,7 +2034,7 @@ export class CatmaidClient implements CatmaidSpatialSkeletonEditApi { expectedNodeId: nodeId, }), ); - const response = await this.fetch(`skeleton/split`, { + const response = await this.fetchProjectEndpoint(`skeleton/split`, { method: "POST", body, }); diff --git a/src/datasource/catmaid/frontend.ts b/src/datasource/catmaid/frontend.ts index 91244d122..58b1b94b4 100644 --- a/src/datasource/catmaid/frontend.ts +++ b/src/datasource/catmaid/frontend.ts @@ -352,6 +352,12 @@ export class CatmaidDataSourceProvider implements DataSourceProvider { credentialsProvider, ); + await options.registry.chunkManager.memoize.getAsync( + { type: "catmaid:version", baseUrl }, + options, + () => client.validateServerVersion(), + ); + // Fetch metadata-derived values through the generic source interface. const [spatialIndexMetadata, cacheProvider, skeletonIds] = await Promise.all([ From 4d34ba02c7b9cf925f065687be426ff43cf3087a Mon Sep 17 00:00:00 2001 From: afonso pinto Date: Wed, 27 May 2026 15:33:07 +0100 Subject: [PATCH 4/8] feat: Remove requireCatmaidCompactDetailNodeRevisionToken --- src/datasource/catmaid/api.spec.ts | 18 ++++++++++++++---- src/datasource/catmaid/api.ts | 14 +------------- 2 files changed, 15 insertions(+), 17 deletions(-) diff --git a/src/datasource/catmaid/api.spec.ts b/src/datasource/catmaid/api.spec.ts index fadd4eea5..a63195a07 100644 --- a/src/datasource/catmaid/api.spec.ts +++ b/src/datasource/catmaid/api.spec.ts @@ -418,7 +418,7 @@ describe("CatmaidClient skeleton editing methods", () => { ); }); - it("rejects compact-detail rows missing edition times", async () => { + it("parses compact-detail rows without local edition-time validation", async () => { const client = new CatmaidClient("https://example.invalid", 1); const fetchMock = vi .fn() @@ -431,9 +431,19 @@ describe("CatmaidClient skeleton editing methods", () => { ]); (client as any).fetchProjectEndpoint = fetchMock; - await expect(client.getSkeleton(2)).rejects.toThrow( - "CATMAID skeletons/compact-detail did not return node edition times. The server must support with_edition_times=true.", - ); + await expect(client.getSkeleton(2)).resolves.toEqual([ + { + nodeId: 22107946, + parentNodeId: undefined, + position: new Float32Array([23697030, 15055839, 16651262]), + segmentId: 2, + radius: 2000, + confidence: 100, + description: undefined, + isTrueEnd: false, + sourceState: undefined, + }, + ]); expect(fetchMock).toHaveBeenCalledTimes(1); }); diff --git a/src/datasource/catmaid/api.ts b/src/datasource/catmaid/api.ts index 98de0993c..6682ff0d3 100644 --- a/src/datasource/catmaid/api.ts +++ b/src/datasource/catmaid/api.ts @@ -1020,18 +1020,6 @@ function getCatmaidSingleNodeRevisionResult( return sourceState === undefined ? {} : { sourceState }; } -function requireCatmaidCompactDetailNodeRevisionToken( - row: readonly unknown[], -): string { - const revisionToken = normalizeCatmaidRevisionToken(row[8]); - if (revisionToken === undefined) { - throw new Error( - "CATMAID skeletons/compact-detail did not return node edition times. The server must support with_edition_times=true.", - ); - } - return revisionToken; -} - function parseCatmaidMoveRevisionToken( response: any, nodeId: number, @@ -1490,7 +1478,7 @@ export class CatmaidClient implements CatmaidSpatialSkeletonEditApi { description: descriptionByNodeId.get(Number(n[0])), isTrueEnd: trueEndByNodeId.has(Number(n[0])), sourceState: makeCatmaidNodeSourceState( - requireCatmaidCompactDetailNodeRevisionToken(n), + normalizeCatmaidRevisionToken(n[8]), ), })); } From 9180be83d773c9537d9b36a54330b4485a055f6c Mon Sep 17 00:00:00 2001 From: afonso pinto Date: Wed, 27 May 2026 16:07:05 +0100 Subject: [PATCH 5/8] feat: Remove deduplication --- src/skeleton/spatial_skeleton_manager.ts | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/src/skeleton/spatial_skeleton_manager.ts b/src/skeleton/spatial_skeleton_manager.ts index e96257c31..98cfdc38e 100644 --- a/src/skeleton/spatial_skeleton_manager.ts +++ b/src/skeleton/spatial_skeleton_manager.ts @@ -863,20 +863,16 @@ export class SpatialSkeletonState extends RefCounted { const fetchedNodes = await skeletonSource.getSkeleton(segmentId, { signal: abortController.signal, }); - const dedupedNodes = new Map(); + const normalizedNodes: SpatiallyIndexedSkeletonNode[] = []; for (const fetchedNode of fetchedNodes) { const mappedNode = normalizeSpatiallyIndexedSkeletonNode( fetchedNode, segmentId, ); if (mappedNode === undefined) continue; - if (!dedupedNodes.has(mappedNode.nodeId)) { - dedupedNodes.set(mappedNode.nodeId, mappedNode); - } + normalizedNodes.push(mappedNode); } - const normalizedNodes = [...dedupedNodes.values()].sort( - (a, b) => a.nodeId - b.nodeId, - ); + normalizedNodes.sort((a, b) => a.nodeId - b.nodeId); if ( this.fullSkeletonCacheGeneration === fetchVersion && pendingFetch.promise !== undefined && From 43dfb61048d0de95368be2bb80ea599c302a2f16 Mon Sep 17 00:00:00 2001 From: afonso pinto Date: Wed, 27 May 2026 21:23:49 +0100 Subject: [PATCH 6/8] feat: Update isCatmaidServerVersionSupported --- docs/user-guide/skeleton_editing.rst | 4 +- src/datasource/catmaid/api.spec.ts | 60 ++++++++++++++++++---------- src/datasource/catmaid/api.ts | 43 ++++++++++++++++---- 3 files changed, 76 insertions(+), 31 deletions(-) diff --git a/docs/user-guide/skeleton_editing.rst b/docs/user-guide/skeleton_editing.rst index a74ca1dc5..1558972b7 100644 --- a/docs/user-guide/skeleton_editing.rst +++ b/docs/user-guide/skeleton_editing.rst @@ -12,9 +12,7 @@ Supported Sources Skeleton editing is currently only supported on CATMAID data sources. See the CATMAID documentation to set up a CATMAID server. At minimum you will need: -- CATMAID commit ``ed261715a0c4b2b1698dfd9a0e2d6f4233533f19`` or newer. This - version adds the edition-time metadata that Neuroglancer uses to validate - edits against the current CATMAID skeleton state. +- CATMAID ``2026.05.06.dev12+g...`` or later by git-describe semantics. - A CATMAID project - A linked project stack - ``AnonymousUser`` permissions to read and edit the data on that project diff --git a/src/datasource/catmaid/api.spec.ts b/src/datasource/catmaid/api.spec.ts index a63195a07..03e1fa60b 100644 --- a/src/datasource/catmaid/api.spec.ts +++ b/src/datasource/catmaid/api.spec.ts @@ -67,28 +67,48 @@ function getFetchInit(fetchMock: FetchMock, callIndex = 0) { } describe("CatmaidClient skeleton editing methods", () => { - it("accepts supported CATMAID server version dates", async () => { - const client = new CatmaidClient("https://example.invalid", 1); - const fetchServerEndpointMock = vi - .fn() - .mockResolvedValue({ SERVER_VERSION: "2026.05.21.dev1+gabcdef0" }); - (client as any).fetchServerEndpoint = fetchServerEndpointMock; - - await expect(client.validateServerVersion()).resolves.toBeUndefined(); - expect(fetchServerEndpointMock).toHaveBeenCalledWith("version"); + it("accepts supported CATMAID server git-described versions", async () => { + for (const version of [ + "2026.05.06.dev12+g24ed227e31", + "2026.05.06.dev13+gabcdef123", + "2026.05.07.dev0+gabcdef123", + ]) { + const client = new CatmaidClient("https://example.invalid", 1); + const fetchServerEndpointMock = vi + .fn() + .mockResolvedValue({ SERVER_VERSION: version }); + (client as any).fetchServerEndpoint = fetchServerEndpointMock; + + await expect(client.validateServerVersion()).resolves.toBeUndefined(); + expect(fetchServerEndpointMock).toHaveBeenCalledWith("version"); + } }); - it("rejects unsupported CATMAID server version dates", async () => { - const client = new CatmaidClient("https://example.invalid", 1); - const fetchServerEndpointMock = vi - .fn() - .mockResolvedValue({ SERVER_VERSION: "2026.05.20.dev12+gabcdef0" }); - (client as any).fetchServerEndpoint = fetchServerEndpointMock; - - await expect(client.validateServerVersion()).rejects.toThrow( - "CATMAID server https://example.invalid version 2026.05.20.dev12+gabcdef0 is not supported. Version 2026.05.21 or newer is required.", - ); - expect(fetchServerEndpointMock).toHaveBeenCalledWith("version"); + it("rejects unsupported CATMAID server git-described versions", async () => { + for (const { response, version } of [ + { + response: { SERVER_VERSION: "2026.05.06.dev11+gabcdef123" }, + version: "2026.05.06.dev11+gabcdef123", + }, + { + response: { SERVER_VERSION: "2026.05.05.dev999+gabcdef123" }, + version: "2026.05.05.dev999+gabcdef123", + }, + { response: {}, version: "unknown" }, + { + response: { SERVER_VERSION: "2026.05.06-12-gabcdef123" }, + version: "2026.05.06-12-gabcdef123", + }, + ]) { + const client = new CatmaidClient("https://example.invalid", 1); + const fetchServerEndpointMock = vi.fn().mockResolvedValue(response); + (client as any).fetchServerEndpoint = fetchServerEndpointMock; + + await expect(client.validateServerVersion()).rejects.toThrow( + `CATMAID server https://example.invalid version ${version} is not supported. Version 2026.05.06.dev12+g... or later by git-describe semantics is required for compact-detail with_edition_times support.`, + ); + expect(fetchServerEndpointMock).toHaveBeenCalledWith("version"); + } }); it("does not cache transient metadata discovery failures as null", async () => { diff --git a/src/datasource/catmaid/api.ts b/src/datasource/catmaid/api.ts index 6682ff0d3..eafb36556 100644 --- a/src/datasource/catmaid/api.ts +++ b/src/datasource/catmaid/api.ts @@ -57,7 +57,9 @@ export const credentialsKey = "CATMAID"; const CATMAID_NO_MATCHING_NODE_PROVIDER_ERROR = "Could not find matching node provider for request"; const CATMAID_STATE_MATCHING_ERROR_TYPE = "StateMatchingError"; -const CATMAID_MIN_SUPPORTED_VERSION_DATE = "2026.05.21"; +const CATMAID_MIN_SUPPORTED_RELEASE_TAG = "2026.05.06"; +const CATMAID_MIN_SUPPORTED_COMMITS_AFTER_RELEASE_TAG = 12; +const CATMAID_MIN_SUPPORTED_GIT_DESCRIBE_VERSION = `${CATMAID_MIN_SUPPORTED_RELEASE_TAG}.dev${CATMAID_MIN_SUPPORTED_COMMITS_AFTER_RELEASE_TAG}+g...`; type CatmaidStatePayload = object; type CatmaidFetchPriority = "high" | "low" | "auto"; @@ -1111,16 +1113,41 @@ function parseCatmaidServerVersionFromResponse( : undefined; } -function parseCatmaidVersionDate(version: string | undefined) { - const match = version?.match(/^(\d{4})\.(\d{2})\.(\d{2})/); - return match == null ? undefined : `${match[1]}.${match[2]}.${match[3]}`; +interface CatmaidGitDescribeVersion { + releaseTag: string; + commitsAfterReleaseTag: number; + commitHash: string; +} + +function parseCatmaidGitDescribeVersion( + version: string | undefined, +): CatmaidGitDescribeVersion | undefined { + const match = version?.match( + /^(\d{4}\.\d{2}\.\d{2})\.dev(\d+)\+g([0-9a-fA-F]+)$/, + ); + if (match == null) { + return undefined; + } + return { + releaseTag: match[1], + commitsAfterReleaseTag: Number(match[2]), + commitHash: match[3], + }; } function isCatmaidServerVersionSupported(version: string | undefined) { - const versionDate = parseCatmaidVersionDate(version); + const parsed = parseCatmaidGitDescribeVersion(version); + if (parsed === undefined) { + return false; + } + const releaseComparison = parsed.releaseTag.localeCompare( + CATMAID_MIN_SUPPORTED_RELEASE_TAG, + ); return ( - versionDate !== undefined && - versionDate.localeCompare(CATMAID_MIN_SUPPORTED_VERSION_DATE) >= 0 + releaseComparison > 0 || + (releaseComparison === 0 && + parsed.commitsAfterReleaseTag >= + CATMAID_MIN_SUPPORTED_COMMITS_AFTER_RELEASE_TAG) ); } @@ -1308,7 +1335,7 @@ export class CatmaidClient implements CatmaidSpatialSkeletonEditApi { throw new Error( `CATMAID server ${this.baseUrl} version ${ version ?? "unknown" - } is not supported. Version ${CATMAID_MIN_SUPPORTED_VERSION_DATE} or newer is required.`, + } is not supported. Version ${CATMAID_MIN_SUPPORTED_GIT_DESCRIBE_VERSION} or later by git-describe semantics is required for compact-detail with_edition_times support.`, ); } From 368444fa702c6ee31c1da5359a52215f522976d5 Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Fri, 5 Jun 2026 13:29:42 +0200 Subject: [PATCH 7/8] fix: correct server version req for catmaid I guess a force push might have happened Also simplifies the test a bit --- src/datasource/catmaid/api.spec.ts | 17 ++++++++--------- src/datasource/catmaid/api.ts | 4 ++-- 2 files changed, 10 insertions(+), 11 deletions(-) diff --git a/src/datasource/catmaid/api.spec.ts b/src/datasource/catmaid/api.spec.ts index 03e1fa60b..7db4abc27 100644 --- a/src/datasource/catmaid/api.spec.ts +++ b/src/datasource/catmaid/api.spec.ts @@ -17,6 +17,7 @@ import { describe, expect, it, vi } from "vitest"; import { + CATMAID_MIN_SUPPORTED_GIT_DESCRIBE_VERSION, CatmaidClient, getCatmaidSpatialSkeletonGridCellBounds, makeCatmaidNodeSourceState, @@ -69,8 +70,8 @@ function getFetchInit(fetchMock: FetchMock, callIndex = 0) { describe("CatmaidClient skeleton editing methods", () => { it("accepts supported CATMAID server git-described versions", async () => { for (const version of [ - "2026.05.06.dev12+g24ed227e31", - "2026.05.06.dev13+gabcdef123", + "2026.05.06.dev11+g24ed227e31", + "2026.05.06.dev12+gabcdef123", "2026.05.07.dev0+gabcdef123", ]) { const client = new CatmaidClient("https://example.invalid", 1); @@ -85,27 +86,25 @@ describe("CatmaidClient skeleton editing methods", () => { }); it("rejects unsupported CATMAID server git-described versions", async () => { - for (const { response, version } of [ + for (const { response } of [ { - response: { SERVER_VERSION: "2026.05.06.dev11+gabcdef123" }, - version: "2026.05.06.dev11+gabcdef123", + response: { SERVER_VERSION: "2026.05.06.dev10+gabcdef123" }, }, { response: { SERVER_VERSION: "2026.05.05.dev999+gabcdef123" }, - version: "2026.05.05.dev999+gabcdef123", }, - { response: {}, version: "unknown" }, + { response: {} }, { response: { SERVER_VERSION: "2026.05.06-12-gabcdef123" }, - version: "2026.05.06-12-gabcdef123", }, ]) { const client = new CatmaidClient("https://example.invalid", 1); const fetchServerEndpointMock = vi.fn().mockResolvedValue(response); (client as any).fetchServerEndpoint = fetchServerEndpointMock; + const version = response.SERVER_VERSION ?? "unknown"; await expect(client.validateServerVersion()).rejects.toThrow( - `CATMAID server https://example.invalid version ${version} is not supported. Version 2026.05.06.dev12+g... or later by git-describe semantics is required for compact-detail with_edition_times support.`, + `CATMAID server https://example.invalid version ${version} is not supported. Version ${CATMAID_MIN_SUPPORTED_GIT_DESCRIBE_VERSION} or later by git-describe semantics is required for compact-detail with_edition_times support.`, ); expect(fetchServerEndpointMock).toHaveBeenCalledWith("version"); } diff --git a/src/datasource/catmaid/api.ts b/src/datasource/catmaid/api.ts index eafb36556..cc159febd 100644 --- a/src/datasource/catmaid/api.ts +++ b/src/datasource/catmaid/api.ts @@ -58,8 +58,8 @@ const CATMAID_NO_MATCHING_NODE_PROVIDER_ERROR = "Could not find matching node provider for request"; const CATMAID_STATE_MATCHING_ERROR_TYPE = "StateMatchingError"; const CATMAID_MIN_SUPPORTED_RELEASE_TAG = "2026.05.06"; -const CATMAID_MIN_SUPPORTED_COMMITS_AFTER_RELEASE_TAG = 12; -const CATMAID_MIN_SUPPORTED_GIT_DESCRIBE_VERSION = `${CATMAID_MIN_SUPPORTED_RELEASE_TAG}.dev${CATMAID_MIN_SUPPORTED_COMMITS_AFTER_RELEASE_TAG}+g...`; +const CATMAID_MIN_SUPPORTED_COMMITS_AFTER_RELEASE_TAG = 11; +export const CATMAID_MIN_SUPPORTED_GIT_DESCRIBE_VERSION = `${CATMAID_MIN_SUPPORTED_RELEASE_TAG}.dev${CATMAID_MIN_SUPPORTED_COMMITS_AFTER_RELEASE_TAG}+g...`; type CatmaidStatePayload = object; type CatmaidFetchPriority = "high" | "low" | "auto"; From afdadfe1e9853c78681aa089e37d15ff5194c0a1 Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Fri, 5 Jun 2026 13:30:24 +0200 Subject: [PATCH 8/8] docs: update guide version --- docs/user-guide/skeleton_editing.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/user-guide/skeleton_editing.rst b/docs/user-guide/skeleton_editing.rst index 1558972b7..298060dd7 100644 --- a/docs/user-guide/skeleton_editing.rst +++ b/docs/user-guide/skeleton_editing.rst @@ -12,7 +12,7 @@ Supported Sources Skeleton editing is currently only supported on CATMAID data sources. See the CATMAID documentation to set up a CATMAID server. At minimum you will need: -- CATMAID ``2026.05.06.dev12+g...`` or later by git-describe semantics. +- CATMAID ``2026.05.06.dev11+g...`` or later by git-describe semantics. - A CATMAID project - A linked project stack - ``AnonymousUser`` permissions to read and edit the data on that project