@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.
- package/dist/fs-agent.d.ts +130 -1
- package/dist/fs-agent.js +810 -90
- package/dist/fs-conflict-resolver.d.ts +181 -0
- package/dist/fs-db-adapter.d.ts +8 -0
- package/dist/fs-scanner.d.ts +40 -6
- package/dist/index.d.ts +1 -0
- package/package.json +20 -10
|
@@ -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
|
+
}
|
package/dist/fs-db-adapter.d.ts
CHANGED
|
@@ -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.
|
package/dist/fs-scanner.d.ts
CHANGED
|
@@ -9,16 +9,23 @@ export interface FsNodeMeta extends Json {
|
|
|
9
9
|
name: string;
|
|
10
10
|
/** Type of node */
|
|
11
11
|
type: 'file' | 'directory';
|
|
12
|
-
/**
|
|
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.
|
|
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.
|
|
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
|
-
"
|
|
55
|
-
"
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
"
|
|
59
|
-
|
|
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
|
+
}
|