Skip to content

perf: optimize node DB queries with batching, indexes, and SQLite PRAGMAs#59

Merged
popemkt merged 9 commits intomainfrom
emdash/perf-optimization-6d0
Mar 8, 2026
Merged

perf: optimize node DB queries with batching, indexes, and SQLite PRAGMAs#59
popemkt merged 9 commits intomainfrom
emdash/perf-optimization-6d0

Conversation

@popemkt
Copy link
Copy Markdown
Owner

@popemkt popemkt commented Mar 8, 2026

Summary

  • Add SQLite performance PRAGMAs (synchronous=NORMAL, 64MB page cache, 256MB mmap, in-memory temp store)
  • Add compound index (node_id, field_node_id) on node_properties and partial index on non-deleted nodes
  • Introduce assembleNodes() batch assembly function — 4 queries total instead of N×(2+M+K) per node
  • Replace N+1 query patterns in query evaluator filters (content, temporal, childOf, supertag) with inArray() batch fetches
  • Convert in-memory .all().filter() / .all().find() patterns to compound SQL WHERE clauses across node.service.ts and bootstrap.ts
  • Use COUNT(*) aggregation in bootstrap summary instead of fetching all rows
  • Add supertag ID cache to SurrealDB backend
  • Batch-delete in clearProperty instead of per-row deletion

Files changed

  • master-client.ts — PRAGMAs + new indexes in DDL
  • node-schema.ts — compound index in Drizzle schema
  • node.service.tsassembleNodes(), compound WHERE clauses, batch operations
  • query-evaluator.service.ts — batch candidate fetching, direct supertag matching
  • bootstrap.ts — compound WHERE in setProperty, COUNT(*) summary
  • surreal-backend.ts — supertag resolution cache

Open with Devin

Summary by CodeRabbit

  • New Features

    • Added a batch node-assembly API for efficient multi-node operations.
    • Stricter typing for property values to improve type safety.
  • Performance Improvements

    • Tuned database initialization for better I/O, caching, and temp storage.
    • Added indices to speed common lookups.
    • Introduced server-side caching and parallelized tag resolution.
    • Reworked query evaluation to use batched/server-side filtering, eliminating many N+1 patterns.
  • Bug Fixes

    • More reliable bootstrap existence checks and efficient counts.

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Mar 8, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Enable WAL and performance PRAGMAs on the master DB, add two indices; introduce server-side supertag ID caching; implement batched, parallel node assembly and refactor query evaluators and node operations to operate on bulk candidate sets with fewer queries.

Changes

Cohort / File(s) Summary
Database init & schema
libs/nxus-db/src/client/master-client.ts, libs/nxus-db/src/schemas/node-schema.ts
Enable WAL and set PRAGMAs (synchronous = NORMAL, cache_size = -64000, mmap_size = 268435456, temp_store = MEMORY); add indices idx_node_properties_node_field and idx_nodes_not_deleted.
Surreal backend & caches
libs/nxus-db/src/services/backends/surreal-backend.ts
Add supertagIdCache (Map<string, string
Batch assembly & node service
libs/nxus-db/src/services/node.service.ts
Export assembleNodes(db, nodeIds); refactor per-node operations to batched queries using inArray/and, perform in-memory joins, and update property operations to use composite WHEREs.
Query evaluator & orchestration
libs/nxus-db/src/services/query-evaluator.service.ts, libs/nxus-db/src/services/bootstrap.ts
Refactor evaluators to use assembleNodes and batched filters, replace per-ID fetch loops with collective queries; bootstrap uses sql\count(*)`` and tighter existence/upsert checks.
Types & small edits
libs/nxus-db/src/types/node.ts, package.json
Change PropertyValue.value from any to unknown; minor manifest tweak.

Sequence Diagram

sequenceDiagram
    participant QE as QueryEvaluator
    participant NS as NodeService
    participant SB as SurrealBackend
    participant DB as Database

    QE->>NS: assembleNodes(db, candidateNodeIds)
    NS->>DB: Batch fetch nodes (inArray nodeIds)
    DB-->>NS: nodes[]
    NS->>DB: Batch fetch node_properties (inArray nodeIds)
    DB-->>NS: properties[]
    NS->>DB: Batch fetch fields/meta (inArray fieldIds)
    DB-->>NS: fields[]
    NS->>SB: Request supertag resolutions (system IDs)
    SB->>SB: consult supertagIdCache
    alt cache miss
      SB->>DB: query supertag records
      DB-->>SB: supertag records
      SB->>SB: store in cache
    end
    SB-->>NS: supertag ID mappings
    NS->>NS: In-memory join & assemble nodes
    NS-->>QE: AssembledNode[]
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Poem

🐇 I hopped through WAL and PRAGMAs with glee,

Cached tag-ids beneath a fast query tree,
I batch-gathered nodes, stitched props in a line,
Parallel hops made the assembly shine,
Thump-thump — small rabbit, big performance spree.

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'perf: optimize node DB queries with batching, indexes, and SQLite PRAGMAs' directly and accurately summarizes the main changes across the entire changeset.
Docstring Coverage ✅ Passed Docstring coverage is 95.65% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch emdash/perf-optimization-6d0

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

devin-ai-integration[bot]

This comment was marked as resolved.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (2)
libs/nxus-db/src/services/backends/surreal-backend.ts (1)

89-91: Unbounded cache may retain stale null entries.

The supertagIdCache has no size limit or TTL. For long-running processes, this could grow indefinitely. More importantly, caching null (line 160) means that if a supertag is created after an initial lookup failure, the cache will continue returning null until the process restarts.

If supertags are created dynamically at runtime, consider either:

  1. Adding a cache invalidation hook when supertags are created.
  2. Not caching null values (only cache positive lookups).

For static/bootstrap-time supertags, this is fine as-is.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@libs/nxus-db/src/services/backends/surreal-backend.ts` around lines 89 - 91,
The supertagIdCache Map in surreal-backend.ts currently stores nulls and is
unbounded; stop caching negative lookups and/or add invalidation/TTL: update
logic around where supertagIdCache is written (references to supertagIdCache and
the lookup path that sets null) to only set entries when a non-null string
RecordId is obtained, and either implement a simple eviction/size limit or
expose an invalidation hook that is called when a supertag is created/updated so
callers can delete the corresponding key from supertagIdCache; ensure no code
path relies on cached nulls after this change.
libs/nxus-db/src/services/node.service.ts (1)

933-951: Consider selective fetch for large datasets.

This pattern fetches ALL supertag property assignments (line 933-937), then filters in memory. For large databases with many nodes, this could load significant data into memory.

The tradeoff is acceptable for avoiding N+1 queries, but if performance issues arise with large datasets, consider:

  1. Using inArray with the target supertag values directly in the WHERE clause.
  2. Adding pagination or limits.

For now, this is fine — the optimization aligns with the PR's goals.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@libs/nxus-db/src/services/node.service.ts` around lines 933 - 951, The
current code loads all supertag property rows into memory (allSupertagProps from
nodeProperties where fieldNodeId = supertagField.id) and filters them in JS
against allSupertagIds, which can OOM for large datasets; change the query to
push filtering into the DB by using an IN-style condition (e.g., inArray or
equivalent) on the parsed property value or the raw stored value to match the
values in allSupertagIds, and/or add pagination/limits to the fetch, updating
the logic that builds nodeIdSet accordingly so only matching rows are returned
by the DB instead of post-filtered in memory.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@libs/nxus-db/src/services/backends/surreal-backend.ts`:
- Around line 89-91: The supertagIdCache Map in surreal-backend.ts currently
stores nulls and is unbounded; stop caching negative lookups and/or add
invalidation/TTL: update logic around where supertagIdCache is written
(references to supertagIdCache and the lookup path that sets null) to only set
entries when a non-null string RecordId is obtained, and either implement a
simple eviction/size limit or expose an invalidation hook that is called when a
supertag is created/updated so callers can delete the corresponding key from
supertagIdCache; ensure no code path relies on cached nulls after this change.

In `@libs/nxus-db/src/services/node.service.ts`:
- Around line 933-951: The current code loads all supertag property rows into
memory (allSupertagProps from nodeProperties where fieldNodeId =
supertagField.id) and filters them in JS against allSupertagIds, which can OOM
for large datasets; change the query to push filtering into the DB by using an
IN-style condition (e.g., inArray or equivalent) on the parsed property value or
the raw stored value to match the values in allSupertagIds, and/or add
pagination/limits to the fetch, updating the logic that builds nodeIdSet
accordingly so only matching rows are returned by the DB instead of
post-filtered in memory.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: a4e1851d-6ca8-4469-82be-c3db0aedf879

📥 Commits

Reviewing files that changed from the base of the PR and between 90917ec and 2894abc.

📒 Files selected for processing (6)
  • libs/nxus-db/src/client/master-client.ts
  • libs/nxus-db/src/schemas/node-schema.ts
  • libs/nxus-db/src/services/backends/surreal-backend.ts
  • libs/nxus-db/src/services/bootstrap.ts
  • libs/nxus-db/src/services/node.service.ts
  • libs/nxus-db/src/services/query-evaluator.service.ts

devin-ai-integration[bot]

This comment was marked as resolved.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
libs/nxus-db/src/services/backends/surreal-backend.ts (1)

89-90: Cache lacks invalidation; may serve stale results after supertag schema changes.

The supertagIdCache caches resolved IDs (including null for not-found) but has no invalidation mechanism. If a supertag is created or deleted at runtime, the cache will return stale data.

For schema-like entities that change infrequently, this is likely acceptable. If runtime supertag mutations become common, consider:

  • Cache invalidation on supertag create/delete events
  • TTL-based expiration
  • Exposing a clearCaches() method for tests

Also applies to: 149-161


ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: d9e2202b-7a51-4c0c-b569-09a454e3438f

📥 Commits

Reviewing files that changed from the base of the PR and between 2894abc and 2c62646.

📒 Files selected for processing (1)
  • libs/nxus-db/src/services/backends/surreal-backend.ts

- Don't cache null results in supertagIdCache to avoid permanently
  missing supertags created after a failed lookup
- Fix temporal filter 'after' operator to use strict '>' instead of
  '>=' to match SQLite evaluator behavior

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@libs/nxus-db/src/services/backends/surreal-backend.ts`:
- Around line 955-959: The code currently calls assembleNode for every id in
candidateIds concurrently before applying the limit, which can spawn thousands
of parallel Surreal reads; change the implementation so you enforce the
concurrency cap before launching work (e.g., process candidateIds in chunks of
size limit or use a concurrency-controlled mapper like p-map/promise-pool) and
for each chunk call this.assembleNode(nid) and await the batch before continuing
to the next chunk; keep the same post-processing (filtering nulls into
assembledNodes) and reference the assembleNode method, the candidateIds
collection, the assembleResults variable, and the limit parameter when making
the change.
- Around line 745-750: The current code filters out nulls from resolveResults
which breaks matchAll semantics; update the logic around
resolveResults/resolveSupertagId so that if matchAll is true and any entry in
resolveResults is null (i.e., a requested supertag is missing) you immediately
return [] instead of filtering them out, otherwise (when matchAll is false)
continue filtering nulls into supertagRecordIds as before; ensure you reference
resolveResults, supertagRecordIds, resolveSupertagId and the matchAll flag when
making the change.
- Around line 1018-1048: getNodeIdsBySupertagWithInheritance currently only
fetches direct children of targetRecordId; change the collection logic
(allSupertagIds / children query) to walk the extends relationship transitively
(BFS/DFS) until no new supertag ids are discovered. Use resolveSupertagId to
seed the queue with targetRecordId, repeatedly query 'SELECT in AS child_ref
FROM extends WHERE out = $stId' for each popped id, add rid(child.child_ref) to
allSupertagIds and enqueue newly found ids, then continue to the existing
parallel has_supertag queries using the fully populated allSupertagIds set so
grandchildren/great-grandchildren are included. Ensure you still handle empty
results and preserve use of StringRecordId and rid(...) conversions as in the
original code.
- Around line 1228-1233: The temporal query currently passes
targetDate.toISOString() as a string to db.query (see surrealField, surrealOp
and matchingNodes/db.query) which fails on SurrealDB v2 and will throw if
targetDate is invalid; validate targetDate is a valid Date (e.g., check
!isNaN(targetDate.getTime())) before serializing, and pass the parameter with an
explicit <datetime> cast in the SQL (e.g., use $targetDate::<datetime>) so the
server compares datetime types correctly.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 24822884-7132-472e-b10f-60702f6cd966

📥 Commits

Reviewing files that changed from the base of the PR and between 2c62646 and 1d29c6c.

📒 Files selected for processing (1)
  • libs/nxus-db/src/services/backends/surreal-backend.ts

Comment on lines +1228 to +1233
// Build SurQL temporal filter to push filtering to the server
const surrealField = field === 'createdAt' ? 'created_at' : 'updated_at'
const surrealOp = op === 'within' ? '>=' : op === 'after' ? '>' : '<'
const [matchingNodes] = await db.query<[Array<{ id: RecordId }>]>(
`SELECT id FROM node WHERE deleted_at IS NONE AND ${surrealField} ${surrealOp} $targetDate`,
{ targetDate: targetDate.toISOString() },
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

site:surrealdb.com SurrealDB v2 datetime strings no longer eagerly convert <datetime> cast parameters

💡 Result:

In SurrealDB / SurrealQL v2.0.0 and later, plain quoted strings are always string and are not automatically (“eagerly”) converted into datetime (even if they look like RFC 3339 timestamps). You must explicitly mark/convert them, e.g.:

  • Use a datetime literal prefix: d"2025-07-03T07:18:52Z" (parser-level) (surrealdb.com)
  • Or cast from a string: <datetime>"2025-07-03T07:18:52.841147Z" (database-level conversion; invalid input errors) (surrealdb.com)

This “no eager conversion” rule is documented explicitly for datetimes in the Datetimes page and more generally in the Strings page (v2.0 behavior change). (surrealdb.com)

Sources

Citations:


🏁 Script executed:

# First, let's check the file exists and get its size
wc -l libs/nxus-db/src/services/backends/surreal-backend.ts

Repository: popemkt/nxus

Length of output: 114


🏁 Script executed:

# Read the specific section around lines 1228-1233
sed -n '1210,1250p' libs/nxus-db/src/services/backends/surreal-backend.ts

Repository: popemkt/nxus

Length of output: 1392


🏁 Script executed:

# Let's look for the function containing this code and understand the targetDate parameter
sed -n '1180,1240p' libs/nxus-db/src/services/backends/surreal-backend.ts

Repository: popemkt/nxus

Length of output: 2008


Add <datetime> cast to $targetDate parameter and validate date before serializing.

SurrealDB v2 does not eagerly convert strings to datetime types. The current code passes targetDate.toISOString() as a string parameter without explicit casting, which will cause type mismatch errors when comparing against datetime fields.

Additionally, calling .toISOString() on an invalid Date object (which can occur when new Date(date) receives an invalid date string) will throw a runtime error. Add validation to ensure targetDate is a valid date before calling .toISOString().

💡 Suggested fix
    const [matchingNodes] = await db.query<[Array<{ id: RecordId }>]>(
-    `SELECT id FROM node WHERE deleted_at IS NONE AND ${surrealField} ${surrealOp} $targetDate`,
+    `SELECT id FROM node WHERE deleted_at IS NONE AND ${surrealField} ${surrealOp} <datetime>$targetDate`,
      { targetDate: targetDate.toISOString() },
    )

Also add validation before the toISOString() call to handle invalid dates.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@libs/nxus-db/src/services/backends/surreal-backend.ts` around lines 1228 -
1233, The temporal query currently passes targetDate.toISOString() as a string
to db.query (see surrealField, surrealOp and matchingNodes/db.query) which fails
on SurrealDB v2 and will throw if targetDate is invalid; validate targetDate is
a valid Date (e.g., check !isNaN(targetDate.getTime())) before serializing, and
pass the parameter with an explicit <datetime> cast in the SQL (e.g., use
$targetDate::<datetime>) so the server compares datetime types correctly.

- Change PropertyValue.value from `any` to `unknown`
- Use canonical filter types (SupertagFilter, PropertyFilter, etc.)
  in surreal-backend instead of loose inline structural types
- Widen resolveFieldId to accept `string`, removing unsafe
  `as FieldSystemId` casts
- Use FilterOp literal union in compareValues instead of `string`
- Remove unnecessary `as string` casts on FieldSystemId (branded
  type already extends string)
- Replace non-null assertions with safe Map access patterns
- Add type predicate to getNodeSupertagSystemIds filter

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Copy link
Copy Markdown

@devin-ai-integration devin-ai-integration bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 1 new potential issue.

View 14 additional findings in Devin Review.

Open in Devin Review

Comment on lines +169 to +170
if (!results || results.length === 0) {
return null
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 supertagIdCache never caches null (negative) lookups, defeating cache purpose

The newly introduced supertagIdCache (typed Map<string, string | null> at surreal-backend.ts:100) is designed to cache both positive and negative lookup results—the has()-based lookup pattern at line 159-160 correctly handles cached null values. However, when resolveSupertagId finds no matching supertag (lines 169-170), it returns null without storing it in the cache. This means every call for a non-existent supertag system ID will always hit the database, which undermines the caching optimization this PR introduces. This is especially impactful since resolveSupertagId is called in hot paths like evaluateSupertagFilter, getNodesBySupertags, and getNodeIdsBySupertagWithInheritance—all of which may repeatedly query the same non-existent supertag IDs.

Suggested change
if (!results || results.length === 0) {
return null
if (!results || results.length === 0) {
this.supertagIdCache.set(supertagSystemId, null)
return null
}
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
libs/nxus-db/src/services/bootstrap.ts (1)

25-36: Consider exporting a cache-clearing function for test isolation.

The systemNodeIds cache persists across calls but has no clear function. Unlike clearSystemNodeCache() in node.service.ts, tests that reset the database cannot clear this cache, potentially causing stale UUID references.

♻️ Suggested addition
 // UUID cache for system nodes (systemId -> UUID)
 const systemNodeIds = new Map<string, string>();

+/**
+ * Clear the system node ID cache (call between test runs)
+ */
+export function clearBootstrapCache(): void {
+  systemNodeIds.clear();
+}
+
 /**
  * Get or create UUID for a system node
  */
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@libs/nxus-db/src/services/bootstrap.ts` around lines 25 - 36, Add a
cache-clear helper to allow tests to reset the in-memory map used by
getSystemNodeId: export a function (e.g., clearSystemNodeIdsCache or
resetSystemNodeIds) that calls systemNodeIds.clear(); update exports to expose
it so test suites can call it for isolation; keep the existing getSystemNodeId
and systemNodeIds names unchanged so callers and references remain valid.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@libs/nxus-db/src/services/bootstrap.ts`:
- Around line 25-36: Add a cache-clear helper to allow tests to reset the
in-memory map used by getSystemNodeId: export a function (e.g.,
clearSystemNodeIdsCache or resetSystemNodeIds) that calls systemNodeIds.clear();
update exports to expose it so test suites can call it for isolation; keep the
existing getSystemNodeId and systemNodeIds names unchanged so callers and
references remain valid.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 9b0b649a-4109-43f4-b189-3af44c5bbd38

📥 Commits

Reviewing files that changed from the base of the PR and between 1d29c6c and f4d061a.

📒 Files selected for processing (4)
  • libs/nxus-db/src/services/backends/surreal-backend.ts
  • libs/nxus-db/src/services/bootstrap.ts
  • libs/nxus-db/src/services/node.service.ts
  • libs/nxus-db/src/types/node.ts

- Return [] early when matchAll=true and any supertag is missing
- Walk extends relationship transitively (BFS) instead of single level
  for correct deep inheritance queries
- Apply limit before assembly to avoid assembling thousands of nodes
- Add <datetime> cast to temporal filter for SurrealDB v2 compatibility

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Copy link
Copy Markdown

@devin-ai-integration devin-ai-integration bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 1 new potential issue.

View 16 additional findings in Devin Review.

Open in Devin Review

Comment on lines +969 to 982
// Apply limit before assembly to avoid assembling thousands of nodes
const limit = definition.limit ?? 500
const idsToAssemble = [...candidateIds].slice(0, limit)

// Assemble nodes in parallel (capped by limit)
const assembleResults = await Promise.all(
idsToAssemble.map((nid) => this.assembleNode(nid)),
)
let assembledNodes = assembleResults.filter((n): n is AssembledNode => n !== null)

// Apply sorting
if (definition.sort) {
assembledNodes = this.sortNodes(assembledNodes, definition.sort)
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 SurrealBackend.evaluateQuery applies limit before sort, returning incorrectly ordered results

In evaluateQuery, the limit is applied at line 971 (idsToAssemble = [...candidateIds].slice(0, limit)) before sorting happens at line 980-982. This means when a query has both sort and limit, an arbitrary subset of limit nodes is assembled and then sorted — instead of sorting all candidates first and returning the top N. This produces incorrect results: e.g., a query for "latest 10 items by createdAt desc" would sort a random 500 items rather than returning the actual top 10.

The SQLite counterpart in query-evaluator.service.ts:96-107 correctly assembles all candidates, sorts them, and only then applies the limit.

Suggested change
// Apply limit before assembly to avoid assembling thousands of nodes
const limit = definition.limit ?? 500
const idsToAssemble = [...candidateIds].slice(0, limit)
// Assemble nodes in parallel (capped by limit)
const assembleResults = await Promise.all(
idsToAssemble.map((nid) => this.assembleNode(nid)),
)
let assembledNodes = assembleResults.filter((n): n is AssembledNode => n !== null)
// Apply sorting
if (definition.sort) {
assembledNodes = this.sortNodes(assembledNodes, definition.sort)
}
// Assemble nodes in parallel (filter out deleted)
const assembleResults = await Promise.all(
[...candidateIds].map((nid) => this.assembleNode(nid)),
)
let assembledNodes = assembleResults.filter((n): n is AssembledNode => n !== null)
// Apply sorting before limit so we get the correct top-N results
if (definition.sort) {
assembledNodes = this.sortNodes(assembledNodes, definition.sort)
}
// Apply limit after sorting
const limit = definition.limit ?? 500
if (assembledNodes.length > limit) {
assembledNodes = assembledNodes.slice(0, limit)
}
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
libs/nxus-db/src/services/backends/surreal-backend.ts (1)

1237-1256: ⚠️ Potential issue | 🟠 Major

Validate temporal filter dates before serializing them.

new Date(date) can produce Invalid Date, and the next toISOString() will throw. This filter comes from external query input, so it needs an explicit validity check before the Surreal query.

💡 Minimal guard
     case 'before':
     case 'after':
       if (!date) return candidateIds
       targetDate = new Date(date)
+      if (Number.isNaN(targetDate.getTime())) {
+        throw new Error(`Invalid temporal filter date: ${String(date)}`)
+      }
       break

As per coding guidelines, "Validate external inputs using Zod schemas before processing" and "Prioritize handling edge cases including errors, empty states, and loading states".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@libs/nxus-db/src/services/backends/surreal-backend.ts` around lines 1237 -
1256, The temporal filter may create an invalid Date (targetDate) from external
input; before calling targetDate.toISOString() or running db.query in the
function handling op/days/date, validate targetDate (e.g., after assigning
targetDate, check Number.isFinite(targetDate.getTime()) or
!isNaN(targetDate.getTime())) and if invalid return candidateIds (or bail out
consistently) instead of proceeding; update the block that sets targetDate
(cases 'within'/'before'/'after') to perform this validity check right before
building surrealField/surrealOp and invoking db.query (symbols: op, days, date,
targetDate, surrealField, surrealOp, db.query, matchingNodes).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@libs/nxus-db/src/services/backends/surreal-backend.ts`:
- Around line 801-805: The current code calls assembleNode for every id in
matchingNodeIds via Promise.all, which can spawn thousands of concurrent Surreal
reads; change both occurrences (the assembleResults = await Promise.all(...)
blocks around matchingNodeIds) to use a concurrency-limited approach: either
process matchingNodeIds in fixed-size chunks and run Promise.all per chunk, or
use a small concurrency queue/limiter (e.g., p-limit or an in-repo helper) to
call this. Ensure you still await all tasks, collect results, and then filter
out nulls as before (i.e., keep the final filter for AssembledNode). Use the
existing assembleNode function as the task and replace direct
Promise.all(matchingNodeIds.map(...)) with the limiter/chunked execution in both
places mentioned.
- Around line 969-977: The current code slices candidateIds to limit before
assembling, which breaks correct ordering when definition.sort is present;
change the logic in the method that builds assembleResults/assembledNodes so
that when definition.sort exists you assemble (or at least fetch sort keys for)
all candidateIds, then apply the sort comparator based on definition.sort to
assembledNodes and only after sorting apply the limit (definition.limit ?? 500);
for the no-sort case you may keep the existing early slice optimization, and
ensure you still filter nulls from assembleNode results into assembledNodes
before sorting/trimming.

---

Outside diff comments:
In `@libs/nxus-db/src/services/backends/surreal-backend.ts`:
- Around line 1237-1256: The temporal filter may create an invalid Date
(targetDate) from external input; before calling targetDate.toISOString() or
running db.query in the function handling op/days/date, validate targetDate
(e.g., after assigning targetDate, check Number.isFinite(targetDate.getTime())
or !isNaN(targetDate.getTime())) and if invalid return candidateIds (or bail out
consistently) instead of proceeding; update the block that sets targetDate
(cases 'within'/'before'/'after') to perform this validity check right before
building surrealField/surrealOp and invoking db.query (symbols: op, days, date,
targetDate, surrealField, surrealOp, db.query, matchingNodes).

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: bf227c9d-cb41-49e7-b09c-5f30f76b8d1e

📥 Commits

Reviewing files that changed from the base of the PR and between f4d061a and 71d59ec.

📒 Files selected for processing (1)
  • libs/nxus-db/src/services/backends/surreal-backend.ts

Comment on lines +801 to +805
// Assemble nodes in parallel (filter out deleted)
const assembleResults = await Promise.all(
matchingNodeIds.map((nid) => this.assembleNode(nid)),
)
return assembleResults.filter((n): n is AssembledNode => n !== null)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Cap parallel assembly in the supertag lookup paths.

Both methods now fire one assembleNode() per match via Promise.all(). On broad supertags, that can become thousands of concurrent Surreal reads and undo the perf gains from the rest of this PR. Please chunk these IDs or funnel them through a concurrency-limited helper before assembling.

Also applies to: 819-822

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@libs/nxus-db/src/services/backends/surreal-backend.ts` around lines 801 -
805, The current code calls assembleNode for every id in matchingNodeIds via
Promise.all, which can spawn thousands of concurrent Surreal reads; change both
occurrences (the assembleResults = await Promise.all(...) blocks around
matchingNodeIds) to use a concurrency-limited approach: either process
matchingNodeIds in fixed-size chunks and run Promise.all per chunk, or use a
small concurrency queue/limiter (e.g., p-limit or an in-repo helper) to call
this. Ensure you still await all tasks, collect results, and then filter out
nulls as before (i.e., keep the final filter for AssembledNode). Use the
existing assembleNode function as the task and replace direct
Promise.all(matchingNodeIds.map(...)) with the limiter/chunked execution in both
places mentioned.

Comment on lines +969 to +977
// Apply limit before assembly to avoid assembling thousands of nodes
const limit = definition.limit ?? 500
const idsToAssemble = [...candidateIds].slice(0, limit)

// Assemble nodes in parallel (capped by limit)
const assembleResults = await Promise.all(
idsToAssemble.map((nid) => this.assembleNode(nid)),
)
let assembledNodes = assembleResults.filter((n): n is AssembledNode => n !== null)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Preserve sort semantics before trimming results.

Applying limit before assembly is only safe when no sort is requested. With definition.sort, this slices an arbitrary subset of candidateIds first and then sorts only that subset, so limit + sort can miss the true top N rows.

💡 Safe fallback
 const totalCount = candidateIds.size

 // Apply limit before assembly to avoid assembling thousands of nodes
 const limit = definition.limit ?? 500
-const idsToAssemble = [...candidateIds].slice(0, limit)
+const idsToAssemble = definition.sort
+  ? [...candidateIds]
+  : [...candidateIds].slice(0, limit)

 // Assemble nodes in parallel (capped by limit)
 const assembleResults = await Promise.all(
   idsToAssemble.map((nid) => this.assembleNode(nid)),
 )
 let assembledNodes = assembleResults.filter((n): n is AssembledNode => n !== null)

 // Apply sorting
 if (definition.sort) {
   assembledNodes = this.sortNodes(assembledNodes, definition.sort)
 }
+if (definition.limit !== undefined) {
+  assembledNodes = assembledNodes.slice(0, limit)
+}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Apply limit before assembly to avoid assembling thousands of nodes
const limit = definition.limit ?? 500
const idsToAssemble = [...candidateIds].slice(0, limit)
// Assemble nodes in parallel (capped by limit)
const assembleResults = await Promise.all(
idsToAssemble.map((nid) => this.assembleNode(nid)),
)
let assembledNodes = assembleResults.filter((n): n is AssembledNode => n !== null)
const totalCount = candidateIds.size
// Apply limit before assembly to avoid assembling thousands of nodes
const limit = definition.limit ?? 500
const idsToAssemble = definition.sort
? [...candidateIds]
: [...candidateIds].slice(0, limit)
// Assemble nodes in parallel (capped by limit)
const assembleResults = await Promise.all(
idsToAssemble.map((nid) => this.assembleNode(nid)),
)
let assembledNodes = assembleResults.filter((n): n is AssembledNode => n !== null)
// Apply sorting
if (definition.sort) {
assembledNodes = this.sortNodes(assembledNodes, definition.sort)
}
if (definition.limit !== undefined) {
assembledNodes = assembledNodes.slice(0, limit)
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@libs/nxus-db/src/services/backends/surreal-backend.ts` around lines 969 -
977, The current code slices candidateIds to limit before assembling, which
breaks correct ordering when definition.sort is present; change the logic in the
method that builds assembleResults/assembledNodes so that when definition.sort
exists you assemble (or at least fetch sort keys for) all candidateIds, then
apply the sort comparator based on definition.sort to assembledNodes and only
after sorting apply the limit (definition.limit ?? 500); for the no-sort case
you may keep the existing early slice optimization, and ensure you still filter
nulls from assembleNode results into assembledNodes before sorting/trimming.

popemkt and others added 2 commits March 8, 2026 19:04
Theme flash fixes:
- Add useThemeHydrated() hook to wait for Zustand persist hydration
  before applying theme classes (prevents stripping inline script's
  correct classes with store defaults)
- ThemeChooser returns null until store has hydrated, preventing
  wrong selection flash
- Gateway visual: defer localStorage read to useEffect to avoid
  SSR hydration mismatch

Dev port fixes:
- Add server.strictPort: true to all 5 vite.config.ts files so
  Vite errors instead of silently picking random ports
- Add server.port to each config (3000-3004) as source of truth
- Add predev script to kill stale processes on ports 3000-3004

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Move theme application from useEffect to module scope across all 5
apps. This runs synchronously before React renders, preventing the
flash caused by useEffect stripping classes set by the inline script.

Three-layer approach (same as Automaker):
1. Inline <script> in <head> — applies classes during HTML parsing
2. Module-scope applyStoredTheme() — catches SPA navigations
3. useEffect — only handles cross-tab sync via storage events

For nxus-core, the Zustand-based useEffect is kept (gated on
hydration) for reactive theme changes from the UI.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Copy link
Copy Markdown

@devin-ai-integration devin-ai-integration bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 1 new potential issue.

View 22 additional findings in Devin Review.

Open in Devin Review

Comment on lines 48 to 49
'brutalism',
]
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Non-core apps missing 3 theme palettes causes CSS class leaking

The ALL_PALETTES arrays and ThemePalette types in nxus-calendar, nxus-gateway, nxus-recall, and nxus-workbench are missing the gothic, cyberpunk, and bauhaus themes that exist in the core app's ThemePalette type (apps/nxus-core/src/stores/theme.store.ts:29-31) and themeOptions (apps/nxus-core/src/config/theme-options.ts:153-170). When a user selects one of these themes in nxus-core, the applyStoredTheme() function in the other apps will correctly add the class (since palette !== 'default'), but will never remove it because ALL_PALETTES.forEach((p) => root.classList.remove(p)) doesn't include those values. Switching away from gothic/cyberpunk/bauhaus in the core app will leave the stale CSS class on <html> in all other mini-apps.

(Refers to lines 29-49)

Prompt for agents
Add the missing theme palettes 'gothic', 'cyberpunk', and 'bauhaus' to the ThemePalette type union and ALL_PALETTES array in all four non-core apps:

1. apps/nxus-calendar/src/routes/__root.tsx - Add to both ThemePalette type (after 'brutalism') and ALL_PALETTES array (after 'brutalism')
2. apps/nxus-gateway/src/routes/__root.tsx - Same changes
3. apps/nxus-recall/src/routes/__root.tsx - Same changes
4. apps/nxus-workbench/src/routes/__root.tsx - Same changes

For each file, add these three entries to the ThemePalette type:
  | 'gothic'
  | 'cyberpunk'
  | 'bauhaus'

And add these three entries to the ALL_PALETTES array:
  'gothic',
  'cyberpunk',
  'bauhaus',

This ensures the applyStoredTheme() function properly cleans up CSS classes for all themes the core app can set.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

popemkt and others added 2 commits March 8, 2026 20:33
- Make useThemeHydrated() SSR-safe with optional chaining (fixes 500 error)
- Use module-scope initialVisual to avoid gateway visual flash on load

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@popemkt popemkt merged commit fbb0e14 into main Mar 8, 2026
5 checks passed
@popemkt popemkt deleted the emdash/perf-optimization-6d0 branch March 8, 2026 14:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant