zen-fs-config 0.3.25 → 0.3.28

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
@@ -150,14 +150,10 @@ function createChrootFS(inner, root) {
150
150
  },
151
151
  async stat(path) {
152
152
  const s = await inner.stat(rp(path));
153
- if (typeof s.isFile === "function" && typeof s.isDirectory === "function") {
154
- return s;
155
- }
156
- const isDir = typeof s.isDirectory === "function" ? s.isDirectory() : typeof s.isDirectory === "boolean" ? s.isDirectory : s.mode !== void 0 && (s.mode & 61440) === 16384;
157
153
  return {
158
- ...s,
159
- isFile: () => !isDir,
160
- isDirectory: () => isDir
154
+ mode: typeof s.mode === "number" ? s.mode : void 0,
155
+ size: s.size ?? 0,
156
+ mtimeMs: s.mtimeMs ?? s.mtime ?? 0
161
157
  };
162
158
  },
163
159
  async access(path) {
@@ -252,8 +248,7 @@ function backendToSyncableFS(backend, name) {
252
248
  async stat(path) {
253
249
  const s = await backend.stat(path);
254
250
  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),
251
+ mode: typeof s.mode === "number" ? s.mode : void 0,
257
252
  size: s.size ?? 0,
258
253
  mtimeMs: typeof s.mtimeMs === "number" ? s.mtimeMs : s.mtime ? new Date(s.mtime).getTime() : 0
259
254
  };
@@ -274,49 +269,6 @@ function backendToSyncableFS(backend, name) {
274
269
  }
275
270
  return syncable;
276
271
  }
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
272
 
321
273
  // src/backend-registry.ts
322
274
  var registry = /* @__PURE__ */ new Map();
@@ -375,12 +327,10 @@ async function wrapZenFSFileSystem(config) {
375
327
  },
376
328
  async stat(path, ..._args) {
377
329
  const st = await isolatedFS.stat(path);
378
- const isDir = typeof st.isDirectory === "function" ? st.isDirectory() : st.mode !== void 0 && (st.mode & 61440) === 16384;
379
330
  return {
380
- isFile: () => !isDir,
381
- isDirectory: () => isDir,
331
+ mode: typeof st.mode === "number" ? st.mode : void 0,
382
332
  size: st.size,
383
- mtime: st.mtimeMs ?? st.mtime
333
+ mtimeMs: st.mtimeMs ?? st.mtime ?? 0
384
334
  };
385
335
  },
386
336
  async exists(path) {
@@ -495,8 +445,12 @@ async function verifyOrRepairVersion(fs, configFilePath, author) {
495
445
  var META_DIR = "/.meta";
496
446
  var BACKENDS_FILE = `${META_DIR}/backends.json`;
497
447
  var CONFLICTS_DIR = `${META_DIR}/.conflicts`;
448
+ var DELETIONS_DIR = `${META_DIR}/.deleted`;
498
449
  var NODES_DIR = "/nodes";
499
450
  var NODE_ID_FILE = `${NODES_DIR}/.node-id`;
451
+ function tombstoneFileName(filePath) {
452
+ return filePath.replace(/^\//, "").replace(/\//g, "__").replace(/\./g, "++") + ".json";
453
+ }
500
454
  var ConfigRepo = class {
501
455
  appId;
502
456
  nodeId;
@@ -512,15 +466,17 @@ var ConfigRepo = class {
512
466
  onConflictCallback;
513
467
  disposed = false;
514
468
  configCache = /* @__PURE__ */ new Map();
469
+ primaryBackendId;
515
470
  constructor(appId, nodeId, primaryBackendId, cachedFS, serializer, onConflict) {
516
471
  this.appId = appId;
517
472
  this.nodeId = nodeId;
473
+ this.primaryBackendId = primaryBackendId;
518
474
  this.cachedFS = cachedFS;
519
475
  this.serializer = serializer;
520
476
  this.syncEngine = new import_zen_fs_sync.ZenFSSync();
521
477
  this.replicaBackends = /* @__PURE__ */ new Map();
522
478
  this.onConflictCallback = onConflict;
523
- this.fullFS = cachedFSToSyncableFS(cachedFS, `CachedFS(${primaryBackendId})`);
479
+ this.fullFS = backendToSyncableFS(cachedFS, primaryBackendId);
524
480
  this.fs = createChrootFS(cachedFS, `/${appId}`);
525
481
  this.rootFS = createChrootFS(cachedFS, "/");
526
482
  }
@@ -664,9 +620,141 @@ var ConfigRepo = class {
664
620
  // -----------------------------------------------------------------------
665
621
  async flush() {
666
622
  this.assertNotDisposed();
623
+ await this.processTombstones();
667
624
  const resultsMap = await this.syncEngine.syncAll();
625
+ await this.updateTombstoneConfirmations();
626
+ await this.gcTombstones();
668
627
  return Array.from(resultsMap.values());
669
628
  }
629
+ // -----------------------------------------------------------------------
630
+ // Tombstone (Deletion Tracking)
631
+ // -----------------------------------------------------------------------
632
+ /**
633
+ * Delete a file and write a tombstone so the deletion propagates
634
+ * to all backends instead of being treated as "missing file → re-create".
635
+ */
636
+ async deleteFile(path) {
637
+ this.assertNotDisposed();
638
+ const normalizedPath = path.startsWith("/") ? path : "/" + path;
639
+ const tombstonePath = `${DELETIONS_DIR}/${tombstoneFileName(normalizedPath)}`;
640
+ const tombstone = {
641
+ path: normalizedPath,
642
+ deletedAt: Date.now(),
643
+ deletedBy: this.primaryBackendId,
644
+ confirmedBy: [this.primaryBackendId]
645
+ };
646
+ await this.ensureDir(tombstonePath);
647
+ await this.cachedFS.writeFile(
648
+ tombstonePath,
649
+ new TextEncoder().encode(JSON.stringify(tombstone, null, 2))
650
+ );
651
+ try {
652
+ await this.cachedFS.unlink(normalizedPath);
653
+ } catch {
654
+ }
655
+ const versionPath = versionPathFor(normalizedPath);
656
+ try {
657
+ await this.cachedFS.unlink(versionPath);
658
+ } catch {
659
+ }
660
+ console.log(`[ConfigRepo] deleteFile: ${normalizedPath} (tombstone at ${tombstonePath})`);
661
+ }
662
+ /**
663
+ * Read all tombstones from the primary backend.
664
+ */
665
+ async readTombstones() {
666
+ try {
667
+ const entries = await this.cachedFS.readdir(DELETIONS_DIR);
668
+ const tombstones = [];
669
+ for (const entry of entries) {
670
+ if (!entry.endsWith(".json")) continue;
671
+ try {
672
+ const raw = await this.cachedFS.readFile(`${DELETIONS_DIR}/${entry}`);
673
+ const data = JSON.parse(new TextDecoder().decode(toUint8Array(raw)));
674
+ tombstones.push(data);
675
+ } catch {
676
+ }
677
+ }
678
+ return tombstones;
679
+ } catch {
680
+ return [];
681
+ }
682
+ }
683
+ /**
684
+ * Before sync: for each tombstone, delete the actual file on all replicas.
685
+ * This prevents bi-directional sync from copying the file back.
686
+ */
687
+ async processTombstones() {
688
+ const tombstones = await this.readTombstones();
689
+ if (tombstones.length === 0) return;
690
+ console.log(`[ConfigRepo] processTombstones: ${tombstones.length} tombstone(s)`);
691
+ for (const tombstone of tombstones) {
692
+ try {
693
+ await this.cachedFS.unlink(tombstone.path);
694
+ } catch {
695
+ }
696
+ try {
697
+ await this.cachedFS.unlink(versionPathFor(tombstone.path));
698
+ } catch {
699
+ }
700
+ for (const [replicaId, replica] of this.replicaBackends) {
701
+ try {
702
+ await replica.instance.unlink(tombstone.path);
703
+ } catch {
704
+ }
705
+ try {
706
+ await replica.instance.unlink(versionPathFor(tombstone.path));
707
+ } catch {
708
+ }
709
+ console.log(`[ConfigRepo] tombstone ${tombstone.path}: deleted on ${replicaId}`);
710
+ }
711
+ }
712
+ }
713
+ /**
714
+ * After sync: mark each tombstone as confirmed by all replica backends.
715
+ */
716
+ async updateTombstoneConfirmations() {
717
+ const tombstones = await this.readTombstones();
718
+ if (tombstones.length === 0) return;
719
+ const backendsMeta = await this.getBackends();
720
+ const allBackendIds = backendsMeta?.backends.map((b) => b.id) ?? [this.primaryBackendId];
721
+ for (const tombstone of tombstones) {
722
+ const tombstonePath = `${DELETIONS_DIR}/${tombstoneFileName(tombstone.path)}`;
723
+ for (const replicaId of this.replicaBackends.keys()) {
724
+ if (!tombstone.confirmedBy.includes(replicaId)) {
725
+ tombstone.confirmedBy.push(replicaId);
726
+ }
727
+ }
728
+ try {
729
+ await this.cachedFS.writeFile(
730
+ tombstonePath,
731
+ new TextEncoder().encode(JSON.stringify(tombstone, null, 2))
732
+ );
733
+ } catch {
734
+ }
735
+ }
736
+ console.log(`[ConfigRepo] updateTombstoneConfirmations: ${tombstones.length} tombstone(s) updated`);
737
+ }
738
+ /**
739
+ * GC: remove tombstones where all backends in backends.json have confirmed.
740
+ */
741
+ async gcTombstones() {
742
+ const tombstones = await this.readTombstones();
743
+ if (tombstones.length === 0) return;
744
+ const backendsMeta = await this.getBackends();
745
+ const allBackendIds = backendsMeta?.backends.map((b) => b.id) ?? [this.primaryBackendId];
746
+ for (const tombstone of tombstones) {
747
+ const allConfirmed = allBackendIds.every((id) => tombstone.confirmedBy.includes(id));
748
+ if (allConfirmed) {
749
+ const tombstonePath = `${DELETIONS_DIR}/${tombstoneFileName(tombstone.path)}`;
750
+ try {
751
+ await this.cachedFS.unlink(tombstonePath);
752
+ console.log(`[ConfigRepo] gcTombstones: removed ${tombstonePath} (all ${allBackendIds.length} backends confirmed)`);
753
+ } catch {
754
+ }
755
+ }
756
+ }
757
+ }
670
758
  /**
671
759
  * Sync .meta/ files (backends.json) to all replica backends.
672
760
  *
@@ -927,9 +1015,9 @@ var ConfigRepo = class {
927
1015
  const fullPath = current === "/" ? `/${entry}` : `${current}/${entry}`;
928
1016
  try {
929
1017
  const stat = await this.cachedFS.stat(fullPath);
930
- if (stat.isDirectory()) {
1018
+ if (stat.mode !== void 0 && (stat.mode & 16384) === 16384) {
931
1019
  stack.push(fullPath);
932
- } else if (stat.isFile()) {
1020
+ } else {
933
1021
  results.push(fullPath);
934
1022
  }
935
1023
  } catch {
@@ -996,21 +1084,7 @@ async function createConfigRepo(appId, options) {
996
1084
  type: options.backendInfo.type,
997
1085
  options: options.backendInfo.options
998
1086
  });
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
- );
1087
+ const cachedFS = primaryInstance;
1014
1088
  try {
1015
1089
  const metaExists = await primaryInstance.exists(META_DIR);
1016
1090
  console.log(`[createConfigRepo] /.meta/ exists: ${metaExists}`);
package/dist/index.mjs CHANGED
@@ -101,14 +101,10 @@ function createChrootFS(inner, root) {
101
101
  },
102
102
  async stat(path) {
103
103
  const s = await inner.stat(rp(path));
104
- if (typeof s.isFile === "function" && typeof s.isDirectory === "function") {
105
- return s;
106
- }
107
- const isDir = typeof s.isDirectory === "function" ? s.isDirectory() : typeof s.isDirectory === "boolean" ? s.isDirectory : s.mode !== void 0 && (s.mode & 61440) === 16384;
108
104
  return {
109
- ...s,
110
- isFile: () => !isDir,
111
- isDirectory: () => isDir
105
+ mode: typeof s.mode === "number" ? s.mode : void 0,
106
+ size: s.size ?? 0,
107
+ mtimeMs: s.mtimeMs ?? s.mtime ?? 0
112
108
  };
113
109
  },
114
110
  async access(path) {
@@ -203,8 +199,7 @@ function backendToSyncableFS(backend, name) {
203
199
  async stat(path) {
204
200
  const s = await backend.stat(path);
205
201
  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),
202
+ mode: typeof s.mode === "number" ? s.mode : void 0,
208
203
  size: s.size ?? 0,
209
204
  mtimeMs: typeof s.mtimeMs === "number" ? s.mtimeMs : s.mtime ? new Date(s.mtime).getTime() : 0
210
205
  };
@@ -225,49 +220,6 @@ function backendToSyncableFS(backend, name) {
225
220
  }
226
221
  return syncable;
227
222
  }
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
223
 
272
224
  // src/backend-registry.ts
273
225
  var registry = /* @__PURE__ */ new Map();
@@ -326,12 +278,10 @@ async function wrapZenFSFileSystem(config) {
326
278
  },
327
279
  async stat(path, ..._args) {
328
280
  const st = await isolatedFS.stat(path);
329
- const isDir = typeof st.isDirectory === "function" ? st.isDirectory() : st.mode !== void 0 && (st.mode & 61440) === 16384;
330
281
  return {
331
- isFile: () => !isDir,
332
- isDirectory: () => isDir,
282
+ mode: typeof st.mode === "number" ? st.mode : void 0,
333
283
  size: st.size,
334
- mtime: st.mtimeMs ?? st.mtime
284
+ mtimeMs: st.mtimeMs ?? st.mtime ?? 0
335
285
  };
336
286
  },
337
287
  async exists(path) {
@@ -446,8 +396,12 @@ async function verifyOrRepairVersion(fs, configFilePath, author) {
446
396
  var META_DIR = "/.meta";
447
397
  var BACKENDS_FILE = `${META_DIR}/backends.json`;
448
398
  var CONFLICTS_DIR = `${META_DIR}/.conflicts`;
399
+ var DELETIONS_DIR = `${META_DIR}/.deleted`;
449
400
  var NODES_DIR = "/nodes";
450
401
  var NODE_ID_FILE = `${NODES_DIR}/.node-id`;
402
+ function tombstoneFileName(filePath) {
403
+ return filePath.replace(/^\//, "").replace(/\//g, "__").replace(/\./g, "++") + ".json";
404
+ }
451
405
  var ConfigRepo = class {
452
406
  appId;
453
407
  nodeId;
@@ -463,15 +417,17 @@ var ConfigRepo = class {
463
417
  onConflictCallback;
464
418
  disposed = false;
465
419
  configCache = /* @__PURE__ */ new Map();
420
+ primaryBackendId;
466
421
  constructor(appId, nodeId, primaryBackendId, cachedFS, serializer, onConflict) {
467
422
  this.appId = appId;
468
423
  this.nodeId = nodeId;
424
+ this.primaryBackendId = primaryBackendId;
469
425
  this.cachedFS = cachedFS;
470
426
  this.serializer = serializer;
471
427
  this.syncEngine = new ZenFSSync();
472
428
  this.replicaBackends = /* @__PURE__ */ new Map();
473
429
  this.onConflictCallback = onConflict;
474
- this.fullFS = cachedFSToSyncableFS(cachedFS, `CachedFS(${primaryBackendId})`);
430
+ this.fullFS = backendToSyncableFS(cachedFS, primaryBackendId);
475
431
  this.fs = createChrootFS(cachedFS, `/${appId}`);
476
432
  this.rootFS = createChrootFS(cachedFS, "/");
477
433
  }
@@ -615,9 +571,141 @@ var ConfigRepo = class {
615
571
  // -----------------------------------------------------------------------
616
572
  async flush() {
617
573
  this.assertNotDisposed();
574
+ await this.processTombstones();
618
575
  const resultsMap = await this.syncEngine.syncAll();
576
+ await this.updateTombstoneConfirmations();
577
+ await this.gcTombstones();
619
578
  return Array.from(resultsMap.values());
620
579
  }
580
+ // -----------------------------------------------------------------------
581
+ // Tombstone (Deletion Tracking)
582
+ // -----------------------------------------------------------------------
583
+ /**
584
+ * Delete a file and write a tombstone so the deletion propagates
585
+ * to all backends instead of being treated as "missing file → re-create".
586
+ */
587
+ async deleteFile(path) {
588
+ this.assertNotDisposed();
589
+ const normalizedPath = path.startsWith("/") ? path : "/" + path;
590
+ const tombstonePath = `${DELETIONS_DIR}/${tombstoneFileName(normalizedPath)}`;
591
+ const tombstone = {
592
+ path: normalizedPath,
593
+ deletedAt: Date.now(),
594
+ deletedBy: this.primaryBackendId,
595
+ confirmedBy: [this.primaryBackendId]
596
+ };
597
+ await this.ensureDir(tombstonePath);
598
+ await this.cachedFS.writeFile(
599
+ tombstonePath,
600
+ new TextEncoder().encode(JSON.stringify(tombstone, null, 2))
601
+ );
602
+ try {
603
+ await this.cachedFS.unlink(normalizedPath);
604
+ } catch {
605
+ }
606
+ const versionPath = versionPathFor(normalizedPath);
607
+ try {
608
+ await this.cachedFS.unlink(versionPath);
609
+ } catch {
610
+ }
611
+ console.log(`[ConfigRepo] deleteFile: ${normalizedPath} (tombstone at ${tombstonePath})`);
612
+ }
613
+ /**
614
+ * Read all tombstones from the primary backend.
615
+ */
616
+ async readTombstones() {
617
+ try {
618
+ const entries = await this.cachedFS.readdir(DELETIONS_DIR);
619
+ const tombstones = [];
620
+ for (const entry of entries) {
621
+ if (!entry.endsWith(".json")) continue;
622
+ try {
623
+ const raw = await this.cachedFS.readFile(`${DELETIONS_DIR}/${entry}`);
624
+ const data = JSON.parse(new TextDecoder().decode(toUint8Array(raw)));
625
+ tombstones.push(data);
626
+ } catch {
627
+ }
628
+ }
629
+ return tombstones;
630
+ } catch {
631
+ return [];
632
+ }
633
+ }
634
+ /**
635
+ * Before sync: for each tombstone, delete the actual file on all replicas.
636
+ * This prevents bi-directional sync from copying the file back.
637
+ */
638
+ async processTombstones() {
639
+ const tombstones = await this.readTombstones();
640
+ if (tombstones.length === 0) return;
641
+ console.log(`[ConfigRepo] processTombstones: ${tombstones.length} tombstone(s)`);
642
+ for (const tombstone of tombstones) {
643
+ try {
644
+ await this.cachedFS.unlink(tombstone.path);
645
+ } catch {
646
+ }
647
+ try {
648
+ await this.cachedFS.unlink(versionPathFor(tombstone.path));
649
+ } catch {
650
+ }
651
+ for (const [replicaId, replica] of this.replicaBackends) {
652
+ try {
653
+ await replica.instance.unlink(tombstone.path);
654
+ } catch {
655
+ }
656
+ try {
657
+ await replica.instance.unlink(versionPathFor(tombstone.path));
658
+ } catch {
659
+ }
660
+ console.log(`[ConfigRepo] tombstone ${tombstone.path}: deleted on ${replicaId}`);
661
+ }
662
+ }
663
+ }
664
+ /**
665
+ * After sync: mark each tombstone as confirmed by all replica backends.
666
+ */
667
+ async updateTombstoneConfirmations() {
668
+ const tombstones = await this.readTombstones();
669
+ if (tombstones.length === 0) return;
670
+ const backendsMeta = await this.getBackends();
671
+ const allBackendIds = backendsMeta?.backends.map((b) => b.id) ?? [this.primaryBackendId];
672
+ for (const tombstone of tombstones) {
673
+ const tombstonePath = `${DELETIONS_DIR}/${tombstoneFileName(tombstone.path)}`;
674
+ for (const replicaId of this.replicaBackends.keys()) {
675
+ if (!tombstone.confirmedBy.includes(replicaId)) {
676
+ tombstone.confirmedBy.push(replicaId);
677
+ }
678
+ }
679
+ try {
680
+ await this.cachedFS.writeFile(
681
+ tombstonePath,
682
+ new TextEncoder().encode(JSON.stringify(tombstone, null, 2))
683
+ );
684
+ } catch {
685
+ }
686
+ }
687
+ console.log(`[ConfigRepo] updateTombstoneConfirmations: ${tombstones.length} tombstone(s) updated`);
688
+ }
689
+ /**
690
+ * GC: remove tombstones where all backends in backends.json have confirmed.
691
+ */
692
+ async gcTombstones() {
693
+ const tombstones = await this.readTombstones();
694
+ if (tombstones.length === 0) return;
695
+ const backendsMeta = await this.getBackends();
696
+ const allBackendIds = backendsMeta?.backends.map((b) => b.id) ?? [this.primaryBackendId];
697
+ for (const tombstone of tombstones) {
698
+ const allConfirmed = allBackendIds.every((id) => tombstone.confirmedBy.includes(id));
699
+ if (allConfirmed) {
700
+ const tombstonePath = `${DELETIONS_DIR}/${tombstoneFileName(tombstone.path)}`;
701
+ try {
702
+ await this.cachedFS.unlink(tombstonePath);
703
+ console.log(`[ConfigRepo] gcTombstones: removed ${tombstonePath} (all ${allBackendIds.length} backends confirmed)`);
704
+ } catch {
705
+ }
706
+ }
707
+ }
708
+ }
621
709
  /**
622
710
  * Sync .meta/ files (backends.json) to all replica backends.
623
711
  *
@@ -878,9 +966,9 @@ var ConfigRepo = class {
878
966
  const fullPath = current === "/" ? `/${entry}` : `${current}/${entry}`;
879
967
  try {
880
968
  const stat = await this.cachedFS.stat(fullPath);
881
- if (stat.isDirectory()) {
969
+ if (stat.mode !== void 0 && (stat.mode & 16384) === 16384) {
882
970
  stack.push(fullPath);
883
- } else if (stat.isFile()) {
971
+ } else {
884
972
  results.push(fullPath);
885
973
  }
886
974
  } catch {
@@ -947,21 +1035,7 @@ async function createConfigRepo(appId, options) {
947
1035
  type: options.backendInfo.type,
948
1036
  options: options.backendInfo.options
949
1037
  });
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
- );
1038
+ const cachedFS = primaryInstance;
965
1039
  try {
966
1040
  const metaExists = await primaryInstance.exists(META_DIR);
967
1041
  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.28",
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
  }