@hraness/kb 0.17.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.
Files changed (200) hide show
  1. package/DISCLOSURE +43 -0
  2. package/LICENSE +21 -0
  3. package/README.md +545 -0
  4. package/dist/agent-context.js +35 -0
  5. package/dist/agent-guide-audit.js +18 -0
  6. package/dist/attachments.js +19 -0
  7. package/dist/authoring.js +33 -0
  8. package/dist/benchmark.js +19 -0
  9. package/dist/capture.js +49 -0
  10. package/dist/cli.js +3646 -0
  11. package/dist/clip/acquire.js +57 -0
  12. package/dist/clip/args.js +21 -0
  13. package/dist/clip/bounded-byte-buffer.js +9 -0
  14. package/dist/clip/browser-profiles.js +17 -0
  15. package/dist/clip/bundle-reader.js +11 -0
  16. package/dist/clip/cli.js +27 -0
  17. package/dist/clip/cookies.js +21 -0
  18. package/dist/clip/defuddle-worker.js +84 -0
  19. package/dist/clip/doctor.js +32 -0
  20. package/dist/clip/jobs.js +784 -0
  21. package/dist/clip/network-proxy.js +9 -0
  22. package/dist/clip/network.js +28 -0
  23. package/dist/clip/persist.js +30 -0
  24. package/dist/clip/refresh.js +11 -0
  25. package/dist/clip/terminal.js +11 -0
  26. package/dist/evaluation-builder.js +12238 -0
  27. package/dist/evaluation-kb.js +26 -0
  28. package/dist/evaluation.js +35 -0
  29. package/dist/git.js +39 -0
  30. package/dist/graph.js +39 -0
  31. package/dist/index-01jj6rbv.js +1103 -0
  32. package/dist/index-06c9ctr6.js +738 -0
  33. package/dist/index-0kavxzqj.js +2480 -0
  34. package/dist/index-1gwbassd.js +743 -0
  35. package/dist/index-1n418kb9.js +827 -0
  36. package/dist/index-1vrd1rmn.js +56 -0
  37. package/dist/index-1xxnjn0d.js +171 -0
  38. package/dist/index-3rm7cz6h.js +373 -0
  39. package/dist/index-3v2z4f0q.js +328 -0
  40. package/dist/index-48pz4jpc.js +392 -0
  41. package/dist/index-4j3tt0c3.js +234 -0
  42. package/dist/index-5n05se68.js +307 -0
  43. package/dist/index-5vwpzb5a.js +764 -0
  44. package/dist/index-6g2pv9d2.js +370 -0
  45. package/dist/index-7fzc32gf.js +712 -0
  46. package/dist/index-84x0vjjp.js +432 -0
  47. package/dist/index-adx6khj5.js +488 -0
  48. package/dist/index-b88v3vtm.js +805 -0
  49. package/dist/index-cv6fh7z5.js +462 -0
  50. package/dist/index-cxfrakt7.js +1067 -0
  51. package/dist/index-d13v9ckt.js +150 -0
  52. package/dist/index-dycjbhw2.js +1943 -0
  53. package/dist/index-dyqwejk5.js +531 -0
  54. package/dist/index-e5fbsywq.js +658 -0
  55. package/dist/index-f984hw45.js +927 -0
  56. package/dist/index-gh719d91.js +63 -0
  57. package/dist/index-hgve9rh2.js +2046 -0
  58. package/dist/index-hya40gb2.js +474 -0
  59. package/dist/index-j4zgmzjr.js +152 -0
  60. package/dist/index-jsmvyyvf.js +1166 -0
  61. package/dist/index-kh0pwvdd.js +1974 -0
  62. package/dist/index-mqx4nd6v.js +119 -0
  63. package/dist/index-mxxxytys.js +1254 -0
  64. package/dist/index-n5dd7r0v.js +711 -0
  65. package/dist/index-npg9z1a4.js +431 -0
  66. package/dist/index-pj501bh1.js +140 -0
  67. package/dist/index-qry4vhxk.js +1041 -0
  68. package/dist/index-s2gw5aw9.js +199 -0
  69. package/dist/index-vvdj6kca.js +70 -0
  70. package/dist/index-w2zc0vwa.js +571 -0
  71. package/dist/index-x3fthpsc.js +585 -0
  72. package/dist/index-zr53sf63.js +44 -0
  73. package/dist/index-zxdy5pby.js +2728 -0
  74. package/dist/index-zzhgcwyt.js +966 -0
  75. package/dist/index.js +453 -0
  76. package/dist/navigation.js +13 -0
  77. package/dist/pdf.js +85 -0
  78. package/dist/percolate.js +24 -0
  79. package/dist/portfolio.js +77 -0
  80. package/dist/query.js +29 -0
  81. package/dist/repository-memory.js +60 -0
  82. package/dist/sdk.js +36 -0
  83. package/dist/search-rules.js +34 -0
  84. package/dist/search.js +22 -0
  85. package/dist/semantic.js +39 -0
  86. package/dist/source-inbox.js +17 -0
  87. package/dist/untrusted-content.js +32 -0
  88. package/dist/url-intelligence.js +74 -0
  89. package/dist/workflow.js +23 -0
  90. package/dist/workflows/decision-context.js +20 -0
  91. package/dist/workflows/explain-change.js +8 -0
  92. package/dist/workflows/index.js +28 -0
  93. package/dist/workflows/plan-radar.js +10 -0
  94. package/package.json +409 -0
  95. package/skills/kb/AGENTS.md +19 -0
  96. package/skills/kb/SKILL.md +82 -0
  97. package/skills/kb/agents/openai.yaml +4 -0
  98. package/skills/kb/references/pdf-review.md +51 -0
  99. package/skills/kb/references/percolate.md +144 -0
  100. package/skills/kb/references/plan-structure.md +105 -0
  101. package/skills/kb/references/plan.md +115 -0
  102. package/skills/kb/references/query.md +231 -0
  103. package/skills/kb/references/refresh.md +133 -0
  104. package/skills/kb/references/save-pdf.md +125 -0
  105. package/skills/kb/references/save-url.md +178 -0
  106. package/skills/kb/references/url-authentication.md +75 -0
  107. package/skills/kb/references/url-platforms.md +25 -0
  108. package/src/agent-context.ts +1241 -0
  109. package/src/agent-guide-audit.ts +750 -0
  110. package/src/attachments.ts +723 -0
  111. package/src/authoring.ts +1725 -0
  112. package/src/benchmark.ts +362 -0
  113. package/src/capture.ts +68 -0
  114. package/src/cli.ts +3660 -0
  115. package/src/clip/acquire.ts +1273 -0
  116. package/src/clip/archive-today.ts +402 -0
  117. package/src/clip/args.ts +423 -0
  118. package/src/clip/assets.ts +262 -0
  119. package/src/clip/bounded-byte-buffer.ts +75 -0
  120. package/src/clip/browser-profiles.ts +372 -0
  121. package/src/clip/bundle-reader.ts +608 -0
  122. package/src/clip/capture.ts +1054 -0
  123. package/src/clip/cli.ts +181 -0
  124. package/src/clip/cookies.ts +512 -0
  125. package/src/clip/doctor.ts +1110 -0
  126. package/src/clip/extract.ts +868 -0
  127. package/src/clip/jobs.ts +959 -0
  128. package/src/clip/lib.ts +861 -0
  129. package/src/clip/media.ts +1474 -0
  130. package/src/clip/metadata-search-tool/Cargo.lock +2498 -0
  131. package/src/clip/metadata-search-tool/Cargo.toml +18 -0
  132. package/src/clip/metadata-search-tool/runner.ts +685 -0
  133. package/src/clip/metadata-search-tool/src/main.rs +389 -0
  134. package/src/clip/metadata-search.ts +687 -0
  135. package/src/clip/network-proxy.ts +627 -0
  136. package/src/clip/network.ts +950 -0
  137. package/src/clip/package-root.ts +54 -0
  138. package/src/clip/persist.ts +1472 -0
  139. package/src/clip/platforms.ts +1824 -0
  140. package/src/clip/refresh.ts +197 -0
  141. package/src/clip/structured.ts +432 -0
  142. package/src/clip/terminal.ts +197 -0
  143. package/src/clip/url-intelligence.ts +581 -0
  144. package/src/clip/url-metadata-backfill.ts +616 -0
  145. package/src/clip/url-metadata-cli.ts +251 -0
  146. package/src/clip/url-metadata.ts +1397 -0
  147. package/src/evaluation-analysis-v2.ts +1440 -0
  148. package/src/evaluation-builder.ts +2539 -0
  149. package/src/evaluation-corpus-authoring.ts +2052 -0
  150. package/src/evaluation-evidence.ts +1147 -0
  151. package/src/evaluation-execution-v2.ts +426 -0
  152. package/src/evaluation-implementation.ts +131 -0
  153. package/src/evaluation-kb-closure.ts +1430 -0
  154. package/src/evaluation-kb-runner-v2.ts +735 -0
  155. package/src/evaluation-kb-v2.ts +2567 -0
  156. package/src/evaluation-kb.ts +1018 -0
  157. package/src/evaluation-measurement-v2.ts +1462 -0
  158. package/src/evaluation-packing-v2.ts +452 -0
  159. package/src/evaluation-redaction.ts +28 -0
  160. package/src/evaluation-v2.ts +3857 -0
  161. package/src/evaluation.ts +1220 -0
  162. package/src/git.ts +1276 -0
  163. package/src/graph.ts +1487 -0
  164. package/src/index.ts +20 -0
  165. package/src/init.ts +123 -0
  166. package/src/navigation.ts +277 -0
  167. package/src/note-lock.ts +529 -0
  168. package/src/pdf/args.ts +202 -0
  169. package/src/pdf/capture.ts +298 -0
  170. package/src/pdf/cli.ts +159 -0
  171. package/src/pdf/extract.ts +303 -0
  172. package/src/pdf/layout.ts +618 -0
  173. package/src/pdf/markdown.ts +154 -0
  174. package/src/pdf/model.ts +234 -0
  175. package/src/pdf/ocr.ts +190 -0
  176. package/src/pdf/persist.ts +314 -0
  177. package/src/pdf/source.ts +135 -0
  178. package/src/pdf/tools.ts +146 -0
  179. package/src/pdf.ts +92 -0
  180. package/src/percolate.ts +877 -0
  181. package/src/portfolio-audit.ts +574 -0
  182. package/src/portfolio-identity.ts +162 -0
  183. package/src/portfolio-registry.ts +473 -0
  184. package/src/portfolio.ts +765 -0
  185. package/src/query.ts +646 -0
  186. package/src/repository-memory.ts +1150 -0
  187. package/src/sdk.ts +1452 -0
  188. package/src/search-rules.ts +712 -0
  189. package/src/search.ts +710 -0
  190. package/src/semantic-runtime.ts +1139 -0
  191. package/src/semantic.ts +2480 -0
  192. package/src/source-inbox.ts +232 -0
  193. package/src/untrusted-content.ts +401 -0
  194. package/src/url-intelligence.ts +104 -0
  195. package/src/vault.ts +636 -0
  196. package/src/workflow.ts +653 -0
  197. package/src/workflows/decision-context.ts +81 -0
  198. package/src/workflows/explain-change.ts +73 -0
  199. package/src/workflows/index.ts +3 -0
  200. package/src/workflows/plan-radar.ts +101 -0
@@ -0,0 +1,1725 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { constants } from "node:fs";
3
+ import {
4
+ link,
5
+ lstat,
6
+ mkdir,
7
+ open,
8
+ readdir,
9
+ realpath,
10
+ rename,
11
+ rmdir,
12
+ unlink,
13
+ } from "node:fs/promises";
14
+ import {
15
+ basename,
16
+ dirname,
17
+ join,
18
+ posix,
19
+ relative,
20
+ resolve,
21
+ sep,
22
+ } from "node:path";
23
+ import {
24
+ Document,
25
+ isMap,
26
+ isScalar,
27
+ isSeq,
28
+ parseDocument,
29
+ type YAMLMap,
30
+ type YAMLSeq,
31
+ } from "yaml";
32
+
33
+ import {
34
+ acquireNoteLock,
35
+ type NoteLock,
36
+ type NoteLockOptions,
37
+ } from "./note-lock.js";
38
+ import { isCanonicalNoteId } from "./graph.js";
39
+ import {
40
+ parseDocumentId,
41
+ parseQualifiedDocumentUri,
42
+ } from "./portfolio-identity.js";
43
+
44
+ const MAX_NOTE_BYTES = 16 * 1024 * 1024;
45
+ const NOTE_REVISION_PATTERN = /^sha256:[0-9a-f]{64}$/u;
46
+ const PREDICATE_PATTERN = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/u;
47
+ const MAX_PARENT_DIRECTORY_ENTRIES = 100_000;
48
+ const MAX_RECOVERY_LOCATIONS_PER_NOTE = 8;
49
+
50
+ export type NoteRevision = `sha256:${string}`;
51
+
52
+ export interface NoteRelation {
53
+ readonly predicate: string;
54
+ readonly target: string;
55
+ }
56
+
57
+ export interface NoteAuthoringResult {
58
+ readonly changed: boolean;
59
+ /** Exact vault-relative Markdown path. */
60
+ readonly path: string;
61
+ readonly revision: NoteRevision;
62
+ readonly relations: readonly NoteRelation[];
63
+ /** Stable authored identity when the note has one valid document_id. */
64
+ readonly documentId?: string;
65
+ }
66
+
67
+ export interface CreateNoteInput {
68
+ /** Exact extensionless vault-root note ID, for example `notes/local-first`. */
69
+ readonly id: string;
70
+ /** Stable ID independent of note path. Generated for new notes when omitted. */
71
+ readonly documentId?: string;
72
+ readonly title: string;
73
+ readonly type: string;
74
+ readonly tags?: readonly string[];
75
+ /** Markdown after frontmatter. Defaults to one H1; a final newline is added. */
76
+ readonly body?: string;
77
+ }
78
+
79
+ export interface CreateConceptNoteInput {
80
+ readonly id: string;
81
+ readonly documentId?: string;
82
+ readonly title: string;
83
+ readonly tags?: readonly string[];
84
+ readonly body?: string;
85
+ }
86
+
87
+ export interface AuthoringInstallContext {
88
+ readonly operation: "create" | "replace";
89
+ readonly path: string;
90
+ readonly temporaryPath: string;
91
+ /**
92
+ * Private recovery path used by replacements. It is absent for creates and
93
+ * remains on disk only when restoring it without clobbering a raced writer
94
+ * is impossible.
95
+ */
96
+ readonly recoveryPath?: string;
97
+ }
98
+
99
+ export interface AuthoringDependencies {
100
+ /** Stable authored document identity. Distinct from transaction filenames. */
101
+ readonly documentId: () => string;
102
+ /** Private transaction/recovery filename token. */
103
+ readonly token: () => string;
104
+ /**
105
+ * Test and embedding seam immediately before ownership and source revision
106
+ * are rechecked. Callers should normally omit this.
107
+ */
108
+ readonly beforeInstall?: (context: AuthoringInstallContext) => Promise<void>;
109
+ /**
110
+ * Deterministic test seam after the final optimistic read but immediately
111
+ * before the no-clobber create or replacement transaction starts.
112
+ */
113
+ readonly beforeCommit?: (context: AuthoringInstallContext) => Promise<void>;
114
+ /**
115
+ * Deterministic test seam after an expected replacement source has been
116
+ * moved and verified at recoveryPath, before the new content is linked.
117
+ */
118
+ readonly afterSourceQuarantined?: (
119
+ context: Required<AuthoringInstallContext>,
120
+ ) => Promise<void>;
121
+ }
122
+
123
+ export interface AuthoringOptions {
124
+ readonly expectedRevision?: NoteRevision;
125
+ readonly lock?: NoteLockOptions;
126
+ readonly dependencies?: Partial<AuthoringDependencies>;
127
+ }
128
+
129
+ export class InvalidCanonicalNoteIdError extends TypeError {
130
+ readonly noteId: string;
131
+
132
+ constructor(noteId: string) {
133
+ super(`not an exact canonical note ID: ${JSON.stringify(noteId)}`);
134
+ this.name = "InvalidCanonicalNoteIdError";
135
+ this.noteId = noteId;
136
+ }
137
+ }
138
+
139
+ export class NoteRevisionConflictError extends Error {
140
+ readonly path: string;
141
+ readonly expected: NoteRevision | null;
142
+ readonly actual: NoteRevision | null;
143
+ /** Vault-relative path retaining the displaced bytes, when restoration raced. */
144
+ readonly recoveryPath: string | null;
145
+
146
+ constructor(
147
+ path: string,
148
+ expected: NoteRevision | null,
149
+ actual: NoteRevision | null,
150
+ recoveryPath: string | null = null,
151
+ ) {
152
+ super(recoveryPath === null
153
+ ? "the note changed during authoring; retry from its current revision"
154
+ : `the note changed during authoring; displaced bytes remain at ${recoveryPath}`);
155
+ this.name = "NoteRevisionConflictError";
156
+ this.path = path;
157
+ this.expected = expected;
158
+ this.actual = actual;
159
+ this.recoveryPath = recoveryPath;
160
+ }
161
+ }
162
+
163
+ export class NoteAlreadyExistsError extends Error {
164
+ readonly path: string;
165
+
166
+ constructor(path: string, reason: string) {
167
+ super(`the existing note is incompatible with this create request: ${reason}`);
168
+ this.name = "NoteAlreadyExistsError";
169
+ this.path = path;
170
+ }
171
+ }
172
+
173
+ export class NoteRecoveryRequiredError extends Error {
174
+ readonly path: string;
175
+ readonly recoveryPath: string;
176
+
177
+ constructor(path: string, recoveryPath: string, cause: unknown) {
178
+ super(`authoring stopped; displaced bytes remain at ${recoveryPath}`, { cause });
179
+ this.name = "NoteRecoveryRequiredError";
180
+ this.path = path;
181
+ this.recoveryPath = recoveryPath;
182
+ }
183
+ }
184
+
185
+ interface Vault {
186
+ readonly root: string;
187
+ }
188
+
189
+ interface NoteSnapshot {
190
+ readonly path: string;
191
+ readonly relativePath: string;
192
+ readonly content: string;
193
+ readonly revision: NoteRevision;
194
+ readonly device: bigint;
195
+ readonly inode: bigint;
196
+ readonly size: bigint;
197
+ readonly modifiedAtNs: bigint;
198
+ readonly changedAtNs: bigint;
199
+ readonly mode: number;
200
+ }
201
+
202
+ interface FrontmatterParts {
203
+ readonly document: Document;
204
+ readonly hadFrontmatter: boolean;
205
+ readonly openingDelimiter: string;
206
+ readonly closingDelimiter: string;
207
+ readonly newline: "\n" | "\r\n";
208
+ /** Exact bytes after the existing closing delimiter, including its newline. */
209
+ readonly bodySuffix: string;
210
+ }
211
+
212
+ interface RelationNodes {
213
+ readonly root: YAMLMap;
214
+ readonly relations: YAMLMap | null;
215
+ }
216
+
217
+ function isErrno(error: unknown, code: string): boolean {
218
+ return typeof error === "object"
219
+ && error !== null
220
+ && "code" in error
221
+ && error.code === code;
222
+ }
223
+
224
+ function sha256(value: string | Uint8Array): string {
225
+ return createHash("sha256").update(value).digest("hex");
226
+ }
227
+
228
+ function revisionFor(bytes: Uint8Array): NoteRevision {
229
+ return `sha256:${sha256(bytes)}`;
230
+ }
231
+
232
+ function inside(root: string, candidate: string): boolean {
233
+ const fromRoot = relative(root, candidate);
234
+ return fromRoot !== ""
235
+ && fromRoot !== ".."
236
+ && !fromRoot.startsWith(`..${sep}`);
237
+ }
238
+
239
+ /** Validate and return an exact extensionless canonical vault note ID. */
240
+ export function canonicalNoteId(value: string): string {
241
+ if (!isCanonicalNoteId(value)) {
242
+ throw new InvalidCanonicalNoteIdError(value);
243
+ }
244
+ return value;
245
+ }
246
+
247
+ /** Validate a local exact note ID or a stable canonical cross-vault URI. */
248
+ export function canonicalRelationTarget(value: string): string {
249
+ if (value.startsWith("kb://")) return parseQualifiedDocumentUri(value).uri;
250
+ return canonicalNoteId(value);
251
+ }
252
+
253
+ /** Normalize a caller predicate to the strict lower-kebab authored form. */
254
+ export function normalizeRelationPredicate(value: string): string {
255
+ const normalized = value
256
+ .trim()
257
+ .normalize("NFC")
258
+ .toLocaleLowerCase("en-US")
259
+ .replaceAll("_", "-")
260
+ .replace(/\s+/gu, "-")
261
+ .replace(/-{2,}/gu, "-");
262
+ if (!PREDICATE_PATTERN.test(normalized)) {
263
+ throw new TypeError(`not a valid relation predicate: ${JSON.stringify(value)}`);
264
+ }
265
+ return normalized;
266
+ }
267
+
268
+ function exactPredicate(value: string): string {
269
+ const normalized = normalizeRelationPredicate(value);
270
+ if (value !== normalized) {
271
+ throw new Error(`authored relation predicate is not canonical kebab-case: ${value}`);
272
+ }
273
+ return value;
274
+ }
275
+
276
+ function requireRevision(value: string): NoteRevision {
277
+ if (!NOTE_REVISION_PATTERN.test(value)) {
278
+ throw new TypeError("expectedRevision is not a KB note revision");
279
+ }
280
+ return value as NoteRevision;
281
+ }
282
+
283
+ async function resolveVault(rootInput: string): Promise<Vault> {
284
+ const root = await realpath(resolve(rootInput));
285
+ const metadata = await lstat(root);
286
+ if (!metadata.isDirectory() || metadata.isSymbolicLink()) {
287
+ throw new Error("the vault root must be a real directory");
288
+ }
289
+ if (dirname(root) === root) {
290
+ throw new Error("refusing to author notes in a filesystem root");
291
+ }
292
+ return { root };
293
+ }
294
+
295
+ function pathFor(vault: Vault, id: string): {
296
+ readonly path: string;
297
+ readonly relativePath: string;
298
+ } {
299
+ const canonicalId = canonicalNoteId(id);
300
+ const relativePath = `${canonicalId}.md`;
301
+ const path = resolve(vault.root, ...relativePath.split("/"));
302
+ if (!inside(vault.root, path)) {
303
+ throw new InvalidCanonicalNoteIdError(id);
304
+ }
305
+ return { path, relativePath };
306
+ }
307
+
308
+ async function assertExactDirectoryEntry(
309
+ directory: string,
310
+ name: string,
311
+ ): Promise<void> {
312
+ const entries = await readdir(directory);
313
+ if (!entries.includes(name)) {
314
+ const error = new Error(`vault path component is not exact: ${name}`) as Error & {
315
+ code?: string;
316
+ };
317
+ error.code = "ENOENT";
318
+ throw error;
319
+ }
320
+ }
321
+
322
+ async function assertSafeParent(vault: Vault, path: string): Promise<void> {
323
+ if (!inside(vault.root, path)) {
324
+ throw new Error("the note path must remain inside the vault");
325
+ }
326
+ const parent = dirname(path);
327
+ const segments = relative(vault.root, parent).split(sep).filter(Boolean);
328
+ let current = vault.root;
329
+ for (const segment of segments) {
330
+ await assertExactDirectoryEntry(current, segment);
331
+ current = join(current, segment);
332
+ const metadata = await lstat(current);
333
+ if (metadata.isSymbolicLink()) {
334
+ throw new Error("the note path must not traverse a symbolic link");
335
+ }
336
+ if (!metadata.isDirectory()) {
337
+ throw new Error("every note parent must be a directory");
338
+ }
339
+ }
340
+ const canonicalParent = await realpath(parent);
341
+ if (canonicalParent !== parent || !inside(vault.root, join(canonicalParent, basename(path)))) {
342
+ throw new Error("the note parent resolves outside the vault");
343
+ }
344
+ }
345
+
346
+ async function readSnapshotAtPath(
347
+ vault: Vault,
348
+ path: string,
349
+ relativePath: string,
350
+ ): Promise<NoteSnapshot> {
351
+ await assertSafeParent(vault, path);
352
+ await assertExactDirectoryEntry(dirname(path), basename(path));
353
+
354
+ const beforeOpen = await lstat(path, { bigint: true });
355
+ if (!beforeOpen.isFile() || beforeOpen.isSymbolicLink()) {
356
+ throw new Error("the note target must be a regular file");
357
+ }
358
+ if (beforeOpen.nlink !== 1n) {
359
+ throw new Error("the note target must not be hard-linked");
360
+ }
361
+ if (beforeOpen.size > BigInt(MAX_NOTE_BYTES)) {
362
+ throw new Error("the note is too large for bounded authoring");
363
+ }
364
+
365
+ const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
366
+ try {
367
+ const opened = await handle.stat({ bigint: true });
368
+ if (
369
+ !opened.isFile()
370
+ || opened.nlink !== 1n
371
+ || opened.dev !== beforeOpen.dev
372
+ || opened.ino !== beforeOpen.ino
373
+ || opened.size !== beforeOpen.size
374
+ || opened.size > BigInt(MAX_NOTE_BYTES)
375
+ ) {
376
+ throw new Error("the note target changed while it was opened");
377
+ }
378
+ const bytes = new Uint8Array(Number(opened.size));
379
+ let offset = 0;
380
+ while (offset < bytes.byteLength) {
381
+ const result = await handle.read(bytes, offset, bytes.byteLength - offset, offset);
382
+ if (result.bytesRead === 0) {
383
+ throw new Error("the note target changed while it was read");
384
+ }
385
+ offset += result.bytesRead;
386
+ }
387
+ const overflow = new Uint8Array(1);
388
+ if ((await handle.read(overflow, 0, 1, Number(opened.size))).bytesRead !== 0) {
389
+ throw new Error("the note target grew while it was read");
390
+ }
391
+
392
+ const finished = await handle.stat({ bigint: true });
393
+ const finalPath = await lstat(path, { bigint: true });
394
+ if (
395
+ !finalPath.isFile()
396
+ || finalPath.isSymbolicLink()
397
+ || finalPath.nlink !== 1n
398
+ || finalPath.dev !== opened.dev
399
+ || finalPath.ino !== opened.ino
400
+ || finalPath.size !== opened.size
401
+ || finished.size !== opened.size
402
+ || finished.mtimeNs !== opened.mtimeNs
403
+ || finished.ctimeNs !== opened.ctimeNs
404
+ ) {
405
+ throw new Error("the note target changed while it was read");
406
+ }
407
+ const canonicalPath = await realpath(path);
408
+ if (canonicalPath !== path || !inside(vault.root, canonicalPath)) {
409
+ throw new Error("the note target resolves outside the vault");
410
+ }
411
+
412
+ let content: string;
413
+ try {
414
+ content = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
415
+ } catch (error) {
416
+ throw new Error("the note target is not valid UTF-8", { cause: error });
417
+ }
418
+ return {
419
+ path,
420
+ relativePath,
421
+ content,
422
+ revision: revisionFor(bytes),
423
+ device: opened.dev,
424
+ inode: opened.ino,
425
+ size: opened.size,
426
+ modifiedAtNs: opened.mtimeNs,
427
+ changedAtNs: opened.ctimeNs,
428
+ mode: Number(opened.mode & 0o777n),
429
+ };
430
+ } finally {
431
+ await handle.close();
432
+ }
433
+ }
434
+
435
+ async function readSnapshot(
436
+ vault: Vault,
437
+ id: string,
438
+ ): Promise<NoteSnapshot> {
439
+ const { path, relativePath } = pathFor(vault, id);
440
+ return readSnapshotAtPath(vault, path, relativePath);
441
+ }
442
+
443
+ async function readOptionalSnapshot(
444
+ vault: Vault,
445
+ id: string,
446
+ ): Promise<NoteSnapshot | null> {
447
+ try {
448
+ return await readSnapshot(vault, id);
449
+ } catch (error) {
450
+ if (isErrno(error, "ENOENT")) return null;
451
+ throw error;
452
+ }
453
+ }
454
+
455
+ function sameSnapshot(left: NoteSnapshot, right: NoteSnapshot): boolean {
456
+ return left.device === right.device
457
+ && left.inode === right.inode
458
+ && left.size === right.size
459
+ && left.modifiedAtNs === right.modifiedAtNs
460
+ && left.changedAtNs === right.changedAtNs
461
+ && left.mode === right.mode
462
+ && left.revision === right.revision;
463
+ }
464
+
465
+ function frontmatter(content: string, relativePath: string): FrontmatterParts {
466
+ const firstLineEnd = content.indexOf("\n");
467
+ const openingEnd = firstLineEnd === -1 ? content.length : firstLineEnd;
468
+ const openingContentEnd = content[openingEnd - 1] === "\r"
469
+ ? openingEnd - 1
470
+ : openingEnd;
471
+ const opening = content.slice(0, openingContentEnd);
472
+ if (opening.trim() !== "---") {
473
+ return {
474
+ document: parseFrontmatterDocument("", relativePath),
475
+ hadFrontmatter: false,
476
+ openingDelimiter: "---",
477
+ closingDelimiter: "---",
478
+ newline: content.includes("\r\n") ? "\r\n" : "\n",
479
+ bodySuffix: content,
480
+ };
481
+ }
482
+ if (firstLineEnd === -1) {
483
+ throw new Error(`invalid YAML frontmatter in ${relativePath}: missing closing delimiter`);
484
+ }
485
+ const newline = content[firstLineEnd - 1] === "\r" ? "\r\n" : "\n";
486
+ let cursor = firstLineEnd + 1;
487
+ for (;;) {
488
+ const nextNewline = content.indexOf("\n", cursor);
489
+ const lineEnd = nextNewline === -1 ? content.length : nextNewline;
490
+ const lineContentEnd = content[lineEnd - 1] === "\r" ? lineEnd - 1 : lineEnd;
491
+ const line = content.slice(cursor, lineContentEnd);
492
+ if (line.trim() === "---") {
493
+ const yamlSource = content.slice(firstLineEnd + 1, cursor);
494
+ return {
495
+ document: parseFrontmatterDocument(yamlSource, relativePath),
496
+ hadFrontmatter: true,
497
+ openingDelimiter: content.slice(0, openingContentEnd),
498
+ closingDelimiter: content.slice(cursor, lineContentEnd),
499
+ newline,
500
+ bodySuffix: content.slice(lineContentEnd),
501
+ };
502
+ }
503
+ if (nextNewline === -1) break;
504
+ cursor = nextNewline + 1;
505
+ }
506
+ throw new Error(`invalid YAML frontmatter in ${relativePath}: missing closing delimiter`);
507
+ }
508
+
509
+ function parseFrontmatterDocument(source: string, relativePath: string): Document {
510
+ const document = parseDocument(source, {
511
+ keepSourceTokens: true,
512
+ schema: "core",
513
+ uniqueKeys: true,
514
+ });
515
+ if (document.errors.length > 0) {
516
+ throw new Error(`invalid YAML frontmatter in ${relativePath}`);
517
+ }
518
+ if (document.contents !== null && !isMap(document.contents)) {
519
+ throw new Error(`invalid YAML frontmatter in ${relativePath}: expected a mapping`);
520
+ }
521
+ if (isMap(document.contents)) {
522
+ const seen = new Set<string>();
523
+ for (const pair of document.contents.items) {
524
+ if (!isScalar(pair.key) || typeof pair.key.value !== "string") {
525
+ throw new Error(`invalid YAML frontmatter in ${relativePath}: keys must be strings`);
526
+ }
527
+ const folded = pair.key.value.toLocaleLowerCase("en-US");
528
+ if (seen.has(folded)) {
529
+ throw new Error(
530
+ `invalid YAML frontmatter in ${relativePath}: keys must not differ only by case`,
531
+ );
532
+ }
533
+ seen.add(folded);
534
+ }
535
+ }
536
+ return document;
537
+ }
538
+
539
+ function relationNodes(
540
+ parts: FrontmatterParts,
541
+ relativePath: string,
542
+ create: boolean,
543
+ ): RelationNodes {
544
+ const { document } = parts;
545
+ if (document.contents === null) {
546
+ if (!create) {
547
+ const detached = document.createNode({});
548
+ if (!isMap(detached)) throw new Error("YAML did not create a mapping");
549
+ return { root: detached, relations: null };
550
+ }
551
+ document.contents = document.createNode({});
552
+ }
553
+ if (!isMap(document.contents)) {
554
+ throw new Error(`invalid YAML frontmatter in ${relativePath}: expected a mapping`);
555
+ }
556
+ const root = document.contents;
557
+ const relationPair = root.items.find((pair) =>
558
+ isScalar(pair.key)
559
+ && typeof pair.key.value === "string"
560
+ && pair.key.value.toLocaleLowerCase("en-US") === "relations");
561
+ const existing = relationPair?.value;
562
+ if (existing === undefined) {
563
+ if (!create) return { root, relations: null };
564
+ const created = document.createNode({});
565
+ if (!isMap(created)) throw new Error("YAML did not create a relation mapping");
566
+ root.set("relations", created);
567
+ return { root, relations: created };
568
+ }
569
+ if (!isMap(existing)) {
570
+ throw new Error(`invalid relations in ${relativePath}: expected a mapping`);
571
+ }
572
+ return { root, relations: existing };
573
+ }
574
+
575
+ function scalarString(value: unknown): string | null {
576
+ return isScalar(value) && typeof value.value === "string" ? value.value : null;
577
+ }
578
+
579
+ function relationsFromParts(
580
+ parts: FrontmatterParts,
581
+ relativePath: string,
582
+ ): readonly NoteRelation[] {
583
+ const { relations } = relationNodes(parts, relativePath, false);
584
+ if (relations === null) return [];
585
+ const output: NoteRelation[] = [];
586
+ const seen = new Set<string>();
587
+ for (const pair of relations.items) {
588
+ const predicateValue = scalarString(pair.key);
589
+ if (predicateValue === null) {
590
+ throw new Error(`invalid relations in ${relativePath}: predicates must be strings`);
591
+ }
592
+ const predicate = exactPredicate(predicateValue);
593
+ const scalarTarget = scalarString(pair.value);
594
+ if (scalarTarget !== null) {
595
+ const target = canonicalRelationTarget(scalarTarget);
596
+ const key = `${predicate}\0${target}`;
597
+ if (!seen.has(key)) {
598
+ seen.add(key);
599
+ output.push({ predicate, target });
600
+ }
601
+ continue;
602
+ }
603
+ if (!isSeq(pair.value)) {
604
+ throw new Error(
605
+ `invalid relations in ${relativePath}: ${predicate} targets must be a string or array`,
606
+ );
607
+ }
608
+ for (const item of pair.value.items) {
609
+ const targetValue = scalarString(item);
610
+ if (targetValue === null) {
611
+ throw new Error(
612
+ `invalid relations in ${relativePath}: ${predicate} targets must be strings`,
613
+ );
614
+ }
615
+ const target = canonicalRelationTarget(targetValue);
616
+ const key = `${predicate}\0${target}`;
617
+ if (seen.has(key)) continue;
618
+ seen.add(key);
619
+ output.push({ predicate, target });
620
+ }
621
+ }
622
+ return output.toSorted((left, right) =>
623
+ left.predicate.localeCompare(right.predicate)
624
+ || left.target.localeCompare(right.target));
625
+ }
626
+
627
+ type RelationValue =
628
+ | { readonly kind: "scalar"; readonly target: string }
629
+ | { readonly kind: "sequence"; readonly sequence: YAMLSeq }
630
+ | null;
631
+
632
+ function relationValue(
633
+ relations: YAMLMap,
634
+ predicate: string,
635
+ relativePath: string,
636
+ ): RelationValue {
637
+ const value = relations.get(predicate, true);
638
+ if (value === undefined) return null;
639
+ const scalarTarget = scalarString(value);
640
+ if (scalarTarget !== null) {
641
+ return { kind: "scalar", target: canonicalRelationTarget(scalarTarget) };
642
+ }
643
+ if (!isSeq(value)) {
644
+ throw new Error(
645
+ `invalid relations in ${relativePath}: ${predicate} targets must be a string or array`,
646
+ );
647
+ }
648
+ for (const item of value.items) {
649
+ if (scalarString(item) === null) {
650
+ throw new Error(
651
+ `invalid relations in ${relativePath}: ${predicate} targets must be strings`,
652
+ );
653
+ }
654
+ }
655
+ return { kind: "sequence", sequence: value };
656
+ }
657
+
658
+ function renderFrontmatter(parts: FrontmatterParts): string {
659
+ let yaml = parts.document.toString({ lineWidth: 0 });
660
+ if (parts.newline === "\r\n") yaml = yaml.replaceAll("\n", "\r\n");
661
+ if (!yaml.endsWith(parts.newline)) yaml += parts.newline;
662
+ if (parts.hadFrontmatter) {
663
+ return parts.openingDelimiter
664
+ + parts.newline
665
+ + yaml
666
+ + parts.closingDelimiter
667
+ + parts.bodySuffix;
668
+ }
669
+ return parts.openingDelimiter
670
+ + parts.newline
671
+ + yaml
672
+ + parts.closingDelimiter
673
+ + parts.newline
674
+ + parts.bodySuffix;
675
+ }
676
+
677
+ function compareScalarNodes(left: unknown, right: unknown): number {
678
+ return (scalarString(left) ?? "").localeCompare(scalarString(right) ?? "");
679
+ }
680
+
681
+ function addRelationToParts(
682
+ parts: FrontmatterParts,
683
+ relativePath: string,
684
+ predicate: string,
685
+ target: string,
686
+ ): boolean {
687
+ const { relations } = relationNodes(parts, relativePath, true);
688
+ if (relations === null) throw new Error("YAML did not create relations");
689
+ const existing = relationValue(relations, predicate, relativePath);
690
+ if (existing === null) {
691
+ const created = parts.document.createNode([target], { flow: true });
692
+ if (!isSeq(created)) throw new Error("YAML did not create a relation sequence");
693
+ relations.set(predicate, created);
694
+ return true;
695
+ }
696
+ if (existing.kind === "scalar") {
697
+ if (existing.target === target) return false;
698
+ const created = parts.document.createNode(
699
+ [existing.target, target].toSorted((left, right) => left.localeCompare(right)),
700
+ { flow: true },
701
+ );
702
+ if (!isSeq(created)) throw new Error("YAML did not create a relation sequence");
703
+ relations.set(predicate, created);
704
+ return true;
705
+ }
706
+ const sequence = existing.sequence;
707
+ if (sequence.items.some((item) => scalarString(item) === target)) return false;
708
+ sequence.add(parts.document.createNode(target));
709
+ sequence.items.sort(compareScalarNodes);
710
+ return true;
711
+ }
712
+
713
+ function removeRelationFromParts(
714
+ parts: FrontmatterParts,
715
+ relativePath: string,
716
+ predicate: string,
717
+ target: string,
718
+ sourceId: string,
719
+ ): boolean {
720
+ const { root, relations } = relationNodes(parts, relativePath, false);
721
+ if (relations === null) return false;
722
+ const value = relations.get(predicate, true);
723
+ if (value === undefined) return false;
724
+
725
+ const repairableTarget = (raw: string): string | null => {
726
+ if (raw.startsWith("kb://")) {
727
+ try {
728
+ return canonicalRelationTarget(raw);
729
+ } catch {
730
+ return null;
731
+ }
732
+ }
733
+ let candidate = raw;
734
+ if (candidate.toLocaleLowerCase("en-US").endsWith(".md")) {
735
+ candidate = candidate.slice(0, -3);
736
+ }
737
+ if (candidate.startsWith(".")) {
738
+ candidate = posix.normalize(posix.join(posix.dirname(sourceId), candidate));
739
+ }
740
+ return isCanonicalNoteId(candidate) ? candidate : null;
741
+ };
742
+ const matches = (node: unknown): boolean => {
743
+ const raw = scalarString(node);
744
+ return raw !== null && repairableTarget(raw) === target;
745
+ };
746
+
747
+ if (!isSeq(value)) {
748
+ if (!matches(value)) return false;
749
+ relations.delete(predicate);
750
+ if (relations.items.length === 0) root.delete("relations");
751
+ return true;
752
+ }
753
+ const sequence = value;
754
+ const retained = sequence.items.filter((item) => !matches(item));
755
+ if (retained.length === sequence.items.length) return false;
756
+ if (retained.length === 0) {
757
+ relations.delete(predicate);
758
+ if (relations.items.length === 0) root.delete("relations");
759
+ } else {
760
+ sequence.items = retained;
761
+ }
762
+ return true;
763
+ }
764
+
765
+ function dependenciesFor(
766
+ overrides: Partial<AuthoringDependencies> | undefined,
767
+ ): AuthoringDependencies {
768
+ return {
769
+ documentId: overrides?.documentId ?? randomUUID,
770
+ token: overrides?.token ?? randomUUID,
771
+ ...(overrides?.beforeInstall === undefined
772
+ ? {}
773
+ : { beforeInstall: overrides.beforeInstall }),
774
+ ...(overrides?.beforeCommit === undefined
775
+ ? {}
776
+ : { beforeCommit: overrides.beforeCommit }),
777
+ ...(overrides?.afterSourceQuarantined === undefined
778
+ ? {}
779
+ : { afterSourceQuarantined: overrides.afterSourceQuarantined }),
780
+ };
781
+ }
782
+
783
+ async function cleanupTemporary(
784
+ temporaryPath: string,
785
+ identity: { readonly device: bigint; readonly inode: bigint } | null,
786
+ ): Promise<void> {
787
+ if (identity === null) return;
788
+ try {
789
+ const current = await lstat(temporaryPath, { bigint: true });
790
+ if (current.dev === identity.device && current.ino === identity.inode) {
791
+ await unlink(temporaryPath);
792
+ }
793
+ } catch (error) {
794
+ if (!isErrno(error, "ENOENT")) throw error;
795
+ }
796
+ }
797
+
798
+ async function fsyncDirectory(path: string): Promise<void> {
799
+ const handle = await open(path, constants.O_RDONLY);
800
+ try {
801
+ await handle.sync();
802
+ } finally {
803
+ await handle.close();
804
+ }
805
+ }
806
+
807
+ interface RecoveryLocation {
808
+ readonly directory: string;
809
+ readonly path: string;
810
+ readonly relativePath: string;
811
+ readonly device: bigint;
812
+ readonly inode: bigint;
813
+ }
814
+
815
+ interface DirectoryIdentity {
816
+ readonly device: bigint;
817
+ readonly inode: bigint;
818
+ }
819
+
820
+ function recoveryRelativePath(vault: Vault, path: string): string {
821
+ return relative(vault.root, path).split(sep).join("/");
822
+ }
823
+
824
+ async function discoveredRecoveryLocations(
825
+ vault: Vault,
826
+ notePath: string,
827
+ ): Promise<{
828
+ readonly recoverable: readonly RecoveryLocation[];
829
+ readonly empty: readonly RecoveryLocation[];
830
+ }> {
831
+ const directory = dirname(notePath);
832
+ const entries = await readdir(directory, { withFileTypes: true });
833
+ if (entries.length > MAX_PARENT_DIRECTORY_ENTRIES) {
834
+ throw new Error("the note parent has too many entries for bounded recovery");
835
+ }
836
+ const prefix = `.${basename(notePath)}.`;
837
+ const suffix = ".recovery";
838
+ const matching = entries
839
+ .filter(({ name }) => name.startsWith(prefix) && name.endsWith(suffix))
840
+ .toSorted((left, right) => left.name.localeCompare(right.name));
841
+ if (matching.length > MAX_RECOVERY_LOCATIONS_PER_NOTE) {
842
+ const firstPath = join(directory, matching[0]?.name ?? "");
843
+ throw new NoteRecoveryRequiredError(
844
+ recoveryRelativePath(vault, notePath),
845
+ recoveryRelativePath(vault, firstPath),
846
+ new Error("too many interrupted authoring transactions require manual recovery"),
847
+ );
848
+ }
849
+ const recoverable: RecoveryLocation[] = [];
850
+ const empty: RecoveryLocation[] = [];
851
+ for (const entry of matching) {
852
+ const nonce = entry.name.slice(prefix.length, -suffix.length);
853
+ const recoveryDirectory = join(directory, entry.name);
854
+ const recoveryDirectoryRelative = recoveryRelativePath(vault, recoveryDirectory);
855
+ if (
856
+ !/^\d+\.[0-9a-f]{32}$/u.test(nonce)
857
+ || !entry.isDirectory()
858
+ || entry.isSymbolicLink()
859
+ ) {
860
+ throw new NoteRecoveryRequiredError(
861
+ recoveryRelativePath(vault, notePath),
862
+ recoveryDirectoryRelative,
863
+ new Error("an unrecognized authoring recovery artifact is present"),
864
+ );
865
+ }
866
+ const metadata = await lstat(recoveryDirectory, { bigint: true });
867
+ if (
868
+ !metadata.isDirectory()
869
+ || metadata.isSymbolicLink()
870
+ || await realpath(recoveryDirectory) !== recoveryDirectory
871
+ ) {
872
+ throw new NoteRecoveryRequiredError(
873
+ recoveryRelativePath(vault, notePath),
874
+ recoveryDirectoryRelative,
875
+ new Error("an authoring recovery directory changed identity"),
876
+ );
877
+ }
878
+ const children = await readdir(recoveryDirectory);
879
+ if (children.length === 0) {
880
+ empty.push({
881
+ directory: recoveryDirectory,
882
+ path: join(recoveryDirectory, basename(notePath)),
883
+ relativePath: recoveryRelativePath(
884
+ vault,
885
+ join(recoveryDirectory, basename(notePath)),
886
+ ),
887
+ device: metadata.dev,
888
+ inode: metadata.ino,
889
+ });
890
+ continue;
891
+ }
892
+ if (children.length !== 1 || children[0] !== basename(notePath)) {
893
+ throw new NoteRecoveryRequiredError(
894
+ recoveryRelativePath(vault, notePath),
895
+ recoveryDirectoryRelative,
896
+ new Error("an authoring recovery directory has unexpected contents"),
897
+ );
898
+ }
899
+ const recoveryPath = join(recoveryDirectory, basename(notePath));
900
+ try {
901
+ await readSnapshotAtPath(
902
+ vault,
903
+ recoveryPath,
904
+ recoveryRelativePath(vault, recoveryPath),
905
+ );
906
+ } catch (error) {
907
+ throw new NoteRecoveryRequiredError(
908
+ recoveryRelativePath(vault, notePath),
909
+ recoveryRelativePath(vault, recoveryPath),
910
+ error,
911
+ );
912
+ }
913
+ recoverable.push({
914
+ directory: recoveryDirectory,
915
+ path: recoveryPath,
916
+ relativePath: recoveryRelativePath(vault, recoveryPath),
917
+ device: metadata.dev,
918
+ inode: metadata.ino,
919
+ });
920
+ }
921
+ return { recoverable, empty };
922
+ }
923
+
924
+ async function directoryIdentity(
925
+ vault: Vault,
926
+ notePath: string,
927
+ ): Promise<DirectoryIdentity> {
928
+ await assertSafeParent(vault, notePath);
929
+ const metadata = await lstat(dirname(notePath), { bigint: true });
930
+ if (!metadata.isDirectory() || metadata.isSymbolicLink()) {
931
+ throw new Error("the note parent must remain a real directory");
932
+ }
933
+ return { device: metadata.dev, inode: metadata.ino };
934
+ }
935
+
936
+ async function assertSameDirectory(
937
+ vault: Vault,
938
+ notePath: string,
939
+ expected: DirectoryIdentity,
940
+ ): Promise<void> {
941
+ const current = await directoryIdentity(vault, notePath);
942
+ if (current.device !== expected.device || current.inode !== expected.inode) {
943
+ throw new Error("the note parent changed during authoring");
944
+ }
945
+ }
946
+
947
+ async function createRecoveryLocation(
948
+ vault: Vault,
949
+ path: string,
950
+ dependencies: AuthoringDependencies,
951
+ ): Promise<RecoveryLocation> {
952
+ const directory = dirname(path);
953
+ const recoveryDirectory = join(
954
+ directory,
955
+ `.${basename(path)}.${process.pid}.${sha256(dependencies.token()).slice(0, 32)}.recovery`,
956
+ );
957
+ await mkdir(recoveryDirectory, { mode: 0o700 });
958
+ const metadata = await lstat(recoveryDirectory, { bigint: true });
959
+ if (!metadata.isDirectory() || metadata.isSymbolicLink()) {
960
+ throw new Error("the recovery location is not a private directory");
961
+ }
962
+ await fsyncDirectory(directory);
963
+ const recoveryPath = join(recoveryDirectory, basename(path));
964
+ return {
965
+ directory: recoveryDirectory,
966
+ path: recoveryPath,
967
+ relativePath: relative(vault.root, recoveryPath).split(sep).join("/"),
968
+ device: metadata.dev,
969
+ inode: metadata.ino,
970
+ };
971
+ }
972
+
973
+ async function assertRecoveryLocation(recovery: RecoveryLocation): Promise<void> {
974
+ const metadata = await lstat(recovery.directory, { bigint: true });
975
+ if (
976
+ !metadata.isDirectory()
977
+ || metadata.isSymbolicLink()
978
+ || metadata.dev !== recovery.device
979
+ || metadata.ino !== recovery.inode
980
+ || await realpath(recovery.directory) !== recovery.directory
981
+ ) {
982
+ throw new Error("the recovery location changed during authoring");
983
+ }
984
+ }
985
+
986
+ async function removeRecoveryDirectory(
987
+ recovery: RecoveryLocation,
988
+ parentDirectory: string,
989
+ ): Promise<void> {
990
+ await rmdir(recovery.directory);
991
+ await fsyncDirectory(parentDirectory);
992
+ }
993
+
994
+ function sameQuarantinedSnapshot(
995
+ quarantined: NoteSnapshot,
996
+ expected: NoteSnapshot,
997
+ ): boolean {
998
+ // A rename may update ctime on some supported filesystems. Identity, bytes,
999
+ // mode, size, and mtime still prove that the entry moved was the snapshot
1000
+ // accepted by the optimistic read.
1001
+ return quarantined.device === expected.device
1002
+ && quarantined.inode === expected.inode
1003
+ && quarantined.size === expected.size
1004
+ && quarantined.modifiedAtNs === expected.modifiedAtNs
1005
+ && quarantined.mode === expected.mode
1006
+ && quarantined.revision === expected.revision;
1007
+ }
1008
+
1009
+ async function restoreQuarantinedSource(
1010
+ vault: Vault,
1011
+ recovery: RecoveryLocation,
1012
+ path: string,
1013
+ expectedDirectory: DirectoryIdentity,
1014
+ ): Promise<boolean> {
1015
+ await assertSameDirectory(vault, path, expectedDirectory);
1016
+ await assertRecoveryLocation(recovery);
1017
+ try {
1018
+ // link(2) is the portable no-clobber primitive: unlike rename, it returns
1019
+ // EEXIST rather than replacing a writer that recreated the source path.
1020
+ await link(recovery.path, path);
1021
+ } catch (error) {
1022
+ if (isErrno(error, "EEXIST")) return false;
1023
+ throw error;
1024
+ }
1025
+ await unlink(recovery.path);
1026
+ await fsyncDirectory(recovery.directory);
1027
+ await removeRecoveryDirectory(recovery, dirname(path));
1028
+ return true;
1029
+ }
1030
+
1031
+ async function assertNoInterruptedRecovery(
1032
+ vault: Vault,
1033
+ id: string,
1034
+ ): Promise<void> {
1035
+ const { path, relativePath } = pathFor(vault, id);
1036
+ await assertSafeParent(vault, path);
1037
+ const artifacts = await discoveredRecoveryLocations(vault, path);
1038
+ const first = artifacts.recoverable[0] ?? artifacts.empty[0];
1039
+ if (first !== undefined) {
1040
+ throw new NoteRecoveryRequiredError(
1041
+ relativePath,
1042
+ first.relativePath,
1043
+ new Error("an interrupted authoring transaction requires a writer to recover it"),
1044
+ );
1045
+ }
1046
+ }
1047
+
1048
+ async function recoverInterruptedAuthoring(
1049
+ vault: Vault,
1050
+ id: string,
1051
+ lock: NoteLock,
1052
+ ): Promise<void> {
1053
+ const { path, relativePath } = pathFor(vault, id);
1054
+ await lock.assertOwned();
1055
+ await assertSafeParent(vault, path);
1056
+ const artifacts = await discoveredRecoveryLocations(vault, path);
1057
+ for (const emptyRecovery of artifacts.empty) {
1058
+ await assertRecoveryLocation(emptyRecovery);
1059
+ await removeRecoveryDirectory(emptyRecovery, dirname(path));
1060
+ }
1061
+ const first = artifacts.recoverable[0];
1062
+ if (first === undefined) return;
1063
+ if (artifacts.recoverable.length !== 1) {
1064
+ throw new NoteRecoveryRequiredError(
1065
+ relativePath,
1066
+ first.relativePath,
1067
+ new Error("multiple interrupted authoring transactions require manual recovery"),
1068
+ );
1069
+ }
1070
+
1071
+ const current = await readOptionalSnapshot(vault, id);
1072
+ if (current !== null) {
1073
+ throw new NoteRecoveryRequiredError(
1074
+ relativePath,
1075
+ first.relativePath,
1076
+ new Error("both the canonical note and displaced bytes exist"),
1077
+ );
1078
+ }
1079
+ const expectedDirectory = await directoryIdentity(vault, path);
1080
+ if (!await restoreQuarantinedSource(vault, first, path, expectedDirectory)) {
1081
+ throw new NoteRecoveryRequiredError(
1082
+ relativePath,
1083
+ first.relativePath,
1084
+ new Error("the canonical note was recreated during interrupted recovery"),
1085
+ );
1086
+ }
1087
+ await lock.assertOwned();
1088
+ }
1089
+
1090
+ async function installTemporaryWithoutClobber(
1091
+ temporaryPath: string,
1092
+ path: string,
1093
+ ): Promise<boolean> {
1094
+ try {
1095
+ // The temporary file has already been fsync'd. Linking gives the final
1096
+ // name to that exact inode only if the name is still absent.
1097
+ await link(temporaryPath, path);
1098
+ } catch (error) {
1099
+ if (isErrno(error, "EEXIST")) return false;
1100
+ throw error;
1101
+ }
1102
+ await unlink(temporaryPath);
1103
+ return true;
1104
+ }
1105
+
1106
+ async function currentRevisionOrNull(
1107
+ vault: Vault,
1108
+ id: string,
1109
+ ): Promise<NoteRevision | null> {
1110
+ try {
1111
+ return (await readOptionalSnapshot(vault, id))?.revision ?? null;
1112
+ } catch {
1113
+ // A raced directory, link, or non-UTF-8 file is still a conflict. Do not
1114
+ // inspect or mutate it further merely to improve an error field.
1115
+ return null;
1116
+ }
1117
+ }
1118
+
1119
+ function withRecoveryPath(
1120
+ error: unknown,
1121
+ relativePath: string,
1122
+ recoveryPath: string,
1123
+ ): Error {
1124
+ if (error instanceof NoteRevisionConflictError) {
1125
+ return new NoteRevisionConflictError(
1126
+ error.path,
1127
+ error.expected,
1128
+ error.actual,
1129
+ recoveryPath,
1130
+ );
1131
+ }
1132
+ return new NoteRecoveryRequiredError(relativePath, recoveryPath, error);
1133
+ }
1134
+
1135
+ async function atomicInstall(
1136
+ vault: Vault,
1137
+ id: string,
1138
+ content: string,
1139
+ expected: NoteSnapshot | null,
1140
+ lock: NoteLock,
1141
+ dependencies: AuthoringDependencies,
1142
+ ): Promise<NoteRevision> {
1143
+ const { path, relativePath } = pathFor(vault, id);
1144
+ await assertSafeParent(vault, path);
1145
+ const bytes = new TextEncoder().encode(content);
1146
+ if (bytes.byteLength > MAX_NOTE_BYTES) {
1147
+ throw new Error("the rendered note is too large for bounded authoring");
1148
+ }
1149
+ const directory = dirname(path);
1150
+ const expectedDirectory = await directoryIdentity(vault, path);
1151
+ const temporaryPath = join(
1152
+ directory,
1153
+ `.${basename(path)}.${process.pid}.${sha256(dependencies.token()).slice(0, 32)}.tmp`,
1154
+ );
1155
+ const mode = expected?.mode ?? 0o644;
1156
+ const handle = await open(
1157
+ temporaryPath,
1158
+ constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW,
1159
+ mode,
1160
+ );
1161
+ let closed = false;
1162
+ let identity: { readonly device: bigint; readonly inode: bigint } | null = null;
1163
+ let recovery: RecoveryLocation | null = null;
1164
+ let sourceQuarantined = false;
1165
+ let destinationInstalled = false;
1166
+ try {
1167
+ const created = await handle.stat({ bigint: true });
1168
+ if (!created.isFile() || created.nlink !== 1n) {
1169
+ throw new Error("the temporary note target is not a private regular file");
1170
+ }
1171
+ identity = { device: created.dev, inode: created.ino };
1172
+ await handle.chmod(mode);
1173
+ await handle.writeFile(bytes);
1174
+ await handle.sync();
1175
+ const complete = await handle.stat({ bigint: true });
1176
+ if (
1177
+ !complete.isFile()
1178
+ || complete.nlink !== 1n
1179
+ || complete.dev !== identity.device
1180
+ || complete.ino !== identity.inode
1181
+ ) {
1182
+ throw new Error("the temporary note target changed before installation");
1183
+ }
1184
+ await handle.close();
1185
+ closed = true;
1186
+
1187
+ await dependencies.beforeInstall?.({
1188
+ operation: expected === null ? "create" : "replace",
1189
+ path,
1190
+ temporaryPath,
1191
+ });
1192
+ await lock.assertOwned();
1193
+ const current = await readOptionalSnapshot(vault, id);
1194
+ if (
1195
+ (expected === null && current !== null)
1196
+ || (expected !== null && (current === null || !sameSnapshot(current, expected)))
1197
+ ) {
1198
+ throw new NoteRevisionConflictError(
1199
+ relativePath,
1200
+ expected?.revision ?? null,
1201
+ current?.revision ?? null,
1202
+ );
1203
+ }
1204
+ await assertSafeParent(vault, path);
1205
+ const temporary = await lstat(temporaryPath, { bigint: true });
1206
+ if (
1207
+ !temporary.isFile()
1208
+ || temporary.isSymbolicLink()
1209
+ || temporary.nlink !== 1n
1210
+ || temporary.dev !== identity.device
1211
+ || temporary.ino !== identity.inode
1212
+ ) {
1213
+ throw new Error("the temporary note target changed before installation");
1214
+ }
1215
+
1216
+ if (expected === null) {
1217
+ const context: AuthoringInstallContext = {
1218
+ operation: "create",
1219
+ path,
1220
+ temporaryPath,
1221
+ };
1222
+ await dependencies.beforeCommit?.(context);
1223
+ await lock.assertOwned();
1224
+ await assertSameDirectory(vault, path, expectedDirectory);
1225
+ if (!await installTemporaryWithoutClobber(temporaryPath, path)) {
1226
+ throw new NoteRevisionConflictError(
1227
+ relativePath,
1228
+ null,
1229
+ await currentRevisionOrNull(vault, id),
1230
+ );
1231
+ }
1232
+ destinationInstalled = true;
1233
+ await fsyncDirectory(directory);
1234
+ return revisionFor(bytes);
1235
+ }
1236
+
1237
+ recovery = await createRecoveryLocation(vault, path, dependencies);
1238
+ const context: Required<AuthoringInstallContext> = {
1239
+ operation: "replace",
1240
+ path,
1241
+ temporaryPath,
1242
+ recoveryPath: recovery.path,
1243
+ };
1244
+ await dependencies.beforeCommit?.(context);
1245
+ await lock.assertOwned();
1246
+ await assertSameDirectory(vault, path, expectedDirectory);
1247
+ await assertRecoveryLocation(recovery);
1248
+ try {
1249
+ // The recovery destination lives in a newly-created private directory,
1250
+ // so this rename cannot overwrite a pre-existing recovery artifact.
1251
+ await rename(path, recovery.path);
1252
+ } catch (error) {
1253
+ if (isErrno(error, "ENOENT")) {
1254
+ throw new NoteRevisionConflictError(relativePath, expected.revision, null);
1255
+ }
1256
+ throw error;
1257
+ }
1258
+ sourceQuarantined = true;
1259
+ await Promise.all([
1260
+ fsyncDirectory(directory),
1261
+ fsyncDirectory(recovery.directory),
1262
+ ]);
1263
+
1264
+ const quarantined = await readSnapshotAtPath(
1265
+ vault,
1266
+ recovery.path,
1267
+ recovery.relativePath,
1268
+ );
1269
+ if (!sameQuarantinedSnapshot(quarantined, expected)) {
1270
+ throw new NoteRevisionConflictError(
1271
+ relativePath,
1272
+ expected.revision,
1273
+ quarantined.revision,
1274
+ );
1275
+ }
1276
+
1277
+ await dependencies.afterSourceQuarantined?.(context);
1278
+ await lock.assertOwned();
1279
+ await assertSameDirectory(vault, path, expectedDirectory);
1280
+ await assertRecoveryLocation(recovery);
1281
+ const stillQuarantined = await readSnapshotAtPath(
1282
+ vault,
1283
+ recovery.path,
1284
+ recovery.relativePath,
1285
+ );
1286
+ if (!sameQuarantinedSnapshot(stillQuarantined, expected)) {
1287
+ throw new NoteRevisionConflictError(
1288
+ relativePath,
1289
+ expected.revision,
1290
+ stillQuarantined.revision,
1291
+ );
1292
+ }
1293
+ if (!await installTemporaryWithoutClobber(temporaryPath, path)) {
1294
+ throw new NoteRevisionConflictError(
1295
+ relativePath,
1296
+ expected.revision,
1297
+ await currentRevisionOrNull(vault, id),
1298
+ );
1299
+ }
1300
+ destinationInstalled = true;
1301
+ await fsyncDirectory(directory);
1302
+
1303
+ await unlink(recovery.path);
1304
+ sourceQuarantined = false;
1305
+ await fsyncDirectory(recovery.directory);
1306
+ await removeRecoveryDirectory(recovery, directory);
1307
+ recovery = null;
1308
+ await fsyncDirectory(directory);
1309
+ return revisionFor(bytes);
1310
+ } catch (error) {
1311
+ if (recovery !== null && sourceQuarantined && !destinationInstalled) {
1312
+ let restored = false;
1313
+ try {
1314
+ restored = await restoreQuarantinedSource(
1315
+ vault,
1316
+ recovery,
1317
+ path,
1318
+ expectedDirectory,
1319
+ );
1320
+ } catch (restoreError) {
1321
+ throw withRecoveryPath(
1322
+ new AggregateError(
1323
+ [error, restoreError],
1324
+ "authoring failed and the prior source could not be restored",
1325
+ ),
1326
+ relativePath,
1327
+ recovery.relativePath,
1328
+ );
1329
+ }
1330
+ if (restored) {
1331
+ sourceQuarantined = false;
1332
+ recovery = null;
1333
+ }
1334
+ }
1335
+ if (recovery !== null && sourceQuarantined) {
1336
+ throw withRecoveryPath(error, relativePath, recovery.relativePath);
1337
+ }
1338
+ if (recovery !== null) {
1339
+ try {
1340
+ await removeRecoveryDirectory(recovery, directory);
1341
+ recovery = null;
1342
+ } catch (cleanupError) {
1343
+ if (!isErrno(cleanupError, "ENOENT")) {
1344
+ throw new AggregateError(
1345
+ [error, cleanupError],
1346
+ "authoring failed and its empty recovery directory could not be removed",
1347
+ );
1348
+ }
1349
+ }
1350
+ }
1351
+ throw error;
1352
+ } finally {
1353
+ if (!closed) await handle.close().catch(() => undefined);
1354
+ // Once linked, the final destination is independent of this name. This
1355
+ // identity-checked cleanup is therefore safe on success and on failure.
1356
+ await cleanupTemporary(temporaryPath, identity);
1357
+ }
1358
+ }
1359
+
1360
+ function checkedExpectedRevision(options: AuthoringOptions): NoteRevision | undefined {
1361
+ return options.expectedRevision === undefined
1362
+ ? undefined
1363
+ : requireRevision(options.expectedRevision);
1364
+ }
1365
+
1366
+ function assertExpected(
1367
+ snapshot: NoteSnapshot,
1368
+ expected: NoteRevision | undefined,
1369
+ ): void {
1370
+ if (expected !== undefined && snapshot.revision !== expected) {
1371
+ throw new NoteRevisionConflictError(
1372
+ snapshot.relativePath,
1373
+ expected,
1374
+ snapshot.revision,
1375
+ );
1376
+ }
1377
+ }
1378
+
1379
+ function noteResult(
1380
+ snapshot: Pick<NoteSnapshot, "relativePath" | "revision">,
1381
+ relations: readonly NoteRelation[],
1382
+ changed: boolean,
1383
+ documentId?: string,
1384
+ ): NoteAuthoringResult {
1385
+ return {
1386
+ changed,
1387
+ path: snapshot.relativePath,
1388
+ revision: snapshot.revision,
1389
+ relations,
1390
+ ...(documentId === undefined ? {} : { documentId }),
1391
+ };
1392
+ }
1393
+
1394
+ function validateTitle(title: string): string {
1395
+ if (
1396
+ title === ""
1397
+ || title !== title.trim()
1398
+ || title.includes("\n")
1399
+ || title.includes("\r")
1400
+ || title.length > 512
1401
+ ) {
1402
+ throw new TypeError("a note title must be a non-empty single line");
1403
+ }
1404
+ return title;
1405
+ }
1406
+
1407
+ function validateType(type: string): string {
1408
+ const canonical = normalizeRelationPredicate(type);
1409
+ if (canonical !== type) throw new TypeError("a note type must be canonical kebab-case");
1410
+ return type;
1411
+ }
1412
+
1413
+ function validateTags(tags: readonly string[] | undefined): readonly string[] {
1414
+ const result: string[] = [];
1415
+ const seen = new Set<string>();
1416
+ for (const candidate of tags ?? []) {
1417
+ const tag = candidate.trim().replace(/^#+/u, "").normalize("NFC");
1418
+ if (
1419
+ tag === ""
1420
+ || tag.includes("\n")
1421
+ || tag.includes("\r")
1422
+ || tag.length > 128
1423
+ ) {
1424
+ throw new TypeError(`not a valid note tag: ${JSON.stringify(candidate)}`);
1425
+ }
1426
+ const folded = tag.toLocaleLowerCase("en-US");
1427
+ if (seen.has(folded)) continue;
1428
+ seen.add(folded);
1429
+ result.push(tag);
1430
+ }
1431
+ return result;
1432
+ }
1433
+
1434
+ function normalizedRequestedBody(body: string): string {
1435
+ return body.endsWith("\n") ? body : `${body}\n`;
1436
+ }
1437
+
1438
+ function renderCreatedNote(input: CreateNoteInput, documentId: string): string {
1439
+ const title = validateTitle(input.title);
1440
+ const type = validateType(input.type);
1441
+ const tags = validateTags(input.tags);
1442
+ const metadata: Record<string, unknown> = { document_id: documentId, type, title };
1443
+ if (tags.length > 0) metadata["tags"] = tags;
1444
+ const document = new Document(metadata, { schema: "core" });
1445
+ const body = normalizedRequestedBody(input.body ?? `# ${title}\n`);
1446
+ return `---\n${document.toString({ lineWidth: 0 })}---\n\n${body}`;
1447
+ }
1448
+
1449
+ function topLevelScalar(
1450
+ parts: FrontmatterParts,
1451
+ key: string,
1452
+ ): string | null {
1453
+ if (!isMap(parts.document.contents)) return null;
1454
+ return scalarString(parts.document.contents.get(key, true));
1455
+ }
1456
+
1457
+ function topLevelStrings(
1458
+ parts: FrontmatterParts,
1459
+ key: string,
1460
+ ): readonly string[] {
1461
+ if (!isMap(parts.document.contents)) return [];
1462
+ const value = parts.document.contents.get(key, true);
1463
+ if (value === undefined) return [];
1464
+ if (isScalar(value) && typeof value.value === "string") return [value.value];
1465
+ if (!isSeq(value)) return [];
1466
+ return value.items.flatMap((item) => {
1467
+ const candidate = scalarString(item);
1468
+ return candidate === null ? [] : [candidate];
1469
+ });
1470
+ }
1471
+
1472
+ type ExistingDocumentId =
1473
+ | { readonly kind: "invalid" }
1474
+ | { readonly kind: "missing" }
1475
+ | { readonly kind: "valid"; readonly documentId: string };
1476
+
1477
+ function existingDocumentId(parts: FrontmatterParts): ExistingDocumentId {
1478
+ if (!isMap(parts.document.contents)) return { kind: "missing" };
1479
+ const values = parts.document.contents.items.flatMap((pair) => {
1480
+ const key = scalarString(pair.key);
1481
+ if (key?.normalize("NFC").toLocaleLowerCase("en-US") !== "document_id") return [];
1482
+ const value = scalarString(pair.value);
1483
+ return value === null ? [null] : [value];
1484
+ });
1485
+ if (values.length === 0) return { kind: "missing" };
1486
+ if (values.length !== 1 || values[0] === null) return { kind: "invalid" };
1487
+ try {
1488
+ return { kind: "valid", documentId: parseDocumentId(values[0]) };
1489
+ } catch {
1490
+ return { kind: "invalid" };
1491
+ }
1492
+ }
1493
+
1494
+ type CompatibleCreate = {
1495
+ readonly documentId?: string;
1496
+ readonly relations: readonly NoteRelation[];
1497
+ };
1498
+
1499
+ function assertCompatibleCreate(
1500
+ snapshot: NoteSnapshot,
1501
+ input: CreateNoteInput,
1502
+ requestedDocumentId: string | undefined,
1503
+ ): CompatibleCreate {
1504
+ const parts = frontmatter(snapshot.content, snapshot.relativePath);
1505
+ const requestedType = validateType(input.type);
1506
+ const requestedTitle = validateTitle(input.title);
1507
+ if (topLevelScalar(parts, "type") !== requestedType) {
1508
+ throw new NoteAlreadyExistsError(snapshot.relativePath, "type differs");
1509
+ }
1510
+ if (topLevelScalar(parts, "title") !== requestedTitle) {
1511
+ throw new NoteAlreadyExistsError(snapshot.relativePath, "title differs");
1512
+ }
1513
+ const presentTags = new Set(
1514
+ topLevelStrings(parts, "tags").map((tag) => tag.toLocaleLowerCase("en-US")),
1515
+ );
1516
+ const missingTag = validateTags(input.tags)
1517
+ .find((tag) => !presentTags.has(tag.toLocaleLowerCase("en-US")));
1518
+ if (missingTag !== undefined) {
1519
+ throw new NoteAlreadyExistsError(snapshot.relativePath, `tag is missing: ${missingTag}`);
1520
+ }
1521
+ if (
1522
+ input.body !== undefined
1523
+ && parts.bodySuffix !== `${parts.newline}${parts.newline}${normalizedRequestedBody(input.body)}`
1524
+ ) {
1525
+ throw new NoteAlreadyExistsError(snapshot.relativePath, "body differs");
1526
+ }
1527
+ const existingId = existingDocumentId(parts);
1528
+ if (
1529
+ requestedDocumentId !== undefined
1530
+ && (existingId.kind !== "valid" || existingId.documentId !== requestedDocumentId)
1531
+ ) {
1532
+ throw new NoteAlreadyExistsError(
1533
+ snapshot.relativePath,
1534
+ existingId.kind === "missing" ? "document_id is missing" : "document_id differs",
1535
+ );
1536
+ }
1537
+ return {
1538
+ relations: relationsFromParts(parts, snapshot.relativePath),
1539
+ ...(existingId.kind === "valid" ? { documentId: existingId.documentId } : {}),
1540
+ };
1541
+ }
1542
+
1543
+ /**
1544
+ * Read the content revision used by optimistic authoring operations.
1545
+ *
1546
+ * Revisions intentionally describe UTF-8 bytes, while installation also
1547
+ * checks inode and timestamps to detect same-content replacement races.
1548
+ */
1549
+ export async function noteRevision(
1550
+ root: string,
1551
+ id: string,
1552
+ ): Promise<NoteRevision> {
1553
+ const vault = await resolveVault(root);
1554
+ const canonicalId = canonicalNoteId(id);
1555
+ await assertNoInterruptedRecovery(vault, canonicalId);
1556
+ return (await readSnapshot(vault, canonicalId)).revision;
1557
+ }
1558
+
1559
+ /** List exact outbound relation declarations without taking an authoring lock. */
1560
+ export async function listNoteRelations(
1561
+ root: string,
1562
+ sourceId: string,
1563
+ ): Promise<readonly NoteRelation[]> {
1564
+ const vault = await resolveVault(root);
1565
+ const canonicalId = canonicalNoteId(sourceId);
1566
+ await assertNoInterruptedRecovery(vault, canonicalId);
1567
+ const source = await readSnapshot(vault, canonicalId);
1568
+ return relationsFromParts(
1569
+ frontmatter(source.content, source.relativePath),
1570
+ source.relativePath,
1571
+ );
1572
+ }
1573
+
1574
+ /**
1575
+ * Create one ordinary Markdown note. Existing compatible notes are an
1576
+ * idempotent success and are never rewritten.
1577
+ *
1578
+ * Parent directories must already exist as real in-vault directories. This
1579
+ * keeps the operation's durable write set to exactly one note.
1580
+ */
1581
+ export async function createNote(
1582
+ root: string,
1583
+ input: CreateNoteInput,
1584
+ options: AuthoringOptions = {},
1585
+ ): Promise<NoteAuthoringResult> {
1586
+ const vault = await resolveVault(root);
1587
+ const id = canonicalNoteId(input.id);
1588
+ const requestedDocumentId = input.documentId === undefined
1589
+ ? undefined
1590
+ : parseDocumentId(input.documentId);
1591
+ const expected = checkedExpectedRevision(options);
1592
+ const dependencies = dependenciesFor(options.dependencies);
1593
+ const lock = await acquireNoteLock(vault.root, id, options.lock);
1594
+ try {
1595
+ await recoverInterruptedAuthoring(vault, id, lock);
1596
+ const existing = await readOptionalSnapshot(vault, id);
1597
+ if (existing !== null) {
1598
+ assertExpected(existing, expected);
1599
+ const compatible = assertCompatibleCreate(existing, input, requestedDocumentId);
1600
+ return noteResult(existing, compatible.relations, false, compatible.documentId);
1601
+ }
1602
+ if (expected !== undefined) {
1603
+ throw new NoteRevisionConflictError(`${id}.md`, expected, null);
1604
+ }
1605
+ const documentId = requestedDocumentId ?? parseDocumentId(dependencies.documentId());
1606
+ const content = renderCreatedNote(input, documentId);
1607
+ const revision = await atomicInstall(
1608
+ vault,
1609
+ id,
1610
+ content,
1611
+ null,
1612
+ lock,
1613
+ dependencies,
1614
+ );
1615
+ return {
1616
+ changed: true,
1617
+ path: `${id}.md`,
1618
+ revision,
1619
+ relations: [],
1620
+ documentId,
1621
+ };
1622
+ } finally {
1623
+ await lock.release();
1624
+ }
1625
+ }
1626
+
1627
+ /** Create an ordinary `type: concept` Markdown note. */
1628
+ export async function createConceptNote(
1629
+ root: string,
1630
+ input: CreateConceptNoteInput,
1631
+ options: AuthoringOptions = {},
1632
+ ): Promise<NoteAuthoringResult> {
1633
+ return createNote(root, { ...input, type: "concept" }, options);
1634
+ }
1635
+
1636
+ async function editNoteRelation(
1637
+ operation: "add" | "remove",
1638
+ root: string,
1639
+ sourceIdInput: string,
1640
+ predicateInput: string,
1641
+ targetIdInput: string,
1642
+ options: AuthoringOptions,
1643
+ ): Promise<NoteAuthoringResult> {
1644
+ const vault = await resolveVault(root);
1645
+ const sourceId = canonicalNoteId(sourceIdInput);
1646
+ const targetId = canonicalRelationTarget(targetIdInput);
1647
+ const predicate = normalizeRelationPredicate(predicateInput);
1648
+ const expected = checkedExpectedRevision(options);
1649
+ const dependencies = dependenciesFor(options.dependencies);
1650
+ const lock = await acquireNoteLock(vault.root, sourceId, options.lock);
1651
+ try {
1652
+ await recoverInterruptedAuthoring(vault, sourceId, lock);
1653
+ const source = await readSnapshot(vault, sourceId);
1654
+ assertExpected(source, expected);
1655
+ if (operation === "add" && !targetId.startsWith("kb://") && targetId !== sourceId) {
1656
+ // Adds require a live exact target. Removes intentionally do not so a
1657
+ // dangling authored assertion can still be repaired after a rename.
1658
+ await readSnapshot(vault, targetId);
1659
+ }
1660
+ const parts = frontmatter(source.content, source.relativePath);
1661
+ // Adds must never preserve malformed assertions. Removes are also the
1662
+ // repair path for deterministic legacy spellings such as ./target.md.
1663
+ if (operation === "add") relationsFromParts(parts, source.relativePath);
1664
+ const changed = operation === "add"
1665
+ ? addRelationToParts(parts, source.relativePath, predicate, targetId)
1666
+ : removeRelationFromParts(
1667
+ parts,
1668
+ source.relativePath,
1669
+ predicate,
1670
+ targetId,
1671
+ sourceId,
1672
+ );
1673
+ if (!changed) {
1674
+ return noteResult(
1675
+ source,
1676
+ relationsFromParts(parts, source.relativePath),
1677
+ false,
1678
+ );
1679
+ }
1680
+
1681
+ const content = renderFrontmatter(parts);
1682
+ const relations = relationsFromParts(
1683
+ frontmatter(content, source.relativePath),
1684
+ source.relativePath,
1685
+ );
1686
+ const revision = await atomicInstall(
1687
+ vault,
1688
+ sourceId,
1689
+ content,
1690
+ source,
1691
+ lock,
1692
+ dependencies,
1693
+ );
1694
+ return {
1695
+ changed: true,
1696
+ path: source.relativePath,
1697
+ revision,
1698
+ relations,
1699
+ };
1700
+ } finally {
1701
+ await lock.release();
1702
+ }
1703
+ }
1704
+
1705
+ /** Add one exact outbound typed relation, idempotently. */
1706
+ export async function addNoteRelation(
1707
+ root: string,
1708
+ sourceId: string,
1709
+ predicate: string,
1710
+ targetId: string,
1711
+ options: AuthoringOptions = {},
1712
+ ): Promise<NoteAuthoringResult> {
1713
+ return editNoteRelation("add", root, sourceId, predicate, targetId, options);
1714
+ }
1715
+
1716
+ /** Remove one exact outbound typed relation, idempotently. */
1717
+ export async function removeNoteRelation(
1718
+ root: string,
1719
+ sourceId: string,
1720
+ predicate: string,
1721
+ targetId: string,
1722
+ options: AuthoringOptions = {},
1723
+ ): Promise<NoteAuthoringResult> {
1724
+ return editNoteRelation("remove", root, sourceId, predicate, targetId, options);
1725
+ }