SQLite cache bootstrap + single-issue mirror upsert (#3)
A rebuildable local mirror of the reconciled backlog — an index over gitea's durable truth, never the source of truth (D4). This lands the core seam: - `cache/cache-v0.ts`: the 5-table schema (issues/labels/milestones/comments/ issue_events), a `CacheDriver` injected-IO interface (exec/run/get/all), and pure row<->domain mappers. `upsertIssue` mirrors one issue (+ its milestone), upsert-by-number so a re-reconcile updates in place; `readIssue` re-derives `facts` from the stored label set via extractLabelFacts, so the mirror can't drift from the label semantics. Facts are never stored. - Resolves #3's open scope: pure mappers + SQL in core (driver-agnostic), native driver in main. Tests bind node:sqlite (present in the Node 24 test runtime) to the same CacheDriver seam better-sqlite3 will fill in main. Acceptance met: upsert one reconciled issue, read back, assert estimateDays/priority/hardDeadline (+ upsert-in-place, no-milestone, and miss cases). Core suite green; typecheck clean. Follow-up (noted in the plan): the main-process better-sqlite3 adapter + snapshot-store migration — packaging-sensitive (native module), kept out of this slice so the shippable .dmg stays verified. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
92
packages/core/src/cache/cache-v0.test.ts
vendored
Normal file
92
packages/core/src/cache/cache-v0.test.ts
vendored
Normal file
@@ -0,0 +1,92 @@
|
||||
import { DatabaseSync } from 'node:sqlite'
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { GiteaIssue } from '../gitea/types.js'
|
||||
import { extractLabelFacts } from '../labels/label-schema.js'
|
||||
import { type CacheDriver, initCache, readIssue, upsertIssue } from './cache-v0.js'
|
||||
|
||||
/** Adapt node:sqlite's DatabaseSync to the CacheDriver seam (main uses better-sqlite3). */
|
||||
function memoryDriver(): CacheDriver {
|
||||
const db = new DatabaseSync(':memory:')
|
||||
return {
|
||||
exec: (sql) => db.exec(sql),
|
||||
run: (sql, params = []) => {
|
||||
db.prepare(sql).run(...(params as never[]))
|
||||
},
|
||||
get: (sql, params = []) => db.prepare(sql).get(...(params as never[])) as Record<string, unknown> | undefined,
|
||||
all: (sql, params = []) => db.prepare(sql).all(...(params as never[])) as Record<string, unknown>[],
|
||||
}
|
||||
}
|
||||
|
||||
function issue(over: Partial<GiteaIssue> = {}): GiteaIssue {
|
||||
const labels = over.labels ?? ['est/5d', 'p/1', 'deadline/hard']
|
||||
return {
|
||||
number: 42,
|
||||
title: 'Monte Carlo engine',
|
||||
body: 'percentile bands',
|
||||
state: 'open',
|
||||
labels,
|
||||
facts: extractLabelFacts(labels),
|
||||
milestone: { id: 7, title: 'P2 — Scheduler', dueOn: '2026-09-01T00:00:00Z' },
|
||||
assignee: 'christian',
|
||||
assignees: ['christian'],
|
||||
createdAt: '2026-07-08T00:00:00Z',
|
||||
updatedAt: '2026-07-08T01:00:00Z',
|
||||
closedAt: null,
|
||||
url: 'https://gitea/christian/commitea/issues/42',
|
||||
...over,
|
||||
}
|
||||
}
|
||||
|
||||
describe('cache-v0', () => {
|
||||
it('mirrors one issue and reads its facts back through extractLabelFacts (acceptance)', () => {
|
||||
const d = memoryDriver()
|
||||
initCache(d)
|
||||
upsertIssue(d, issue({ labels: ['est/5d', 'p/1', 'deadline/hard'] }))
|
||||
|
||||
const back = readIssue(d, 42)!
|
||||
expect(back.labels).toEqual(['est/5d', 'p/1', 'deadline/hard'])
|
||||
// facts are re-derived on read, not stored
|
||||
expect(back.facts.estimateDays).toBe(5)
|
||||
expect(back.facts.priority).toBe(1)
|
||||
expect(back.facts.hardDeadline).toBe(true)
|
||||
// the rest of the domain shape round-trips
|
||||
expect(back.milestone).toEqual({ id: 7, title: 'P2 — Scheduler', dueOn: '2026-09-01T00:00:00Z' })
|
||||
expect(back.assignee).toBe('christian')
|
||||
expect(back.state).toBe('open')
|
||||
})
|
||||
|
||||
it('re-derives facts from the current labels after a re-reconcile (upsert in place, no dup)', () => {
|
||||
const d = memoryDriver()
|
||||
initCache(d)
|
||||
upsertIssue(d, issue({ labels: ['est/2d', 'p/3'] }))
|
||||
// reconcile again with changed labels + closed
|
||||
upsertIssue(d, issue({ labels: ['est/8d', 'p/1'], state: 'closed', closedAt: '2026-07-09T00:00:00Z' }))
|
||||
|
||||
expect(d.all('SELECT number FROM issues')).toHaveLength(1) // upsert by number, not a second row
|
||||
const back = readIssue(d, 42)!
|
||||
expect(back.facts.estimateDays).toBe(8)
|
||||
expect(back.facts.priority).toBe(1)
|
||||
expect(back.facts.hardDeadline).toBe(false) // deadline/hard dropped
|
||||
expect(back.state).toBe('closed')
|
||||
expect(back.closedAt).toBe('2026-07-09T00:00:00Z')
|
||||
})
|
||||
|
||||
it('reads an issue with no milestone / empty labels', () => {
|
||||
const d = memoryDriver()
|
||||
initCache(d)
|
||||
upsertIssue(d, issue({ number: 9, labels: [], milestone: null, assignee: null, assignees: [] }))
|
||||
const back = readIssue(d, 9)!
|
||||
expect(back.milestone).toBeNull()
|
||||
expect(back.labels).toEqual([])
|
||||
expect(back.facts.estimateDays).toBeNull()
|
||||
expect(back.assignee).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null for an uncached issue', () => {
|
||||
const d = memoryDriver()
|
||||
initCache(d)
|
||||
expect(readIssue(d, 999)).toBeNull()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user