@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/notes.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Database, type SQLQueryBindings } from "bun:sqlite";
|
|
2
|
-
import type { Note, NoteIndex, QueryOpts, QueryNotesPage, VaultStats } from "./types.js";
|
|
2
|
+
import type { Note, NoteIndex, QueryOpts, QueryNotesPage, VaultStats, VaultMap, AggregateSpec, AggregateRow } from "./types.js";
|
|
3
3
|
import { normalizePath } from "./paths.js";
|
|
4
4
|
import { transaction } from "./txn.js";
|
|
5
5
|
import {
|
|
@@ -14,8 +14,7 @@ import {
|
|
|
14
14
|
computeQueryHash,
|
|
15
15
|
decodeCursor,
|
|
16
16
|
encodeCursor,
|
|
17
|
-
|
|
18
|
-
millisToIso,
|
|
17
|
+
timestampToMs,
|
|
19
18
|
type CursorPayload,
|
|
20
19
|
type QueryHashInputs,
|
|
21
20
|
} from "./cursor.js";
|
|
@@ -28,8 +27,7 @@ import {
|
|
|
28
27
|
SEARCH_WEIGHT_CONTENT,
|
|
29
28
|
type SearchMode,
|
|
30
29
|
} from "./search-query.js";
|
|
31
|
-
|
|
32
|
-
let idCounter = 0;
|
|
30
|
+
import { generateUlid } from "./ulid.js";
|
|
33
31
|
|
|
34
32
|
/**
|
|
35
33
|
* Write-attribution context (vault#298) — the two axes of provenance threaded
|
|
@@ -61,20 +59,25 @@ function attrValue(v: string | null | undefined): string | null {
|
|
|
61
59
|
return t.length === 0 ? null : t;
|
|
62
60
|
}
|
|
63
61
|
|
|
64
|
-
/**
|
|
62
|
+
/**
|
|
63
|
+
* Generate a new note/attachment ID.
|
|
64
|
+
*
|
|
65
|
+
* As of vault#ulid-ids this returns a ULID (see `ulid.ts`) — monotonic,
|
|
66
|
+
* lexicographically time-sortable, Crockford base32, opaque, and
|
|
67
|
+
* collision-resistant. Previously this returned a timestamp-format ID
|
|
68
|
+
* (`YYYY-MM-DD-HH-MM-SS-ffffff`).
|
|
69
|
+
*
|
|
70
|
+
* IMPORTANT: existing notes are NOT migrated. Old timestamp-format IDs
|
|
71
|
+
* stay exactly as they are — only newly-generated IDs use the ULID
|
|
72
|
+
* format, so a vault's `id` column is (and must remain) a mix of both
|
|
73
|
+
* shapes indefinitely. Nothing may assume a uniform id format, and
|
|
74
|
+
* nothing may parse a note's ID to recover its creation time — that's
|
|
75
|
+
* what the `created_at` column is for. The cursor-pagination tiebreaker
|
|
76
|
+
* (`cursor.ts`) treats `id` as an opaque, stable string for ordering
|
|
77
|
+
* ties only, which holds for any id format.
|
|
78
|
+
*/
|
|
65
79
|
export function generateId(): string {
|
|
66
|
-
|
|
67
|
-
const pad = (n: number, len = 2) => String(n).padStart(len, "0");
|
|
68
|
-
const micro = now.getMilliseconds() * 1000 + (idCounter++ % 1000);
|
|
69
|
-
return [
|
|
70
|
-
now.getFullYear(),
|
|
71
|
-
pad(now.getMonth() + 1),
|
|
72
|
-
pad(now.getDate()),
|
|
73
|
-
pad(now.getHours()),
|
|
74
|
-
pad(now.getMinutes()),
|
|
75
|
-
pad(now.getSeconds()),
|
|
76
|
-
pad(micro, 6),
|
|
77
|
-
].join("-");
|
|
80
|
+
return generateUlid();
|
|
78
81
|
}
|
|
79
82
|
|
|
80
83
|
export function createNote(
|
|
@@ -111,10 +114,18 @@ export function createNote(
|
|
|
111
114
|
// value. Hook-style writes with `skipUpdatedAt` preserve this; real user
|
|
112
115
|
// edits bump it strictly upward, so `updated_at > created_at` still means
|
|
113
116
|
// "user-touched since creation."
|
|
117
|
+
//
|
|
118
|
+
// `updated_at_ms` (vault#586) is the integer keyset-ordering mirror of
|
|
119
|
+
// `updated_at`. Derived from the SAME `createdAt` value with the UTC-correct
|
|
120
|
+
// `timestampToMs`; a `null` (unparseable caller-supplied `created_at`, e.g.
|
|
121
|
+
// via `createNoteRaw` during import) falls back to wall-clock now so a fresh
|
|
122
|
+
// row never lands with a NULL keyset key. Import's `restoreNoteTimestamps`
|
|
123
|
+
// overwrites both columns from the exported bytes immediately after.
|
|
124
|
+
const updatedAtMs = timestampToMs(createdAt) ?? Date.now();
|
|
114
125
|
try {
|
|
115
126
|
db.prepare(
|
|
116
|
-
`INSERT INTO notes (id, content, path, metadata, created_at, updated_at, extension, created_by, created_via, last_updated_by, last_updated_via) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
117
|
-
).run(id, content, path, metadata, createdAt, createdAt, extension, actor, via, actor, via);
|
|
127
|
+
`INSERT INTO notes (id, content, path, metadata, created_at, updated_at, updated_at_ms, extension, created_by, created_via, last_updated_by, last_updated_via) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
128
|
+
).run(id, content, path, metadata, createdAt, createdAt, updatedAtMs, extension, actor, via, actor, via);
|
|
118
129
|
} catch (err) {
|
|
119
130
|
if (path !== null && isPathUniqueError(err)) {
|
|
120
131
|
throw new PathConflictError(path);
|
|
@@ -183,6 +194,66 @@ export function getNoteByPath(db: Database, path: string, extension?: string): N
|
|
|
183
194
|
);
|
|
184
195
|
}
|
|
185
196
|
|
|
197
|
+
/**
|
|
198
|
+
* Extract a note's "H1 title" — the first line in `content` that starts
|
|
199
|
+
* with a literal `"# "` (a level-1 Markdown heading; `"## "`+ don't match
|
|
200
|
+
* because `^#[ \t]` fails at that line's start once a second `#` follows
|
|
201
|
+
* the first). Returns null when no such line exists, or the text after
|
|
202
|
+
* the marker is blank once trimmed.
|
|
203
|
+
*/
|
|
204
|
+
export function extractH1Title(content: string): string | null {
|
|
205
|
+
const match = content.match(/^#[ \t]+(.+)$/m);
|
|
206
|
+
if (!match) return null;
|
|
207
|
+
const title = match[1]!.trim();
|
|
208
|
+
return title.length > 0 ? title : null;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Find every note whose H1 title (see {@link extractH1Title}) equals
|
|
213
|
+
* `title`, case-insensitively (matches the COLLATE NOCASE policy every
|
|
214
|
+
* other path/basename lookup in this file uses). Used as the title-fallback
|
|
215
|
+
* step for wikilink/id/path resolution — a target that misses on
|
|
216
|
+
* id/path/basename gets one more chance against notes whose displayed
|
|
217
|
+
* title differs from their path.
|
|
218
|
+
*
|
|
219
|
+
* This is a full-content scan: no index exists on "first heading line," so
|
|
220
|
+
* every note's content is read once and matched in JS. Acceptable because
|
|
221
|
+
* every call site reaches this ONLY after the cheap indexed lookups (id,
|
|
222
|
+
* path, basename) have already missed — a rare fallback path, not the hot
|
|
223
|
+
* resolution route.
|
|
224
|
+
*/
|
|
225
|
+
export function findNotesByTitle(db: Database, title: string): { id: string; path: string | null }[] {
|
|
226
|
+
const needle = title.trim().toLowerCase();
|
|
227
|
+
if (!needle) return [];
|
|
228
|
+
const rows = db.prepare("SELECT id, path, content FROM notes").all() as {
|
|
229
|
+
id: string;
|
|
230
|
+
path: string | null;
|
|
231
|
+
content: string;
|
|
232
|
+
}[];
|
|
233
|
+
const matches: { id: string; path: string | null }[] = [];
|
|
234
|
+
for (const row of rows) {
|
|
235
|
+
const h1 = extractH1Title(row.content);
|
|
236
|
+
if (h1 && h1.toLowerCase() === needle) {
|
|
237
|
+
matches.push({ id: row.id, path: row.path });
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
return matches;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Title-fallback lookup: resolve `title` to a note ONLY when exactly one
|
|
245
|
+
* note in the vault carries that H1 title (see {@link findNotesByTitle}).
|
|
246
|
+
* Zero matches or 2+ matches (ambiguous) both return null — "don't guess"
|
|
247
|
+
* mirrors the existing basename-ambiguity policy (vault#328). Callers use
|
|
248
|
+
* this as the LAST resort after id and path/basename resolution have
|
|
249
|
+
* already missed; exact id/path matches always win first.
|
|
250
|
+
*/
|
|
251
|
+
export function getNoteByTitle(db: Database, title: string): Note | null {
|
|
252
|
+
const matches = findNotesByTitle(db, title);
|
|
253
|
+
if (matches.length !== 1) return null;
|
|
254
|
+
return getNote(db, matches[0]!.id);
|
|
255
|
+
}
|
|
256
|
+
|
|
186
257
|
export function getNotes(db: Database, ids: string[]): Note[] {
|
|
187
258
|
if (ids.length === 0) return [];
|
|
188
259
|
// Dedupe before chunking: a duplicate id straddling two chunk boundaries
|
|
@@ -402,6 +473,63 @@ export function validateExtension(extension: unknown): string {
|
|
|
402
473
|
return extension;
|
|
403
474
|
}
|
|
404
475
|
|
|
476
|
+
/**
|
|
477
|
+
* Thrown by {@link validatePath} when a caller-supplied note `path` can never
|
|
478
|
+
* round-trip safely (vault#589 / FIX 2). Carries a stable `error_type`
|
|
479
|
+
* (`invalid_path`) so REST's catch and the generic MCP domain-error mapping
|
|
480
|
+
* (`src/mcp-http.ts`) both surface a clean 400 — same shape rule as
|
|
481
|
+
* `ExtensionValidationError`.
|
|
482
|
+
*/
|
|
483
|
+
export class PathValidationError extends Error {
|
|
484
|
+
code = "INVALID_PATH" as const;
|
|
485
|
+
error_type = "invalid_path" as const;
|
|
486
|
+
path: string;
|
|
487
|
+
reason: string;
|
|
488
|
+
|
|
489
|
+
constructor(path: string, reason: string) {
|
|
490
|
+
super(`invalid_path: "${path}" ${reason}`);
|
|
491
|
+
this.name = "PathValidationError";
|
|
492
|
+
this.path = path;
|
|
493
|
+
this.reason = reason;
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/**
|
|
498
|
+
* Reject a caller-supplied note path that can never round-trip safely
|
|
499
|
+
* (vault#589 / FIX 2). Two rules, both enforced at the WRITE surface only
|
|
500
|
+
* (REST + MCP create/update, exactly where `validateExtension` sits):
|
|
501
|
+
*
|
|
502
|
+
* 1. **No NUL byte** — never valid in a filename. A NUL-in-path note keeps
|
|
503
|
+
* its resolved target inside the export root (so it slips the traversal
|
|
504
|
+
* guard), then uncaught-throws `writeFileSync`, aborting the ENTIRE vault
|
|
505
|
+
* export for everyone. Rejecting NUL at write is the primary fix; the
|
|
506
|
+
* export sink's per-file try/catch (portable-md.ts) is the belt for rows
|
|
507
|
+
* that predate this guard.
|
|
508
|
+
* 2. **No `..` path segment** — traversal-shaped. Export guard-skips such a
|
|
509
|
+
* note (silently un-round-trippable) and it has no legitimate use as a
|
|
510
|
+
* vault note path.
|
|
511
|
+
*
|
|
512
|
+
* A `null`/empty-after-normalize path is fine — notes need no path. Reads /
|
|
513
|
+
* queries never call this, so a lookup by a `..`/NUL path degrades to
|
|
514
|
+
* not-found rather than throwing. The Store itself still trusts internal /
|
|
515
|
+
* importer writes (mirrors the `validateExtension` split). Throws
|
|
516
|
+
* `PathValidationError`.
|
|
517
|
+
*/
|
|
518
|
+
export function validatePath(path: unknown): void {
|
|
519
|
+
if (path === null || path === undefined) return;
|
|
520
|
+
if (typeof path !== "string") return; // non-string: not a path; caller's shape check owns it
|
|
521
|
+
// NUL check on the RAW value — `normalizePath` strips NUL, so this must run
|
|
522
|
+
// before normalization to actually reject rather than silently clean.
|
|
523
|
+
if (path.includes("\0")) {
|
|
524
|
+
throw new PathValidationError(path, "contains a NUL byte");
|
|
525
|
+
}
|
|
526
|
+
const normalized = normalizePath(path);
|
|
527
|
+
if (normalized === null) return;
|
|
528
|
+
if (normalized.split("/").some((seg) => seg === "..")) {
|
|
529
|
+
throw new PathValidationError(path, "contains a '..' path segment (path traversal)");
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
|
|
405
533
|
/**
|
|
406
534
|
* Match bun:sqlite's UNIQUE-constraint error on the notes path index.
|
|
407
535
|
* Post-vault#328 the unique index is composite `(path, extension)`, so
|
|
@@ -478,7 +606,9 @@ export function updateNote(
|
|
|
478
606
|
// and path has been removed.
|
|
479
607
|
|
|
480
608
|
const sets: string[] = [];
|
|
481
|
-
|
|
609
|
+
// `updated_at_ms` binds as a number (INTEGER column), so the value list
|
|
610
|
+
// carries numbers alongside strings/nulls (vault#586).
|
|
611
|
+
const values: (string | number | null)[] = [];
|
|
482
612
|
|
|
483
613
|
// Hooks and other machine-level writers pass `skipUpdatedAt: true` so
|
|
484
614
|
// their metadata markers don't look like user activity. See issue #44.
|
|
@@ -496,6 +626,12 @@ export function updateNote(
|
|
|
496
626
|
}
|
|
497
627
|
sets.push("updated_at = ?");
|
|
498
628
|
values.push(now);
|
|
629
|
+
// `updated_at_ms` (vault#586) rides the SAME gate as `updated_at` — the
|
|
630
|
+
// integer keyset-ordering mirror must move in lockstep with the string.
|
|
631
|
+
// `now` is canonical `.toISOString()`, so `timestampToMs` never returns
|
|
632
|
+
// null here; the fallback is belt-and-suspenders.
|
|
633
|
+
sets.push("updated_at_ms = ?");
|
|
634
|
+
values.push(timestampToMs(now) ?? Date.now());
|
|
499
635
|
|
|
500
636
|
// Write-attribution (vault#298): the most-recent-write columns ride the
|
|
501
637
|
// SAME gate as `updated_at`. A `skipUpdatedAt` machine write doesn't bump
|
|
@@ -769,8 +905,20 @@ function validateIsoDateValue(field: string, value: string): void {
|
|
|
769
905
|
}
|
|
770
906
|
}
|
|
771
907
|
|
|
772
|
-
|
|
773
|
-
|
|
908
|
+
/**
|
|
909
|
+
* Build the shared WHERE-clause conditions + bound params for the filter
|
|
910
|
+
* surface both `queryNotes` and `aggregateNotes` apply: tag membership
|
|
911
|
+
* (include/exclude/presence), link/broken-link presence, id-set scoping,
|
|
912
|
+
* exact path / path-prefix / extension, write-attribution, metadata
|
|
913
|
+
* (operator + plain-equality), and date range. Does NOT include the
|
|
914
|
+
* cursor-keyset predicate, ORDER BY, or LIMIT/OFFSET — those are
|
|
915
|
+
* query-shape concerns specific to `queryNotes`'s paginated-list contract
|
|
916
|
+
* and don't apply to an aggregate rollup (which spans every matching row,
|
|
917
|
+
* not a page of them). Extracted so aggregation reuses the EXACT filter
|
|
918
|
+
* semantics a normal query applies, rather than risking the two drifting
|
|
919
|
+
* apart.
|
|
920
|
+
*/
|
|
921
|
+
function buildFilterConditions(db: Database, opts: QueryOpts): { conditions: string[]; params: SQLQueryBindings[] } {
|
|
774
922
|
const conditions: string[] = [];
|
|
775
923
|
const params: SQLQueryBindings[] = [];
|
|
776
924
|
|
|
@@ -1024,6 +1172,11 @@ export function queryNotes(db: Database, opts: QueryOpts): Note[] {
|
|
|
1024
1172
|
if (field === "created_at") {
|
|
1025
1173
|
column = "n.created_at";
|
|
1026
1174
|
} else if (field === "updated_at") {
|
|
1175
|
+
// NOTE (vault#586 follow-up): this range filter compares the TEXT
|
|
1176
|
+
// `updated_at`, which is inconsistent on non-canonical rows the same way
|
|
1177
|
+
// the cursor keyset was pre-v26. Cursor pagination moved to the integer
|
|
1178
|
+
// `updated_at_ms` column; `date_filter`/`order_by`/export `--since` still
|
|
1179
|
+
// read the string and are tracked separately. Behavior unchanged here.
|
|
1027
1180
|
column = "n.updated_at";
|
|
1028
1181
|
} else {
|
|
1029
1182
|
// Re-uses the same indexed-field gate as `metadata` operator queries
|
|
@@ -1054,7 +1207,22 @@ export function queryNotes(db: Database, opts: QueryOpts): Note[] {
|
|
|
1054
1207
|
}
|
|
1055
1208
|
}
|
|
1056
1209
|
|
|
1057
|
-
|
|
1210
|
+
return { conditions, params };
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
/**
|
|
1214
|
+
* `_outUpdatedAtMs` (internal, vault#586) — when supplied, the phase-1 page
|
|
1215
|
+
* query writes each returned id's integer `updated_at_ms` keyset key into it.
|
|
1216
|
+
* Only `queryNotesPaged` passes it, to derive the cursor watermark from the
|
|
1217
|
+
* SAME statement that determined page membership + order (one consistent
|
|
1218
|
+
* snapshot — see the call site). Ordinary callers omit it and are unaffected;
|
|
1219
|
+
* it never touches the returned `Note` shape.
|
|
1220
|
+
*/
|
|
1221
|
+
export function queryNotes(db: Database, opts: QueryOpts, _outUpdatedAtMs?: Map<string, number>): Note[] {
|
|
1222
|
+
validateLimitOffset(opts);
|
|
1223
|
+
const { conditions, params } = buildFilterConditions(db, opts);
|
|
1224
|
+
|
|
1225
|
+
// ---- Cursor predicate (vault#313; keyset column vault#586) ----
|
|
1058
1226
|
//
|
|
1059
1227
|
// Cursor mode is keyed on PRESENCE of `opts.cursor`, not truthiness
|
|
1060
1228
|
// (vault#550 bootstrap fix). `cursor: ""` is the bootstrap call — "I want
|
|
@@ -1065,24 +1233,38 @@ export function queryNotes(db: Database, opts: QueryOpts): Note[] {
|
|
|
1065
1233
|
// Before this fix, `if (opts.cursor)` treated an empty string exactly
|
|
1066
1234
|
// like "no cursor" — the caller's bootstrap intent silently vanished and
|
|
1067
1235
|
// the first page came back in `created_at` order instead of the
|
|
1068
|
-
//
|
|
1236
|
+
// keyset order the SECOND page (a real cursor) would use,
|
|
1069
1237
|
// so naive "did I see this note already" comparisons could skip or
|
|
1070
1238
|
// duplicate rows across the boundary.
|
|
1071
1239
|
//
|
|
1240
|
+
// The keyset orders on the integer `n.updated_at_ms` column (vault#586),
|
|
1241
|
+
// NOT the TEXT `n.updated_at`. That column is the single source of truth
|
|
1242
|
+
// for cursor ordering: walk-order, the boundary predicate below, and the
|
|
1243
|
+
// watermark in `queryNotesPaged` all read the SAME integer, so they can't
|
|
1244
|
+
// diverge the way three separate readings of `updated_at` did on
|
|
1245
|
+
// aged/imported vaults whose timestamps aren't canonical `.toISOString()`
|
|
1246
|
+
// (space-form / offset / no-`Z`) — under which TEXT-lex order, an ISO
|
|
1247
|
+
// boundary string, and a `Date.parse` watermark disagreed and silently
|
|
1248
|
+
// skipped or re-delivered rows.
|
|
1249
|
+
//
|
|
1072
1250
|
// When a REAL (non-empty) cursor is present, decode it, verify its
|
|
1073
1251
|
// query_hash matches the current query, and add a keyset predicate of
|
|
1074
1252
|
// the form:
|
|
1075
1253
|
//
|
|
1076
|
-
// (
|
|
1077
|
-
// OR (
|
|
1254
|
+
// (updated_at_ms > last_updated_at)
|
|
1255
|
+
// OR (updated_at_ms = last_updated_at AND id > last_id)
|
|
1078
1256
|
//
|
|
1079
|
-
// The cursor
|
|
1080
|
-
//
|
|
1081
|
-
//
|
|
1082
|
-
//
|
|
1083
|
-
//
|
|
1084
|
-
//
|
|
1085
|
-
//
|
|
1257
|
+
// The cursor payload's `last_updated_at` is ALREADY a millisecond epoch
|
|
1258
|
+
// (see cursor.ts) — the same units as the column — so it binds directly
|
|
1259
|
+
// with no ISO round-trip. This is also why existing client cursors keep
|
|
1260
|
+
// working unchanged across the upgrade: the encoded watermark was always
|
|
1261
|
+
// ms. The cursor forces ORDER BY n.updated_at_ms ASC, n.id ASC so the
|
|
1262
|
+
// watermark math is sound — paginating by update time while ordering by
|
|
1263
|
+
// created_at would skip rows whose update differs from their creation.
|
|
1264
|
+
// `orderBy` and `sort: "desc"` are mutually exclusive with cursor mode (a
|
|
1265
|
+
// "since last checked" loop wants ascending update order, full stop); we
|
|
1266
|
+
// reject with INVALID_QUERY so callers don't silently get a broken
|
|
1267
|
+
// iteration.
|
|
1086
1268
|
const cursorMode = opts.cursor !== undefined;
|
|
1087
1269
|
let cursorPayload: CursorPayload | null = null;
|
|
1088
1270
|
if (cursorMode) {
|
|
@@ -1107,20 +1289,17 @@ export function queryNotes(db: Database, opts: QueryOpts): Note[] {
|
|
|
1107
1289
|
"cursor_query_mismatch",
|
|
1108
1290
|
);
|
|
1109
1291
|
}
|
|
1110
|
-
//
|
|
1111
|
-
//
|
|
1112
|
-
//
|
|
1113
|
-
//
|
|
1114
|
-
//
|
|
1115
|
-
//
|
|
1116
|
-
|
|
1117
|
-
// safe: we round-trip the cursor's millis through `new Date()`'s
|
|
1118
|
-
// canonical ISO so the comparison is apples-to-apples.
|
|
1119
|
-
const cursorIso = millisToIso(cursorPayload.last_updated_at);
|
|
1292
|
+
// The cursor's `last_updated_at` is a millisecond epoch (cursor.ts) —
|
|
1293
|
+
// the SAME units as the integer `n.updated_at_ms` column — so it binds
|
|
1294
|
+
// straight into the keyset predicate with no ISO round-trip. Ordering
|
|
1295
|
+
// and boundary now read one numeric column, so heterogeneous /
|
|
1296
|
+
// non-canonical `updated_at` TEXT (space-form, offset, no-`Z`) can no
|
|
1297
|
+
// longer make the walk and the watermark disagree.
|
|
1298
|
+
const cursorMs = cursorPayload.last_updated_at;
|
|
1120
1299
|
conditions.push(
|
|
1121
|
-
"(n.
|
|
1300
|
+
"(n.updated_at_ms > ? OR (n.updated_at_ms = ? AND n.id > ?))",
|
|
1122
1301
|
);
|
|
1123
|
-
params.push(
|
|
1302
|
+
params.push(cursorMs, cursorMs, cursorPayload.last_id);
|
|
1124
1303
|
}
|
|
1125
1304
|
// else: bootstrap call (`cursor === ""`) — no watermark yet, no
|
|
1126
1305
|
// predicate to add, but the ORDER BY below still switches to the
|
|
@@ -1131,11 +1310,12 @@ export function queryNotes(db: Database, opts: QueryOpts): Note[] {
|
|
|
1131
1310
|
const direction = opts.sort === "desc" ? "DESC" : "ASC";
|
|
1132
1311
|
let orderBy: string;
|
|
1133
1312
|
if (cursorMode) {
|
|
1134
|
-
// Cursor mode forces a deterministic keyset order
|
|
1135
|
-
//
|
|
1136
|
-
//
|
|
1137
|
-
//
|
|
1138
|
-
|
|
1313
|
+
// Cursor mode forces a deterministic keyset order on the integer
|
|
1314
|
+
// `updated_at_ms` column (vault#586), matching the boundary predicate
|
|
1315
|
+
// above and the `idx_notes_updated_ms` index. `id` is the tiebreaker —
|
|
1316
|
+
// without it, two notes sharing an `updated_at_ms` would be at the mercy
|
|
1317
|
+
// of SQLite's row order and the next page could miss or duplicate one.
|
|
1318
|
+
orderBy = "n.updated_at_ms ASC, n.id ASC";
|
|
1139
1319
|
} else if (opts.orderBy === "link_count") {
|
|
1140
1320
|
// `link_count` is a pseudo-field — like `created_at`/`updated_at` in the
|
|
1141
1321
|
// dateFilter block above, it bypasses `requireIndexedField` (it's not a
|
|
@@ -1190,18 +1370,156 @@ export function queryNotes(db: Database, opts: QueryOpts): Note[] {
|
|
|
1190
1370
|
// Phase 2 fetches full rows for just the page (≤ limit ids) and re-orders
|
|
1191
1371
|
// to the phase-1 order; tags are hydrated in ONE batched query instead of
|
|
1192
1372
|
// one query per returned note.
|
|
1373
|
+
// Phase 1 also selects `n.updated_at_ms` (vault#586) so a paging caller can
|
|
1374
|
+
// read the keyset key from the SAME statement that applied the keyset order
|
|
1375
|
+
// — the watermark then comes from one consistent snapshot, immune to a
|
|
1376
|
+
// cross-process writer bumping a page row's ms between two separate reads.
|
|
1193
1377
|
const idSql = `
|
|
1194
|
-
SELECT n.id FROM notes n
|
|
1378
|
+
SELECT n.id, n.updated_at_ms FROM notes n
|
|
1195
1379
|
${whereClause}
|
|
1196
1380
|
ORDER BY ${orderBy}
|
|
1197
1381
|
LIMIT ? OFFSET ?
|
|
1198
1382
|
`;
|
|
1199
1383
|
params.push(limit, offset);
|
|
1200
1384
|
|
|
1201
|
-
const idRows = db.prepare(idSql).all(...params) as { id: string }[];
|
|
1385
|
+
const idRows = db.prepare(idSql).all(...params) as { id: string; updated_at_ms: number | null }[];
|
|
1386
|
+
if (_outUpdatedAtMs) {
|
|
1387
|
+
for (const r of idRows) {
|
|
1388
|
+
if (r.updated_at_ms !== null && r.updated_at_ms !== undefined) {
|
|
1389
|
+
_outUpdatedAtMs.set(r.id, r.updated_at_ms);
|
|
1390
|
+
}
|
|
1391
|
+
}
|
|
1392
|
+
}
|
|
1202
1393
|
return fetchNotesByIdsOrdered(db, idRows.map((r) => r.id));
|
|
1203
1394
|
}
|
|
1204
1395
|
|
|
1396
|
+
/**
|
|
1397
|
+
* Aggregation / rollup query (top new-feature ask from a UX round — see
|
|
1398
|
+
* `QueryOpts.aggregate` / `AggregateSpec` in types.ts). Applies the SAME
|
|
1399
|
+
* filter surface `queryNotes` does — via the shared `buildFilterConditions`:
|
|
1400
|
+
* tags, exclude-tags, presence filters, `ids`, path/extension, write-
|
|
1401
|
+
* attribution, metadata, date range — BEFORE grouping, so aggregating over a
|
|
1402
|
+
* filtered query rolls up exactly the notes that query would have listed.
|
|
1403
|
+
*
|
|
1404
|
+
* Tag-scope is NOT enforced here — core stays scope-unaware, same division
|
|
1405
|
+
* every other core query surface keeps. A tag-scoped caller narrows via
|
|
1406
|
+
* `opts.ids` to a pre-computed visible-note-id set (see `src/mcp-tools.ts`'s
|
|
1407
|
+
* `aggregateVisibility` wiring for MCP and `src/routes.ts`'s REST aggregate
|
|
1408
|
+
* branch — both filter to visible notes FIRST, then pass the resulting ids
|
|
1409
|
+
* in here, exactly like a normal query's `near` neighborhood scoping does).
|
|
1410
|
+
*
|
|
1411
|
+
* `group_by: "tag"` groups by tag MEMBERSHIP, not partition: implemented as
|
|
1412
|
+
* a JOIN against `note_tags`, so a note carrying N of the tags present in
|
|
1413
|
+
* the filtered result set contributes to N separate group rows (the
|
|
1414
|
+
* existing filter conditions, which reference `n.*`, compose unchanged
|
|
1415
|
+
* against the join). Any other `group_by` value must be a declared
|
|
1416
|
+
* `indexed: true` metadata field — reuses `requireIndexedField`, the same
|
|
1417
|
+
* FIELD_NOT_INDEXED contract `metadata` operator queries and `order_by`
|
|
1418
|
+
* use — and groups by its generated `meta_<field>` column.
|
|
1419
|
+
*
|
|
1420
|
+
* `op: "sum"` requires `field` — another indexed field whose declared
|
|
1421
|
+
* storage type is `INTEGER` (the only indexable numeric shape; a bare
|
|
1422
|
+
* `type: "number"` schema field is never indexed — see
|
|
1423
|
+
* `indexed-fields.ts`'s `TYPE_MAP` — and a `TEXT`-backed field can't be
|
|
1424
|
+
* summed). `op: "count"` ignores `field`.
|
|
1425
|
+
*
|
|
1426
|
+
* A note whose group_by value is absent/null collects into one
|
|
1427
|
+
* `{group: null, ...}` row — standard SQL `GROUP BY` behavior, not silently
|
|
1428
|
+
* dropped.
|
|
1429
|
+
*/
|
|
1430
|
+
export function aggregateNotes(db: Database, opts: QueryOpts): AggregateRow[] {
|
|
1431
|
+
const spec = opts.aggregate;
|
|
1432
|
+
if (!spec) {
|
|
1433
|
+
throw new QueryError(
|
|
1434
|
+
`aggregateNotes requires opts.aggregate`,
|
|
1435
|
+
"INVALID_QUERY",
|
|
1436
|
+
{
|
|
1437
|
+
error_type: "invalid_query",
|
|
1438
|
+
field: "aggregate",
|
|
1439
|
+
hint: `pass { group_by, op } — group_by is an indexed metadata field or "tag"; op is "count" or "sum"`,
|
|
1440
|
+
},
|
|
1441
|
+
);
|
|
1442
|
+
}
|
|
1443
|
+
if (typeof spec.group_by !== "string" || spec.group_by.length === 0) {
|
|
1444
|
+
throw new QueryError(
|
|
1445
|
+
`aggregate.group_by is required — an indexed metadata field name, or "tag"`,
|
|
1446
|
+
"INVALID_QUERY",
|
|
1447
|
+
{
|
|
1448
|
+
error_type: "invalid_query",
|
|
1449
|
+
field: "aggregate.group_by",
|
|
1450
|
+
got: spec.group_by,
|
|
1451
|
+
hint: `pass an indexed metadata field name, or "tag"`,
|
|
1452
|
+
},
|
|
1453
|
+
);
|
|
1454
|
+
}
|
|
1455
|
+
if (spec.op !== "count" && spec.op !== "sum") {
|
|
1456
|
+
throw new QueryError(
|
|
1457
|
+
`invalid aggregate.op: ${JSON.stringify(spec.op)} — must be "count" or "sum"`,
|
|
1458
|
+
"INVALID_QUERY",
|
|
1459
|
+
{ error_type: "invalid_query", field: "aggregate.op", got: spec.op, hint: `pass "count" or "sum"` },
|
|
1460
|
+
);
|
|
1461
|
+
}
|
|
1462
|
+
if (spec.op === "sum" && (typeof spec.field !== "string" || spec.field.length === 0)) {
|
|
1463
|
+
throw new QueryError(
|
|
1464
|
+
`aggregate.field is required when aggregate.op is "sum"`,
|
|
1465
|
+
"INVALID_QUERY",
|
|
1466
|
+
{
|
|
1467
|
+
error_type: "invalid_query",
|
|
1468
|
+
field: "aggregate.field",
|
|
1469
|
+
hint: "pass the indexed numeric metadata field to sum",
|
|
1470
|
+
},
|
|
1471
|
+
);
|
|
1472
|
+
}
|
|
1473
|
+
|
|
1474
|
+
const { conditions, params } = buildFilterConditions(db, opts);
|
|
1475
|
+
|
|
1476
|
+
const groupByTag = spec.group_by === "tag";
|
|
1477
|
+
let groupExpr: string;
|
|
1478
|
+
let fromClause: string;
|
|
1479
|
+
if (groupByTag) {
|
|
1480
|
+
groupExpr = "nt.tag_name";
|
|
1481
|
+
fromClause = "FROM notes n JOIN note_tags nt ON nt.note_id = n.id";
|
|
1482
|
+
} else {
|
|
1483
|
+
// `group_by` came from indexed_fields (validated via FIELD_NAME_RE at
|
|
1484
|
+
// declaration time), so interpolating the column name is safe — same
|
|
1485
|
+
// justification `orderBy`/`buildOperatorClause` use.
|
|
1486
|
+
requireIndexedField(db, spec.group_by);
|
|
1487
|
+
groupExpr = `"meta_${spec.group_by}"`;
|
|
1488
|
+
fromClause = "FROM notes n";
|
|
1489
|
+
}
|
|
1490
|
+
|
|
1491
|
+
let valueExpr: string;
|
|
1492
|
+
if (spec.op === "count") {
|
|
1493
|
+
valueExpr = "COUNT(*)";
|
|
1494
|
+
} else {
|
|
1495
|
+
const fieldInfo = requireIndexedField(db, spec.field!);
|
|
1496
|
+
if (fieldInfo.sqliteType !== "INTEGER") {
|
|
1497
|
+
throw new QueryError(
|
|
1498
|
+
`aggregate.field "${spec.field}" is not numeric (declared sqlite type ${fieldInfo.sqliteType}) — "sum" requires a field declared type: "integer" or "boolean" in its tag schema`,
|
|
1499
|
+
"INVALID_QUERY",
|
|
1500
|
+
{
|
|
1501
|
+
error_type: "invalid_query",
|
|
1502
|
+
field: "aggregate.field",
|
|
1503
|
+
got: spec.field,
|
|
1504
|
+
hint: `sum requires a numeric indexed field (type: "integer"/"boolean"); "${spec.field}" is declared a non-numeric type`,
|
|
1505
|
+
},
|
|
1506
|
+
);
|
|
1507
|
+
}
|
|
1508
|
+
valueExpr = `SUM("meta_${spec.field}")`;
|
|
1509
|
+
}
|
|
1510
|
+
|
|
1511
|
+
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
|
1512
|
+
const sql = `
|
|
1513
|
+
SELECT ${groupExpr} AS group_key, ${valueExpr} AS value
|
|
1514
|
+
${fromClause}
|
|
1515
|
+
${whereClause}
|
|
1516
|
+
GROUP BY ${groupExpr}
|
|
1517
|
+
ORDER BY ${groupExpr}
|
|
1518
|
+
`;
|
|
1519
|
+
const rows = db.prepare(sql).all(...params) as { group_key: string | number | null; value: number | null }[];
|
|
1520
|
+
return rows.map((r) => ({ group: r.group_key, value: r.value ?? 0 }));
|
|
1521
|
+
}
|
|
1522
|
+
|
|
1205
1523
|
/**
|
|
1206
1524
|
* Fetch full note rows for `ids`, preserving the input order, with tags
|
|
1207
1525
|
* hydrated via ONE batched query per chunk (not one per note). Ids not
|
|
@@ -1307,7 +1625,12 @@ function toQueryHashInputs(opts: QueryOpts): QueryHashInputs {
|
|
|
1307
1625
|
* with a non-null `updated_at` greater than the unix epoch).
|
|
1308
1626
|
*/
|
|
1309
1627
|
export function queryNotesPaged(db: Database, opts: QueryOpts): QueryNotesPage {
|
|
1310
|
-
|
|
1628
|
+
// Capture each page row's integer keyset key (vault#586) from the SAME
|
|
1629
|
+
// phase-1 statement that ordered + sliced the page — one consistent
|
|
1630
|
+
// snapshot, so the watermark can't leapfrog rows a cross-process writer
|
|
1631
|
+
// bumps between reads. This replaces a separate post-query round-trip.
|
|
1632
|
+
const updatedAtMsById = new Map<string, number>();
|
|
1633
|
+
const notes = queryNotes(db, opts, updatedAtMsById);
|
|
1311
1634
|
const queryHash = computeQueryHash(toQueryHashInputs(opts));
|
|
1312
1635
|
|
|
1313
1636
|
// Watermark math: pick the larger of (last returned row, prior cursor
|
|
@@ -1326,14 +1649,20 @@ export function queryNotesPaged(db: Database, opts: QueryOpts): QueryNotesPage {
|
|
|
1326
1649
|
lastId = prior.last_id;
|
|
1327
1650
|
}
|
|
1328
1651
|
if (notes.length > 0) {
|
|
1329
|
-
//
|
|
1330
|
-
//
|
|
1331
|
-
//
|
|
1332
|
-
//
|
|
1333
|
-
//
|
|
1652
|
+
// Advance the watermark to the page's MAX (updated_at_ms, id) using the
|
|
1653
|
+
// keyset keys captured in the phase-1 snapshot above (vault#586) — the
|
|
1654
|
+
// SAME integer `updated_at_ms` the walk-order used, from the SAME read.
|
|
1655
|
+
// Using the snapshot, rather than re-deriving ms from each note's
|
|
1656
|
+
// `updatedAt` string or re-reading the column, is what keeps walk-order
|
|
1657
|
+
// and watermark from diverging: a row whose backfilled column and
|
|
1658
|
+
// non-canonical `updated_at` TEXT disagree would otherwise re-parse to a
|
|
1659
|
+
// different ms here and skip or re-deliver at the boundary. No throw — a
|
|
1660
|
+
// NULL key (shouldn't occur post-migrateToV26) reads as 0. When a cursor
|
|
1661
|
+
// is in effect the SQL already returns rows in (updated_at_ms, id) order
|
|
1662
|
+
// so the last row IS the max; the explicit max also covers the
|
|
1663
|
+
// created_at-ordered no-cursor path.
|
|
1334
1664
|
for (const note of notes) {
|
|
1335
|
-
const
|
|
1336
|
-
const ms = isoToMillis(updatedIso);
|
|
1665
|
+
const ms = updatedAtMsById.get(note.id) ?? 0;
|
|
1337
1666
|
if (ms > lastUpdatedAt || (ms === lastUpdatedAt && note.id > lastId)) {
|
|
1338
1667
|
lastUpdatedAt = ms;
|
|
1339
1668
|
lastId = note.id;
|
|
@@ -1831,6 +2160,10 @@ export function renameTag(db: Database, oldName: string, newName: string): Renam
|
|
|
1831
2160
|
// note_tags FK on `tag_name` has no ON DELETE, so the delete must
|
|
1832
2161
|
// come AFTER the repoint.
|
|
1833
2162
|
const now = new Date().toISOString();
|
|
2163
|
+
// Integer keyset mirror of `now` for the note `updated_at_ms` bumps in the
|
|
2164
|
+
// content/path rewrite passes below (vault#586). `now` is canonical, so
|
|
2165
|
+
// `timestampToMs` never returns null here.
|
|
2166
|
+
const nowMs = timestampToMs(now) ?? Date.now();
|
|
1834
2167
|
const readStmt = db.prepare(
|
|
1835
2168
|
"SELECT description, fields, relationships, parent_names, created_at FROM tags WHERE name = ?",
|
|
1836
2169
|
);
|
|
@@ -1958,12 +2291,13 @@ export function renameTag(db: Database, oldName: string, newName: string): Renam
|
|
|
1958
2291
|
// `#newtag`) and must bump `updated_at` like any other content
|
|
1959
2292
|
// write, or a cursor/sync-poll loop never sees it. Shares the one
|
|
1960
2293
|
// `now` timestamp for the whole cascade (same convention as the
|
|
1961
|
-
// tag-row rename pass above).
|
|
1962
|
-
|
|
2294
|
+
// tag-row rename pass above). `updated_at_ms` moves with it (vault#586)
|
|
2295
|
+
// so the cursor keyset actually surfaces the rewrite.
|
|
2296
|
+
const updateStmt = db.prepare("UPDATE notes SET content = ?, updated_at = ?, updated_at_ms = ? WHERE id = ?");
|
|
1963
2297
|
for (const row of candidates) {
|
|
1964
2298
|
const next = rewriteNoteBody(row.content, renames);
|
|
1965
2299
|
if (next === row.content) continue;
|
|
1966
|
-
updateStmt.run(next, now, row.id);
|
|
2300
|
+
updateStmt.run(next, now, nowMs, row.id);
|
|
1967
2301
|
notesRewritten++;
|
|
1968
2302
|
}
|
|
1969
2303
|
}
|
|
@@ -1979,12 +2313,13 @@ export function renameTag(db: Database, oldName: string, newName: string): Renam
|
|
|
1979
2313
|
.prepare(`SELECT id, path FROM notes WHERE path IS NOT NULL AND (${orClauses})`)
|
|
1980
2314
|
.all(...params) as { id: string; path: string }[];
|
|
1981
2315
|
// vault#555 fix 2 — same reasoning as the content rewrite above: a
|
|
1982
|
-
// path rewrite is a real persisted-state change.
|
|
1983
|
-
|
|
2316
|
+
// path rewrite is a real persisted-state change. `updated_at_ms` moves
|
|
2317
|
+
// with `updated_at` (vault#586).
|
|
2318
|
+
const updateStmt = db.prepare("UPDATE notes SET path = ?, updated_at = ?, updated_at_ms = ? WHERE id = ?");
|
|
1984
2319
|
for (const row of candidates) {
|
|
1985
2320
|
const next = rewriteTagConfigPath(row.path, renames);
|
|
1986
2321
|
if (next === row.path) continue;
|
|
1987
|
-
updateStmt.run(next, now, row.id);
|
|
2322
|
+
updateStmt.run(next, now, nowMs, row.id);
|
|
1988
2323
|
pathsRenamed++;
|
|
1989
2324
|
}
|
|
1990
2325
|
}
|
|
@@ -2359,6 +2694,122 @@ export function getVaultStats(
|
|
|
2359
2694
|
};
|
|
2360
2695
|
}
|
|
2361
2696
|
|
|
2697
|
+
// ---- Vault map (front-door structural orientation) ----
|
|
2698
|
+
|
|
2699
|
+
/** Shared bucket-expression for the top-level path segment: the text before
|
|
2700
|
+
* the first `/`, or the whole path when it has none. Applied to whichever
|
|
2701
|
+
* `path` column reference the caller substitutes in (`path` or `n.path`). */
|
|
2702
|
+
function pathBucketExpr(pathCol: string): string {
|
|
2703
|
+
return `CASE WHEN instr(${pathCol}, '/') > 0 THEN substr(${pathCol}, 1, instr(${pathCol}, '/') - 1) ELSE ${pathCol} END`;
|
|
2704
|
+
}
|
|
2705
|
+
|
|
2706
|
+
/**
|
|
2707
|
+
* Compute the compact structural map `vault-info` surfaces for one-call
|
|
2708
|
+
* orientation: total note count, every tag currently carried by at least one
|
|
2709
|
+
* note with its membership count, and every top-level path "bucket" (the
|
|
2710
|
+
* first `/`-delimited segment of `path`) with its note count — plus a count
|
|
2711
|
+
* of notes with no path at all (excluded from `path_buckets`, nothing to
|
|
2712
|
+
* bucket). Counts only, no content; three grouped-COUNT queries, safe on
|
|
2713
|
+
* large vaults.
|
|
2714
|
+
*
|
|
2715
|
+
* `opts.tagFilter`, when the KEY IS PRESENT, restricts every count to notes
|
|
2716
|
+
* reachable through that exact tag-name set (an `IN`-clause join through
|
|
2717
|
+
* `note_tags`) — the scope-aware path a tag-scoped caller's already-expanded
|
|
2718
|
+
* allowlist takes (server layer; core stays scope-unaware, it just filters
|
|
2719
|
+
* by the plain tag-name list it's given). An empty array is a valid,
|
|
2720
|
+
* meaningful filter — "nothing is in scope" — and short-circuits to an
|
|
2721
|
+
* all-zero map WITHOUT falling through to the unfiltered/full-vault query;
|
|
2722
|
+
* only OMITTING `tagFilter` entirely computes the vault-wide map.
|
|
2723
|
+
*/
|
|
2724
|
+
export function getVaultMap(
|
|
2725
|
+
db: Database,
|
|
2726
|
+
opts?: { tagFilter?: string[] },
|
|
2727
|
+
): VaultMap {
|
|
2728
|
+
const hasFilter = opts !== undefined && opts.tagFilter !== undefined;
|
|
2729
|
+
const tagFilter = opts?.tagFilter ?? [];
|
|
2730
|
+
|
|
2731
|
+
if (hasFilter && tagFilter.length === 0) {
|
|
2732
|
+
return { total_notes: 0, tags: [], path_buckets: [], unfiled_notes: 0 };
|
|
2733
|
+
}
|
|
2734
|
+
|
|
2735
|
+
if (hasFilter) {
|
|
2736
|
+
const placeholders = tagFilter.map(() => "?").join(",");
|
|
2737
|
+
|
|
2738
|
+
const totalRow = db
|
|
2739
|
+
.prepare(`SELECT COUNT(DISTINCT note_id) as c FROM note_tags WHERE tag_name IN (${placeholders})`)
|
|
2740
|
+
.get(...tagFilter) as { c: number };
|
|
2741
|
+
|
|
2742
|
+
const tagRows = db
|
|
2743
|
+
.prepare(
|
|
2744
|
+
`SELECT tag_name AS name, COUNT(*) AS count
|
|
2745
|
+
FROM note_tags
|
|
2746
|
+
WHERE tag_name IN (${placeholders})
|
|
2747
|
+
GROUP BY tag_name
|
|
2748
|
+
ORDER BY count DESC, name ASC`,
|
|
2749
|
+
)
|
|
2750
|
+
.all(...tagFilter) as { name: string; count: number }[];
|
|
2751
|
+
|
|
2752
|
+
const bucketRows = db
|
|
2753
|
+
.prepare(
|
|
2754
|
+
`SELECT ${pathBucketExpr("n.path")} AS name, COUNT(DISTINCT n.id) AS count
|
|
2755
|
+
FROM notes n
|
|
2756
|
+
JOIN note_tags nt ON nt.note_id = n.id
|
|
2757
|
+
WHERE nt.tag_name IN (${placeholders}) AND n.path IS NOT NULL
|
|
2758
|
+
GROUP BY name
|
|
2759
|
+
ORDER BY count DESC, name ASC`,
|
|
2760
|
+
)
|
|
2761
|
+
.all(...tagFilter) as { name: string; count: number }[];
|
|
2762
|
+
|
|
2763
|
+
const unfiledRow = db
|
|
2764
|
+
.prepare(
|
|
2765
|
+
`SELECT COUNT(DISTINCT n.id) as c
|
|
2766
|
+
FROM notes n
|
|
2767
|
+
JOIN note_tags nt ON nt.note_id = n.id
|
|
2768
|
+
WHERE nt.tag_name IN (${placeholders}) AND n.path IS NULL`,
|
|
2769
|
+
)
|
|
2770
|
+
.get(...tagFilter) as { c: number };
|
|
2771
|
+
|
|
2772
|
+
return {
|
|
2773
|
+
total_notes: totalRow.c,
|
|
2774
|
+
tags: tagRows,
|
|
2775
|
+
path_buckets: bucketRows,
|
|
2776
|
+
unfiled_notes: unfiledRow.c,
|
|
2777
|
+
};
|
|
2778
|
+
}
|
|
2779
|
+
|
|
2780
|
+
const totalRow = db.prepare("SELECT COUNT(*) as c FROM notes").get() as { c: number };
|
|
2781
|
+
|
|
2782
|
+
const tagRows = db
|
|
2783
|
+
.prepare(
|
|
2784
|
+
`SELECT tag_name AS name, COUNT(*) AS count
|
|
2785
|
+
FROM note_tags
|
|
2786
|
+
GROUP BY tag_name
|
|
2787
|
+
ORDER BY count DESC, name ASC`,
|
|
2788
|
+
)
|
|
2789
|
+
.all() as { name: string; count: number }[];
|
|
2790
|
+
|
|
2791
|
+
const bucketRows = db
|
|
2792
|
+
.prepare(
|
|
2793
|
+
`SELECT ${pathBucketExpr("path")} AS name, COUNT(*) AS count
|
|
2794
|
+
FROM notes
|
|
2795
|
+
WHERE path IS NOT NULL
|
|
2796
|
+
GROUP BY name
|
|
2797
|
+
ORDER BY count DESC, name ASC`,
|
|
2798
|
+
)
|
|
2799
|
+
.all() as { name: string; count: number }[];
|
|
2800
|
+
|
|
2801
|
+
const unfiledRow = db
|
|
2802
|
+
.prepare("SELECT COUNT(*) as c FROM notes WHERE path IS NULL")
|
|
2803
|
+
.get() as { c: number };
|
|
2804
|
+
|
|
2805
|
+
return {
|
|
2806
|
+
total_notes: totalRow.c,
|
|
2807
|
+
tags: tagRows,
|
|
2808
|
+
path_buckets: bucketRows,
|
|
2809
|
+
unfiled_notes: unfiledRow.c,
|
|
2810
|
+
};
|
|
2811
|
+
}
|
|
2812
|
+
|
|
2362
2813
|
// ---- Bulk Operations ----
|
|
2363
2814
|
|
|
2364
2815
|
export interface BulkNoteInput {
|