@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.
- package/.rush/temp/{44b0369e8112f3c6f4248af466a3f35ad626d9d7.tar.log → 54f89319dd14b2acc47614d70128001f4227d01e.tar.log} +2 -2
- package/.rush/temp/chunked-rush-logs/ts-web-extras.build.chunks.jsonl +17 -17
- package/.rush/temp/operation/build/all.log +17 -17
- package/.rush/temp/operation/build/log-chunks.jsonl +17 -17
- package/.rush/temp/operation/build/state.json +1 -1
- package/dist/packlets/file-tree/httpTreeAccessors.js +72 -42
- package/dist/packlets/file-tree/httpTreeAccessors.js.map +1 -1
- package/dist/test/unit/httpTreeAccessors.test.js +135 -0
- package/dist/test/unit/httpTreeAccessors.test.js.map +1 -1
- package/dist/ts-web-extras.d.ts +6 -0
- package/lib/packlets/file-tree/httpTreeAccessors.d.ts +6 -0
- package/lib/packlets/file-tree/httpTreeAccessors.d.ts.map +1 -1
- package/lib/packlets/file-tree/httpTreeAccessors.js +72 -42
- package/lib/packlets/file-tree/httpTreeAccessors.js.map +1 -1
- package/lib/test/unit/httpTreeAccessors.test.js +135 -0
- package/lib/test/unit/httpTreeAccessors.test.js.map +1 -1
- package/package.json +10 -10
- package/rush-logs/ts-web-extras.build.cache.log +1 -1
- package/rush-logs/ts-web-extras.build.log +17 -17
- package/src/packlets/file-tree/httpTreeAccessors.ts +76 -46
- package/src/test/unit/httpTreeAccessors.test.ts +179 -0
- package/temp/build/typescript/ts_8nwakTlr.json +1 -1
- package/temp/coverage/crypto-utils/browserCryptoProvider.ts.html +1 -1
- package/temp/coverage/crypto-utils/browserHashProvider.ts.html +1 -1
- package/temp/coverage/crypto-utils/index.html +1 -1
- package/temp/coverage/file-tree/directoryHandleStore.ts.html +1 -1
- package/temp/coverage/file-tree/fileApiTreeAccessors.ts.html +1 -1
- package/temp/coverage/file-tree/fileSystemAccessTreeAccessors.ts.html +1 -1
- package/temp/coverage/file-tree/httpTreeAccessors.ts.html +333 -243
- package/temp/coverage/file-tree/index.html +11 -11
- package/temp/coverage/file-tree/localStorageTreeAccessors.ts.html +1 -1
- package/temp/coverage/helpers/fileTreeHelpers.ts.html +1 -1
- package/temp/coverage/helpers/index.html +1 -1
- package/temp/coverage/index.html +10 -10
- package/temp/coverage/lcov-report/crypto-utils/browserCryptoProvider.ts.html +1 -1
- package/temp/coverage/lcov-report/crypto-utils/browserHashProvider.ts.html +1 -1
- package/temp/coverage/lcov-report/crypto-utils/index.html +1 -1
- package/temp/coverage/lcov-report/file-tree/directoryHandleStore.ts.html +1 -1
- package/temp/coverage/lcov-report/file-tree/fileApiTreeAccessors.ts.html +1 -1
- package/temp/coverage/lcov-report/file-tree/fileSystemAccessTreeAccessors.ts.html +1 -1
- package/temp/coverage/lcov-report/file-tree/httpTreeAccessors.ts.html +333 -243
- package/temp/coverage/lcov-report/file-tree/index.html +11 -11
- package/temp/coverage/lcov-report/file-tree/localStorageTreeAccessors.ts.html +1 -1
- package/temp/coverage/lcov-report/helpers/fileTreeHelpers.ts.html +1 -1
- package/temp/coverage/lcov-report/helpers/index.html +1 -1
- package/temp/coverage/lcov-report/index.html +10 -10
- package/temp/coverage/lcov-report/url-utils/index.html +1 -1
- package/temp/coverage/lcov-report/url-utils/urlParams.ts.html +1 -1
- package/temp/coverage/lcov.info +470 -437
- package/temp/coverage/url-utils/index.html +1 -1
- package/temp/coverage/url-utils/urlParams.ts.html +1 -1
- package/temp/test/jest/haste-map-7492f1b44480e0cdd1f220078fb3afd8-c8dd6c3430605adeb2f1cadf4f75e791-8c9336785555d572065b28c111982ba4 +0 -0
- 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-
|
|
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/
|
|
50
|
-
"@fgv/
|
|
51
|
-
"@fgv/ts-
|
|
52
|
-
"@fgv/ts-
|
|
53
|
-
"@fgv/ts-
|
|
54
|
-
"@fgv/ts-
|
|
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-
|
|
60
|
-
"@fgv/ts-
|
|
61
|
-
"@fgv/ts-utils": "5.1.0-
|
|
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
|
".": {
|
|
@@ -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 (
|
|
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/
|
|
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.
|
|
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/
|
|
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.
|
|
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/
|
|
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/
|
|
28
|
-
[test:jest] PASS lib/test/unit/
|
|
29
|
-
[test:jest] PASS lib/test/unit/
|
|
30
|
-
[test:jest] PASS lib/test/unit/browserHashProvider.test.js (duration: 1.
|
|
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:
|
|
33
|
+
[test:jest] Successes: 379
|
|
34
34
|
[test:jest] Failures: 0
|
|
35
|
-
[test:jest] Total:
|
|
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.
|
|
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.
|
|
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 (
|
|
55
|
-
-------------------- Finished (
|
|
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
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
//
|
|
148
|
-
//
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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
|
-
|
|
176
|
-
|
|
177
|
-
contents: contentsResult.value
|
|
178
|
-
};
|
|
203
|
+
if (didWork) {
|
|
204
|
+
const syncBody: Record<string, unknown> = {};
|
|
179
205
|
if (this._namespace) {
|
|
180
|
-
|
|
206
|
+
syncBody.namespace = this._namespace;
|
|
181
207
|
}
|
|
182
208
|
|
|
183
|
-
const
|
|
184
|
-
method: '
|
|
185
|
-
body: JSON.stringify(
|
|
209
|
+
const syncResult = await this._requestWithRetry<IHttpStorageSyncResponse>('/sync', {
|
|
210
|
+
method: 'POST',
|
|
211
|
+
body: JSON.stringify(syncBody)
|
|
186
212
|
});
|
|
187
|
-
|
|
188
|
-
|
|
213
|
+
|
|
214
|
+
if (syncResult.isFailure()) {
|
|
215
|
+
return fail(syncResult.message);
|
|
189
216
|
}
|
|
190
217
|
}
|
|
218
|
+
return succeed(undefined);
|
|
219
|
+
}
|
|
191
220
|
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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
|
});
|