@4fu/arbor-notes-sqlite 0.0.0-stage → 2026.1106.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +187 -2
- package/diff.d.ts +12 -0
- package/diff.js +106 -0
- package/index.d.ts +13 -0
- package/index.js +10 -0
- package/notes.d.ts +126 -0
- package/notes.js +867 -0
- package/package.json +44 -4
- package/refs.d.ts +24 -0
- package/refs.js +49 -0
- package/schema.d.ts +34 -0
- package/schema.js +141 -0
- package/score.d.ts +67 -0
- package/score.js +203 -0
- package/search.d.ts +27 -0
- package/search.js +50 -0
package/notes.js
ADDED
|
@@ -0,0 +1,867 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The default `NoteStore`: SQLite beside the journal (docs/memory.md).
|
|
3
|
+
*
|
|
4
|
+
* Notes are the long-term memory: one copy of each, shared by every window,
|
|
5
|
+
* written only through the note tools. What this implementation owns:
|
|
6
|
+
*
|
|
7
|
+
* - the one copy, and the checked write: a write, replace or delete names the
|
|
8
|
+
* revision it read, and a stale one is refused with what changed since -
|
|
9
|
+
* the revisions in between, their windows and the differences - so the model
|
|
10
|
+
* merges and writes again. A refused call changes nothing.
|
|
11
|
+
* - revisions, append-only, with the anchor the host recorded and the refs the
|
|
12
|
+
* write named, so an anchor keeps working after the note is edited, archived
|
|
13
|
+
* or deleted;
|
|
14
|
+
* - the active set, picked at each snapshot epoch by usage score within a
|
|
15
|
+
* count and token budget, and frozen with the epoch's version, so every
|
|
16
|
+
* window opened in one epoch has the same prefix and later windows see the
|
|
17
|
+
* changes as a delta;
|
|
18
|
+
* - every write recorded in the journal as a `note_revision` event.
|
|
19
|
+
*
|
|
20
|
+
* It is synchronous inside: one `DatabaseSync`, one transaction per write, so
|
|
21
|
+
* the revision a write checks and the revision it writes are one atomic step.
|
|
22
|
+
* Writes are serialised in process as well, because a write spans the notes
|
|
23
|
+
* database and the journal. The interface is asynchronous because a `NoteStore`
|
|
24
|
+
* may live behind a process boundary.
|
|
25
|
+
*/
|
|
26
|
+
import { DatabaseSync } from "node:sqlite";
|
|
27
|
+
import { arborError, isArborError } from "@4fu/arbor-errors";
|
|
28
|
+
import { DEFAULT_PAGE_LIMIT, MAX_PAGE_LIMIT } from "@4fu/arbor-interfaces";
|
|
29
|
+
import { bodyDiff } from "./diff.js";
|
|
30
|
+
import { noteIdOf, noteRefOf, noteRefForms, parseNoteId, parseNoteRef, storedEventRef, storedWindowRef, } from "./refs.js";
|
|
31
|
+
import { FTS_TABLE, createSchema } from "./schema.js";
|
|
32
|
+
import { USE_WEIGHTS, applyJudgeShare, pickByBudget, scoreNotes } from "./score.js";
|
|
33
|
+
import { holdingIds, substringClause } from "./search.js";
|
|
34
|
+
/** A summary is at most this many characters (docs/memory.md). */
|
|
35
|
+
export const SUMMARY_LIMIT = 200;
|
|
36
|
+
/** How many characters of a body one read returns. */
|
|
37
|
+
export const DEFAULT_BODY_LIMIT = 7000;
|
|
38
|
+
export const MAX_BODY_LIMIT = 20000;
|
|
39
|
+
/** The system prompt and memory snapshot rebuild at most this often. 10 min. */
|
|
40
|
+
export const DEFAULT_MIN_INTERVAL_MS = 600_000;
|
|
41
|
+
/** The active set's budget, which bounds every prefix. */
|
|
42
|
+
export const DEFAULT_ACTIVE_BUDGET = { count: 40, tokens: 8000 };
|
|
43
|
+
/** The share of the budget the judge may add, which never outweighs the model's own use. */
|
|
44
|
+
export const DEFAULT_JUDGE_SHARE = 0.15;
|
|
45
|
+
/** Usage loses half of its weight this often. */
|
|
46
|
+
export const DEFAULT_HALF_LIFE_MS = 86_400_000;
|
|
47
|
+
/** How many events one `about` lookup reads. */
|
|
48
|
+
const ABOUT_EVENT_LIMIT = 200;
|
|
49
|
+
function toNumber(value) {
|
|
50
|
+
if (typeof value === "number")
|
|
51
|
+
return value;
|
|
52
|
+
if (typeof value === "bigint")
|
|
53
|
+
return Number(value);
|
|
54
|
+
throw new Error(`notes: storage returned a value that is not a number: ${String(value)}`);
|
|
55
|
+
}
|
|
56
|
+
/** One transaction: every write is one atomic step of the one copy. */
|
|
57
|
+
function inTransaction(db, work) {
|
|
58
|
+
db.exec("BEGIN IMMEDIATE");
|
|
59
|
+
try {
|
|
60
|
+
const result = work();
|
|
61
|
+
db.exec("COMMIT");
|
|
62
|
+
return result;
|
|
63
|
+
}
|
|
64
|
+
catch (error) {
|
|
65
|
+
try {
|
|
66
|
+
db.exec("ROLLBACK");
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
// The transaction is already gone; the original error is what matters.
|
|
70
|
+
}
|
|
71
|
+
throw error;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
function clampLimit(limit) {
|
|
75
|
+
if (limit === undefined)
|
|
76
|
+
return DEFAULT_PAGE_LIMIT;
|
|
77
|
+
return Math.min(Math.max(1, Math.floor(limit)), MAX_PAGE_LIMIT);
|
|
78
|
+
}
|
|
79
|
+
function clampBodyLimit(limit) {
|
|
80
|
+
if (limit === undefined)
|
|
81
|
+
return DEFAULT_BODY_LIMIT;
|
|
82
|
+
return Math.min(Math.max(1, Math.floor(limit)), MAX_BODY_LIMIT);
|
|
83
|
+
}
|
|
84
|
+
/** The opening of a body, when a write carries no summary of its own. */
|
|
85
|
+
export function deriveSummary(body) {
|
|
86
|
+
const flat = body.replace(/\s+/g, " ").trim();
|
|
87
|
+
const characters = Array.from(flat);
|
|
88
|
+
return characters.length <= SUMMARY_LIMIT ? flat : `${characters.slice(0, SUMMARY_LIMIT).join("")}…`;
|
|
89
|
+
}
|
|
90
|
+
function invalidNoteRef(call, ref) {
|
|
91
|
+
return arborError({
|
|
92
|
+
code: "invalid_input",
|
|
93
|
+
rule: `note.${call}.ref`,
|
|
94
|
+
message: `note.${call}: ${ref} is not a note ref`,
|
|
95
|
+
nextAction: "pass a note ref such as n012, or one of its revisions such as n012@3",
|
|
96
|
+
details: { ref },
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
function parseOrThrow(ref, call) {
|
|
100
|
+
const parsed = parseNoteRef(ref);
|
|
101
|
+
if (parsed === null)
|
|
102
|
+
throw invalidNoteRef(call, ref);
|
|
103
|
+
return parsed;
|
|
104
|
+
}
|
|
105
|
+
function toSummary(row) {
|
|
106
|
+
const id = toNumber(row.id);
|
|
107
|
+
const revision = toNumber(row.revision);
|
|
108
|
+
return {
|
|
109
|
+
ref: noteRefOf(id, revision),
|
|
110
|
+
id: noteIdOf(id),
|
|
111
|
+
title: row.title,
|
|
112
|
+
summary: row.summary,
|
|
113
|
+
status: row.status,
|
|
114
|
+
revision,
|
|
115
|
+
time: toNumber(row.updated),
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
function toNote(row) {
|
|
119
|
+
const id = toNumber(row.id);
|
|
120
|
+
const revision = toNumber(row.revision);
|
|
121
|
+
return {
|
|
122
|
+
ref: noteRefOf(id, revision),
|
|
123
|
+
id: noteIdOf(id),
|
|
124
|
+
title: row.title,
|
|
125
|
+
summary: row.summary,
|
|
126
|
+
// A deleted note has no current body; its revisions still resolve by ref.
|
|
127
|
+
body: row.status === "deleted" ? null : row.body,
|
|
128
|
+
status: row.status,
|
|
129
|
+
revision,
|
|
130
|
+
...(row.anchor === null ? {} : { anchor: JSON.parse(row.anchor) }),
|
|
131
|
+
time: toNumber(row.updated),
|
|
132
|
+
window: storedWindowRef(row.window),
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
function toNoteRevision(row) {
|
|
136
|
+
const note = toNumber(row.note);
|
|
137
|
+
const revision = toNumber(row.revision);
|
|
138
|
+
return {
|
|
139
|
+
ref: noteRefOf(note, revision),
|
|
140
|
+
revision,
|
|
141
|
+
window: storedWindowRef(row.window),
|
|
142
|
+
time: toNumber(row.time),
|
|
143
|
+
summary: row.summary,
|
|
144
|
+
...(row.anchor === null ? {} : { anchor: JSON.parse(row.anchor) }),
|
|
145
|
+
refs: row.refs === null ? [] : JSON.parse(row.refs),
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
function searchCall(query, offset, limit) {
|
|
149
|
+
const parts = [];
|
|
150
|
+
if (query.query !== undefined)
|
|
151
|
+
parts.push(`query=${JSON.stringify(query.query)}`);
|
|
152
|
+
if (query.status !== undefined)
|
|
153
|
+
parts.push(`status=${query.status}`);
|
|
154
|
+
if (query.about !== undefined)
|
|
155
|
+
parts.push(`about=${query.about}`);
|
|
156
|
+
if (query.linked !== undefined)
|
|
157
|
+
parts.push(`linked=${query.linked}`);
|
|
158
|
+
parts.push(`offset=${offset + limit}`);
|
|
159
|
+
return `notes.search(${parts.join(", ")})`;
|
|
160
|
+
}
|
|
161
|
+
export class SqliteNoteStore {
|
|
162
|
+
path;
|
|
163
|
+
db;
|
|
164
|
+
journal;
|
|
165
|
+
clock;
|
|
166
|
+
halfLifeMs;
|
|
167
|
+
/** One write at a time: a write spans the notes database and the journal. */
|
|
168
|
+
writeQueue = Promise.resolve();
|
|
169
|
+
closed = false;
|
|
170
|
+
constructor(options) {
|
|
171
|
+
this.path = options.path ?? ":memory:";
|
|
172
|
+
this.db = new DatabaseSync(this.path);
|
|
173
|
+
createSchema(this.db);
|
|
174
|
+
this.journal = options.journal;
|
|
175
|
+
this.clock = options.now ?? (() => Date.now());
|
|
176
|
+
this.halfLifeMs = options.usageHalfLifeMs ?? DEFAULT_HALF_LIFE_MS;
|
|
177
|
+
}
|
|
178
|
+
// --- writes -------------------------------------------------------------
|
|
179
|
+
async write(input) {
|
|
180
|
+
const run = this.writeQueue.then(() => this.writeOne(input));
|
|
181
|
+
this.writeQueue = run.catch(() => undefined);
|
|
182
|
+
return run;
|
|
183
|
+
}
|
|
184
|
+
async writeOne(input) {
|
|
185
|
+
this.ensureOpen();
|
|
186
|
+
this.checkWrite(input);
|
|
187
|
+
const time = this.clock();
|
|
188
|
+
const written = inTransaction(this.db, () => this.place(input, time));
|
|
189
|
+
const noteId = noteIdOf(written.id);
|
|
190
|
+
const noteRef = noteRefOf(written.id, written.revision);
|
|
191
|
+
// The write stands or falls with its journal event: a note revision nobody
|
|
192
|
+
// can find in the record is not a revision.
|
|
193
|
+
let event;
|
|
194
|
+
try {
|
|
195
|
+
event = await this.journal.append({
|
|
196
|
+
kind: "note_revision",
|
|
197
|
+
window: written.window,
|
|
198
|
+
body: `${written.title}\n${written.summary}`,
|
|
199
|
+
targets: [noteId],
|
|
200
|
+
refs: [noteRef, ...(input.refs ?? [])],
|
|
201
|
+
links: [
|
|
202
|
+
{ kind: "revision", to: noteRef, weight: 1 },
|
|
203
|
+
...(written.anchor?.evidence ?? []).map((to) => ({ kind: "evidence", to, weight: 1 })),
|
|
204
|
+
],
|
|
205
|
+
time: written.time,
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
catch (error) {
|
|
209
|
+
this.takeBack(written);
|
|
210
|
+
// The journal's own refusal travels with this one, so the model reads
|
|
211
|
+
// both the rule that refused the write and what the journal said.
|
|
212
|
+
const detail = isArborError(error)
|
|
213
|
+
? `${error.rule}: ${error.message}`
|
|
214
|
+
: error instanceof Error
|
|
215
|
+
? error.message
|
|
216
|
+
: String(error);
|
|
217
|
+
throw arborError({
|
|
218
|
+
code: isArborError(error) ? error.code : "internal",
|
|
219
|
+
rule: "note.write.journal",
|
|
220
|
+
message: `note.write: ${noteRef} was not written because the journal refused the event (${detail})`,
|
|
221
|
+
nextAction: "fix what the journal refused, then write the note again",
|
|
222
|
+
details: { upstream: detail },
|
|
223
|
+
cause: error,
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
inTransaction(this.db, () => {
|
|
227
|
+
this.db
|
|
228
|
+
.prepare("UPDATE note_revisions SET event = ? WHERE note = ? AND revision = ?")
|
|
229
|
+
.run(event.ref, written.id, written.revision);
|
|
230
|
+
this.db.prepare("UPDATE notes SET event = ? WHERE id = ?").run(event.ref, written.id);
|
|
231
|
+
});
|
|
232
|
+
return toNote(this.noteRow(written.id));
|
|
233
|
+
}
|
|
234
|
+
/** The rules a write has to meet before it touches the one copy. */
|
|
235
|
+
checkWrite(input) {
|
|
236
|
+
if (input.title.trim() === "") {
|
|
237
|
+
throw arborError({
|
|
238
|
+
code: "invalid_input",
|
|
239
|
+
rule: "note.write.title",
|
|
240
|
+
message: "note.write: a note needs a title",
|
|
241
|
+
nextAction: "give the note a short title",
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
if (input.delete !== true && input.body.trim() === "") {
|
|
245
|
+
throw arborError({
|
|
246
|
+
code: "invalid_input",
|
|
247
|
+
rule: "note.write.body",
|
|
248
|
+
message: "note.write: a note needs a body",
|
|
249
|
+
nextAction: "write what the note holds in body; a delete needs no body",
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
if (input.summary !== undefined && Array.from(input.summary).length > SUMMARY_LIMIT) {
|
|
253
|
+
throw arborError({
|
|
254
|
+
code: "invalid_input",
|
|
255
|
+
rule: "note.write.summary",
|
|
256
|
+
message: `note.write: a summary is at most ${SUMMARY_LIMIT} characters`,
|
|
257
|
+
nextAction: `shorten the summary to at most ${SUMMARY_LIMIT} characters, or omit it`,
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
if (input.id === undefined && input.expectedRevision !== undefined) {
|
|
261
|
+
throw arborError({
|
|
262
|
+
code: "invalid_input",
|
|
263
|
+
rule: "note.write.expected_revision",
|
|
264
|
+
message: "note.write: a new note has no revision to name",
|
|
265
|
+
nextAction: "omit expectedRevision to write a new note, or pass the note's id to replace it",
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
/** The one atomic step: check the revision, then place the revision. */
|
|
270
|
+
place(input, time) {
|
|
271
|
+
let previous = null;
|
|
272
|
+
let id;
|
|
273
|
+
let revision;
|
|
274
|
+
let created = false;
|
|
275
|
+
if (input.id === undefined) {
|
|
276
|
+
id = 0;
|
|
277
|
+
revision = 1;
|
|
278
|
+
created = true;
|
|
279
|
+
}
|
|
280
|
+
else {
|
|
281
|
+
const parsed = parseNoteId(input.id);
|
|
282
|
+
if (parsed === null)
|
|
283
|
+
throw invalidNoteRef("write", input.id);
|
|
284
|
+
previous = this.noteRow(parsed);
|
|
285
|
+
if (previous === null) {
|
|
286
|
+
throw arborError({
|
|
287
|
+
code: "not_found",
|
|
288
|
+
rule: "note.write.note",
|
|
289
|
+
message: `note.write: ${input.id} is not in the notes`,
|
|
290
|
+
nextAction: "omit id to write a new note, or look the note up with notes.search",
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
if (previous.status === "deleted") {
|
|
294
|
+
throw arborError({
|
|
295
|
+
code: "not_found",
|
|
296
|
+
rule: "note.write.deleted",
|
|
297
|
+
message: `note.write: ${input.id} is deleted`,
|
|
298
|
+
nextAction: "its revisions still resolve by ref, but it cannot be written; write a new note instead",
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
const expected = input.expectedRevision;
|
|
302
|
+
if (expected === undefined) {
|
|
303
|
+
throw arborError({
|
|
304
|
+
code: "invalid_input",
|
|
305
|
+
rule: "note.write.revision",
|
|
306
|
+
message: `note.write: ${input.id} is at revision ${previous.revision}; a write names the revision it read`,
|
|
307
|
+
nextAction: `read the note and write again against revision ${previous.revision}`,
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
if (expected > previous.revision) {
|
|
311
|
+
throw arborError({
|
|
312
|
+
code: "invalid_input",
|
|
313
|
+
rule: "note.write.revision",
|
|
314
|
+
message: `note.write: ${input.id}@${expected} does not exist; the note is at revision ${previous.revision}`,
|
|
315
|
+
nextAction: `read the note and write again against revision ${previous.revision}`,
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
if (expected < previous.revision)
|
|
319
|
+
throw this.conflict(previous, expected);
|
|
320
|
+
id = parsed;
|
|
321
|
+
revision = previous.revision + 1;
|
|
322
|
+
// A replace that changes the body names a summary, so a list never shows
|
|
323
|
+
// yesterday's line on today's note.
|
|
324
|
+
if (input.delete !== true &&
|
|
325
|
+
input.summary === undefined &&
|
|
326
|
+
input.body !== previous.body) {
|
|
327
|
+
throw arborError({
|
|
328
|
+
code: "invalid_input",
|
|
329
|
+
rule: "note.write.summary",
|
|
330
|
+
message: `note.write: a replace that changes the body of ${input.id} names a summary`,
|
|
331
|
+
nextAction: `write a summary of at most ${SUMMARY_LIMIT} characters for the new body`,
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
const anchor = input.anchor ?? null;
|
|
336
|
+
const deleted = input.delete === true;
|
|
337
|
+
// A replace that names no summary keeps the one it has.
|
|
338
|
+
const summary = input.summary ?? (previous === null ? deriveSummary(input.body) : previous.summary);
|
|
339
|
+
const status = deleted ? "deleted" : (previous?.status ?? "archived");
|
|
340
|
+
const body = deleted ? "" : input.body;
|
|
341
|
+
const refs = input.refs === undefined || input.refs.length === 0 ? null : JSON.stringify([...input.refs]);
|
|
342
|
+
const anchorJson = anchor === null ? null : JSON.stringify(anchor);
|
|
343
|
+
const window = anchor?.window ?? null;
|
|
344
|
+
if (created) {
|
|
345
|
+
const info = this.db
|
|
346
|
+
.prepare(`INSERT INTO notes (created, updated, revision, title, summary, body, status, window, anchor, event)
|
|
347
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, NULL)`)
|
|
348
|
+
.run(time, time, revision, input.title, summary, body, status, window, anchorJson);
|
|
349
|
+
id = toNumber(info.lastInsertRowid);
|
|
350
|
+
}
|
|
351
|
+
else {
|
|
352
|
+
this.db
|
|
353
|
+
.prepare(`UPDATE notes SET updated = ?, revision = ?, title = ?, summary = ?, body = ?,
|
|
354
|
+
status = ?, window = ?, anchor = ?, event = NULL
|
|
355
|
+
WHERE id = ?`)
|
|
356
|
+
.run(time, revision, input.title, summary, body, status, window, anchorJson, id);
|
|
357
|
+
}
|
|
358
|
+
this.db
|
|
359
|
+
.prepare(`INSERT INTO note_revisions (note, revision, title, summary, body, deleted, time, window, event, anchor, refs)
|
|
360
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, NULL, ?, ?)`)
|
|
361
|
+
.run(id, revision, input.title, summary, body, deleted ? 1 : 0, time, window, anchorJson, refs);
|
|
362
|
+
// The search index holds the current state; a deleted note is not found.
|
|
363
|
+
this.db.prepare(`DELETE FROM ${FTS_TABLE} WHERE rowid = ?`).run(id);
|
|
364
|
+
if (!deleted) {
|
|
365
|
+
this.db
|
|
366
|
+
.prepare(`INSERT INTO ${FTS_TABLE} (rowid, title, summary, body) VALUES (?, ?, ?, ?)`)
|
|
367
|
+
.run(id, input.title, summary, body);
|
|
368
|
+
}
|
|
369
|
+
// The refs the revision names: what it cites and what it holds as
|
|
370
|
+
// evidence. The evidence ones between notes are what the credit walk
|
|
371
|
+
// follows, and every one of them answers a `linked` lookup.
|
|
372
|
+
const insertRef = this.db.prepare(`INSERT OR IGNORE INTO note_refs (note, revision, kind, ref, to_note, weight, time)
|
|
373
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)`);
|
|
374
|
+
const named = (ref, kind, weight) => {
|
|
375
|
+
const toNote = parseNoteId(ref);
|
|
376
|
+
insertRef.run(id, revision, kind, ref, toNote, weight, time);
|
|
377
|
+
};
|
|
378
|
+
for (const ref of input.refs ?? [])
|
|
379
|
+
named(ref, "cite", USE_WEIGHTS.cite);
|
|
380
|
+
for (const ref of anchor?.evidence ?? [])
|
|
381
|
+
named(ref, "evidence", USE_WEIGHTS.evidence);
|
|
382
|
+
return {
|
|
383
|
+
id,
|
|
384
|
+
revision,
|
|
385
|
+
created,
|
|
386
|
+
previous,
|
|
387
|
+
time,
|
|
388
|
+
title: input.title,
|
|
389
|
+
summary,
|
|
390
|
+
window,
|
|
391
|
+
anchor,
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
/** Undo a placed revision whose journal event was refused. */
|
|
395
|
+
takeBack(written) {
|
|
396
|
+
inTransaction(this.db, () => {
|
|
397
|
+
this.db
|
|
398
|
+
.prepare("DELETE FROM note_revisions WHERE note = ? AND revision = ?")
|
|
399
|
+
.run(written.id, written.revision);
|
|
400
|
+
this.db
|
|
401
|
+
.prepare("DELETE FROM note_refs WHERE note = ? AND revision = ?")
|
|
402
|
+
.run(written.id, written.revision);
|
|
403
|
+
this.db.prepare(`DELETE FROM ${FTS_TABLE} WHERE rowid = ?`).run(written.id);
|
|
404
|
+
if (written.created) {
|
|
405
|
+
this.db.prepare("DELETE FROM notes WHERE id = ?").run(written.id);
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
const previous = written.previous;
|
|
409
|
+
this.db
|
|
410
|
+
.prepare(`UPDATE notes SET updated = ?, revision = ?, title = ?, summary = ?, body = ?,
|
|
411
|
+
status = ?, window = ?, anchor = ?, event = ?
|
|
412
|
+
WHERE id = ?`)
|
|
413
|
+
.run(previous.updated, previous.revision, previous.title, previous.summary, previous.body, previous.status, previous.window, previous.anchor, previous.event, written.id);
|
|
414
|
+
if (previous.status !== "deleted") {
|
|
415
|
+
this.db
|
|
416
|
+
.prepare(`INSERT INTO ${FTS_TABLE} (rowid, title, summary, body) VALUES (?, ?, ?, ?)`)
|
|
417
|
+
.run(written.id, previous.title, previous.summary, previous.body);
|
|
418
|
+
}
|
|
419
|
+
});
|
|
420
|
+
}
|
|
421
|
+
/**
|
|
422
|
+
* The refusal a stale write gets: what changed since the revision it read -
|
|
423
|
+
* the revisions in between, their windows and the differences - so the model
|
|
424
|
+
* merges them and writes again against the current revision.
|
|
425
|
+
*/
|
|
426
|
+
conflict(note, read) {
|
|
427
|
+
const current = note.revision;
|
|
428
|
+
const rows = this.db
|
|
429
|
+
.prepare(`SELECT note, revision, title, summary, time, window, anchor, refs FROM note_revisions
|
|
430
|
+
WHERE note = ? AND revision > ? AND revision <= ? ORDER BY revision`)
|
|
431
|
+
.all(note.id, read, current);
|
|
432
|
+
const revisions = rows.map(toNoteRevision);
|
|
433
|
+
const readRow = this.revisionRow(note.id, read);
|
|
434
|
+
const diff = readRow === null ? null : bodyDiff(readRow.body, note.body);
|
|
435
|
+
return arborError({
|
|
436
|
+
code: "conflict",
|
|
437
|
+
rule: "note.write.stale_revision",
|
|
438
|
+
message: `note.write: ${noteIdOf(note.id)} is at revision ${current}, not the revision ${read} you read; merge what changed and write again`,
|
|
439
|
+
nextAction: `read ${noteIdOf(note.id)}@${current} and the revisions since ${read}, merge them with this write, then write again against revision ${current}`,
|
|
440
|
+
refs: [noteRefOf(note.id, current), ...revisions.map((revision) => revision.ref)],
|
|
441
|
+
details: {
|
|
442
|
+
conflict: { read, current, revisions },
|
|
443
|
+
...(diff === null ? {} : { diff }),
|
|
444
|
+
},
|
|
445
|
+
});
|
|
446
|
+
}
|
|
447
|
+
// --- reads --------------------------------------------------------------
|
|
448
|
+
async read(ref, options) {
|
|
449
|
+
this.ensureOpen();
|
|
450
|
+
const parsed = parseOrThrow(ref, "read");
|
|
451
|
+
const row = parsed.revision === null ? this.latestRevisionRow(parsed.id) : this.revisionRow(parsed.id, parsed.revision);
|
|
452
|
+
if (row === null)
|
|
453
|
+
return null;
|
|
454
|
+
const characters = Array.from(row.body);
|
|
455
|
+
const offset = Math.max(0, Math.floor(options?.offset ?? 0));
|
|
456
|
+
const limit = clampBodyLimit(options?.limit);
|
|
457
|
+
const page = characters.slice(offset, offset + limit).join("");
|
|
458
|
+
// A read is one of the things the active set is scored by.
|
|
459
|
+
if (page !== "")
|
|
460
|
+
this.recordRead(parsed.id);
|
|
461
|
+
const out = noteRefOf(parsed.id, toNumber(row.revision));
|
|
462
|
+
return {
|
|
463
|
+
ref: out,
|
|
464
|
+
revision: toNumber(row.revision),
|
|
465
|
+
body: page,
|
|
466
|
+
total: characters.length,
|
|
467
|
+
nextCall: `notes.read(${out}, offset=${offset + limit})`,
|
|
468
|
+
};
|
|
469
|
+
}
|
|
470
|
+
async revisions(ref) {
|
|
471
|
+
this.ensureOpen();
|
|
472
|
+
const parsed = parseOrThrow(ref, "revisions");
|
|
473
|
+
const limit = DEFAULT_PAGE_LIMIT;
|
|
474
|
+
const nextCall = `notes.revisions(${noteIdOf(parsed.id)}, offset=${limit})`;
|
|
475
|
+
const note = this.noteRow(parsed.id);
|
|
476
|
+
if (note === null)
|
|
477
|
+
return { items: [], total: 0, limit, nextCall };
|
|
478
|
+
const rows = this.db
|
|
479
|
+
.prepare(`SELECT note, revision, title, summary, time, window, anchor, refs FROM note_revisions
|
|
480
|
+
WHERE note = ? ORDER BY revision LIMIT ?`)
|
|
481
|
+
.all(parsed.id, limit);
|
|
482
|
+
return { items: rows.map(toNoteRevision), total: note.revision, limit, nextCall };
|
|
483
|
+
}
|
|
484
|
+
async search(query = {}) {
|
|
485
|
+
this.ensureOpen();
|
|
486
|
+
const limit = clampLimit(query.limit);
|
|
487
|
+
const offset = Math.max(0, Math.floor(query.offset ?? 0));
|
|
488
|
+
const text = (query.query ?? "").trim();
|
|
489
|
+
// `about` and `linked` bound the result to the notes the journal resolved,
|
|
490
|
+
// in weight order; a text match narrows that set.
|
|
491
|
+
if (query.about !== undefined || query.linked !== undefined) {
|
|
492
|
+
const ordered = await this.linkedNoteIds(query.about, query.linked);
|
|
493
|
+
const holding = text === "" ? null : new Set(holdingIds(this.db, text, ordered));
|
|
494
|
+
const chosen = holding === null ? ordered : ordered.filter((id) => holding.has(id));
|
|
495
|
+
const page = this.summaryPageByIds(chosen, query, limit, offset);
|
|
496
|
+
return { ...page, nextCall: searchCall(query, offset, limit) };
|
|
497
|
+
}
|
|
498
|
+
const match = text === "" ? null : substringClause(text);
|
|
499
|
+
const page = this.summaryPageBySql(query, limit, offset, match);
|
|
500
|
+
return { ...page, nextCall: searchCall(query, offset, limit) };
|
|
501
|
+
}
|
|
502
|
+
async active() {
|
|
503
|
+
const snapshot = await this.snapshot();
|
|
504
|
+
return {
|
|
505
|
+
items: [...snapshot.items],
|
|
506
|
+
total: snapshot.total,
|
|
507
|
+
limit: snapshot.budget.count,
|
|
508
|
+
nextCall: `notes.active(offset=${snapshot.total})`,
|
|
509
|
+
};
|
|
510
|
+
}
|
|
511
|
+
async context(ref) {
|
|
512
|
+
this.ensureOpen();
|
|
513
|
+
const parsed = parseOrThrow(ref, "context");
|
|
514
|
+
const note = this.noteRow(parsed.id);
|
|
515
|
+
if (note === null)
|
|
516
|
+
return null;
|
|
517
|
+
const row = this.revisionRow(parsed.id, parsed.revision ?? note.revision);
|
|
518
|
+
if (row === null)
|
|
519
|
+
return null;
|
|
520
|
+
const anchor = row.anchor === null ? undefined : JSON.parse(row.anchor);
|
|
521
|
+
// The surroundings of a revision are the events around the one its anchor
|
|
522
|
+
// names, or around the write's own event when it names none.
|
|
523
|
+
const at = anchor?.event ?? storedEventRef(row.event) ?? undefined;
|
|
524
|
+
const events = [];
|
|
525
|
+
if (at !== undefined) {
|
|
526
|
+
const around = await this.journal.around(at, { before: 5, after: 5 });
|
|
527
|
+
events.push(...around.map((event) => event.ref));
|
|
528
|
+
}
|
|
529
|
+
return {
|
|
530
|
+
ref: noteRefOf(parsed.id, toNumber(row.revision)),
|
|
531
|
+
revision: toNumber(row.revision),
|
|
532
|
+
...(anchor === undefined ? {} : { anchor }),
|
|
533
|
+
events,
|
|
534
|
+
evidence: anchor?.evidence ?? [],
|
|
535
|
+
};
|
|
536
|
+
}
|
|
537
|
+
// --- the active set and its epochs -------------------------------------
|
|
538
|
+
async snapshot(options = {}) {
|
|
539
|
+
this.ensureOpen();
|
|
540
|
+
const now = this.clock();
|
|
541
|
+
const minIntervalMs = options.minIntervalMs ?? DEFAULT_MIN_INTERVAL_MS;
|
|
542
|
+
const budget = options.budget ?? DEFAULT_ACTIVE_BUDGET;
|
|
543
|
+
const judgeShare = options.judgeShare ?? DEFAULT_JUDGE_SHARE;
|
|
544
|
+
return inTransaction(this.db, () => {
|
|
545
|
+
const epoch = this.epochAt(now, minIntervalMs, budget, options.judgeKeep ?? [], judgeShare);
|
|
546
|
+
const items = this.epochSummaries(epoch.epoch);
|
|
547
|
+
return {
|
|
548
|
+
epoch: epoch.epoch,
|
|
549
|
+
time: epoch.time,
|
|
550
|
+
items,
|
|
551
|
+
total: items.length,
|
|
552
|
+
limit: items.length,
|
|
553
|
+
nextCall: `notes.delta(${epoch.epoch})`,
|
|
554
|
+
budget,
|
|
555
|
+
};
|
|
556
|
+
});
|
|
557
|
+
}
|
|
558
|
+
async delta(since, options) {
|
|
559
|
+
this.ensureOpen();
|
|
560
|
+
const limit = clampLimit(options?.limit);
|
|
561
|
+
const offset = Math.max(0, Math.floor(options?.offset ?? 0));
|
|
562
|
+
const epoch = since === 0 ? null : this.epochRow(since);
|
|
563
|
+
if (since !== 0 && epoch === null) {
|
|
564
|
+
throw arborError({
|
|
565
|
+
code: "invalid_input",
|
|
566
|
+
rule: "note.delta.epoch",
|
|
567
|
+
message: `note.delta: epoch ${since} is not in the notes`,
|
|
568
|
+
nextAction: "pass the epoch the window froze, or 0 for every note",
|
|
569
|
+
details: { since },
|
|
570
|
+
});
|
|
571
|
+
}
|
|
572
|
+
const after = epoch?.wseq ?? 0;
|
|
573
|
+
const rows = this.db
|
|
574
|
+
.prepare(`SELECT n.id AS id, n.revision AS revision, n.title AS title, n.summary AS summary,
|
|
575
|
+
n.updated AS updated, n.window AS window, n.status AS status,
|
|
576
|
+
r.wseq AS wseq,
|
|
577
|
+
(SELECT MIN(r2.wseq) FROM note_revisions r2 WHERE r2.note = n.id) AS created_wseq
|
|
578
|
+
FROM notes n JOIN note_revisions r ON r.note = n.id AND r.revision = n.revision
|
|
579
|
+
WHERE r.wseq > ? ORDER BY r.wseq LIMIT ? OFFSET ?`)
|
|
580
|
+
.all(after, limit, offset);
|
|
581
|
+
const total = toNumber(this.db
|
|
582
|
+
.prepare(`SELECT count(*) AS n FROM notes n
|
|
583
|
+
JOIN note_revisions r ON r.note = n.id AND r.revision = n.revision
|
|
584
|
+
WHERE r.wseq > ?`)
|
|
585
|
+
.get(after).n);
|
|
586
|
+
const items = rows.map((row) => ({
|
|
587
|
+
ref: noteRefOf(toNumber(row.id), toNumber(row.revision)),
|
|
588
|
+
id: noteIdOf(toNumber(row.id)),
|
|
589
|
+
revision: toNumber(row.revision),
|
|
590
|
+
title: row.title,
|
|
591
|
+
summary: row.summary,
|
|
592
|
+
// A note written after the epoch is new to a window that froze it; one
|
|
593
|
+
// written before it and changed since has been revised.
|
|
594
|
+
change: toNumber(row.created_wseq) > after
|
|
595
|
+
? "created"
|
|
596
|
+
: row.status === "deleted"
|
|
597
|
+
? "deleted"
|
|
598
|
+
: "revised",
|
|
599
|
+
window: storedWindowRef(row.window),
|
|
600
|
+
time: toNumber(row.updated),
|
|
601
|
+
}));
|
|
602
|
+
const current = this.latestEpoch();
|
|
603
|
+
return {
|
|
604
|
+
since,
|
|
605
|
+
epoch: current?.epoch ?? 0,
|
|
606
|
+
items,
|
|
607
|
+
total,
|
|
608
|
+
limit,
|
|
609
|
+
nextCall: `notes.delta(${since}, offset=${offset + limit})`,
|
|
610
|
+
};
|
|
611
|
+
}
|
|
612
|
+
async score(options) {
|
|
613
|
+
this.ensureOpen();
|
|
614
|
+
const limit = clampLimit(options?.limit);
|
|
615
|
+
const offset = Math.max(0, Math.floor(options?.offset ?? 0));
|
|
616
|
+
const scored = scoreNotes(this.db, this.clock(), this.halfLifeMs);
|
|
617
|
+
const page = scored.slice(offset, offset + limit);
|
|
618
|
+
const states = this.noteStatesFor(page.map((item) => item.note));
|
|
619
|
+
const items = page.map((item) => ({
|
|
620
|
+
ref: noteRefOf(item.note, states.get(item.note)?.revision ?? 0),
|
|
621
|
+
id: noteIdOf(item.note),
|
|
622
|
+
summary: states.get(item.note)?.summary ?? "",
|
|
623
|
+
score: item.score,
|
|
624
|
+
}));
|
|
625
|
+
return {
|
|
626
|
+
items,
|
|
627
|
+
total: scored.length,
|
|
628
|
+
limit,
|
|
629
|
+
nextCall: `notes.score(offset=${offset + limit})`,
|
|
630
|
+
};
|
|
631
|
+
}
|
|
632
|
+
async recordUse(input) {
|
|
633
|
+
this.ensureOpen();
|
|
634
|
+
const parsed = parseOrThrow(input.ref, "recordUse");
|
|
635
|
+
// A use of a note that is not there names a ref that does not resolve; the
|
|
636
|
+
// host's own append must not fail for it.
|
|
637
|
+
if (this.noteRow(parsed.id) === null)
|
|
638
|
+
return;
|
|
639
|
+
const time = input.time ?? this.clock();
|
|
640
|
+
const weight = input.weight ?? (input.kind === "read" ? USE_WEIGHTS.read : 1);
|
|
641
|
+
inTransaction(this.db, () => {
|
|
642
|
+
if (input.event !== undefined) {
|
|
643
|
+
const existing = this.db
|
|
644
|
+
.prepare("SELECT rowid FROM note_usage WHERE note = ? AND kind = ? AND event = ?")
|
|
645
|
+
.get(parsed.id, input.kind, input.event);
|
|
646
|
+
if (existing !== undefined) {
|
|
647
|
+
this.db
|
|
648
|
+
.prepare("UPDATE note_usage SET weight = ?, window = ?, time = ? WHERE rowid = ?")
|
|
649
|
+
.run(weight, input.window ?? null, time, toNumber(existing.rowid));
|
|
650
|
+
return;
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
this.db
|
|
654
|
+
.prepare("INSERT INTO note_usage (note, kind, weight, window, event, time) VALUES (?, ?, ?, ?, ?, ?)")
|
|
655
|
+
.run(parsed.id, input.kind, weight, input.window ?? null, input.event ?? null, time);
|
|
656
|
+
});
|
|
657
|
+
}
|
|
658
|
+
async close() {
|
|
659
|
+
if (this.closed)
|
|
660
|
+
return;
|
|
661
|
+
this.closed = true;
|
|
662
|
+
await this.writeQueue;
|
|
663
|
+
this.db.close();
|
|
664
|
+
}
|
|
665
|
+
// --- internals ---------------------------------------------------------
|
|
666
|
+
ensureOpen() {
|
|
667
|
+
if (this.closed) {
|
|
668
|
+
throw arborError({
|
|
669
|
+
code: "unavailable",
|
|
670
|
+
rule: "note.closed",
|
|
671
|
+
message: "notes: the note store is closed",
|
|
672
|
+
nextAction: "open a note store before reading or writing",
|
|
673
|
+
});
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
noteRow(id) {
|
|
677
|
+
const row = this.db
|
|
678
|
+
.prepare(`SELECT id, created, updated, revision, title, summary, body, status, window, anchor, event
|
|
679
|
+
FROM notes WHERE id = ?`)
|
|
680
|
+
.get(id);
|
|
681
|
+
return row === undefined ? null : row;
|
|
682
|
+
}
|
|
683
|
+
revisionRow(note, revision) {
|
|
684
|
+
const row = this.db
|
|
685
|
+
.prepare(`SELECT wseq, note, revision, title, summary, body, deleted, time, window, event, anchor, refs
|
|
686
|
+
FROM note_revisions WHERE note = ? AND revision = ?`)
|
|
687
|
+
.get(note, revision);
|
|
688
|
+
return row === undefined ? null : row;
|
|
689
|
+
}
|
|
690
|
+
latestRevisionRow(note) {
|
|
691
|
+
const current = this.noteRow(note);
|
|
692
|
+
if (current === null || current.status === "deleted")
|
|
693
|
+
return null;
|
|
694
|
+
return this.revisionRow(note, current.revision);
|
|
695
|
+
}
|
|
696
|
+
recordRead(note) {
|
|
697
|
+
this.db
|
|
698
|
+
.prepare(`INSERT INTO note_usage (note, kind, weight, window, event, time)
|
|
699
|
+
VALUES (?, 'read', ?, NULL, NULL, ?)`)
|
|
700
|
+
.run(note, USE_WEIGHTS.read, this.clock());
|
|
701
|
+
}
|
|
702
|
+
/** The current summary and revision of several notes, in one read. */
|
|
703
|
+
noteStatesFor(ids) {
|
|
704
|
+
const states = new Map();
|
|
705
|
+
if (ids.length === 0)
|
|
706
|
+
return states;
|
|
707
|
+
const rows = this.db
|
|
708
|
+
.prepare(`SELECT id, summary, revision FROM notes WHERE id IN (${ids.map(() => "?").join(", ")})`)
|
|
709
|
+
.all(...ids);
|
|
710
|
+
for (const row of rows) {
|
|
711
|
+
states.set(toNumber(row.id), { summary: row.summary, revision: toNumber(row.revision) });
|
|
712
|
+
}
|
|
713
|
+
return states;
|
|
714
|
+
}
|
|
715
|
+
summaryPageBySql(query, limit, offset, match) {
|
|
716
|
+
const clauses = [];
|
|
717
|
+
const params = [];
|
|
718
|
+
if (match !== null) {
|
|
719
|
+
clauses.push(match.sql);
|
|
720
|
+
params.push(...match.params);
|
|
721
|
+
}
|
|
722
|
+
if (query.status !== undefined) {
|
|
723
|
+
clauses.push("n.status = ?");
|
|
724
|
+
params.push(query.status);
|
|
725
|
+
}
|
|
726
|
+
const where = clauses.length === 0 ? "" : ` WHERE ${clauses.join(" AND ")}`;
|
|
727
|
+
const rows = this.db
|
|
728
|
+
.prepare(`SELECT n.id AS id, n.revision AS revision, n.title AS title, n.summary AS summary,
|
|
729
|
+
n.status AS status, n.updated AS updated
|
|
730
|
+
FROM notes n${where} ORDER BY n.id LIMIT ? OFFSET ?`)
|
|
731
|
+
.all(...params, limit, offset);
|
|
732
|
+
const total = toNumber(this.db
|
|
733
|
+
.prepare(`SELECT count(*) AS n FROM notes n${where}`)
|
|
734
|
+
.get(...params).n);
|
|
735
|
+
return { items: rows.map(toSummary), total, limit };
|
|
736
|
+
}
|
|
737
|
+
summaryPageByIds(ordered, query, limit, offset) {
|
|
738
|
+
if (ordered.length === 0)
|
|
739
|
+
return { items: [], total: 0, limit };
|
|
740
|
+
const clauses = [`id IN (${ordered.map(() => "?").join(", ")})`];
|
|
741
|
+
const params = [...ordered];
|
|
742
|
+
if (query.status !== undefined) {
|
|
743
|
+
clauses.push("status = ?");
|
|
744
|
+
params.push(query.status);
|
|
745
|
+
}
|
|
746
|
+
const rows = this.db
|
|
747
|
+
.prepare(`SELECT id, revision, title, summary, status, updated FROM notes
|
|
748
|
+
WHERE ${clauses.join(" AND ")}`)
|
|
749
|
+
.all(...params);
|
|
750
|
+
const rank = new Map(ordered.map((id, index) => [id, index]));
|
|
751
|
+
const sorted = [...rows].sort((a, b) => (rank.get(toNumber(a.id)) ?? 0) - (rank.get(toNumber(b.id)) ?? 0));
|
|
752
|
+
return { items: sorted.slice(offset, offset + limit).map(toSummary), total: sorted.length, limit };
|
|
753
|
+
}
|
|
754
|
+
/**
|
|
755
|
+
* The notes about a target, or linked to a ref: the notes the events on that
|
|
756
|
+
* target name, and the notes whose revisions name that ref. A note the store
|
|
757
|
+
* has never heard of is skipped, so a hallucinated ref costs nothing.
|
|
758
|
+
*/
|
|
759
|
+
async linkedNoteIds(about, linked) {
|
|
760
|
+
const aboutIds = about === undefined ? null : await this.notesAbout(about);
|
|
761
|
+
const linkedIds = linked === undefined ? null : this.notesLinkedTo(linked);
|
|
762
|
+
if (aboutIds !== null && linkedIds !== null) {
|
|
763
|
+
const keep = new Set(linkedIds);
|
|
764
|
+
return aboutIds.filter((id) => keep.has(id));
|
|
765
|
+
}
|
|
766
|
+
return aboutIds ?? linkedIds ?? [];
|
|
767
|
+
}
|
|
768
|
+
/** The notes the events on a target name, strongest link first. */
|
|
769
|
+
async notesAbout(target) {
|
|
770
|
+
const page = await this.journal.list({ targets: [target], limit: ABOUT_EVENT_LIMIT });
|
|
771
|
+
const weights = new Map();
|
|
772
|
+
const add = (ref, weight) => {
|
|
773
|
+
const id = parseNoteId(ref);
|
|
774
|
+
if (id === null)
|
|
775
|
+
return;
|
|
776
|
+
if (weight > (weights.get(id) ?? 0))
|
|
777
|
+
weights.set(id, weight);
|
|
778
|
+
};
|
|
779
|
+
for (const event of page.items) {
|
|
780
|
+
for (const link of event.links)
|
|
781
|
+
add(link.to, link.weight);
|
|
782
|
+
}
|
|
783
|
+
return [...weights.entries()].sort((a, b) => b[1] - a[1] || a[0] - b[0]).map(([id]) => id);
|
|
784
|
+
}
|
|
785
|
+
/** The notes whose revisions name a ref, whichever revision named it. */
|
|
786
|
+
notesLinkedTo(ref) {
|
|
787
|
+
// A note is named under the revision it was cited at, and under its bare id.
|
|
788
|
+
const forms = noteRefForms(ref);
|
|
789
|
+
const rows = this.db
|
|
790
|
+
.prepare(`SELECT note, MAX(weight) AS weight FROM note_refs
|
|
791
|
+
WHERE ref IN (${forms.map(() => "?").join(", ")})
|
|
792
|
+
GROUP BY note ORDER BY weight DESC, note`)
|
|
793
|
+
.all(...forms);
|
|
794
|
+
return rows.map((row) => toNumber(row.note));
|
|
795
|
+
}
|
|
796
|
+
latestEpoch() {
|
|
797
|
+
const row = this.db
|
|
798
|
+
.prepare("SELECT epoch, time, wseq, budget_count, budget_tokens FROM note_epochs ORDER BY epoch DESC LIMIT 1")
|
|
799
|
+
.get();
|
|
800
|
+
return row === undefined ? null : row;
|
|
801
|
+
}
|
|
802
|
+
epochRow(epoch) {
|
|
803
|
+
const row = this.db
|
|
804
|
+
.prepare("SELECT epoch, time, wseq, budget_count, budget_tokens FROM note_epochs WHERE epoch = ?")
|
|
805
|
+
.get(epoch);
|
|
806
|
+
return row === undefined ? null : row;
|
|
807
|
+
}
|
|
808
|
+
newestWriteSeq() {
|
|
809
|
+
const row = this.db.prepare("SELECT MAX(wseq) AS wseq FROM note_revisions").get();
|
|
810
|
+
return row?.wseq == null ? 0 : toNumber(row.wseq);
|
|
811
|
+
}
|
|
812
|
+
/**
|
|
813
|
+
* The epoch a window freezes its prefix at. A new one is built at most every
|
|
814
|
+
* `minIntervalMs`, and only when a note was written since the last one or the
|
|
815
|
+
* budget changed, so every window opened in one epoch has the same prefix and
|
|
816
|
+
* the changes since reach it as a delta.
|
|
817
|
+
*/
|
|
818
|
+
epochAt(now, minIntervalMs, budget, judgeKeep, judgeShare) {
|
|
819
|
+
const last = this.latestEpoch();
|
|
820
|
+
const newest = this.newestWriteSeq();
|
|
821
|
+
const changed = last === null ||
|
|
822
|
+
newest > last.wseq ||
|
|
823
|
+
last.budget_count !== budget.count ||
|
|
824
|
+
last.budget_tokens !== budget.tokens;
|
|
825
|
+
if (last !== null && (!changed || now - last.time < minIntervalMs))
|
|
826
|
+
return last;
|
|
827
|
+
const scored = scoreNotes(this.db, now, this.halfLifeMs);
|
|
828
|
+
const picked = pickByBudget(scored, budget);
|
|
829
|
+
const chosen = applyJudgeShare(picked, new Map(scored.map((item) => [item.note, item])), judgeKeep, judgeShare, budget);
|
|
830
|
+
const info = this.db
|
|
831
|
+
.prepare("INSERT INTO note_epochs (time, wseq, budget_count, budget_tokens) VALUES (?, ?, ?, ?)")
|
|
832
|
+
.run(now, newest, budget.count, budget.tokens);
|
|
833
|
+
const epoch = toNumber(info.lastInsertRowid);
|
|
834
|
+
const insertItem = this.db.prepare(`INSERT INTO note_epoch_items (epoch, note, revision, title, summary, tokens, time)
|
|
835
|
+
SELECT ?, n.id, n.revision, n.title, n.summary, ?, n.updated FROM notes n WHERE n.id = ?`);
|
|
836
|
+
for (const item of chosen)
|
|
837
|
+
insertItem.run(epoch, item.tokens, item.note);
|
|
838
|
+
this.db.prepare("UPDATE notes SET status = 'archived' WHERE status = 'active'").run();
|
|
839
|
+
const activate = this.db.prepare("UPDATE notes SET status = 'active' WHERE id = ?");
|
|
840
|
+
for (const item of chosen)
|
|
841
|
+
activate.run(item.note);
|
|
842
|
+
return this.epochRow(epoch);
|
|
843
|
+
}
|
|
844
|
+
/** The active notes one epoch froze, by summary, sorted by id. */
|
|
845
|
+
epochSummaries(epoch) {
|
|
846
|
+
const rows = this.db
|
|
847
|
+
.prepare(`SELECT i.note AS note, i.revision AS revision, i.title AS title, i.summary AS summary,
|
|
848
|
+
i.time AS time, n.status AS status
|
|
849
|
+
FROM note_epoch_items i JOIN notes n ON n.id = i.note
|
|
850
|
+
WHERE i.epoch = ? AND n.status != 'deleted'
|
|
851
|
+
ORDER BY i.note`)
|
|
852
|
+
.all(epoch);
|
|
853
|
+
return rows.map((row) => {
|
|
854
|
+
const note = toNumber(row.note);
|
|
855
|
+
const revision = toNumber(row.revision);
|
|
856
|
+
return {
|
|
857
|
+
ref: noteRefOf(note, revision),
|
|
858
|
+
id: noteIdOf(note),
|
|
859
|
+
title: row.title,
|
|
860
|
+
summary: row.summary,
|
|
861
|
+
status: row.status,
|
|
862
|
+
revision,
|
|
863
|
+
time: toNumber(row.time),
|
|
864
|
+
};
|
|
865
|
+
});
|
|
866
|
+
}
|
|
867
|
+
}
|