diff --git a/docs/user-guide/skeleton_editing.rst b/docs/user-guide/skeleton_editing.rst index 328630109..298060dd7 100644 --- a/docs/user-guide/skeleton_editing.rst +++ b/docs/user-guide/skeleton_editing.rst @@ -12,6 +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.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 diff --git a/src/datasource/catmaid/api.spec.ts b/src/datasource/catmaid/api.spec.ts index 0fe0d511c..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, @@ -67,6 +68,48 @@ 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.dev11+g24ed227e31", + "2026.05.06.dev12+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 git-described versions", async () => { + for (const { response } of [ + { + response: { SERVER_VERSION: "2026.05.06.dev10+gabcdef123" }, + }, + { + response: { SERVER_VERSION: "2026.05.05.dev999+gabcdef123" }, + }, + { response: {} }, + { + response: { SERVER_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 ${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"); + } + }); + 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(() => {}); @@ -300,82 +343,58 @@ 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"]], - }, - [], - [], - ]); - (client as any).fetch = fetchMock; + [ + 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).fetchProjectEndpoint = fetchMock; await expect(client.getSkeleton(2)).resolves.toEqual([ { @@ -412,98 +431,39 @@ 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 () => { + it("parses compact-detail rows without local edition-time validation", 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, - ], - ], + .mockResolvedValue([ + [[22107946, null, 2, 23697030.0, 15055839.0, 16651262.0, 2000.0, 5]], [], - { - ends: [[23218380, "2026-04-22 15:11:58.824455+00:00"]], - }, + {}, [], [], - ]) - .mockResolvedValueOnce([[], [], {}, [], []]); - (client as any).fetch = fetchMock; + ]); + (client as any).fetchProjectEndpoint = fetchMock; - await expect(client.getSkeleton(2974940)).resolves.toEqual([ + await expect(client.getSkeleton(2)).resolves.toEqual([ { - nodeId: 23218380, + nodeId: 22107946, parentNodeId: undefined, - position: new Float32Array([24233266, 13917594, 15605623]), - segmentId: 2974940, - radius: 0, + position: new Float32Array([23697030, 15055839, 16651262]), + segmentId: 2, + radius: 2000, confidence: 100, description: undefined, isTrueEnd: false, - sourceState: testSourceState("2026-05-06 20:17:31.181383+00:00"), + sourceState: undefined, }, ]); - }); - - it("ignores zero-width history rows when compact-detail includes ordering", 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, - ], - ], - [], - {}, - [], - [], - ]); - (client as any).fetch = fetchMock; - - await expect(client.getSkeleton(1140285)).resolves.toEqual([]); + expect(fetchMock).toHaveBeenCalledTimes(1); }); it("merges skeletons using from/to treenode ids", async () => { @@ -513,7 +473,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, { @@ -554,7 +514,7 @@ describe("CatmaidClient skeleton editing methods", () => { [], [], ]); - (client as any).fetch = fetchMock; + (client as any).fetchProjectEndpoint = fetchMock; await expect( client.fetchNodes({ @@ -588,7 +548,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( { @@ -615,7 +575,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({ @@ -634,7 +594,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, @@ -647,7 +607,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, { @@ -668,7 +628,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, { @@ -696,7 +656,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, @@ -722,7 +682,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], { @@ -766,14 +726,12 @@ 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], - ]); - (client as any).fetch = fetchMock; + const fetchMock = vi.fn().mockResolvedValue({ + newroot: 202, + skeleton_id: 17, + edition_time: "2026-03-29T12:08:00Z", + }); + (client as any).fetchProjectEndpoint = fetchMock; await expect( client.rerootSkeleton(202, { @@ -798,18 +756,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,16 +781,12 @@ 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 () => { 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, { @@ -856,7 +810,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, { @@ -890,15 +844,13 @@ 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], - ]); - (client as any).fetch = fetchMock; + const fetchMock = vi.fn().mockResolvedValue({ + newroot: 202, + skeleton_id: 17, + }); + (client as any).fetchProjectEndpoint = fetchMock; await expect( client.rerootSkeleton(202, { @@ -917,8 +869,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 () => { @@ -928,7 +881,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, { @@ -955,7 +908,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, { @@ -1001,7 +954,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"), @@ -1021,7 +974,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", { @@ -1043,7 +996,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"), @@ -1066,7 +1019,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 0d5462815..cc159febd 100644 --- a/src/datasource/catmaid/api.ts +++ b/src/datasource/catmaid/api.ts @@ -57,6 +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_RELEASE_TAG = "2026.05.06"; +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"; @@ -766,28 +769,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,28 +1022,6 @@ function getCatmaidSingleNodeRevisionResult( return sourceState === undefined ? {} : { sourceState }; } -function parseCatmaidNodeRevisionUpdates( - rows: unknown, -): CatmaidSkeletonNodeSourceStateUpdate[] { - if (!Array.isArray(rows)) { - throw new Error( - "CATMAID treenodes/compact-detail endpoint returned an unexpected response format.", - ); - } - 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; -} - function parseCatmaidMoveRevisionToken( response: any, nodeId: number, @@ -1142,6 +1101,80 @@ 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; +} + +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 parsed = parseCatmaidGitDescribeVersion(version); + if (parsed === undefined) { + return false; + } + const releaseComparison = parsed.releaseTag.localeCompare( + CATMAID_MIN_SUPPORTED_RELEASE_TAG, + ); + return ( + releaseComparison > 0 || + (releaseComparison === 0 && + parsed.commitsAfterReleaseTag >= + CATMAID_MIN_SUPPORTED_COMMITS_AFTER_RELEASE_TAG) + ); +} + +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, @@ -1200,7 +1233,7 @@ export class CatmaidClient implements CatmaidSpatialSkeletonEditApi { return error; } - private async fetch( + private async fetchProjectEndpoint( endpoint: string, options: CatmaidRequestInit = {}, expectMsgpack: boolean = false, @@ -1253,6 +1286,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 { @@ -1264,15 +1322,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_GIT_DESCRIBE_VERSION} or later by git-describe semantics is required for compact-detail with_edition_times support.`, + ); } 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 { @@ -1400,17 +1472,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.fetchProjectEndpoint( + `skeletons/${skeletonId}/compact-detail?with_tags=true&with_edition_times=true`, + { signal, - }), - ]); + }, + ); } catch (error) { if (error instanceof CatmaidNotFoundError) { return []; @@ -1419,25 +1487,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 +1505,7 @@ export class CatmaidClient implements CatmaidSpatialSkeletonEditApi { description: descriptionByNodeId.get(Number(n[0])), isTrueEnd: trueEndByNodeId.has(Number(n[0])), sourceState: makeCatmaidNodeSourceState( - getCatmaidHistoryRevisionToken(n), + normalizeCatmaidRevisionToken(n[8]), ), })); } @@ -1483,7 +1539,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, @@ -1568,7 +1624,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, }); @@ -1580,7 +1636,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); } @@ -1597,54 +1655,22 @@ export class CatmaidClient implements CatmaidSpatialSkeletonEditApi { expectedNodeId: nodeId, }), ); - await this.fetch(`skeleton/reroot`, { + const response = await this.fetchProjectEndpoint(`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`, { - method: "POST", - body, - }); - const revisionUpdates = parseCatmaidNodeRevisionUpdates(response); - const returnedNodeIds = new Set( - revisionUpdates.map((update) => update.nodeId), - ); - const missingNodeIds = normalizedNodeIds.filter( - (nodeId) => !returnedNodeIds.has(nodeId), - ); - if (missingNodeIds.length > 0) { + const revisionToken = normalizeCatmaidRevisionToken(response?.edition_time); + 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( @@ -1670,7 +1696,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, }); @@ -1701,7 +1727,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, }); @@ -1767,7 +1793,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, }); @@ -1803,7 +1829,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, }); @@ -1938,10 +1964,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), ); @@ -1962,10 +1991,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), ); @@ -1987,7 +2019,7 @@ export class CatmaidClient implements CatmaidSpatialSkeletonEditApi { toNodeId, ]), ); - const response = await this.fetch(`skeleton/join`, { + const response = await this.fetchProjectEndpoint(`skeleton/join`, { method: "POST", body, }); @@ -2017,7 +2049,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([ 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 &&