@openparachute/vault 0.7.0-rc.9 → 0.7.2-rc.3
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/core/src/aggregate.test.ts +260 -0
- package/core/src/contract-taxonomy.test.ts +26 -2
- package/core/src/contract-typed-index.test.ts +4 -2
- package/core/src/core.test.ts +737 -2
- package/core/src/cursor-keyset-ms.test.ts +537 -0
- package/core/src/cursor.ts +76 -6
- package/core/src/doctor.ts +23 -1
- package/core/src/hooks.ts +9 -0
- package/core/src/indexed-fields.test.ts +4 -1
- package/core/src/indexed-fields.ts +6 -1
- package/core/src/links.ts +22 -0
- package/core/src/mcp.ts +322 -53
- package/core/src/notes.ts +518 -67
- package/core/src/paths.ts +4 -0
- package/core/src/portable-md.test.ts +161 -0
- package/core/src/portable-md.ts +140 -9
- package/core/src/query-warnings.ts +60 -12
- package/core/src/schema-defaults.ts +7 -1
- package/core/src/schema.ts +128 -2
- package/core/src/seed-packs.ts +55 -15
- package/core/src/store.ts +141 -7
- package/core/src/tag-schemas.ts +20 -7
- package/core/src/txn.test.ts +100 -4
- package/core/src/txn.ts +119 -24
- package/core/src/types.ts +89 -0
- package/core/src/ulid.test.ts +56 -0
- package/core/src/ulid.ts +116 -0
- package/core/src/vault-projection.ts +19 -1
- package/core/src/wikilinks.test.ts +205 -1
- package/core/src/wikilinks.ts +200 -35
- package/package.json +1 -1
- package/src/aggregate-routes.test.ts +230 -0
- package/src/cli.ts +6 -0
- package/src/contract-errors.test.ts +2 -1
- package/src/contract-search.test.ts +17 -0
- package/src/mcp-link-warnings-scope.test.ts +144 -0
- package/src/mcp-query-notes-aggregate-scope.test.ts +183 -0
- package/src/mcp-tools.ts +76 -17
- package/src/mirror-import.ts +5 -0
- package/src/routes.ts +298 -24
- package/src/routing.test.ts +167 -0
- package/src/routing.ts +47 -11
- package/src/tag-integrity-mcp.test.ts +45 -6
- package/src/tag-scope.ts +10 -1
- package/src/vault.test.ts +557 -21
- package/web/ui/dist/assets/{index-B8pGeQam.js → index-CJ6NtIwh.js} +1 -1
- package/web/ui/dist/index.html +1 -1
package/core/src/txn.test.ts
CHANGED
|
@@ -89,9 +89,12 @@ describe("transaction (sync)", () => {
|
|
|
89
89
|
expect(calls).toEqual(["BEGIN IMMEDIATE", "COMMIT", "ROLLBACK"]);
|
|
90
90
|
});
|
|
91
91
|
|
|
92
|
-
it("preserves the callback error
|
|
92
|
+
it("preserves the callback error when ROLLBACK fails for the expected 'already-resolved' reason", () => {
|
|
93
93
|
const callbackErr = new Error("callback boom");
|
|
94
|
-
|
|
94
|
+
// The one swallowed case: the txn was already resolved, so ROLLBACK is a
|
|
95
|
+
// no-op that reports "no transaction is active". The ORIGINAL callback
|
|
96
|
+
// error is the root cause and must still propagate.
|
|
97
|
+
const rollbackErr = new Error("cannot rollback - no transaction is active");
|
|
95
98
|
const { db } = fakeDb({ rollbackThrows: rollbackErr });
|
|
96
99
|
let thrown: unknown;
|
|
97
100
|
try {
|
|
@@ -101,6 +104,22 @@ describe("transaction (sync)", () => {
|
|
|
101
104
|
}
|
|
102
105
|
expect(thrown).toBe(callbackErr);
|
|
103
106
|
});
|
|
107
|
+
|
|
108
|
+
it("re-throws an UNEXPECTED ROLLBACK failure instead of silently masking it (vault#589 NIT 2)", () => {
|
|
109
|
+
const callbackErr = new Error("callback boom");
|
|
110
|
+
// A rollback failure that is NOT the benign "already-resolved" case (a
|
|
111
|
+
// corrupt connection / lost write lock) means the transaction may NOT have
|
|
112
|
+
// rolled back — surfacing it beats a false "clean rollback".
|
|
113
|
+
const rollbackErr = new Error("database disk image is malformed");
|
|
114
|
+
const { db } = fakeDb({ rollbackThrows: rollbackErr });
|
|
115
|
+
let thrown: unknown;
|
|
116
|
+
try {
|
|
117
|
+
transaction(db, () => { throw callbackErr; });
|
|
118
|
+
} catch (e) {
|
|
119
|
+
thrown = e;
|
|
120
|
+
}
|
|
121
|
+
expect(thrown).toBe(rollbackErr);
|
|
122
|
+
});
|
|
104
123
|
});
|
|
105
124
|
|
|
106
125
|
describe("transaction — native transactionSync preference (DO backend)", () => {
|
|
@@ -214,9 +233,9 @@ describe("transactionAsync", () => {
|
|
|
214
233
|
expect(calls).toEqual(["BEGIN IMMEDIATE", "COMMIT", "ROLLBACK"]);
|
|
215
234
|
});
|
|
216
235
|
|
|
217
|
-
it("preserves the callback error
|
|
236
|
+
it("preserves the callback error when ROLLBACK fails for the expected 'already-resolved' reason", async () => {
|
|
218
237
|
const callbackErr = new Error("callback boom");
|
|
219
|
-
const rollbackErr = new Error("rollback
|
|
238
|
+
const rollbackErr = new Error("cannot rollback - no transaction is active");
|
|
220
239
|
const { db } = fakeDb({ rollbackThrows: rollbackErr });
|
|
221
240
|
let thrown: unknown;
|
|
222
241
|
try {
|
|
@@ -226,4 +245,81 @@ describe("transactionAsync", () => {
|
|
|
226
245
|
}
|
|
227
246
|
expect(thrown).toBe(callbackErr);
|
|
228
247
|
});
|
|
248
|
+
|
|
249
|
+
it("re-throws an UNEXPECTED ROLLBACK failure instead of silently masking it (vault#589 NIT 2)", async () => {
|
|
250
|
+
const callbackErr = new Error("callback boom");
|
|
251
|
+
const rollbackErr = new Error("database disk image is malformed");
|
|
252
|
+
const { db } = fakeDb({ rollbackThrows: rollbackErr });
|
|
253
|
+
let thrown: unknown;
|
|
254
|
+
try {
|
|
255
|
+
await transactionAsync(db, async () => { throw callbackErr; });
|
|
256
|
+
} catch (e) {
|
|
257
|
+
thrown = e;
|
|
258
|
+
}
|
|
259
|
+
expect(thrown).toBe(rollbackErr);
|
|
260
|
+
});
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
describe("transaction — re-entrancy via SAVEPOINTs (vault#589)", () => {
|
|
264
|
+
it("a nested sync transaction uses SAVEPOINT/RELEASE, not a second BEGIN", () => {
|
|
265
|
+
const { db, calls } = fakeDb();
|
|
266
|
+
const out = transaction(db, () => {
|
|
267
|
+
const inner = transaction(db, () => "inner");
|
|
268
|
+
expect(inner).toBe("inner");
|
|
269
|
+
return "outer";
|
|
270
|
+
});
|
|
271
|
+
expect(out).toBe("outer");
|
|
272
|
+
expect(calls).toEqual([
|
|
273
|
+
"BEGIN IMMEDIATE",
|
|
274
|
+
"SAVEPOINT parachute_sp_1",
|
|
275
|
+
"RELEASE parachute_sp_1",
|
|
276
|
+
"COMMIT",
|
|
277
|
+
]);
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
it("an inner throw rolls back to the savepoint; the outer can still commit", () => {
|
|
281
|
+
const db = freshDb();
|
|
282
|
+
transaction(db, () => {
|
|
283
|
+
db.exec("INSERT INTO t (id, v) VALUES (1, 'keep')");
|
|
284
|
+
try {
|
|
285
|
+
transaction(db, () => {
|
|
286
|
+
db.exec("INSERT INTO t (id, v) VALUES (2, 'drop')");
|
|
287
|
+
throw new Error("inner boom");
|
|
288
|
+
});
|
|
289
|
+
} catch {
|
|
290
|
+
// Outer decides to continue past the failed nested block.
|
|
291
|
+
}
|
|
292
|
+
db.exec("INSERT INTO t (id, v) VALUES (3, 'keep2')");
|
|
293
|
+
});
|
|
294
|
+
// Only the savepoint'd insert (2) was undone; 1 and 3 committed.
|
|
295
|
+
const ids = (db.prepare("SELECT id FROM t ORDER BY id").all() as { id: number }[]).map((r) => r.id);
|
|
296
|
+
expect(ids).toEqual([1, 3]);
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
it("an UNCAUGHT inner throw rolls back the WHOLE outer transaction", () => {
|
|
300
|
+
const db = freshDb();
|
|
301
|
+
expect(() =>
|
|
302
|
+
transaction(db, () => {
|
|
303
|
+
db.exec("INSERT INTO t (id, v) VALUES (1, 'a')");
|
|
304
|
+
transaction(db, () => {
|
|
305
|
+
db.exec("INSERT INTO t (id, v) VALUES (2, 'b')");
|
|
306
|
+
throw new Error("boom");
|
|
307
|
+
});
|
|
308
|
+
}),
|
|
309
|
+
).toThrow("boom");
|
|
310
|
+
expect((db.prepare("SELECT COUNT(*) AS c FROM t").get() as { c: number }).c).toBe(0);
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
it("transactionAsync composes a nested sync transaction via SAVEPOINT (the atomic blow-away shape)", async () => {
|
|
314
|
+
const { db, calls } = fakeDb();
|
|
315
|
+
await transactionAsync(db, async () => {
|
|
316
|
+
transaction(db, () => "nested-sync-write");
|
|
317
|
+
});
|
|
318
|
+
expect(calls).toEqual([
|
|
319
|
+
"BEGIN IMMEDIATE",
|
|
320
|
+
"SAVEPOINT parachute_sp_1",
|
|
321
|
+
"RELEASE parachute_sp_1",
|
|
322
|
+
"COMMIT",
|
|
323
|
+
]);
|
|
324
|
+
});
|
|
229
325
|
});
|
package/core/src/txn.ts
CHANGED
|
@@ -14,12 +14,40 @@
|
|
|
14
14
|
* one of them only ever wrote, so acquiring the write lock eagerly is a
|
|
15
15
|
* strict improvement (no lazy busy-upgrade) and never a semantic change.
|
|
16
16
|
*
|
|
17
|
-
* **Nesting
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
* `
|
|
21
|
-
*
|
|
22
|
-
*
|
|
17
|
+
* **Nesting via SAVEPOINTs** (vault#589): the outermost `transaction` /
|
|
18
|
+
* `transactionAsync` on a connection opens a real `BEGIN IMMEDIATE`; a nested
|
|
19
|
+
* call (one whose connection is already inside a transaction) opens a
|
|
20
|
+
* `SAVEPOINT` instead, so composing an atomic block out of primitives that
|
|
21
|
+
* each open their own transaction never hits SQLite's "cannot start a
|
|
22
|
+
* transaction within a transaction". This is the re-entrancy the original
|
|
23
|
+
* seam anticipated ("the fix is SAVEPOINT-based re-entrancy in the bun
|
|
24
|
+
* implementation here — the call sites stay untouched"). The nesting depth is
|
|
25
|
+
* tracked per connection in {@link txnDepth}; a non-nested caller (depth 0 →
|
|
26
|
+
* 1) is byte-for-byte the old `BEGIN … COMMIT` / `ROLLBACK` path, so existing
|
|
27
|
+
* single-level callers are unaffected. The motivating nested caller is the
|
|
28
|
+
* atomic blow-away import (`importVault` wrapping schema/note/link writes,
|
|
29
|
+
* several of which open their own `transaction`).
|
|
30
|
+
*
|
|
31
|
+
* The `transactionSync` (Durable-Object) delegate path is unchanged — a DO
|
|
32
|
+
* backend supplies its own natively re-entrant primitive, so the SAVEPOINT
|
|
33
|
+
* bookkeeping here only governs the bun `BEGIN` path.
|
|
34
|
+
*
|
|
35
|
+
* **Shared-connection invariant (load-bearing for {@link transactionAsync}).**
|
|
36
|
+
* The bun backend is a single synchronous connection. A `transactionAsync`
|
|
37
|
+
* body — AND anything it calls — must not `await` genuine, slow I/O while the
|
|
38
|
+
* transaction is open: every `await` yields the event loop, and any work that
|
|
39
|
+
* runs during that yield and touches the store (a concurrent request, a
|
|
40
|
+
* mutation hook that writes before its first real await) will `SAVEPOINT`-JOIN
|
|
41
|
+
* this open transaction on the shared connection — committing/rolling back
|
|
42
|
+
* with it, its writes cross-wiped by a `ROLLBACK TO`. The blow-away import
|
|
43
|
+
* (the sole `transactionAsync` wrapping non-batch work) is safe by
|
|
44
|
+
* construction: its replay awaits only already-resolved promises over the
|
|
45
|
+
* synchronous bun store, so it never actually yields to unrelated work
|
|
46
|
+
* mid-transaction, and the mutation hooks it fires do no synchronous store
|
|
47
|
+
* writes (they defer via `queueMicrotask` and arm debounces / kick idempotent
|
|
48
|
+
* workers — see hooks.ts). Any NEW `transactionAsync` caller must preserve
|
|
49
|
+
* this: keep the body's awaits confined to the same synchronous connection,
|
|
50
|
+
* never awaiting network/disk/subprocess I/O with the transaction open.
|
|
23
51
|
*/
|
|
24
52
|
|
|
25
53
|
/** The minimal DB surface the transaction seam needs — kept structural (no
|
|
@@ -38,6 +66,79 @@ export interface TxnCapableDb {
|
|
|
38
66
|
transactionSync?<T>(fn: () => T): T;
|
|
39
67
|
}
|
|
40
68
|
|
|
69
|
+
/**
|
|
70
|
+
* Per-connection nesting depth for the bun `BEGIN` path. Keyed on the db
|
|
71
|
+
* handle so two connections track independently; a `WeakMap` so a dropped
|
|
72
|
+
* connection's entry is collected. Depth 0 = no open transaction; the first
|
|
73
|
+
* `transaction`/`transactionAsync` opens `BEGIN IMMEDIATE` and sets depth 1;
|
|
74
|
+
* each further nested call opens a `SAVEPOINT` and bumps the depth. See the
|
|
75
|
+
* file header (vault#589).
|
|
76
|
+
*/
|
|
77
|
+
const txnDepth = new WeakMap<TxnCapableDb, number>();
|
|
78
|
+
|
|
79
|
+
/** One entry on the nesting stack: whether this frame owns the outer
|
|
80
|
+
* `BEGIN … COMMIT`/`ROLLBACK` (outermost) or a `SAVEPOINT` (nested), plus the
|
|
81
|
+
* depth to restore on exit. */
|
|
82
|
+
interface TxnFrame {
|
|
83
|
+
outermost: boolean;
|
|
84
|
+
savepoint: string | null;
|
|
85
|
+
priorDepth: number;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Open a transaction frame: `BEGIN IMMEDIATE` at depth 0, else a uniquely
|
|
89
|
+
* named `SAVEPOINT`. Bumps {@link txnDepth} only after the SQL succeeds. */
|
|
90
|
+
function enterTxn(db: TxnCapableDb): TxnFrame {
|
|
91
|
+
const priorDepth = txnDepth.get(db) ?? 0;
|
|
92
|
+
if (priorDepth === 0) {
|
|
93
|
+
db.exec("BEGIN IMMEDIATE");
|
|
94
|
+
txnDepth.set(db, 1);
|
|
95
|
+
return { outermost: true, savepoint: null, priorDepth };
|
|
96
|
+
}
|
|
97
|
+
const savepoint = `parachute_sp_${priorDepth}`;
|
|
98
|
+
db.exec(`SAVEPOINT ${savepoint}`);
|
|
99
|
+
txnDepth.set(db, priorDepth + 1);
|
|
100
|
+
return { outermost: false, savepoint, priorDepth };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Commit a frame: `COMMIT` for the outermost, `RELEASE` for a savepoint
|
|
104
|
+
* (which merges its writes into the enclosing transaction). */
|
|
105
|
+
function commitTxn(db: TxnCapableDb, frame: TxnFrame): void {
|
|
106
|
+
if (frame.outermost) db.exec("COMMIT");
|
|
107
|
+
else db.exec(`RELEASE ${frame.savepoint}`);
|
|
108
|
+
txnDepth.set(db, frame.priorDepth);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Roll a frame back: `ROLLBACK` for the outermost (whole transaction),
|
|
112
|
+
* `ROLLBACK TO` + `RELEASE` for a savepoint (unwind just this frame; the
|
|
113
|
+
* error still propagates so an enclosing frame decides its own fate).
|
|
114
|
+
* Best-effort — if the COMMIT itself threw, the transaction is already
|
|
115
|
+
* resolved and these throw "no transaction"/"no such savepoint"; swallow so
|
|
116
|
+
* the original error propagates. */
|
|
117
|
+
function rollbackTxn(db: TxnCapableDb, frame: TxnFrame): void {
|
|
118
|
+
try {
|
|
119
|
+
if (frame.outermost) {
|
|
120
|
+
db.exec("ROLLBACK");
|
|
121
|
+
} else {
|
|
122
|
+
db.exec(`ROLLBACK TO ${frame.savepoint}`);
|
|
123
|
+
db.exec(`RELEASE ${frame.savepoint}`);
|
|
124
|
+
}
|
|
125
|
+
} catch (rollbackErr) {
|
|
126
|
+
// Only swallow the ONE expected case: the COMMIT/RELEASE itself already
|
|
127
|
+
// resolved the transaction, so there's nothing left to unwind — SQLite
|
|
128
|
+
// reports "cannot rollback - no transaction is active" / "no such
|
|
129
|
+
// savepoint". Any OTHER rollback failure is a real problem (a corrupt
|
|
130
|
+
// connection, a lost write lock) and must not be masked — re-throw it so
|
|
131
|
+
// the caller sees a failed rollback rather than a false success. Depth is
|
|
132
|
+
// restored first so the connection's bookkeeping stays consistent even on
|
|
133
|
+
// the re-throw path.
|
|
134
|
+
txnDepth.set(db, frame.priorDepth);
|
|
135
|
+
const msg = String((rollbackErr as Error)?.message ?? rollbackErr);
|
|
136
|
+
if (/no transaction is active|no such savepoint/i.test(msg)) return;
|
|
137
|
+
throw rollbackErr;
|
|
138
|
+
}
|
|
139
|
+
txnDepth.set(db, frame.priorDepth);
|
|
140
|
+
}
|
|
141
|
+
|
|
41
142
|
/**
|
|
42
143
|
* Run `fn` inside a single write transaction, committing its result or
|
|
43
144
|
* rolling back on throw. Synchronous — the callback must not await (see
|
|
@@ -55,20 +156,13 @@ export function transaction<T>(db: TxnCapableDb, fn: () => T): T {
|
|
|
55
156
|
if (typeof db.transactionSync === "function") {
|
|
56
157
|
return db.transactionSync(fn);
|
|
57
158
|
}
|
|
58
|
-
db
|
|
159
|
+
const frame = enterTxn(db);
|
|
59
160
|
try {
|
|
60
161
|
const result = fn();
|
|
61
|
-
db
|
|
162
|
+
commitTxn(db, frame);
|
|
62
163
|
return result;
|
|
63
164
|
} catch (err) {
|
|
64
|
-
|
|
65
|
-
// already resolved and ROLLBACK would throw "no transaction is active";
|
|
66
|
-
// swallow that so the original error is the one that propagates.
|
|
67
|
-
try {
|
|
68
|
-
db.exec("ROLLBACK");
|
|
69
|
-
} catch {
|
|
70
|
-
// no active transaction to roll back
|
|
71
|
-
}
|
|
165
|
+
rollbackTxn(db, frame);
|
|
72
166
|
throw err;
|
|
73
167
|
}
|
|
74
168
|
}
|
|
@@ -86,20 +180,21 @@ export function transaction<T>(db: TxnCapableDb, fn: () => T): T {
|
|
|
86
180
|
* so it cannot wrap an `async` body that awaits the Store facade between writes.
|
|
87
181
|
* A DO backend that needs an atomic async batch has to reshape the batch as a
|
|
88
182
|
* synchronous unit (or use an async DO transaction primitive) — out of scope
|
|
89
|
-
* for this seam; the
|
|
183
|
+
* for this seam; the bun `BEGIN IMMEDIATE` path stays the single implementation.
|
|
184
|
+
*
|
|
185
|
+
* Re-entrant (vault#589): if the connection is already inside a transaction —
|
|
186
|
+
* e.g. the atomic blow-away import wraps this around store writes that
|
|
187
|
+
* themselves call {@link transaction} — a nested call opens a SAVEPOINT rather
|
|
188
|
+
* than a second `BEGIN`. See the file header.
|
|
90
189
|
*/
|
|
91
190
|
export async function transactionAsync<T>(db: TxnCapableDb, fn: () => Promise<T>): Promise<T> {
|
|
92
|
-
db
|
|
191
|
+
const frame = enterTxn(db);
|
|
93
192
|
try {
|
|
94
193
|
const result = await fn();
|
|
95
|
-
db
|
|
194
|
+
commitTxn(db, frame);
|
|
96
195
|
return result;
|
|
97
196
|
} catch (err) {
|
|
98
|
-
|
|
99
|
-
db.exec("ROLLBACK");
|
|
100
|
-
} catch {
|
|
101
|
-
// no active transaction to roll back
|
|
102
|
-
}
|
|
197
|
+
rollbackTxn(db, frame);
|
|
103
198
|
throw err;
|
|
104
199
|
}
|
|
105
200
|
}
|
package/core/src/types.ts
CHANGED
|
@@ -118,6 +118,35 @@ export interface VaultStats {
|
|
|
118
118
|
contentBytes: number;
|
|
119
119
|
}
|
|
120
120
|
|
|
121
|
+
// ---- Vault Map (front-door structural orientation) ----
|
|
122
|
+
|
|
123
|
+
/** One counted bucket in a `VaultMap` — a tag name or a top-level path segment. */
|
|
124
|
+
export interface VaultMapEntry {
|
|
125
|
+
name: string;
|
|
126
|
+
count: number;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Compact structural map of a vault: counts only, no content. Designed so a
|
|
131
|
+
* fresh reader (human or AI) orients in ONE `vault-info` call without also
|
|
132
|
+
* needing `include_stats: true` — see `getVaultMap` (core/src/notes.ts) for
|
|
133
|
+
* the SQL and the scope-aware `tagFilter` contract.
|
|
134
|
+
*/
|
|
135
|
+
export interface VaultMap {
|
|
136
|
+
/** Total notes in scope (all notes when unfiltered). */
|
|
137
|
+
total_notes: number;
|
|
138
|
+
/** Every tag currently carried by at least one in-scope note, with its membership count. Sorted by count desc, then name. */
|
|
139
|
+
tags: VaultMapEntry[];
|
|
140
|
+
/**
|
|
141
|
+
* Every top-level path segment (the text before the first `/`, or the
|
|
142
|
+
* whole path when it has none) among in-scope notes that HAVE a path, with
|
|
143
|
+
* its note count. Sorted by count desc, then name.
|
|
144
|
+
*/
|
|
145
|
+
path_buckets: VaultMapEntry[];
|
|
146
|
+
/** In-scope notes with no `path` set — excluded from `path_buckets` (nothing to bucket). */
|
|
147
|
+
unfiled_notes: number;
|
|
148
|
+
}
|
|
149
|
+
|
|
121
150
|
// ---- Query Options ----
|
|
122
151
|
|
|
123
152
|
export interface QueryOpts {
|
|
@@ -224,6 +253,54 @@ export interface QueryOpts {
|
|
|
224
253
|
* treat the string as opaque.
|
|
225
254
|
*/
|
|
226
255
|
cursor?: string;
|
|
256
|
+
/**
|
|
257
|
+
* Aggregation / rollup mode (top new-feature ask from a UX round). When
|
|
258
|
+
* present, `aggregateNotes` applies every OTHER filter on this `QueryOpts`
|
|
259
|
+
* (tags, metadata, date range, write-attribution, ids, ...) exactly as
|
|
260
|
+
* `queryNotes` would, then groups the matching notes and returns
|
|
261
|
+
* `[{group, value}]` instead of note rows. `cursor` / `orderBy` / `sort` /
|
|
262
|
+
* `limit` / `offset` are ignored in aggregate mode — a rollup returns one
|
|
263
|
+
* row per group, not a paginated note list.
|
|
264
|
+
*/
|
|
265
|
+
aggregate?: AggregateSpec;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Aggregation / rollup spec — `QueryOpts.aggregate`. Mirrored by the
|
|
270
|
+
* `query-notes` MCP tool's `aggregate` param and the REST
|
|
271
|
+
* `?aggregate[group_by]=…&aggregate[op]=…&aggregate[field]=…` params.
|
|
272
|
+
*/
|
|
273
|
+
export interface AggregateSpec {
|
|
274
|
+
/**
|
|
275
|
+
* What to group by: an indexed metadata field name (declared
|
|
276
|
+
* `indexed: true` in a tag schema — same FIELD_NOT_INDEXED contract
|
|
277
|
+
* `metadata` operator queries and `order_by` use), or the special value
|
|
278
|
+
* `"tag"` to group by tag membership. Under `"tag"`, a note carrying N of
|
|
279
|
+
* the tags in the (filtered) result set contributes to N separate groups
|
|
280
|
+
* — this is a membership rollup, not a partition.
|
|
281
|
+
*/
|
|
282
|
+
group_by: string;
|
|
283
|
+
/** `"count"` — number of matching notes per group. `"sum"` — sum of `field` per group. */
|
|
284
|
+
op: "count" | "sum";
|
|
285
|
+
/**
|
|
286
|
+
* Required when `op` is `"sum"`; ignored for `"count"`. Must be an
|
|
287
|
+
* indexed metadata field with SQLite storage type `INTEGER` (i.e. declared
|
|
288
|
+
* `type: "integer"` or `type: "boolean"` — the only indexable numeric
|
|
289
|
+
* shapes; a plain `type: "number"` field is never indexable, see
|
|
290
|
+
* `mapFieldType`, and a `TEXT`-backed field can't be summed).
|
|
291
|
+
*/
|
|
292
|
+
field?: string;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* One rollup row from `aggregateNotes`. `group` is the group_by value (the
|
|
297
|
+
* tag name under `group_by: "tag"`, or the indexed field's stored value) —
|
|
298
|
+
* `null` collects notes where the group_by field is absent/unset (SQL NULL
|
|
299
|
+
* groups together, standard GROUP BY behavior). `value` is the count/sum.
|
|
300
|
+
*/
|
|
301
|
+
export interface AggregateRow {
|
|
302
|
+
group: string | number | boolean | null;
|
|
303
|
+
value: number;
|
|
227
304
|
}
|
|
228
305
|
|
|
229
306
|
/**
|
|
@@ -346,6 +423,18 @@ export interface Store {
|
|
|
346
423
|
* agent loop can persist a single watermark and keep polling.
|
|
347
424
|
*/
|
|
348
425
|
queryNotesPaged(opts: QueryOpts): Promise<QueryNotesPage>;
|
|
426
|
+
/**
|
|
427
|
+
* Aggregation / rollup query. Applies every OTHER filter in `opts` (tags,
|
|
428
|
+
* metadata, date range, write-attribution, `ids`, ...) exactly as
|
|
429
|
+
* `queryNotes` would, then groups by `opts.aggregate.group_by` ("tag" or
|
|
430
|
+
* an indexed metadata field) and returns `[{group, value}]` — one row per
|
|
431
|
+
* group, count or sum per `opts.aggregate.op`. `cursor`/`orderBy`/`sort`/
|
|
432
|
+
* `limit`/`offset` on `opts` are ignored. Throws `QueryError` (code
|
|
433
|
+
* `FIELD_NOT_INDEXED`) when `group_by`/`field` isn't a declared indexed
|
|
434
|
+
* field, or `INVALID_QUERY` for a malformed `aggregate` spec (missing
|
|
435
|
+
* `field` on a `"sum"`, a non-numeric `field`, ...).
|
|
436
|
+
*/
|
|
437
|
+
aggregateNotes(opts: QueryOpts): Promise<AggregateRow[]>;
|
|
349
438
|
/**
|
|
350
439
|
* `mode` (vault#551 — literal-by-default): "literal" (default) escapes +
|
|
351
440
|
* phrase-quotes the query so FTS5 punctuation syntax (hyphen = NOT, an
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for the ULID generator (vault#ulid-ids), pinned in isolation
|
|
3
|
+
* from the Store. Integration coverage (new notes get ULID ids, mixed
|
|
4
|
+
* old-format + ULID cursor pagination, old-format id round-trip) lives in
|
|
5
|
+
* core.test.ts under `describe("ULID ids for new notes ...")`.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { describe, it, expect } from "bun:test";
|
|
9
|
+
import { generateUlid, ULID_REGEX } from "./ulid.js";
|
|
10
|
+
|
|
11
|
+
describe("generateUlid", () => {
|
|
12
|
+
it("produces a 26-character Crockford base32 string", () => {
|
|
13
|
+
const id = generateUlid();
|
|
14
|
+
expect(id).toHaveLength(26);
|
|
15
|
+
expect(id).toMatch(ULID_REGEX);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("never emits the excluded Crockford letters (I, L, O, U)", () => {
|
|
19
|
+
for (let i = 0; i < 500; i++) {
|
|
20
|
+
const id = generateUlid();
|
|
21
|
+
expect(id).not.toMatch(/[ILOU]/);
|
|
22
|
+
}
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("the first 10 chars encode a timestamp close to Date.now()", () => {
|
|
26
|
+
const before = Date.now();
|
|
27
|
+
const id = generateUlid();
|
|
28
|
+
const after = Date.now();
|
|
29
|
+
|
|
30
|
+
const CROCKFORD = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
|
|
31
|
+
let time = 0;
|
|
32
|
+
for (const ch of id.slice(0, 10)) {
|
|
33
|
+
time = time * 32 + CROCKFORD.indexOf(ch);
|
|
34
|
+
}
|
|
35
|
+
expect(time).toBeGreaterThanOrEqual(before);
|
|
36
|
+
expect(time).toBeLessThanOrEqual(after);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("is monotonically increasing under plain string compare, even across a tight loop", () => {
|
|
40
|
+
const ids = Array.from({ length: 1000 }, () => generateUlid());
|
|
41
|
+
for (let i = 1; i < ids.length; i++) {
|
|
42
|
+
expect(ids[i]! > ids[i - 1]!).toBe(true);
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("produces no duplicates across many calls", () => {
|
|
47
|
+
const ids = new Set(Array.from({ length: 5000 }, () => generateUlid()));
|
|
48
|
+
expect(ids.size).toBe(5000);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("two IDs minted back-to-back share the same 10-char timestamp prefix (same ms) or the second sorts strictly after", () => {
|
|
52
|
+
const a = generateUlid();
|
|
53
|
+
const b = generateUlid();
|
|
54
|
+
expect(b > a).toBe(true);
|
|
55
|
+
});
|
|
56
|
+
});
|
package/core/src/ulid.ts
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ULID generator (vault#ulid-ids) — monotonic, lexicographically
|
|
3
|
+
* time-sortable, Crockford base32, opaque, collision-resistant identifiers.
|
|
4
|
+
*
|
|
5
|
+
* Spec: https://github.com/ulid/spec
|
|
6
|
+
*
|
|
7
|
+
* - 48-bit millisecond timestamp, encoded as the first 10 Crockford
|
|
8
|
+
* base32 characters (most-significant digit first).
|
|
9
|
+
* - 80-bit randomness, encoded as the remaining 16 characters.
|
|
10
|
+
* - 26 characters total.
|
|
11
|
+
*
|
|
12
|
+
* Monotonicity: when two IDs are minted within the SAME millisecond, the
|
|
13
|
+
* random component of the second is the first's incremented by 1
|
|
14
|
+
* (odometer-style carry across base32 digits) rather than re-rolled, so
|
|
15
|
+
* lexicographic string order matches generation order even at IDgen rates
|
|
16
|
+
* far above 1kHz. This mirrors the reference `ulid` package's
|
|
17
|
+
* `monotonicFactory`.
|
|
18
|
+
*
|
|
19
|
+
* This is a small, dependency-light, hand-rolled implementation rather than
|
|
20
|
+
* a pull of the `ulid` npm package — the algorithm is short enough (~60
|
|
21
|
+
* lines) that inlining it avoids a new supply-chain dependency for what is,
|
|
22
|
+
* cryptographically, just "48 bits of time + 80 bits of randomness, base32
|
|
23
|
+
* encoded."
|
|
24
|
+
*
|
|
25
|
+
* Randomness source: `crypto.getRandomValues` (Web Crypto — available as a
|
|
26
|
+
* global in Bun), not `Math.random`. This module is runtime application
|
|
27
|
+
* code (invoked from note/attachment creation), not a workflow/sandbox
|
|
28
|
+
* script, so Web Crypto is available; it's also simply better randomness
|
|
29
|
+
* than `Math.random` for anything used as a public, collision-resistant
|
|
30
|
+
* identifier.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
const CROCKFORD_BASE32 = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
|
|
34
|
+
const BASE = CROCKFORD_BASE32.length; // 32
|
|
35
|
+
const TIME_LEN = 10; // 10 chars * 5 bits = 50 bits, enough for the 48-bit timestamp
|
|
36
|
+
const RANDOM_LEN = 16; // 16 chars * 5 bits = 80 bits
|
|
37
|
+
const TIME_MAX = 2 ** 48 - 1;
|
|
38
|
+
|
|
39
|
+
/** Encode a non-negative integer as a fixed-length Crockford base32 string. */
|
|
40
|
+
function encodeTime(time: number, length: number): string {
|
|
41
|
+
let t = time;
|
|
42
|
+
let str = "";
|
|
43
|
+
for (let i = length; i > 0; i--) {
|
|
44
|
+
const mod = t % BASE;
|
|
45
|
+
str = CROCKFORD_BASE32[mod]! + str;
|
|
46
|
+
t = (t - mod) / BASE;
|
|
47
|
+
}
|
|
48
|
+
return str;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Draw `length` cryptographically-random Crockford base32 characters. */
|
|
52
|
+
function randomChars(length: number): string {
|
|
53
|
+
const bytes = new Uint8Array(length);
|
|
54
|
+
crypto.getRandomValues(bytes);
|
|
55
|
+
let str = "";
|
|
56
|
+
for (let i = 0; i < length; i++) {
|
|
57
|
+
// Each byte (0-255) mod 32 introduces a slight bias (256 isn't a
|
|
58
|
+
// multiple of 32... actually 256 = 8*32, so this is exactly uniform).
|
|
59
|
+
str += CROCKFORD_BASE32[bytes[i]! % BASE];
|
|
60
|
+
}
|
|
61
|
+
return str;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Increment a Crockford base32 string by 1, odometer-style: rightmost digit
|
|
66
|
+
* first, carrying leftward on overflow. Used to derive the next monotonic
|
|
67
|
+
* ID within the same millisecond.
|
|
68
|
+
*
|
|
69
|
+
* Throws if every digit is already the max ('Z') — i.e. the full 80-bit
|
|
70
|
+
* random space has been exhausted within a single millisecond. That would
|
|
71
|
+
* require minting 2^80 (~1.2e24) IDs in under a millisecond, several orders
|
|
72
|
+
* of magnitude beyond any realistic write rate; this is a defensive bound,
|
|
73
|
+
* not a real-world concern.
|
|
74
|
+
*/
|
|
75
|
+
function incrementBase32(str: string): string {
|
|
76
|
+
const chars = str.split("");
|
|
77
|
+
for (let i = chars.length - 1; i >= 0; i--) {
|
|
78
|
+
const idx = CROCKFORD_BASE32.indexOf(chars[i]!);
|
|
79
|
+
if (idx < BASE - 1) {
|
|
80
|
+
chars[i] = CROCKFORD_BASE32[idx + 1]!;
|
|
81
|
+
return chars.join("");
|
|
82
|
+
}
|
|
83
|
+
chars[i] = CROCKFORD_BASE32[0]!;
|
|
84
|
+
}
|
|
85
|
+
throw new Error("ULID random component overflow — 2^80 IDs minted within a single millisecond");
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Monotonic state, process-local (matches generateId()'s prior idCounter
|
|
89
|
+
// approach — a single vault process is the unit of monotonicity; a cursor's
|
|
90
|
+
// tiebreaker doesn't require cross-process ordering, just a stable total
|
|
91
|
+
// order per id string).
|
|
92
|
+
let lastTime = 0;
|
|
93
|
+
let lastRandom = "";
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Generate a monotonic ULID. See module doc for the algorithm.
|
|
97
|
+
*
|
|
98
|
+
* Exported separately from `generateId()` (in notes.ts) so it's testable in
|
|
99
|
+
* isolation (monotonicity, format, charset) without pulling in the Store.
|
|
100
|
+
*/
|
|
101
|
+
export function generateUlid(): string {
|
|
102
|
+
const now = Math.min(Date.now(), TIME_MAX);
|
|
103
|
+
if (now <= lastTime && lastRandom) {
|
|
104
|
+
// Same (or earlier — clock skew) millisecond as the last mint: keep
|
|
105
|
+
// `lastTime` as-is and bump the random component to preserve strict
|
|
106
|
+
// monotonicity.
|
|
107
|
+
lastRandom = incrementBase32(lastRandom);
|
|
108
|
+
} else {
|
|
109
|
+
lastTime = now;
|
|
110
|
+
lastRandom = randomChars(RANDOM_LEN);
|
|
111
|
+
}
|
|
112
|
+
return encodeTime(lastTime, TIME_LEN) + lastRandom;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** 26-char Crockford base32 (`0-9A-HJKMNP-TV-Z`, case-insensitive by spec — we always emit uppercase). */
|
|
116
|
+
export const ULID_REGEX = /^[0-9A-HJKMNP-TV-Z]{26}$/;
|
|
@@ -32,7 +32,7 @@ import {
|
|
|
32
32
|
} from "./tag-schemas.ts";
|
|
33
33
|
import { DEFAULT_TAG_NAME } from "./tag-hierarchy.ts";
|
|
34
34
|
import * as noteOps from "./notes.ts";
|
|
35
|
-
import type { VaultStats } from "./types.ts";
|
|
35
|
+
import type { VaultStats, VaultMap } from "./types.ts";
|
|
36
36
|
import { GETTING_STARTED_PATH } from "./seed-packs.ts";
|
|
37
37
|
|
|
38
38
|
/**
|
|
@@ -103,6 +103,17 @@ export interface VaultProjection {
|
|
|
103
103
|
getting_started?: string;
|
|
104
104
|
/** Included when the caller requests stats; omitted otherwise. */
|
|
105
105
|
stats?: VaultStats;
|
|
106
|
+
/**
|
|
107
|
+
* Compact structural map — total note count, tag counts, top-level
|
|
108
|
+
* path-bucket counts (front-door orientation). ALWAYS present (unlike
|
|
109
|
+
* `stats`): it's three cheap grouped-COUNT queries, so a fresh reader
|
|
110
|
+
* orients in one `vault-info` call without also passing
|
|
111
|
+
* `include_stats: true`. See `noteOps.getVaultMap`. Scope-aware callers
|
|
112
|
+
* (tag-scoped tokens) get a RECOMPUTED map from the server layer — see
|
|
113
|
+
* `applyTagScopeWrappers` in src/mcp-tools.ts — because path-bucket counts
|
|
114
|
+
* can't be reconstructed by post-hoc filtering an unscoped rollup.
|
|
115
|
+
*/
|
|
116
|
+
map: VaultMap;
|
|
106
117
|
}
|
|
107
118
|
|
|
108
119
|
// ---------------------------------------------------------------------------
|
|
@@ -182,6 +193,12 @@ export const QUERY_HINTS: readonly string[] = [
|
|
|
182
193
|
* - `stats`: included when `opts.includeStats === true`. Uses the
|
|
183
194
|
* existing `getVaultStats` shape unchanged — camelCase keys, full
|
|
184
195
|
* monthly distribution.
|
|
196
|
+
*
|
|
197
|
+
* - `map`: ALWAYS included (unlike `stats`) — `getVaultMap`'s cheap
|
|
198
|
+
* total-notes / tag-counts / path-bucket-counts rollup, computed
|
|
199
|
+
* vault-wide (unscoped). See the `VaultProjection.map` doc comment for
|
|
200
|
+
* why a tag-scoped caller's map is recomputed one layer up rather than
|
|
201
|
+
* filtered here.
|
|
185
202
|
*/
|
|
186
203
|
export function buildVaultProjection(
|
|
187
204
|
db: Database,
|
|
@@ -233,6 +250,7 @@ export function buildVaultProjection(
|
|
|
233
250
|
tags,
|
|
234
251
|
indexed_fields,
|
|
235
252
|
query_hints: [...QUERY_HINTS],
|
|
253
|
+
map: noteOps.getVaultMap(db),
|
|
236
254
|
};
|
|
237
255
|
|
|
238
256
|
// A2: point any connected AI at the seeded onboarding guide (a path pointer,
|