@lapis-notes/api 0.1.3 → 0.2.0

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 (199) hide show
  1. package/CHANGELOG.md +304 -13
  2. package/README.md +19 -0
  3. package/dist/__tests__/fixtures/indexed-mutations.json +6862 -0
  4. package/dist/__tests__/fixtures/memory-storage.json +309 -0
  5. package/dist/__tests__/fixtures/native-facade-contract.json +3224 -0
  6. package/dist/__tests__/fixtures/projection-query.json +195 -0
  7. package/dist/__tests__/fixtures/projection-values.json +60 -0
  8. package/dist/__tests__/fixtures/search-preparation.json +1513 -0
  9. package/dist/__tests__/fixtures/search-retrieval.json +15409 -0
  10. package/dist/__tests__/fixtures/search-semantic-model.json +3691 -0
  11. package/dist/__tests__/fixtures/search-semantic-retrieval.json +6603 -0
  12. package/dist/__tests__/fixtures/search-storage.json +409 -0
  13. package/dist/__tests__/fixtures/task-projection.json +428 -0
  14. package/dist/__tests__/indexed-mutation-support.js +262 -0
  15. package/dist/__tests__/projection-query-support.js +40 -0
  16. package/dist/__tests__/search-retrieval-support.js +47 -0
  17. package/dist/app-database-host.d.ts +1 -0
  18. package/dist/app-shell-plugin-persistence.d.ts +3 -2
  19. package/dist/app-shell-plugin-persistence.js +37 -3
  20. package/dist/cache.svelte.d.ts +8 -1
  21. package/dist/cache.svelte.js +103 -5
  22. package/dist/command.svelte.js +29 -2
  23. package/dist/components/editor/editor.css +4 -0
  24. package/dist/components/editor/editor.svelte +24 -11
  25. package/dist/components/editor/embedded-editor-surface.js +3 -1
  26. package/dist/components/editor/inline-title-alignment.d.ts +3 -0
  27. package/dist/components/editor/inline-title-alignment.js +57 -0
  28. package/dist/configuration.svelte.d.ts +270 -206
  29. package/dist/configuration.svelte.js +161 -86
  30. package/dist/context.svelte.d.ts +23 -3
  31. package/dist/context.svelte.js +68 -30
  32. package/dist/diagnostics.d.ts +8 -0
  33. package/dist/diagnostics.js +6 -0
  34. package/dist/editor-save-context.d.ts +35 -0
  35. package/dist/editor-save-context.js +132 -0
  36. package/dist/editor-view-registry.d.ts +10 -0
  37. package/dist/editor-view-registry.js +44 -4
  38. package/dist/editor.svelte.d.ts +19 -1
  39. package/dist/editor.svelte.js +528 -55
  40. package/dist/embed-registry.d.ts +10 -0
  41. package/dist/embed-registry.js +15 -0
  42. package/dist/file-editing-failures.d.ts +2 -0
  43. package/dist/file-editing-failures.js +29 -0
  44. package/dist/file-editing-native-issues.d.ts +19 -0
  45. package/dist/file-editing-native-issues.js +65 -0
  46. package/dist/file-editing-sessions.d.ts +76 -0
  47. package/dist/file-editing-sessions.js +367 -0
  48. package/dist/file-manager.d.ts +9 -1
  49. package/dist/file-manager.js +45 -3
  50. package/dist/file-state-store.d.ts +5 -0
  51. package/dist/file-state-store.js +34 -0
  52. package/dist/file-text-conflicts.d.ts +25 -0
  53. package/dist/file-text-conflicts.js +35 -0
  54. package/dist/file-text-mutation.d.ts +13 -0
  55. package/dist/file-text-mutation.js +100 -0
  56. package/dist/file-text-persistence.d.ts +36 -0
  57. package/dist/file-text-persistence.js +234 -0
  58. package/dist/file-text-state-writer.d.ts +34 -0
  59. package/dist/file-text-state-writer.js +183 -0
  60. package/dist/files-configuration.d.ts +20 -0
  61. package/dist/files-configuration.js +47 -0
  62. package/dist/generated/plugin-host-modules.schema.generated.d.ts +114 -34
  63. package/dist/generated/plugin-host-modules.schema.generated.js +132 -34
  64. package/dist/glob.d.ts +3 -0
  65. package/dist/glob.js +2 -1
  66. package/dist/icons/changes.d.ts +3 -0
  67. package/dist/icons/changes.js +16 -0
  68. package/dist/icons/index.d.ts +5 -1
  69. package/dist/icons/index.js +44 -3
  70. package/dist/icons/registry.d.ts +35 -0
  71. package/dist/icons/registry.js +154 -0
  72. package/dist/icons/safe-svg.d.ts +2 -0
  73. package/dist/icons/safe-svg.js +44 -0
  74. package/dist/index.d.ts +6 -1
  75. package/dist/index.js +4 -1
  76. package/dist/lapis-extension.d.ts +6 -2
  77. package/dist/lapis-extension.js +2 -0
  78. package/dist/metadata-store.d.ts +23 -0
  79. package/dist/metadata-store.js +1 -0
  80. package/dist/metadata.svelte.js +45 -2
  81. package/dist/native-background-indexing.d.ts +48 -0
  82. package/dist/native-background-indexing.js +208 -0
  83. package/dist/native-database-protocol.d.ts +29 -0
  84. package/dist/native-database-protocol.js +1 -0
  85. package/dist/native-editing-intent-lease.d.ts +17 -0
  86. package/dist/native-editing-intent-lease.js +188 -0
  87. package/dist/native-editing-intents-protocol.d.ts +70 -0
  88. package/dist/native-editing-intents-protocol.js +1 -0
  89. package/dist/native-editing-intents.d.ts +8 -0
  90. package/dist/native-editing-intents.js +1 -0
  91. package/dist/native-editing-problems.d.ts +3 -0
  92. package/dist/native-editing-problems.js +180 -0
  93. package/dist/native-editing-recovery.d.ts +24 -0
  94. package/dist/native-editing-recovery.js +198 -0
  95. package/dist/native-editing-session.d.ts +51 -0
  96. package/dist/native-editing-session.js +251 -0
  97. package/dist/native-history-index-protocol.d.ts +80 -0
  98. package/dist/native-history-index-protocol.js +1 -0
  99. package/dist/native-history-index.d.ts +6 -0
  100. package/dist/native-history-index.js +1 -0
  101. package/dist/native-indexing-protocol.d.ts +63 -0
  102. package/dist/native-indexing-protocol.js +1 -0
  103. package/dist/native-participant-protocol.d.ts +67 -0
  104. package/dist/native-participant-protocol.js +3 -0
  105. package/dist/native-queue-protocol.d.ts +73 -0
  106. package/dist/native-queue-protocol.js +3 -0
  107. package/dist/native-tasks-protocol.d.ts +264 -0
  108. package/dist/native-tasks-protocol.js +1 -0
  109. package/dist/native-tasks.d.ts +35 -0
  110. package/dist/native-tasks.js +79 -0
  111. package/dist/native-turn-protocol.d.ts +81 -0
  112. package/dist/native-turn-protocol.js +1 -0
  113. package/dist/native-turns.d.ts +29 -0
  114. package/dist/native-turns.js +2 -0
  115. package/dist/plugin-asset-server.js +3 -2
  116. package/dist/plugin-dependency-resolver.d.ts +1 -1
  117. package/dist/plugin-distribution/compatibility.d.ts +1 -1
  118. package/dist/plugin-distribution/compatibility.js +10 -5
  119. package/dist/plugin-distribution/installed-plugin-state.js +2 -1
  120. package/dist/plugin-distribution/installer.d.ts +6 -0
  121. package/dist/plugin-distribution/installer.js +90 -37
  122. package/dist/plugin-distribution/manager.d.ts +4 -1
  123. package/dist/plugin-distribution/manager.js +12 -5
  124. package/dist/plugin-distribution/registry-cache.js +2 -1
  125. package/dist/plugin-distribution/registry-source-state.js +2 -1
  126. package/dist/plugin-distribution/schemas.d.ts +26 -8
  127. package/dist/plugin-distribution/schemas.js +2 -1
  128. package/dist/plugin-distribution/types.d.ts +4 -0
  129. package/dist/plugin-manager.d.ts +21 -5
  130. package/dist/plugin-manager.js +228 -64
  131. package/dist/plugin-runtime-entry.d.ts +1 -1
  132. package/dist/plugin-runtime-entry.js +16 -0
  133. package/dist/plugin.d.ts +19 -3
  134. package/dist/plugin.js +110 -44
  135. package/dist/registry-schema/catalog-v1.d.ts +60 -9
  136. package/dist/registry-schema/catalog-v1.js +2 -1
  137. package/dist/registry-schema/catalog-v2.d.ts +60 -9
  138. package/dist/registry-schema/catalog-v2.js +1 -1
  139. package/dist/search-result-provider.d.ts +95 -0
  140. package/dist/search-result-provider.js +42 -0
  141. package/dist/status-bar.svelte.d.ts +26 -20
  142. package/dist/status-bar.svelte.js +1 -0
  143. package/dist/storage/app-database.d.ts +9 -23
  144. package/dist/storage/app-database.js +41 -34
  145. package/dist/storage/desktop-native.d.ts +5 -3
  146. package/dist/storage/desktop-native.js +59 -6
  147. package/dist/storage/directory-watcher.d.ts +27 -0
  148. package/dist/storage/directory-watcher.js +201 -0
  149. package/dist/storage/fs.d.ts +14 -27
  150. package/dist/storage/fs.js +2 -168
  151. package/dist/storage/index-projection.js +33 -15
  152. package/dist/storage/memory-vault-adapter.d.ts +2 -0
  153. package/dist/storage/memory-vault-adapter.js +14 -1
  154. package/dist/storage/path.d.ts +6 -2
  155. package/dist/storage/path.js +8 -2
  156. package/dist/storage/projection-query-candidates.d.ts +3 -0
  157. package/dist/storage/projection-query-candidates.js +69 -0
  158. package/dist/storage/remote-app-database.d.ts +2 -2
  159. package/dist/storage/remote-app-database.js +1 -1
  160. package/dist/storage/runtime.d.ts +3 -0
  161. package/dist/storage/runtime.js +3 -0
  162. package/dist/storage/search-embedding-record.d.ts +3 -0
  163. package/dist/storage/search-embedding-record.js +18 -0
  164. package/dist/storage/turso-app-database.d.ts +2 -0
  165. package/dist/storage/turso-app-database.js +67 -30
  166. package/dist/storage/vault-listing.d.ts +3 -0
  167. package/dist/storage/vault-listing.js +36 -0
  168. package/dist/storage/vault-reconciliation.d.ts +19 -0
  169. package/dist/storage/vault-reconciliation.js +137 -0
  170. package/dist/storage/vault-session.d.ts +8 -0
  171. package/dist/storage/vault-session.js +10 -2
  172. package/dist/storage/vault.svelte.d.ts +53 -4
  173. package/dist/storage/vault.svelte.js +223 -87
  174. package/dist/sync-editor-flush.d.ts +6 -0
  175. package/dist/sync-editor-flush.js +22 -0
  176. package/dist/sync-host-local.d.ts +15 -0
  177. package/dist/sync-host-local.js +228 -0
  178. package/dist/sync-host-security.d.ts +24 -9
  179. package/dist/sync-host-security.js +47 -14
  180. package/dist/sync-host.d.ts +33 -4
  181. package/dist/sync-host.js +160 -10
  182. package/dist/sync-repository.d.ts +106 -0
  183. package/dist/sync-repository.js +18 -0
  184. package/dist/vault-change-views.d.ts +4 -0
  185. package/dist/vault-change-views.js +43 -0
  186. package/dist/vault-changes.d.ts +37 -0
  187. package/dist/vault-changes.js +228 -0
  188. package/dist/view.svelte.d.ts +182 -149
  189. package/dist/view.svelte.js +39 -21
  190. package/dist/workspace-close-guard.d.ts +3 -0
  191. package/dist/workspace-close-guard.js +35 -0
  192. package/dist/workspace-host-internal.js +10 -2
  193. package/dist/workspace-layout-normalizer.d.ts +4 -2
  194. package/dist/workspace-layout-normalizer.js +5 -5
  195. package/dist/workspace-view-document.d.ts +3 -0
  196. package/dist/workspace-view-document.js +12 -0
  197. package/dist/workspace.svelte.d.ts +13 -2
  198. package/dist/workspace.svelte.js +371 -82
  199. package/package.json +61 -8
@@ -3,6 +3,7 @@ import { TFile } from "./storage/fs";
3
3
  import { joinPath } from "./storage/path";
4
4
  import { defaultLinkSettings, generateInternalLink, } from "./links";
5
5
  import { parseLinktext } from "./utils";
6
+ import { mutateFileText, } from "./file-text-mutation";
6
7
  const FRONTMATTER_RETRY_ERROR = "__lapis_frontmatter_retry__";
7
8
  function linkOffsets(position) {
8
9
  if (!position || typeof position !== "object")
@@ -28,7 +29,9 @@ export class FileManager {
28
29
  return JSON.parse(JSON.stringify(frontmatter));
29
30
  }
30
31
  async readFrontmatterSeed(file, content) {
31
- const cachedFrontmatter = (await this.app.metadataCache.getFileCacheAsync(file))?.frontmatter;
32
+ const cachedFrontmatter = content !== undefined && this.app.vault.supportsConditionalTextWrites
33
+ ? undefined
34
+ : (await this.app.metadataCache.getFileCacheAsync(file))?.frontmatter;
32
35
  if (cachedFrontmatter && typeof cachedFrontmatter === "object") {
33
36
  return this.cloneFrontmatter(cachedFrontmatter);
34
37
  }
@@ -151,6 +154,18 @@ export class FileManager {
151
154
  getNewFileParent(sourcePath, newFilePath) {
152
155
  return this.app.vault.getRoot();
153
156
  }
157
+ /** New-file intent; exclusive-capable hosts never fall back to overwrite. */
158
+ createFile(path, data, options) {
159
+ return this.app.fileEditingSessions.track(async () => {
160
+ await this.app.vault.load();
161
+ if (this.app.vault.adapter.getCapabilities?.()
162
+ .exclusiveCreate === true)
163
+ return this.app.vault.createExclusive(path, data, options);
164
+ return typeof data === "string"
165
+ ? this.app.vault.create(path, data, options)
166
+ : this.app.vault.createBinary(path, data, options);
167
+ });
168
+ }
154
169
  getAvailablePathForAttachment(filename, sourcePath) {
155
170
  const parent = this.getNewFileParent(sourcePath ?? "", filename);
156
171
  const extMatch = /(\.[^.]*)$/.exec(filename);
@@ -173,6 +188,14 @@ export class FileManager {
173
188
  * @public
174
189
  */
175
190
  async renameFile(file, newPath) {
191
+ const capturedPath = file.path;
192
+ await this.app.fileEditingSessions.guard(undefined, async () => {
193
+ if (file.path !== capturedPath)
194
+ throw new Error("Rename target changed during editor admission");
195
+ await this.renameProtectedFile(file, newPath);
196
+ });
197
+ }
198
+ async renameProtectedFile(file, newPath) {
176
199
  if (!(file instanceof TFile)) {
177
200
  return this.app.vault.rename(file, newPath);
178
201
  }
@@ -202,8 +225,13 @@ export class FileManager {
202
225
  * @param file
203
226
  * @public
204
227
  */
205
- trashFile(file) {
206
- return this.app.vault.trash(file, true);
228
+ async trashFile(file) {
229
+ const capturedPath = file.path;
230
+ await this.app.fileEditingSessions.guard(undefined, async () => {
231
+ if (file.path !== capturedPath)
232
+ throw new Error("Trash target changed during editor admission");
233
+ await this.app.vault.trash(file, true);
234
+ });
207
235
  }
208
236
  promptForDeletion(file) {
209
237
  return this.trashFile(file);
@@ -260,6 +288,20 @@ export class FileManager {
260
288
  * @public
261
289
  */
262
290
  processFrontMatter(file, fn, options) {
291
+ if (this.app.vault.supportsConditionalTextWrites) {
292
+ let matches = true;
293
+ return mutateFileText(this.app, file, async (snapshot) => {
294
+ const seed = await this.readFrontmatterSeed(file, snapshot);
295
+ matches =
296
+ options?.expectedFrontmatter === undefined ||
297
+ this.isFrontmatterEqual(seed, options.expectedFrontmatter);
298
+ const next = this.cloneFrontmatter(seed);
299
+ fn(next);
300
+ return this.isFrontmatterEqual(seed, next)
301
+ ? snapshot
302
+ : this.writeUpdatedFrontmatter(file, snapshot, next);
303
+ }, { ...options, validateBaseline: () => matches });
304
+ }
263
305
  const attempt = async () => {
264
306
  const snapshot = await this.app.vault.read(file);
265
307
  const seedFrontmatter = await this.readFrontmatterSeed(file, snapshot);
@@ -0,0 +1,5 @@
1
+ import type { App } from "./context.svelte";
2
+ /** Capture the acknowledged state while the owner loads, before edits begin. */
3
+ export declare function readFileState(app: App, path: string, reset?: (text: string) => void | Promise<void>): Promise<string | null>;
4
+ /** Register synchronously so shutdown cannot overtake initial read or write IO. */
5
+ export declare function saveFileState(app: App, path: string, text: string): Promise<void>;
@@ -0,0 +1,34 @@
1
+ import { FileTextStateWriter } from "./file-text-state-writer";
2
+ import { TFile } from "./storage/fs";
3
+ import { normalizePath } from "./storage/path";
4
+ const stores = new WeakMap();
5
+ function writer(app, path, baseline, reset = () => { }) {
6
+ path = normalizePath(path).replace(/^\/+/, "");
7
+ let store = stores.get(app);
8
+ if (!store)
9
+ stores.set(app, (store = new Map()));
10
+ let entry = store.get(path);
11
+ if (!entry) {
12
+ const file = app.vault.getFileByPath(path) ??
13
+ new TFile(path, { ctime: 0, mtime: 0, size: 0 }, null);
14
+ entry = new FileTextStateWriter(app, file, baseline, reset, {
15
+ recoverOutsideQueue: true,
16
+ });
17
+ store.set(path, entry);
18
+ }
19
+ return entry;
20
+ }
21
+ /** Capture the acknowledged state while the owner loads, before edits begin. */
22
+ export async function readFileState(app, path, reset) {
23
+ const state = app.vault.supportsConditionalTextWrites
24
+ ? writer(app, path, undefined, reset)
25
+ : undefined;
26
+ const adapter = app.vault.adapter;
27
+ const text = (await adapter.exists(path)) ? await adapter.read(path) : null;
28
+ state?.acceptBaseline(text);
29
+ return text;
30
+ }
31
+ /** Register synchronously so shutdown cannot overtake initial read or write IO. */
32
+ export function saveFileState(app, path, text) {
33
+ return writer(app, path).set(text);
34
+ }
@@ -0,0 +1,25 @@
1
+ export type FileTextResolution = "keep-local" | "use-external";
2
+ /** A captured recovery decision; stale objects cannot resolve newer conflicts. */
3
+ export interface FileTextConflict {
4
+ readonly id: string;
5
+ readonly path: string;
6
+ readonly baseline: string | null;
7
+ readonly local: string;
8
+ /** Undefined means the authoritative disk read failed; null means deletion. */
9
+ readonly external: string | null | undefined;
10
+ readonly readError?: string;
11
+ resolve(choice: FileTextResolution): Promise<void>;
12
+ /** Persist reviewed merge text against this exact disk snapshot. */
13
+ resolveMerged(contents: string): Promise<void>;
14
+ refresh(): Promise<void>;
15
+ }
16
+ /** App-owned text recovery, independent of the lifetime of individual views. */
17
+ export declare class FileTextConflictRegistry {
18
+ #private;
19
+ get entries(): readonly FileTextConflict[];
20
+ subscribe(listener: (entries: readonly FileTextConflict[]) => void): () => void;
21
+ /** @internal Writer-owned publication; hosts consume entries and their actions. */
22
+ publish(conflict: FileTextConflict): void;
23
+ /** @internal Release only after persistence or an explicit discard succeeds. */
24
+ clear(id: string): void;
25
+ }
@@ -0,0 +1,35 @@
1
+ /** App-owned text recovery, independent of the lifetime of individual views. */
2
+ export class FileTextConflictRegistry {
3
+ #entries = new Map();
4
+ #listeners = new Set();
5
+ get entries() {
6
+ return [...this.#entries.values()];
7
+ }
8
+ subscribe(listener) {
9
+ this.#listeners.add(listener);
10
+ listener(this.entries);
11
+ return () => {
12
+ this.#listeners.delete(listener);
13
+ };
14
+ }
15
+ /** @internal Writer-owned publication; hosts consume entries and their actions. */
16
+ publish(conflict) {
17
+ this.#entries.set(conflict.id, Object.freeze(conflict));
18
+ this.#notify();
19
+ }
20
+ /** @internal Release only after persistence or an explicit discard succeeds. */
21
+ clear(id) {
22
+ if (this.#entries.delete(id))
23
+ this.#notify();
24
+ }
25
+ #notify() {
26
+ for (const listener of this.#listeners) {
27
+ try {
28
+ listener(this.entries);
29
+ }
30
+ catch (error) {
31
+ console.error("Text conflict subscriber failed", error);
32
+ }
33
+ }
34
+ }
35
+ }
@@ -0,0 +1,13 @@
1
+ import type { App } from "./context.svelte";
2
+ import type { TFile, DataWriteOptions } from "./storage/fs";
3
+ export type FileTextMutationOptions = DataWriteOptions & {
4
+ /**
5
+ * A presentation-owned snapshot; omit to transform the authoritative latest
6
+ * text.
7
+ */
8
+ expectedText?: string | null;
9
+ onSaved?(text: string): void;
10
+ onReset?(text: string, expected: string | null): void;
11
+ validateBaseline?(): boolean;
12
+ };
13
+ export declare function mutateFileText(app: App, file: TFile, transform: (text: string) => Promise<string>, options?: FileTextMutationOptions): Promise<void>;
@@ -0,0 +1,100 @@
1
+ import { FileTextPersistence } from "./file-text-persistence";
2
+ /** A retained one-shot draft; it has no view, timer or independent save path. */
3
+ class TextMutation {
4
+ app;
5
+ file;
6
+ transform;
7
+ options;
8
+ #dirty = true;
9
+ #path;
10
+ #draft = "";
11
+ #persistence;
12
+ constructor(app, file, transform, options) {
13
+ this.app = app;
14
+ this.file = file;
15
+ this.transform = transform;
16
+ this.options = options;
17
+ this.#path = file.path;
18
+ }
19
+ get dirty() {
20
+ return this.#dirty || !!this.#persistence?.hasConflict;
21
+ }
22
+ native = {
23
+ hold: () => () => { },
24
+ savedText: () => this.#persistence?.baseline,
25
+ };
26
+ async flush() {
27
+ if (!this.dirty)
28
+ return;
29
+ if (this.file.path !== this.#path)
30
+ throw new Error("File mutation target changed");
31
+ if (!this.#persistence) {
32
+ const expected = this.options.expectedText;
33
+ // Keep the admitted transform if initial IO fails before a draft exists.
34
+ const baseline = expected === undefined
35
+ ? await this.app.vault.read(this.file)
36
+ : expected;
37
+ try {
38
+ this.#draft = await this.transform(baseline ?? "");
39
+ if (this.file.path !== this.#path)
40
+ throw new Error("File mutation target changed");
41
+ if (this.#draft === baseline) {
42
+ this.#dirty = false;
43
+ return;
44
+ }
45
+ this.#persistence = new FileTextPersistence({
46
+ vault: {
47
+ read: (file) => this.app.vault.read(file),
48
+ modifyIfUnchanged: (file, value, expected) => this.app.vault.modifyIfUnchanged(file, value, expected, this.options),
49
+ },
50
+ file: this.file,
51
+ baseline,
52
+ conflicts: this.app.fileEditingSessions.textConflicts,
53
+ local: () => this.#draft,
54
+ protect: () => ({
55
+ release() { },
56
+ saved: () => this.#saved(),
57
+ reset: (text) => this.options.onReset?.(text, this.#persistence.baseline),
58
+ resetSaved: (text) => {
59
+ this.#draft = text;
60
+ this.#dirty = false;
61
+ },
62
+ }),
63
+ serialize: (run) => this.app.fileEditingSessions.recover(this, run),
64
+ });
65
+ }
66
+ catch (error) {
67
+ // Rejected transforms are invalid actions, rather than failed save drafts.
68
+ this.#dirty = false;
69
+ throw error;
70
+ }
71
+ }
72
+ if (this.options.validateBaseline?.() === false)
73
+ await this.#persistence.rejectPersistence();
74
+ try {
75
+ await this.#persistence.persist(this.#draft);
76
+ }
77
+ catch (error) {
78
+ if (!this.#persistence.hasConflict)
79
+ await this.#persistence.rejectPersistence(error);
80
+ throw error;
81
+ }
82
+ this.#saved();
83
+ }
84
+ #saved() {
85
+ this.#dirty = false;
86
+ this.options.onSaved?.(this.#draft);
87
+ }
88
+ async protect() {
89
+ // No user edits enter this captured one-shot draft after admission.
90
+ return () => { };
91
+ }
92
+ }
93
+ export function mutateFileText(app, file, transform, options = {}) {
94
+ const path = file.path;
95
+ return app.fileEditingSessions.mutate(path, () => {
96
+ if (file.path !== path)
97
+ throw new Error("File mutation target changed");
98
+ return new TextMutation(app, file, transform, options);
99
+ });
100
+ }
@@ -0,0 +1,36 @@
1
+ import type { TFile } from "./storage/fs";
2
+ import type { Vault } from "./storage/vault.svelte";
3
+ import type { FileTextConflictRegistry } from "./file-text-conflicts";
4
+ /** A single recovery operation owns its protected document acknowledgement. */
5
+ export type FileTextRecoveryLease = {
6
+ release(): void;
7
+ saved(): void;
8
+ reset(contents: string): void | Promise<void>;
9
+ resetSaved?(contents: string): void;
10
+ };
11
+ export type FileTextPersistenceOptions = {
12
+ vault: Pick<Vault, "read" | "modifyIfUnchanged">;
13
+ file: TFile;
14
+ baseline: string | null;
15
+ conflicts: FileTextConflictRegistry;
16
+ local(): string;
17
+ /** Acquire editing protection synchronously, before asynchronous admission. */
18
+ protect(): FileTextRecoveryLease | Promise<FileTextRecoveryLease>;
19
+ settle?(): Promise<void>;
20
+ serialize(run: () => Promise<void>): Promise<void>;
21
+ /** State restoration may itself write other owned files; revalidate afterward. */
22
+ recoverOutsideQueue?: boolean;
23
+ };
24
+ /** A single captured document's baseline and explicit text-conflict recovery. */
25
+ export declare class FileTextPersistence {
26
+ #private;
27
+ private readonly options;
28
+ constructor(options: FileTextPersistenceOptions);
29
+ get hasConflict(): boolean;
30
+ get baseline(): string | null;
31
+ acceptBaseline(contents: string | null): void;
32
+ updateLocal(contents: string): void;
33
+ /** Retain a computed draft and authoritative disk snapshot before rejecting. */
34
+ rejectPersistence(error?: unknown): Promise<never>;
35
+ persist(contents: string): Promise<void>;
36
+ }
@@ -0,0 +1,234 @@
1
+ function hasCode(error, code) {
2
+ return (!!error &&
3
+ typeof error === "object" &&
4
+ "code" in error &&
5
+ error.code === code);
6
+ }
7
+ /** A single captured document's baseline and explicit text-conflict recovery. */
8
+ export class FileTextPersistence {
9
+ options;
10
+ #id = crypto.randomUUID();
11
+ #path;
12
+ #error;
13
+ #baseline;
14
+ #conflict;
15
+ constructor(options) {
16
+ this.options = options;
17
+ this.#path = options.file.path;
18
+ this.#baseline = options.baseline;
19
+ this.#error = Object.assign(new Error(`File changed on disk: ${this.#path}`), { code: "ECONFLICT" });
20
+ }
21
+ get hasConflict() {
22
+ return !!this.#conflict;
23
+ }
24
+ get baseline() {
25
+ return this.#baseline;
26
+ }
27
+ acceptBaseline(contents) {
28
+ if (!this.#conflict)
29
+ this.#baseline = contents;
30
+ }
31
+ updateLocal(contents) {
32
+ if (this.#conflict && this.#conflict.local !== contents)
33
+ this.#publish(this.#conflict.external, this.#conflict.readError);
34
+ }
35
+ /** Retain a computed draft and authoritative disk snapshot before rejecting. */
36
+ async rejectPersistence(error = this.#error) {
37
+ this.#assertTarget();
38
+ await this.#captureExternal();
39
+ throw error;
40
+ }
41
+ async persist(contents) {
42
+ this.#assertTarget();
43
+ if (this.#conflict) {
44
+ this.updateLocal(this.options.local());
45
+ throw this.#error;
46
+ }
47
+ try {
48
+ await this.options.vault.modifyIfUnchanged(this.options.file, contents, this.#baseline);
49
+ this.#baseline = contents;
50
+ }
51
+ catch (error) {
52
+ if (hasCode(error, "ECONFLICT")) {
53
+ await this.#captureExternal();
54
+ throw this.#error;
55
+ }
56
+ throw error;
57
+ }
58
+ }
59
+ #assertTarget() {
60
+ if (this.options.file.path !== this.#path)
61
+ throw new Error("File text persistence target changed");
62
+ }
63
+ #assertCurrent(conflict) {
64
+ this.#assertTarget();
65
+ if (this.#conflict !== conflict)
66
+ throw new Error("Conflict changed; review the latest versions");
67
+ }
68
+ async #read() {
69
+ this.#assertTarget();
70
+ try {
71
+ return await this.options.vault.read(this.options.file);
72
+ }
73
+ catch (error) {
74
+ if (hasCode(error, "ENOENT"))
75
+ return null;
76
+ throw error;
77
+ }
78
+ }
79
+ async #captureExternal() {
80
+ try {
81
+ this.#publish(await this.#read());
82
+ }
83
+ catch (error) {
84
+ this.#publish(undefined, error instanceof Error ? error.message : String(error));
85
+ }
86
+ }
87
+ #publish(external, readError) {
88
+ const conflict = {
89
+ id: this.#id,
90
+ path: this.#path,
91
+ baseline: this.#baseline,
92
+ local: this.options.local(),
93
+ external,
94
+ ...(readError === undefined ? {} : { readError }),
95
+ resolve: (choice) => this.#recover(conflict, choice),
96
+ resolveMerged: (contents) => typeof contents === "string"
97
+ ? this.#recover(conflict, "keep-local", contents)
98
+ : Promise.reject(new Error("Merged file contents must be text")),
99
+ refresh: () => this.#recover(conflict),
100
+ };
101
+ this.#conflict = conflict;
102
+ this.options.conflicts.publish(conflict);
103
+ }
104
+ async #recover(conflict, choice, merged) {
105
+ this.#assertCurrent(conflict);
106
+ if (choice !== undefined &&
107
+ choice !== "keep-local" &&
108
+ choice !== "use-external")
109
+ throw new Error("Invalid text conflict resolution");
110
+ if (merged !== undefined && typeof merged !== "string")
111
+ throw new Error("Merged file contents must be text");
112
+ const lease = await this.options.protect();
113
+ try {
114
+ await this.options.settle?.();
115
+ if (this.options.recoverOutsideQueue && choice !== undefined) {
116
+ await this.#recoverState(conflict, choice, lease, merged);
117
+ return;
118
+ }
119
+ await this.options.serialize(async () => {
120
+ this.#assertCurrent(conflict);
121
+ if (choice === undefined) {
122
+ await this.#captureExternal();
123
+ return;
124
+ }
125
+ if (conflict.external === undefined)
126
+ throw new Error("Read the disk version before resolving this conflict");
127
+ if (choice === "keep-local") {
128
+ const contents = merged ?? this.options.local();
129
+ try {
130
+ await this.options.vault.modifyIfUnchanged(this.options.file, contents, conflict.external);
131
+ }
132
+ catch (error) {
133
+ if (hasCode(error, "ECONFLICT")) {
134
+ await this.#captureExternal();
135
+ throw this.#error;
136
+ }
137
+ throw error;
138
+ }
139
+ const previousBaseline = this.#baseline;
140
+ this.#baseline = contents;
141
+ if (merged !== undefined) {
142
+ try {
143
+ await lease.reset(contents);
144
+ }
145
+ catch (error) {
146
+ this.#baseline = previousBaseline;
147
+ throw error;
148
+ }
149
+ lease.resetSaved?.(contents);
150
+ }
151
+ else
152
+ lease.saved();
153
+ this.#clear();
154
+ }
155
+ else {
156
+ let external;
157
+ try {
158
+ external = await this.#read();
159
+ }
160
+ catch (error) {
161
+ await this.#captureExternal();
162
+ throw error;
163
+ }
164
+ if (external !== conflict.external) {
165
+ this.#publish(external);
166
+ throw this.#error;
167
+ }
168
+ const previousBaseline = this.#baseline;
169
+ this.#baseline = external;
170
+ try {
171
+ await lease.reset(external ?? "");
172
+ }
173
+ catch (error) {
174
+ this.#baseline = previousBaseline;
175
+ throw error;
176
+ }
177
+ this.#clear();
178
+ lease.resetSaved?.(external ?? "");
179
+ }
180
+ });
181
+ }
182
+ finally {
183
+ lease.release();
184
+ }
185
+ }
186
+ async #recoverState(conflict, choice, lease, merged) {
187
+ let selected = "";
188
+ await this.options.serialize(async () => {
189
+ this.#assertCurrent(conflict);
190
+ if (conflict.external === undefined)
191
+ throw new Error("Read the disk version before resolving this conflict");
192
+ const actual = await this.#read();
193
+ if (actual !== conflict.external) {
194
+ this.#publish(actual);
195
+ throw this.#error;
196
+ }
197
+ selected =
198
+ merged ??
199
+ (choice === "keep-local" ? this.options.local() : (actual ?? ""));
200
+ });
201
+ // The owner lease remains held, but other owned files can use the queue.
202
+ await lease.reset(selected);
203
+ await this.options.serialize(async () => {
204
+ this.#assertCurrent(conflict);
205
+ if (choice === "keep-local") {
206
+ try {
207
+ await this.options.vault.modifyIfUnchanged(this.options.file, selected, conflict.external);
208
+ }
209
+ catch (error) {
210
+ if (hasCode(error, "ECONFLICT"))
211
+ await this.#captureExternal();
212
+ throw error;
213
+ }
214
+ }
215
+ else {
216
+ const actual = await this.#read();
217
+ if (actual !== conflict.external) {
218
+ this.#publish(actual);
219
+ throw this.#error;
220
+ }
221
+ }
222
+ this.#baseline = choice === "keep-local" ? selected : conflict.external;
223
+ this.#clear();
224
+ if (choice === "keep-local" && merged === undefined)
225
+ lease.saved();
226
+ else
227
+ lease.resetSaved?.(selected);
228
+ });
229
+ }
230
+ #clear() {
231
+ this.#conflict = undefined;
232
+ this.options.conflicts.clear(this.#id);
233
+ }
234
+ }
@@ -0,0 +1,34 @@
1
+ import type { App } from "./context.svelte";
2
+ import type { FileEditingSession } from "./file-editing-sessions";
3
+ import type { TFile } from "./storage/fs";
4
+ /**
5
+ * App-owned mutable text state using the same persistence contract as
6
+ * documents.
7
+ */
8
+ export declare class FileTextStateWriter implements FileEditingSession {
9
+ #private;
10
+ private readonly app;
11
+ private readonly file;
12
+ private readonly reset;
13
+ private readonly options;
14
+ constructor(app: App, file: TFile, baseline: string | null | undefined, reset: (text: string) => void | Promise<void>, options?: {
15
+ recoverOutsideQueue?: boolean;
16
+ });
17
+ get dirty(): boolean;
18
+ readonly native: {
19
+ lifetime: "operation";
20
+ hold: () => () => void;
21
+ savedText: () => string | null | undefined;
22
+ };
23
+ acceptBaseline(contents: string | null): boolean;
24
+ assertEditable(): void;
25
+ set(contents: string): Promise<void>;
26
+ /** Calculate a logical change only after every earlier change is acknowledged. */
27
+ update(calculate: () => {
28
+ contents: string;
29
+ onSaved(): void;
30
+ } | undefined): Promise<void>;
31
+ flush(): Promise<void>;
32
+ protect(): Promise<() => void>;
33
+ dispose(): void;
34
+ }