@iasiv5/dsh-skins 1.5.15 → 1.5.16
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/lib/client.js +177 -136
- package/package.json +1 -1
package/lib/client.js
CHANGED
|
@@ -2191,6 +2191,7 @@ ${references.join("\n")}`,
|
|
|
2191
2191
|
var CHANNEL = "dsh-skins";
|
|
2192
2192
|
var FETCH_TIMEOUT_MS = 3e3;
|
|
2193
2193
|
var UPLOAD_TIMEOUT_MS = 12e4;
|
|
2194
|
+
var RETRY = /* @__PURE__ */ Symbol("dsh-skins:write-dialect-retry");
|
|
2194
2195
|
function cloneSkins(skins) {
|
|
2195
2196
|
return skins === void 0 || skins === null ? {} : structuredClone(skins);
|
|
2196
2197
|
}
|
|
@@ -2320,31 +2321,25 @@ ${references.join("\n")}`,
|
|
|
2320
2321
|
void flushNow();
|
|
2321
2322
|
}, FLUSH_DEBOUNCE_MS);
|
|
2322
2323
|
}
|
|
2323
|
-
function
|
|
2324
|
-
if (flushTimer !== null) {
|
|
2325
|
-
clearTimeout(flushTimer);
|
|
2326
|
-
flushTimer = null;
|
|
2327
|
-
}
|
|
2328
|
-
if (previews.size === 0) return Promise.resolve({ flushed: 0 });
|
|
2329
|
-
const intent = [...previews.entries()];
|
|
2330
|
-
const buildOperations = () => intent.map(([composite, value]) => {
|
|
2331
|
-
const [skinId, key] = composite.split(" ");
|
|
2332
|
-
return value === null ? { op: "delete", skinId, key } : { op: "set", skinId, key, value };
|
|
2333
|
-
});
|
|
2334
|
-
const count = buildOperations().length;
|
|
2324
|
+
function runWriteOp(operations, policy) {
|
|
2335
2325
|
writeChain = writeChain.then(async () => {
|
|
2336
2326
|
try {
|
|
2337
|
-
if (disposed) return
|
|
2327
|
+
if (disposed) return policy.disposed();
|
|
2338
2328
|
const blocked = writeGate();
|
|
2339
|
-
if (blocked !== null) return
|
|
2329
|
+
if (blocked !== null) return policy.gated(blocked);
|
|
2340
2330
|
const attemptedRevision = snapshot.revision;
|
|
2341
2331
|
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
2342
|
-
|
|
2343
|
-
|
|
2344
|
-
|
|
2345
|
-
|
|
2346
|
-
|
|
2347
|
-
|
|
2332
|
+
let response;
|
|
2333
|
+
try {
|
|
2334
|
+
response = await request("/config", {
|
|
2335
|
+
method: "PATCH",
|
|
2336
|
+
headers: { "content-type": "application/json" },
|
|
2337
|
+
body: JSON.stringify({ baseRevision: snapshot.revision, operations: operations() })
|
|
2338
|
+
});
|
|
2339
|
+
} catch (error) {
|
|
2340
|
+
return policy.offline(error);
|
|
2341
|
+
}
|
|
2342
|
+
if (disposed) return policy.disposedMidflight();
|
|
2348
2343
|
if (response.status === 409) {
|
|
2349
2344
|
const conflictBody = await response.json().catch(() => null);
|
|
2350
2345
|
const code = conflictBody?.code;
|
|
@@ -2352,96 +2347,146 @@ ${references.join("\n")}`,
|
|
|
2352
2347
|
if (code === "STORE_READONLY") {
|
|
2353
2348
|
setFlushFailure(null, null);
|
|
2354
2349
|
setStatus("unsupported-readonly");
|
|
2355
|
-
return
|
|
2350
|
+
return policy.readOnly(conflictBody);
|
|
2356
2351
|
}
|
|
2357
2352
|
if (code === "REVISION_CONFLICT") {
|
|
2358
|
-
|
|
2359
|
-
|
|
2360
|
-
|
|
2361
|
-
return { flushed: 0, blocked: "conflict" };
|
|
2362
|
-
}
|
|
2363
|
-
const refreshed = await refetch();
|
|
2364
|
-
const mayRetry = refreshed.status === "synced" && refreshed.mode === "normal" && refreshed.revision !== attemptedRevision;
|
|
2365
|
-
if (mayRetry) continue;
|
|
2366
|
-
if (refreshed.mode === "recovery") {
|
|
2367
|
-
setFlushFailure(null, null);
|
|
2368
|
-
emit();
|
|
2369
|
-
return { flushed: 0, blocked: "recovery" };
|
|
2370
|
-
}
|
|
2371
|
-
if (refreshed.status === "offline-failed") {
|
|
2372
|
-
setFlushFailure(null, null);
|
|
2373
|
-
emit();
|
|
2374
|
-
return { flushed: 0, blocked: "offline" };
|
|
2375
|
-
}
|
|
2376
|
-
if (refreshed.status === "unsupported-readonly") {
|
|
2377
|
-
setFlushFailure(null, null);
|
|
2378
|
-
emit();
|
|
2379
|
-
return { flushed: 0, blocked: "unsupported" };
|
|
2380
|
-
}
|
|
2381
|
-
setFlushFailure("REVISION_CONFLICT", null);
|
|
2382
|
-
emit();
|
|
2383
|
-
return { flushed: 0, blocked: "conflict" };
|
|
2353
|
+
const decision2 = await policy.onConflict({ attempt, attemptedRevision, errorMessage });
|
|
2354
|
+
if (decision2 === RETRY) continue;
|
|
2355
|
+
return decision2;
|
|
2384
2356
|
}
|
|
2385
2357
|
if (code === "STORE_RECOVERY_REQUIRED") {
|
|
2386
|
-
const
|
|
2387
|
-
if (
|
|
2388
|
-
|
|
2389
|
-
emit();
|
|
2390
|
-
return { flushed: 0, blocked: "recovery" };
|
|
2391
|
-
}
|
|
2392
|
-
setFlushFailure(null, errorMessage);
|
|
2393
|
-
emit();
|
|
2394
|
-
return { flushed: 0, blocked: "error", errorMessage };
|
|
2395
|
-
}
|
|
2396
|
-
setFlushFailure(null, errorMessage);
|
|
2397
|
-
emit();
|
|
2398
|
-
return { flushed: 0, blocked: "error", errorMessage };
|
|
2399
|
-
}
|
|
2400
|
-
if (!response.ok) {
|
|
2401
|
-
const errorBody = await response.json().catch(() => null);
|
|
2402
|
-
const errorMessage = errorBody?.error ?? `HTTP ${response.status}`;
|
|
2403
|
-
const issues = Array.isArray(errorBody?.params?.issues) ? errorBody.params.issues : null;
|
|
2404
|
-
let excised = false;
|
|
2405
|
-
let onlyDangling = issues !== null && issues.length > 0;
|
|
2406
|
-
if (issues !== null) {
|
|
2407
|
-
for (const issue of issues) {
|
|
2408
|
-
const dangling = issue.code === "MISSING_ASSET" || issue.code === "BAD_ASSET";
|
|
2409
|
-
if (!dangling) onlyDangling = false;
|
|
2410
|
-
if (dangling && typeof issue.skinId === "string" && typeof issue.key === "string") {
|
|
2411
|
-
const composite = `${issue.skinId} ${issue.key}`;
|
|
2412
|
-
if (previews.has(composite)) {
|
|
2413
|
-
previews.delete(composite);
|
|
2414
|
-
excised = true;
|
|
2415
|
-
noteReverted({ skinId: issue.skinId, key: issue.key });
|
|
2416
|
-
}
|
|
2417
|
-
}
|
|
2358
|
+
const decision2 = await policy.onRecoveryRequired(errorMessage);
|
|
2359
|
+
if (decision2 === RETRY) {
|
|
2360
|
+
throw new Error("[dsh-skins] onRecoveryRequired must resolve, never RETRY");
|
|
2418
2361
|
}
|
|
2362
|
+
return decision2;
|
|
2419
2363
|
}
|
|
2420
|
-
const
|
|
2421
|
-
|
|
2422
|
-
|
|
2423
|
-
if (excised && previews.size > 0) void flushNow();
|
|
2424
|
-
if (healed) return { flushed: 0, blocked: null, selfHealed: true };
|
|
2425
|
-
return { flushed: 0, blocked: "error", errorMessage };
|
|
2364
|
+
const decision = await policy.onUnknown409({ attempt, errorMessage });
|
|
2365
|
+
if (decision === RETRY) continue;
|
|
2366
|
+
return decision;
|
|
2426
2367
|
}
|
|
2427
|
-
|
|
2428
|
-
|
|
2429
|
-
if (previews.get(composite) === value) previews.delete(composite);
|
|
2430
|
-
}
|
|
2431
|
-
setFlushFailure(null, null);
|
|
2432
|
-
notices = [];
|
|
2368
|
+
if (!response.ok) return await policy.onError(response);
|
|
2369
|
+
const result = await policy.onSuccess(response);
|
|
2433
2370
|
await refetch();
|
|
2434
2371
|
announce();
|
|
2435
|
-
return
|
|
2372
|
+
return result;
|
|
2373
|
+
}
|
|
2374
|
+
return policy.exhausted();
|
|
2375
|
+
} catch (error) {
|
|
2376
|
+
return policy.chainError(error);
|
|
2377
|
+
}
|
|
2378
|
+
});
|
|
2379
|
+
return writeChain;
|
|
2380
|
+
}
|
|
2381
|
+
function flushNow() {
|
|
2382
|
+
if (flushTimer !== null) {
|
|
2383
|
+
clearTimeout(flushTimer);
|
|
2384
|
+
flushTimer = null;
|
|
2385
|
+
}
|
|
2386
|
+
if (previews.size === 0) return Promise.resolve({ flushed: 0 });
|
|
2387
|
+
const intent = [...previews.entries()];
|
|
2388
|
+
const buildOperations = () => intent.map(([composite, value]) => {
|
|
2389
|
+
const [skinId, key] = composite.split(" ");
|
|
2390
|
+
return value === null ? { op: "delete", skinId, key } : { op: "set", skinId, key, value };
|
|
2391
|
+
});
|
|
2392
|
+
const count = buildOperations().length;
|
|
2393
|
+
return runWriteOp(buildOperations, {
|
|
2394
|
+
disposed: () => ({ flushed: 0, blocked: "disposed" }),
|
|
2395
|
+
disposedMidflight: () => ({ flushed: 0 }),
|
|
2396
|
+
gated: (blocked) => ({ flushed: 0, blocked }),
|
|
2397
|
+
offline: (error) => {
|
|
2398
|
+
throw error;
|
|
2399
|
+
},
|
|
2400
|
+
readOnly: (conflictBody) => ({ flushed: 0, blocked: "conflict", errorMessage: conflictBody?.error ?? "" }),
|
|
2401
|
+
// ADR-0003: retry once only after a conflict refetch has actually
|
|
2402
|
+
// applied a newer normal-mode snapshot.
|
|
2403
|
+
onConflict: async ({ attempt, attemptedRevision }) => {
|
|
2404
|
+
if (attempt === 1) {
|
|
2405
|
+
setFlushFailure("REVISION_CONFLICT", null);
|
|
2406
|
+
emit();
|
|
2407
|
+
return { flushed: 0, blocked: "conflict" };
|
|
2436
2408
|
}
|
|
2409
|
+
const refreshed = await refetch();
|
|
2410
|
+
const mayRetry = refreshed.status === "synced" && refreshed.mode === "normal" && refreshed.revision !== attemptedRevision;
|
|
2411
|
+
if (mayRetry) return RETRY;
|
|
2412
|
+
if (refreshed.mode === "recovery") {
|
|
2413
|
+
setFlushFailure(null, null);
|
|
2414
|
+
emit();
|
|
2415
|
+
return { flushed: 0, blocked: "recovery" };
|
|
2416
|
+
}
|
|
2417
|
+
if (refreshed.status === "offline-failed") {
|
|
2418
|
+
setFlushFailure(null, null);
|
|
2419
|
+
emit();
|
|
2420
|
+
return { flushed: 0, blocked: "offline" };
|
|
2421
|
+
}
|
|
2422
|
+
if (refreshed.status === "unsupported-readonly") {
|
|
2423
|
+
setFlushFailure(null, null);
|
|
2424
|
+
emit();
|
|
2425
|
+
return { flushed: 0, blocked: "unsupported" };
|
|
2426
|
+
}
|
|
2427
|
+
setFlushFailure("REVISION_CONFLICT", null);
|
|
2428
|
+
emit();
|
|
2437
2429
|
return { flushed: 0, blocked: "conflict" };
|
|
2438
|
-
}
|
|
2430
|
+
},
|
|
2431
|
+
onRecoveryRequired: async (errorMessage) => {
|
|
2432
|
+
const refreshed = await refetch();
|
|
2433
|
+
if (refreshed.mode === "recovery") {
|
|
2434
|
+
setFlushFailure(null, null);
|
|
2435
|
+
emit();
|
|
2436
|
+
return { flushed: 0, blocked: "recovery" };
|
|
2437
|
+
}
|
|
2438
|
+
setFlushFailure(null, errorMessage);
|
|
2439
|
+
emit();
|
|
2440
|
+
return { flushed: 0, blocked: "error", errorMessage };
|
|
2441
|
+
},
|
|
2442
|
+
onUnknown409: ({ errorMessage }) => {
|
|
2443
|
+
setFlushFailure(null, errorMessage);
|
|
2444
|
+
emit();
|
|
2445
|
+
return { flushed: 0, blocked: "error", errorMessage };
|
|
2446
|
+
},
|
|
2447
|
+
onError: async (response) => {
|
|
2448
|
+
const errorBody = await response.json().catch(() => null);
|
|
2449
|
+
const errorMessage = errorBody?.error ?? `HTTP ${response.status}`;
|
|
2450
|
+
const issues = Array.isArray(errorBody?.params?.issues) ? errorBody.params.issues : null;
|
|
2451
|
+
let excised = false;
|
|
2452
|
+
let onlyDangling = issues !== null && issues.length > 0;
|
|
2453
|
+
if (issues !== null) {
|
|
2454
|
+
for (const issue of issues) {
|
|
2455
|
+
const dangling = issue.code === "MISSING_ASSET" || issue.code === "BAD_ASSET";
|
|
2456
|
+
if (!dangling) onlyDangling = false;
|
|
2457
|
+
if (dangling && typeof issue.skinId === "string" && typeof issue.key === "string") {
|
|
2458
|
+
const composite = `${issue.skinId} ${issue.key}`;
|
|
2459
|
+
if (previews.has(composite)) {
|
|
2460
|
+
previews.delete(composite);
|
|
2461
|
+
excised = true;
|
|
2462
|
+
noteReverted({ skinId: issue.skinId, key: issue.key });
|
|
2463
|
+
}
|
|
2464
|
+
}
|
|
2465
|
+
}
|
|
2466
|
+
}
|
|
2467
|
+
const healed = onlyDangling && excised;
|
|
2468
|
+
setFlushFailure(null, healed ? null : errorMessage);
|
|
2469
|
+
emit();
|
|
2470
|
+
if (excised && previews.size > 0) void flushNow();
|
|
2471
|
+
if (healed) return { flushed: 0, blocked: null, selfHealed: true };
|
|
2472
|
+
return { flushed: 0, blocked: "error", errorMessage };
|
|
2473
|
+
},
|
|
2474
|
+
onSuccess: async (response) => {
|
|
2475
|
+
await response.json().catch(() => null);
|
|
2476
|
+
for (const [composite, value] of intent) {
|
|
2477
|
+
if (previews.get(composite) === value) previews.delete(composite);
|
|
2478
|
+
}
|
|
2479
|
+
setFlushFailure(null, null);
|
|
2480
|
+
notices = [];
|
|
2481
|
+
return { flushed: count };
|
|
2482
|
+
},
|
|
2483
|
+
exhausted: () => ({ flushed: 0, blocked: "conflict" }),
|
|
2484
|
+
chainError: () => {
|
|
2439
2485
|
setFlushFailure(null, null);
|
|
2440
2486
|
emit();
|
|
2441
2487
|
return { flushed: 0, blocked: "error" };
|
|
2442
2488
|
}
|
|
2443
2489
|
});
|
|
2444
|
-
return writeChain;
|
|
2445
2490
|
}
|
|
2446
2491
|
function announce() {
|
|
2447
2492
|
try {
|
|
@@ -2567,55 +2612,51 @@ ${references.join("\n")}`,
|
|
|
2567
2612
|
return { asset: body.asset };
|
|
2568
2613
|
}
|
|
2569
2614
|
function runGalleryOp(operations, goalAchieved) {
|
|
2570
|
-
|
|
2571
|
-
|
|
2572
|
-
|
|
2573
|
-
if (
|
|
2574
|
-
|
|
2575
|
-
|
|
2576
|
-
|
|
2577
|
-
|
|
2578
|
-
|
|
2579
|
-
|
|
2580
|
-
|
|
2581
|
-
|
|
2582
|
-
|
|
2583
|
-
|
|
2584
|
-
|
|
2585
|
-
|
|
2586
|
-
|
|
2587
|
-
|
|
2588
|
-
|
|
2589
|
-
|
|
2590
|
-
|
|
2591
|
-
|
|
2592
|
-
|
|
2615
|
+
const conflictDisposition = async ({ attempt }) => {
|
|
2616
|
+
await refetch();
|
|
2617
|
+
if (goalAchieved()) return { affectedSkins: [] };
|
|
2618
|
+
if (attempt === 1) {
|
|
2619
|
+
setFlushFailure(null, null);
|
|
2620
|
+
emit();
|
|
2621
|
+
return { error: "REVISION_CONFLICT" };
|
|
2622
|
+
}
|
|
2623
|
+
return RETRY;
|
|
2624
|
+
};
|
|
2625
|
+
return runWriteOp(() => operations, {
|
|
2626
|
+
disposed: () => ({ error: "disposed" }),
|
|
2627
|
+
disposedMidflight: () => ({ error: "disposed" }),
|
|
2628
|
+
gated: (blocked) => ({ error: blocked }),
|
|
2629
|
+
offline: () => ({ error: "OFFLINE" }),
|
|
2630
|
+
readOnly: () => ({ error: "conflict" }),
|
|
2631
|
+
onConflict: conflictDisposition,
|
|
2632
|
+
// ADR-0018 alignment: the recovery code is dispatched BY NAME now —
|
|
2633
|
+
// never folded into the blind conflict retry. Code passthrough: the
|
|
2634
|
+
// Host-stable code reaches the caller unsuppressed (host-errors.js
|
|
2635
|
+
// resolves it), after one refetch so the recovery banner can land.
|
|
2636
|
+
onRecoveryRequired: async () => {
|
|
2637
|
+
await refetch();
|
|
2638
|
+
return { error: "STORE_RECOVERY_REQUIRED" };
|
|
2639
|
+
},
|
|
2640
|
+
onUnknown409: conflictDisposition,
|
|
2641
|
+
// unknown 409s keep the goal-first read
|
|
2642
|
+
onError: async (response) => {
|
|
2643
|
+
const body = await response.json().catch(() => null);
|
|
2644
|
+
if (body?.code === "INVALID_CONFIG" && body.params?.issues?.some((issue) => issue.code === "ASSET_NOT_FOUND")) {
|
|
2593
2645
|
await refetch();
|
|
2594
2646
|
if (goalAchieved()) return { affectedSkins: [] };
|
|
2595
|
-
|
|
2596
|
-
setFlushFailure(null, null);
|
|
2597
|
-
emit();
|
|
2598
|
-
return { error: "REVISION_CONFLICT" };
|
|
2599
|
-
}
|
|
2600
|
-
continue;
|
|
2601
|
-
}
|
|
2602
|
-
if (!response.ok) {
|
|
2603
|
-
const body2 = await response.json().catch(() => null);
|
|
2604
|
-
if (body2?.code === "INVALID_CONFIG" && body2.params?.issues?.some((issue) => issue.code === "ASSET_NOT_FOUND")) {
|
|
2605
|
-
await refetch();
|
|
2606
|
-
if (goalAchieved()) return { affectedSkins: [] };
|
|
2607
|
-
return { error: "ASSET_NOT_FOUND" };
|
|
2608
|
-
}
|
|
2609
|
-
return { error: body2?.code ?? `HTTP ${response.status}` };
|
|
2647
|
+
return { error: "ASSET_NOT_FOUND" };
|
|
2610
2648
|
}
|
|
2649
|
+
return { error: body?.code ?? `HTTP ${response.status}` };
|
|
2650
|
+
},
|
|
2651
|
+
onSuccess: async (response) => {
|
|
2611
2652
|
const body = await response.json().catch(() => null);
|
|
2612
|
-
await refetch();
|
|
2613
|
-
announce();
|
|
2614
2653
|
return { affectedSkins: body?.affectedSkins ?? [], revision: body?.revision };
|
|
2654
|
+
},
|
|
2655
|
+
exhausted: () => ({ error: "REVISION_CONFLICT" }),
|
|
2656
|
+
chainError: (error) => {
|
|
2657
|
+
throw error;
|
|
2615
2658
|
}
|
|
2616
|
-
return { error: "REVISION_CONFLICT" };
|
|
2617
2659
|
});
|
|
2618
|
-
return writeChain;
|
|
2619
2660
|
}
|
|
2620
2661
|
async function deleteImage(id) {
|
|
2621
2662
|
const blocked = writeGate();
|