@rljson/fs-agent 0.0.12 → 0.0.15

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.
@@ -0,0 +1,181 @@
1
+ import { Conflict } from '@rljson/db';
2
+ import { InsertHistoryRow } from '@rljson/rljson';
3
+ import { FsTree } from './fs-scanner.js';
4
+ /**
5
+ * Nextcloud-style conflict resolution for forked FS-tree DAGs.
6
+ *
7
+ * When two peers edit a shared tree while one is offline, the InsertHistory
8
+ * DAG forks into two tips (B and C, both descending from a common ancestor A).
9
+ * `@rljson/db` fires a `dagBranch` conflict. This module resolves it on the
10
+ * **client** by performing a deterministic three-way file-level merge and
11
+ * writing a single **merge revision D** whose `InsertHistory.previous`
12
+ * references *both* tips — collapsing the fork back to one tip.
13
+ *
14
+ * The pure functions (merge, naming, ancestor walk, winner selection) are
15
+ * deterministic: every peer resolving the same fork produces the identical D,
16
+ * so resolution converges instead of forking again.
17
+ *
18
+ * See `doc/conflict-resolution-design.md`.
19
+ */
20
+ /** relativePath → blobId. Directories are recorded as {@link DIR_MARKER}. */
21
+ export type ContentMap = Map<string, string>;
22
+ /** Sentinel blobId used for directory entries in a {@link ContentMap}. */
23
+ export declare const DIR_MARKER = "<dir>";
24
+ /**
25
+ * Builds a {@link ContentMap} (relativePath → blobId) from an FsTree, ignoring
26
+ * mtime so two trees with identical content compare equal regardless of when
27
+ * they were written.
28
+ * @param tree - The FsTree to flatten
29
+ * @returns A map of relativePath → blobId (directories use {@link DIR_MARKER})
30
+ */
31
+ export declare function fsTreeToContentMap(tree: FsTree): ContentMap;
32
+ /** A branch tip's identity, used for deterministic winner selection. */
33
+ export interface BranchTip {
34
+ /** InsertHistory timeId of the tip (per-db; used only for local lookups). */
35
+ timeId: string;
36
+ /** Shared content ref of the tip — the cross-client deterministic tiebreak. */
37
+ ref: string;
38
+ /** Originating client id (InsertHistory `origin`). Empty string if unknown. */
39
+ clientId: string;
40
+ /** InsertHistory client timestamp (ms). 0 if unknown. */
41
+ timestamp: number;
42
+ }
43
+ /**
44
+ * Total, deterministic order over two tips. Returns a positive number when `a`
45
+ * outranks `b`. Greater timestamp wins; ties broken by greater clientId, then
46
+ * greater **content ref**. The final tiebreak is the ref (not the timeId)
47
+ * because timeIds are per-db — using them would make different peers pick
48
+ * different winners and never converge; the ref is shared, so every peer agrees.
49
+ * @param a - First tip
50
+ * @param b - Second tip
51
+ * @returns Positive if `a` outranks `b`, negative if `b` outranks `a`, else 0
52
+ */
53
+ export declare function compareTips(a: BranchTip, b: BranchTip): number;
54
+ /**
55
+ * Picks the path-owning winner of a conflict. Per design decision §11.1 the
56
+ * revision with the greater InsertHistory timestamp keeps the original path;
57
+ * the loser's content is preserved under a renamed conflict copy.
58
+ * @param a - First tip
59
+ * @param b - Second tip
60
+ * @returns The winning and losing tips
61
+ */
62
+ export declare function decideWinner(a: BranchTip, b: BranchTip): {
63
+ winner: BranchTip;
64
+ loser: BranchTip;
65
+ };
66
+ /**
67
+ * Finds the nearest common ancestor timeId of two tips by walking the
68
+ * InsertHistory `previous` chains. Returns null when the tips share no
69
+ * ancestor (treat as an empty ancestor — everything is an add/add).
70
+ * @param rows - All InsertHistory rows for the table
71
+ * @param tipA - First tip timeId
72
+ * @param tipB - Second tip timeId
73
+ * @returns The nearest common ancestor timeId, or null if none
74
+ */
75
+ export declare function findCommonAncestor(rows: InsertHistoryRow<string>[], tipA: string, tipB: string): string | null;
76
+ /**
77
+ * Formats a timestamp as a stable UTC `YYYY-MM-DD HHMMSS` string. UTC keeps the
78
+ * conflict-copy name identical across peers in different timezones.
79
+ * @param ms - Milliseconds since the epoch
80
+ * @returns The formatted UTC timestamp
81
+ */
82
+ export declare function formatConflictTimestamp(ms: number): string;
83
+ /**
84
+ * Derives a Nextcloud-style conflict-copy path:
85
+ * `document.txt` → `document (conflicted copy <clientId> <ts>).txt`.
86
+ *
87
+ * - The suffix is inserted before the final extension; dotfiles / extensionless
88
+ * names get it appended.
89
+ * - Identity + timestamp come from the *losing* revision, so every peer derives
90
+ * the same name (determinism).
91
+ * - If the candidate name is already taken, a numeric ` (n)` is appended; the
92
+ * chosen name is added to `taken`.
93
+ * @param relativePath - The original conflicting path
94
+ * @param clientId - The losing revision's client id
95
+ * @param timestamp - The losing revision's InsertHistory timestamp (ms)
96
+ * @param taken - Set of already-used paths; the chosen name is added to it
97
+ * @returns A unique conflict-copy path
98
+ */
99
+ export declare function conflictCopyName(relativePath: string, clientId: string, timestamp: number, taken: Set<string>): string;
100
+ /** A conflict copy to materialise: the losing content under a renamed path. */
101
+ export interface ConflictCopy {
102
+ /** The renamed path the losing content is written to. */
103
+ path: string;
104
+ /** The losing blobId (content preserved, nothing lost). */
105
+ blobId: string;
106
+ }
107
+ /** The result of a three-way merge. */
108
+ export interface MergePlan {
109
+ /** Final tree content (winner-resolved): relativePath → blobId. */
110
+ merged: ContentMap;
111
+ /** Extra files to write beside the merged set (the renamed losers). */
112
+ copies: ConflictCopy[];
113
+ /** Original paths that genuinely conflicted (both sides changed differently). */
114
+ conflictPaths: string[];
115
+ }
116
+ /**
117
+ * Three-way file-level merge of ancestor `o`, branch `ours`, branch `theirs`.
118
+ * `winnerSide` decides who keeps the path on a real conflict; the loser's
119
+ * content is preserved under a {@link conflictCopyName}. Pure & deterministic.
120
+ *
121
+ * Per relative path (see design §4.4):
122
+ * - both sides equal → keep it (covers unchanged + add-same + delete-both)
123
+ * - only theirs changed (`ours === o`) → take theirs
124
+ * - only ours changed (`theirs === o`) → take ours
125
+ * - both changed differently → CONFLICT: winner keeps path, loser renamed
126
+ * @param o - Ancestor content map
127
+ * @param ours - Our branch content map
128
+ * @param theirs - Their branch content map
129
+ * @param winnerSide - Which side keeps the path on a real conflict
130
+ * @param loserClientId - The losing revision's client id (for copy names)
131
+ * @param loserTimestamp - The losing revision's timestamp (for copy names)
132
+ * @returns The merge plan (merged set, conflict copies, conflicting paths)
133
+ */
134
+ export declare function threeWayMerge(o: ContentMap, ours: ContentMap, theirs: ContentMap, winnerSide: 'ours' | 'theirs', loserClientId: string, loserTimestamp: number): MergePlan;
135
+ /**
136
+ * The capabilities the resolver needs from its host FsAgent + Db. Injected so
137
+ * the orchestration is unit-testable with in-memory fakes (no real db/fs).
138
+ */
139
+ export interface ConflictResolverDeps {
140
+ /** The tree table key (route is `/${treeKey}`). */
141
+ treeKey: string;
142
+ /** All InsertHistory rows for `treeKey`. */
143
+ getInsertHistory: (table: string) => Promise<InsertHistoryRow<string>[]>;
144
+ /** Resolve a tip timeId to its tree root ref. */
145
+ getRefOfTimeId: (table: string, timeId: string) => Promise<string | null>;
146
+ /** Fetch a full FsTree by its root ref. */
147
+ fetchTree: (rootRef: string) => Promise<FsTree>;
148
+ /** Read a blob's bytes by blobId. */
149
+ getBlobContent: (blobId: string) => Promise<Buffer>;
150
+ /** Restore an FsTree onto the working dir, pruning extraneous entries. */
151
+ restoreTree: (tree: FsTree) => Promise<void>;
152
+ /** Write bytes to a relative path under the working dir (mkdir -p). */
153
+ writeFileAt: (relativePath: string, content: Buffer) => Promise<void>;
154
+ /** Remove a relative path under the working dir (best effort). */
155
+ deleteFileAt: (relativePath: string) => Promise<void>;
156
+ /** Re-scan the working dir into a fresh, hashed FsTree. */
157
+ scan: () => Promise<FsTree>;
158
+ /** Store the merge revision with explicit predecessors; returns its root ref. */
159
+ storeMerge: (tree: FsTree, previous: string[]) => Promise<string>;
160
+ /** Notified with the stored merge ref so the host can suppress the echo. */
161
+ onMergeStored?: (ref: string) => void;
162
+ /** Optional structured logger. */
163
+ log?: (level: 'info' | 'warn' | 'error', msg: string) => void;
164
+ }
165
+ /**
166
+ * Resolves a single `dagBranch` conflict into a merge revision. For more than
167
+ * two tips it merges the two lowest-identity tips per call; the resulting
168
+ * smaller fork re-fires the observer and converges in further rounds.
169
+ */
170
+ export declare class FsConflictResolver {
171
+ private readonly deps;
172
+ constructor(deps: ConflictResolverDeps);
173
+ private _log;
174
+ /**
175
+ * Resolves the conflict, returning the stored merge ref, or null when the
176
+ * conflict is not ours / not actionable.
177
+ * @param conflict - The detected DAG-branch conflict
178
+ * @returns The stored merge revision's root ref, or null
179
+ */
180
+ resolve(conflict: Conflict): Promise<string | null>;
181
+ }
@@ -1,4 +1,5 @@
1
1
  import { Db } from '@rljson/db';
2
+ import { InsertHistoryTimeId } from '@rljson/rljson';
2
3
  import { FsTree } from './fs-scanner.js';
3
4
  /**
4
5
  * Options for storing filesystem trees in database
@@ -10,6 +11,13 @@ export interface StoreFsTreeOptions {
10
11
  * via the standard db.insertTrees() pipeline.
11
12
  */
12
13
  skipNotification?: boolean;
14
+ /**
15
+ * Explicit predecessor override for the InsertHistory row. When set, the
16
+ * stored revision's `previous` references exactly these timeIds instead of
17
+ * the controller-derived predecessor. Used to write a **merge revision**
18
+ * whose parents are both tips of a forked DAG, collapsing the fork.
19
+ */
20
+ previous?: InsertHistoryTimeId[];
13
21
  }
14
22
  /**
15
23
  * Adapter for storing filesystem trees in a database.
@@ -9,16 +9,23 @@ export interface FsNodeMeta extends Json {
9
9
  name: string;
10
10
  /** Type of node */
11
11
  type: 'file' | 'directory';
12
- /** Absolute path */
13
- path: string;
14
- /** Relative path from scan root */
12
+ /** Relative path from scan root (the cross-client-stable content identity) */
15
13
  relativePath: string;
16
14
  /** File size in bytes (for files) */
17
15
  size?: number;
18
- /** Last modified timestamp (milliseconds since epoch) */
19
- mtime: number;
20
16
  /** Blob ID for file content (files only) */
21
17
  blobId?: string;
18
+ /**
19
+ * Absolute path — informational only, NOT part of the content identity.
20
+ * Excluded from stored meta so tree refs are folder-independent (shared
21
+ * across clients). Retained in the type for back-compat.
22
+ */
23
+ path?: string;
24
+ /**
25
+ * Last modified timestamp — NOT part of the content identity (environment-
26
+ * specific). Excluded from stored meta so refs are mtime-independent.
27
+ */
28
+ mtime?: number;
22
29
  }
23
30
  /**
24
31
  * Tree structure with hash mapping
@@ -32,7 +39,7 @@ export interface FsTree {
32
39
  /**
33
40
  * Type of file system change
34
41
  */
35
- export type FsChangeType = 'added' | 'modified' | 'deleted';
42
+ export type FsChangeType = 'added' | 'modified' | 'deleted' | 'safety-rescan';
36
43
  /**
37
44
  * File system change event
38
45
  */
@@ -73,6 +80,10 @@ export declare class FsScanner {
73
80
  private _bs;
74
81
  private _paused;
75
82
  private _missedChangesDuringPause;
83
+ /** Periodic full-rescan timer that catches events the native watcher drops. */
84
+ private _safetyTimer;
85
+ /** Set by stopWatch() so a pending watcher reinstall / rescan bails out. */
86
+ private _stopRequested;
76
87
  constructor(rootPath: string, options?: FsScanOptions);
77
88
  get tree(): FsTree | null;
78
89
  get rootPath(): string;
@@ -81,6 +92,29 @@ export declare class FsScanner {
81
92
  private _scanDirectory;
82
93
  private _shouldIgnore;
83
94
  watch(): Promise<void>;
95
+ /**
96
+ * One safety-rescan pass: rescans the tree and, if its content differs from
97
+ * the previous scan (the native watcher dropped an event), emits a sync
98
+ * notification so syncToDb reconciles the drift. Paused/stopped scanners and
99
+ * scan failures are no-ops.
100
+ */
101
+ private _runSafetyRescan;
102
+ /**
103
+ * Path+blobId content fingerprint used by the safety rescan to detect drift
104
+ * the native watcher missed (mtime-independent, same idea as the agent's
105
+ * content key but local to the scanner).
106
+ * @param tree - The tree to fingerprint
107
+ * @returns A stable content key
108
+ */
109
+ private _safetyContentKey;
110
+ /**
111
+ * Extracts a readable message from a thrown value.
112
+ * @param err - The caught value
113
+ * @returns A message string
114
+ */
115
+ private static _errMessage;
116
+ /** Whether the host is Windows — gates Windows-specific watcher hardening. */
117
+ private static get _isWindows();
84
118
  private _handleFileChange;
85
119
  private _findTreeByPath;
86
120
  private _notifyChange;
package/dist/index.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  export { FsAgent, SYNC_ERROR_FILE, type FsAgentOptions, type RestoreOptions, type TimeoutConfig, } from './fs-agent.ts';
2
2
  export { FsBlobAdapter, type BlobToFileOptions, type FileBlobMeta, type FileToBlobOptions, } from './fs-blob-adapter.ts';
3
3
  export { FsDbAdapter, type StoreFsTreeOptions } from './fs-db-adapter.ts';
4
+ export { compareTips, conflictCopyName, decideWinner, DIR_MARKER, findCommonAncestor, formatConflictTimestamp, FsConflictResolver, fsTreeToContentMap, threeWayMerge, type BranchTip, type ConflictCopy, type ConflictResolverDeps, type ContentMap, type MergePlan, } from './fs-conflict-resolver.ts';
4
5
  export { FsScanner, type FsChange, type FsChangeCallback, type FsChangeType, type FsNodeMeta, type FsScanOptions, type FsTree, } from './fs-scanner.ts';
5
6
  export { runClientServerSetup, type ClientServerSetupOptions, type ClientServerSetupResult, } from './client-server/client-server-setup.ts';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rljson/fs-agent",
3
- "version": "0.0.12",
3
+ "version": "0.0.15",
4
4
  "description": "Rljson fs-agent description",
5
5
  "homepage": "https://github.com/rljson/fs-agent",
6
6
  "bugs": "https://github.com/rljson/fs-agent/issues",
@@ -19,6 +19,14 @@
19
19
  "dist"
20
20
  ],
21
21
  "type": "module",
22
+ "scripts": {
23
+ "build": "pnpm exec vite build && tsc && node scripts/copy-readme-to-dist.js",
24
+ "test": "cross-env NODE_OPTIONS=--max-old-space-size=8192 pnpm exec vitest run --coverage && pnpm run lint",
25
+ "prebuild": "npm run test",
26
+ "prepublishOnly": "npm run build",
27
+ "lint": "pnpm exec eslint .",
28
+ "updateGoldens": "cross-env UPDATE_GOLDENS=true pnpm test"
29
+ },
22
30
  "devDependencies": {
23
31
  "@rljson/server": "^0.0.10",
24
32
  "@types/node": "^25.3.1",
@@ -43,7 +51,7 @@
43
51
  },
44
52
  "dependencies": {
45
53
  "@rljson/bs": "^0.0.21",
46
- "@rljson/db": "^0.0.15",
54
+ "@rljson/db": "^0.0.22",
47
55
  "@rljson/hash": "^0.0.18",
48
56
  "@rljson/io": "^0.0.66",
49
57
  "@rljson/json": "^0.0.23",
@@ -51,11 +59,13 @@
51
59
  "socket.io": "^4.8.3",
52
60
  "socket.io-client": "^4.8.3"
53
61
  },
54
- "scripts": {
55
- "build": "pnpm exec vite build && tsc && node scripts/copy-readme-to-dist.js",
56
- "test": "cross-env NODE_OPTIONS=--max-old-space-size=8192 pnpm exec vitest run --coverage && pnpm run lint",
57
- "prebuild": "npm run test",
58
- "lint": "pnpm exec eslint .",
59
- "updateGoldens": "cross-env UPDATE_GOLDENS=true pnpm test"
60
- }
61
- }
62
+ "pnpm": {
63
+ "onlyBuiltDependencies": [
64
+ "esbuild"
65
+ ],
66
+ "overrides": {
67
+ "@rljson/rljson": "^0.0.78"
68
+ }
69
+ },
70
+ "packageManager": "pnpm@10.11.0"
71
+ }