velocious 1.0.643 → 1.0.644

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 (48) hide show
  1. package/README.md +10 -1
  2. package/build/src/sync/assets/cache.d.ts +288 -0
  3. package/build/src/sync/assets/cache.d.ts.map +1 -0
  4. package/build/src/sync/assets/cache.js +883 -0
  5. package/build/src/sync/assets/types.d.ts +199 -0
  6. package/build/src/sync/assets/types.d.ts.map +1 -0
  7. package/build/src/sync/assets/types.js +56 -0
  8. package/build/src/testing/test-runner.d.ts +8 -0
  9. package/build/src/testing/test-runner.d.ts.map +1 -1
  10. package/build/src/testing/test-runner.js +49 -548
  11. package/build/src/testing/velocious-attempt-executor.d.ts +69 -0
  12. package/build/src/testing/velocious-attempt-executor.d.ts.map +1 -0
  13. package/build/src/testing/velocious-attempt-executor.js +402 -0
  14. package/build/src/testing/velocious-runner-reporter.d.ts +72 -0
  15. package/build/src/testing/velocious-runner-reporter.d.ts.map +1 -0
  16. package/build/src/testing/velocious-runner-reporter.js +152 -0
  17. package/build/src/testing/velocious-suite-hook-executor.d.ts +37 -0
  18. package/build/src/testing/velocious-suite-hook-executor.d.ts.map +1 -0
  19. package/build/src/testing/velocious-suite-hook-executor.js +66 -0
  20. package/build/src/testing/velocious-test-arguments.d.ts +34 -0
  21. package/build/src/testing/velocious-test-arguments.d.ts.map +1 -0
  22. package/build/src/testing/velocious-test-arguments.js +47 -0
  23. package/build/src/utils/sha256-bytes-hex.d.ts +7 -0
  24. package/build/src/utils/sha256-bytes-hex.d.ts.map +1 -0
  25. package/build/src/utils/sha256-bytes-hex.js +117 -0
  26. package/build/src/utils/sha256-hex.d.ts +2 -4
  27. package/build/src/utils/sha256-hex.d.ts.map +1 -1
  28. package/build/src/utils/sha256-hex.js +6 -120
  29. package/build/sync/assets/cache.js +998 -0
  30. package/build/sync/assets/types.js +62 -0
  31. package/build/testing/test-runner.js +46 -584
  32. package/build/testing/velocious-attempt-executor.js +431 -0
  33. package/build/testing/velocious-runner-reporter.js +166 -0
  34. package/build/testing/velocious-suite-hook-executor.js +71 -0
  35. package/build/testing/velocious-test-arguments.js +55 -0
  36. package/build/tsconfig.tsbuildinfo +1 -1
  37. package/build/utils/sha256-bytes-hex.js +132 -0
  38. package/build/utils/sha256-hex.js +7 -135
  39. package/package.json +1 -1
  40. package/src/sync/assets/cache.js +998 -0
  41. package/src/sync/assets/types.js +62 -0
  42. package/src/testing/test-runner.js +46 -584
  43. package/src/testing/velocious-attempt-executor.js +431 -0
  44. package/src/testing/velocious-runner-reporter.js +166 -0
  45. package/src/testing/velocious-suite-hook-executor.js +71 -0
  46. package/src/testing/velocious-test-arguments.js +55 -0
  47. package/src/utils/sha256-bytes-hex.js +132 -0
  48. package/src/utils/sha256-hex.js +7 -135
@@ -0,0 +1,998 @@
1
+ // @ts-check
2
+
3
+ import sha256BytesHex from "../../utils/sha256-bytes-hex.js"
4
+
5
+ /**
6
+ * @typedef {{
7
+ * byteSize: number,
8
+ * contentType: string | null,
9
+ * promise: Promise<{error: Error, uri: null} | {error: null, uri: string}>
10
+ * }} SynchronizedAssetDownloadFlight */
11
+
12
+ const CACHE_STATE_VERSION = 1
13
+ const DEFAULT_RETRY_BASE_DELAY_MS = 1000
14
+ const DEFAULT_RETRY_MAX_DELAY_MS = 1000 * 60 * 5
15
+
16
+ /**
17
+ * Core synchronized asset cache. Platform packages own byte and metadata
18
+ * persistence while this class owns policy, integrity, and lifecycle.
19
+ */
20
+ export default class SynchronizedAssetCache {
21
+ /**
22
+ * Creates a synchronized asset cache.
23
+ * @param {object} args Options.
24
+ * @param {string} args.accountId Authenticated account namespace.
25
+ * @param {import("./types.js").SynchronizedAssetCacheAdapter} args.adapter Platform storage adapter.
26
+ * @param {(descriptor: import("./types.js").SynchronizedAssetCacheDescriptor) => Promise<Uint8Array>} args.download Authenticated byte downloader.
27
+ * @param {number} args.maxBytes Maximum evictable cache size.
28
+ * @param {() => Date} [args.now] Clock.
29
+ * @param {number} [args.retryBaseDelayMs] Initial retry delay.
30
+ * @param {number} [args.retryMaxDelayMs] Maximum retry delay.
31
+ */
32
+ constructor({accountId, adapter, download, maxBytes, now = () => new Date(), retryBaseDelayMs = DEFAULT_RETRY_BASE_DELAY_MS, retryMaxDelayMs = DEFAULT_RETRY_MAX_DELAY_MS}) {
33
+ if (!accountId) throw new Error("Synchronized asset cache requires an account id")
34
+ if (!Number.isSafeInteger(maxBytes) || maxBytes < 0) throw new Error("Synchronized asset cache maxBytes must be a non-negative safe integer")
35
+ if (!Number.isSafeInteger(retryBaseDelayMs) || retryBaseDelayMs < 1) throw new Error("Synchronized asset cache retryBaseDelayMs must be a positive safe integer")
36
+ if (!Number.isSafeInteger(retryMaxDelayMs) || retryMaxDelayMs < retryBaseDelayMs) throw new Error("Synchronized asset cache retryMaxDelayMs must be at least retryBaseDelayMs")
37
+
38
+ this.accountId = accountId
39
+ this.adapter = adapter
40
+ this.download = download
41
+ this.maxBytes = maxBytes
42
+ this.now = now
43
+ this.retryBaseDelayMs = retryBaseDelayMs
44
+ this.retryMaxDelayMs = retryMaxDelayMs
45
+ /** @type {Map<string, number>} */
46
+ this.activeDigestCounts = new Map()
47
+ /** @type {Map<string, Promise<void>>} */
48
+ this.deletionPromises = new Map()
49
+ /** @type {Set<string>} */
50
+ this.cleanupRequiredAfterReleaseDigests = new Set()
51
+ /** @type {Promise<number>} */
52
+ this.cleanupPromise = Promise.resolve(0)
53
+ /** @type {Map<string, SynchronizedAssetDownloadFlight>} */
54
+ this.downloadPromises = new Map()
55
+ /** @type {import("./types.js").SynchronizedAssetCacheState | null} */
56
+ this.state = null
57
+ /** @type {Promise<import("./types.js").SynchronizedAssetCacheState> | null} */
58
+ this.statePromise = null
59
+ /** @type {Promise<void>} */
60
+ this.saveStatePromise = Promise.resolve()
61
+ /** @type {Map<string, Promise<import("./types.js").SynchronizedAssetCacheSynchronizationResult>>} */
62
+ this.synchronizePromises = new Map()
63
+ }
64
+
65
+ /**
66
+ * Reconciles the immutable descriptors for one synchronized scope and
67
+ * downloads eligible eager assets.
68
+ * @param {object} args Reconciliation inputs.
69
+ * @param {import("./types.js").SynchronizedAssetCacheDescriptor[]} args.descriptors Current descriptors in the scope.
70
+ * @param {boolean} args.online Whether authenticated downloads are available.
71
+ * @param {string} args.scopeKey Stable synchronized scope key.
72
+ * @returns {Promise<import("./types.js").SynchronizedAssetCacheSynchronizationResult>} Synchronization result.
73
+ */
74
+ async synchronize({descriptors, online, scopeKey}) {
75
+ const synchronize = async () => await this.synchronizeScope({descriptors, online, scopeKey})
76
+ const previousSynchronizationPromise = this.synchronizePromises.get(scopeKey)
77
+ const synchronizationPromise = previousSynchronizationPromise
78
+ ? previousSynchronizationPromise.then(synchronize, synchronize)
79
+ : synchronize()
80
+
81
+ this.synchronizePromises.set(scopeKey, synchronizationPromise)
82
+
83
+ try {
84
+ return await synchronizationPromise
85
+ } finally {
86
+ if (this.synchronizePromises.get(scopeKey) === synchronizationPromise) {
87
+ this.synchronizePromises.delete(scopeKey)
88
+ }
89
+ }
90
+ }
91
+
92
+ /**
93
+ * Runs one scope synchronization after prior calls for that scope finish.
94
+ * @param {object} args Reconciliation inputs.
95
+ * @param {import("./types.js").SynchronizedAssetCacheDescriptor[]} args.descriptors Current descriptors in the scope.
96
+ * @param {boolean} args.online Whether authenticated downloads are available.
97
+ * @param {string} args.scopeKey Stable synchronized scope key.
98
+ * @returns {Promise<import("./types.js").SynchronizedAssetCacheSynchronizationResult>} Synchronization result.
99
+ */
100
+ async synchronizeScope({descriptors, online, scopeKey}) {
101
+ await this.loadState()
102
+ /** @type {Map<string, import("./types.js").SynchronizedAssetCacheDescriptor[]>} */
103
+ const descriptorsByDigest = new Map()
104
+ /** @type {import("./types.js").SynchronizedAssetCacheFailure[]} */
105
+ const failures = []
106
+ /** @type {Set<string>} */
107
+ const activeDigests = new Set()
108
+
109
+ for (const descriptor of descriptors) {
110
+ const digestDescriptors = descriptorsByDigest.get(descriptor.digest) || []
111
+
112
+ digestDescriptors.push(descriptor)
113
+ descriptorsByDigest.set(descriptor.digest, digestDescriptors)
114
+ }
115
+
116
+ try {
117
+ for (const digest of descriptorsByDigest.keys()) {
118
+ await this.beginActiveDigest(digest)
119
+ activeDigests.add(digest)
120
+ }
121
+
122
+ const entriesById = await this.reconcileDescriptors({descriptors, scopeKey})
123
+
124
+ await this.deleteUnreferencedDigests()
125
+
126
+ for (const [digest, digestDescriptors] of descriptorsByDigest) {
127
+ const eagerDescriptors = online ? digestDescriptors.filter((descriptor) => descriptor.fetch === "eager") : []
128
+
129
+ if (eagerDescriptors.length === 0) {
130
+ activeDigests.delete(digest)
131
+ await this.finishActiveDigest(digest)
132
+ continue
133
+ }
134
+
135
+ /** @type {import("./types.js").SynchronizedAssetCacheEntry[]} */
136
+ const eagerEntries = []
137
+
138
+ for (const descriptor of eagerDescriptors) {
139
+ const entry = entriesById.get(descriptor.id)
140
+
141
+ if (!entry) throw new Error(`Missing reconciled synchronized asset descriptor ${descriptor.id}`)
142
+
143
+ eagerEntries.push(entry)
144
+ }
145
+
146
+ if (eagerEntries.some((entry) => this.retryEligible(entry))) {
147
+ const cacheResult = await this.ensureCachedWhileActive(eagerEntries)
148
+
149
+ if (cacheResult.error) {
150
+ for (const entry of eagerEntries) {
151
+ failures.push({assetId: entry.descriptor.id, error: cacheResult.error})
152
+ }
153
+ }
154
+ }
155
+
156
+ activeDigests.delete(digest)
157
+ await this.finishActiveDigest(digest)
158
+ await this.cleanup()
159
+ }
160
+ } finally {
161
+ await this.finishActiveDigests([...activeDigests])
162
+ }
163
+
164
+ await this.cleanup()
165
+
166
+ return {
167
+ failures,
168
+ missingRequiredAssetIds: await this.missingRequiredAssetIds(scopeKey)
169
+ }
170
+ }
171
+
172
+ /**
173
+ * Resolves a cached asset URI, downloading it on demand when allowed.
174
+ * @param {object} args Resolution inputs.
175
+ * @param {string} args.assetId Attachment descriptor id.
176
+ * @param {boolean} args.online Whether authenticated downloads are available.
177
+ * @returns {Promise<string | null>} Cached asset URI.
178
+ */
179
+ async resolve({assetId, online}) {
180
+ const state = await this.loadState()
181
+ const entry = state.assets.find((candidate) => candidate.descriptor.id === assetId)
182
+
183
+ if (!entry) return null
184
+
185
+ const digest = entry.descriptor.digest
186
+ let resolvedUri = null
187
+ let shouldCleanup = false
188
+
189
+ await this.beginActiveDigest(digest)
190
+
191
+ try {
192
+ const cachedUri = await this.cachedUriWhileActive(entry)
193
+
194
+ if (cachedUri) {
195
+ entry.lastAccessedAt = this.nowMilliseconds()
196
+ entry.status = "cached"
197
+ await this.saveState()
198
+
199
+ resolvedUri = cachedUri
200
+ } else if (online && this.retryEligible(entry)) {
201
+ const cacheResult = await this.ensureCachedWhileActive([entry])
202
+
203
+ if (cacheResult.error) throw cacheResult.error
204
+
205
+ if (cacheResult.uri) {
206
+ resolvedUri = cacheResult.uri
207
+ shouldCleanup = true
208
+ }
209
+ }
210
+ } finally {
211
+ await this.finishActiveDigest(digest, shouldCleanup ? new Set([digest]) : new Set())
212
+ }
213
+
214
+ if (shouldCleanup) await this.cleanup(new Set([digest]))
215
+ const requiresUnprotectedCleanup = shouldCleanup || (entry.descriptor.byteSize > this.maxBytes && !state.assets.some((candidate) => {
216
+ return candidate.descriptor.digest === digest && candidate.descriptor.retention === "durable"
217
+ }))
218
+
219
+ if (requiresUnprotectedCleanup) await this.cleanup()
220
+ if (!resolvedUri) return null
221
+ const resolvedEntry = state.assets.find((candidate) => candidate.descriptor.id === assetId && candidate.descriptor.digest === digest)
222
+
223
+ if (!resolvedEntry) return null
224
+
225
+ return await this.cachedUri(resolvedEntry)
226
+ }
227
+
228
+ /**
229
+ * Evicts least-recently-used blobs until the unique cached byte total is
230
+ * within the configured budget. A blob stays durable when any live
231
+ * descriptor reference declares durable retention.
232
+ * @param {Set<string>} [protectedDigests] Digests needed by the active caller.
233
+ * @returns {Promise<number>} Bytes removed.
234
+ */
235
+ async cleanup(protectedDigests = new Set()) {
236
+ const cleanup = async () => await this.performCleanup(protectedDigests)
237
+ const cleanupPromise = this.cleanupPromise.then(cleanup, cleanup)
238
+
239
+ this.cleanupPromise = cleanupPromise
240
+
241
+ return await cleanupPromise
242
+ }
243
+
244
+ /**
245
+ * Performs one serialized eviction pass.
246
+ * @param {Set<string>} protectedDigests Digests needed by the active caller.
247
+ * @returns {Promise<number>} Bytes removed.
248
+ */
249
+ async performCleanup(protectedDigests) {
250
+ const state = await this.loadState()
251
+ /** @type {Map<string, import("./types.js").SynchronizedAssetCacheEntry[]>} */
252
+ const entriesByDigest = new Map()
253
+
254
+ for (const entry of state.assets) {
255
+ const digestEntries = entriesByDigest.get(entry.descriptor.digest) || []
256
+
257
+ digestEntries.push(entry)
258
+ entriesByDigest.set(entry.descriptor.digest, digestEntries)
259
+ }
260
+
261
+ /** @type {{byteSize: number, digest: string, lastAccessedAt: number}[]} */
262
+ const cachedBlobs = []
263
+ let cachedBytes = 0
264
+
265
+ for (const [digest, references] of entriesByDigest) {
266
+ const uri = await this.adapter.blobUri({accountId: this.accountId, digest})
267
+
268
+ if (!uri) {
269
+ for (const entry of references) {
270
+ if (entry.status === "cached") entry.status = "missing"
271
+ }
272
+ continue
273
+ }
274
+
275
+ const byteSize = references[0].descriptor.byteSize
276
+
277
+ cachedBytes += byteSize
278
+ cachedBlobs.push({
279
+ byteSize,
280
+ digest,
281
+ lastAccessedAt: Math.max(...references.map((entry) => entry.lastAccessedAt))
282
+ })
283
+ }
284
+
285
+ let removedBytes = 0
286
+
287
+ while (cachedBlobs.length > 0) {
288
+ if (cachedBytes <= this.maxBytes) break
289
+
290
+ for (const cachedBlob of cachedBlobs) {
291
+ const currentReferences = state.assets.filter((entry) => entry.descriptor.digest === cachedBlob.digest)
292
+
293
+ if (currentReferences.length > 0) {
294
+ cachedBlob.lastAccessedAt = Math.max(...currentReferences.map((entry) => entry.lastAccessedAt))
295
+ }
296
+ }
297
+
298
+ cachedBlobs.sort((left, right) => left.lastAccessedAt - right.lastAccessedAt || left.digest.localeCompare(right.digest))
299
+
300
+ const blob = cachedBlobs.shift()
301
+
302
+ if (!blob) throw new Error("Expected a synchronized asset cache eviction candidate")
303
+ if (protectedDigests.has(blob.digest)) continue
304
+ let blobWasAlreadyMissing = false
305
+ let deletionChecked = false
306
+ const deleted = await this.deleteDigestIfInactive(blob.digest, async () => {
307
+ deletionChecked = true
308
+
309
+ if (!this.state) throw new Error("Cannot clean synchronized asset blobs before loading state")
310
+
311
+ const currentUri = await this.adapter.blobUri({accountId: this.accountId, digest: blob.digest})
312
+ const currentReferences = this.state.assets.filter((entry) => entry.descriptor.digest === blob.digest)
313
+
314
+ if (!currentUri) {
315
+ blobWasAlreadyMissing = true
316
+
317
+ for (const entry of currentReferences) {
318
+ if (entry.status === "cached") entry.status = "missing"
319
+ }
320
+
321
+ return false
322
+ }
323
+ if (currentReferences.some((entry) => entry.descriptor.retention === "durable")) return false
324
+
325
+ await this.adapter.deleteBlob({accountId: this.accountId, digest: blob.digest})
326
+
327
+ for (const entry of currentReferences) {
328
+ entry.attempts = 0
329
+ entry.nextRetryAt = null
330
+ entry.status = "missing"
331
+ }
332
+
333
+ return true
334
+ })
335
+
336
+ if (!deletionChecked) this.cleanupRequiredAfterReleaseDigests.add(blob.digest)
337
+ if (blobWasAlreadyMissing) cachedBytes -= blob.byteSize
338
+ if (!deleted) continue
339
+
340
+ cachedBytes -= blob.byteSize
341
+ removedBytes += blob.byteSize
342
+ }
343
+
344
+ await this.saveState()
345
+
346
+ return removedBytes
347
+ }
348
+
349
+ /**
350
+ * Loads cache state once for this cache instance.
351
+ * @returns {Promise<import("./types.js").SynchronizedAssetCacheState>} Loaded state.
352
+ */
353
+ async loadState() {
354
+ if (this.state) return this.state
355
+ if (this.statePromise) return await this.statePromise
356
+
357
+ this.statePromise = this.loadStateFromAdapter()
358
+
359
+ try {
360
+ this.state = await this.statePromise
361
+
362
+ return this.state
363
+ } finally {
364
+ this.statePromise = null
365
+ }
366
+ }
367
+
368
+ /**
369
+ * Loads and recovers persisted cache state.
370
+ * @returns {Promise<import("./types.js").SynchronizedAssetCacheState>} Loaded state.
371
+ */
372
+ async loadStateFromAdapter() {
373
+ const loadedState = await this.adapter.loadState({accountId: this.accountId})
374
+
375
+ if (!loadedState) return {assets: [], pendingDeletionDigests: [], version: CACHE_STATE_VERSION}
376
+ if (loadedState.version !== CACHE_STATE_VERSION) {
377
+ throw new Error(`Unsupported synchronized asset cache state version: ${loadedState.version}`)
378
+ }
379
+
380
+ let recoveredInterruptedDownload = false
381
+
382
+ for (const entry of loadedState.assets) {
383
+ if (entry.status !== "downloading") continue
384
+
385
+ entry.attempts += 1
386
+ entry.nextRetryAt = this.nowMilliseconds()
387
+ entry.status = "failed"
388
+ recoveredInterruptedDownload = true
389
+ }
390
+
391
+ if (recoveredInterruptedDownload) {
392
+ await this.adapter.saveState({accountId: this.accountId, state: loadedState})
393
+ }
394
+
395
+ return loadedState
396
+ }
397
+
398
+ /**
399
+ * Persists the current cache state.
400
+ * @returns {Promise<void>} Resolves after state persistence.
401
+ */
402
+ async saveState() {
403
+ if (!this.state) throw new Error("Cannot save synchronized asset cache before loading state")
404
+ const state = this.copyState(this.state)
405
+
406
+ const persist = async () => {
407
+ await this.adapter.saveState({accountId: this.accountId, state})
408
+ }
409
+
410
+ await this.serializeStatePersistence(persist)
411
+ }
412
+
413
+ /**
414
+ * Persists a detached reconciliation before exposing it through shared state.
415
+ * @param {object} args Reconciliation inputs.
416
+ * @param {import("./types.js").SynchronizedAssetCacheDescriptor[]} args.descriptors Current descriptors in the scope.
417
+ * @param {string} args.scopeKey Stable synchronized scope key.
418
+ * @returns {Promise<Map<string, import("./types.js").SynchronizedAssetCacheEntry>>} Reconciled live entries by id.
419
+ */
420
+ async reconcileDescriptors({descriptors, scopeKey}) {
421
+ /** @type {Map<string, import("./types.js").SynchronizedAssetCacheEntry> | null} */
422
+ let entriesById = null
423
+
424
+ const persist = async () => {
425
+ if (!this.state) throw new Error("Cannot reconcile synchronized asset cache before loading state")
426
+
427
+ const candidateState = this.copyState(this.state)
428
+ const newEntryLastAccessedAt = this.nowMilliseconds()
429
+
430
+ this.applyDescriptorReconciliation({descriptors, newEntryLastAccessedAt, scopeKey, state: candidateState})
431
+ await this.adapter.saveState({accountId: this.accountId, state: candidateState})
432
+ entriesById = this.applyDescriptorReconciliation({descriptors, newEntryLastAccessedAt, scopeKey, state: this.state})
433
+ }
434
+
435
+ await this.serializeStatePersistence(persist)
436
+
437
+ if (!entriesById) throw new Error("Synchronized asset descriptor reconciliation completed without live entries")
438
+
439
+ return entriesById
440
+ }
441
+
442
+ /**
443
+ * Applies one scope's descriptor set to cache state.
444
+ * @param {object} args Reconciliation inputs.
445
+ * @param {import("./types.js").SynchronizedAssetCacheDescriptor[]} args.descriptors Current descriptors in the scope.
446
+ * @param {number} args.newEntryLastAccessedAt Initial LRU timestamp for new entries.
447
+ * @param {string} args.scopeKey Stable synchronized scope key.
448
+ * @param {import("./types.js").SynchronizedAssetCacheState} args.state State to reconcile.
449
+ * @returns {Map<string, import("./types.js").SynchronizedAssetCacheEntry>} Live entries by id.
450
+ */
451
+ applyDescriptorReconciliation({descriptors, newEntryLastAccessedAt, scopeKey, state}) {
452
+ const incomingIds = new Set(descriptors.map((asset) => asset.id))
453
+ const entriesById = new Map(state.assets.map((entry) => [entry.descriptor.id, entry]))
454
+ const descriptorsById = new Map(state.assets.map((entry) => [entry.descriptor.id, entry.descriptor]))
455
+ /** @type {Map<string, import("./types.js").SynchronizedAssetCacheDescriptor>} */
456
+ const removedDescriptorsByDigest = new Map()
457
+
458
+ for (const asset of descriptors) {
459
+ const knownDescriptor = descriptorsById.get(asset.id)
460
+ const downloadFlight = this.downloadPromises.get(asset.digest)
461
+
462
+ if (knownDescriptor && knownDescriptor.digest !== asset.digest) {
463
+ throw new Error(`Synchronized asset descriptor ${asset.id} changed its immutable digest`)
464
+ }
465
+ if (knownDescriptor && knownDescriptor.byteSize !== asset.byteSize) {
466
+ throw new Error(`Synchronized asset descriptor ${asset.id} changed its immutable byte size`)
467
+ }
468
+ if (knownDescriptor && knownDescriptor.contentType !== asset.contentType) {
469
+ throw new Error(`Synchronized asset descriptor ${asset.id} changed its immutable content type`)
470
+ }
471
+ if (downloadFlight && downloadFlight.byteSize !== asset.byteSize) {
472
+ throw new Error(`Synchronized asset digest ${asset.digest} has inconsistent byte sizes`)
473
+ }
474
+ if (downloadFlight && downloadFlight.contentType !== asset.contentType) {
475
+ throw new Error(`Synchronized asset digest ${asset.digest} has inconsistent content types`)
476
+ }
477
+
478
+ descriptorsById.set(asset.id, asset)
479
+ }
480
+
481
+ for (const entry of state.assets) {
482
+ if (!entry.scopeKeys.includes(scopeKey) || incomingIds.has(entry.descriptor.id)) continue
483
+
484
+ entry.scopeKeys = entry.scopeKeys.filter((candidate) => candidate !== scopeKey)
485
+ if (entry.scopeKeys.length === 0) removedDescriptorsByDigest.set(entry.descriptor.digest, entry.descriptor)
486
+ }
487
+
488
+ state.assets = state.assets.filter((entry) => entry.scopeKeys.length > 0)
489
+
490
+ for (const asset of descriptors) {
491
+ const existing = entriesById.get(asset.id)
492
+
493
+ if (existing && state.assets.includes(existing)) {
494
+ existing.descriptor = asset
495
+ if (!existing.scopeKeys.includes(scopeKey)) existing.scopeKeys.push(scopeKey)
496
+ } else {
497
+ const newEntry = {
498
+ attempts: 0,
499
+ descriptor: asset,
500
+ lastAccessedAt: newEntryLastAccessedAt,
501
+ nextRetryAt: null,
502
+ scopeKeys: [scopeKey],
503
+ status: /** @type {const} */ ("missing")
504
+ }
505
+
506
+ state.assets.push(newEntry)
507
+ entriesById.set(asset.id, newEntry)
508
+ }
509
+ }
510
+
511
+ /** @type {Map<string, number>} */
512
+ const byteSizesByDigest = new Map()
513
+ /** @type {Map<string, string | null>} */
514
+ const contentTypesByDigest = new Map()
515
+
516
+ for (const entry of state.assets) {
517
+ const knownByteSize = byteSizesByDigest.get(entry.descriptor.digest)
518
+ const knownContentType = contentTypesByDigest.get(entry.descriptor.digest)
519
+
520
+ if (knownByteSize !== undefined && knownByteSize !== entry.descriptor.byteSize) {
521
+ throw new Error(`Synchronized asset digest ${entry.descriptor.digest} has inconsistent byte sizes`)
522
+ }
523
+ if (knownContentType !== undefined && knownContentType !== entry.descriptor.contentType) {
524
+ throw new Error(`Synchronized asset digest ${entry.descriptor.digest} has inconsistent content types`)
525
+ }
526
+
527
+ byteSizesByDigest.set(entry.descriptor.digest, entry.descriptor.byteSize)
528
+ contentTypesByDigest.set(entry.descriptor.digest, entry.descriptor.contentType)
529
+ }
530
+
531
+ for (const [digest, removedDescriptor] of removedDescriptorsByDigest) {
532
+ const retainedEntry = state.assets.find((entry) => entry.descriptor.digest === digest)
533
+
534
+ if (retainedEntry && retainedEntry.descriptor.byteSize === removedDescriptor.byteSize && retainedEntry.descriptor.contentType === removedDescriptor.contentType) continue
535
+ if (!state.pendingDeletionDigests.includes(digest)) state.pendingDeletionDigests.push(digest)
536
+ }
537
+
538
+ return entriesById
539
+ }
540
+
541
+ /**
542
+ * Copies metadata into a detached persistence candidate.
543
+ * @param {import("./types.js").SynchronizedAssetCacheState} state State to copy.
544
+ * @returns {import("./types.js").SynchronizedAssetCacheState} Detached state.
545
+ */
546
+ copyState(state) {
547
+ return {
548
+ assets: state.assets.map((entry) => ({
549
+ ...entry,
550
+ descriptor: {...entry.descriptor},
551
+ scopeKeys: [...entry.scopeKeys]
552
+ })),
553
+ pendingDeletionDigests: [...state.pendingDeletionDigests],
554
+ version: state.version
555
+ }
556
+ }
557
+
558
+ /**
559
+ * Serializes one metadata persistence operation after prior failures or successes.
560
+ * @param {() => Promise<void>} persist Persistence operation.
561
+ * @returns {Promise<void>} Resolves after persistence.
562
+ */
563
+ async serializeStatePersistence(persist) {
564
+ this.saveStatePromise = this.saveStatePromise.then(persist, persist)
565
+
566
+ await this.saveStatePromise
567
+ }
568
+
569
+ /**
570
+ * Ensures one descriptor has verified local bytes.
571
+ * @param {import("./types.js").SynchronizedAssetCacheEntry} entry Descriptor state.
572
+ * @returns {Promise<{error: Error | null, uri: string | null}>} Cache result.
573
+ */
574
+ async ensureCached(entry) {
575
+ const digest = entry.descriptor.digest
576
+
577
+ await this.beginActiveDigest(digest)
578
+
579
+ try {
580
+ return await this.ensureCachedWhileActive([entry])
581
+ } finally {
582
+ await this.finishActiveDigest(digest)
583
+ }
584
+ }
585
+
586
+ /**
587
+ * Resolves or downloads descriptors sharing one protected digest.
588
+ * @param {import("./types.js").SynchronizedAssetCacheEntry[]} entries Descriptor states.
589
+ * @returns {Promise<{error: Error | null, uri: string | null}>} Cache result.
590
+ */
591
+ async ensureCachedWhileActive(entries) {
592
+ const entry = entries[0]
593
+
594
+ if (!entry) throw new Error("Cannot cache a synchronized asset digest without descriptor entries")
595
+
596
+ const existingUri = await this.cachedUriWhileActive(entry)
597
+
598
+ if (existingUri) {
599
+ await this.recordCachedEntries(entries)
600
+
601
+ return {error: null, uri: existingUri}
602
+ }
603
+
604
+ const digest = entry.descriptor.digest
605
+ let downloadFlight = this.downloadPromises.get(digest)
606
+ let ownsDownloadPromise = false
607
+
608
+ if (downloadFlight) {
609
+ for (const digestEntry of entries) {
610
+ if (downloadFlight.byteSize !== digestEntry.descriptor.byteSize) {
611
+ throw new Error(`Synchronized asset digest ${digest} has inconsistent byte sizes`)
612
+ }
613
+ if (downloadFlight.contentType !== digestEntry.descriptor.contentType) {
614
+ throw new Error(`Synchronized asset digest ${digest} has inconsistent content types`)
615
+ }
616
+ }
617
+ }
618
+
619
+ for (const digestEntry of entries) digestEntry.status = "downloading"
620
+
621
+ if (!downloadFlight) {
622
+ downloadFlight = {
623
+ byteSize: entry.descriptor.byteSize,
624
+ contentType: entry.descriptor.contentType,
625
+ promise: this.downloadAfterPersistingState(entry.descriptor)
626
+ }
627
+ this.downloadPromises.set(digest, downloadFlight)
628
+ ownsDownloadPromise = true
629
+ } else {
630
+ await this.saveState()
631
+ }
632
+
633
+ try {
634
+ const cacheResult = await downloadFlight.promise
635
+
636
+ if (cacheResult.error) {
637
+ if (entry.status === "downloading") await this.recordDownloadFailure(digest)
638
+
639
+ return cacheResult
640
+ }
641
+
642
+ await this.recordCachedEntries(entries)
643
+
644
+ return cacheResult
645
+ } finally {
646
+ if (ownsDownloadPromise && this.downloadPromises.get(digest) === downloadFlight) {
647
+ this.downloadPromises.delete(digest)
648
+ }
649
+ }
650
+ }
651
+
652
+ /**
653
+ * Records one cached digest result for every participating descriptor.
654
+ * @param {import("./types.js").SynchronizedAssetCacheEntry[]} entries Descriptor states.
655
+ * @returns {Promise<void>} Resolves after persistence.
656
+ */
657
+ async recordCachedEntries(entries) {
658
+ if (!this.state) throw new Error("Cannot record synchronized asset cache results before loading state")
659
+ const state = this.state
660
+ const lastAccessedAt = this.nowMilliseconds()
661
+
662
+ for (const entry of entries) {
663
+ entry.attempts = 0
664
+ entry.lastAccessedAt = lastAccessedAt
665
+ entry.nextRetryAt = null
666
+ entry.status = "cached"
667
+ }
668
+
669
+ const verifiedDigests = new Set(entries.map((entry) => entry.descriptor.digest))
670
+
671
+ state.pendingDeletionDigests = state.pendingDeletionDigests.filter((digest) => {
672
+ return !verifiedDigests.has(digest) || !state.assets.some((entry) => entry.descriptor.digest === digest)
673
+ })
674
+
675
+ await this.saveState()
676
+ }
677
+
678
+ /**
679
+ * Persists download intent, then downloads one digest and records a shared failure once.
680
+ * @param {import("./types.js").SynchronizedAssetCacheDescriptor} descriptor Asset descriptor.
681
+ * @returns {Promise<{error: Error, uri: null} | {error: null, uri: string}>} Shared cache result.
682
+ */
683
+ async downloadAfterPersistingState(descriptor) {
684
+ await this.saveState()
685
+
686
+ try {
687
+ return {error: null, uri: await this.downloadVerified(descriptor)}
688
+ } catch (error) {
689
+ const failure = error instanceof Error ? error : new Error(String(error))
690
+
691
+ await this.recordDownloadFailure(descriptor.digest)
692
+
693
+ return {error: failure, uri: null}
694
+ }
695
+ }
696
+
697
+ /**
698
+ * Advances retry metadata for every live descriptor sharing one failed digest.
699
+ * @param {string} digest Content digest.
700
+ * @returns {Promise<void>} Resolves after persistence.
701
+ */
702
+ async recordDownloadFailure(digest) {
703
+ if (!this.state) throw new Error("Cannot record synchronized asset download failure before loading state")
704
+
705
+ const failedAt = this.nowMilliseconds()
706
+
707
+ for (const entry of this.state.assets) {
708
+ if (entry.descriptor.digest !== digest) continue
709
+ if (entry.status !== "downloading") continue
710
+
711
+ entry.attempts += 1
712
+ entry.nextRetryAt = failedAt + this.retryDelay(entry.attempts)
713
+ entry.status = "failed"
714
+ }
715
+
716
+ await this.saveState()
717
+ }
718
+
719
+ /**
720
+ * Downloads, verifies, and atomically persists one content digest.
721
+ * @param {import("./types.js").SynchronizedAssetCacheDescriptor} descriptor Asset descriptor.
722
+ * @returns {Promise<string>} Adapter URI.
723
+ */
724
+ async downloadVerified(descriptor) {
725
+ const downloadedBytes = await this.download(descriptor)
726
+
727
+ if (!(downloadedBytes instanceof Uint8Array)) {
728
+ throw new Error(`Synchronized asset ${descriptor.id} download did not return Uint8Array bytes`)
729
+ }
730
+ if (downloadedBytes.byteLength !== descriptor.byteSize) {
731
+ throw new Error(`Synchronized asset ${descriptor.id} byte size did not match its descriptor`)
732
+ }
733
+
734
+ const digest = `sha256-${sha256BytesHex(downloadedBytes)}`
735
+
736
+ if (digest !== descriptor.digest) {
737
+ throw new Error(`Synchronized asset ${descriptor.id} digest did not match its descriptor`)
738
+ }
739
+
740
+ const uri = await this.adapter.writeBlob({
741
+ accountId: this.accountId,
742
+ bytes: downloadedBytes,
743
+ contentType: descriptor.contentType,
744
+ digest
745
+ })
746
+
747
+ if (!uri) throw new Error(`Synchronized asset adapter returned no URI for ${descriptor.id}`)
748
+
749
+ return uri
750
+ }
751
+
752
+ /**
753
+ * Resolves an existing local URI after waiting for deletion work.
754
+ * @param {import("./types.js").SynchronizedAssetCacheEntry} entry Descriptor state.
755
+ * @returns {Promise<string | null>} Existing URI.
756
+ */
757
+ async cachedUri(entry) {
758
+ const digest = entry.descriptor.digest
759
+
760
+ while (true) {
761
+ await this.beginActiveDigest(digest)
762
+ let revalidationRequired
763
+ let uri
764
+
765
+ try {
766
+ uri = await this.cachedUriWhileActive(entry)
767
+ } finally {
768
+ revalidationRequired = await this.finishActiveDigest(digest)
769
+ }
770
+
771
+ if (!this.state) throw new Error("Cannot revalidate synchronized asset cache URI before loading state")
772
+ if (!this.state.assets.some((candidate) => {
773
+ return candidate.descriptor.id === entry.descriptor.id && candidate.descriptor.digest === digest
774
+ })) return null
775
+ if (!revalidationRequired) return uri
776
+ }
777
+ }
778
+
779
+ /**
780
+ * Resolves an existing local URI while its digest is protected.
781
+ * @param {import("./types.js").SynchronizedAssetCacheEntry} entry Descriptor state.
782
+ * @returns {Promise<string | null>} Existing URI.
783
+ */
784
+ async cachedUriWhileActive(entry) {
785
+ if (!this.state) throw new Error("Cannot resolve synchronized asset cache URI before loading state")
786
+ if (this.state.pendingDeletionDigests.includes(entry.descriptor.digest)) {
787
+ if (entry.status === "cached") entry.status = "missing"
788
+
789
+ return null
790
+ }
791
+
792
+ const uri = await this.adapter.blobUri({
793
+ accountId: this.accountId,
794
+ digest: entry.descriptor.digest
795
+ })
796
+
797
+ if (!uri && entry.status === "cached") entry.status = "missing"
798
+
799
+ return uri
800
+ }
801
+
802
+ /**
803
+ * Waits for deletion and protects a digest for one active cache operation.
804
+ * @param {string} digest Content digest.
805
+ * @returns {Promise<void>} Resolves after protection is registered.
806
+ */
807
+ async beginActiveDigest(digest) {
808
+ let deletionPromise = this.deletionPromises.get(digest)
809
+
810
+ while (deletionPromise) {
811
+ await deletionPromise
812
+ deletionPromise = this.deletionPromises.get(digest)
813
+ }
814
+
815
+ const activeCount = this.activeDigestCounts.get(digest) ?? 0
816
+
817
+ this.activeDigestCounts.set(digest, activeCount + 1)
818
+ }
819
+
820
+ /**
821
+ * Releases one cache operation and processes deferred deletion after the last.
822
+ * @param {string} digest Content digest.
823
+ * @param {Set<string>} [protectedCleanupDigests] Digests needed by the resolving caller.
824
+ * @returns {Promise<boolean>} Whether finalization requires URI revalidation.
825
+ */
826
+ async finishActiveDigest(digest, protectedCleanupDigests = new Set()) {
827
+ const activeCount = this.activeDigestCounts.get(digest)
828
+
829
+ if (activeCount === undefined) {
830
+ throw new Error(`Missing active synchronized asset digest count for ${digest}`)
831
+ }
832
+
833
+ if (activeCount > 1) {
834
+ this.activeDigestCounts.set(digest, activeCount - 1)
835
+ return false
836
+ }
837
+
838
+ this.activeDigestCounts.delete(digest)
839
+ const pendingDigestDeleted = await this.deletePendingDigestIfUnreferenced(digest)
840
+ const deferredCleanupRequired = this.cleanupRequiredAfterReleaseDigests.delete(digest)
841
+
842
+ if (deferredCleanupRequired) await this.cleanup(protectedCleanupDigests)
843
+
844
+ return pendingDigestDeleted || deferredCleanupRequired
845
+ }
846
+
847
+ /**
848
+ * Releases every acquired digest before propagating finalization failures.
849
+ * @param {string[]} digests Content digests.
850
+ * @returns {Promise<void>} Resolves after every digest is released.
851
+ */
852
+ async finishActiveDigests(digests) {
853
+ /** @type {Error[]} */
854
+ const failures = []
855
+
856
+ for (const digest of digests) {
857
+ try {
858
+ await this.finishActiveDigest(digest)
859
+ } catch (error) {
860
+ failures.push(error instanceof Error ? error : new Error(String(error)))
861
+ }
862
+ }
863
+
864
+ if (failures.length === 1) throw failures[0]
865
+ if (failures.length > 1) {
866
+ throw new AggregateError(failures, "Multiple synchronized asset digest finalizers failed", {cause: failures[0]})
867
+ }
868
+ }
869
+
870
+ /**
871
+ * Deletes blobs that lost their final descriptor reference.
872
+ * @returns {Promise<void>} Resolves after deletion.
873
+ */
874
+ async deleteUnreferencedDigests() {
875
+ if (!this.state) throw new Error("Cannot delete synchronized asset blobs before loading state")
876
+
877
+ for (const digest of [...this.state.pendingDeletionDigests]) {
878
+ await this.deletePendingDigestIfUnreferenced(digest)
879
+ }
880
+ }
881
+
882
+ /**
883
+ * Deletes one persisted pending digest when no descriptor or active operation owns it.
884
+ * @param {string} digest Content digest.
885
+ * @returns {Promise<boolean>} Whether the blob was deleted.
886
+ */
887
+ async deletePendingDigestIfUnreferenced(digest) {
888
+ if (!this.state) throw new Error("Cannot delete synchronized asset blobs before loading state")
889
+ if (!this.state.pendingDeletionDigests.includes(digest)) return false
890
+
891
+ return await this.deleteDigestIfInactive(digest, async () => {
892
+ if (!this.state) throw new Error("Cannot delete synchronized asset blobs before loading state")
893
+ if (!this.state.pendingDeletionDigests.includes(digest)) return false
894
+ if (this.state.assets.some((entry) => entry.descriptor.digest === digest)) return false
895
+
896
+ await this.adapter.deleteBlob({accountId: this.accountId, digest})
897
+
898
+ const pendingDeletionDigests = this.state.pendingDeletionDigests
899
+
900
+ this.state.pendingDeletionDigests = pendingDeletionDigests.filter((candidate) => candidate !== digest)
901
+
902
+ try {
903
+ await this.saveState()
904
+ } catch (error) {
905
+ if (!this.state.pendingDeletionDigests.includes(digest)) this.state.pendingDeletionDigests.push(digest)
906
+ throw error
907
+ }
908
+
909
+ return true
910
+ })
911
+ }
912
+
913
+ /**
914
+ * Runs one deletion only after earlier deletion work and when no cache operation owns the digest.
915
+ * @param {string} digest Content digest.
916
+ * @param {() => Promise<boolean>} callback Protected deletion callback.
917
+ * @returns {Promise<boolean>} Whether the callback deleted the blob.
918
+ */
919
+ async deleteDigestIfInactive(digest, callback) {
920
+ let activeDeletionPromise = this.deletionPromises.get(digest)
921
+
922
+ while (activeDeletionPromise) {
923
+ await activeDeletionPromise
924
+ activeDeletionPromise = this.deletionPromises.get(digest)
925
+ }
926
+
927
+ if (this.activeDigestCounts.has(digest)) return false
928
+
929
+ /**
930
+ * Releases callers waiting for deletion completion.
931
+ * @type {() => void}
932
+ */
933
+ let releaseDeletion = () => {}
934
+ /**
935
+ * Blocks new digest activity until deletion completes.
936
+ * @type {Promise<void>}
937
+ */
938
+ const deletionPromise = new Promise((resolve) => {
939
+ releaseDeletion = () => resolve(undefined)
940
+ })
941
+
942
+ this.deletionPromises.set(digest, deletionPromise)
943
+
944
+ try {
945
+ return await callback()
946
+ } finally {
947
+ if (this.deletionPromises.get(digest) === deletionPromise) this.deletionPromises.delete(digest)
948
+ releaseDeletion()
949
+ }
950
+ }
951
+
952
+ /**
953
+ * Finds required assets without locally cached bytes.
954
+ * @param {string} scopeKey Synchronized scope to inspect.
955
+ * @returns {Promise<string[]>} Missing required descriptor ids.
956
+ */
957
+ async missingRequiredAssetIds(scopeKey) {
958
+ const state = await this.loadState()
959
+ /** @type {string[]} */
960
+ const missingAssetIds = []
961
+
962
+ for (const entry of state.assets) {
963
+ if (!entry.scopeKeys.includes(scopeKey)) continue
964
+ if (entry.descriptor.offlineRequirement !== "required") continue
965
+ if (await this.cachedUri(entry)) continue
966
+
967
+ missingAssetIds.push(entry.descriptor.id)
968
+ }
969
+
970
+ return missingAssetIds
971
+ }
972
+
973
+ /**
974
+ * Checks whether a failed or missing entry may be downloaded now.
975
+ * @param {import("./types.js").SynchronizedAssetCacheEntry} entry Descriptor state.
976
+ * @returns {boolean} Whether the retry deadline has passed.
977
+ */
978
+ retryEligible(entry) {
979
+ return entry.status !== "failed" || entry.nextRetryAt === null || entry.nextRetryAt <= this.nowMilliseconds()
980
+ }
981
+
982
+ /**
983
+ * Calculates bounded exponential retry delay.
984
+ * @param {number} attempts Consecutive failures.
985
+ * @returns {number} Retry delay.
986
+ */
987
+ retryDelay(attempts) {
988
+ return Math.min(this.retryMaxDelayMs, this.retryBaseDelayMs * (2 ** Math.max(0, attempts - 1)))
989
+ }
990
+
991
+ /**
992
+ * Reads the injectable wall clock.
993
+ * @returns {number} Current epoch milliseconds.
994
+ */
995
+ nowMilliseconds() {
996
+ return this.now().getTime()
997
+ }
998
+ }