The renderer no longer holds the whole history. Startup sends queued changes, lets Main reconcile leftover pending/running records against the stored tasks (same localized interruptedOnRestart line), then loads the first page (200). The Activity page loads a full visible batch (500, as before paging); "load more" shows the next batch and loads the next page behind it; a filter switch refetches that filter's first page; leaving the page trims to one batch. Counts, project/conversation groups, titles, conversation status and the clear count come from activityHistory.summary (refreshed on entry, by the page timer, the refresh button and after load more / filter changes). Records that are not loaded are still updated incrementally: the update contract gains content-matched changes (upsert-call, update-request, resolve-approval, settle-request, remove-request, remove-call) that Main applies in SQL to shown rows; the store applies the same semantics to its pages through the shared reference. update returns Main's stored call records so a call whose earlier record was not loaded keeps one id. Task refresh reconciles via Main SQL. Legacy localStorage records migrate as incremental changes; the renderer no longer calls replace. Tests: randomized sequences of every action, page loads, trims, flushes and injected failures against the former full-list code on a real database; first page <= 200 after startup; flush payload depends only on changed records (including unloaded ones); ActivityPanel output with pages + summary equals the full-list rendering for randomized datasets with duplicates; restart interrupts leftover records with the same detail; clear empties the database, pages and summary; SQL change semantics equal the reference.
496 lines
23 KiB
TypeScript
496 lines
23 KiB
TypeScript
import {
|
|
activityHistoryPageRequestSchema,
|
|
activityHistoryReconcileRequestSchema,
|
|
activityHistorySnapshotSchema,
|
|
activityHistorySummaryRequestSchema,
|
|
activityHistoryUpdateSchema,
|
|
activityRecordSchema,
|
|
type ActivityHistoryPage,
|
|
type ActivityHistorySnapshot,
|
|
type ActivityHistorySummary,
|
|
type ActivityHistoryUpdateResult,
|
|
type ActivityRecord
|
|
} from '../../shared/assistant-contracts'
|
|
import {
|
|
activityRecordKey,
|
|
isActivityHistoryPlanEmpty,
|
|
parseActivityHistorySnapshot,
|
|
planActivityHistoryReplace,
|
|
type ActivityHistoryPlan,
|
|
type ActivityHistoryState
|
|
} from './activity-history-plan'
|
|
|
|
/**
|
|
* The connection the repository runs on; AssistantDatabase owns and passes
|
|
* it, so this module does not import node:sqlite itself (PERF-16 allowlist).
|
|
*/
|
|
type SqlValue = string | number | bigint | null | Uint8Array
|
|
type Connection = {
|
|
readonly isTransaction: boolean
|
|
exec: (sql: string) => void
|
|
prepare: (sql: string) => {
|
|
all: (...args: SqlValue[]) => unknown[]
|
|
get: (...args: SqlValue[]) => unknown
|
|
run: (...args: SqlValue[]) => { changes: number | bigint }
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Activity history storage (PERF-15). Every record is one row of
|
|
* activity_history_records:
|
|
* - `record_key` = JSON `[id, occurrence]`: legacy history may hold an ID more
|
|
* than once; only occurrence 0 (the first in list order) is shown.
|
|
* - `order_seq`: list position, newer = larger. A move to the front assigns a
|
|
* new maximum, so no order list is rewritten.
|
|
* - `record_id`, `occurrence`, `status`, `status_group`, `kind`,
|
|
* `request_id`, `conversation_id`, `project_id`, `created_at`: stored
|
|
* columns generated from the key and JSON, indexed for pages, counts and
|
|
* updates; they cannot disagree with the record.
|
|
* The singleton activity_history row keeps only the legacy-incomplete flag.
|
|
*/
|
|
const recordsTableSql = (name: string): string => `
|
|
CREATE TABLE ${name} (
|
|
record_key TEXT PRIMARY KEY NOT NULL,
|
|
record_json TEXT NOT NULL,
|
|
order_seq INTEGER NOT NULL,
|
|
record_id TEXT GENERATED ALWAYS AS (json_extract(record_key, '$[0]')) STORED,
|
|
occurrence INTEGER GENERATED ALWAYS AS (json_extract(record_key, '$[1]')) STORED,
|
|
status TEXT GENERATED ALWAYS AS (json_extract(record_json, '$.status')) STORED,
|
|
status_group TEXT GENERATED ALWAYS AS (CASE json_extract(record_json, '$.status')
|
|
WHEN 'pending' THEN 'active' WHEN 'running' THEN 'active'
|
|
WHEN 'completed' THEN 'other' ELSE 'failed' END) STORED,
|
|
kind TEXT GENERATED ALWAYS AS (json_extract(record_json, '$.kind')) STORED,
|
|
request_id TEXT GENERATED ALWAYS AS (json_extract(record_json, '$.requestId')) STORED,
|
|
conversation_id TEXT GENERATED ALWAYS AS (json_extract(record_json, '$.conversationId')) STORED,
|
|
project_id TEXT GENERATED ALWAYS AS (json_extract(record_json, '$.scope.projectId')) STORED,
|
|
created_at REAL GENERATED ALWAYS AS (json_extract(record_json, '$.createdAt')) STORED
|
|
)`
|
|
|
|
/** Schema 57: order and indexed columns; replaces the record_order_json list. */
|
|
export function migrateActivityHistoryOrder(database: Connection): void {
|
|
const columns = database.prepare(
|
|
"SELECT name FROM pragma_table_xinfo('activity_history_records')"
|
|
).all() as Array<{ name: string }>
|
|
// Re-running the migration (a lowered user_version) changes nothing.
|
|
if (columns.some((column) => column.name === 'status_group')) return
|
|
// Tables created by the schema-44 migration of this version already carry the order.
|
|
const hasOrder = columns.some((column) => column.name === 'order_seq')
|
|
const header = database.prepare(
|
|
'SELECT record_order_json FROM activity_history WHERE singleton = 1'
|
|
).get() as { record_order_json: string } | undefined
|
|
database.exec(recordsTableSql('activity_history_records_v57'))
|
|
if (hasOrder) {
|
|
database.exec(`INSERT INTO activity_history_records_v57 (record_key, record_json, order_seq)
|
|
SELECT record_key, record_json, order_seq FROM activity_history_records`)
|
|
} else {
|
|
// The stored order lists every row once; rows outside it were never shown.
|
|
database.prepare(`
|
|
INSERT INTO activity_history_records_v57 (record_key, record_json, order_seq)
|
|
SELECT records.record_key, records.record_json, json_array_length(?1) - CAST(ordering.key AS INTEGER)
|
|
FROM json_each(?1) AS ordering
|
|
JOIN activity_history_records AS records ON records.record_key = ordering.value
|
|
`).run(header?.record_order_json ?? '[]')
|
|
}
|
|
database.exec(`
|
|
DROP TABLE activity_history_records;
|
|
ALTER TABLE activity_history_records_v57 RENAME TO activity_history_records;
|
|
UPDATE activity_history SET record_order_json = '[]' WHERE singleton = 1;
|
|
CREATE INDEX activity_history_records_order ON activity_history_records(occurrence, order_seq);
|
|
CREATE INDEX activity_history_records_group ON activity_history_records(occurrence, status_group, order_seq);
|
|
CREATE INDEX activity_history_records_conversation ON activity_history_records(conversation_id, occurrence,
|
|
order_seq, kind, status, request_id, created_at, record_key);
|
|
CREATE INDEX activity_history_records_project ON activity_history_records(project_id, occurrence, order_seq);
|
|
CREATE INDEX activity_history_records_request ON activity_history_records(request_id, kind);
|
|
CREATE INDEX activity_history_records_id ON activity_history_records(record_id);
|
|
`)
|
|
}
|
|
type SummaryRow = {
|
|
conversation_id: string
|
|
kind: ActivityRecord['kind']
|
|
status: ActivityRecord['status']
|
|
request_id: string
|
|
created_at: number
|
|
record_key: string
|
|
}
|
|
|
|
/**
|
|
* Title (first request in list order) and status (the latest request's
|
|
* latest result, else the latest result, else the latest record) of one
|
|
* conversation; `rows` are newest first, so createdAt ties resolve to the
|
|
* record nearer the front, as the former full-list computation did.
|
|
*/
|
|
function summarizeConversation(
|
|
rows: readonly SummaryRow[],
|
|
titles: ReadonlyMap<string, string>
|
|
): { title?: string; status: ActivityRecord['status'] } {
|
|
const latest = (match: (row: SummaryRow) => boolean): SummaryRow | undefined => {
|
|
let best: SummaryRow | undefined
|
|
for (const row of rows) if (match(row) && (!best || row.created_at > best.created_at)) best = row
|
|
return best
|
|
}
|
|
const firstRequest = rows.find((row) => row.kind === 'request')
|
|
const title = firstRequest ? titles.get(firstRequest.record_key) : undefined
|
|
const request = latest((row) => row.kind === 'request')
|
|
const status = request
|
|
? (latest((row) => row.kind === 'result' && row.request_id === request.request_id)?.status ?? request.status)
|
|
: (latest((row) => row.kind === 'result')?.status ?? latest(() => true)?.status ?? 'completed')
|
|
return { ...(title === undefined ? {} : { title }), status }
|
|
}
|
|
|
|
function parseRow(json: string): ActivityRecord {
|
|
return activityRecordSchema.parse(JSON.parse(json))
|
|
}
|
|
|
|
export class ActivityHistoryRepository {
|
|
/** Validated rows of the last committed replace, reused by the next one. */
|
|
private replaceCache?: { state: ActivityHistoryState; dataVersion: number }
|
|
|
|
constructor(private readonly database: Connection) {}
|
|
|
|
/** Drops cached rows after writes that bypass the repository. */
|
|
invalidate(): void {
|
|
this.replaceCache = undefined
|
|
}
|
|
|
|
/** The whole stored list, every occurrence, newest first. */
|
|
snapshot(): ActivityHistorySnapshot {
|
|
const rows = this.database.prepare(
|
|
'SELECT record_json FROM activity_history_records ORDER BY order_seq DESC, record_key'
|
|
).all() as Array<{ record_json: string }>
|
|
return activityHistorySnapshotSchema.parse({
|
|
records: rows.map((row) => JSON.parse(row.record_json) as unknown),
|
|
legacyHistoryMayBeIncomplete: this.readIncomplete()
|
|
})
|
|
}
|
|
|
|
/** One page of shown records, newest first, by index. */
|
|
page(input: unknown): ActivityHistoryPage {
|
|
const request = activityHistoryPageRequestSchema.parse(input)
|
|
const where = ['occurrence = 0']
|
|
const args: Array<string | number> = []
|
|
if (request.before !== undefined) {
|
|
where.push('order_seq < ?')
|
|
args.push(request.before)
|
|
}
|
|
if (request.filter && request.filter !== 'all') {
|
|
where.push('status_group = ?')
|
|
args.push(request.filter)
|
|
}
|
|
if (request.projectId) {
|
|
where.push('project_id = ?')
|
|
args.push(request.projectId)
|
|
}
|
|
if (request.conversationId) {
|
|
where.push('conversation_id = ?')
|
|
args.push(request.conversationId)
|
|
}
|
|
const rows = this.database.prepare(
|
|
`SELECT order_seq, record_json FROM activity_history_records
|
|
WHERE ${where.join(' AND ')} ORDER BY order_seq DESC LIMIT ?`
|
|
).all(...args, request.limit + 1) as Array<{ order_seq: number; record_json: string }>
|
|
const more = rows.length > request.limit
|
|
const shown = more ? rows.slice(0, request.limit) : rows
|
|
return {
|
|
records: shown.map((row) => parseRow(row.record_json)),
|
|
...(more ? { nextBefore: shown.at(-1)!.order_seq } : {})
|
|
}
|
|
}
|
|
|
|
/** What the Activity page derives from the whole history. */
|
|
summary(input: unknown): ActivityHistorySummary {
|
|
const request = activityHistorySummaryRequestSchema.parse(input)
|
|
const groupRows = this.database.prepare(
|
|
`SELECT status_group AS status_group, COUNT(*) AS count FROM activity_history_records
|
|
WHERE occurrence = 0 GROUP BY status_group`
|
|
).all() as Array<{ status_group: 'active' | 'failed' | 'other'; count: number }>
|
|
const counts = { all: 0, active: 0, failed: 0 }
|
|
for (const { status_group: group, count } of groupRows) {
|
|
counts.all += count
|
|
if (group !== 'other') counts[group] += count
|
|
}
|
|
// One indexed read of the requested conversations' rows, newest first.
|
|
const rows = this.database.prepare(
|
|
`SELECT conversation_id, kind, status, request_id, created_at, record_key
|
|
FROM activity_history_records
|
|
WHERE conversation_id IN (SELECT value FROM json_each(?)) AND occurrence = 0
|
|
ORDER BY conversation_id, order_seq DESC`
|
|
).all(JSON.stringify([...new Set(request.conversationIds)])) as SummaryRow[]
|
|
const byConversation = new Map<string, SummaryRow[]>()
|
|
for (const row of rows) {
|
|
const own = byConversation.get(row.conversation_id)
|
|
if (own) own.push(row)
|
|
else byConversation.set(row.conversation_id, [row])
|
|
}
|
|
// Titles come from the first request row of each conversation only.
|
|
const titleKeys = [...byConversation.values()].flatMap((own) => {
|
|
const first = own.find((row) => row.kind === 'request')
|
|
return first ? [first.record_key] : []
|
|
})
|
|
const titles = new Map((this.database.prepare(
|
|
`SELECT record_key, json_extract(record_json, '$.title') AS title FROM activity_history_records
|
|
WHERE record_key IN (SELECT value FROM json_each(?))`
|
|
).all(JSON.stringify(titleKeys)) as Array<{ record_key: string; title: string }>)
|
|
.map((row) => [row.record_key, row.title]))
|
|
const conversations: ActivityHistorySummary['conversations'] = {}
|
|
for (const conversationId of new Set(request.conversationIds)) {
|
|
conversations[conversationId] = summarizeConversation(byConversation.get(conversationId) ?? [], titles)
|
|
}
|
|
return { counts, conversations, legacyHistoryMayBeIncomplete: this.readIncomplete() }
|
|
}
|
|
|
|
/**
|
|
* Ends pending/running records whose request is not running in this
|
|
* window: without a task they become interrupted (with the given localized
|
|
* line appended); with a finished task they take its status, except that
|
|
* tool, approval and subagent records of a completed task become
|
|
* interrupted. Returns the number of changed records.
|
|
*/
|
|
reconcile(input: unknown): number {
|
|
const request = activityHistoryReconcileRequestSchema.parse(input)
|
|
return this.write(() => {
|
|
const result = this.database.prepare(`
|
|
WITH candidates AS (
|
|
SELECT records.record_key,
|
|
CASE
|
|
WHEN tasks.id IS NULL THEN 'interrupted'
|
|
WHEN tasks.status IN ('completed', 'failed', 'cancelled', 'interrupted') THEN tasks.status
|
|
END AS terminal
|
|
FROM activity_history_records AS records
|
|
LEFT JOIN tasks ON tasks.id = records.request_id AND tasks.visible = 1
|
|
WHERE records.occurrence = 0 AND records.status_group = 'active'
|
|
AND records.request_id NOT IN (SELECT value FROM json_each(?1))
|
|
)
|
|
UPDATE activity_history_records SET record_json = json_set(record_json,
|
|
'$.status', CASE
|
|
WHEN candidates.terminal = 'completed' AND activity_history_records.kind IN ('tool', 'approval', 'subagent')
|
|
THEN 'interrupted'
|
|
ELSE candidates.terminal END,
|
|
'$.detail', CASE
|
|
WHEN candidates.terminal = 'interrupted'
|
|
THEN json_extract(record_json, '$.detail') || char(10) || ?2
|
|
ELSE json_extract(record_json, '$.detail') END)
|
|
FROM candidates
|
|
WHERE activity_history_records.record_key = candidates.record_key AND candidates.terminal IS NOT NULL
|
|
`).run(JSON.stringify(request.activeRequestIds), request.interruptedDetail)
|
|
return Number(result.changes)
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Applies incremental changes in one transaction, without reading the
|
|
* history. Returns the stored record of every upsert-call, which keeps
|
|
* the id and createdAt of an earlier record of the call the renderer may
|
|
* not have loaded.
|
|
*/
|
|
update(input: unknown): ActivityHistoryUpdateResult {
|
|
const update = activityHistoryUpdateSchema.parse(input)
|
|
const calls: ActivityRecord[] = []
|
|
this.write(() => {
|
|
const database = this.database
|
|
const nextSeq = database.prepare(
|
|
'SELECT COALESCE(MAX(order_seq), 0) + 1 AS seq FROM activity_history_records'
|
|
)
|
|
const exists = database.prepare('SELECT 1 FROM activity_history_records WHERE record_key = ?')
|
|
const insert = database.prepare(
|
|
'INSERT INTO activity_history_records (record_key, record_json, order_seq) VALUES (?, ?, ?)'
|
|
)
|
|
const moveToFront = database.prepare(
|
|
'UPDATE activity_history_records SET record_json = ?, order_seq = ? WHERE record_key = ?'
|
|
)
|
|
const replaceInPlace = database.prepare(
|
|
'UPDATE activity_history_records SET record_json = ? WHERE record_key = ? AND record_json != ?'
|
|
)
|
|
const remove = database.prepare('DELETE FROM activity_history_records WHERE record_id = ?')
|
|
const seq = (): number => (nextSeq.get() as { seq: number }).seq
|
|
// Shown rows (first occurrences) a content-matched change applies to, newest first.
|
|
type Row = { record_key: string; order_seq: number; record: ActivityRecord }
|
|
const select = (where: string, limit: number, ...args: SqlValue[]): Row[] =>
|
|
(database.prepare(
|
|
`SELECT record_key, order_seq, record_json FROM activity_history_records
|
|
WHERE occurrence = 0 AND ${where} ORDER BY order_seq DESC LIMIT ${limit}`
|
|
).all(...args) as Array<{ record_key: string; order_seq: number; record_json: string }>)
|
|
.map((row) => ({ record_key: row.record_key, order_seq: row.order_seq, record: parseRow(row.record_json) }))
|
|
const frontSeq = database.prepare(
|
|
'SELECT MAX(order_seq) AS seq FROM activity_history_records WHERE occurrence = 0'
|
|
)
|
|
const rewrite = (key: string, record: ActivityRecord): void => {
|
|
const json = JSON.stringify(activityRecordSchema.parse(record))
|
|
replaceInPlace.run(json, key, json)
|
|
}
|
|
for (const change of update.changes) {
|
|
switch (change.type) {
|
|
case 'remove-duplicates':
|
|
database.exec('DELETE FROM activity_history_records WHERE occurrence != 0')
|
|
break
|
|
case 'remove':
|
|
remove.run(change.id)
|
|
break
|
|
case 'upsert': {
|
|
// The first occurrence stays first when moved to the front, so its key is stable.
|
|
const key = activityRecordKey(change.record.id, 0)
|
|
const json = JSON.stringify(change.record)
|
|
if (!exists.get(key)) insert.run(key, json, seq())
|
|
else if (change.position === 'front') moveToFront.run(json, seq(), key)
|
|
else replaceInPlace.run(json, key, json)
|
|
break
|
|
}
|
|
case 'upsert-call': {
|
|
const incoming = change.record
|
|
const [existing] = select(
|
|
"request_id = ? AND kind = ? AND json_extract(record_json, '$.callId') = ?", 1,
|
|
incoming.requestId, incoming.kind, incoming.callId!
|
|
)
|
|
if (!existing) {
|
|
insert.run(activityRecordKey(incoming.id, 0), JSON.stringify(incoming), seq())
|
|
calls.push(incoming)
|
|
break
|
|
}
|
|
const before = existing.record
|
|
const unchanged = before.conversationId === incoming.conversationId && before.title === incoming.title &&
|
|
before.detail === incoming.detail && before.status === incoming.status
|
|
const front = existing.order_seq === (frontSeq.get() as { seq: number }).seq
|
|
const next = unchanged ? before : { ...incoming, id: before.id, createdAt: before.createdAt, scope: before.scope }
|
|
calls.push(next)
|
|
if (unchanged && front) break
|
|
moveToFront.run(JSON.stringify(activityRecordSchema.parse(next)), seq(), existing.record_key)
|
|
break
|
|
}
|
|
case 'update-request':
|
|
for (const row of select("request_id = ? AND kind = 'request'", -1, change.requestId)) {
|
|
rewrite(row.record_key, { ...row.record, status: change.status, detail: change.detail ?? row.record.detail })
|
|
}
|
|
break
|
|
case 'resolve-approval': {
|
|
const [row] = select("conversation_id = ? AND kind = 'approval' AND status = 'pending'", 1,
|
|
change.conversationId)
|
|
if (row) {
|
|
rewrite(row.record_key, { ...row.record, status: change.status, detail: `${row.record.detail}\n${change.detailLine}` })
|
|
}
|
|
break
|
|
}
|
|
case 'settle-request':
|
|
for (const row of select("request_id = ? AND kind != 'request' AND status_group = 'active'", -1,
|
|
change.requestId)) {
|
|
rewrite(row.record_key, { ...row.record, status: change.status, detail: `${row.record.detail}\n${change.detailLine}` })
|
|
}
|
|
break
|
|
case 'remove-request':
|
|
for (const row of select('request_id = ?', -1, change.requestId)) remove.run(row.record.id)
|
|
break
|
|
case 'remove-call':
|
|
for (const row of select(
|
|
"request_id = ? AND kind = 'tool' AND json_extract(record_json, '$.callId') = ?", -1,
|
|
change.requestId, change.callId
|
|
)) remove.run(row.record.id)
|
|
break
|
|
}
|
|
}
|
|
if (update.legacyHistoryMayBeIncomplete !== undefined) {
|
|
this.writeIncomplete(update.legacyHistoryMayBeIncomplete)
|
|
}
|
|
})
|
|
return { calls }
|
|
}
|
|
|
|
/**
|
|
* Persists a whole newest-first list (legacy migration). Unchanged records
|
|
* are neither re-validated, re-serialized nor written; a fully unchanged
|
|
* snapshot does not open a write transaction.
|
|
*/
|
|
replace(input: unknown): void {
|
|
// Reuse is safe even from a stale cache: cached records are validated copies.
|
|
const { items, incomplete } = parseActivityHistorySnapshot(input, this.replaceCache?.state)
|
|
const committed = this.committedState()
|
|
const planned = committed && planActivityHistoryReplace(items, incomplete, committed)
|
|
if (planned && isActivityHistoryPlanEmpty(planned)) return
|
|
let result: ActivityHistoryPlan | undefined
|
|
this.write(() => {
|
|
const previous = committed && planned ? committed : this.readState()
|
|
result = previous === committed && planned ? planned : planActivityHistoryReplace(items, incomplete, previous)
|
|
this.applyPlan(result)
|
|
})
|
|
// Publish only committed records; a failed save must remain retryable.
|
|
this.replaceCache = { state: result!.next, dataVersion: this.dataVersion() }
|
|
}
|
|
|
|
/** Writes a parsed legacy snapshot into empty tables (schema migrations). */
|
|
writeSnapshot(snapshot: ActivityHistorySnapshot, onProgress?: (processed: number) => void): void {
|
|
this.applyPlan(planActivityHistoryReplace(
|
|
snapshot.records.map((record) => ({ record })), snapshot.legacyHistoryMayBeIncomplete,
|
|
{ entries: new Map(), incomplete: !snapshot.legacyHistoryMayBeIncomplete }, onProgress))
|
|
}
|
|
|
|
/** Deletes the whole history in one transaction. */
|
|
clear(): void {
|
|
this.write(() => {
|
|
this.database.exec('DELETE FROM activity_history_records')
|
|
this.writeIncomplete(false)
|
|
})
|
|
}
|
|
|
|
private write<T>(body: () => T): T {
|
|
const database = this.database
|
|
this.replaceCache = undefined
|
|
if (database.isTransaction) return body()
|
|
database.exec('BEGIN IMMEDIATE')
|
|
try {
|
|
const result = body()
|
|
database.exec('COMMIT')
|
|
return result
|
|
} catch (error) {
|
|
database.exec('ROLLBACK')
|
|
throw error
|
|
}
|
|
}
|
|
|
|
private dataVersion(): number {
|
|
return (this.database.prepare('PRAGMA data_version').get() as { data_version: number }).data_version
|
|
}
|
|
|
|
/** The cached committed state, when no other connection committed since. */
|
|
private committedState(): ActivityHistoryState | undefined {
|
|
const cache = this.replaceCache
|
|
if (!cache || this.database.isTransaction) return undefined
|
|
return cache.dataVersion === this.dataVersion() ? cache.state : undefined
|
|
}
|
|
|
|
private readState(): ActivityHistoryState {
|
|
const rows = this.database.prepare(
|
|
'SELECT record_key, record_json, order_seq FROM activity_history_records'
|
|
).all() as Array<{ record_key: string; record_json: string; order_seq: number }>
|
|
return {
|
|
entries: new Map(rows.map((row) => [row.record_key, { json: row.record_json, seq: row.order_seq }])),
|
|
incomplete: this.readIncomplete()
|
|
}
|
|
}
|
|
|
|
private applyPlan(plan: ActivityHistoryPlan): void {
|
|
const database = this.database
|
|
if (plan.removes.length > 0) {
|
|
const remove = database.prepare('DELETE FROM activity_history_records WHERE record_key = ?')
|
|
for (const key of plan.removes) remove.run(key)
|
|
}
|
|
if (plan.upserts.length > 0) {
|
|
const upsert = database.prepare(
|
|
`INSERT INTO activity_history_records (record_key, record_json, order_seq) VALUES (?, ?, ?)
|
|
ON CONFLICT(record_key) DO UPDATE SET record_json = excluded.record_json, order_seq = excluded.order_seq`
|
|
)
|
|
for (const [key, json, seq] of plan.upserts) upsert.run(key, json, seq)
|
|
}
|
|
if (plan.headerChanged) this.writeIncomplete(plan.next.incomplete)
|
|
}
|
|
|
|
private readIncomplete(): boolean {
|
|
const row = this.database.prepare(
|
|
'SELECT legacy_history_may_be_incomplete AS value FROM activity_history WHERE singleton = 1'
|
|
).get() as { value: number } | undefined
|
|
return row?.value === 1
|
|
}
|
|
|
|
private writeIncomplete(value: boolean): void {
|
|
this.database.prepare(
|
|
'UPDATE activity_history SET legacy_history_may_be_incomplete = ? WHERE singleton = 1'
|
|
).run(Number(value))
|
|
}
|
|
}
|