@openparachute/vault 0.6.4 → 0.6.5-rc.10
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/__fixtures__/golden-vault.ts +125 -0
- package/core/src/__fixtures__/portable-export-golden.json +10 -0
- package/core/src/do-param-cap.test.ts +161 -0
- package/core/src/links.ts +8 -13
- package/core/src/mcp.ts +306 -314
- package/core/src/notes.ts +54 -62
- package/core/src/onboarding.ts +14 -296
- package/core/src/portable-md-batching.test.ts +67 -0
- package/core/src/portable-md-golden.test.ts +55 -0
- package/core/src/portable-md.ts +579 -435
- package/core/src/schema.ts +21 -43
- package/core/src/seed-packs.test.ts +191 -0
- package/core/src/seed-packs.ts +559 -0
- package/core/src/sql-in.test.ts +58 -0
- package/core/src/sql-in.ts +76 -0
- package/core/src/store.ts +14 -9
- package/core/src/transcription/provider.ts +141 -0
- package/core/src/txn.test.ts +229 -0
- package/core/src/txn.ts +105 -0
- package/core/src/types.ts +9 -0
- 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/cli.ts +778 -7
- package/src/onboarding-seed.test.ts +126 -40
- package/src/onboarding-seed.ts +41 -46
- package/src/routes.ts +25 -7
- package/src/server.ts +108 -31
- package/src/transcription/build.test.ts +224 -0
- package/src/transcription/build.ts +252 -0
- package/src/transcription/capability.test.ts +118 -0
- package/src/transcription/capability.ts +96 -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 +259 -0
- package/src/transcription/select.ts +334 -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 +38 -10
- package/src/vault.test.ts +48 -0
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared deterministic vault builder + export-tree serializer for the
|
|
3
|
+
* portable-md "old bytes == new bytes" golden fixture (Phase-1 streaming
|
|
4
|
+
* export refactor).
|
|
5
|
+
*
|
|
6
|
+
* Both the one-shot capture script (scripts/capture-portable-golden.ts,
|
|
7
|
+
* run against the PRE-refactor code) and the regression test
|
|
8
|
+
* (portable-md-golden.test.ts, run against the POST-refactor code) build
|
|
9
|
+
* the SAME vault here and serialize the export tree the SAME way — so a
|
|
10
|
+
* byte diff between the committed golden JSON and a fresh export is a real
|
|
11
|
+
* drift, not a fixture-vs-test mismatch.
|
|
12
|
+
*
|
|
13
|
+
* Determinism requirements met here:
|
|
14
|
+
* - explicit note ids + pinned created_at/updated_at (no wall-clock)
|
|
15
|
+
* - export called with `exportedAt` + `caseSensitiveOverride: true`
|
|
16
|
+
* (so the bytes don't depend on the runner's filesystem)
|
|
17
|
+
* - content that exercises every format field: pathed / unpathed /
|
|
18
|
+
* empty-content notes, a non-md (csv) note that forces a notes-meta
|
|
19
|
+
* sidecar, tags with schema + relationships, typed links, multi-line
|
|
20
|
+
* + control-character metadata, and created_at != updated_at.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { readdirSync, readFileSync, statSync } from "fs";
|
|
24
|
+
import { join } from "path";
|
|
25
|
+
import type { Store } from "../types.js";
|
|
26
|
+
|
|
27
|
+
/** Fixed export timestamp — pins vault.yaml's `exported_at`. */
|
|
28
|
+
export const GOLDEN_EXPORTED_AT = "2026-07-02T00:00:00.000Z";
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Populate `store` with a deterministic, format-exhaustive vault. Callers
|
|
32
|
+
* export it with `{ exportedAt: GOLDEN_EXPORTED_AT, caseSensitiveOverride: true }`.
|
|
33
|
+
*/
|
|
34
|
+
export async function buildGoldenVault(store: Store): Promise<void> {
|
|
35
|
+
// Tag schema + opaque relationship vocabulary (vault#428 shape).
|
|
36
|
+
await store.upsertTagSchema("project", {
|
|
37
|
+
description: "A long-running effort",
|
|
38
|
+
fields: { status: { type: "string", enum: ["active", "done"], indexed: true } },
|
|
39
|
+
});
|
|
40
|
+
await store.upsertTagRecord("project", {
|
|
41
|
+
relationships: {
|
|
42
|
+
"works-on": { from: "person", to: "project" },
|
|
43
|
+
"based-at": { from: "project", to: "place", note: "freeform" },
|
|
44
|
+
},
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
const t0 = "2026-01-01T00:00:00.000Z";
|
|
48
|
+
const t1 = "2026-01-01T00:01:00.000Z";
|
|
49
|
+
|
|
50
|
+
await store.createNote("alpha body", {
|
|
51
|
+
id: "01HX001",
|
|
52
|
+
path: "Inbox/alpha",
|
|
53
|
+
tags: ["project", "z-other"],
|
|
54
|
+
metadata: {
|
|
55
|
+
priority: "high",
|
|
56
|
+
notes: "line1\nline2\nline3",
|
|
57
|
+
// control character exercises the \xNN escape path (vault#317 F1)
|
|
58
|
+
ctrl: "a\tb",
|
|
59
|
+
status: "active",
|
|
60
|
+
},
|
|
61
|
+
created_at: t0,
|
|
62
|
+
});
|
|
63
|
+
await store.createNote("beta body", {
|
|
64
|
+
id: "01HX002",
|
|
65
|
+
path: "Inbox/beta",
|
|
66
|
+
tags: ["project"],
|
|
67
|
+
metadata: { status: "done" },
|
|
68
|
+
created_at: t0,
|
|
69
|
+
});
|
|
70
|
+
await store.createNote("unpathed jot", { id: "01HX003", created_at: t0 });
|
|
71
|
+
// Empty-content skeleton note (vault#323).
|
|
72
|
+
await store.createNote("", {
|
|
73
|
+
id: "01HX004",
|
|
74
|
+
path: "Inbox/skeleton",
|
|
75
|
+
tags: ["project"],
|
|
76
|
+
created_at: t0,
|
|
77
|
+
});
|
|
78
|
+
// Non-md note → forces a .parachute/notes-meta/<id>.yaml sidecar.
|
|
79
|
+
await store.createNote("col1,col2\n1,2\n", {
|
|
80
|
+
id: "01HX005",
|
|
81
|
+
path: "Data/table",
|
|
82
|
+
extension: "csv",
|
|
83
|
+
tags: ["z-other"],
|
|
84
|
+
metadata: { rows: 2 },
|
|
85
|
+
created_at: t0,
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
await store.createLink("01HX001", "01HX002", "derived-from", { source: "git://example" });
|
|
89
|
+
|
|
90
|
+
// Pin every timestamp so the export bytes are wall-clock-independent.
|
|
91
|
+
// n1 gets a divergent updated_at to exercise restoreNoteTimestamps.
|
|
92
|
+
await store.restoreNoteTimestamps("01HX001", t0, t1);
|
|
93
|
+
for (const id of ["01HX002", "01HX003", "01HX004", "01HX005"]) {
|
|
94
|
+
await store.restoreNoteTimestamps(id, t0, t0);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Recursively serialize an export directory into a sorted map of
|
|
100
|
+
* `<relative-posix-path>` → file content. Directories are recorded as
|
|
101
|
+
* entries with a trailing `/` and an empty value so an intentionally
|
|
102
|
+
* empty dir (e.g. `.parachute/schemas/` on a schema-less vault) still
|
|
103
|
+
* shows up in the byte comparison.
|
|
104
|
+
*/
|
|
105
|
+
export function serializeExportTree(dir: string): Record<string, string> {
|
|
106
|
+
const out: Record<string, string> = {};
|
|
107
|
+
const walk = (abs: string, rel: string): void => {
|
|
108
|
+
const entries = readdirSync(abs).sort();
|
|
109
|
+
if (entries.length === 0 && rel !== "") {
|
|
110
|
+
out[rel + "/"] = "";
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
for (const entry of entries) {
|
|
114
|
+
const childAbs = join(abs, entry);
|
|
115
|
+
const childRel = rel === "" ? entry : `${rel}/${entry}`;
|
|
116
|
+
if (statSync(childAbs).isDirectory()) {
|
|
117
|
+
walk(childAbs, childRel);
|
|
118
|
+
} else {
|
|
119
|
+
out[childRel] = readFileSync(childAbs, "utf-8");
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
walk(dir, "");
|
|
124
|
+
return out;
|
|
125
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
{
|
|
2
|
+
".parachute/notes-meta/01HX005.yaml": "id: 01HX005\npath: Data/table\nextension: csv\ntags:\n - z-other\nmetadata:\n rows: 2\ncreated_at: 2026-01-01T00:00:00.000Z\nupdated_at: 2026-01-01T00:00:00.000Z\n",
|
|
3
|
+
".parachute/schemas/project.yaml": "description: A long-running effort\nfields:\n status:\n enum:\n - active\n - done\n indexed: true\n type: string\nname: project\nrelationships:\n based-at:\n from: project\n note: freeform\n to: place\n works-on:\n from: person\n to: project\n",
|
|
4
|
+
".parachute/vault.yaml": "description: portable-md byte-stability fixture\nexport_format_version: 1\nexported_at: 2026-07-02T00:00:00.000Z\nname: golden\n",
|
|
5
|
+
"Data/table.csv": "col1,col2\n1,2\n",
|
|
6
|
+
"Inbox/alpha.md": "---\nid: 01HX001\npath: Inbox/alpha\ntags:\n - project\n - z-other\nmetadata:\n ctrl: \"a\\tb\"\n notes: \"line1\\nline2\\nline3\"\n priority: high\n status: active\nlinks:\n - metadata:\n source: git://example\n relationship: derived-from\n target: 01HX002\ncreated_at: 2026-01-01T00:00:00.000Z\nupdated_at: 2026-01-01T00:01:00.000Z\n---\nalpha body\n",
|
|
7
|
+
"Inbox/beta.md": "---\nid: 01HX002\npath: Inbox/beta\ntags:\n - project\nmetadata:\n status: done\ncreated_at: 2026-01-01T00:00:00.000Z\nupdated_at: 2026-01-01T00:00:00.000Z\n---\nbeta body\n",
|
|
8
|
+
"Inbox/skeleton.md": "---\nid: 01HX004\npath: Inbox/skeleton\ntags:\n - project\ncreated_at: 2026-01-01T00:00:00.000Z\nupdated_at: 2026-01-01T00:00:00.000Z\n---\n",
|
|
9
|
+
"_unpathed/01HX003.md": "---\nid: 01HX003\ncreated_at: 2026-01-01T00:00:00.000Z\nupdated_at: 2026-01-01T00:00:00.000Z\n---\nunpathed jot\n"
|
|
10
|
+
}
|
|
@@ -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) {
|