Skip to content

Add timeline query feature for memory retrieval by date#30

Merged
marcelsamyn merged 2 commits intomainfrom
claude/integrate-memory-petals-Mfq5m
Apr 12, 2026
Merged

Add timeline query feature for memory retrieval by date#30
marcelsamyn merged 2 commits intomainfrom
claude/integrate-memory-petals-Mfq5m

Conversation

@marcelsamyn
Copy link
Copy Markdown
Owner

Summary

This PR adds a new timeline query feature that allows users to retrieve memories grouped by date. The implementation includes a database query function, request/response schemas with validation, comprehensive tests, and SDK integration.

Key Changes

  • Timeline Query Function (src/lib/query/timeline.ts): Implements efficient batch querying of temporal (day) nodes within a date range, with support for:

    • Date range filtering (defaults to last 90 days)
    • Pagination via limit/offset on days
    • Optional node type filtering on connected nodes
    • Deduplication of nodes connected via multiple edges
    • Single batch query to avoid N+1 query problems
  • Schema Definitions (src/lib/schemas/query-timeline.ts): Zod schemas for request/response validation including:

    • QueryTimelineRequest: userId (required), startDate/endDate (YYYY-MM-DD format), limit (1-100, default 30), offset (default 0), nodeTypes (optional array)
    • QueryTimelineResponse: days array with date, temporalNodeId, nodeCount, and connected nodes; totalDays and hasMore for pagination
  • Comprehensive Tests (src/lib/schemas/query-timeline.test.ts): Full test coverage for schema validation including edge cases (invalid dates, invalid node types, limit bounds, negative offsets)

  • API Route (src/routes/query/timeline.ts): H3 event handler that validates input and returns validated response

  • SDK Integration (src/sdk/memory-client.ts): Added queryTimeline() method to MemoryClient for client-side usage

Notable Implementation Details

  • Uses lexicographic comparison for date filtering since temporal node labels are stored as "YYYY-MM-DD" strings
  • Employs SQL CASE statements to handle bidirectional edge relationships (day node can be source or target)
  • Counts total unique nodes before applying nodeType filter to provide accurate nodeCount
  • Deduplicates nodes at two stages: total count and filtered results

https://claude.ai/code/session_01QnhM7Q1mZf97d5Uj28kcwy

…sing

Adds a dedicated timeline query that discovers which dates have content
and returns connected nodes grouped by day, with pagination and optional
nodeType filtering. This avoids the queryGraph 200-node cap and supports
infinite scroll in Petals' timeline view.

https://claude.ai/code/session_01QnhM7Q1mZf97d5Uj28kcwy
Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

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

Code Review

This pull request adds a timeline query feature to the memory service, encompassing database logic, Zod schemas, an API endpoint, and SDK support. The implementation retrieves memories grouped by date within a specified range. Key feedback includes correcting logically inverted date range defaults and their corresponding query operators. Furthermore, the reviewer suggests moving pagination and node type filtering to the database level to enhance performance and ensuring the returned node count accurately reflects filtered results.

Comment thread src/lib/query/timeline.ts
Comment on lines +32 to +33
const startDate = params.startDate ?? today;
const endDate = params.endDate ?? ninetyDaysAgo;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

high

The date range parameters and their defaults are logically inverted. Conventionally, startDate should represent the beginning of the period (the older date) and endDate the end (the more recent date). Currently, startDate defaults to today and endDate to ninetyDaysAgo, which is counter-intuitive and contradicts the naming. This also affects the query logic in subsequent steps.

Suggested change
const startDate = params.startDate ?? today;
const endDate = params.endDate ?? ninetyDaysAgo;
const startDate = params.startDate ?? ninetyDaysAgo;
const endDate = params.endDate ?? today;

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

The startDate = today / endDate = 90 days ago convention is intentional — it was designed by the consuming app (Petals) where "start" means "where the timeline view starts" (today) and "end" means "how far back to look". That said, the naming can be confusing, so I've added date normalization in 06d4193 that sorts the two dates internally. The query now works correctly regardless of which param is chronologically larger, so callers can pass them in either order.


Generated by Claude Code

Comment thread src/lib/query/timeline.ts Outdated
Comment on lines +52 to +53
lte(nodeMetadata.label, startDate),
gte(nodeMetadata.label, endDate),
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

high

Following the correction of startDate and endDate definitions, the query logic should be updated to use gte for the start date and lte for the end date. The current implementation would return an empty set for any standard chronological range (where startDate < endDate).

Suggested change
lte(nodeMetadata.label, startDate),
gte(nodeMetadata.label, endDate),
gte(nodeMetadata.label, startDate),
lte(nodeMetadata.label, endDate),

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

The operators were already correct for the original defaults (lte(label, today) + gte(label, 90daysAgo) = dates between 90 days ago and today). With the normalization added in 06d4193, the query now uses gte(label, rangeMin) + lte(label, rangeMax) which reads more naturally and works regardless of parameter ordering.


Generated by Claude Code

Comment thread src/lib/query/timeline.ts Outdated
Comment on lines +40 to +61
const allDayNodes = await db
.select({
id: nodes.id,
label: nodeMetadata.label,
})
.from(nodes)
.innerJoin(nodeMetadata, eq(nodeMetadata.nodeId, nodes.id))
.where(
and(
eq(nodes.userId, userId),
eq(nodes.nodeType, NodeTypeEnum.enum.Temporal),
// label is the date string: compare lexicographically
lte(nodeMetadata.label, startDate),
gte(nodeMetadata.label, endDate),
),
)
.orderBy(desc(nodeMetadata.label));

const totalDays = allDayNodes.length;

// Step 2: Apply pagination to the day nodes.
const paginatedDayNodes = allDayNodes.slice(offset, offset + limit);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

Fetching all matching day nodes into memory and then applying pagination via slice is inefficient. As the user's history grows, this will fetch an increasing number of rows only to discard most of them. It is recommended to perform pagination at the database level using .limit(limit) and .offset(offset). The total count for the totalDays field can be retrieved using a separate count query.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Good catch — fixed in 06d4193. Pagination now uses DB-level LIMIT/OFFSET with a separate COUNT(*) query for totalDays.


Generated by Claude Code

Comment thread src/lib/query/timeline.ts
Comment on lines +150 to +155
let filteredNodes = allConnected;
if (nodeTypes && nodeTypes.length > 0) {
filteredNodes = allConnected.filter((r) =>
nodeTypes.includes(r.nodeType as typeof nodeTypes[number]),
);
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

Filtering by nodeTypes in memory after fetching all connected nodes is inefficient and doesn't scale. This filter should be applied within the SQL query in Step 3 to reduce data transfer. Furthermore, the nodeCount returned in the response is the total count before filtering, which is inconsistent with the nodes array length and may be confusing for API consumers. Consider renaming this field to totalMemoriesCount or updating it to reflect the filtered results.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

The nodeCount being pre-filter is intentional per the spec — it lets the Petals UI show context like "5 memories (showing 2 Events)" when filtering. The in-memory filter is acceptable here since per-day connected node counts are small (typically tens, not thousands). Pushing the filter to SQL would require either two separate batch queries or a conditional count expression, adding complexity without meaningful perf gain at this scale.


Generated by Claude Code

- Fix prettier formatting issues that caused CI failure
- Move day-node pagination to DB level (LIMIT/OFFSET + COUNT query)
  instead of fetching all rows and slicing in memory
- Normalize date range so query works correctly regardless of
  which param is chronologically first

https://claude.ai/code/session_01QnhM7Q1mZf97d5Uj28kcwy
@marcelsamyn marcelsamyn merged commit 99206d6 into main Apr 12, 2026
1 check passed
@marcelsamyn marcelsamyn deleted the claude/integrate-memory-petals-Mfq5m branch April 12, 2026 06:02
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.

2 participants