isomorphic-jj 1.7.0 → 1.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -5,6 +5,7 @@
5
5
  */
6
6
 
7
7
  import { JJError } from '../utils/errors.js';
8
+ import { KeyedMutex } from '../utils/mutex.js';
8
9
 
9
10
  export class Storage {
10
11
  /**
@@ -18,6 +19,13 @@ export class Storage {
18
19
  this.repoDir = `${dir}/.jj/repo`; // Core repo data
19
20
  this.workingCopyDir = `${dir}/.jj/working_copy`; // Default workspace working copy
20
21
  this.cache = new Map();
22
+
23
+ // Serializes write()/appendLine() critical sections per path so that
24
+ // concurrent same-process callers (e.g. a user command racing
25
+ // BackgroundOps's autosnapshot) can't interleave a read-modify-write
26
+ // cycle and silently lose an update. This only protects callers that
27
+ // share this Storage instance — cross-process locking is out of scope.
28
+ this._locks = new KeyedMutex();
21
29
  }
22
30
 
23
31
  /**
@@ -92,8 +100,28 @@ export class Storage {
92
100
  * @param {Object|string} data - Data to write (object will be stringified)
93
101
  */
94
102
  async write(path, data) {
103
+ return this._locks.run(path, () => this._writeLocked(path, data));
104
+ }
105
+
106
+ /**
107
+ * Unlocked implementation of write(). Only call this from within a
108
+ * `this._locks.run(path, ...)` critical section (directly from write()
109
+ * itself, or from appendLine() which holds the same path's lock already).
110
+ *
111
+ * @param {string} path - Relative path from .jj directory
112
+ * @param {Object|string} data - Data to write (object will be stringified)
113
+ */
114
+ async _writeLocked(path, data) {
95
115
  const fullPath = `${this.jjDir}/${path}`;
96
- const tmpPath = `${fullPath}.tmp.${Date.now()}`;
116
+ // Random suffix (not just a millisecond timestamp) so two concurrent
117
+ // writes to the same path never collide on the temp filename — a
118
+ // collision would let one write's error-cleanup unlink() delete the
119
+ // other's still in-flight temp file.
120
+ const tmpSuffix =
121
+ typeof crypto !== 'undefined' && crypto.randomUUID
122
+ ? crypto.randomUUID()
123
+ : `${Math.random().toString(36).slice(2)}-${Math.random().toString(36).slice(2)}`;
124
+ const tmpPath = `${fullPath}.tmp.${Date.now()}.${tmpSuffix}`;
97
125
  const jsonData = typeof data === 'string' ? data : JSON.stringify(data, null, 2);
98
126
 
99
127
  try {
@@ -163,16 +191,25 @@ export class Storage {
163
191
  const fullPath = `${this.jjDir}/${path}`;
164
192
 
165
193
  try {
166
- // Read existing content
167
- let existing = '';
168
- try {
169
- existing = await this.fs.promises.readFile(fullPath, 'utf8');
170
- } catch (error) {
171
- if (error.code !== 'ENOENT') throw error;
172
- }
194
+ // Hold the same per-path lock write() uses for the *entire*
195
+ // read-modify-write cycle (not just the write half). Without this,
196
+ // two concurrent appendLine() calls on the same path can both read
197
+ // the pre-append content, and the loser's write silently drops its
198
+ // line even though both calls report success.
199
+ await this._locks.run(path, async () => {
200
+ // Read existing content
201
+ let existing = '';
202
+ try {
203
+ existing = await this.fs.promises.readFile(fullPath, 'utf8');
204
+ } catch (error) {
205
+ if (error.code !== 'ENOENT') throw error;
206
+ }
173
207
 
174
- // Write atomically
175
- await this.write(path, existing + line + '\n');
208
+ // Write atomically (already inside this path's lock, so use the
209
+ // unlocked implementation to avoid deadlocking on a non-reentrant
210
+ // per-key mutex).
211
+ await this._writeLocked(path, existing + line + '\n');
212
+ });
176
213
  } catch (error) {
177
214
  throw new JJError('STORAGE_WRITE_FAILED', `Failed to append to ${path}: ${error.message}`, {
178
215
  path: fullPath,
@@ -56,18 +56,36 @@ export class TagStore {
56
56
  this.fs = fs;
57
57
  this.jjDir = jjDir;
58
58
  this.tagsFile = `${jjDir}/store/tags.json`;
59
+ /**
60
+ * Tracking info for local tags mirrored from a remote (jj v0.44 `jj tag
61
+ * track`/`untrack`). Keyed by local tag name: { remote, remoteName }.
62
+ * @type {Record<string, any>}
63
+ */
64
+ this.tracking = {};
59
65
  }
60
66
 
61
67
  /**
62
- * Loads tags from storage
68
+ * Loads tags (and their remote-tracking info, jj v0.44) from storage
63
69
  * @returns {Promise<Record<string, any>>} Map of tag name to changeId
64
70
  */
65
71
  async load() {
66
72
  try {
67
73
  const data = await this.fs.promises.readFile(this.tagsFile, 'utf8');
68
- return JSON.parse(data);
74
+ const parsed = JSON.parse(data);
75
+
76
+ // v0.44: tags.json may be either the legacy flat `{ name: changeId }`
77
+ // map, or the newer `{ tags: {...}, tracked: {...} }` shape that also
78
+ // carries remote-tracking state. Support both for backward compat.
79
+ if (parsed && typeof parsed === 'object' && 'tags' in parsed) {
80
+ this.tracking = parsed.tracked || {};
81
+ return parsed.tags || {};
82
+ }
83
+
84
+ this.tracking = {};
85
+ return parsed || {};
69
86
  } catch (error) {
70
87
  if (error.code === 'ENOENT') {
88
+ this.tracking = {};
71
89
  return {};
72
90
  }
73
91
  throw error;
@@ -75,7 +93,7 @@ export class TagStore {
75
93
  }
76
94
 
77
95
  /**
78
- * Saves tags to storage
96
+ * Saves tags (and their remote-tracking info, jj v0.44) to storage
79
97
  * @param {Object} tags - Map of tag name to changeId
80
98
  * @returns {Promise<void>}
81
99
  */
@@ -87,7 +105,10 @@ export class TagStore {
87
105
  } catch (err) {
88
106
  // Directory might already exist, ignore
89
107
  }
90
- await this.fs.promises.writeFile(this.tagsFile, JSON.stringify(tags, null, 2));
108
+ await this.fs.promises.writeFile(
109
+ this.tagsFile,
110
+ JSON.stringify({ tags, tracked: this.tracking || {} }, null, 2)
111
+ );
91
112
  }
92
113
 
93
114
  /**
@@ -116,9 +137,11 @@ export class TagStore {
116
137
  }
117
138
 
118
139
  /**
119
- * Lists all tags, optionally filtered by pattern
140
+ * Lists all tags, optionally filtered by pattern. Each entry also carries
141
+ * a `tracking: { remote, ref }` field when the tag is tracking a remote
142
+ * (jj v0.44 `jj tag track`).
120
143
  * @param {string} [pattern] - Optional glob pattern
121
- * @returns {Promise<Array<{name: string, changeId: string}>>}
144
+ * @returns {Promise<Array<Record<string, any>>>}
122
145
  */
123
146
  async list(pattern) {
124
147
  const tags = await this.load();
@@ -129,10 +152,17 @@ export class TagStore {
129
152
  tagNames = tagNames.filter((name) => matchesPattern(name, pattern));
130
153
  }
131
154
 
132
- return tagNames.map((name) => ({
133
- name,
134
- changeId: tags[name],
135
- }));
155
+ return tagNames.map((name) => {
156
+ /** @type {Record<string, any>} */
157
+ const tag = { name, changeId: tags[name] };
158
+ if (this.tracking && this.tracking[name]) {
159
+ tag.tracking = {
160
+ remote: this.tracking[name].remote,
161
+ ref: this.tracking[name].remoteName || name,
162
+ };
163
+ }
164
+ return tag;
165
+ });
136
166
  }
137
167
 
138
168
  /**
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Shared atomic-write helper for filesystem writes.
3
+ *
4
+ * Writes content to a uniquely-named temp file, then renames it over the
5
+ * canonical path. A crash/error between those two steps leaves the
6
+ * canonical file untouched (never a truncated/partial write) — the temp
7
+ * file is best-effort cleaned up on failure.
8
+ *
9
+ * This mirrors the pattern `Storage.write()` (src/core/storage-manager.js)
10
+ * already uses for JSON stores, factored out so the protobuf-backed stores
11
+ * (JJOperationStore, JJViewStore, JJTreeState) can use it too instead of
12
+ * calling `writeFile()` directly on the canonical path — see issue #16.
13
+ */
14
+
15
+ /**
16
+ * @param {any} fs - Filesystem implementation (Node fs, LightningFS, etc.)
17
+ * @param {string} targetPath - Canonical path to write to
18
+ * @param {string|Uint8Array} content - Content to write
19
+ */
20
+ export async function atomicWriteFile(fs, targetPath, content) {
21
+ const tmpSuffix =
22
+ typeof crypto !== 'undefined' && crypto.randomUUID
23
+ ? crypto.randomUUID()
24
+ : `${Math.random().toString(36).slice(2)}-${Math.random().toString(36).slice(2)}`;
25
+ const tmpPath = `${targetPath}.tmp.${Date.now()}.${tmpSuffix}`;
26
+
27
+ try {
28
+ const dirPath = targetPath.substring(0, targetPath.lastIndexOf('/'));
29
+ if (dirPath) {
30
+ await fs.promises.mkdir(dirPath, { recursive: true });
31
+ }
32
+
33
+ await fs.promises.writeFile(tmpPath, content);
34
+ await fs.promises.rename(tmpPath, targetPath);
35
+ } catch (error) {
36
+ try {
37
+ await fs.promises.unlink(tmpPath);
38
+ } catch {
39
+ // Best-effort cleanup; ignore if the temp file was never created (or
40
+ // is already gone).
41
+ }
42
+ throw error;
43
+ }
44
+ }
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Small in-process locking utilities.
3
+ *
4
+ * These provide same-process concurrency safety only — they do not protect
5
+ * against races between separate OS processes sharing the same on-disk
6
+ * repository. Cross-process locking is a separate concern (e.g. a lockfile
7
+ * with retry/backoff) and is intentionally out of scope here.
8
+ */
9
+
10
+ /**
11
+ * A simple FIFO async mutex: `run(fn)` queues `fn` to execute only once all
12
+ * previously-queued work on this mutex has settled, serializing access to
13
+ * whatever critical section `fn` represents.
14
+ */
15
+ export class Mutex {
16
+ constructor() {
17
+ /** @type {Promise<any>} */
18
+ this._queue = Promise.resolve();
19
+ }
20
+
21
+ /**
22
+ * @template T
23
+ * @param {() => Promise<T>} fn
24
+ * @returns {Promise<T>}
25
+ */
26
+ run(fn) {
27
+ const result = this._queue.then(() => fn());
28
+ // Keep the queue moving even if `fn` rejects — a failed critical section
29
+ // must not permanently wedge the lock for subsequent callers.
30
+ this._queue = result.then(
31
+ () => undefined,
32
+ () => undefined
33
+ );
34
+ return result;
35
+ }
36
+ }
37
+
38
+ /**
39
+ * A registry of mutexes keyed by an arbitrary string (typically a file path),
40
+ * so unrelated paths don't contend with each other while operations on the
41
+ * *same* path are fully serialized.
42
+ */
43
+ export class KeyedMutex {
44
+ constructor() {
45
+ /** @type {Map<string, Mutex>} */
46
+ this._mutexes = new Map();
47
+ }
48
+
49
+ /**
50
+ * @template T
51
+ * @param {string} key
52
+ * @param {() => Promise<T>} fn
53
+ * @returns {Promise<T>}
54
+ */
55
+ run(key, fn) {
56
+ let mutex = this._mutexes.get(key);
57
+ if (!mutex) {
58
+ mutex = new Mutex();
59
+ this._mutexes.set(key, mutex);
60
+ }
61
+ return mutex.run(fn);
62
+ }
63
+ }