@fgv/ts-web-extras 5.1.0-4 → 5.1.0-5

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.
Files changed (53) hide show
  1. package/.rush/temp/{44b0369e8112f3c6f4248af466a3f35ad626d9d7.tar.log → 54f89319dd14b2acc47614d70128001f4227d01e.tar.log} +2 -2
  2. package/.rush/temp/chunked-rush-logs/ts-web-extras.build.chunks.jsonl +17 -17
  3. package/.rush/temp/operation/build/all.log +17 -17
  4. package/.rush/temp/operation/build/log-chunks.jsonl +17 -17
  5. package/.rush/temp/operation/build/state.json +1 -1
  6. package/dist/packlets/file-tree/httpTreeAccessors.js +72 -42
  7. package/dist/packlets/file-tree/httpTreeAccessors.js.map +1 -1
  8. package/dist/test/unit/httpTreeAccessors.test.js +135 -0
  9. package/dist/test/unit/httpTreeAccessors.test.js.map +1 -1
  10. package/dist/ts-web-extras.d.ts +6 -0
  11. package/lib/packlets/file-tree/httpTreeAccessors.d.ts +6 -0
  12. package/lib/packlets/file-tree/httpTreeAccessors.d.ts.map +1 -1
  13. package/lib/packlets/file-tree/httpTreeAccessors.js +72 -42
  14. package/lib/packlets/file-tree/httpTreeAccessors.js.map +1 -1
  15. package/lib/test/unit/httpTreeAccessors.test.js +135 -0
  16. package/lib/test/unit/httpTreeAccessors.test.js.map +1 -1
  17. package/package.json +10 -10
  18. package/rush-logs/ts-web-extras.build.cache.log +1 -1
  19. package/rush-logs/ts-web-extras.build.log +17 -17
  20. package/src/packlets/file-tree/httpTreeAccessors.ts +76 -46
  21. package/src/test/unit/httpTreeAccessors.test.ts +179 -0
  22. package/temp/build/typescript/ts_8nwakTlr.json +1 -1
  23. package/temp/coverage/crypto-utils/browserCryptoProvider.ts.html +1 -1
  24. package/temp/coverage/crypto-utils/browserHashProvider.ts.html +1 -1
  25. package/temp/coverage/crypto-utils/index.html +1 -1
  26. package/temp/coverage/file-tree/directoryHandleStore.ts.html +1 -1
  27. package/temp/coverage/file-tree/fileApiTreeAccessors.ts.html +1 -1
  28. package/temp/coverage/file-tree/fileSystemAccessTreeAccessors.ts.html +1 -1
  29. package/temp/coverage/file-tree/httpTreeAccessors.ts.html +333 -243
  30. package/temp/coverage/file-tree/index.html +11 -11
  31. package/temp/coverage/file-tree/localStorageTreeAccessors.ts.html +1 -1
  32. package/temp/coverage/helpers/fileTreeHelpers.ts.html +1 -1
  33. package/temp/coverage/helpers/index.html +1 -1
  34. package/temp/coverage/index.html +10 -10
  35. package/temp/coverage/lcov-report/crypto-utils/browserCryptoProvider.ts.html +1 -1
  36. package/temp/coverage/lcov-report/crypto-utils/browserHashProvider.ts.html +1 -1
  37. package/temp/coverage/lcov-report/crypto-utils/index.html +1 -1
  38. package/temp/coverage/lcov-report/file-tree/directoryHandleStore.ts.html +1 -1
  39. package/temp/coverage/lcov-report/file-tree/fileApiTreeAccessors.ts.html +1 -1
  40. package/temp/coverage/lcov-report/file-tree/fileSystemAccessTreeAccessors.ts.html +1 -1
  41. package/temp/coverage/lcov-report/file-tree/httpTreeAccessors.ts.html +333 -243
  42. package/temp/coverage/lcov-report/file-tree/index.html +11 -11
  43. package/temp/coverage/lcov-report/file-tree/localStorageTreeAccessors.ts.html +1 -1
  44. package/temp/coverage/lcov-report/helpers/fileTreeHelpers.ts.html +1 -1
  45. package/temp/coverage/lcov-report/helpers/index.html +1 -1
  46. package/temp/coverage/lcov-report/index.html +10 -10
  47. package/temp/coverage/lcov-report/url-utils/index.html +1 -1
  48. package/temp/coverage/lcov-report/url-utils/urlParams.ts.html +1 -1
  49. package/temp/coverage/lcov.info +470 -437
  50. package/temp/coverage/url-utils/index.html +1 -1
  51. package/temp/coverage/url-utils/urlParams.ts.html +1 -1
  52. package/temp/test/jest/haste-map-7492f1b44480e0cdd1f220078fb3afd8-c8dd6c3430605adeb2f1cadf4f75e791-8c9336785555d572065b28c111982ba4 +0 -0
  53. package/temp/test/jest/perf-cache-7492f1b44480e0cdd1f220078fb3afd8-da39a3ee5e6b4b0d3255bfef95601890 +1 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fgv/ts-web-extras",
3
- "version": "5.1.0-4",
3
+ "version": "5.1.0-5",
4
4
  "description": "Browser-compatible utilities and FileTree implementations",
5
5
  "main": "lib/index.js",
6
6
  "types": "dist/ts-web-extras.d.ts",
@@ -46,19 +46,19 @@
46
46
  "@types/react": "~19.2.8",
47
47
  "typedoc": "~0.28.16",
48
48
  "typedoc-plugin-markdown": "~4.9.0",
49
- "@fgv/heft-dual-rig": "5.1.0-4",
50
- "@fgv/typedoc-compact-theme": "5.1.0-4",
51
- "@fgv/ts-json-base": "5.1.0-4",
52
- "@fgv/ts-utils-jest": "5.1.0-4",
53
- "@fgv/ts-utils": "5.1.0-4",
54
- "@fgv/ts-extras": "5.1.0-4"
49
+ "@fgv/typedoc-compact-theme": "5.1.0-5",
50
+ "@fgv/heft-dual-rig": "5.1.0-5",
51
+ "@fgv/ts-utils": "5.1.0-5",
52
+ "@fgv/ts-json-base": "5.1.0-5",
53
+ "@fgv/ts-extras": "5.1.0-5",
54
+ "@fgv/ts-utils-jest": "5.1.0-5"
55
55
  },
56
56
  "peerDependencies": {
57
57
  "react": ">=18 <20",
58
58
  "react-dom": ">=18 <20",
59
- "@fgv/ts-json-base": "5.1.0-4",
60
- "@fgv/ts-extras": "5.1.0-4",
61
- "@fgv/ts-utils": "5.1.0-4"
59
+ "@fgv/ts-extras": "5.1.0-5",
60
+ "@fgv/ts-json-base": "5.1.0-5",
61
+ "@fgv/ts-utils": "5.1.0-5"
62
62
  },
63
63
  "exports": {
64
64
  ".": {
@@ -1,3 +1,3 @@
1
1
  Caching build output folders: dist, lib, temp, .rush/temp/operation/build
2
2
  Successfully set cache entry.
3
- Cache key: 44b0369e8112f3c6f4248af466a3f35ad626d9d7
3
+ Cache key: 54f89319dd14b2acc47614d70128001f4227d01e
@@ -5,34 +5,34 @@ Invoking: heft test --clean
5
5
  [build:api-extractor] Using API Extractor version 7.57.7
6
6
  [build:api-extractor] Analysis will use the bundled TypeScript version 5.8.2
7
7
  [build:api-extractor] *** The target project appears to use TypeScript 5.9.3 which is newer than the bundled compiler engine; consider upgrading API Extractor.
8
- ---- build finished (24.455s) ----
8
+ ---- build finished (25.483s) ----
9
9
  ---- test started ----
10
10
  [test:jest] Using Jest version 29.5.0
11
11
  [test:jest]
12
12
  [test:jest] Run start. 9 test suites
13
- [test:jest] START lib/test/unit/fileApiTreeAccessors.test.js
14
13
  [test:jest] START lib/test/unit/httpTreeAccessors.test.js
14
+ [test:jest] START lib/test/unit/fileApiTreeAccessors.test.js
15
15
  [test:jest] START lib/test/unit/localStorageTreeAccessors.test.js
16
16
  [test:jest] START lib/test/unit/fileSystemAccessTreeAccessors.test.js
17
- [test:jest] PASS lib/test/unit/httpTreeAccessors.test.js (duration: 1.937s, 57 passed, 0 failed)
17
+ [test:jest] PASS lib/test/unit/localStorageTreeAccessors.test.js (duration: 1.899s, 44 passed, 0 failed)
18
18
  [test:jest] START lib/test/unit/fileTreeHelpers.test.js
19
- [test:jest] PASS lib/test/unit/fileApiTreeAccessors.test.js (duration: 2.182s, 65 passed, 0 failed)
19
+ [test:jest] PASS lib/test/unit/fileApiTreeAccessors.test.js (duration: 2.144s, 65 passed, 0 failed)
20
20
  [test:jest] START lib/test/unit/fileApiTypes.test.js
21
- [test:jest] PASS lib/test/unit/localStorageTreeAccessors.test.js (duration: 2.170s, 44 passed, 0 failed)
21
+ [test:jest] PASS lib/test/unit/httpTreeAccessors.test.js (duration: 2.546s, 61 passed, 0 failed)
22
22
  [test:jest] START lib/test/unit/urlParams.test.js
23
- [test:jest] PASS lib/test/unit/fileSystemAccessTreeAccessors.test.js (duration: 1.659s, 44 passed, 0 failed)
23
+ [test:jest] PASS lib/test/unit/fileSystemAccessTreeAccessors.test.js (duration: 1.489s, 44 passed, 0 failed)
24
24
  [test:jest] START lib/test/unit/directoryHandleStore.test.js
25
- [test:jest] PASS lib/test/unit/fileApiTypes.test.js (duration: 1.275s, 36 passed, 0 failed)
25
+ [test:jest] PASS lib/test/unit/fileTreeHelpers.test.js (duration: 1.864s, 35 passed, 0 failed)
26
26
  [test:jest] START lib/test/unit/browserHashProvider.test.js
27
- [test:jest] PASS lib/test/unit/fileTreeHelpers.test.js (duration: 1.784s, 35 passed, 0 failed)
28
- [test:jest] PASS lib/test/unit/directoryHandleStore.test.js (duration: 1.140s, 19 passed, 0 failed)
29
- [test:jest] PASS lib/test/unit/urlParams.test.js (duration: 1.398s, 60 passed, 0 failed)
30
- [test:jest] PASS lib/test/unit/browserHashProvider.test.js (duration: 1.392s, 15 passed, 0 failed)
27
+ [test:jest] PASS lib/test/unit/fileApiTypes.test.js (duration: 1.590s, 36 passed, 0 failed)
28
+ [test:jest] PASS lib/test/unit/urlParams.test.js (duration: 1.224s, 60 passed, 0 failed)
29
+ [test:jest] PASS lib/test/unit/directoryHandleStore.test.js (duration: 1.391s, 19 passed, 0 failed)
30
+ [test:jest] PASS lib/test/unit/browserHashProvider.test.js (duration: 1.278s, 15 passed, 0 failed)
31
31
  [test:jest]
32
32
  [test:jest] Tests finished:
33
- [test:jest] Successes: 375
33
+ [test:jest] Successes: 379
34
34
  [test:jest] Failures: 0
35
- [test:jest] Total: 376
35
+ [test:jest] Total: 380
36
36
  -----------------------------------|---------|----------|---------|---------|-------------------
37
37
  File | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s
38
38
  -----------------------------------|---------|----------|---------|---------|-------------------
@@ -40,16 +40,16 @@ All files | 100 | 99.62 | 100 | 100 |
40
40
  crypto-utils | 100 | 100 | 100 | 100 |
41
41
  browserCryptoProvider.ts | 100 | 100 | 100 | 100 |
42
42
  browserHashProvider.ts | 100 | 100 | 100 | 100 |
43
- file-tree | 100 | 99.57 | 100 | 100 |
43
+ file-tree | 100 | 99.58 | 100 | 100 |
44
44
  directoryHandleStore.ts | 100 | 100 | 100 | 100 |
45
45
  fileApiTreeAccessors.ts | 100 | 100 | 100 | 100 |
46
46
  fileSystemAccessTreeAccessors.ts | 100 | 100 | 100 | 100 |
47
- httpTreeAccessors.ts | 100 | 98.41 | 100 | 100 | 339
47
+ httpTreeAccessors.ts | 100 | 98.42 | 100 | 100 | 369
48
48
  localStorageTreeAccessors.ts | 100 | 100 | 100 | 100 |
49
49
  helpers | 100 | 100 | 100 | 100 |
50
50
  fileTreeHelpers.ts | 100 | 100 | 100 | 100 |
51
51
  url-utils | 100 | 100 | 100 | 100 |
52
52
  urlParams.ts | 100 | 100 | 100 | 100 |
53
53
  -----------------------------------|---------|----------|---------|---------|-------------------
54
- ---- test finished (7.763s) ----
55
- -------------------- Finished (32.236s) --------------------
54
+ ---- test finished (8.203s) ----
55
+ -------------------- Finished (33.696s) --------------------
@@ -130,6 +130,8 @@ export class HttpTreeAccessors<TCT extends string = string>
130
130
  */
131
131
  public async syncToDisk(): Promise<Result<void>> {
132
132
  if (this._syncPromise) {
133
+ // Wait for the in-flight sync — it drains the queue in a loop,
134
+ // so any items added before it finishes will be included.
133
135
  return this._syncPromise;
134
136
  }
135
137
 
@@ -140,66 +142,94 @@ export class HttpTreeAccessors<TCT extends string = string>
140
142
  }
141
143
 
142
144
  private async _doSync(): Promise<Result<void>> {
143
- if (this._dirtyFiles.size === 0 && this._pendingDeletions.size === 0) {
144
- return succeed(undefined);
145
- }
146
-
147
- // Snapshot and clear dirty sets so that changes arriving during
148
- // the async sync are not dropped when we finish.
149
- const deletions = new Set(this._pendingDeletions);
150
- const dirty = new Set(this._dirtyFiles);
151
- this._pendingDeletions.clear();
152
- this._dirtyFiles.clear();
145
+ // Drain loop: keep processing as long as new items arrive.
146
+ // This is critical for bulk operations (e.g. reset) where many
147
+ // deleteFile/saveFileContents calls happen synchronously — only
148
+ // the first may be in the set when we snapshot, but the rest
149
+ // arrive during the async gaps and must be picked up before
150
+ // we return.
151
+ let didWork = false;
152
+ while (this._dirtyFiles.size > 0 || this._pendingDeletions.size > 0) {
153
+ didWork = true;
154
+ // Snapshot and clear so that changes arriving during the async
155
+ // requests land in the live sets for the next iteration.
156
+ const deletions = new Set(this._pendingDeletions);
157
+ const dirty = new Set(this._dirtyFiles);
158
+ this._pendingDeletions.clear();
159
+ this._dirtyFiles.clear();
160
+
161
+ for (const path of deletions) {
162
+ const query = new URLSearchParams();
163
+ query.set('path', path);
164
+ if (this._namespace) {
165
+ query.set('namespace', this._namespace);
166
+ }
153
167
 
154
- for (const path of deletions) {
155
- const query = new URLSearchParams();
156
- query.set('path', path);
157
- if (this._namespace) {
158
- query.set('namespace', this._namespace);
168
+ const deleteResult = await this._requestWithRetry<{ deleted: boolean }>(`/file?${query.toString()}`, {
169
+ method: 'DELETE'
170
+ });
171
+ if (deleteResult.isFailure()) {
172
+ this._restoreUnsynced(deletions, dirty);
173
+ return fail(`delete ${path}: ${deleteResult.message}`);
174
+ }
159
175
  }
160
176
 
161
- const deleteResult = await this._requestWithRetry<{ deleted: boolean }>(`/file?${query.toString()}`, {
162
- method: 'DELETE'
163
- });
164
- if (deleteResult.isFailure()) {
165
- return fail(`delete ${path}: ${deleteResult.message}`);
166
- }
167
- }
177
+ for (const path of dirty) {
178
+ const contentsResult = this.getFileContents(path);
179
+ if (contentsResult.isFailure()) {
180
+ this._restoreUnsynced(deletions, dirty);
181
+ return fail(`${path}: ${contentsResult.message}`);
182
+ }
168
183
 
169
- for (const path of dirty) {
170
- const contentsResult = this.getFileContents(path);
171
- if (contentsResult.isFailure()) {
172
- return fail(`${path}: ${contentsResult.message}`);
184
+ const body: Record<string, unknown> = {
185
+ path,
186
+ contents: contentsResult.value
187
+ };
188
+ if (this._namespace) {
189
+ body.namespace = this._namespace;
190
+ }
191
+
192
+ const saveResult = await this._requestWithRetry<IHttpStorageFileResponse>('/file', {
193
+ method: 'PUT',
194
+ body: JSON.stringify(body)
195
+ });
196
+ if (saveResult.isFailure()) {
197
+ this._restoreUnsynced(deletions, dirty);
198
+ return fail(`sync ${path}: ${saveResult.message}`);
199
+ }
173
200
  }
201
+ }
174
202
 
175
- const body: Record<string, unknown> = {
176
- path,
177
- contents: contentsResult.value
178
- };
203
+ if (didWork) {
204
+ const syncBody: Record<string, unknown> = {};
179
205
  if (this._namespace) {
180
- body.namespace = this._namespace;
206
+ syncBody.namespace = this._namespace;
181
207
  }
182
208
 
183
- const saveResult = await this._requestWithRetry<IHttpStorageFileResponse>('/file', {
184
- method: 'PUT',
185
- body: JSON.stringify(body)
209
+ const syncResult = await this._requestWithRetry<IHttpStorageSyncResponse>('/sync', {
210
+ method: 'POST',
211
+ body: JSON.stringify(syncBody)
186
212
  });
187
- if (saveResult.isFailure()) {
188
- return fail(`sync ${path}: ${saveResult.message}`);
213
+
214
+ if (syncResult.isFailure()) {
215
+ return fail(syncResult.message);
189
216
  }
190
217
  }
218
+ return succeed(undefined);
219
+ }
191
220
 
192
- const syncBody: Record<string, unknown> = {};
193
- if (this._namespace) {
194
- syncBody.namespace = this._namespace;
221
+ /**
222
+ * Restores snapshotted items back into the live dirty sets so they
223
+ * are retried on the next sync attempt. Items that were added to
224
+ * the live sets while the sync was in flight are preserved.
225
+ */
226
+ private _restoreUnsynced(deletions: Set<string>, dirty: Set<string>): void {
227
+ for (const path of deletions) {
228
+ this._pendingDeletions.add(path);
229
+ }
230
+ for (const path of dirty) {
231
+ this._dirtyFiles.add(path);
195
232
  }
196
-
197
- const syncResult = await this._requestWithRetry<IHttpStorageSyncResponse>('/sync', {
198
- method: 'POST',
199
- body: JSON.stringify(syncBody)
200
- });
201
-
202
- return syncResult.isFailure() ? fail(syncResult.message) : succeed(undefined);
203
233
  }
204
234
 
205
235
  /**
@@ -1567,5 +1567,184 @@ describe('HttpTreeAccessors', () => {
1567
1567
  expect(putCalls).toHaveLength(1);
1568
1568
  expect(postCalls).toHaveLength(1);
1569
1569
  });
1570
+
1571
+ test('drains items written during an in-flight sync within the same _doSync call', async () => {
1572
+ // This tests the drain loop fix for the race where:
1573
+ // 1. sync starts, snapshots dirty set {a.json}
1574
+ // 2. during the PUT for a.json, a new write arrives adding a.json back
1575
+ // 3. _doSync loops back, snapshots the new item, and syncs it too
1576
+ // 4. Only one POST /sync is sent at the end, after all items are drained
1577
+ let callCount = 0;
1578
+ let saveHook: (() => void) | undefined;
1579
+
1580
+ const fetchImpl: typeof fetch = (url, init) => {
1581
+ callCount++;
1582
+ const urlStr = url.toString();
1583
+
1584
+ // Calls 1-2: fromHttp load
1585
+ if (callCount <= 2) {
1586
+ if (callCount === 1) {
1587
+ return Promise.resolve(makeMockResponse({ ok: true, jsonValue: rootWithOneFile('a.json') }));
1588
+ }
1589
+ return Promise.resolve(
1590
+ makeMockResponse({ ok: true, jsonValue: fileResponse('/a.json', '"orig"') })
1591
+ );
1592
+ }
1593
+
1594
+ // Call 3: first PUT /file for a.json — trigger a write mid-sync
1595
+ if (callCount === 3 && init?.method === 'PUT') {
1596
+ saveHook?.();
1597
+ return Promise.resolve(makeMockResponse({ ok: true, jsonValue: fileResponse('/a.json', '"v1"') }));
1598
+ }
1599
+
1600
+ // Call 4: second PUT /file for a.json (drain loop iteration)
1601
+ if (callCount === 4 && init?.method === 'PUT') {
1602
+ return Promise.resolve(makeMockResponse({ ok: true, jsonValue: fileResponse('/a.json', '"v2"') }));
1603
+ }
1604
+
1605
+ // Call 5: single POST /sync after drain loop exits
1606
+ if (callCount === 5 && urlStr.includes('/sync')) {
1607
+ return Promise.resolve(makeMockResponse({ ok: true, jsonValue: { synced: 1 } }));
1608
+ }
1609
+
1610
+ return Promise.reject(new Error(`Unexpected call #${callCount} to ${urlStr}`));
1611
+ };
1612
+
1613
+ const accessors = (
1614
+ await HttpTreeAccessors.fromHttp({
1615
+ baseUrl: 'http://localhost:3000',
1616
+ fetchImpl: fetchImpl as typeof fetch,
1617
+ mutable: true
1618
+ })
1619
+ ).orThrow();
1620
+
1621
+ // Write a.json
1622
+ accessors.saveFileContents('/a.json', '"v1"').orThrow();
1623
+
1624
+ // Set up the hook BEFORE starting sync — the mock fetch for the PUT
1625
+ // runs synchronously within syncToDisk(), so the hook must be in place.
1626
+ saveHook = () => {
1627
+ accessors.saveFileContents('/a.json', '"v2"').orThrow();
1628
+ };
1629
+
1630
+ // Start sync — during the first PUT, saveHook fires and writes v2.
1631
+ // The drain loop in _doSync picks this up and PUTs again before
1632
+ // sending the final POST /sync.
1633
+ const result = await accessors.syncToDisk();
1634
+
1635
+ expect(result).toSucceed();
1636
+ expect(accessors.isDirty()).toBe(false);
1637
+
1638
+ // 5 calls: 2 load + 2 PUTs (v1, v2) + 1 POST /sync
1639
+ expect(callCount).toBe(5);
1640
+ });
1641
+ });
1642
+
1643
+ describe('syncToDisk() error recovery', () => {
1644
+ test('restores dirty files when PUT fails so they can be retried', async () => {
1645
+ const { fetchImpl } = makeMockFetch([
1646
+ { ok: true, jsonValue: rootWithOneFile('data.json') },
1647
+ { ok: true, jsonValue: fileResponse('/data.json', '{}') },
1648
+ // PUT /file fails with non-transient error
1649
+ { ok: false, status: 500, textValue: 'Server Error' }
1650
+ ]);
1651
+
1652
+ const accessors = (
1653
+ await HttpTreeAccessors.fromHttp({
1654
+ baseUrl: 'http://localhost:3000',
1655
+ fetchImpl,
1656
+ mutable: true
1657
+ })
1658
+ ).orThrow();
1659
+
1660
+ accessors.saveFileContents('/data.json', '"updated"').orThrow();
1661
+ expect(accessors.isDirty()).toBe(true);
1662
+
1663
+ const result = await accessors.syncToDisk();
1664
+ expect(result).toFailWith(/sync.*data\.json.*server error/i);
1665
+
1666
+ // The file should still be dirty so a retry is possible
1667
+ expect(accessors.isDirty()).toBe(true);
1668
+ expect(accessors.getDirtyPaths()).toContain('/data.json');
1669
+ });
1670
+
1671
+ test('restores pending deletions when DELETE fails so they can be retried', async () => {
1672
+ const { fetchImpl } = makeMockFetch([
1673
+ { ok: true, jsonValue: rootWithOneFile('data.json') },
1674
+ { ok: true, jsonValue: fileResponse('/data.json', '{}') },
1675
+ // DELETE fails with non-transient error
1676
+ { ok: false, status: 500, textValue: 'Server Error' }
1677
+ ]);
1678
+
1679
+ const accessors = (
1680
+ await HttpTreeAccessors.fromHttp({
1681
+ baseUrl: 'http://localhost:3000',
1682
+ fetchImpl,
1683
+ mutable: true
1684
+ })
1685
+ ).orThrow();
1686
+
1687
+ accessors.deleteFile('/data.json').orThrow();
1688
+ expect(accessors.isDirty()).toBe(true);
1689
+
1690
+ const result = await accessors.syncToDisk();
1691
+ expect(result).toFailWith(/delete.*data\.json.*server error/i);
1692
+
1693
+ // The deletion should still be pending so a retry is possible
1694
+ expect(accessors.isDirty()).toBe(true);
1695
+ expect(accessors.getDirtyPaths()).toContain('/data.json');
1696
+ });
1697
+
1698
+ test('restores dirty files when POST /sync fails so they can be retried', async () => {
1699
+ jest.useFakeTimers();
1700
+ try {
1701
+ let callCount = 0;
1702
+ const fetchImpl: typeof fetch = (_url, _init) => {
1703
+ callCount++;
1704
+ if (callCount === 1) {
1705
+ return Promise.resolve(makeMockResponse({ ok: true, jsonValue: rootWithOneFile('data.json') }));
1706
+ }
1707
+ if (callCount === 2) {
1708
+ return Promise.resolve(
1709
+ makeMockResponse({ ok: true, jsonValue: fileResponse('/data.json', '{}') })
1710
+ );
1711
+ }
1712
+ if (callCount === 3) {
1713
+ // PUT succeeds
1714
+ return Promise.resolve(
1715
+ makeMockResponse({ ok: true, jsonValue: fileResponse('/data.json', '"v2"') })
1716
+ );
1717
+ }
1718
+ // All /sync attempts: persistent 503
1719
+ return Promise.resolve(
1720
+ makeMockResponse({ ok: false, status: 503, textValue: 'Service Unavailable' })
1721
+ );
1722
+ };
1723
+
1724
+ const accessors = (
1725
+ await HttpTreeAccessors.fromHttp({
1726
+ baseUrl: 'http://localhost:3000',
1727
+ fetchImpl,
1728
+ mutable: true
1729
+ })
1730
+ ).orThrow();
1731
+
1732
+ accessors.saveFileContents('/data.json', '"v2"').orThrow();
1733
+
1734
+ const syncPromise = accessors.syncToDisk();
1735
+ await jest.advanceTimersByTimeAsync(1500);
1736
+
1737
+ const result = await syncPromise;
1738
+ expect(result).toFailWith(/service unavailable/i);
1739
+
1740
+ // The PUT succeeded — data is on the server — so the file is no
1741
+ // longer dirty. Only the server-side /sync acknowledgement failed,
1742
+ // which is not something the client can meaningfully retry via
1743
+ // re-sending the file contents.
1744
+ expect(accessors.isDirty()).toBe(false);
1745
+ } finally {
1746
+ jest.useRealTimers();
1747
+ }
1748
+ });
1570
1749
  });
1571
1750
  });