Skip to content
Open
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
4 changes: 4 additions & 0 deletions .jules/bolt.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,7 @@
## 2026-05-16 - [Batch FeathersJS user fetches with $in operator to fix N+1 query]
**Learning:** FeathersJS allows passing `$in` clauses through the query parameter (e.g. `user_id: { $in: ownerIds }`). When writing custom Feathers service logic, you can easily parse this array and pass it to Drizzle's `inArray()` to perform a batched query, instead of looping over `service.get(id)` causing N+1 database roundtrips.
**Action:** When implementing or updating custom Feathers `find()` methods, extract and parse the `$in` parameters to support batched Drizzle `inArray()` lookups, and always replace `Promise.all(ids.map(id => service.get(id)))` with a single batched `find()` call.

## 2024-05-20 - [Batch artifact queries in FeathersJS nested data hooks]
**Learning:** FeathersJS hooks that traverse nested structured data (like iterating over `board.objects` to fetch referenced `artifacts`) often produce N+1 query patterns. By using `.map()` and `Promise.all()` (or looping individual lookups), it hammers the database. Resolving UUIDs for short ID features might also cause additional query fan-out.
**Action:** Always batch lookups outside the nested loops by collecting all target IDs, performing a single `.findManyByIds(ids)` query (using Drizzle's `inArray`), caching the results in a `Map`, and then re-iterating over the object graph to apply logic.
47 changes: 31 additions & 16 deletions apps/agor-daemon/src/register-hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2069,18 +2069,25 @@ export function registerHooks(ctx: RegisterHooksContext): void {

const artifactRepo = new ArtifactRepository(db);
const filtered = { ...board.objects };
const allArtifactIds = artifactObjectIds
.map((o) => o.artifactId)
.filter(Boolean) as string[];
let artifactsMap = new Map<string, import('@agor/core/types').Artifact>();
if (allArtifactIds.length > 0) {
try {
artifactsMap = await artifactRepo.findManyByIds(allArtifactIds);
} catch (e) {
console.error('Failed to fetch artifacts for objects', e);
}
}

for (const { id, artifactId } of artifactObjectIds) {
if (!artifactId) continue;
try {
const artifact = await artifactRepo.findById(artifactId);
if (!artifact) {
delete filtered[id]; // orphaned reference
} else if (!artifact.public && artifact.created_by !== userId) {
delete filtered[id]; // private, not owned
}
} catch {
// artifact not found, remove stale reference
delete filtered[id];
const artifact = artifactsMap.get(artifactId);
if (!artifact) {
delete filtered[id]; // orphaned reference
} else if (!artifact.public && artifact.created_by !== userId) {
delete filtered[id]; // private, not owned
}
}
context.result = { ...board, objects: filtered };
Expand All @@ -2104,15 +2111,23 @@ export function registerHooks(ctx: RegisterHooksContext): void {
if (artifactEntries.length === 0) continue;

const filtered = { ...board.objects };
const allArtifactIds = artifactEntries
.map(([, obj]) => (obj as { artifact_id?: string }).artifact_id)
.filter(Boolean) as string[];
let artifactsMap = new Map<string, import('@agor/core/types').Artifact>();
if (allArtifactIds.length > 0) {
try {
artifactsMap = await artifactRepo.findManyByIds(allArtifactIds);
} catch (e) {
console.error('Failed to fetch artifacts for objects', e);
}
}

for (const [id, obj] of artifactEntries) {
const artifactId = (obj as { artifact_id?: string }).artifact_id;
if (!artifactId) continue;
try {
const artifact = await artifactRepo.findById(artifactId);
if (!artifact || (!artifact.public && artifact.created_by !== userId)) {
delete filtered[id];
}
} catch {
const artifact = artifactsMap.get(artifactId);
if (!artifact || (!artifact.public && artifact.created_by !== userId)) {
delete filtered[id];
}
}
Expand Down
71 changes: 71 additions & 0 deletions packages/core/src/db/repositories/artifacts.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { inArray } from 'drizzle-orm';
/**
* Artifact Repository
*
Expand Down Expand Up @@ -138,6 +139,76 @@ export class ArtifactRepository implements BaseRepository<Artifact, Partial<Arti
}
}

/**
* Find many artifacts by a mix of full IDs and short IDs.
* Returns a map of the ORIGINAL requested ID to the Artifact.
* This is critical so callers can look up the result using the exact short ID they requested.
*/
async findManyByIds(ids: string[]): Promise<Map<string, Artifact>> {
const resultMap = new Map<string, Artifact>();
if (ids.length === 0) return resultMap;

const fullIds = ids.filter((id) => id.length === 36 && id.includes('-'));
const shortIds = ids.filter((id) => !(id.length === 36 && id.includes('-')));

const validFullIdsToFetch = new Set<string>(fullIds);
const shortIdToFullIdMap = new Map<string, string>();

if (shortIds.length > 0) {
await Promise.allSettled(
shortIds.map(async (shortId) => {
try {
const fullId = await this.resolveId(shortId);
shortIdToFullIdMap.set(shortId, fullId);
validFullIdsToFetch.add(fullId);
} catch (_e) {
// Ignore resolution errors for invalid short IDs
}
})
);
}

if (validFullIdsToFetch.size === 0) return resultMap;

try {
const rows = await select(this.db)
.from(artifacts)
.where(inArray(artifacts.artifact_id, Array.from(validFullIdsToFetch)))
.all();

const fetchedArtifacts = rows.map((row: ArtifactRow) => this.rowToArtifact(row));

// Build a map of full UUID to Artifact for quick lookup
const fullIdToArtifactMap = new Map<string, Artifact>(
fetchedArtifacts.map((a: Artifact) => [a.artifact_id, a])
);

// Populate the result map using the originally requested IDs
for (const reqId of ids) {
let artifact: Artifact | undefined;
if (fullIds.includes(reqId)) {
artifact = fullIdToArtifactMap.get(reqId);
} else {
const fullId = shortIdToFullIdMap.get(reqId);
if (fullId) {
artifact = fullIdToArtifactMap.get(fullId);
}
}

if (artifact) {
resultMap.set(reqId, artifact);
}
}

return resultMap;
} catch (error) {
throw new RepositoryError(
`Failed to find artifacts by ids: ${error instanceof Error ? error.message : String(error)}`,
error
);
}
}

async findAll(): Promise<Artifact[]> {
try {
const rows = await select(this.db).from(artifacts).all();
Expand Down