feat: [ENG-1889] Implement FileQueryLogStore with Zod validation#354
feat: [ENG-1889] Implement FileQueryLogStore with Zod validation#354bao-byterover merged 2 commits intoproj/query-logfrom
Conversation
|
Claude finished @bao-byterover's task in 3m 49s —— View job PR Review: feat: [ENG-1889] Implement FileQueryLogStore with Zod validation
SummaryOverall score: 4 / 5 The implementation is solid. Atomic writes (tmp → rename), Zod-validated reads, silent-null on corruption, and path-traversal prevention via Issues requiring attention
Suggestions / nitpicks
|
| durationMs: z.number(), | ||
| }) | ||
|
|
||
| const QueryLogTierSchema = z.union([z.literal(0), z.literal(1), z.literal(2), z.literal(3), z.literal(4)]) |
There was a problem hiding this comment.
issue (correctness): QueryLogTierSchema duplicates the domain QUERY_LOG_TIERS array and will drift
QUERY_LOG_TIERS in query-log-entry.ts is declared as the single source of truth. This hardcoded union will silently become stale when a tier is added or removed.
Derive the schema from the imported const instead:
| const QueryLogTierSchema = z.union([z.literal(0), z.literal(1), z.literal(2), z.literal(3), z.literal(4)]) | |
| const QueryLogTierSchema = z.union( | |
| QUERY_LOG_TIERS.map((t) => z.literal(t)) as [ | |
| z.ZodLiteral<0>, | |
| z.ZodLiteral<1>, | |
| z.ZodLiteral<2>, | |
| z.ZodLiteral<3>, | |
| z.ZodLiteral<4>, | |
| ], | |
| ) |
You'd also need to add QUERY_LOG_TIERS to the import from the entity.
| return [] | ||
| } | ||
|
|
||
| const hasFilters = Boolean(status?.length || tier?.length || after !== undefined || before !== undefined) |
There was a problem hiding this comment.
issue (correctness): Limit is not applied when filters are present, causing full scan even for limit: 1
When any filter is active, filesToRead = files (all files on disk). A query like list({ status: ['processing'], limit: 1 }) still reads every entry. For a long-running daemon with thousands of log entries this becomes very slow.
A partial improvement: if limit and only time-range filters (after/before) are present, you can still pre-slice by filename timestamp before I/O. For status/tier filters a full scan is unavoidable, but capping reads at Math.min(files.length, MAX_SAFE_SCAN) would bound the worst case.
At minimum, the behaviour should be documented in the interface so callers know that filtered calls are always O(n).
| throw error | ||
| } | ||
|
|
||
| this.pruneOldest().catch(() => {}) |
There was a problem hiding this comment.
suggestion: prune fire-and-forget could be debounced / guarded
pruneOldest is a costly readdir + stat of every log file. Calling it after every save means concurrent writes each spawn a separate prune pass. Under high write rates (e.g. agentic query burst) this creates redundant I/O.
Consider: guard with an in-flight flag, or only prune when the count crosses a threshold (e.g. files.length > maxEntries * 1.2) to avoid prune-on-every-write once the store is stable.
| status: 'error', | ||
| } | ||
|
|
||
| this.save(recovered).catch(() => {}) |
There was a problem hiding this comment.
issue (correctness): resolveStale calling this.save() cascades into pruneOldest on every stale read
resolveStale → this.save(recovered) → pruneOldest() (fire-and-forget). If a consumer calls list() and 20 entries are stale, that triggers 20 concurrent prune passes. The prune itself already fires from save; the stale-recovery path doesn't need the prune overhead.
Write the recovered entry directly (or extract a saveRaw helper that skips the prune) to keep the side-effects of a read minimal:
// instead of this.save(recovered).catch(() => {})
writeFile(this.entryPath(recovered.id), JSON.stringify(recovered, null, 2), 'utf8').catch(() => {})| * Generate the next monotonic log entry ID in the format `qry-{timestamp_ms}`. | ||
| * Guaranteed to increase even if called multiple times in the same millisecond. | ||
| */ | ||
| async getNextId(): Promise<string> { |
There was a problem hiding this comment.
nitpick: getNextId monotonicity is instance-scoped
lastTimestamp lives on the instance, so two FileQueryLogStore instances pointing at the same baseDir could race and emit the same ID in the same millisecond. In practice this is unlikely (single daemon, single store), but the class's doc comment should note this assumption so it isn't misused in tests or refactors.
| // Use IDs with old timestamps to match age-based pruning (which checks filename timestamp) | ||
| const oldTs = Date.now() - 10 * 86_400_000 | ||
| const recentTs = Date.now() - 1 * 86_400_000 | ||
| const idOld = `qry-${oldTs}` |
There was a problem hiding this comment.
thought: directly constructing IDs couples the test to the filename encoding
The age-pruning test sidesteps getNextId and builds IDs from raw timestamps (qry-${oldTs}). This works because pruning parses the timestamp back out of the filename. If the ID format ever changes (e.g. a prefix change or format bump) this test would silently stop exercising age pruning while still passing.
A @internal comment explaining why direct construction is intentional here (to control filename timestamps for age-testing) would help future readers distinguish this from accidental bypass of getNextId.
|
|
||
| afterEach(async () => { | ||
| // Allow async fire-and-forget writes (resolveStale, prune) to settle | ||
| await new Promise((resolve) => { |
There was a problem hiding this comment.
nitpick: 50 ms settle delay is fragile under CI load
The setTimeout(resolve, 50) waits for fire-and-forget async writes (stale recovery, prune) to complete before rm. Under high CPU contention on CI this can be too short, causing flaky ENOENT errors on cleanup or stale files being deleted before assertions run in the next test.
Consider 100 ms here, or — better — inject a clock/prune hook in tests that need to observe side-effects deterministically (see the stale processing recovery suite which does its own 50 ms wait inside the test).
| }) | ||
|
|
||
| // Test 19 | ||
| it('should apply count limit after age-based pruning', async () => { |
There was a problem hiding this comment.
issue (correctness): pruning test assertion order is inverted relative to startedAt
saveEntries with (i) => ({startedAt: now - i * 1000}) makes ids[0] the newest by startedAt (now) but the oldest by ID/filename (now - 0 → smallest ID). Pruning removes by filename order (oldest first), so ids[0] and ids[1] are pruned — which is what the test asserts.
However, this is counterintuitive: the entry with the highest startedAt value gets deleted. A comment explaining that count-based pruning is filename-order (not startedAt-order) would prevent future maintainers from accidentally "fixing" the assertion order.
|
Claude finished @bao-byterover's task in 2m 32s —— View job PR Review: feat: [ENG-1889] Implement FileQueryLogStore with Zod validation
SummaryOverall score: 5 / 5 All three issues and all five suggestions from the first review have been addressed cleanly in this commit. The implementation is production-ready. What was fixed
Remaining nitpicks (non-blocking)
|
| const QueryLogTierSchema = z.custom<QueryLogTier>( | ||
| (val) => QUERY_LOG_TIER_SET.has(val), | ||
| {message: 'Invalid query log tier'}, | ||
| ) |
There was a problem hiding this comment.
suggestion: z.custom loses Zod's built-in enum features
The z.custom<QueryLogTier> approach is functionally correct and solves the drift problem, but the explicit <QueryLogTier> type parameter is an assertion (a claim), not a proof — Zod can't verify the return type of the custom predicate matches QueryLogTier. If, say, QUERY_LOG_TIERS were widened to include a string tier in the future, the schema would silently fail to narrow correctly.
The previous suggestion's shape — z.union(QUERY_LOG_TIERS.map(t => z.literal(t))) — is structurally safer because the return type is inferred rather than asserted, and Zod can enumerate valid values in error messages. The cast dance (as [z.ZodLiteral<0>, ...]) is the awkward part; an alternative that avoids it:
const QueryLogTierSchema = z.union(
QUERY_LOG_TIERS.map((t) => z.literal(t)) as [
ReturnType<typeof z.literal<(typeof QUERY_LOG_TIERS)[number]>>,
...ReturnType<typeof z.literal<(typeof QUERY_LOG_TIERS)[number]>>[]
]
)Or simply use z.enum with a string cast if tiers were strings. For numeric literals z.union + z.literal is the idiomatic Zod pattern. That said, the current approach works correctly for this codebase — just noting the trade-off.
| * List entries sorted newest-first (by timestamp embedded in filename). | ||
| * Filters (status, tier, after, before) are applied before limit. | ||
| * Reads stop early once `limit` matches are found, so filtered queries with small limits | ||
| * are O(matches) rather than O(total entries). Skips corrupt entries silently. |
There was a problem hiding this comment.
nitpick: docstring overstates the O(matches) claim
Reads stop early once
limitmatches are found, so filtered queries with small limits are O(matches) rather than O(total entries).
This is only true when there are enough matching entries near the head of the file list. In the worst case — list({status: ['error'], limit: 1}) with zero error entries — every file is still read. The actual bound is O(files scanned until limit matching entries are found), which degrades to O(n) when filters are selective.
Suggested rewrite:
| * are O(matches) rather than O(total entries). Skips corrupt entries silently. | |
| * Reads stop as soon as `limit` matching entries are found, so filtered queries terminate | |
| * early when matches appear near the newest files. Worst-case is still O(total entries) | |
| * when filters are selective or matches are sparse. Skips corrupt entries silently. |
* feat: [ENG-1897] Create brv query-log view oclif command (#347) * feat: [ENG-1897] Create brv query-log view oclif command * feat: [ENG-1897] fix review * feat: [ENG-1896] Implement QueryLogUseCase with list and detail views (#349) * feat: [ENG-1896] Implement QueryLogUseCase with list and detail views * feat: [ENG-1896] Fix review * feat: [ENG-1888] Define IQueryLogStore interface (#351) * feat: [ENG-1887] Define QueryLogEntry entity with discriminated union (#353) * feat: [ENG-1889] Implement FileQueryLogStore with Zod validation (#354) * feat: [ENG-1889] Implement FileQueryLogStore with Zod validation * feat: [ENG-1889] fix review * feat: [ENG-1899] Create brv query-log summary oclif command (#357) * feat: [ENG-1899] Create brv query-log summary oclif command * feat: [ENG-1899] fix review * feat: [ENG-1898] Implement QueryLogSummaryUseCase (#368) * feat: [ENG-1892] Add QueryExecutorResult type with tier and timing metadata (#369) * feat: [ENG-1893] Wire QueryLogHandler into daemon lifecycle (#370) * feat: [ENG-1894] Wire QueryLogHandler into daemon lifecycle (#371) * feat: [ENG-1894] enhance message output * feat: [proj/query-log] fix review * feat: [ENG-2123] brv curate view / brv query-log view truncate long c… (#424) * feat: [ENG-2123] brv curate view / brv query-log view truncate long content * feat: [ENG-2123] fix review * feat: [ENG-2177] Increase Storage Limit for Curate Log and Query Log (#430) * feat: [ENG-2177] Increase Storage Limit for Curate Log and Query Log * feat: [ENG-2177] fix review * feat: [ENG-2177] fix slow test --------- Co-authored-by: Hoang Pham <lehoangpham1092@gmail.com>
No description provided.