@openparachute/vault 0.6.5-rc.2 → 0.6.5
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 +1 -0
- package/core/src/core.test.ts +7 -7
- package/core/src/do-param-cap.test.ts +161 -0
- package/core/src/links.ts +8 -13
- package/core/src/mcp.ts +1 -1
- package/core/src/notes.ts +33 -15
- package/core/src/onboarding.ts +14 -296
- package/core/src/schema.ts +5 -5
- package/core/src/seed-packs.test.ts +357 -0
- package/core/src/seed-packs.ts +823 -0
- package/core/src/sql-in.test.ts +58 -0
- package/core/src/sql-in.ts +76 -0
- package/core/src/tag-hierarchy.ts +1 -1
- package/core/src/tag-schemas.ts +2 -2
- package/core/src/transcription/provider.ts +141 -0
- package/core/src/types.ts +1 -1
- package/core/src/vault-projection.ts +1 -1
- package/core/src/wikilinks.ts +10 -4
- package/package.json +1 -1
- package/src/add-pack.test.ts +142 -0
- package/src/admin-spa.ts +2 -1
- package/src/auth.ts +1 -1
- package/src/cli.ts +806 -7
- package/src/export-watch.ts +1 -1
- package/src/live-frame-parity.test.ts +201 -0
- package/src/module-manifest.ts +8 -0
- package/src/onboarding-seed.test.ts +188 -40
- package/src/onboarding-seed.ts +41 -46
- package/src/routes.ts +20 -3
- package/src/routing.test.ts +2 -2
- package/src/routing.ts +18 -6
- package/src/self-register.test.ts +19 -0
- package/src/self-register.ts +6 -1
- package/src/server.ts +133 -31
- package/src/services-manifest.ts +8 -0
- package/src/subscriptions.ts +100 -42
- package/src/tag-scope.ts +3 -3
- package/src/test-support/live-frame-corpus.ts +72 -0
- package/src/token-store.ts +2 -2
- package/src/transcription/build.test.ts +305 -0
- package/src/transcription/build.ts +332 -0
- package/src/transcription/capability.test.ts +118 -0
- package/src/transcription/capability.ts +96 -0
- package/src/transcription/download.test.ts +101 -0
- package/src/transcription/download.ts +71 -0
- package/src/transcription/install-python.test.ts +366 -0
- package/src/transcription/install-python.ts +471 -0
- package/src/transcription/install.test.ts +167 -0
- package/src/transcription/install.ts +296 -0
- package/src/transcription/providers/onnx-asr.test.ts +229 -0
- package/src/transcription/providers/onnx-asr.ts +239 -0
- package/src/transcription/providers/parakeet-mlx.test.ts +290 -0
- package/src/transcription/providers/parakeet-mlx.ts +242 -0
- package/src/transcription/providers/scribe-http.test.ts +195 -0
- package/src/transcription/providers/scribe-http.ts +144 -0
- package/src/transcription/providers/transcribe-cpp.test.ts +314 -0
- package/src/transcription/providers/transcribe-cpp.ts +293 -0
- package/src/transcription/select.test.ts +299 -0
- package/src/transcription/select.ts +397 -0
- package/src/transcription/tiers.test.ts +197 -0
- package/src/transcription/tiers.ts +184 -0
- package/src/transcription-worker.test.ts +44 -0
- package/src/transcription-worker.ts +57 -122
- package/src/vault-create.test.ts +43 -10
- package/src/vault.test.ts +49 -1
- package/src/ws-server.ts +408 -0
- package/src/ws-subscribe.test.ts +474 -0
- package/src/ws-subscribe.ts +242 -0
- package/src/subscribe.test.ts +0 -609
- package/src/subscribe.ts +0 -248
package/.parachute/module.json
CHANGED
package/core/src/core.test.ts
CHANGED
|
@@ -4046,7 +4046,7 @@ describe("query-notes link expansion", async () => {
|
|
|
4046
4046
|
});
|
|
4047
4047
|
|
|
4048
4048
|
// ---------------------------------------------------------------------------
|
|
4049
|
-
// Tag hierarchy via tags.parent_names (post-v14,
|
|
4049
|
+
// Tag hierarchy via tags.parent_names (post-v14, docs/contracts/tag-data-model.md)
|
|
4050
4050
|
// ---------------------------------------------------------------------------
|
|
4051
4051
|
|
|
4052
4052
|
describe("tag hierarchy (tags.parent_names)", async () => {
|
|
@@ -5107,7 +5107,7 @@ describe("schema inheritance via parent_names (vault#270)", async () => {
|
|
|
5107
5107
|
});
|
|
5108
5108
|
});
|
|
5109
5109
|
|
|
5110
|
-
describe("expandTagsWithDescendants (tag-scoped tokens —
|
|
5110
|
+
describe("expandTagsWithDescendants (tag-scoped tokens — docs/contracts/tag-scoped-tokens.md)", async () => {
|
|
5111
5111
|
it("returns the union of root + every descendant per tags.parent_names", async () => {
|
|
5112
5112
|
await store.upsertTagRecord("health/food", { parent_names: ["health"] });
|
|
5113
5113
|
await store.upsertTagRecord("health/food/breakfast", { parent_names: ["health/food"] });
|
|
@@ -5143,10 +5143,10 @@ describe("expandTagsWithDescendants (tag-scoped tokens — patterns/tag-scoped-t
|
|
|
5143
5143
|
});
|
|
5144
5144
|
|
|
5145
5145
|
// ---------------------------------------------------------------------------
|
|
5146
|
-
// Tag record API —
|
|
5146
|
+
// Tag record API — docs/contracts/tag-data-model.md
|
|
5147
5147
|
// ---------------------------------------------------------------------------
|
|
5148
5148
|
|
|
5149
|
-
describe("tag record API (
|
|
5149
|
+
describe("tag record API (docs/contracts/tag-data-model.md)", async () => {
|
|
5150
5150
|
it("upsertTagRecord persists description + fields + relationships + parent_names", async () => {
|
|
5151
5151
|
await store.upsertTagRecord("project", {
|
|
5152
5152
|
description: "long-running deliverable",
|
|
@@ -5430,7 +5430,7 @@ describe("tag record API (patterns/tag-data-model.md)", async () => {
|
|
|
5430
5430
|
});
|
|
5431
5431
|
|
|
5432
5432
|
// ---------------------------------------------------------------------------
|
|
5433
|
-
// Schema migration v13 → v14 —
|
|
5433
|
+
// Schema migration v13 → v14 — docs/contracts/tag-data-model.md
|
|
5434
5434
|
// ---------------------------------------------------------------------------
|
|
5435
5435
|
|
|
5436
5436
|
describe("schema migration v13 → v14", async () => {
|
|
@@ -5809,7 +5809,7 @@ describe("schema migration v15 → v16", async () => {
|
|
|
5809
5809
|
});
|
|
5810
5810
|
|
|
5811
5811
|
// ---------------------------------------------------------------------------
|
|
5812
|
-
// Tag-scope auth post-v14 —
|
|
5812
|
+
// Tag-scope auth post-v14 — docs/contracts/tag-scoped-tokens.md
|
|
5813
5813
|
// ---------------------------------------------------------------------------
|
|
5814
5814
|
|
|
5815
5815
|
describe("tag-scope auth (post-v14 hierarchy)", async () => {
|
|
@@ -5824,7 +5824,7 @@ describe("tag-scope auth (post-v14 hierarchy)", async () => {
|
|
|
5824
5824
|
});
|
|
5825
5825
|
|
|
5826
5826
|
it("orphan sub-tag fallback: token for `health` still sees `#health/food` even with no declared hierarchy", async () => {
|
|
5827
|
-
// Per
|
|
5827
|
+
// Per docs/contracts/tag-scoped-tokens.md §Storage details, the auth check
|
|
5828
5828
|
// also splits on '/' and matches the root verbatim against the raw
|
|
5829
5829
|
// allowlist. This survives the v14 source-of-truth swap because the
|
|
5830
5830
|
// fallback lives in src/tag-scope.ts, not in the resolver.
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Behavioral proof that the id-list query paths stay under the Cloudflare
|
|
3
|
+
* Durable Object SQLite 100-bound-param cap over a real >100-note vault.
|
|
4
|
+
*
|
|
5
|
+
* bun:sqlite tolerates 999+ bound params, so it CANNOT reproduce the DO
|
|
6
|
+
* `too many SQL variables` 500 directly — the ultimate DO-constraint proof is
|
|
7
|
+
* the cloud staging export re-verify (orchestrator's post-merge step). What we
|
|
8
|
+
* CAN prove locally, and what actually catches the regression that let the bug
|
|
9
|
+
* ship, is the *shape* of the SQL each path issues: no single prepared
|
|
10
|
+
* statement binds more than the chunk size (chunked lists) or a handful of
|
|
11
|
+
* params (json_each id-sets). A prepare-spy records the max bind count per
|
|
12
|
+
* statement; if a future edit raises the chunk size back over the cap, the
|
|
13
|
+
* placeholder-IN assertion here fails.
|
|
14
|
+
*
|
|
15
|
+
* Root cause recap: DO SQLite caps bound params at 100/statement. The export
|
|
16
|
+
* walk pages 500 notes at a time and hydrated each page's rows + tags with an
|
|
17
|
+
* `id IN (?×page)` list sized for standard SQLite's 999 floor → >100 params →
|
|
18
|
+
* DO 500. Fix: chunk id-lists at 90 (sql-in.ts) and bind embedded id-sets
|
|
19
|
+
* (e.g. `near` neighborhoods) as one json_each param.
|
|
20
|
+
*/
|
|
21
|
+
import { describe, it, expect, beforeEach, afterEach } from "bun:test";
|
|
22
|
+
import { Database } from "bun:sqlite";
|
|
23
|
+
import { existsSync, mkdtempSync, rmSync } from "fs";
|
|
24
|
+
import { tmpdir } from "os";
|
|
25
|
+
import { join } from "path";
|
|
26
|
+
|
|
27
|
+
import { SqliteStore } from "./store.js";
|
|
28
|
+
import { exportVaultToDir } from "./portable-md.js";
|
|
29
|
+
import { IN_PARAM_CHUNK } from "./sql-in.js";
|
|
30
|
+
|
|
31
|
+
/** Zero-padded so lexicographic id order == numeric order. */
|
|
32
|
+
const pad = (i: number): string => String(i).padStart(5, "0");
|
|
33
|
+
|
|
34
|
+
/** Wrap `db.prepare` to record the max bind-arg count seen per SQL string.
|
|
35
|
+
* Install AFTER seeding so bulk-insert params don't pollute the sample. */
|
|
36
|
+
function installBindSpy(db: Database) {
|
|
37
|
+
const perSql = new Map<string, number>();
|
|
38
|
+
const origPrepare = db.prepare.bind(db);
|
|
39
|
+
(db as unknown as { prepare: (sql: string) => unknown }).prepare = (sql: string) => {
|
|
40
|
+
const stmt = origPrepare(sql) as Record<string, unknown>;
|
|
41
|
+
const record = (n: number) => perSql.set(sql, Math.max(perSql.get(sql) ?? 0, n));
|
|
42
|
+
for (const m of ["all", "get", "run", "values", "iterate"] as const) {
|
|
43
|
+
const orig = stmt[m];
|
|
44
|
+
if (typeof orig === "function") {
|
|
45
|
+
stmt[m] = (...args: unknown[]) => {
|
|
46
|
+
record(args.length);
|
|
47
|
+
return (orig as (...a: unknown[]) => unknown).apply(stmt, args);
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return stmt;
|
|
52
|
+
};
|
|
53
|
+
return {
|
|
54
|
+
restore() {
|
|
55
|
+
(db as unknown as { prepare: unknown }).prepare = origPrepare;
|
|
56
|
+
},
|
|
57
|
+
/** Max binds over statements carrying a literal `IN (?, …)` placeholder
|
|
58
|
+
* list — the chunked kind that must never exceed the chunk size. */
|
|
59
|
+
maxPlaceholderInBinds(): number {
|
|
60
|
+
let max = 0;
|
|
61
|
+
for (const [sql, n] of perSql) if (/\bIN \(\?/.test(sql)) max = Math.max(max, n);
|
|
62
|
+
return max;
|
|
63
|
+
},
|
|
64
|
+
/** Max binds over ALL statements — must stay under the DO cap. */
|
|
65
|
+
maxAnyBinds(): number {
|
|
66
|
+
let max = 0;
|
|
67
|
+
for (const n of perSql.values()) max = Math.max(max, n);
|
|
68
|
+
return max;
|
|
69
|
+
},
|
|
70
|
+
usedJsonEach(): boolean {
|
|
71
|
+
for (const sql of perSql.keys()) if (/json_each\(/.test(sql)) return true;
|
|
72
|
+
return false;
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const DO_PARAM_CAP = 100;
|
|
78
|
+
|
|
79
|
+
describe("id-list query paths stay under the DO 100-bound-param cap", () => {
|
|
80
|
+
let store: SqliteStore;
|
|
81
|
+
let db: Database;
|
|
82
|
+
|
|
83
|
+
beforeEach(async () => {
|
|
84
|
+
db = new Database(":memory:");
|
|
85
|
+
store = new SqliteStore(db);
|
|
86
|
+
// 250 notes > the DO cap and > 2× the chunk size, so chunking spans
|
|
87
|
+
// multiple chunks (250 = 90 + 90 + 70).
|
|
88
|
+
const inputs = [];
|
|
89
|
+
for (let i = 0; i < 250; i++) {
|
|
90
|
+
inputs.push({ content: `note ${i}`, id: `n${pad(i)}`, path: `notes/n${pad(i)}` });
|
|
91
|
+
}
|
|
92
|
+
await store.createNotes(inputs);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("getNotes chunks a >100-id fetch (correct rows, no statement over the cap)", async () => {
|
|
96
|
+
const ids = Array.from({ length: 250 }, (_, i) => `n${pad(i)}`);
|
|
97
|
+
const spy = installBindSpy(db);
|
|
98
|
+
try {
|
|
99
|
+
const notes = await store.getNotes(ids);
|
|
100
|
+
// Correctness: every requested note returned, ordered by created_at then id.
|
|
101
|
+
expect(notes.length).toBe(250);
|
|
102
|
+
expect(notes.map((n) => n.id)).toEqual(ids);
|
|
103
|
+
} finally {
|
|
104
|
+
spy.restore();
|
|
105
|
+
}
|
|
106
|
+
// Regression guard: no IN-list statement binds more than the chunk size...
|
|
107
|
+
expect(spy.maxPlaceholderInBinds()).toBeLessThanOrEqual(IN_PARAM_CHUNK);
|
|
108
|
+
// ...and nothing at all trips the DO cap.
|
|
109
|
+
expect(spy.maxAnyBinds()).toBeLessThanOrEqual(DO_PARAM_CAP);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("queryNotes over a >100-id set (the `near` path) binds the set as one json_each param", async () => {
|
|
113
|
+
const ids = Array.from({ length: 150 }, (_, i) => `n${pad(i)}`);
|
|
114
|
+
const spy = installBindSpy(db);
|
|
115
|
+
let notes;
|
|
116
|
+
try {
|
|
117
|
+
notes = await store.queryNotes({ ids, limit: 1000, sort: "asc" });
|
|
118
|
+
} finally {
|
|
119
|
+
spy.restore();
|
|
120
|
+
}
|
|
121
|
+
// Correctness: all 150 in-set notes returned.
|
|
122
|
+
expect(notes.length).toBe(150);
|
|
123
|
+
// The id-set filter rode a single json_each param, not 150 placeholders...
|
|
124
|
+
expect(spy.usedJsonEach()).toBe(true);
|
|
125
|
+
// ...so no statement — including the phase-1 id select carrying the set —
|
|
126
|
+
// exceeds the DO cap, and the phase-2 chunked hydration stays under it.
|
|
127
|
+
expect(spy.maxPlaceholderInBinds()).toBeLessThanOrEqual(IN_PARAM_CHUNK);
|
|
128
|
+
expect(spy.maxAnyBinds()).toBeLessThanOrEqual(DO_PARAM_CAP);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("empty id-set still short-circuits (no json_each, no rows)", async () => {
|
|
132
|
+
const notes = await store.queryNotes({ ids: [], limit: 10 });
|
|
133
|
+
expect(notes.length).toBe(0);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
describe("export a >100-note vault", () => {
|
|
137
|
+
let outDir: string;
|
|
138
|
+
beforeEach(() => { outDir = mkdtempSync(join(tmpdir(), "do-cap-export-")); });
|
|
139
|
+
afterEach(() => { try { rmSync(outDir, { recursive: true, force: true }); } catch {} });
|
|
140
|
+
|
|
141
|
+
it("exports all 250 notes with no statement over the cap (the shipped bug)", async () => {
|
|
142
|
+
const spy = installBindSpy(db);
|
|
143
|
+
let stats;
|
|
144
|
+
try {
|
|
145
|
+
stats = await exportVaultToDir(store, { outDir, exportedAt: "2026-07-03T00:00:00.000Z" });
|
|
146
|
+
} finally {
|
|
147
|
+
spy.restore();
|
|
148
|
+
}
|
|
149
|
+
// Correctness: every note on disk.
|
|
150
|
+
expect(stats.notes).toBe(250);
|
|
151
|
+
expect(existsSync(join(outDir, "notes/n00000.md"))).toBe(true);
|
|
152
|
+
expect(existsSync(join(outDir, "notes/n00090.md"))).toBe(true); // first chunk boundary
|
|
153
|
+
expect(existsSync(join(outDir, "notes/n00249.md"))).toBe(true); // tail
|
|
154
|
+
// The bug: the export page's `id IN (?×page)` hydration exceeded 100 on
|
|
155
|
+
// DO. Now every IN-list statement stays under the chunk size...
|
|
156
|
+
expect(spy.maxPlaceholderInBinds()).toBeLessThanOrEqual(IN_PARAM_CHUNK);
|
|
157
|
+
// ...and no statement trips the DO cap.
|
|
158
|
+
expect(spy.maxAnyBinds()).toBeLessThanOrEqual(DO_PARAM_CAP);
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
});
|
package/core/src/links.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Database } from "bun:sqlite";
|
|
2
2
|
import type { Link, NoteSummary, HydratedLink } from "./types.js";
|
|
3
3
|
import { getNoteTagsForNotes } from "./notes.js";
|
|
4
|
+
import { chunkForInClause } from "./sql-in.js";
|
|
4
5
|
|
|
5
6
|
export function createLink(
|
|
6
7
|
db: Database,
|
|
@@ -103,16 +104,13 @@ function parseMetadata(raw: string | null): Record<string, unknown> | undefined
|
|
|
103
104
|
try { return JSON.parse(raw); } catch { return undefined; }
|
|
104
105
|
}
|
|
105
106
|
|
|
106
|
-
/** IN-list chunk size — matches getLinkCounts' conservative bound-variable floor. */
|
|
107
|
-
const IN_CHUNK = 900;
|
|
108
|
-
|
|
109
107
|
function getNoteSummaries(db: Database, noteIds: string[]): Map<string, NoteSummary> {
|
|
110
108
|
const map = new Map<string, NoteSummary>();
|
|
111
109
|
if (noteIds.length === 0) return map;
|
|
112
110
|
const ids = [...new Set(noteIds)];
|
|
113
111
|
const rows: SummaryRow[] = [];
|
|
114
|
-
|
|
115
|
-
|
|
112
|
+
// Chunk under the DO 100-bound-param cap (see sql-in.ts).
|
|
113
|
+
for (const chunk of chunkForInClause(ids)) {
|
|
116
114
|
const placeholders = chunk.map(() => "?").join(", ");
|
|
117
115
|
rows.push(...db.prepare(
|
|
118
116
|
`SELECT id, path, metadata, created_at, updated_at FROM notes WHERE id IN (${placeholders})`,
|
|
@@ -193,8 +191,8 @@ export function getLinksHydratedForNotes(
|
|
|
193
191
|
// (source, target, relationship) primary key so a link whose endpoints
|
|
194
192
|
// are both on the page is fetched once.
|
|
195
193
|
const rowsByKey = new Map<string, LinkRow>();
|
|
196
|
-
|
|
197
|
-
|
|
194
|
+
// Chunk under the DO 100-bound-param cap (see sql-in.ts).
|
|
195
|
+
for (const chunk of chunkForInClause(ids)) {
|
|
198
196
|
const placeholders = chunk.map(() => "?").join(", ");
|
|
199
197
|
for (const column of ["source_id", "target_id"] as const) {
|
|
200
198
|
const rows = db.prepare(
|
|
@@ -265,12 +263,9 @@ export function getLinkCounts(
|
|
|
265
263
|
const wantOutbound = direction === "outbound" || direction === "both";
|
|
266
264
|
const wantInbound = direction === "inbound" || direction === "both";
|
|
267
265
|
|
|
268
|
-
//
|
|
269
|
-
//
|
|
270
|
-
|
|
271
|
-
const CHUNK = 900;
|
|
272
|
-
for (let i = 0; i < ids.length; i += CHUNK) {
|
|
273
|
-
const chunk = ids.slice(i, i + CHUNK);
|
|
266
|
+
// Chunk under the Cloudflare Durable Object SQLite 100-bound-param cap
|
|
267
|
+
// (bun:sqlite tolerates 999+, DO SQLite rejects >100). See sql-in.ts.
|
|
268
|
+
for (const chunk of chunkForInClause(ids)) {
|
|
274
269
|
const placeholders = chunk.map(() => "?").join(", ");
|
|
275
270
|
|
|
276
271
|
if (wantOutbound) {
|
package/core/src/mcp.ts
CHANGED
|
@@ -1408,7 +1408,7 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
|
|
|
1408
1408
|
{
|
|
1409
1409
|
name: "update-tag",
|
|
1410
1410
|
requiredVerb: "write",
|
|
1411
|
-
description: "Create or update a tag's identity row: description, indexed-field schemas, relationship-vocabulary map, and hierarchy parents. If the tag doesn't exist, it's created. Fields are merged (new keys added, existing keys replaced); relationships and parent_names are replaced wholesale when provided. Pass null for fields/relationships/parent_names to clear that column. See parachute-
|
|
1411
|
+
description: "Create or update a tag's identity row: description, indexed-field schemas, relationship-vocabulary map, and hierarchy parents. If the tag doesn't exist, it's created. Fields are merged (new keys added, existing keys replaced); relationships and parent_names are replaced wholesale when provided. Pass null for fields/relationships/parent_names to clear that column. See parachute-vault/docs/contracts/tag-data-model.md.",
|
|
1412
1412
|
inputSchema: {
|
|
1413
1413
|
type: "object",
|
|
1414
1414
|
properties: {
|
package/core/src/notes.ts
CHANGED
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
} from "./cursor.js";
|
|
22
22
|
import { getIndexedField, releaseField } from "./indexed-fields.js";
|
|
23
23
|
import { stripTagHash } from "./tag-hierarchy.js";
|
|
24
|
+
import { chunkForInClause, IN_VIA_JSON_EACH, jsonEachParam } from "./sql-in.js";
|
|
24
25
|
|
|
25
26
|
let idCounter = 0;
|
|
26
27
|
|
|
@@ -178,10 +179,26 @@ export function getNoteByPath(db: Database, path: string, extension?: string): N
|
|
|
178
179
|
|
|
179
180
|
export function getNotes(db: Database, ids: string[]): Note[] {
|
|
180
181
|
if (ids.length === 0) return [];
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
182
|
+
// Dedupe before chunking: a duplicate id straddling two chunk boundaries
|
|
183
|
+
// would otherwise be fetched (and returned) twice, unlike the old single
|
|
184
|
+
// IN-list which returned each matching row once regardless of duplicate
|
|
185
|
+
// params. Matches getNoteTagsForNotes / getNoteSummaries.
|
|
186
|
+
const uniqueIds = [...new Set(ids)];
|
|
187
|
+
// Chunk under the DO 100-bound-param cap (see sql-in.ts). Each chunk is its
|
|
188
|
+
// own IN-list query; results are merged and re-sorted by created_at (with id
|
|
189
|
+
// as a deterministic tiebreak) to preserve the single-statement ORDER BY.
|
|
190
|
+
const rows: NoteRow[] = [];
|
|
191
|
+
for (const chunk of chunkForInClause(uniqueIds)) {
|
|
192
|
+
const placeholders = chunk.map(() => "?").join(", ");
|
|
193
|
+
rows.push(...db.prepare(
|
|
194
|
+
`SELECT * FROM notes WHERE id IN (${placeholders})`,
|
|
195
|
+
).all(...chunk) as NoteRow[]);
|
|
196
|
+
}
|
|
197
|
+
rows.sort((a, b) =>
|
|
198
|
+
a.created_at < b.created_at ? -1
|
|
199
|
+
: a.created_at > b.created_at ? 1
|
|
200
|
+
: a.id < b.id ? -1 : a.id > b.id ? 1 : 0,
|
|
201
|
+
);
|
|
185
202
|
return notesWithTags(db, rows);
|
|
186
203
|
}
|
|
187
204
|
|
|
@@ -756,9 +773,13 @@ export function queryNotes(db: Database, opts: QueryOpts): Note[] {
|
|
|
756
773
|
// with an always-false condition; building `IN ()` would be a SQL error.
|
|
757
774
|
conditions.push("0 = 1");
|
|
758
775
|
} else {
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
776
|
+
// Bind the id-set as ONE json_each param, not one `?` per id: this IN is
|
|
777
|
+
// embedded in a paginated statement (shared ORDER BY / LIMIT / OFFSET),
|
|
778
|
+
// so it can't be chunked, and `near` neighborhoods routinely exceed the
|
|
779
|
+
// DO 100-bound-param cap. json_each keeps the whole set at a single
|
|
780
|
+
// bound param. See sql-in.ts.
|
|
781
|
+
conditions.push(`n.id IN ${IN_VIA_JSON_EACH}`);
|
|
782
|
+
params.push(jsonEachParam(opts.ids));
|
|
762
783
|
}
|
|
763
784
|
}
|
|
764
785
|
|
|
@@ -1044,10 +1065,6 @@ export function queryNotes(db: Database, opts: QueryOpts): Note[] {
|
|
|
1044
1065
|
return fetchNotesByIdsOrdered(db, idRows.map((r) => r.id));
|
|
1045
1066
|
}
|
|
1046
1067
|
|
|
1047
|
-
/** Chunk size for IN-list queries — comfortably under SQLite's conservative
|
|
1048
|
-
* 999 bound-variable floor (older builds), matching getLinkCounts. */
|
|
1049
|
-
const IN_CHUNK = 900;
|
|
1050
|
-
|
|
1051
1068
|
/**
|
|
1052
1069
|
* Fetch full note rows for `ids`, preserving the input order, with tags
|
|
1053
1070
|
* hydrated via ONE batched query per chunk (not one per note). Ids not
|
|
@@ -1056,8 +1073,9 @@ const IN_CHUNK = 900;
|
|
|
1056
1073
|
function fetchNotesByIdsOrdered(db: Database, ids: string[]): Note[] {
|
|
1057
1074
|
if (ids.length === 0) return [];
|
|
1058
1075
|
const rowsById = new Map<string, NoteRow>();
|
|
1059
|
-
|
|
1060
|
-
|
|
1076
|
+
// Chunk under the DO 100-bound-param cap (see sql-in.ts) — a single export
|
|
1077
|
+
// page (EXPORT_BATCH_SIZE=500) or a large query result exceeds it otherwise.
|
|
1078
|
+
for (const chunk of chunkForInClause(ids)) {
|
|
1061
1079
|
const placeholders = chunk.map(() => "?").join(", ");
|
|
1062
1080
|
const rows = db.prepare(
|
|
1063
1081
|
`SELECT * FROM notes WHERE id IN (${placeholders})`,
|
|
@@ -1084,8 +1102,8 @@ export function getNoteTagsForNotes(db: Database, noteIds: string[]): Map<string
|
|
|
1084
1102
|
if (noteIds.length === 0) return map;
|
|
1085
1103
|
const ids = [...new Set(noteIds)];
|
|
1086
1104
|
for (const id of ids) map.set(id, []);
|
|
1087
|
-
|
|
1088
|
-
|
|
1105
|
+
// Chunk under the DO 100-bound-param cap (see sql-in.ts).
|
|
1106
|
+
for (const chunk of chunkForInClause(ids)) {
|
|
1089
1107
|
const placeholders = chunk.map(() => "?").join(", ");
|
|
1090
1108
|
const rows = db.prepare(
|
|
1091
1109
|
`SELECT note_id, tag_name FROM note_tags WHERE note_id IN (${placeholders}) ORDER BY tag_name`,
|