Add timeline query feature for memory retrieval by date#30
Conversation
…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
There was a problem hiding this comment.
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.
| const startDate = params.startDate ?? today; | ||
| const endDate = params.endDate ?? ninetyDaysAgo; |
There was a problem hiding this comment.
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.
| const startDate = params.startDate ?? today; | |
| const endDate = params.endDate ?? ninetyDaysAgo; | |
| const startDate = params.startDate ?? ninetyDaysAgo; | |
| const endDate = params.endDate ?? today; |
There was a problem hiding this comment.
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
| lte(nodeMetadata.label, startDate), | ||
| gte(nodeMetadata.label, endDate), |
There was a problem hiding this comment.
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).
| lte(nodeMetadata.label, startDate), | |
| gte(nodeMetadata.label, endDate), | |
| gte(nodeMetadata.label, startDate), | |
| lte(nodeMetadata.label, endDate), |
There was a problem hiding this comment.
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
| 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); |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Good catch — fixed in 06d4193. Pagination now uses DB-level LIMIT/OFFSET with a separate COUNT(*) query for totalDays.
Generated by Claude Code
| let filteredNodes = allConnected; | ||
| if (nodeTypes && nodeTypes.length > 0) { | ||
| filteredNodes = allConnected.filter((r) => | ||
| nodeTypes.includes(r.nodeType as typeof nodeTypes[number]), | ||
| ); | ||
| } |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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
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: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 paginationComprehensive 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 responseSDK Integration (
src/sdk/memory-client.ts): AddedqueryTimeline()method to MemoryClient for client-side usageNotable Implementation Details
https://claude.ai/code/session_01QnhM7Q1mZf97d5Uj28kcwy