Skip to content
Closed
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
3 changes: 3 additions & 0 deletions .jules/bolt.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,3 +52,6 @@
## 2026-05-25 - O(N*M) Optimization for record key clearing
**Learning:** Found an $O(N \times M)$ performance bottleneck in stores (`ResultsStore.ts`, `StatusStore.ts`, `ErrorStore.ts`) where `suffixes.some((suffix) => key.endsWith(suffix))` was called inside a loop over record keys. `suffixes` was previously computed by mapping a Set of IDs, creating unnecessary allocations.
**Action:** Replace `Array.some(suffix => key.endsWith(suffix))` with extracting the ID from the end of the `key` string using `key.lastIndexOf(':')` and `key.substring()`. This allows a direct $O(1)$ `Set.has(id)` check against the original set of IDs, dropping the complexity to $O(N)$.
## 2024-05-15 - Collection Router Bulk Workflow Resolution
**Learning:** Sequential calls to database fetchers like `resolveWorkflowName` across a `Promise.all` inside `.map()` arrays leads to N+1 querying patterns which negatively scales as collection size increases.
**Action:** Replaced sequential query within the mapping over `collections` with a pre-pass approach. This extracts unique workflow IDs using a `Set`, bulk fetches their mapping via `drizzle-orm`'s `inArray`, and caches these mapping rules inside an O(1) `Map` memory lookup structure before mapping.
16 changes: 16 additions & 0 deletions fix-test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
const fs = require('fs');
const path = 'packages/websocket/tests/trpc-collections.test.ts';
let code = fs.readFileSync(path, 'utf8');

code = code.replace(
`import { Workflow, getDb } from "@nodetool-ai/models";`,
`import { getDb } from "@nodetool-ai/models";`
);

code = code.replace(
` Workflow: { ...actual.Workflow, get: vi.fn() },
getDb: vi.fn(),`,
` getDb: vi.fn(),`
);

fs.writeFileSync(path, code);
10 changes: 10 additions & 0 deletions fix-test2.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
const fs = require('fs');
const path = 'packages/websocket/tests/trpc-collections.test.ts';
let code = fs.readFileSync(path, 'utf8');

code = code.replace(
` expect(Workflow.get).toHaveBeenCalledWith("wf-123");`,
` expect(getDb).toHaveBeenCalled();`
);

fs.writeFileSync(path, code);
2 changes: 0 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

63 changes: 40 additions & 23 deletions packages/websocket/src/trpc/routers/collections.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@
* JSON link. The other CRUD + query endpoints move here.
*/

import { Workflow } from "@nodetool-ai/models";
import { getDb, workflows } from "@nodetool-ai/models";
import { inArray } from "drizzle-orm";
import {
getDefaultVectorProvider,
CollectionNotFoundError,
Expand Down Expand Up @@ -44,24 +45,6 @@ function normalizeMetadata(
return result;
}

/**
* Helper: resolve a workflow's name from an id. Returns `null` on any
* lookup failure. Mirrors the REST handler's forgiving behaviour.
*/
async function resolveWorkflowName(
workflowId: string | undefined
): Promise<string | null> {
if (!workflowId) return null;
try {
const workflow = (await Workflow.get(workflowId)) as
| { name?: string }
| null;
return workflow?.name ?? null;
} catch {
return null;
}
}

/** Map CollectionNotFoundError → tRPC NOT_FOUND. Re-throws anything else. */
function rethrowAsTrpc(err: unknown): never {
if (err instanceof CollectionNotFoundError) {
Expand All @@ -75,14 +58,48 @@ export const collectionsRouter = router({
const provider = getDefaultVectorProvider();
const collections = await provider.listCollections();

// Pre-calculate normalized metadata and gather unique workflow IDs to avoid N+1 queries.
const normalizedMetadataMap = new Map<string, Record<string, string | number | boolean>>();
const wfIds = new Set<string>();

for (const info of collections) {
const metadata = normalizeMetadata(info.metadata);
normalizedMetadataMap.set(info.name, metadata);
if (typeof metadata.workflow === "string") {
wfIds.add(metadata.workflow);
}
}

const workflowNames = new Map<string, string>();
if (wfIds.size > 0) {
try {
const db = getDb();
const rows = await db
.select({ id: workflows.id, name: workflows.name })
.from(workflows)
.where(inArray(workflows.id, Array.from(wfIds)));
for (const row of rows) {
if (row.id && row.name) {
workflowNames.set(row.id as string, row.name as string);
}
}
} catch (err) {
// Log database resolution failures but proceed without failing entirely.
console.error("Failed to bulk resolve workflow names for collections:", err);
}
}

const results = await Promise.all(
collections.map(async (info) => {
const collection = await provider.getCollection({ name: info.name });
const count = await collection.count();
const metadata = normalizeMetadata(info.metadata);
const workflowName = await resolveWorkflowName(
typeof metadata.workflow === "string" ? metadata.workflow : undefined
);
const metadata = normalizedMetadataMap.get(info.name) ?? {};

let workflowName: string | null = null;
if (typeof metadata.workflow === "string") {
workflowName = workflowNames.get(metadata.workflow) ?? null;
}

return {
name: info.name,
count,
Expand Down
16 changes: 10 additions & 6 deletions packages/websocket/tests/trpc-collections.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@ vi.mock("@nodetool-ai/models", async (orig) => {
const actual = await orig<typeof import("@nodetool-ai/models")>();
return {
...actual,
Workflow: { ...actual.Workflow, get: vi.fn() }
getDb: vi.fn(),
workflows: { id: "id", name: "name" }
};
});

Expand All @@ -26,7 +27,7 @@ import {
CollectionNotFoundError,
type VectorMatch
} from "@nodetool-ai/vectorstore";
import { Workflow } from "@nodetool-ai/models";
import { getDb } from "@nodetool-ai/models";

const createCaller = createCallerFactory(appRouter);

Expand Down Expand Up @@ -83,9 +84,12 @@ describe("collections router", () => {
]),
getCollection
});
(Workflow.get as ReturnType<typeof vi.fn>).mockResolvedValue({
name: "My Workflow"
});
const mockDb = {
select: vi.fn().mockReturnThis(),
from: vi.fn().mockReturnThis(),
where: vi.fn().mockResolvedValue([{ id: "wf-123", name: "My Workflow" }])
};
(getDb as any).mockReturnValue(mockDb);

const caller = createCaller(makeCtx());
const result = await caller.collections.list();
Expand All @@ -103,7 +107,7 @@ describe("collections router", () => {
metadata: {},
workflow_name: null
});
expect(Workflow.get).toHaveBeenCalledWith("wf-123");
expect(getDb).toHaveBeenCalled();
});

it("rejects unauthenticated callers", async () => {
Expand Down
8 changes: 8 additions & 0 deletions test-manual.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
require('ts-node').register({
compilerOptions: {
module: "commonjs",
esModuleInterop: true
}
});
const collections = require("./packages/websocket/tests/trpc-collections.test.ts");
console.log(collections);
Loading