@procedures/store 0.0.0-stage → 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 (115) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/LICENSE +21 -0
  3. package/README.md +65 -2
  4. package/dist/.tsbuildinfo +1 -0
  5. package/dist/errors.d.ts +16 -0
  6. package/dist/errors.d.ts.map +1 -0
  7. package/dist/errors.js +18 -0
  8. package/dist/errors.js.map +1 -0
  9. package/dist/index.d.ts +19 -0
  10. package/dist/index.d.ts.map +1 -0
  11. package/dist/index.js +19 -0
  12. package/dist/index.js.map +1 -0
  13. package/dist/module.d.ts +23 -0
  14. package/dist/module.d.ts.map +1 -0
  15. package/dist/module.js +26 -0
  16. package/dist/module.js.map +1 -0
  17. package/dist/procedures.d.ts +13 -0
  18. package/dist/procedures.d.ts.map +1 -0
  19. package/dist/procedures.js +14 -0
  20. package/dist/procedures.js.map +1 -0
  21. package/dist/program-store/attach.d.ts +20 -0
  22. package/dist/program-store/attach.d.ts.map +1 -0
  23. package/dist/program-store/attach.js +36 -0
  24. package/dist/program-store/attach.js.map +1 -0
  25. package/dist/program-store/collection.d.ts +19 -0
  26. package/dist/program-store/collection.d.ts.map +1 -0
  27. package/dist/program-store/collection.js +34 -0
  28. package/dist/program-store/collection.js.map +1 -0
  29. package/dist/program-store/file.d.ts +29 -0
  30. package/dist/program-store/file.d.ts.map +1 -0
  31. package/dist/program-store/file.js +156 -0
  32. package/dist/program-store/file.js.map +1 -0
  33. package/dist/program-store/index.d.ts +11 -0
  34. package/dist/program-store/index.d.ts.map +1 -0
  35. package/dist/program-store/index.js +10 -0
  36. package/dist/program-store/index.js.map +1 -0
  37. package/dist/program-store/memory.d.ts +15 -0
  38. package/dist/program-store/memory.d.ts.map +1 -0
  39. package/dist/program-store/memory.js +25 -0
  40. package/dist/program-store/memory.js.map +1 -0
  41. package/dist/program-store/procedures.d.ts +23 -0
  42. package/dist/program-store/procedures.d.ts.map +1 -0
  43. package/dist/program-store/procedures.js +228 -0
  44. package/dist/program-store/procedures.js.map +1 -0
  45. package/dist/program-store/types.d.ts +50 -0
  46. package/dist/program-store/types.d.ts.map +1 -0
  47. package/dist/program-store/types.js +41 -0
  48. package/dist/program-store/types.js.map +1 -0
  49. package/dist/register.d.ts +7 -0
  50. package/dist/register.d.ts.map +1 -0
  51. package/dist/register.js +9 -0
  52. package/dist/register.js.map +1 -0
  53. package/dist/storage/adapter.d.ts +148 -0
  54. package/dist/storage/adapter.d.ts.map +1 -0
  55. package/dist/storage/adapter.js +214 -0
  56. package/dist/storage/adapter.js.map +1 -0
  57. package/dist/storage/api.d.ts +125 -0
  58. package/dist/storage/api.d.ts.map +1 -0
  59. package/dist/storage/api.js +220 -0
  60. package/dist/storage/api.js.map +1 -0
  61. package/dist/storage/factory.d.ts +134 -0
  62. package/dist/storage/factory.d.ts.map +1 -0
  63. package/dist/storage/factory.js +166 -0
  64. package/dist/storage/factory.js.map +1 -0
  65. package/dist/storage/hybrid.d.ts +193 -0
  66. package/dist/storage/hybrid.d.ts.map +1 -0
  67. package/dist/storage/hybrid.js +519 -0
  68. package/dist/storage/hybrid.js.map +1 -0
  69. package/dist/storage/index.d.ts +21 -0
  70. package/dist/storage/index.d.ts.map +1 -0
  71. package/dist/storage/index.js +20 -0
  72. package/dist/storage/index.js.map +1 -0
  73. package/dist/storage/procedures.d.ts +14 -0
  74. package/dist/storage/procedures.d.ts.map +1 -0
  75. package/dist/storage/procedures.js +101 -0
  76. package/dist/storage/procedures.js.map +1 -0
  77. package/dist/storage/serialization.d.ts +123 -0
  78. package/dist/storage/serialization.d.ts.map +1 -0
  79. package/dist/storage/serialization.js +216 -0
  80. package/dist/storage/serialization.js.map +1 -0
  81. package/dist/storage/synced-registry.d.ts +246 -0
  82. package/dist/storage/synced-registry.d.ts.map +1 -0
  83. package/dist/storage/synced-registry.js +575 -0
  84. package/dist/storage/synced-registry.js.map +1 -0
  85. package/dist/storage/types.d.ts +130 -0
  86. package/dist/storage/types.d.ts.map +1 -0
  87. package/dist/storage/types.js +8 -0
  88. package/dist/storage/types.js.map +1 -0
  89. package/dist/types.d.ts +7 -0
  90. package/dist/types.d.ts.map +1 -0
  91. package/dist/types.js +7 -0
  92. package/dist/types.js.map +1 -0
  93. package/package.json +78 -3
  94. package/src/errors.ts +20 -0
  95. package/src/index.ts +19 -0
  96. package/src/module.ts +30 -0
  97. package/src/procedures.ts +17 -0
  98. package/src/program-store/attach.ts +46 -0
  99. package/src/program-store/collection.ts +39 -0
  100. package/src/program-store/file.ts +157 -0
  101. package/src/program-store/index.ts +17 -0
  102. package/src/program-store/memory.ts +30 -0
  103. package/src/program-store/procedures.ts +351 -0
  104. package/src/program-store/types.ts +78 -0
  105. package/src/register.ts +11 -0
  106. package/src/storage/adapter.ts +273 -0
  107. package/src/storage/api.ts +335 -0
  108. package/src/storage/factory.ts +268 -0
  109. package/src/storage/hybrid.ts +676 -0
  110. package/src/storage/index.ts +61 -0
  111. package/src/storage/procedures.ts +149 -0
  112. package/src/storage/serialization.ts +283 -0
  113. package/src/storage/synced-registry.ts +698 -0
  114. package/src/storage/types.ts +180 -0
  115. package/src/types.ts +7 -0
@@ -0,0 +1,698 @@
1
+ /**
2
+ * Synced Procedure Registry
3
+ *
4
+ * Wraps ProcedureRegistry with storage synchronization capabilities.
5
+ * Enables procedure persistence and sync across backends.
6
+ */
7
+
8
+ import type { CollectionStorage } from "@procedures/collections";
9
+ import { ProcedureRegistry } from "@procedures/client";
10
+ import type { AnyProcedure, ProcedurePath, RegistrationOptions } from "@procedures/client";
11
+ import { pathToKey } from "@procedures/client";
12
+ import type {
13
+ SerializedProcedure,
14
+ SyncedRegistryOptions,
15
+ SyncResult,
16
+ SyncStatus,
17
+ SyncDirection,
18
+ SyncConflict,
19
+ HandlerLoader,
20
+ HandlerReference,
21
+ } from "./types.js";
22
+ import { ProcedureStorageAdapter } from "./adapter.js";
23
+ import { serializeProcedure, deserializeProcedure, getSerializedKey } from "./serialization.js";
24
+
25
+ // =============================================================================
26
+ // Extended Registration Options
27
+ // =============================================================================
28
+
29
+ /**
30
+ * Extended registration options with persistence flag.
31
+ */
32
+ export interface SyncedRegistrationOptions extends RegistrationOptions {
33
+ /** Whether to persist this procedure to storage (default: true) */
34
+ persist?: boolean | undefined;
35
+
36
+ /**
37
+ * The module and export of the handler. The stored record keeps it, so another process can
38
+ * load the handler (with an allowlisted handler loader) when it pulls the record.
39
+ */
40
+ handlerRef?: HandlerReference | undefined;
41
+ }
42
+
43
+ // =============================================================================
44
+ // The store of a registry
45
+ // =============================================================================
46
+
47
+ const STORES = new WeakMap<ProcedureRegistry, SyncedProcedureRegistry>();
48
+
49
+ /**
50
+ * The synced registry that wraps a registry, if one does. The `procedure.store`, `load`,
51
+ * `sync` and `remote` procedures use it.
52
+ *
53
+ * @param registry - A procedure registry
54
+ * @returns The synced registry of the newest wrapper that is not closed, or undefined
55
+ */
56
+ export function getProcedureStore(registry: ProcedureRegistry): SyncedProcedureRegistry | undefined {
57
+ return STORES.get(registry);
58
+ }
59
+
60
+ // =============================================================================
61
+ // Synced Procedure Registry
62
+ // =============================================================================
63
+
64
+ /**
65
+ * Procedure registry with storage synchronization.
66
+ *
67
+ * Wraps an existing ProcedureRegistry and adds:
68
+ * - Automatic persistence on register (write-through/write-back)
69
+ * - Sync from storage on demand
70
+ * - Conflict resolution
71
+ * - Connection management for remote storage
72
+ *
73
+ * A pull compares the `storedAt` of each record with the one of the last sync, and a local
74
+ * change counter for each path. A change on one side only is not a conflict: the changed side
75
+ * wins. A change on both sides (or a path with no sync yet) is a conflict, and the
76
+ * `conflictResolution` option decides. A record with no handler never replaces a procedure
77
+ * that has one: the procedure keeps its handler and takes the record's metadata.
78
+ *
79
+ * @example
80
+ * ```typescript
81
+ * const registry = new SyncedProcedureRegistry(
82
+ * PROCEDURE_REGISTRY,
83
+ * storageAdapter,
84
+ * {
85
+ * writeStrategy: 'write-through',
86
+ * conflictResolution: 'remote',
87
+ * }
88
+ * );
89
+ *
90
+ * // Sync from storage on startup
91
+ * await registry.syncFromStorage();
92
+ *
93
+ * // Register with auto-persistence
94
+ * registry.register(myProcedure, { persist: true });
95
+ *
96
+ * // Manual sync
97
+ * const result = await registry.sync('both');
98
+ * ```
99
+ */
100
+ export class SyncedProcedureRegistry {
101
+ private readonly baseRegistry: ProcedureRegistry;
102
+ private readonly adapter: ProcedureStorageAdapter;
103
+ private readonly options: SyncedRegistryOptions;
104
+
105
+ /** Pending changes for write-back strategy */
106
+ private pendingChanges: Map<string, AnyProcedure> = new Map();
107
+
108
+ /** Pending deletes for write-back strategy (deep dive DATA-8: they were never deleted) */
109
+ private pendingDeletes: Map<string, ProcedurePath> = new Map();
110
+
111
+ /** The handler reference of each path, for the stored records */
112
+ private readonly handlerRefs = new Map<string, HandlerReference>();
113
+
114
+ /** The `storedAt` of the record of each path at its last push or pull */
115
+ private readonly syncedAt = new Map<string, number>();
116
+
117
+ /** A counter of local changes for each path, and its value at the last push or pull */
118
+ private readonly localVersion = new Map<string, number>();
119
+ private readonly syncedVersion = new Map<string, number>();
120
+
121
+ /** Write-through stores that have not finished */
122
+ private readonly inFlight = new Set<Promise<unknown>>();
123
+
124
+ /** Whether currently connected to remote storage */
125
+ private connected = false;
126
+
127
+ /** Remote endpoint (if connected) */
128
+ private endpoint: string | undefined;
129
+
130
+ /** Last sync timestamp */
131
+ private lastSyncTime: number | undefined;
132
+
133
+ /** Whether currently syncing */
134
+ private syncing = false;
135
+
136
+ /** Auto-sync interval handle */
137
+ private syncIntervalHandle: ReturnType<typeof setInterval> | undefined;
138
+
139
+ constructor(
140
+ baseRegistry: ProcedureRegistry,
141
+ storage: CollectionStorage<SerializedProcedure>,
142
+ options: Partial<SyncedRegistryOptions> = {}
143
+ ) {
144
+ this.baseRegistry = baseRegistry;
145
+ this.adapter = new ProcedureStorageAdapter(storage, {
146
+ handlerLoader: options.handlerLoader,
147
+ });
148
+ this.options = {
149
+ writeStrategy: options.writeStrategy ?? "write-through",
150
+ conflictResolution: options.conflictResolution ?? "local",
151
+ syncInterval: options.syncInterval ?? 0,
152
+ handlerLoader: options.handlerLoader,
153
+ syncOnInit: options.syncOnInit ?? false,
154
+ };
155
+
156
+ // The storage procedures of this registry find this wrapper
157
+ STORES.set(baseRegistry, this);
158
+
159
+ // Set up auto-sync if configured
160
+ if (this.options.syncInterval && this.options.syncInterval > 0) {
161
+ this.startAutoSync(this.options.syncInterval);
162
+ }
163
+ }
164
+
165
+ // ===========================================================================
166
+ // Registry Methods (delegated to base registry)
167
+ // ===========================================================================
168
+
169
+ /**
170
+ * Register a procedure with optional persistence.
171
+ *
172
+ * The record has the path where the procedure is registered: with a `pathPrefix`, the
173
+ * prefixed path. (Before, the record had the path without the prefix: deep dive CORE-8.)
174
+ *
175
+ * @param procedure - Procedure to register
176
+ * @param options - Registration options including persist flag
177
+ */
178
+ register(procedure: AnyProcedure, options?: SyncedRegistrationOptions): void {
179
+ // Register in base registry
180
+ this.baseRegistry.register(procedure, options);
181
+
182
+ const path = options?.pathPrefix ? [...options.pathPrefix, ...procedure.path] : procedure.path;
183
+ const registered = this.baseRegistry.get(path) ?? { ...procedure, path };
184
+ const key = pathToKey(path);
185
+ if (options?.handlerRef) {
186
+ this.handlerRefs.set(key, options.handlerRef);
187
+ }
188
+ this.touch(key);
189
+
190
+ if (options?.persist === false) {
191
+ return;
192
+ }
193
+ this.pendingDeletes.delete(key);
194
+ if (this.options.writeStrategy === "write-through") {
195
+ // Write-through: persist now. flushWrites() waits for these writes.
196
+ this.track(
197
+ this.storeRecords([registered]).catch((err) => {
198
+ console.error(`Failed to persist procedure ${key}:`, err);
199
+ })
200
+ );
201
+ } else {
202
+ // Write-back: queue for later sync
203
+ this.pendingChanges.set(key, registered);
204
+ }
205
+ }
206
+
207
+ /**
208
+ * Unregister a procedure. Its record is deleted from storage: now (write-through), or at the
209
+ * next flush (write-back).
210
+ *
211
+ * @param path - Procedure path
212
+ * @returns True if procedure was removed
213
+ */
214
+ unregister(path: ProcedurePath): boolean {
215
+ const result = this.baseRegistry.unregister(path);
216
+
217
+ if (result) {
218
+ const key = pathToKey(path);
219
+ this.pendingChanges.delete(key);
220
+ this.touch(key);
221
+ if (this.options.writeStrategy === "write-through") {
222
+ this.track(
223
+ this.adapter.remove(path).catch((err) => {
224
+ console.error(`Failed to remove procedure ${key} from storage:`, err);
225
+ })
226
+ );
227
+ } else {
228
+ this.pendingDeletes.set(key, path);
229
+ }
230
+ }
231
+
232
+ return result;
233
+ }
234
+
235
+ /**
236
+ * Get a procedure by path.
237
+ */
238
+ get(path: ProcedurePath): AnyProcedure | undefined {
239
+ return this.baseRegistry.get(path);
240
+ }
241
+
242
+ /**
243
+ * Check if a procedure exists.
244
+ */
245
+ has(path: ProcedurePath): boolean {
246
+ return this.baseRegistry.has(path);
247
+ }
248
+
249
+ /**
250
+ * Get all registered procedures.
251
+ */
252
+ getAll(): AnyProcedure[] {
253
+ return this.baseRegistry.getAll();
254
+ }
255
+
256
+ /**
257
+ * Get procedures by prefix.
258
+ */
259
+ getByPrefix(prefix: ProcedurePath): AnyProcedure[] {
260
+ return this.baseRegistry.getByPrefix(prefix);
261
+ }
262
+
263
+ /**
264
+ * Get count of registered procedures.
265
+ */
266
+ get size(): number {
267
+ return this.baseRegistry.size;
268
+ }
269
+
270
+ // ===========================================================================
271
+ // Sync Operations
272
+ // ===========================================================================
273
+
274
+ /**
275
+ * Sync from storage to registry.
276
+ * Loads procedures from storage and merges with registry.
277
+ *
278
+ * @param filter - Pull only the records for which this function gives true (default: all)
279
+ * @returns Sync result with counts and conflicts
280
+ */
281
+ async syncFromStorage(filter?: (record: SerializedProcedure) => boolean): Promise<SyncResult> {
282
+ this.syncing = true;
283
+ const conflicts: SyncConflict[] = [];
284
+ let pulled = 0;
285
+
286
+ try {
287
+ const records = (await this.adapter.getAllRaw()).filter((record) => !filter || filter(record));
288
+
289
+ for (const record of records) {
290
+ const key = getSerializedKey(record);
291
+ const remote = await deserializeProcedure(record, { handlerLoader: this.options.handlerLoader });
292
+ if (record.handlerRef) {
293
+ this.handlerRefs.set(key, record.handlerRef);
294
+ }
295
+ const existing = this.baseRegistry.get(record.path);
296
+
297
+ if (!existing) {
298
+ this.baseRegistry.register(remote, { override: false });
299
+ this.markSynced(key, record);
300
+ pulled++;
301
+ continue;
302
+ }
303
+
304
+ const known = this.syncedAt.get(key);
305
+ const remoteChanged = known === undefined || (record.storedAt ?? 0) !== known;
306
+ const localChanged = known === undefined || this.isDirty(key);
307
+
308
+ if (!remoteChanged) {
309
+ continue;
310
+ }
311
+ if (!localChanged) {
312
+ // Only the remote changed: it wins, and it is not a conflict
313
+ this.baseRegistry.register(this.keepHandler(existing, remote), { override: true });
314
+ this.markSynced(key, record);
315
+ pulled++;
316
+ continue;
317
+ }
318
+
319
+ const conflict = this.resolveConflict(record.path, existing, remote);
320
+ if (conflict) {
321
+ conflicts.push(conflict);
322
+ }
323
+ if (conflict?.resolution !== "local") {
324
+ this.markSynced(key, record);
325
+ } else {
326
+ // The local version stays, and the next push stores it
327
+ this.syncedAt.set(key, record.storedAt ?? 0);
328
+ }
329
+ }
330
+
331
+ this.lastSyncTime = Date.now();
332
+ return {
333
+ pushed: 0,
334
+ pulled,
335
+ conflicts,
336
+ timestamp: this.lastSyncTime,
337
+ };
338
+ } finally {
339
+ this.syncing = false;
340
+ }
341
+ }
342
+
343
+ /**
344
+ * Sync from registry to storage.
345
+ * Persists all registered procedures to storage, each with its handler reference.
346
+ *
347
+ * @returns Sync result
348
+ */
349
+ async syncToStorage(): Promise<SyncResult> {
350
+ this.syncing = true;
351
+
352
+ try {
353
+ const procedures = this.baseRegistry.getAll();
354
+ await this.storeRecords(procedures);
355
+ await this.removeRecords([...this.pendingDeletes.values()]);
356
+
357
+ // Clear pending changes
358
+ this.pendingChanges.clear();
359
+ this.pendingDeletes.clear();
360
+
361
+ this.lastSyncTime = Date.now();
362
+ return {
363
+ pushed: procedures.length,
364
+ pulled: 0,
365
+ conflicts: [],
366
+ timestamp: this.lastSyncTime,
367
+ };
368
+ } finally {
369
+ this.syncing = false;
370
+ }
371
+ }
372
+
373
+ /**
374
+ * Full bidirectional sync.
375
+ *
376
+ * @param direction - Sync direction (push/pull/both)
377
+ * @returns Sync result
378
+ */
379
+ async sync(direction: SyncDirection = "both"): Promise<SyncResult> {
380
+ const results: SyncResult = {
381
+ pushed: 0,
382
+ pulled: 0,
383
+ conflicts: [],
384
+ timestamp: Date.now(),
385
+ };
386
+
387
+ if (direction === "pull" || direction === "both") {
388
+ const pullResult = await this.syncFromStorage();
389
+ results.pulled = pullResult.pulled;
390
+ results.conflicts.push(...pullResult.conflicts);
391
+ }
392
+
393
+ if (direction === "push" || direction === "both") {
394
+ const pushResult = await this.syncToStorage();
395
+ results.pushed = pushResult.pushed;
396
+ }
397
+
398
+ this.lastSyncTime = results.timestamp;
399
+ return results;
400
+ }
401
+
402
+ /**
403
+ * Flush pending changes to storage: the queued writes and deletes of the write-back strategy.
404
+ *
405
+ * @returns The number of records written or deleted
406
+ */
407
+ async flushPending(): Promise<number> {
408
+ await this.flushWrites();
409
+ const procedures = Array.from(this.pendingChanges.values());
410
+ const deletes = Array.from(this.pendingDeletes.values());
411
+ if (procedures.length === 0 && deletes.length === 0) return 0;
412
+
413
+ if (procedures.length > 0) {
414
+ await this.storeRecords(procedures);
415
+ }
416
+ await this.removeRecords(deletes);
417
+
418
+ this.pendingChanges.clear();
419
+ this.pendingDeletes.clear();
420
+ return procedures.length + deletes.length;
421
+ }
422
+
423
+ /**
424
+ * Store the registered procedure at a path now, whatever the write strategy.
425
+ *
426
+ * @param path - Procedure path
427
+ * @param options - A handler reference for the record
428
+ * @returns The stored record, or undefined when no procedure is registered at the path
429
+ */
430
+ async storeProcedure(
431
+ path: ProcedurePath,
432
+ options: { handlerRef?: HandlerReference | undefined } = {}
433
+ ): Promise<SerializedProcedure | undefined> {
434
+ const procedure = this.baseRegistry.get(path);
435
+ if (!procedure) return undefined;
436
+ const key = pathToKey(path);
437
+ if (options.handlerRef) {
438
+ this.handlerRefs.set(key, options.handlerRef);
439
+ }
440
+ await this.storeRecords([procedure]);
441
+ this.pendingChanges.delete(key);
442
+ this.pendingDeletes.delete(key);
443
+ return this.adapter.getRaw(path);
444
+ }
445
+
446
+ /**
447
+ * Wait for the write-through writes that have started.
448
+ */
449
+ async flushWrites(): Promise<void> {
450
+ while (this.inFlight.size > 0) {
451
+ await Promise.all([...this.inFlight]);
452
+ }
453
+ }
454
+
455
+ // ===========================================================================
456
+ // Connection Management
457
+ // ===========================================================================
458
+
459
+ /**
460
+ * Record a remote endpoint, and optionally pull from storage. This method does not change the
461
+ * storage: give the registry an `ApiStorage` for the endpoint when you make it.
462
+ *
463
+ * @param endpoint - Remote endpoint URL
464
+ * @param options - Connection options
465
+ */
466
+ async connect(
467
+ endpoint: string,
468
+ options?: { syncOnConnect?: boolean }
469
+ ): Promise<void> {
470
+ this.endpoint = endpoint;
471
+ this.connected = true;
472
+
473
+ if (options?.syncOnConnect ?? this.options.syncOnInit) {
474
+ await this.syncFromStorage();
475
+ }
476
+ }
477
+
478
+ /**
479
+ * Disconnect from remote storage.
480
+ */
481
+ disconnect(): void {
482
+ this.connected = false;
483
+ this.endpoint = undefined;
484
+ this.stopAutoSync();
485
+ }
486
+
487
+ /**
488
+ * Get current sync status.
489
+ */
490
+ getStatus(): SyncStatus {
491
+ return {
492
+ connected: this.connected,
493
+ endpoint: this.endpoint,
494
+ lastSync: this.lastSyncTime,
495
+ pendingChanges: this.pendingChanges.size + this.pendingDeletes.size,
496
+ syncing: this.syncing,
497
+ };
498
+ }
499
+
500
+ // ===========================================================================
501
+ // Auto-Sync
502
+ // ===========================================================================
503
+
504
+ /**
505
+ * Start auto-sync at specified interval.
506
+ *
507
+ * @param intervalMs - Sync interval in milliseconds
508
+ */
509
+ startAutoSync(intervalMs: number): void {
510
+ this.stopAutoSync();
511
+ const handle = setInterval(() => {
512
+ void this.sync("both").catch((err) => {
513
+ console.error("Auto-sync failed:", err);
514
+ });
515
+ }, intervalMs);
516
+ // Don't let the auto-sync timer keep the process alive. See BUGS-2026-07.md (M4).
517
+ handle.unref?.();
518
+ this.syncIntervalHandle = handle;
519
+ }
520
+
521
+ /**
522
+ * Stop auto-sync.
523
+ */
524
+ stopAutoSync(): void {
525
+ if (this.syncIntervalHandle) {
526
+ clearInterval(this.syncIntervalHandle);
527
+ this.syncIntervalHandle = undefined;
528
+ }
529
+ }
530
+
531
+ // ===========================================================================
532
+ // Conflict Resolution
533
+ // ===========================================================================
534
+
535
+ /**
536
+ * Resolve a conflict between local and remote procedures.
537
+ *
538
+ * @param path - Procedure path
539
+ * @param local - Local procedure
540
+ * @param remote - Remote procedure
541
+ * @returns Conflict info if not automatically resolved
542
+ */
543
+ private resolveConflict(
544
+ path: ProcedurePath,
545
+ local: AnyProcedure,
546
+ remote: AnyProcedure
547
+ ): SyncConflict | undefined {
548
+ switch (this.options.conflictResolution) {
549
+ case "local":
550
+ // Keep local, ignore remote
551
+ return {
552
+ path,
553
+ resolution: "local",
554
+ local: serializeProcedure(local),
555
+ remote: serializeProcedure(remote),
556
+ };
557
+
558
+ case "remote":
559
+ // Replace with remote, but never replace a handler with a stub (deep dive CORE-8)
560
+ this.baseRegistry.register(this.keepHandler(local, remote), { override: true });
561
+ return {
562
+ path,
563
+ resolution: "remote",
564
+ local: serializeProcedure(local),
565
+ remote: serializeProcedure(remote),
566
+ };
567
+
568
+ case "error":
569
+ // Throw error on conflict
570
+ throw new Error(`Sync conflict at ${pathToKey(path)}`);
571
+
572
+ case "merge": {
573
+ // Merge metadata, keep local handler
574
+ const merged: AnyProcedure = {
575
+ ...local,
576
+ metadata: { ...remote.metadata, ...local.metadata },
577
+ };
578
+ this.baseRegistry.register(merged, { override: true });
579
+ return {
580
+ path,
581
+ resolution: "merge",
582
+ local: serializeProcedure(local),
583
+ remote: serializeProcedure(remote),
584
+ };
585
+ }
586
+
587
+ default:
588
+ return undefined;
589
+ }
590
+ }
591
+
592
+ /**
593
+ * The remote version of a procedure. When the remote record gives no handler (a stub), the
594
+ * local handler and schemas stay, and the remote metadata applies.
595
+ */
596
+ private keepHandler(local: AnyProcedure, remote: AnyProcedure): AnyProcedure {
597
+ if (remote.handler || !local.handler) {
598
+ return remote;
599
+ }
600
+ const merged: AnyProcedure = { ...local, metadata: { ...remote.metadata } };
601
+ if (remote.streaming !== undefined) {
602
+ merged.streaming = remote.streaming;
603
+ }
604
+ return merged;
605
+ }
606
+
607
+ // ===========================================================================
608
+ // Change tracking
609
+ // ===========================================================================
610
+
611
+ /** Record a local change of a path. */
612
+ private touch(key: string): void {
613
+ this.localVersion.set(key, (this.localVersion.get(key) ?? 0) + 1);
614
+ }
615
+
616
+ /** True when the path changed locally since its last push or pull. */
617
+ private isDirty(key: string): boolean {
618
+ return (this.localVersion.get(key) ?? 0) !== (this.syncedVersion.get(key) ?? 0);
619
+ }
620
+
621
+ /** Record that storage and the registry agree on a path. */
622
+ private markSynced(key: string, record: SerializedProcedure, version = this.localVersion.get(key) ?? 0): void {
623
+ this.syncedAt.set(key, record.storedAt ?? 0);
624
+ this.syncedVersion.set(key, version);
625
+ }
626
+
627
+ /** Store records for procedures, each with its handler reference, and mark them synced. */
628
+ private async storeRecords(procedures: AnyProcedure[]): Promise<void> {
629
+ // The local versions at the start: a change during the write stays dirty
630
+ const versions = procedures.map((procedure) => this.localVersion.get(pathToKey(procedure.path)) ?? 0);
631
+ const records = await this.adapter.storeAll(procedures, (procedure) => {
632
+ const handlerRef = this.handlerRefs.get(pathToKey(procedure.path));
633
+ return handlerRef ? { handlerRef } : undefined;
634
+ });
635
+ records.forEach((record, index) => this.markSynced(getSerializedKey(record), record, versions[index]));
636
+ }
637
+
638
+ /** Delete records, and forget their sync state. */
639
+ private async removeRecords(paths: ProcedurePath[]): Promise<void> {
640
+ if (paths.length === 0) return;
641
+ await this.adapter.removeAll(paths);
642
+ for (const path of paths) {
643
+ const key = pathToKey(path);
644
+ this.syncedAt.delete(key);
645
+ this.syncedVersion.set(key, this.localVersion.get(key) ?? 0);
646
+ }
647
+ }
648
+
649
+ private track(write: Promise<unknown>): void {
650
+ this.inFlight.add(write);
651
+ void write.finally(() => this.inFlight.delete(write));
652
+ }
653
+
654
+ // ===========================================================================
655
+ // Access to Underlying Components
656
+ // ===========================================================================
657
+
658
+ /**
659
+ * Get the base registry.
660
+ */
661
+ getBaseRegistry(): ProcedureRegistry {
662
+ return this.baseRegistry;
663
+ }
664
+
665
+ /**
666
+ * Get the storage adapter.
667
+ */
668
+ getAdapter(): ProcedureStorageAdapter {
669
+ return this.adapter;
670
+ }
671
+
672
+ /**
673
+ * Get the handler loader.
674
+ */
675
+ getHandlerLoader(): HandlerLoader | undefined {
676
+ return this.options.handlerLoader;
677
+ }
678
+
679
+ // ===========================================================================
680
+ // Lifecycle
681
+ // ===========================================================================
682
+
683
+ /**
684
+ * Close the synced registry and cleanup resources.
685
+ */
686
+ async close(): Promise<void> {
687
+ this.stopAutoSync();
688
+
689
+ // Flush pending changes before closing
690
+ await this.flushPending();
691
+
692
+ await this.adapter.close();
693
+ this.connected = false;
694
+ if (STORES.get(this.baseRegistry) === this) {
695
+ STORES.delete(this.baseRegistry);
696
+ }
697
+ }
698
+ }