@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
package/dist/sync-host.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { TFile } from "./storage/fs";
2
2
  import { sha256 } from "@noble/hashes/sha2.js";
3
3
  import { bytesToHex } from "@noble/hashes/utils.js";
4
+ import { ScopedSyncLocalRuntime } from "./sync-host-local";
4
5
  import { SyncHostSecurityService, } from "./sync-host-security";
5
6
  const ROOT_PREFIX = "sync-host:v1:";
6
7
  const DATABASE_GENERATION_KEY = `${ROOT_PREFIX}database-generation`;
@@ -71,19 +72,39 @@ export class SyncHostService {
71
72
  database;
72
73
  vault;
73
74
  clock;
74
- options;
75
75
  #ready;
76
76
  #eventQueue = Promise.resolve();
77
77
  #runtimeIds = new Map();
78
78
  #origins = new Map();
79
79
  #security;
80
+ #selectSecurityProvider;
81
+ #securityProviders = new Map();
82
+ #delegatedRuntimes = new Map();
83
+ #localRuntimes = new Map();
84
+ #selectLocalProvider;
85
+ #guardLocalApply;
86
+ #securityCleanup = new Set();
87
+ #stopObserving;
88
+ #disposed = false;
89
+ #disposal;
90
+ options;
80
91
  constructor(database, vault, clock = Date.now, options = {}) {
81
92
  this.database = database;
82
93
  this.vault = vault;
83
94
  this.clock = clock;
84
- this.options = options;
85
- this.#security = new SyncHostSecurityService(database, (pluginId, runtimeId) => this.#assertRuntime(pluginId, runtimeId), options.security);
86
- this.vault.on("all", (event, file, context) => {
95
+ const { securityStore, securityProvider, localProvider, guardLocalApply, ...retainedOptions } = options;
96
+ this.options =
97
+ securityStore === undefined &&
98
+ securityProvider === undefined &&
99
+ localProvider === undefined &&
100
+ guardLocalApply === undefined
101
+ ? options
102
+ : retainedOptions;
103
+ this.#selectSecurityProvider = securityProvider;
104
+ this.#selectLocalProvider = localProvider;
105
+ this.#guardLocalApply = guardLocalApply;
106
+ this.#security = new SyncHostSecurityService(securityStore === undefined ? database : securityStore, (pluginId, runtimeId) => this.#assertRuntime(pluginId, runtimeId), options.security);
107
+ const eventRef = this.vault.on("all", (event, file, context) => {
87
108
  if (!(file instanceof TFile) || !this.#isChangeKind(event))
88
109
  return;
89
110
  const previousPath = typeof context.oldPath === "string" ? context.oldPath : undefined;
@@ -92,10 +113,12 @@ export class SyncHostService {
92
113
  : undefined;
93
114
  this.#eventQueue = this.#eventQueue
94
115
  .then(async () => {
116
+ await this.#ready;
95
117
  await this.#recordFileChange(event, file, previousPath, originId);
96
118
  })
97
119
  .catch(() => undefined);
98
120
  });
121
+ this.#stopObserving = () => this.vault.offref(eventRef);
99
122
  this.#ready = this.#initialize();
100
123
  }
101
124
  async ready() {
@@ -103,6 +126,8 @@ export class SyncHostService {
103
126
  await this.#eventQueue;
104
127
  }
105
128
  forPlugin(pluginId, grantedCapabilities) {
129
+ if (this.#disposed)
130
+ throw new Error("Sync host is disposed");
106
131
  const normalizedPluginId = requireBoundedString(pluginId, "Plugin ID", 128);
107
132
  const runtimeId = randomId();
108
133
  const runtimeIds = this.#runtimeIds.get(normalizedPluginId) ?? new Set();
@@ -116,7 +141,7 @@ export class SyncHostService {
116
141
  throw new Error(`Plugin ${normalizedPluginId} is not granted ${capability}`);
117
142
  }
118
143
  };
119
- return {
144
+ const capabilities = {
120
145
  files: {
121
146
  getIdentity: async (path) => {
122
147
  assertGranted("metadata:identity");
@@ -304,32 +329,157 @@ export class SyncHostService {
304
329
  }),
305
330
  generatedState: this.#generatedState(normalizedPluginId, runtimeId, () => assertGranted("plugin:generated-state")),
306
331
  };
332
+ if (this.#selectLocalProvider) {
333
+ const local = new ScopedSyncLocalRuntime({
334
+ runtimeId,
335
+ assertGranted,
336
+ selectProvider: () => this.#selectLocalProvider(normalizedPluginId),
337
+ guardApply: this.#guardLocalApply,
338
+ });
339
+ this.#localRuntimes.set(runtimeId, local);
340
+ Object.assign(capabilities, { local: local.capabilities });
341
+ }
342
+ let delegated;
343
+ let selected = false;
344
+ for (const group of [
345
+ "crypto",
346
+ "syncIdentity",
347
+ "pairing",
348
+ "membership",
349
+ "recovery",
350
+ ]) {
351
+ const local = capabilities[group];
352
+ const guarded = {};
353
+ for (const method of Object.keys(local)) {
354
+ guarded[method] = async (...args) => {
355
+ assertGranted("crypto:nostr-sync");
356
+ if (!selected) {
357
+ const provider = this.#securityProvider(normalizedPluginId);
358
+ if (provider) {
359
+ this.#delegatedRuntimes.set(runtimeId, provider);
360
+ delegated = provider.forRuntime(runtimeId);
361
+ if (!delegated || typeof delegated !== "object")
362
+ throw new Error("Invalid Sync security provider capabilities");
363
+ }
364
+ selected = true;
365
+ }
366
+ assertGranted("crypto:nostr-sync");
367
+ const target = delegated ? delegated[group] : local;
368
+ const operation = target && target[method];
369
+ if (typeof operation !== "function")
370
+ throw new Error(`Invalid Sync security provider operation: ${group}.${method}`);
371
+ const result = await operation.apply(target, args);
372
+ assertGranted("crypto:nostr-sync");
373
+ return result;
374
+ };
375
+ }
376
+ Object.assign(capabilities, { [group]: guarded });
377
+ }
378
+ return capabilities;
307
379
  }
308
380
  revokePlugin(pluginId) {
381
+ const runtimes = this.#runtimeIds.get(pluginId);
309
382
  this.#runtimeIds.delete(pluginId);
310
383
  for (const [token, owner] of this.#origins) {
311
384
  if (owner.pluginId === pluginId)
312
385
  this.#origins.delete(token);
313
386
  }
314
387
  this.#security.revokePlugin(pluginId);
388
+ const cleanups = [];
389
+ for (const runtimeId of runtimes ?? []) {
390
+ const provider = this.#delegatedRuntimes.get(runtimeId);
391
+ this.#delegatedRuntimes.delete(runtimeId);
392
+ const local = this.#localRuntimes.get(runtimeId);
393
+ this.#localRuntimes.delete(runtimeId);
394
+ if (!provider && !local)
395
+ continue;
396
+ let cleanup;
397
+ try {
398
+ // Local streams/operations must finish while native crypto custody still exists.
399
+ const localCleanup = local?.revoke();
400
+ cleanup = localCleanup
401
+ ? (async () => {
402
+ const result = await Promise.allSettled([localCleanup]);
403
+ await provider?.revokeRuntime(runtimeId);
404
+ if (result[0]?.status === "rejected")
405
+ throw result[0].reason;
406
+ })()
407
+ : Promise.resolve(provider.revokeRuntime(runtimeId));
408
+ }
409
+ catch (error) {
410
+ cleanup = Promise.reject(error);
411
+ }
412
+ this.#securityCleanup.add(cleanup);
413
+ cleanups.push(cleanup);
414
+ }
415
+ return this.#drainSecurity(cleanups);
416
+ }
417
+ /** Invalidate first, then wait for every admitted host cleanup and file event. */
418
+ dispose() {
419
+ if (this.#disposal)
420
+ return this.#disposal;
421
+ this.#disposed = true;
422
+ this.#stopObserving();
423
+ const revocations = [...this.#runtimeIds.keys()].map((pluginId) => this.revokePlugin(pluginId));
424
+ this.#disposal = this.#drainSecurity([
425
+ ...revocations,
426
+ ...this.#securityCleanup,
427
+ this.ready(),
428
+ ]);
429
+ return this.#disposal;
430
+ }
431
+ async #drainSecurity(cleanups) {
432
+ const results = await Promise.allSettled(cleanups);
433
+ for (const cleanup of cleanups)
434
+ this.#securityCleanup.delete(cleanup);
435
+ const failed = results.find((result) => result.status === "rejected");
436
+ if (failed?.status === "rejected")
437
+ throw failed.reason;
438
+ }
439
+ #securityProvider(pluginId) {
440
+ if (!this.#securityProviders.has(pluginId)) {
441
+ const provider = this.#selectSecurityProvider?.(pluginId);
442
+ if (provider !== undefined &&
443
+ (!provider ||
444
+ typeof provider.forRuntime !== "function" ||
445
+ typeof provider.revokeRuntime !== "function"))
446
+ throw new Error("Invalid Sync security provider");
447
+ this.#securityProviders.set(pluginId, provider);
448
+ }
449
+ return this.#securityProviders.get(pluginId);
450
+ }
451
+ #assertLocalSecurity(pluginId) {
452
+ requireBoundedString(pluginId, "Plugin ID", 128);
453
+ if (this.#disposed)
454
+ throw new Error("Sync host is disposed");
455
+ if (this.#securityProvider(pluginId))
456
+ throw new Error(`Sync security for ${pluginId} is delegated to its host provider`);
315
457
  }
316
- /** Host-controlled enrollment; this operation is intentionally absent from plugin facades. */
317
- enrollPluginCrypto(pluginId, passphrase, request) {
458
+ /**
459
+ * Host-controlled enrollment; this operation is intentionally absent from
460
+ * plugin facades.
461
+ */
462
+ async enrollPluginCrypto(pluginId, passphrase, request) {
463
+ this.#assertLocalSecurity(pluginId);
318
464
  return this.#security.enroll(requireBoundedString(pluginId, "Plugin ID", 128), passphrase, request);
319
465
  }
320
466
  /** Host-controlled unlock; plugins can only observe locked state. */
321
- unlockPluginCrypto(pluginId, passphrase) {
467
+ async unlockPluginCrypto(pluginId, passphrase) {
468
+ this.#assertLocalSecurity(pluginId);
322
469
  return this.#security.unlock(requireBoundedString(pluginId, "Plugin ID", 128), passphrase);
323
470
  }
324
471
  /** Host-controlled passkey or OS-credential enrollment. */
325
- enrollPluginCryptoWithWrappingKey(pluginId, wrappingKey, descriptor, request) {
472
+ async enrollPluginCryptoWithWrappingKey(pluginId, wrappingKey, descriptor, request) {
473
+ this.#assertLocalSecurity(pluginId);
326
474
  return this.#security.enrollWithWrappingKey(requireBoundedString(pluginId, "Plugin ID", 128), wrappingKey, descriptor, request);
327
475
  }
328
476
  /** Host-controlled passkey or OS-credential unlock. */
329
- unlockPluginCryptoWithWrappingKey(pluginId, wrappingKey) {
477
+ async unlockPluginCryptoWithWrappingKey(pluginId, wrappingKey) {
478
+ this.#assertLocalSecurity(pluginId);
330
479
  return this.#security.unlockWithWrappingKey(requireBoundedString(pluginId, "Plugin ID", 128), wrappingKey);
331
480
  }
332
481
  lockPluginCrypto(pluginId) {
482
+ this.#assertLocalSecurity(pluginId);
333
483
  this.#security.lock(requireBoundedString(pluginId, "Plugin ID", 128));
334
484
  }
335
485
  grantPluginNetworkOrigin(pluginId, origin, options) {
@@ -0,0 +1,106 @@
1
+ /** Durable opaque sync data. Hosts keep this separate from disposable AppDatabase indexes. */
2
+ export interface SealedSyncState {
3
+ version: number;
4
+ epoch: number;
5
+ ciphertext: Uint8Array;
6
+ }
7
+ export interface SyncRepository {
8
+ readonly blobs: {
9
+ has(hash: string): Promise<boolean>;
10
+ put(ciphertext: Uint8Array): Promise<{
11
+ hash: string;
12
+ size: number;
13
+ }>;
14
+ get(hash: string): Promise<Uint8Array>;
15
+ };
16
+ readState(): Promise<SealedSyncState | null>;
17
+ compareAndSwap(expectedVersion: number, next: SealedSyncState, authority?: SyncOperationAuthority): Promise<boolean>;
18
+ }
19
+ /** Expected endpoint authority for a guarded host operation, outside encrypted protocol data. */
20
+ export interface SyncOperationAuthority {
21
+ vaultId: string;
22
+ devicePublicKey: string;
23
+ epoch: number;
24
+ membershipGeneration: number;
25
+ }
26
+ /** Immutable sealed records addressed by opaque 64-character lowercase hex keys. */
27
+ export interface SealedSyncRecord {
28
+ epoch: number;
29
+ ciphertext: Uint8Array;
30
+ }
31
+ /** Optional repository extension for independently encrypted, indexed local records. */
32
+ export interface SyncIndexedRepository extends SyncRepository {
33
+ readonly records: {
34
+ get(key: string): Promise<SealedSyncRecord | null>;
35
+ /** False preserves the existing record unchanged, including concurrent insertion. */
36
+ putIfAbsent(key: string, record: SealedSyncRecord): Promise<boolean>;
37
+ };
38
+ }
39
+ /** Captured by the owning host; a missing file is represented by null. */
40
+ export interface SyncFileGeneration {
41
+ fileId: string;
42
+ contentGeneration: number;
43
+ hostFileId?: string;
44
+ }
45
+ export interface SyncFileMutation {
46
+ id: string;
47
+ kind: "write" | "remove";
48
+ path: string;
49
+ expected: SyncFileGeneration | null;
50
+ fileId: string;
51
+ }
52
+ /** Capture only protocol-neutral mutation data; file handles may carry read functions. */
53
+ export declare function snapshotSyncFileMutation(input: SyncFileMutation): SyncFileMutation;
54
+ export interface SyncFileMutationReceipt {
55
+ operation: SyncFileMutation;
56
+ result: SyncFileGeneration | null;
57
+ }
58
+ /** The host journals immutable operation IDs and verifies guards at mutation. */
59
+ export interface SyncFileMutationAccess {
60
+ receipt(operationId: string, authority?: SyncOperationAuthority): Promise<SyncFileMutationReceipt | null>;
61
+ mutate(operation: SyncFileMutation, source?: ReadableStream<Uint8Array>, authority?: SyncOperationAuthority): Promise<SyncFileMutationReceipt>;
62
+ }
63
+ export interface SyncFileDescriptor extends SyncFileGeneration {
64
+ path: string;
65
+ }
66
+ export interface SyncGuardedFile extends SyncFileDescriptor {
67
+ openRead(): Promise<ReadableStream<Uint8Array>>;
68
+ }
69
+ /** Captured working files and durable encrypted state owned by the current host. */
70
+ export interface SyncLocalHostCapabilities {
71
+ list(): Promise<SyncGuardedFile[]>;
72
+ readonly repository: SyncRepository;
73
+ readonly apply: SyncFileMutationAccess;
74
+ }
75
+ /** Trusted host adapter. Neither this controller nor storage paths enter plugin facades. */
76
+ export interface SyncHostLocalProvider {
77
+ forRuntime(runtimeId: string): SyncLocalHostCapabilities;
78
+ revokeRuntime(runtimeId: string): void | Promise<void>;
79
+ }
80
+ /** The owning host protects editor state for the entire callback, including recovery. */
81
+ export type SyncLocalApplyGuard = <T>(path: string | undefined, operation: () => Promise<T>) => Promise<T>;
82
+ /** Minimal opaque crypto boundary; implementations retain all secret material. */
83
+ export interface SyncVaultStateCrypto {
84
+ status(): Promise<{
85
+ state: "unenrolled" | "locked" | "unlocked";
86
+ suite: "lapis-sync-v1-xchacha20poly1305-schnorr";
87
+ vaultId?: string;
88
+ devicePublicKey?: string;
89
+ deviceState?: "active" | "revoked" | "recovery-pending";
90
+ currentEpoch?: number;
91
+ membershipGeneration?: number;
92
+ }>;
93
+ encryptObject(input: {
94
+ epoch: number;
95
+ type: "local-state";
96
+ plaintext: Uint8Array;
97
+ }): Promise<{
98
+ bytes: Uint8Array;
99
+ objectHash: string;
100
+ }>;
101
+ decryptObject(input: {
102
+ epoch: number;
103
+ type: "local-state";
104
+ envelope: Uint8Array;
105
+ }): Promise<Uint8Array>;
106
+ }
@@ -0,0 +1,18 @@
1
+ /** Capture only protocol-neutral mutation data; file handles may carry read functions. */
2
+ export function snapshotSyncFileMutation(input) {
3
+ return {
4
+ id: input.id,
5
+ kind: input.kind,
6
+ path: input.path,
7
+ fileId: input.fileId,
8
+ expected: input.expected === null
9
+ ? null
10
+ : {
11
+ fileId: input.expected.fileId,
12
+ contentGeneration: input.expected.contentGeneration,
13
+ ...(input.expected.hostFileId !== undefined
14
+ ? { hostFileId: input.expected.hostFileId }
15
+ : {}),
16
+ },
17
+ };
18
+ }
@@ -0,0 +1,4 @@
1
+ import type { App } from "./context.svelte";
2
+ import { TFile } from "./storage/fs";
3
+ /** Project into existing owned views; no replacement editor or persistence path. */
4
+ export declare function projectVaultChange(app: App, event: string, file: TFile, context: Record<string, unknown>, current: () => boolean): Promise<void>;
@@ -0,0 +1,43 @@
1
+ import { TFile } from "./storage/fs";
2
+ import { FileView } from "./view.svelte";
3
+ /** Project into existing owned views; no replacement editor or persistence path. */
4
+ export async function projectVaultChange(app, event, file, context, current) {
5
+ if (!current())
6
+ return;
7
+ if (context.external === true)
8
+ app.workspace.trigger("file-change", file, event);
9
+ if (event !== "rename" && event !== "delete")
10
+ return;
11
+ const oldPath = event === "rename" ? String(context.oldPath) : file.path;
12
+ if (app.fileEditingSessions.hasDirty(oldPath)) {
13
+ app.notifications.notify({
14
+ id: `vault-file-change:${oldPath}`,
15
+ title: "File changed on disk",
16
+ message: event === "rename"
17
+ ? `${oldPath} moved to ${file.path}. Local edits remain in the original view for recovery.`
18
+ : `${oldPath} was removed on disk. Local edits remain in the original view for recovery.`,
19
+ severity: "warning",
20
+ source: "vault",
21
+ });
22
+ return;
23
+ }
24
+ const leaves = [];
25
+ app.workspace.iterateAllLeaves((leaf) => {
26
+ if (leaf.view instanceof FileView && leaf.view.file?.path === oldPath)
27
+ leaves.push(leaf);
28
+ });
29
+ for (const leaf of leaves) {
30
+ if (!current() ||
31
+ !(leaf.view instanceof FileView) ||
32
+ leaf.view.file?.path !== oldPath)
33
+ continue;
34
+ if (event === "rename")
35
+ await leaf.view.onRename(file.copy());
36
+ else
37
+ await leaf.setViewState({ type: "empty", state: {} });
38
+ if (current())
39
+ app.workspace.requestSaveLayout();
40
+ }
41
+ if (current())
42
+ app.workspace.reconcileFileReference(oldPath, event === "rename" ? file : null);
43
+ }
@@ -0,0 +1,37 @@
1
+ import type { App } from "./context.svelte";
2
+ /** One App owns watching, reconciliation and retirement for its live Vault. */
3
+ export declare class VaultChangeService {
4
+ private readonly app;
5
+ private watcher;
6
+ private starting;
7
+ private stopping;
8
+ private processing;
9
+ private timer;
10
+ private generation;
11
+ private active;
12
+ private registered;
13
+ private dirty;
14
+ private uncertain;
15
+ private failures;
16
+ private readonly hints;
17
+ private stopProjection;
18
+ private readonly projections;
19
+ constructor(app: App);
20
+ start(): Promise<void>;
21
+ /**
22
+ * Retire callbacks synchronously, then drain registration, native IO and
23
+ * scans.
24
+ */
25
+ stop(): Promise<void>;
26
+ /**
27
+ * Flush pending hints now; explicit calls also retry a previously failed
28
+ * scan.
29
+ */
30
+ settle(): Promise<void>;
31
+ private live;
32
+ private clearTimer;
33
+ private report;
34
+ private schedule;
35
+ private changed;
36
+ private drain;
37
+ }
@@ -0,0 +1,228 @@
1
+ import { DirectoryWatcher, TFile, } from "./storage/fs";
2
+ import { projectVaultChange } from "./vault-change-views";
3
+ /** One App owns watching, reconciliation and retirement for its live Vault. */
4
+ export class VaultChangeService {
5
+ app;
6
+ watcher;
7
+ starting;
8
+ stopping;
9
+ processing;
10
+ timer;
11
+ generation = 0;
12
+ active = false;
13
+ registered = false;
14
+ dirty = false;
15
+ uncertain = false;
16
+ failures = 0;
17
+ hints = new Map();
18
+ stopProjection;
19
+ projections = new Set();
20
+ constructor(app) {
21
+ this.app = app;
22
+ }
23
+ start() {
24
+ if (this.starting)
25
+ return this.starting;
26
+ if (this.active)
27
+ return this.settle();
28
+ const run = async () => {
29
+ const retiring = this.stop();
30
+ const requested = this.generation;
31
+ await retiring;
32
+ if (this.generation !== requested)
33
+ return;
34
+ const generation = ++this.generation;
35
+ this.active = true;
36
+ this.dirty = true;
37
+ const watcher = new DirectoryWatcher(this.app.vault.adapter);
38
+ this.watcher = watcher;
39
+ const projection = this.app.vault.on("all", (event, file, context) => {
40
+ if (!this.live(generation) || !(file instanceof TFile))
41
+ return;
42
+ const snapshot = file.copy(), details = { ...context };
43
+ const pending = this.app.fileEditingSessions.track(() => this.app.fileEditingSessions.afterMutations(() => projectVaultChange(this.app, event, snapshot, details, () => this.live(generation))));
44
+ this.projections.add(pending);
45
+ void pending
46
+ .catch((error) => this.report(error))
47
+ .finally(() => this.projections.delete(pending));
48
+ });
49
+ this.stopProjection = () => this.app.vault.offref(projection);
50
+ watcher.on("all", (event) => {
51
+ if (this.live(generation))
52
+ this.changed(event, generation);
53
+ });
54
+ const subscription = watcher.watch("/", { recursive: true });
55
+ await subscription.ready;
56
+ if (!this.live(generation))
57
+ return;
58
+ this.registered = true;
59
+ await this.drain(generation);
60
+ };
61
+ const pending = run()
62
+ .catch(async (error) => {
63
+ this.active = false;
64
+ this.registered = false;
65
+ ++this.generation;
66
+ this.clearTimer();
67
+ this.stopProjection?.();
68
+ this.stopProjection = undefined;
69
+ try {
70
+ await this.watcher?.close();
71
+ }
72
+ catch (cleanup) {
73
+ throw new AggregateError([error, cleanup], "Vault watch startup and cleanup failed");
74
+ }
75
+ this.watcher = undefined;
76
+ throw error;
77
+ })
78
+ .finally(() => {
79
+ if (this.starting === pending)
80
+ this.starting = undefined;
81
+ });
82
+ this.starting = pending;
83
+ return pending;
84
+ }
85
+ /**
86
+ * Retire callbacks synchronously, then drain registration, native IO and
87
+ * scans.
88
+ */
89
+ stop() {
90
+ this.active = false;
91
+ this.registered = false;
92
+ ++this.generation;
93
+ this.clearTimer();
94
+ this.stopProjection?.();
95
+ this.stopProjection = undefined;
96
+ this.hints.clear();
97
+ this.dirty = false;
98
+ this.uncertain = false;
99
+ if (this.stopping)
100
+ return this.stopping;
101
+ const watcher = this.watcher;
102
+ const pending = Promise.allSettled([
103
+ watcher?.close(),
104
+ this.processing,
105
+ this.starting,
106
+ ...this.projections,
107
+ ])
108
+ .then((results) => {
109
+ const errors = results.flatMap((result) => result.status === "rejected" ? [result.reason] : []);
110
+ if (errors.length)
111
+ throw new AggregateError(errors, "Vault watch cleanup failed");
112
+ if (this.watcher === watcher)
113
+ this.watcher = undefined;
114
+ })
115
+ .finally(() => {
116
+ if (this.stopping === pending)
117
+ this.stopping = undefined;
118
+ });
119
+ this.stopping = pending;
120
+ return pending;
121
+ }
122
+ /**
123
+ * Flush pending hints now; explicit calls also retry a previously failed
124
+ * scan.
125
+ */
126
+ async settle() {
127
+ await this.starting;
128
+ this.clearTimer();
129
+ if (this.active && this.registered) {
130
+ await this.drain(this.generation);
131
+ while (this.projections.size)
132
+ await Promise.all([...this.projections]);
133
+ }
134
+ }
135
+ live(generation) {
136
+ return this.active && this.generation === generation;
137
+ }
138
+ clearTimer() {
139
+ if (this.timer)
140
+ clearTimeout(this.timer);
141
+ this.timer = undefined;
142
+ }
143
+ report(error) {
144
+ this.app.notifications.notify({
145
+ id: "vault-watch-reconciliation",
146
+ title: "File changes need attention",
147
+ message: error instanceof Error ? error.message : String(error),
148
+ severity: "error",
149
+ source: "vault",
150
+ });
151
+ }
152
+ schedule(generation, delay = 40) {
153
+ if (!this.registered || this.timer || !this.live(generation))
154
+ return;
155
+ this.timer = setTimeout(() => {
156
+ this.timer = undefined;
157
+ void this.drain(generation).catch(() => { });
158
+ }, delay);
159
+ }
160
+ changed(event, generation) {
161
+ this.dirty = true;
162
+ if (event.type === "error") {
163
+ this.uncertain = true;
164
+ this.hints.clear();
165
+ this.report(event.error);
166
+ }
167
+ else if (!this.uncertain) {
168
+ const previous = event.previousPath
169
+ ? this.hints.get(event.previousPath)
170
+ : this.hints.get(event.path);
171
+ const hint = {
172
+ ...event,
173
+ previousPath: previous?.previousPath ?? event.previousPath,
174
+ };
175
+ if (event.previousPath)
176
+ this.hints.delete(event.previousPath);
177
+ this.hints.set(event.path, hint);
178
+ const bytes = [...this.hints.values()].reduce((sum, value) => sum +
179
+ new TextEncoder().encode(value.path + (value.previousPath ?? ""))
180
+ .byteLength +
181
+ 96, 0);
182
+ if (event.path === "/" ||
183
+ !event.path ||
184
+ this.hints.size > 2048 ||
185
+ bytes > 128 * 1024) {
186
+ this.uncertain = true;
187
+ this.hints.clear();
188
+ }
189
+ }
190
+ this.schedule(generation);
191
+ }
192
+ drain(generation) {
193
+ if (this.processing)
194
+ return this.processing;
195
+ const run = async () => {
196
+ while (this.live(generation) && this.dirty) {
197
+ const hints = this.uncertain ? [] : [...this.hints.values()];
198
+ this.hints.clear();
199
+ this.dirty = false;
200
+ this.uncertain = false;
201
+ try {
202
+ await this.app.vault.reconcile(hints, () => this.live(generation), {
203
+ external: true,
204
+ });
205
+ while (this.projections.size)
206
+ await Promise.all([...this.projections]);
207
+ this.failures = 0;
208
+ }
209
+ catch (error) {
210
+ if (this.live(generation)) {
211
+ this.dirty = true;
212
+ this.uncertain = true;
213
+ this.report(error);
214
+ if (++this.failures <= 4)
215
+ this.schedule(generation, 250 * 2 ** (this.failures - 1));
216
+ }
217
+ throw error;
218
+ }
219
+ }
220
+ };
221
+ const pending = run().finally(() => {
222
+ if (this.processing === pending)
223
+ this.processing = undefined;
224
+ });
225
+ this.processing = pending;
226
+ return pending;
227
+ }
228
+ }