@openparachute/vault 0.6.0-rc.1 → 0.6.1
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/.parachute/module.json +14 -3
- package/README.md +32 -7
- package/core/src/content-range.test.ts +374 -0
- package/core/src/content-range.ts +185 -0
- package/core/src/core.test.ts +279 -26
- package/core/src/expand-visibility.test.ts +102 -0
- package/core/src/expand.ts +31 -3
- package/core/src/indexed-fields.ts +1 -1
- package/core/src/link-count.test.ts +301 -0
- package/core/src/links.ts +172 -22
- package/core/src/mcp.ts +254 -34
- package/core/src/notes.ts +172 -48
- package/core/src/obsidian-alignment.test.ts +375 -0
- package/core/src/obsidian.ts +234 -14
- package/core/src/portable-md.test.ts +40 -0
- package/core/src/portable-md.ts +142 -16
- package/core/src/query-perf-routing.test.ts +208 -0
- package/core/src/schema.ts +87 -11
- package/core/src/store.ts +69 -22
- package/core/src/tag-expand-axis.test.ts +301 -0
- package/core/src/tag-hierarchy.ts +80 -0
- package/core/src/tag-schemas.ts +61 -46
- package/core/src/triggers-store.test.ts +100 -0
- package/core/src/triggers-store.ts +165 -0
- package/core/src/types.ts +68 -4
- package/core/src/vault-projection.ts +20 -0
- package/core/src/wikilinks.ts +2 -2
- package/package.json +2 -3
- package/src/admin-spa.test.ts +100 -10
- package/src/admin-spa.ts +48 -3
- package/src/auth-hub-jwt.test.ts +8 -1
- package/src/auth-status.ts +2 -2
- package/src/auth.test.ts +39 -3
- package/src/auth.ts +31 -2
- package/src/auto-transcribe.test.ts +51 -0
- package/src/auto-transcribe.ts +24 -6
- package/src/autostart.test.ts +75 -0
- package/src/autostart.ts +84 -0
- package/src/cli.ts +434 -140
- package/src/config.test.ts +109 -0
- package/src/config.ts +157 -10
- package/src/content-range-routes.test.ts +178 -0
- package/src/export-watch.test.ts +23 -0
- package/src/export-watch.ts +14 -0
- package/src/git-preflight.test.ts +70 -0
- package/src/git-preflight.ts +68 -0
- package/src/github-device-flow.test.ts +265 -6
- package/src/github-device-flow.ts +297 -45
- package/src/hub-jwt.test.ts +75 -2
- package/src/hub-jwt.ts +43 -6
- package/src/init-summary.test.ts +120 -5
- package/src/init-summary.ts +67 -25
- package/src/live-match.test.ts +198 -0
- package/src/live-match.ts +310 -0
- package/src/mcp-install.test.ts +93 -0
- package/src/mcp-install.ts +106 -0
- package/src/mcp-tools.ts +80 -7
- package/src/mirror-config.test.ts +14 -0
- package/src/mirror-config.ts +11 -0
- package/src/mirror-credentials.test.ts +20 -0
- package/src/mirror-credentials.ts +6 -2
- package/src/mirror-import.test.ts +110 -0
- package/src/mirror-import.ts +71 -13
- package/src/mirror-manager.test.ts +51 -0
- package/src/mirror-manager.ts +73 -11
- package/src/mirror-routes.test.ts +1331 -110
- package/src/mirror-routes.ts +787 -30
- package/src/oauth-discovery.test.ts +55 -0
- package/src/oauth-discovery.ts +24 -5
- package/src/routes.ts +763 -122
- package/src/routing.test.ts +451 -5
- package/src/routing.ts +121 -5
- package/src/scopes.ts +1 -1
- package/src/server.ts +66 -4
- package/src/storage.test.ts +162 -0
- package/src/subscribe.test.ts +588 -0
- package/src/subscribe.ts +248 -0
- package/src/subscriptions.ts +295 -0
- package/src/tag-expand-routes.test.ts +45 -0
- package/src/tag-scope.ts +68 -1
- package/src/token-store.ts +7 -7
- package/src/transcription-worker.test.ts +471 -5
- package/src/transcription-worker.ts +212 -44
- package/src/triggers-api.test.ts +533 -0
- package/src/triggers-api.ts +295 -0
- package/src/triggers.ts +93 -7
- package/src/usage.test.ts +362 -0
- package/src/usage.ts +318 -0
- package/src/vault-create.test.ts +340 -12
- package/src/vault-name.test.ts +61 -3
- package/src/vault-name.ts +62 -14
- package/src/vault-remove.test.ts +187 -0
- package/src/vault-store.ts +10 -3
- package/src/vault.test.ts +1353 -62
- package/web/ui/dist/assets/index-BPgyIjR7.js +61 -0
- package/web/ui/dist/assets/index-J0pVP7I-.css +1 -0
- package/web/ui/dist/index.html +2 -2
- package/web/ui/dist/assets/index-DBe8Xiah.css +0 -1
- package/web/ui/dist/assets/index-DDRo6F4u.js +0 -60
package/core/src/notes.ts
CHANGED
|
@@ -18,7 +18,7 @@ import {
|
|
|
18
18
|
type CursorPayload,
|
|
19
19
|
type QueryHashInputs,
|
|
20
20
|
} from "./cursor.js";
|
|
21
|
-
import { releaseField } from "./indexed-fields.js";
|
|
21
|
+
import { getIndexedField, releaseField } from "./indexed-fields.js";
|
|
22
22
|
|
|
23
23
|
let idCounter = 0;
|
|
24
24
|
|
|
@@ -83,7 +83,7 @@ export function createNote(
|
|
|
83
83
|
}
|
|
84
84
|
|
|
85
85
|
export function getNote(db: Database, id: string): Note | null {
|
|
86
|
-
const row = db.prepare("SELECT * FROM notes WHERE id = ?").get(id) as NoteRow |
|
|
86
|
+
const row = db.prepare("SELECT * FROM notes WHERE id = ?").get(id) as NoteRow | null;
|
|
87
87
|
if (!row) return null;
|
|
88
88
|
|
|
89
89
|
const note = rowToNote(row);
|
|
@@ -111,7 +111,7 @@ export function getNoteByPath(db: Database, path: string, extension?: string): N
|
|
|
111
111
|
if (extension !== undefined) {
|
|
112
112
|
const row = db.prepare(
|
|
113
113
|
"SELECT * FROM notes WHERE path = ? COLLATE NOCASE AND LOWER(extension) = ?",
|
|
114
|
-
).get(path, extension.toLowerCase()) as NoteRow |
|
|
114
|
+
).get(path, extension.toLowerCase()) as NoteRow | null;
|
|
115
115
|
if (!row) return null;
|
|
116
116
|
const note = rowToNote(row);
|
|
117
117
|
note.tags = getNoteTags(db, note.id);
|
|
@@ -142,11 +142,7 @@ export function getNotes(db: Database, ids: string[]): Note[] {
|
|
|
142
142
|
const rows = db.prepare(
|
|
143
143
|
`SELECT * FROM notes WHERE id IN (${placeholders}) ORDER BY created_at`,
|
|
144
144
|
).all(...ids) as NoteRow[];
|
|
145
|
-
return rows
|
|
146
|
-
const note = rowToNote(row);
|
|
147
|
-
note.tags = getNoteTags(db, note.id);
|
|
148
|
-
return note;
|
|
149
|
-
});
|
|
145
|
+
return notesWithTags(db, rows);
|
|
150
146
|
}
|
|
151
147
|
|
|
152
148
|
/**
|
|
@@ -459,7 +455,7 @@ export function updateNote(
|
|
|
459
455
|
if (isPathUniqueError(err)) {
|
|
460
456
|
const conflictPath = updates.path !== undefined
|
|
461
457
|
? (normalizePath(updates.path) ?? updates.path)
|
|
462
|
-
: ((db.prepare("SELECT path FROM notes WHERE id = ?").get(id) as { path: string | null } |
|
|
458
|
+
: ((db.prepare("SELECT path FROM notes WHERE id = ?").get(id) as { path: string | null } | null)?.path ?? "<unknown>");
|
|
463
459
|
throw new PathConflictError(conflictPath);
|
|
464
460
|
}
|
|
465
461
|
throw err;
|
|
@@ -475,7 +471,7 @@ export function updateNote(
|
|
|
475
471
|
function throwConflictOrMissing(db: Database, id: string, expected: string): never {
|
|
476
472
|
const row = db.prepare("SELECT updated_at, path FROM notes WHERE id = ?").get(id) as
|
|
477
473
|
| { updated_at: string | null; path: string | null }
|
|
478
|
-
|
|
|
474
|
+
| null;
|
|
479
475
|
if (!row) {
|
|
480
476
|
throw new Error(`Note not found: "${id}"`);
|
|
481
477
|
}
|
|
@@ -489,7 +485,6 @@ export function deleteNote(db: Database, id: string): void {
|
|
|
489
485
|
export function queryNotes(db: Database, opts: QueryOpts): Note[] {
|
|
490
486
|
const conditions: string[] = [];
|
|
491
487
|
const params: SQLQueryBindings[] = [];
|
|
492
|
-
const joins: string[] = [];
|
|
493
488
|
|
|
494
489
|
// Include tags — "all" (default): must have ALL tags; "any": must have ANY tag.
|
|
495
490
|
// The `_tagsExpanded` internal field carries per-input-tag descendant sets
|
|
@@ -498,6 +493,15 @@ export function queryNotes(db: Database, opts: QueryOpts): Note[] {
|
|
|
498
493
|
// `{manual, voice, text, ...}` per declared `_tags/*` config notes. Falls
|
|
499
494
|
// back to `[opts.tags[i]]` (single-element set) when no expansion is set,
|
|
500
495
|
// preserving the original semantics.
|
|
496
|
+
//
|
|
497
|
+
// Membership is expressed as a SEMIJOIN (`n.id IN (SELECT note_id ...)`),
|
|
498
|
+
// not a `JOIN note_tags`. A JOIN multiplies rows when a note carries
|
|
499
|
+
// several matching tags, which forced `SELECT DISTINCT n.*` — and that
|
|
500
|
+
// DISTINCT materialized every candidate's FULL row (content included)
|
|
501
|
+
// into a temp B-tree before LIMIT could apply, making large-tag queries
|
|
502
|
+
// cost O(candidates × row size) regardless of limit. The IN-subquery
|
|
503
|
+
// rides idx_note_tags_tag, produces each note id at most once, and lets
|
|
504
|
+
// the whole query drop DISTINCT. See the 2026-06-10 perf measurements.
|
|
501
505
|
if (opts.tags && opts.tags.length > 0) {
|
|
502
506
|
const tagSets: string[][] = (opts as QueryOpts & { _tagsExpanded?: string[][] })._tagsExpanded
|
|
503
507
|
?? opts.tags.map((t) => [t]);
|
|
@@ -508,17 +512,16 @@ export function queryNotes(db: Database, opts: QueryOpts): Note[] {
|
|
|
508
512
|
const flat = Array.from(new Set(tagSets.flat()));
|
|
509
513
|
if (flat.length > 0) {
|
|
510
514
|
const placeholders = flat.map(() => "?").join(", ");
|
|
511
|
-
|
|
515
|
+
conditions.push(`n.id IN (SELECT note_id FROM note_tags WHERE tag_name IN (${placeholders}))`);
|
|
512
516
|
params.push(...flat);
|
|
513
517
|
}
|
|
514
518
|
} else {
|
|
515
|
-
// "all": one
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
if (set.length === 0) continue;
|
|
519
|
-
const alias = `nt${i}`;
|
|
519
|
+
// "all": one membership clause per input tag, each accepting the
|
|
520
|
+
// input or any descendant.
|
|
521
|
+
for (const set of tagSets) {
|
|
522
|
+
if (!set || set.length === 0) continue;
|
|
520
523
|
const placeholders = set.map(() => "?").join(", ");
|
|
521
|
-
|
|
524
|
+
conditions.push(`n.id IN (SELECT note_id FROM note_tags WHERE tag_name IN (${placeholders}))`);
|
|
522
525
|
params.push(...set);
|
|
523
526
|
}
|
|
524
527
|
}
|
|
@@ -601,6 +604,20 @@ export function queryNotes(db: Database, opts: QueryOpts): Note[] {
|
|
|
601
604
|
// Metadata filters — operator objects route through the indexed generated
|
|
602
605
|
// column (fast, loud errors on non-indexed fields); primitives keep the
|
|
603
606
|
// existing JSON-scan exact-match behavior for backcompat.
|
|
607
|
+
//
|
|
608
|
+
// Plain-equality fast path (2026-06-10 perf measurements): when the field
|
|
609
|
+
// happens to be indexed, a plain `{field: value}` equality used to pay the
|
|
610
|
+
// same full-table json_extract scan as a non-indexed field — 280× slower
|
|
611
|
+
// than the operator form `{field: {eq: value}}` ON THE SAME column. We now
|
|
612
|
+
// prepend an indexed-prefilter conjunct (`"meta_<field>" = ?`) so the
|
|
613
|
+
// B-tree narrows the candidates, while KEEPING the original json_extract
|
|
614
|
+
// clause as a residual predicate. The conjunction is result-identical to
|
|
615
|
+
// the scan by construction: any row the scan matches also satisfies the
|
|
616
|
+
// prefilter (the generated column is the same json_extract under the
|
|
617
|
+
// column's type affinity), and rows where the affinity-converted column
|
|
618
|
+
// matches but the raw extraction doesn't (e.g. JSON number 5 vs query
|
|
619
|
+
// string "5") are excluded by the residual — exactly as the scan excluded
|
|
620
|
+
// them. Pinned by query-plain-eq-routing.test.ts.
|
|
604
621
|
if (opts.metadata) {
|
|
605
622
|
for (const [key, value] of Object.entries(opts.metadata)) {
|
|
606
623
|
if (isOperatorObject(value)) {
|
|
@@ -612,8 +629,17 @@ export function queryNotes(db: Database, opts: QueryOpts): Note[] {
|
|
|
612
629
|
conditions.push(sql);
|
|
613
630
|
params.push(...opParams);
|
|
614
631
|
} else {
|
|
615
|
-
|
|
616
|
-
|
|
632
|
+
const bound = typeof value === "string" ? value : JSON.stringify(value);
|
|
633
|
+
// `getIndexedField` returning a row proves `key` was validated by
|
|
634
|
+
// FIELD_NAME_RE at declaration time, so interpolating the column
|
|
635
|
+
// name is safe — same justification as buildOperatorClause.
|
|
636
|
+
if (getIndexedField(db, key)) {
|
|
637
|
+
conditions.push(`("meta_${key}" = ? AND json_extract(n.metadata, '$.' || ?) = ?)`);
|
|
638
|
+
params.push(bound, key, bound);
|
|
639
|
+
} else {
|
|
640
|
+
conditions.push(`json_extract(n.metadata, '$.' || ?) = ?`);
|
|
641
|
+
params.push(key, bound);
|
|
642
|
+
}
|
|
617
643
|
}
|
|
618
644
|
}
|
|
619
645
|
}
|
|
@@ -736,36 +762,121 @@ export function queryNotes(db: Database, opts: QueryOpts): Note[] {
|
|
|
736
762
|
// be at the mercy of SQLite's row order and the next page could
|
|
737
763
|
// miss or duplicate one.
|
|
738
764
|
orderBy = "n.updated_at ASC, n.id ASC";
|
|
765
|
+
} else if (opts.orderBy === "link_count") {
|
|
766
|
+
// `link_count` is a pseudo-field — like `created_at`/`updated_at` in the
|
|
767
|
+
// dateFilter block above, it bypasses `requireIndexedField` (it's not a
|
|
768
|
+
// metadata column). Sort by link DEGREE using the SAME directional-sum
|
|
769
|
+
// definition as the `linkCount` response field (see `getLinkCounts` in
|
|
770
|
+
// links.ts): two correlated COUNT subqueries summed. This MUST stay a
|
|
771
|
+
// sum of two directional counts — a single
|
|
772
|
+
// `COUNT(*) ... WHERE source_id=n.id OR target_id=n.id` would count a
|
|
773
|
+
// self-loop ONCE (degree 1) and DIVERGE from the field's degree-2. Both
|
|
774
|
+
// subqueries ride the existing `idx_links_source` / `idx_links_target`
|
|
775
|
+
// B-trees. `created_at` stays the stable tiebreaker.
|
|
776
|
+
//
|
|
777
|
+
// Always the both-directions degree — inbound-only ordering is a future
|
|
778
|
+
// extension and is not built here.
|
|
779
|
+
//
|
|
780
|
+
// Perf caveat: these are correlated subqueries, evaluated once per
|
|
781
|
+
// candidate row. At small-to-moderate vault sizes (tens of thousands of
|
|
782
|
+
// notes) that's fine — each subquery is an O(log n) index probe. At very
|
|
783
|
+
// large vault sizes the per-row scan cost grows; the upgrade path is a
|
|
784
|
+
// maintained `link_count` counter column on `notes`, incremented in
|
|
785
|
+
// `createLink` and decremented in `deleteLink`, then ordered directly.
|
|
786
|
+
// NOT built now — flagged so a future contributor sees the lever.
|
|
787
|
+
orderBy =
|
|
788
|
+
`((SELECT COUNT(*) FROM links WHERE source_id = n.id) ` +
|
|
789
|
+
`+ (SELECT COUNT(*) FROM links WHERE target_id = n.id)) ${direction}, ` +
|
|
790
|
+
`n.created_at ${direction}`;
|
|
739
791
|
} else if (opts.orderBy) {
|
|
740
792
|
requireIndexedField(db, opts.orderBy);
|
|
741
793
|
// `orderBy` came from indexed_fields (validated on declaration), so
|
|
742
794
|
// the column name is safe to interpolate. Append created_at as a
|
|
743
795
|
// stable tiebreaker so two rows with the same indexed value have a
|
|
744
796
|
// deterministic order.
|
|
745
|
-
orderBy = `"meta_${opts.orderBy}" ${direction}, n.created_at ${direction}`;
|
|
797
|
+
orderBy = `"meta_${opts.orderBy}" ${direction}, n.created_at ${direction}, n.id ${direction}`;
|
|
746
798
|
} else {
|
|
747
|
-
|
|
799
|
+
// id tiebreaker: same-millisecond inserts get deterministic relative
|
|
800
|
+
// order — load-bearing now that the two-phase page fetch makes
|
|
801
|
+
// pagination ordering the contract (#485 review nit).
|
|
802
|
+
orderBy = `n.created_at ${direction}, n.id ${direction}`;
|
|
748
803
|
}
|
|
749
804
|
const limit = typeof opts.limit === "number" ? opts.limit : 100;
|
|
750
805
|
const offset = typeof opts.offset === "number" ? opts.offset : 0;
|
|
751
806
|
|
|
752
807
|
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
|
753
808
|
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
809
|
+
// Two-phase "deferred join" page fetch (2026-06-10 perf measurements).
|
|
810
|
+
//
|
|
811
|
+
// Phase 1 selects ONLY `n.id` — the ORDER BY temp B-tree (when one is
|
|
812
|
+
// needed) holds narrow id/sort-key entries instead of full note rows, so
|
|
813
|
+
// sort/materialization cost no longer scales with content size. With the
|
|
814
|
+
// tag semijoin above there is no row multiplication, so no DISTINCT.
|
|
815
|
+
//
|
|
816
|
+
// Phase 2 fetches full rows for just the page (≤ limit ids) and re-orders
|
|
817
|
+
// to the phase-1 order; tags are hydrated in ONE batched query instead of
|
|
818
|
+
// one query per returned note.
|
|
819
|
+
const idSql = `
|
|
820
|
+
SELECT n.id FROM notes n
|
|
757
821
|
${whereClause}
|
|
758
822
|
ORDER BY ${orderBy}
|
|
759
823
|
LIMIT ? OFFSET ?
|
|
760
824
|
`;
|
|
761
825
|
params.push(limit, offset);
|
|
762
826
|
|
|
763
|
-
const
|
|
764
|
-
return
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
827
|
+
const idRows = db.prepare(idSql).all(...params) as { id: string }[];
|
|
828
|
+
return fetchNotesByIdsOrdered(db, idRows.map((r) => r.id));
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
/** Chunk size for IN-list queries — comfortably under SQLite's conservative
|
|
832
|
+
* 999 bound-variable floor (older builds), matching getLinkCounts. */
|
|
833
|
+
const IN_CHUNK = 900;
|
|
834
|
+
|
|
835
|
+
/**
|
|
836
|
+
* Fetch full note rows for `ids`, preserving the input order, with tags
|
|
837
|
+
* hydrated via ONE batched query per chunk (not one per note). Ids not
|
|
838
|
+
* found (deleted between phases) are silently dropped.
|
|
839
|
+
*/
|
|
840
|
+
function fetchNotesByIdsOrdered(db: Database, ids: string[]): Note[] {
|
|
841
|
+
if (ids.length === 0) return [];
|
|
842
|
+
const rowsById = new Map<string, NoteRow>();
|
|
843
|
+
for (let i = 0; i < ids.length; i += IN_CHUNK) {
|
|
844
|
+
const chunk = ids.slice(i, i + IN_CHUNK);
|
|
845
|
+
const placeholders = chunk.map(() => "?").join(", ");
|
|
846
|
+
const rows = db.prepare(
|
|
847
|
+
`SELECT * FROM notes WHERE id IN (${placeholders})`,
|
|
848
|
+
).all(...chunk) as NoteRow[];
|
|
849
|
+
for (const row of rows) rowsById.set(row.id, row);
|
|
850
|
+
}
|
|
851
|
+
const notes: Note[] = [];
|
|
852
|
+
for (const id of ids) {
|
|
853
|
+
const row = rowsById.get(id);
|
|
854
|
+
if (row) notes.push(rowToNote(row));
|
|
855
|
+
}
|
|
856
|
+
const tagsById = getNoteTagsForNotes(db, notes.map((n) => n.id));
|
|
857
|
+
for (const note of notes) note.tags = tagsById.get(note.id) ?? [];
|
|
858
|
+
return notes;
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
/**
|
|
862
|
+
* Batched tag lookup: tags for many notes in one IN-list query per chunk.
|
|
863
|
+
* Per-note arrays are sorted by tag_name — identical to `getNoteTags`.
|
|
864
|
+
* Every requested id is present in the map (empty array when untagged).
|
|
865
|
+
*/
|
|
866
|
+
export function getNoteTagsForNotes(db: Database, noteIds: string[]): Map<string, string[]> {
|
|
867
|
+
const map = new Map<string, string[]>();
|
|
868
|
+
if (noteIds.length === 0) return map;
|
|
869
|
+
const ids = [...new Set(noteIds)];
|
|
870
|
+
for (const id of ids) map.set(id, []);
|
|
871
|
+
for (let i = 0; i < ids.length; i += IN_CHUNK) {
|
|
872
|
+
const chunk = ids.slice(i, i + IN_CHUNK);
|
|
873
|
+
const placeholders = chunk.map(() => "?").join(", ");
|
|
874
|
+
const rows = db.prepare(
|
|
875
|
+
`SELECT note_id, tag_name FROM note_tags WHERE note_id IN (${placeholders}) ORDER BY tag_name`,
|
|
876
|
+
).all(...chunk) as { note_id: string; tag_name: string }[];
|
|
877
|
+
for (const row of rows) map.get(row.note_id)!.push(row.tag_name);
|
|
878
|
+
}
|
|
879
|
+
return map;
|
|
769
880
|
}
|
|
770
881
|
|
|
771
882
|
/**
|
|
@@ -869,20 +980,19 @@ export function searchNotes(
|
|
|
869
980
|
|
|
870
981
|
if (opts?.tags && opts.tags.length > 0) {
|
|
871
982
|
try {
|
|
983
|
+
// Tag membership as a semijoin — same rationale as queryNotes: a
|
|
984
|
+
// `JOIN note_tags` multiplies rows for multi-tagged notes and forced
|
|
985
|
+
// DISTINCT over full rows. The FTS join itself is 1:1 on rowid.
|
|
872
986
|
const tagPlaceholders = opts.tags.map(() => "?").join(", ");
|
|
873
987
|
const rows = db.prepare(`
|
|
874
|
-
SELECT
|
|
988
|
+
SELECT n.* FROM notes n
|
|
875
989
|
JOIN notes_fts fts ON fts.rowid = n.rowid
|
|
876
|
-
JOIN note_tags nt ON nt.note_id = n.id AND nt.tag_name IN (${tagPlaceholders})
|
|
877
990
|
WHERE notes_fts MATCH ?
|
|
991
|
+
AND n.id IN (SELECT note_id FROM note_tags WHERE tag_name IN (${tagPlaceholders}))
|
|
878
992
|
ORDER BY rank
|
|
879
993
|
LIMIT ?
|
|
880
|
-
`).all(...opts.tags,
|
|
881
|
-
return rows
|
|
882
|
-
const note = rowToNote(row);
|
|
883
|
-
note.tags = getNoteTags(db, note.id);
|
|
884
|
-
return note;
|
|
885
|
-
});
|
|
994
|
+
`).all(query, ...opts.tags, limit) as NoteRow[];
|
|
995
|
+
return notesWithTags(db, rows);
|
|
886
996
|
} catch {
|
|
887
997
|
return [];
|
|
888
998
|
}
|
|
@@ -896,16 +1006,20 @@ export function searchNotes(
|
|
|
896
1006
|
ORDER BY rank
|
|
897
1007
|
LIMIT ?
|
|
898
1008
|
`).all(query, limit) as NoteRow[];
|
|
899
|
-
return rows
|
|
900
|
-
const note = rowToNote(row);
|
|
901
|
-
note.tags = getNoteTags(db, note.id);
|
|
902
|
-
return note;
|
|
903
|
-
});
|
|
1009
|
+
return notesWithTags(db, rows);
|
|
904
1010
|
} catch {
|
|
905
1011
|
return [];
|
|
906
1012
|
}
|
|
907
1013
|
}
|
|
908
1014
|
|
|
1015
|
+
/** Map rows → Notes with tags hydrated in one batched query. */
|
|
1016
|
+
function notesWithTags(db: Database, rows: NoteRow[]): Note[] {
|
|
1017
|
+
const notes = rows.map(rowToNote);
|
|
1018
|
+
const tagsById = getNoteTagsForNotes(db, notes.map((n) => n.id));
|
|
1019
|
+
for (const note of notes) note.tags = tagsById.get(note.id) ?? [];
|
|
1020
|
+
return notes;
|
|
1021
|
+
}
|
|
1022
|
+
|
|
909
1023
|
// ---- Tag Operations ----
|
|
910
1024
|
|
|
911
1025
|
export function tagNote(db: Database, noteId: string, tags: string[]): void {
|
|
@@ -946,7 +1060,7 @@ export function listTags(db: Database): { name: string; count: number }[] {
|
|
|
946
1060
|
export function deleteTag(db: Database, name: string): { deleted: boolean; notes_untagged: number } {
|
|
947
1061
|
const row = db.prepare("SELECT fields FROM tags WHERE name = ?").get(name) as
|
|
948
1062
|
| { fields: string | null }
|
|
949
|
-
|
|
|
1063
|
+
| null;
|
|
950
1064
|
if (!row) return { deleted: false, notes_untagged: 0 };
|
|
951
1065
|
|
|
952
1066
|
const countRow = db.prepare("SELECT COUNT(*) as c FROM note_tags WHERE tag_name = ?").get(name) as { c: number };
|
|
@@ -1107,7 +1221,7 @@ export function renameTag(db: Database, oldName: string, newName: string): Renam
|
|
|
1107
1221
|
for (const { from, to } of renames) {
|
|
1108
1222
|
const old = readStmt.get(from) as
|
|
1109
1223
|
| { description: string | null; fields: string | null; relationships: string | null; parent_names: string | null; created_at: string | null }
|
|
1110
|
-
|
|
|
1224
|
+
| null;
|
|
1111
1225
|
insertStmt.run(
|
|
1112
1226
|
to,
|
|
1113
1227
|
old?.description ?? null,
|
|
@@ -1522,11 +1636,11 @@ export function getVaultStats(
|
|
|
1522
1636
|
|
|
1523
1637
|
const earliestRow = db.prepare(
|
|
1524
1638
|
"SELECT id, created_at FROM notes ORDER BY created_at ASC, id ASC LIMIT 1",
|
|
1525
|
-
).get() as { id: string; created_at: string } |
|
|
1639
|
+
).get() as { id: string; created_at: string } | null;
|
|
1526
1640
|
|
|
1527
1641
|
const latestRow = db.prepare(
|
|
1528
1642
|
"SELECT id, created_at FROM notes ORDER BY created_at DESC, id DESC LIMIT 1",
|
|
1529
|
-
).get() as { id: string; created_at: string } |
|
|
1643
|
+
).get() as { id: string; created_at: string } | null;
|
|
1530
1644
|
|
|
1531
1645
|
const monthRows = db.prepare(`
|
|
1532
1646
|
SELECT strftime('%Y-%m', created_at) AS month, COUNT(*) AS count
|
|
@@ -1553,6 +1667,15 @@ export function getVaultStats(
|
|
|
1553
1667
|
const linkCountRow = db.prepare("SELECT COUNT(*) as c FROM links").get() as { c: number };
|
|
1554
1668
|
const linkCount = linkCountRow.c;
|
|
1555
1669
|
|
|
1670
|
+
// Total content bytes. CAST(content AS BLOB) forces SQLite's LENGTH() to
|
|
1671
|
+
// count UTF-8 BYTES rather than characters (bare LENGTH on TEXT returns a
|
|
1672
|
+
// char count, which undercounts multibyte content). COALESCE because SUM
|
|
1673
|
+
// over zero rows is NULL. See VaultStats.contentBytes for the rationale.
|
|
1674
|
+
const contentBytesRow = db
|
|
1675
|
+
.prepare("SELECT COALESCE(SUM(LENGTH(CAST(content AS BLOB))), 0) as b FROM notes")
|
|
1676
|
+
.get() as { b: number };
|
|
1677
|
+
const contentBytes = contentBytesRow.b;
|
|
1678
|
+
|
|
1556
1679
|
return {
|
|
1557
1680
|
totalNotes,
|
|
1558
1681
|
earliestNote: earliestRow
|
|
@@ -1566,6 +1689,7 @@ export function getVaultStats(
|
|
|
1566
1689
|
tagCount,
|
|
1567
1690
|
attachmentCount,
|
|
1568
1691
|
linkCount,
|
|
1692
|
+
contentBytes,
|
|
1569
1693
|
};
|
|
1570
1694
|
}
|
|
1571
1695
|
|