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

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 → 5e8fec808bd019a7b6a148d5097c65cd58a7d86d.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
@@ -1224,6 +1224,141 @@ describe('HttpTreeAccessors', () => {
1224
1224
  expect(putCalls).toHaveLength(1);
1225
1225
  expect(postCalls).toHaveLength(1);
1226
1226
  });
1227
+ test('drains items written during an in-flight sync within the same _doSync call', async () => {
1228
+ // This tests the drain loop fix for the race where:
1229
+ // 1. sync starts, snapshots dirty set {a.json}
1230
+ // 2. during the PUT for a.json, a new write arrives adding a.json back
1231
+ // 3. _doSync loops back, snapshots the new item, and syncs it too
1232
+ // 4. Only one POST /sync is sent at the end, after all items are drained
1233
+ let callCount = 0;
1234
+ let saveHook;
1235
+ const fetchImpl = (url, init) => {
1236
+ callCount++;
1237
+ const urlStr = url.toString();
1238
+ // Calls 1-2: fromHttp load
1239
+ if (callCount <= 2) {
1240
+ if (callCount === 1) {
1241
+ return Promise.resolve(makeMockResponse({ ok: true, jsonValue: rootWithOneFile('a.json') }));
1242
+ }
1243
+ return Promise.resolve(makeMockResponse({ ok: true, jsonValue: fileResponse('/a.json', '"orig"') }));
1244
+ }
1245
+ // Call 3: first PUT /file for a.json — trigger a write mid-sync
1246
+ if (callCount === 3 && (init === null || init === void 0 ? void 0 : init.method) === 'PUT') {
1247
+ saveHook === null || saveHook === void 0 ? void 0 : saveHook();
1248
+ return Promise.resolve(makeMockResponse({ ok: true, jsonValue: fileResponse('/a.json', '"v1"') }));
1249
+ }
1250
+ // Call 4: second PUT /file for a.json (drain loop iteration)
1251
+ if (callCount === 4 && (init === null || init === void 0 ? void 0 : init.method) === 'PUT') {
1252
+ return Promise.resolve(makeMockResponse({ ok: true, jsonValue: fileResponse('/a.json', '"v2"') }));
1253
+ }
1254
+ // Call 5: single POST /sync after drain loop exits
1255
+ if (callCount === 5 && urlStr.includes('/sync')) {
1256
+ return Promise.resolve(makeMockResponse({ ok: true, jsonValue: { synced: 1 } }));
1257
+ }
1258
+ return Promise.reject(new Error(`Unexpected call #${callCount} to ${urlStr}`));
1259
+ };
1260
+ const accessors = (await HttpTreeAccessors.fromHttp({
1261
+ baseUrl: 'http://localhost:3000',
1262
+ fetchImpl: fetchImpl,
1263
+ mutable: true
1264
+ })).orThrow();
1265
+ // Write a.json
1266
+ accessors.saveFileContents('/a.json', '"v1"').orThrow();
1267
+ // Set up the hook BEFORE starting sync — the mock fetch for the PUT
1268
+ // runs synchronously within syncToDisk(), so the hook must be in place.
1269
+ saveHook = () => {
1270
+ accessors.saveFileContents('/a.json', '"v2"').orThrow();
1271
+ };
1272
+ // Start sync — during the first PUT, saveHook fires and writes v2.
1273
+ // The drain loop in _doSync picks this up and PUTs again before
1274
+ // sending the final POST /sync.
1275
+ const result = await accessors.syncToDisk();
1276
+ expect(result).toSucceed();
1277
+ expect(accessors.isDirty()).toBe(false);
1278
+ // 5 calls: 2 load + 2 PUTs (v1, v2) + 1 POST /sync
1279
+ expect(callCount).toBe(5);
1280
+ });
1281
+ });
1282
+ describe('syncToDisk() error recovery', () => {
1283
+ test('restores dirty files when PUT fails so they can be retried', async () => {
1284
+ const { fetchImpl } = makeMockFetch([
1285
+ { ok: true, jsonValue: rootWithOneFile('data.json') },
1286
+ { ok: true, jsonValue: fileResponse('/data.json', '{}') },
1287
+ // PUT /file fails with non-transient error
1288
+ { ok: false, status: 500, textValue: 'Server Error' }
1289
+ ]);
1290
+ const accessors = (await HttpTreeAccessors.fromHttp({
1291
+ baseUrl: 'http://localhost:3000',
1292
+ fetchImpl,
1293
+ mutable: true
1294
+ })).orThrow();
1295
+ accessors.saveFileContents('/data.json', '"updated"').orThrow();
1296
+ expect(accessors.isDirty()).toBe(true);
1297
+ const result = await accessors.syncToDisk();
1298
+ expect(result).toFailWith(/sync.*data\.json.*server error/i);
1299
+ // The file should still be dirty so a retry is possible
1300
+ expect(accessors.isDirty()).toBe(true);
1301
+ expect(accessors.getDirtyPaths()).toContain('/data.json');
1302
+ });
1303
+ test('restores pending deletions when DELETE fails so they can be retried', async () => {
1304
+ const { fetchImpl } = makeMockFetch([
1305
+ { ok: true, jsonValue: rootWithOneFile('data.json') },
1306
+ { ok: true, jsonValue: fileResponse('/data.json', '{}') },
1307
+ // DELETE fails with non-transient error
1308
+ { ok: false, status: 500, textValue: 'Server Error' }
1309
+ ]);
1310
+ const accessors = (await HttpTreeAccessors.fromHttp({
1311
+ baseUrl: 'http://localhost:3000',
1312
+ fetchImpl,
1313
+ mutable: true
1314
+ })).orThrow();
1315
+ accessors.deleteFile('/data.json').orThrow();
1316
+ expect(accessors.isDirty()).toBe(true);
1317
+ const result = await accessors.syncToDisk();
1318
+ expect(result).toFailWith(/delete.*data\.json.*server error/i);
1319
+ // The deletion should still be pending so a retry is possible
1320
+ expect(accessors.isDirty()).toBe(true);
1321
+ expect(accessors.getDirtyPaths()).toContain('/data.json');
1322
+ });
1323
+ test('restores dirty files when POST /sync fails so they can be retried', async () => {
1324
+ jest.useFakeTimers();
1325
+ try {
1326
+ let callCount = 0;
1327
+ const fetchImpl = (_url, _init) => {
1328
+ callCount++;
1329
+ if (callCount === 1) {
1330
+ return Promise.resolve(makeMockResponse({ ok: true, jsonValue: rootWithOneFile('data.json') }));
1331
+ }
1332
+ if (callCount === 2) {
1333
+ return Promise.resolve(makeMockResponse({ ok: true, jsonValue: fileResponse('/data.json', '{}') }));
1334
+ }
1335
+ if (callCount === 3) {
1336
+ // PUT succeeds
1337
+ return Promise.resolve(makeMockResponse({ ok: true, jsonValue: fileResponse('/data.json', '"v2"') }));
1338
+ }
1339
+ // All /sync attempts: persistent 503
1340
+ return Promise.resolve(makeMockResponse({ ok: false, status: 503, textValue: 'Service Unavailable' }));
1341
+ };
1342
+ const accessors = (await HttpTreeAccessors.fromHttp({
1343
+ baseUrl: 'http://localhost:3000',
1344
+ fetchImpl,
1345
+ mutable: true
1346
+ })).orThrow();
1347
+ accessors.saveFileContents('/data.json', '"v2"').orThrow();
1348
+ const syncPromise = accessors.syncToDisk();
1349
+ await jest.advanceTimersByTimeAsync(1500);
1350
+ const result = await syncPromise;
1351
+ expect(result).toFailWith(/service unavailable/i);
1352
+ // The PUT succeeded — data is on the server — so the file is no
1353
+ // longer dirty. Only the server-side /sync acknowledgement failed,
1354
+ // which is not something the client can meaningfully retry via
1355
+ // re-sending the file contents.
1356
+ expect(accessors.isDirty()).toBe(false);
1357
+ }
1358
+ finally {
1359
+ jest.useRealTimers();
1360
+ }
1361
+ });
1227
1362
  });
1228
1363
  });
1229
1364
  //# sourceMappingURL=httpTreeAccessors.test.js.map