zen-fs-config 0.3.25 → 0.3.27

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.mts CHANGED
@@ -29,6 +29,17 @@ interface VersionMeta {
29
29
  /** Timestamp when the version was created. */
30
30
  timestamp: number;
31
31
  }
32
+ /** Content of a tombstone file in `.meta/.deleted/`. */
33
+ interface TombstoneMeta {
34
+ /** The deleted file path. */
35
+ path: string;
36
+ /** Timestamp of deletion. */
37
+ deletedAt: number;
38
+ /** Backend ID that initiated the deletion. */
39
+ deletedBy: string;
40
+ /** Backend IDs that have confirmed the deletion (synced). */
41
+ confirmedBy: string[];
42
+ }
32
43
  /** Content of a conflict archive file in `.meta/.conflicts/`. */
33
44
  interface ConflictArchive {
34
45
  /** The config file path that conflicted. */
@@ -146,6 +157,12 @@ interface IConfigRepo {
146
157
  getBackends(): Promise<BackendsMeta | null>;
147
158
  /** Write .meta/backends.json. */
148
159
  updateBackends(meta: BackendsMeta): Promise<void>;
160
+ /**
161
+ * Delete a file and record a tombstone for cross-backend sync.
162
+ * The tombstone ensures the deletion propagates to all backends
163
+ * instead of being treated as a "missing file" that gets re-created.
164
+ */
165
+ deleteFile(path: string): Promise<void>;
149
166
  /**
150
167
  * Sync .meta/ files (backends.json) to all replica backends.
151
168
  * Called automatically by createConfigRepo() after setupSync().
@@ -191,21 +208,54 @@ declare function configKeyToFilePath(configPath: string): string;
191
208
  declare function getExtension(path: string): string;
192
209
 
193
210
  /**
194
- * zen-fs-config — ConfigRepo Implementation
211
+ * zen-fs-config — Backend Registry
195
212
  *
196
- * Core implementation of IConfigRepo and the createConfigRepo factory.
213
+ * A pluggable registry that maps backend type names to factory functions.
214
+ *
215
+ * Core principle: zen-fs-config does NOT hardcode every ZenFS backend.
216
+ * Instead, it provides:
217
+ * 1. A simple registry API (registerBackend, createBackend, etc.)
218
+ * 2. One built-in backend (InMemory) — zero extra dependencies
219
+ * 3. A wrapZenFSFileSystem() helper to adapt any ZenFS FileSystem
220
+ * implementation into the BackendInstance interface
221
+ *
222
+ * Applications (like zen-fs-config-admin) register whatever backends
223
+ * they need at startup. Adding a new backend never requires changing
224
+ * zen-fs-config itself.
197
225
  */
198
226
 
199
- interface MinimalAsyncFS {
227
+ type BackendFactory = (options: Record<string, unknown>) => Promise<BackendInstance>;
228
+ /**
229
+ * The minimal interface a backend instance must satisfy.
230
+ * Matches zen-fs-cache's CacheableFileSystem requirements.
231
+ */
232
+ interface BackendInstance {
200
233
  readFile(path: string, ...args: any[]): Promise<any>;
201
- writeFile(path: string, data: any, options?: any): Promise<void>;
234
+ writeFile(path: string, data: string | Uint8Array | ArrayBuffer, options?: any): Promise<void>;
202
235
  readdir(path: string): Promise<string[]>;
203
- stat(path: string): Promise<any>;
236
+ stat(path: string, ...args: any[]): Promise<any>;
204
237
  exists(path: string): Promise<boolean>;
205
238
  mkdir(path: string, options?: any): Promise<any>;
206
239
  unlink(path: string): Promise<void>;
207
- rmdir?(path: string): Promise<void>;
240
+ rmdir(path: string): Promise<void>;
208
241
  rename?(oldPath: string, newPath: string): Promise<void>;
242
+ readFileMeta?(path: string, opts?: any): Promise<any>;
243
+ getRevision?(path: string): Promise<string | number | undefined>;
244
+ }
245
+ declare function registerBackend(type: string, factory: BackendFactory): void;
246
+ declare function unregisterBackend(type: string): boolean;
247
+ declare function createBackend(descriptor: Pick<BackendDescriptor, 'type' | 'options'>): Promise<BackendInstance>;
248
+ declare function hasBackend(type: string): boolean;
249
+ declare function listBackends(): string[];
250
+ declare function wrapZenFSFileSystem(config: any): Promise<BackendInstance>;
251
+
252
+ /**
253
+ * zen-fs-config — ConfigRepo Implementation
254
+ *
255
+ * Core implementation of IConfigRepo and the createConfigRepo factory.
256
+ */
257
+
258
+ interface MinimalAsyncFS extends BackendInstance {
209
259
  }
210
260
  declare class ConfigRepo implements IConfigRepo {
211
261
  readonly appId: string;
@@ -222,6 +272,7 @@ declare class ConfigRepo implements IConfigRepo {
222
272
  private onConflictCallback?;
223
273
  private disposed;
224
274
  private configCache;
275
+ private readonly primaryBackendId;
225
276
  constructor(appId: string, nodeId: string, primaryBackendId: string, cachedFS: MinimalAsyncFS, serializer: PathAwareSerializer, onConflict?: (conflict: ConflictInfo) => Promise<unknown | null>);
226
277
  /** Full path to this node's directory on the primary backend. */
227
278
  get nodePath(): string;
@@ -235,6 +286,28 @@ declare class ConfigRepo implements IConfigRepo {
235
286
  }): Promise<SyncResult>;
236
287
  peekNodeConfig<T = unknown>(nodeId: string, path: string): Promise<T>;
237
288
  flush(): Promise<SyncResult[]>;
289
+ /**
290
+ * Delete a file and write a tombstone so the deletion propagates
291
+ * to all backends instead of being treated as "missing file → re-create".
292
+ */
293
+ deleteFile(path: string): Promise<void>;
294
+ /**
295
+ * Read all tombstones from the primary backend.
296
+ */
297
+ private readTombstones;
298
+ /**
299
+ * Before sync: for each tombstone, delete the actual file on all replicas.
300
+ * This prevents bi-directional sync from copying the file back.
301
+ */
302
+ private processTombstones;
303
+ /**
304
+ * After sync: mark each tombstone as confirmed by all replica backends.
305
+ */
306
+ private updateTombstoneConfirmations;
307
+ /**
308
+ * GC: remove tombstones where all backends in backends.json have confirmed.
309
+ */
310
+ private gcTombstones;
238
311
  /**
239
312
  * Sync .meta/ files (backends.json) to all replica backends.
240
313
  *
@@ -264,48 +337,6 @@ declare class ConfigRepo implements IConfigRepo {
264
337
  }
265
338
  declare function createConfigRepo(appId: string, options: ConfigRepoOptions): Promise<IConfigRepo>;
266
339
 
267
- /**
268
- * zen-fs-config — Backend Registry
269
- *
270
- * A pluggable registry that maps backend type names to factory functions.
271
- *
272
- * Core principle: zen-fs-config does NOT hardcode every ZenFS backend.
273
- * Instead, it provides:
274
- * 1. A simple registry API (registerBackend, createBackend, etc.)
275
- * 2. One built-in backend (InMemory) — zero extra dependencies
276
- * 3. A wrapZenFSFileSystem() helper to adapt any ZenFS FileSystem
277
- * implementation into the BackendInstance interface
278
- *
279
- * Applications (like zen-fs-config-admin) register whatever backends
280
- * they need at startup. Adding a new backend never requires changing
281
- * zen-fs-config itself.
282
- */
283
-
284
- type BackendFactory = (options: Record<string, unknown>) => Promise<BackendInstance>;
285
- /**
286
- * The minimal interface a backend instance must satisfy.
287
- * Matches zen-fs-cache's CacheableFileSystem requirements.
288
- */
289
- interface BackendInstance {
290
- readFile(path: string, ...args: any[]): Promise<any>;
291
- writeFile(path: string, data: string | Uint8Array | ArrayBuffer, options?: any): Promise<void>;
292
- readdir(path: string): Promise<string[]>;
293
- stat(path: string, ...args: any[]): Promise<any>;
294
- exists(path: string): Promise<boolean>;
295
- mkdir(path: string, options?: any): Promise<any>;
296
- unlink(path: string): Promise<void>;
297
- rmdir(path: string): Promise<void>;
298
- rename?(oldPath: string, newPath: string): Promise<void>;
299
- readFileMeta?(path: string, opts?: any): Promise<any>;
300
- getRevision?(path: string): Promise<string | number | undefined>;
301
- }
302
- declare function registerBackend(type: string, factory: BackendFactory): void;
303
- declare function unregisterBackend(type: string): boolean;
304
- declare function createBackend(descriptor: Pick<BackendDescriptor, 'type' | 'options'>): Promise<BackendInstance>;
305
- declare function hasBackend(type: string): boolean;
306
- declare function listBackends(): string[];
307
- declare function wrapZenFSFileSystem(config: any): Promise<BackendInstance>;
308
-
309
340
  /**
310
341
  * zen-fs-config — Sidecar Version File Management
311
342
  *
@@ -348,4 +379,4 @@ declare function incrementVersion(fs: SyncableFS, configFilePath: string, newCon
348
379
  */
349
380
  declare function verifyOrRepairVersion(fs: SyncableFS, configFilePath: string, author: string): Promise<VersionMeta | null>;
350
381
 
351
- export { type BackendDescriptor, type BackendFactory, type BackendInstance, type BackendsMeta, type CacheOptions, ConfigRepo, type ConfigRepoOptions, type ConfigSerializer, type ConflictArchive, type ConflictInfo, type IConfigRepo, type VersionMeta, configKeyToFilePath, createBackend, createConfigRepo, createSerializerChain, getExtension, hasBackend, incrementVersion, listBackends, readVersion, registerBackend, sha256, unregisterBackend, verifyOrRepairVersion, versionPathFor, wrapZenFSFileSystem, writeVersion };
382
+ export { type BackendDescriptor, type BackendFactory, type BackendInstance, type BackendsMeta, type CacheOptions, ConfigRepo, type ConfigRepoOptions, type ConfigSerializer, type ConflictArchive, type ConflictInfo, type IConfigRepo, type TombstoneMeta, type VersionMeta, configKeyToFilePath, createBackend, createConfigRepo, createSerializerChain, getExtension, hasBackend, incrementVersion, listBackends, readVersion, registerBackend, sha256, unregisterBackend, verifyOrRepairVersion, versionPathFor, wrapZenFSFileSystem, writeVersion };
package/dist/index.d.ts CHANGED
@@ -29,6 +29,17 @@ interface VersionMeta {
29
29
  /** Timestamp when the version was created. */
30
30
  timestamp: number;
31
31
  }
32
+ /** Content of a tombstone file in `.meta/.deleted/`. */
33
+ interface TombstoneMeta {
34
+ /** The deleted file path. */
35
+ path: string;
36
+ /** Timestamp of deletion. */
37
+ deletedAt: number;
38
+ /** Backend ID that initiated the deletion. */
39
+ deletedBy: string;
40
+ /** Backend IDs that have confirmed the deletion (synced). */
41
+ confirmedBy: string[];
42
+ }
32
43
  /** Content of a conflict archive file in `.meta/.conflicts/`. */
33
44
  interface ConflictArchive {
34
45
  /** The config file path that conflicted. */
@@ -146,6 +157,12 @@ interface IConfigRepo {
146
157
  getBackends(): Promise<BackendsMeta | null>;
147
158
  /** Write .meta/backends.json. */
148
159
  updateBackends(meta: BackendsMeta): Promise<void>;
160
+ /**
161
+ * Delete a file and record a tombstone for cross-backend sync.
162
+ * The tombstone ensures the deletion propagates to all backends
163
+ * instead of being treated as a "missing file" that gets re-created.
164
+ */
165
+ deleteFile(path: string): Promise<void>;
149
166
  /**
150
167
  * Sync .meta/ files (backends.json) to all replica backends.
151
168
  * Called automatically by createConfigRepo() after setupSync().
@@ -191,21 +208,54 @@ declare function configKeyToFilePath(configPath: string): string;
191
208
  declare function getExtension(path: string): string;
192
209
 
193
210
  /**
194
- * zen-fs-config — ConfigRepo Implementation
211
+ * zen-fs-config — Backend Registry
195
212
  *
196
- * Core implementation of IConfigRepo and the createConfigRepo factory.
213
+ * A pluggable registry that maps backend type names to factory functions.
214
+ *
215
+ * Core principle: zen-fs-config does NOT hardcode every ZenFS backend.
216
+ * Instead, it provides:
217
+ * 1. A simple registry API (registerBackend, createBackend, etc.)
218
+ * 2. One built-in backend (InMemory) — zero extra dependencies
219
+ * 3. A wrapZenFSFileSystem() helper to adapt any ZenFS FileSystem
220
+ * implementation into the BackendInstance interface
221
+ *
222
+ * Applications (like zen-fs-config-admin) register whatever backends
223
+ * they need at startup. Adding a new backend never requires changing
224
+ * zen-fs-config itself.
197
225
  */
198
226
 
199
- interface MinimalAsyncFS {
227
+ type BackendFactory = (options: Record<string, unknown>) => Promise<BackendInstance>;
228
+ /**
229
+ * The minimal interface a backend instance must satisfy.
230
+ * Matches zen-fs-cache's CacheableFileSystem requirements.
231
+ */
232
+ interface BackendInstance {
200
233
  readFile(path: string, ...args: any[]): Promise<any>;
201
- writeFile(path: string, data: any, options?: any): Promise<void>;
234
+ writeFile(path: string, data: string | Uint8Array | ArrayBuffer, options?: any): Promise<void>;
202
235
  readdir(path: string): Promise<string[]>;
203
- stat(path: string): Promise<any>;
236
+ stat(path: string, ...args: any[]): Promise<any>;
204
237
  exists(path: string): Promise<boolean>;
205
238
  mkdir(path: string, options?: any): Promise<any>;
206
239
  unlink(path: string): Promise<void>;
207
- rmdir?(path: string): Promise<void>;
240
+ rmdir(path: string): Promise<void>;
208
241
  rename?(oldPath: string, newPath: string): Promise<void>;
242
+ readFileMeta?(path: string, opts?: any): Promise<any>;
243
+ getRevision?(path: string): Promise<string | number | undefined>;
244
+ }
245
+ declare function registerBackend(type: string, factory: BackendFactory): void;
246
+ declare function unregisterBackend(type: string): boolean;
247
+ declare function createBackend(descriptor: Pick<BackendDescriptor, 'type' | 'options'>): Promise<BackendInstance>;
248
+ declare function hasBackend(type: string): boolean;
249
+ declare function listBackends(): string[];
250
+ declare function wrapZenFSFileSystem(config: any): Promise<BackendInstance>;
251
+
252
+ /**
253
+ * zen-fs-config — ConfigRepo Implementation
254
+ *
255
+ * Core implementation of IConfigRepo and the createConfigRepo factory.
256
+ */
257
+
258
+ interface MinimalAsyncFS extends BackendInstance {
209
259
  }
210
260
  declare class ConfigRepo implements IConfigRepo {
211
261
  readonly appId: string;
@@ -222,6 +272,7 @@ declare class ConfigRepo implements IConfigRepo {
222
272
  private onConflictCallback?;
223
273
  private disposed;
224
274
  private configCache;
275
+ private readonly primaryBackendId;
225
276
  constructor(appId: string, nodeId: string, primaryBackendId: string, cachedFS: MinimalAsyncFS, serializer: PathAwareSerializer, onConflict?: (conflict: ConflictInfo) => Promise<unknown | null>);
226
277
  /** Full path to this node's directory on the primary backend. */
227
278
  get nodePath(): string;
@@ -235,6 +286,28 @@ declare class ConfigRepo implements IConfigRepo {
235
286
  }): Promise<SyncResult>;
236
287
  peekNodeConfig<T = unknown>(nodeId: string, path: string): Promise<T>;
237
288
  flush(): Promise<SyncResult[]>;
289
+ /**
290
+ * Delete a file and write a tombstone so the deletion propagates
291
+ * to all backends instead of being treated as "missing file → re-create".
292
+ */
293
+ deleteFile(path: string): Promise<void>;
294
+ /**
295
+ * Read all tombstones from the primary backend.
296
+ */
297
+ private readTombstones;
298
+ /**
299
+ * Before sync: for each tombstone, delete the actual file on all replicas.
300
+ * This prevents bi-directional sync from copying the file back.
301
+ */
302
+ private processTombstones;
303
+ /**
304
+ * After sync: mark each tombstone as confirmed by all replica backends.
305
+ */
306
+ private updateTombstoneConfirmations;
307
+ /**
308
+ * GC: remove tombstones where all backends in backends.json have confirmed.
309
+ */
310
+ private gcTombstones;
238
311
  /**
239
312
  * Sync .meta/ files (backends.json) to all replica backends.
240
313
  *
@@ -264,48 +337,6 @@ declare class ConfigRepo implements IConfigRepo {
264
337
  }
265
338
  declare function createConfigRepo(appId: string, options: ConfigRepoOptions): Promise<IConfigRepo>;
266
339
 
267
- /**
268
- * zen-fs-config — Backend Registry
269
- *
270
- * A pluggable registry that maps backend type names to factory functions.
271
- *
272
- * Core principle: zen-fs-config does NOT hardcode every ZenFS backend.
273
- * Instead, it provides:
274
- * 1. A simple registry API (registerBackend, createBackend, etc.)
275
- * 2. One built-in backend (InMemory) — zero extra dependencies
276
- * 3. A wrapZenFSFileSystem() helper to adapt any ZenFS FileSystem
277
- * implementation into the BackendInstance interface
278
- *
279
- * Applications (like zen-fs-config-admin) register whatever backends
280
- * they need at startup. Adding a new backend never requires changing
281
- * zen-fs-config itself.
282
- */
283
-
284
- type BackendFactory = (options: Record<string, unknown>) => Promise<BackendInstance>;
285
- /**
286
- * The minimal interface a backend instance must satisfy.
287
- * Matches zen-fs-cache's CacheableFileSystem requirements.
288
- */
289
- interface BackendInstance {
290
- readFile(path: string, ...args: any[]): Promise<any>;
291
- writeFile(path: string, data: string | Uint8Array | ArrayBuffer, options?: any): Promise<void>;
292
- readdir(path: string): Promise<string[]>;
293
- stat(path: string, ...args: any[]): Promise<any>;
294
- exists(path: string): Promise<boolean>;
295
- mkdir(path: string, options?: any): Promise<any>;
296
- unlink(path: string): Promise<void>;
297
- rmdir(path: string): Promise<void>;
298
- rename?(oldPath: string, newPath: string): Promise<void>;
299
- readFileMeta?(path: string, opts?: any): Promise<any>;
300
- getRevision?(path: string): Promise<string | number | undefined>;
301
- }
302
- declare function registerBackend(type: string, factory: BackendFactory): void;
303
- declare function unregisterBackend(type: string): boolean;
304
- declare function createBackend(descriptor: Pick<BackendDescriptor, 'type' | 'options'>): Promise<BackendInstance>;
305
- declare function hasBackend(type: string): boolean;
306
- declare function listBackends(): string[];
307
- declare function wrapZenFSFileSystem(config: any): Promise<BackendInstance>;
308
-
309
340
  /**
310
341
  * zen-fs-config — Sidecar Version File Management
311
342
  *
@@ -348,4 +379,4 @@ declare function incrementVersion(fs: SyncableFS, configFilePath: string, newCon
348
379
  */
349
380
  declare function verifyOrRepairVersion(fs: SyncableFS, configFilePath: string, author: string): Promise<VersionMeta | null>;
350
381
 
351
- export { type BackendDescriptor, type BackendFactory, type BackendInstance, type BackendsMeta, type CacheOptions, ConfigRepo, type ConfigRepoOptions, type ConfigSerializer, type ConflictArchive, type ConflictInfo, type IConfigRepo, type VersionMeta, configKeyToFilePath, createBackend, createConfigRepo, createSerializerChain, getExtension, hasBackend, incrementVersion, listBackends, readVersion, registerBackend, sha256, unregisterBackend, verifyOrRepairVersion, versionPathFor, wrapZenFSFileSystem, writeVersion };
382
+ export { type BackendDescriptor, type BackendFactory, type BackendInstance, type BackendsMeta, type CacheOptions, ConfigRepo, type ConfigRepoOptions, type ConfigSerializer, type ConflictArchive, type ConflictInfo, type IConfigRepo, type TombstoneMeta, type VersionMeta, configKeyToFilePath, createBackend, createConfigRepo, createSerializerChain, getExtension, hasBackend, incrementVersion, listBackends, readVersion, registerBackend, sha256, unregisterBackend, verifyOrRepairVersion, versionPathFor, wrapZenFSFileSystem, writeVersion };
package/dist/index.js CHANGED
@@ -252,8 +252,7 @@ function backendToSyncableFS(backend, name) {
252
252
  async stat(path) {
253
253
  const s = await backend.stat(path);
254
254
  return {
255
- isFile: typeof s.isFile === "function" ? () => s.isFile() : () => !!(s.mode && !(s.mode & 16384)),
256
- isDirectory: typeof s.isDirectory === "function" ? () => s.isDirectory() : () => !!(s.mode && s.mode & 16384),
255
+ mode: typeof s.mode === "number" ? s.mode : void 0,
257
256
  size: s.size ?? 0,
258
257
  mtimeMs: typeof s.mtimeMs === "number" ? s.mtimeMs : s.mtime ? new Date(s.mtime).getTime() : 0
259
258
  };
@@ -274,49 +273,6 @@ function backendToSyncableFS(backend, name) {
274
273
  }
275
274
  return syncable;
276
275
  }
277
- function cachedFSToSyncableFS(cached, name) {
278
- const syncable = {
279
- async readdir(path) {
280
- return cached.readdir(path);
281
- },
282
- async readFile(path, encoding) {
283
- const data = await cached.readFile(path);
284
- if (encoding) {
285
- if (typeof data === "string") return data;
286
- return new TextDecoder().decode(
287
- data instanceof ArrayBuffer ? new Uint8Array(data) : data
288
- );
289
- }
290
- if (typeof data === "string") return Buffer.from(data);
291
- if (data instanceof ArrayBuffer) return Buffer.from(new Uint8Array(data));
292
- return Buffer.from(data);
293
- },
294
- async writeFile(path, data) {
295
- return cached.writeFile(path, data);
296
- },
297
- async unlink(path) {
298
- return cached.unlink(path);
299
- },
300
- async stat(path) {
301
- const s = await cached.stat(path);
302
- const isDir = typeof s.isDirectory === "function" ? s.isDirectory() : typeof s.isDirectory === "boolean" ? s.isDirectory : s.mode !== void 0 && (s.mode & 61440) === 16384;
303
- return {
304
- isFile: () => !isDir,
305
- isDirectory: () => isDir,
306
- size: s.size,
307
- mtimeMs: s.mtimeMs ?? s.mtime
308
- };
309
- },
310
- async mkdir(path, options) {
311
- return cached.mkdir(path, options);
312
- },
313
- async exists(path) {
314
- return cached.exists(path);
315
- }
316
- };
317
- syncable.backendName = name || "CachedFS";
318
- return syncable;
319
- }
320
276
 
321
277
  // src/backend-registry.ts
322
278
  var registry = /* @__PURE__ */ new Map();
@@ -375,12 +331,10 @@ async function wrapZenFSFileSystem(config) {
375
331
  },
376
332
  async stat(path, ..._args) {
377
333
  const st = await isolatedFS.stat(path);
378
- const isDir = typeof st.isDirectory === "function" ? st.isDirectory() : st.mode !== void 0 && (st.mode & 61440) === 16384;
379
334
  return {
380
- isFile: () => !isDir,
381
- isDirectory: () => isDir,
335
+ mode: typeof st.mode === "number" ? st.mode : void 0,
382
336
  size: st.size,
383
- mtime: st.mtimeMs ?? st.mtime
337
+ mtimeMs: st.mtimeMs ?? st.mtime ?? 0
384
338
  };
385
339
  },
386
340
  async exists(path) {
@@ -495,8 +449,12 @@ async function verifyOrRepairVersion(fs, configFilePath, author) {
495
449
  var META_DIR = "/.meta";
496
450
  var BACKENDS_FILE = `${META_DIR}/backends.json`;
497
451
  var CONFLICTS_DIR = `${META_DIR}/.conflicts`;
452
+ var DELETIONS_DIR = `${META_DIR}/.deleted`;
498
453
  var NODES_DIR = "/nodes";
499
454
  var NODE_ID_FILE = `${NODES_DIR}/.node-id`;
455
+ function tombstoneFileName(filePath) {
456
+ return filePath.replace(/^\//, "").replace(/\//g, "__").replace(/\./g, "++") + ".json";
457
+ }
500
458
  var ConfigRepo = class {
501
459
  appId;
502
460
  nodeId;
@@ -512,15 +470,17 @@ var ConfigRepo = class {
512
470
  onConflictCallback;
513
471
  disposed = false;
514
472
  configCache = /* @__PURE__ */ new Map();
473
+ primaryBackendId;
515
474
  constructor(appId, nodeId, primaryBackendId, cachedFS, serializer, onConflict) {
516
475
  this.appId = appId;
517
476
  this.nodeId = nodeId;
477
+ this.primaryBackendId = primaryBackendId;
518
478
  this.cachedFS = cachedFS;
519
479
  this.serializer = serializer;
520
480
  this.syncEngine = new import_zen_fs_sync.ZenFSSync();
521
481
  this.replicaBackends = /* @__PURE__ */ new Map();
522
482
  this.onConflictCallback = onConflict;
523
- this.fullFS = cachedFSToSyncableFS(cachedFS, `CachedFS(${primaryBackendId})`);
483
+ this.fullFS = backendToSyncableFS(cachedFS, primaryBackendId);
524
484
  this.fs = createChrootFS(cachedFS, `/${appId}`);
525
485
  this.rootFS = createChrootFS(cachedFS, "/");
526
486
  }
@@ -664,9 +624,141 @@ var ConfigRepo = class {
664
624
  // -----------------------------------------------------------------------
665
625
  async flush() {
666
626
  this.assertNotDisposed();
627
+ await this.processTombstones();
667
628
  const resultsMap = await this.syncEngine.syncAll();
629
+ await this.updateTombstoneConfirmations();
630
+ await this.gcTombstones();
668
631
  return Array.from(resultsMap.values());
669
632
  }
633
+ // -----------------------------------------------------------------------
634
+ // Tombstone (Deletion Tracking)
635
+ // -----------------------------------------------------------------------
636
+ /**
637
+ * Delete a file and write a tombstone so the deletion propagates
638
+ * to all backends instead of being treated as "missing file → re-create".
639
+ */
640
+ async deleteFile(path) {
641
+ this.assertNotDisposed();
642
+ const normalizedPath = path.startsWith("/") ? path : "/" + path;
643
+ const tombstonePath = `${DELETIONS_DIR}/${tombstoneFileName(normalizedPath)}`;
644
+ const tombstone = {
645
+ path: normalizedPath,
646
+ deletedAt: Date.now(),
647
+ deletedBy: this.primaryBackendId,
648
+ confirmedBy: [this.primaryBackendId]
649
+ };
650
+ await this.ensureDir(tombstonePath);
651
+ await this.cachedFS.writeFile(
652
+ tombstonePath,
653
+ new TextEncoder().encode(JSON.stringify(tombstone, null, 2))
654
+ );
655
+ try {
656
+ await this.cachedFS.unlink(normalizedPath);
657
+ } catch {
658
+ }
659
+ const versionPath = versionPathFor(normalizedPath);
660
+ try {
661
+ await this.cachedFS.unlink(versionPath);
662
+ } catch {
663
+ }
664
+ console.log(`[ConfigRepo] deleteFile: ${normalizedPath} (tombstone at ${tombstonePath})`);
665
+ }
666
+ /**
667
+ * Read all tombstones from the primary backend.
668
+ */
669
+ async readTombstones() {
670
+ try {
671
+ const entries = await this.cachedFS.readdir(DELETIONS_DIR);
672
+ const tombstones = [];
673
+ for (const entry of entries) {
674
+ if (!entry.endsWith(".json")) continue;
675
+ try {
676
+ const raw = await this.cachedFS.readFile(`${DELETIONS_DIR}/${entry}`);
677
+ const data = JSON.parse(new TextDecoder().decode(toUint8Array(raw)));
678
+ tombstones.push(data);
679
+ } catch {
680
+ }
681
+ }
682
+ return tombstones;
683
+ } catch {
684
+ return [];
685
+ }
686
+ }
687
+ /**
688
+ * Before sync: for each tombstone, delete the actual file on all replicas.
689
+ * This prevents bi-directional sync from copying the file back.
690
+ */
691
+ async processTombstones() {
692
+ const tombstones = await this.readTombstones();
693
+ if (tombstones.length === 0) return;
694
+ console.log(`[ConfigRepo] processTombstones: ${tombstones.length} tombstone(s)`);
695
+ for (const tombstone of tombstones) {
696
+ try {
697
+ await this.cachedFS.unlink(tombstone.path);
698
+ } catch {
699
+ }
700
+ try {
701
+ await this.cachedFS.unlink(versionPathFor(tombstone.path));
702
+ } catch {
703
+ }
704
+ for (const [replicaId, replica] of this.replicaBackends) {
705
+ try {
706
+ await replica.instance.unlink(tombstone.path);
707
+ } catch {
708
+ }
709
+ try {
710
+ await replica.instance.unlink(versionPathFor(tombstone.path));
711
+ } catch {
712
+ }
713
+ console.log(`[ConfigRepo] tombstone ${tombstone.path}: deleted on ${replicaId}`);
714
+ }
715
+ }
716
+ }
717
+ /**
718
+ * After sync: mark each tombstone as confirmed by all replica backends.
719
+ */
720
+ async updateTombstoneConfirmations() {
721
+ const tombstones = await this.readTombstones();
722
+ if (tombstones.length === 0) return;
723
+ const backendsMeta = await this.getBackends();
724
+ const allBackendIds = backendsMeta?.backends.map((b) => b.id) ?? [this.primaryBackendId];
725
+ for (const tombstone of tombstones) {
726
+ const tombstonePath = `${DELETIONS_DIR}/${tombstoneFileName(tombstone.path)}`;
727
+ for (const replicaId of this.replicaBackends.keys()) {
728
+ if (!tombstone.confirmedBy.includes(replicaId)) {
729
+ tombstone.confirmedBy.push(replicaId);
730
+ }
731
+ }
732
+ try {
733
+ await this.cachedFS.writeFile(
734
+ tombstonePath,
735
+ new TextEncoder().encode(JSON.stringify(tombstone, null, 2))
736
+ );
737
+ } catch {
738
+ }
739
+ }
740
+ console.log(`[ConfigRepo] updateTombstoneConfirmations: ${tombstones.length} tombstone(s) updated`);
741
+ }
742
+ /**
743
+ * GC: remove tombstones where all backends in backends.json have confirmed.
744
+ */
745
+ async gcTombstones() {
746
+ const tombstones = await this.readTombstones();
747
+ if (tombstones.length === 0) return;
748
+ const backendsMeta = await this.getBackends();
749
+ const allBackendIds = backendsMeta?.backends.map((b) => b.id) ?? [this.primaryBackendId];
750
+ for (const tombstone of tombstones) {
751
+ const allConfirmed = allBackendIds.every((id) => tombstone.confirmedBy.includes(id));
752
+ if (allConfirmed) {
753
+ const tombstonePath = `${DELETIONS_DIR}/${tombstoneFileName(tombstone.path)}`;
754
+ try {
755
+ await this.cachedFS.unlink(tombstonePath);
756
+ console.log(`[ConfigRepo] gcTombstones: removed ${tombstonePath} (all ${allBackendIds.length} backends confirmed)`);
757
+ } catch {
758
+ }
759
+ }
760
+ }
761
+ }
670
762
  /**
671
763
  * Sync .meta/ files (backends.json) to all replica backends.
672
764
  *
@@ -996,21 +1088,7 @@ async function createConfigRepo(appId, options) {
996
1088
  type: options.backendInfo.type,
997
1089
  options: options.backendInfo.options
998
1090
  });
999
- const zenCache = await import("zen-fs-cache");
1000
- let cacheStore;
1001
- const storeType = options.cache?.storeType ?? "MemoryCacheStore";
1002
- if (storeType === "IdbCacheStore") {
1003
- cacheStore = new zenCache.IdbCacheStore(options.cache?.storePrefix);
1004
- } else {
1005
- cacheStore = new zenCache.MemoryCacheStore();
1006
- }
1007
- const cachedFS = new zenCache.CachedFileSystem(
1008
- primaryInstance,
1009
- cacheStore,
1010
- {
1011
- ttlMs: options.cache?.ttlMs ?? 0
1012
- }
1013
- );
1091
+ const cachedFS = primaryInstance;
1014
1092
  try {
1015
1093
  const metaExists = await primaryInstance.exists(META_DIR);
1016
1094
  console.log(`[createConfigRepo] /.meta/ exists: ${metaExists}`);
package/dist/index.mjs CHANGED
@@ -203,8 +203,7 @@ function backendToSyncableFS(backend, name) {
203
203
  async stat(path) {
204
204
  const s = await backend.stat(path);
205
205
  return {
206
- isFile: typeof s.isFile === "function" ? () => s.isFile() : () => !!(s.mode && !(s.mode & 16384)),
207
- isDirectory: typeof s.isDirectory === "function" ? () => s.isDirectory() : () => !!(s.mode && s.mode & 16384),
206
+ mode: typeof s.mode === "number" ? s.mode : void 0,
208
207
  size: s.size ?? 0,
209
208
  mtimeMs: typeof s.mtimeMs === "number" ? s.mtimeMs : s.mtime ? new Date(s.mtime).getTime() : 0
210
209
  };
@@ -225,49 +224,6 @@ function backendToSyncableFS(backend, name) {
225
224
  }
226
225
  return syncable;
227
226
  }
228
- function cachedFSToSyncableFS(cached, name) {
229
- const syncable = {
230
- async readdir(path) {
231
- return cached.readdir(path);
232
- },
233
- async readFile(path, encoding) {
234
- const data = await cached.readFile(path);
235
- if (encoding) {
236
- if (typeof data === "string") return data;
237
- return new TextDecoder().decode(
238
- data instanceof ArrayBuffer ? new Uint8Array(data) : data
239
- );
240
- }
241
- if (typeof data === "string") return Buffer.from(data);
242
- if (data instanceof ArrayBuffer) return Buffer.from(new Uint8Array(data));
243
- return Buffer.from(data);
244
- },
245
- async writeFile(path, data) {
246
- return cached.writeFile(path, data);
247
- },
248
- async unlink(path) {
249
- return cached.unlink(path);
250
- },
251
- async stat(path) {
252
- const s = await cached.stat(path);
253
- const isDir = typeof s.isDirectory === "function" ? s.isDirectory() : typeof s.isDirectory === "boolean" ? s.isDirectory : s.mode !== void 0 && (s.mode & 61440) === 16384;
254
- return {
255
- isFile: () => !isDir,
256
- isDirectory: () => isDir,
257
- size: s.size,
258
- mtimeMs: s.mtimeMs ?? s.mtime
259
- };
260
- },
261
- async mkdir(path, options) {
262
- return cached.mkdir(path, options);
263
- },
264
- async exists(path) {
265
- return cached.exists(path);
266
- }
267
- };
268
- syncable.backendName = name || "CachedFS";
269
- return syncable;
270
- }
271
227
 
272
228
  // src/backend-registry.ts
273
229
  var registry = /* @__PURE__ */ new Map();
@@ -326,12 +282,10 @@ async function wrapZenFSFileSystem(config) {
326
282
  },
327
283
  async stat(path, ..._args) {
328
284
  const st = await isolatedFS.stat(path);
329
- const isDir = typeof st.isDirectory === "function" ? st.isDirectory() : st.mode !== void 0 && (st.mode & 61440) === 16384;
330
285
  return {
331
- isFile: () => !isDir,
332
- isDirectory: () => isDir,
286
+ mode: typeof st.mode === "number" ? st.mode : void 0,
333
287
  size: st.size,
334
- mtime: st.mtimeMs ?? st.mtime
288
+ mtimeMs: st.mtimeMs ?? st.mtime ?? 0
335
289
  };
336
290
  },
337
291
  async exists(path) {
@@ -446,8 +400,12 @@ async function verifyOrRepairVersion(fs, configFilePath, author) {
446
400
  var META_DIR = "/.meta";
447
401
  var BACKENDS_FILE = `${META_DIR}/backends.json`;
448
402
  var CONFLICTS_DIR = `${META_DIR}/.conflicts`;
403
+ var DELETIONS_DIR = `${META_DIR}/.deleted`;
449
404
  var NODES_DIR = "/nodes";
450
405
  var NODE_ID_FILE = `${NODES_DIR}/.node-id`;
406
+ function tombstoneFileName(filePath) {
407
+ return filePath.replace(/^\//, "").replace(/\//g, "__").replace(/\./g, "++") + ".json";
408
+ }
451
409
  var ConfigRepo = class {
452
410
  appId;
453
411
  nodeId;
@@ -463,15 +421,17 @@ var ConfigRepo = class {
463
421
  onConflictCallback;
464
422
  disposed = false;
465
423
  configCache = /* @__PURE__ */ new Map();
424
+ primaryBackendId;
466
425
  constructor(appId, nodeId, primaryBackendId, cachedFS, serializer, onConflict) {
467
426
  this.appId = appId;
468
427
  this.nodeId = nodeId;
428
+ this.primaryBackendId = primaryBackendId;
469
429
  this.cachedFS = cachedFS;
470
430
  this.serializer = serializer;
471
431
  this.syncEngine = new ZenFSSync();
472
432
  this.replicaBackends = /* @__PURE__ */ new Map();
473
433
  this.onConflictCallback = onConflict;
474
- this.fullFS = cachedFSToSyncableFS(cachedFS, `CachedFS(${primaryBackendId})`);
434
+ this.fullFS = backendToSyncableFS(cachedFS, primaryBackendId);
475
435
  this.fs = createChrootFS(cachedFS, `/${appId}`);
476
436
  this.rootFS = createChrootFS(cachedFS, "/");
477
437
  }
@@ -615,9 +575,141 @@ var ConfigRepo = class {
615
575
  // -----------------------------------------------------------------------
616
576
  async flush() {
617
577
  this.assertNotDisposed();
578
+ await this.processTombstones();
618
579
  const resultsMap = await this.syncEngine.syncAll();
580
+ await this.updateTombstoneConfirmations();
581
+ await this.gcTombstones();
619
582
  return Array.from(resultsMap.values());
620
583
  }
584
+ // -----------------------------------------------------------------------
585
+ // Tombstone (Deletion Tracking)
586
+ // -----------------------------------------------------------------------
587
+ /**
588
+ * Delete a file and write a tombstone so the deletion propagates
589
+ * to all backends instead of being treated as "missing file → re-create".
590
+ */
591
+ async deleteFile(path) {
592
+ this.assertNotDisposed();
593
+ const normalizedPath = path.startsWith("/") ? path : "/" + path;
594
+ const tombstonePath = `${DELETIONS_DIR}/${tombstoneFileName(normalizedPath)}`;
595
+ const tombstone = {
596
+ path: normalizedPath,
597
+ deletedAt: Date.now(),
598
+ deletedBy: this.primaryBackendId,
599
+ confirmedBy: [this.primaryBackendId]
600
+ };
601
+ await this.ensureDir(tombstonePath);
602
+ await this.cachedFS.writeFile(
603
+ tombstonePath,
604
+ new TextEncoder().encode(JSON.stringify(tombstone, null, 2))
605
+ );
606
+ try {
607
+ await this.cachedFS.unlink(normalizedPath);
608
+ } catch {
609
+ }
610
+ const versionPath = versionPathFor(normalizedPath);
611
+ try {
612
+ await this.cachedFS.unlink(versionPath);
613
+ } catch {
614
+ }
615
+ console.log(`[ConfigRepo] deleteFile: ${normalizedPath} (tombstone at ${tombstonePath})`);
616
+ }
617
+ /**
618
+ * Read all tombstones from the primary backend.
619
+ */
620
+ async readTombstones() {
621
+ try {
622
+ const entries = await this.cachedFS.readdir(DELETIONS_DIR);
623
+ const tombstones = [];
624
+ for (const entry of entries) {
625
+ if (!entry.endsWith(".json")) continue;
626
+ try {
627
+ const raw = await this.cachedFS.readFile(`${DELETIONS_DIR}/${entry}`);
628
+ const data = JSON.parse(new TextDecoder().decode(toUint8Array(raw)));
629
+ tombstones.push(data);
630
+ } catch {
631
+ }
632
+ }
633
+ return tombstones;
634
+ } catch {
635
+ return [];
636
+ }
637
+ }
638
+ /**
639
+ * Before sync: for each tombstone, delete the actual file on all replicas.
640
+ * This prevents bi-directional sync from copying the file back.
641
+ */
642
+ async processTombstones() {
643
+ const tombstones = await this.readTombstones();
644
+ if (tombstones.length === 0) return;
645
+ console.log(`[ConfigRepo] processTombstones: ${tombstones.length} tombstone(s)`);
646
+ for (const tombstone of tombstones) {
647
+ try {
648
+ await this.cachedFS.unlink(tombstone.path);
649
+ } catch {
650
+ }
651
+ try {
652
+ await this.cachedFS.unlink(versionPathFor(tombstone.path));
653
+ } catch {
654
+ }
655
+ for (const [replicaId, replica] of this.replicaBackends) {
656
+ try {
657
+ await replica.instance.unlink(tombstone.path);
658
+ } catch {
659
+ }
660
+ try {
661
+ await replica.instance.unlink(versionPathFor(tombstone.path));
662
+ } catch {
663
+ }
664
+ console.log(`[ConfigRepo] tombstone ${tombstone.path}: deleted on ${replicaId}`);
665
+ }
666
+ }
667
+ }
668
+ /**
669
+ * After sync: mark each tombstone as confirmed by all replica backends.
670
+ */
671
+ async updateTombstoneConfirmations() {
672
+ const tombstones = await this.readTombstones();
673
+ if (tombstones.length === 0) return;
674
+ const backendsMeta = await this.getBackends();
675
+ const allBackendIds = backendsMeta?.backends.map((b) => b.id) ?? [this.primaryBackendId];
676
+ for (const tombstone of tombstones) {
677
+ const tombstonePath = `${DELETIONS_DIR}/${tombstoneFileName(tombstone.path)}`;
678
+ for (const replicaId of this.replicaBackends.keys()) {
679
+ if (!tombstone.confirmedBy.includes(replicaId)) {
680
+ tombstone.confirmedBy.push(replicaId);
681
+ }
682
+ }
683
+ try {
684
+ await this.cachedFS.writeFile(
685
+ tombstonePath,
686
+ new TextEncoder().encode(JSON.stringify(tombstone, null, 2))
687
+ );
688
+ } catch {
689
+ }
690
+ }
691
+ console.log(`[ConfigRepo] updateTombstoneConfirmations: ${tombstones.length} tombstone(s) updated`);
692
+ }
693
+ /**
694
+ * GC: remove tombstones where all backends in backends.json have confirmed.
695
+ */
696
+ async gcTombstones() {
697
+ const tombstones = await this.readTombstones();
698
+ if (tombstones.length === 0) return;
699
+ const backendsMeta = await this.getBackends();
700
+ const allBackendIds = backendsMeta?.backends.map((b) => b.id) ?? [this.primaryBackendId];
701
+ for (const tombstone of tombstones) {
702
+ const allConfirmed = allBackendIds.every((id) => tombstone.confirmedBy.includes(id));
703
+ if (allConfirmed) {
704
+ const tombstonePath = `${DELETIONS_DIR}/${tombstoneFileName(tombstone.path)}`;
705
+ try {
706
+ await this.cachedFS.unlink(tombstonePath);
707
+ console.log(`[ConfigRepo] gcTombstones: removed ${tombstonePath} (all ${allBackendIds.length} backends confirmed)`);
708
+ } catch {
709
+ }
710
+ }
711
+ }
712
+ }
621
713
  /**
622
714
  * Sync .meta/ files (backends.json) to all replica backends.
623
715
  *
@@ -947,21 +1039,7 @@ async function createConfigRepo(appId, options) {
947
1039
  type: options.backendInfo.type,
948
1040
  options: options.backendInfo.options
949
1041
  });
950
- const zenCache = await import("zen-fs-cache");
951
- let cacheStore;
952
- const storeType = options.cache?.storeType ?? "MemoryCacheStore";
953
- if (storeType === "IdbCacheStore") {
954
- cacheStore = new zenCache.IdbCacheStore(options.cache?.storePrefix);
955
- } else {
956
- cacheStore = new zenCache.MemoryCacheStore();
957
- }
958
- const cachedFS = new zenCache.CachedFileSystem(
959
- primaryInstance,
960
- cacheStore,
961
- {
962
- ttlMs: options.cache?.ttlMs ?? 0
963
- }
964
- );
1042
+ const cachedFS = primaryInstance;
965
1043
  try {
966
1044
  const metaExists = await primaryInstance.exists(META_DIR);
967
1045
  console.log(`[createConfigRepo] /.meta/ exists: ${metaExists}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zen-fs-config",
3
- "version": "0.3.25",
3
+ "version": "0.3.27",
4
4
  "description": "Distributed config management library built on ZenFS, zen-fs-cache, and zen-fs-sync",
5
5
  "main": "dist/index.js",
6
6
  "module": "dist/index.mjs",
@@ -49,7 +49,6 @@
49
49
  "typescript": "^5.9.3",
50
50
  "vitest": "^1.6.1",
51
51
  "zen-fs-cache": "^1.0.1",
52
- "zen-fs-sync": "^0.1.0"
53
- },
54
- "dependencies": {}
52
+ "zen-fs-sync": "^0.2.2"
53
+ }
55
54
  }