@dcl/sdk 7.29.1-36591934525.commit-48aa9b5 → 7.29.1-36625469671.commit-2d5ffac

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 (80) hide show
  1. package/atom.d.ts +19 -0
  2. package/atom.js +83 -0
  3. package/future.d.ts +8 -0
  4. package/future.js +26 -0
  5. package/internal/transports/rendererTransport.js +5 -2
  6. package/network/binary-message-bus.d.ts +6 -3
  7. package/network/binary-message-bus.js +9 -5
  8. package/network/chunking.d.ts +5 -0
  9. package/network/chunking.js +38 -0
  10. package/network/events/implementation.d.ts +93 -0
  11. package/network/events/implementation.js +221 -0
  12. package/network/events/index.d.ts +42 -0
  13. package/network/events/index.js +43 -0
  14. package/network/events/protocol.d.ts +27 -0
  15. package/network/events/protocol.js +66 -0
  16. package/network/events/registry.d.ts +8 -0
  17. package/network/events/registry.js +3 -0
  18. package/network/index.d.ts +8 -2
  19. package/network/index.js +16 -3
  20. package/network/message-bus-sync.d.ts +16 -3
  21. package/network/message-bus-sync.js +164 -103
  22. package/network/server/index.d.ts +14 -0
  23. package/network/server/index.js +219 -0
  24. package/network/server/utils.d.ts +18 -0
  25. package/network/server/utils.js +135 -0
  26. package/network/state.js +3 -5
  27. package/package.json +6 -6
  28. package/server/env-var.d.ts +15 -0
  29. package/server/env-var.js +31 -0
  30. package/server/index.d.ts +2 -0
  31. package/server/index.js +3 -0
  32. package/server/storage/constants.d.ts +80 -0
  33. package/server/storage/constants.js +19 -0
  34. package/server/storage/error-message.d.ts +1 -0
  35. package/server/storage/error-message.js +9 -0
  36. package/server/storage/index.d.ts +54 -0
  37. package/server/storage/index.js +60 -0
  38. package/server/storage/player.d.ts +60 -0
  39. package/server/storage/player.js +244 -0
  40. package/server/storage/scene.d.ts +55 -0
  41. package/server/storage/scene.js +232 -0
  42. package/server/storage/serialize.d.ts +1 -0
  43. package/server/storage/serialize.js +135 -0
  44. package/server/storage/value-cache.d.ts +1 -0
  45. package/server/storage/value-cache.js +67 -0
  46. package/server/storage/write-queue.d.ts +1 -0
  47. package/server/storage/write-queue.js +113 -0
  48. package/server/storage-url.d.ts +13 -0
  49. package/server/storage-url.js +42 -0
  50. package/server/utils.d.ts +35 -0
  51. package/server/utils.js +69 -0
  52. package/src/atom.ts +98 -0
  53. package/src/future.ts +38 -0
  54. package/src/internal/transports/rendererTransport.ts +4 -1
  55. package/src/network/binary-message-bus.ts +9 -4
  56. package/src/network/chunking.ts +45 -0
  57. package/src/network/events/implementation.ts +271 -0
  58. package/src/network/events/index.ts +48 -0
  59. package/src/network/events/protocol.ts +94 -0
  60. package/src/network/events/registry.ts +18 -0
  61. package/src/network/index.ts +40 -3
  62. package/src/network/message-bus-sync.ts +180 -112
  63. package/src/network/server/index.ts +301 -0
  64. package/src/network/server/utils.ts +189 -0
  65. package/src/network/state.ts +3 -4
  66. package/src/server/env-var.ts +36 -0
  67. package/src/server/index.ts +12 -0
  68. package/src/server/storage/constants.ts +112 -0
  69. package/src/server/storage/error-message.ts +8 -0
  70. package/src/server/storage/index.ts +83 -0
  71. package/src/server/storage/player.ts +345 -0
  72. package/src/server/storage/scene.ts +324 -0
  73. package/src/server/storage/serialize.ts +132 -0
  74. package/src/server/storage/value-cache.ts +119 -0
  75. package/src/server/storage/write-queue.ts +174 -0
  76. package/src/server/storage-url.ts +49 -0
  77. package/src/server/utils.ts +83 -0
  78. package/explorer-ui/index.d.ts +0 -118
  79. package/explorer-ui/index.js +0 -235
  80. package/src/explorer-ui/index.ts +0 -398
@@ -0,0 +1,345 @@
1
+ import { getStorageServerUrl } from '../storage-url'
2
+ import { assertIsServer, wrapSignedFetch } from '../utils'
3
+ import {
4
+ createStorageConfig,
5
+ GetOptions,
6
+ GetValuesOptions,
7
+ GetValuesResult,
8
+ MODULE_NAME,
9
+ SetOptions,
10
+ StorageConfigState
11
+ } from './constants'
12
+ import { createValueCache } from './value-cache'
13
+ import { errorMessage } from './error-message'
14
+ import { assertPlayerAddress, assertStorageKey, serializeStorageValue } from './serialize'
15
+ import { createWriteQueue, SupersedingWriteFailed } from './write-queue'
16
+
17
+ /**
18
+ * Player-scoped storage interface for key-value pairs from the Server Side Storage service.
19
+ * This is NOT filesystem storage - data is stored in the remote storage service.
20
+ */
21
+ export interface IPlayerStorage {
22
+ /**
23
+ * Retrieves a value from a player's storage by key from the Server Side Storage service.
24
+ *
25
+ * By default (cacheReads), values read or written during the last cacheMaxAgeMs
26
+ * are served from a local cache without a network request, including confirmed
27
+ * "not found" results. Concurrent gets for the same player and key share one
28
+ * request. Out-of-band writers (e.g. CLI storage commands) may not be visible
29
+ * for up to cacheMaxAgeMs — pass { fresh: true } to force a network read.
30
+ * A read first waits for writes to the same key that were pending when it was
31
+ * issued, so it reflects every write issued before it.
32
+ * @param address - The player's wallet address
33
+ * @param key - The key to retrieve
34
+ * @param options - Optional { fresh } to bypass the read cache
35
+ * @returns A promise that resolves to the parsed JSON value, or null if not found
36
+ * @throws Error if the read fails, so a failure is never mistaken for a missing key
37
+ * @throws TypeError if the address or key is invalid
38
+ */
39
+ get<T = unknown>(address: string, key: string, options?: GetOptions): Promise<T | null>
40
+
41
+ /**
42
+ * Stores a value in a player's storage in the Server Side Storage service.
43
+ * @param address - The player's wallet address
44
+ * @param key - The key to store the value under
45
+ * @param value - The value to store (will be JSON serialized)
46
+ * @param options - Optional { skipIfUnchanged } to skip the network write when the value is already stored
47
+ * @returns true once stored, or once a newer write that replaced this one lands
48
+ * (rapid writes coalesce); false if the write, or its replacement, fails
49
+ * @throws TypeError if the address or key is invalid, or the value cannot be stored unchanged: undefined,
50
+ * circular, a BigInt, or containing a function, a symbol, a non-finite number, an undefined array
51
+ * element or hole, a Map, Set, typed array or other built-in with no JSON form, or text with an
52
+ * unpaired surrogate or a NUL character. An undefined object property is dropped; a Date is
53
+ * stored as its ISO string and a class instance as its own enumerable data.
54
+ */
55
+ set<T = unknown>(address: string, key: string, value: T, options?: SetOptions): Promise<boolean>
56
+
57
+ /**
58
+ * Deletes a value from a player's storage in the Server Side Storage service.
59
+ * @param address - The player's wallet address
60
+ * @param key - The key to delete
61
+ * @returns true once applied, or once a newer write that replaced it lands;
62
+ * false only for a confirmed 404
63
+ * @throws Error if the delete, or its replacement, fails; a failure is never reported as absence
64
+ * @throws TypeError if the address or key is invalid
65
+ */
66
+ delete(address: string, key: string): Promise<boolean>
67
+
68
+ /**
69
+ * Returns key-value entries from a player's storage, optionally filtered by prefix.
70
+ * Supports pagination via limit and offset.
71
+ * @param address - The player's wallet address
72
+ * @param options - Optional { prefix, limit, offset } for filtering and pagination.
73
+ * @returns A promise that resolves to { data, pagination: { offset, total } } for pagination UI
74
+ * @throws Error if the read fails, so a failure is never mistaken for an empty page
75
+ * @throws TypeError if the address is invalid
76
+ */
77
+ getValues(address: string, options?: GetValuesOptions): Promise<GetValuesResult>
78
+ }
79
+
80
+ /**
81
+ * Creates player-scoped storage that provides methods to interact with
82
+ * player-specific key-value pairs from the Server Side Storage service.
83
+ * This module only works when running on server-side scenes.
84
+ * @internal
85
+ */
86
+ export const createPlayerStorage = (config: StorageConfigState = createStorageConfig()): IPlayerStorage => {
87
+ const cache = createValueCache(config)
88
+ // Each in-flight GET is tracked by a wrapper object whose identity marks
89
+ // ownership: a landed write detaches the pending GET so its stale response is not cached.
90
+ const inflightGets = new Map<string, { promise: Promise<unknown> }>()
91
+
92
+ // Lowercased so checksummed callers share an entry.
93
+ const cacheKey = (address: string, key: string) => JSON.stringify([address.toLowerCase(), key])
94
+
95
+ // Writes to the same player key are serialized (and rapid ones coalesced to
96
+ // the latest value) so the service commits them in issue order — overlapping
97
+ // PUTs would otherwise leave both the kept value and the cached value to
98
+ // response-order chance. Keyed by the same case-insensitive cache key.
99
+ const writes = createWriteQueue()
100
+
101
+ async function executeSet(address: string, key: string, ck: string, body: string): Promise<boolean> {
102
+ let baseUrl: string
103
+ try {
104
+ baseUrl = await getStorageServerUrl()
105
+ } catch (error) {
106
+ cache.delete(ck)
107
+ console.error(`Failed to set player storage value '${key}' for '${address}': ${errorMessage(error)}`)
108
+ return false
109
+ }
110
+ const url = `${baseUrl}/players/${encodeURIComponent(address)}/values/${encodeURIComponent(key)}`
111
+
112
+ const [error] = await wrapSignedFetch({
113
+ url,
114
+ init: {
115
+ method: 'PUT',
116
+ headers: {
117
+ 'content-type': 'application/json'
118
+ },
119
+ body
120
+ }
121
+ })
122
+
123
+ // Either way the entry changed server-side (or may have): detach any
124
+ // overlapping in-flight GET so its stale response is not cached.
125
+ inflightGets.delete(ck)
126
+
127
+ if (error) {
128
+ // The PUT may have reached the server, so the cached body is no
129
+ // longer reliable.
130
+ cache.delete(ck)
131
+ return false
132
+ }
133
+
134
+ cache.set(ck, { body })
135
+ return true
136
+ }
137
+
138
+ async function executeDelete(address: string, key: string, ck: string): Promise<boolean> {
139
+ let baseUrl: string
140
+ try {
141
+ baseUrl = await getStorageServerUrl()
142
+ } catch (error) {
143
+ cache.delete(ck)
144
+ throw new Error(`Failed to delete player storage value '${key}' for '${address}': ${errorMessage(error)}`)
145
+ }
146
+ const url = `${baseUrl}/players/${encodeURIComponent(address)}/values/${encodeURIComponent(key)}`
147
+
148
+ const [error, , status] = await wrapSignedFetch({
149
+ url,
150
+ init: {
151
+ method: 'DELETE',
152
+ headers: {}
153
+ }
154
+ })
155
+
156
+ // Detach again: a GET may have started while the DELETE was in flight.
157
+ inflightGets.delete(ck)
158
+
159
+ if (error) {
160
+ // A 404 still confirms the key is absent server-side.
161
+ if (status === 404) {
162
+ cache.setAbsent(ck)
163
+ return false
164
+ }
165
+ // The DELETE may have reached the server, so the cached body is unreliable.
166
+ cache.delete(ck)
167
+ throw new Error(`Failed to delete player storage value '${key}' for '${address}': ${errorMessage(error)}`)
168
+ }
169
+
170
+ cache.setAbsent(ck)
171
+ return true
172
+ }
173
+
174
+ return {
175
+ async get<T = unknown>(address: string, key: string, options?: GetOptions): Promise<T | null> {
176
+ assertIsServer(MODULE_NAME)
177
+ assertPlayerAddress(address, 'Storage.player.get()')
178
+ assertStorageKey(key, 'Storage.player.get()')
179
+
180
+ const ck = cacheKey(address, key)
181
+
182
+ if (writes.isPending(ck)) await writes.settled(ck)
183
+
184
+ if (config.cacheReads && !options?.fresh) {
185
+ const entry = cache.get(ck)
186
+ if (entry?.absent) return null
187
+ // Parse per hit so each caller gets a fresh object (no shared mutation).
188
+ if (entry?.body !== undefined) return JSON.parse(entry.body).value as T
189
+ }
190
+
191
+ // Coalesce concurrent gets (even fresh ones: an in-flight response is
192
+ // milliseconds old, not TTL-stale) into a single network request.
193
+ const joined = inflightGets.get(ck)
194
+ if (joined) return joined.promise as Promise<T | null>
195
+
196
+ const inflight = {} as { promise: Promise<T | null> }
197
+ inflight.promise = (async () => {
198
+ try {
199
+ let baseUrl: string
200
+ try {
201
+ baseUrl = await getStorageServerUrl()
202
+ } catch (error) {
203
+ throw new Error(`Failed to get player storage value '${key}' for '${address}': ${errorMessage(error)}`)
204
+ }
205
+ const url = `${baseUrl}/players/${encodeURIComponent(address)}/values/${encodeURIComponent(key)}`
206
+
207
+ const [error, data, status] = await wrapSignedFetch<{ value: T }>({ url })
208
+
209
+ const isOwner = inflightGets.get(ck) === inflight
210
+
211
+ if (error) {
212
+ // A confirmed 404 is a first-class "absent" outcome, not a failure.
213
+ if (status === 404) {
214
+ if (isOwner) cache.setAbsent(ck)
215
+ return null
216
+ }
217
+ throw new Error(`Failed to get player storage value '${key}' for '${address}': ${errorMessage(error)}`)
218
+ }
219
+
220
+ // wrapSignedFetch parses an empty 2xx body as {}: a missing value is a fault, not an absence.
221
+ if (!data || data.value === undefined) {
222
+ throw new Error(`Failed to get player storage value '${key}' for '${address}': response carried no value`)
223
+ }
224
+
225
+ // Same serialization shape as set()'s PUT body, so a read followed by
226
+ // an unchanged write can be skipped.
227
+ const body = JSON.stringify({ value: data.value })
228
+ if (isOwner) cache.set(ck, { body })
229
+ return data.value
230
+ } finally {
231
+ if (inflightGets.get(ck) === inflight) inflightGets.delete(ck)
232
+ }
233
+ })()
234
+
235
+ inflightGets.set(ck, inflight)
236
+ return inflight.promise
237
+ },
238
+
239
+ async set<T = unknown>(address: string, key: string, value: T, options?: SetOptions): Promise<boolean> {
240
+ assertIsServer(MODULE_NAME)
241
+ assertPlayerAddress(address, 'Storage.player.set()')
242
+ assertStorageKey(key, 'Storage.player.set()')
243
+
244
+ const ck = cacheKey(address, key)
245
+ const body = serializeStorageValue(value, `Storage.player.set('${address}', '${key}')`)
246
+ const skipIfUnchanged = options?.skipIfUnchanged ?? config.skipIfUnchanged
247
+
248
+ // Dedup against confirmed state only while no write is pending — a
249
+ // pending write makes the cache momentarily stale; enqueue() coalesces
250
+ // against pending writes instead.
251
+ if (skipIfUnchanged && writes.pending(ck) === undefined && cache.get(ck)?.body === body) {
252
+ return true
253
+ }
254
+
255
+ return writes.enqueue(ck, body, (b) => executeSet(address, key, ck, b as string), skipIfUnchanged)
256
+ },
257
+
258
+ async delete(address: string, key: string): Promise<boolean> {
259
+ assertIsServer(MODULE_NAME)
260
+ assertPlayerAddress(address, 'Storage.player.delete()')
261
+ assertStorageKey(key, 'Storage.player.delete()')
262
+
263
+ const ck = cacheKey(address, key)
264
+
265
+ return writes
266
+ .enqueue(ck, null, () => executeDelete(address, key, ck), true)
267
+ .catch((error: unknown) => {
268
+ if (error instanceof SupersedingWriteFailed) {
269
+ throw new Error(`Failed to delete player storage value '${key}' for '${address}': ${error.message}`)
270
+ }
271
+ throw error
272
+ })
273
+ },
274
+
275
+ async getValues(address: string, options?: GetValuesOptions): Promise<GetValuesResult> {
276
+ assertIsServer(MODULE_NAME)
277
+ assertPlayerAddress(address, 'Storage.player.getValues()')
278
+
279
+ const { prefix, limit, offset } = options ?? {}
280
+ let baseUrl: string
281
+ try {
282
+ baseUrl = await getStorageServerUrl()
283
+ } catch (error) {
284
+ throw new Error(`Failed to get player storage values for '${address}': ${errorMessage(error)}`)
285
+ }
286
+ const parts: string[] = []
287
+
288
+ if (!!prefix) {
289
+ parts.push(`prefix=${encodeURIComponent(prefix)}`)
290
+ }
291
+
292
+ if (!!limit) {
293
+ parts.push(`limit=${limit}`)
294
+ }
295
+
296
+ if (!!offset) {
297
+ parts.push(`offset=${offset}`)
298
+ }
299
+
300
+ const query = parts.join('&')
301
+ const url = query
302
+ ? `${baseUrl}/players/${encodeURIComponent(address)}/values?${query}`
303
+ : `${baseUrl}/players/${encodeURIComponent(address)}/values`
304
+
305
+ const watcher = cache.watch()
306
+ const [error, response] = await wrapSignedFetch<GetValuesResult>({ url })
307
+ watcher.stop()
308
+
309
+ if (error) {
310
+ throw new Error(`Failed to get player storage values for '${address}': ${errorMessage(error)}`)
311
+ }
312
+
313
+ const data = response?.data
314
+ if (!Array.isArray(data)) {
315
+ throw new Error(`Failed to get player storage values for '${address}': response carried no data array`)
316
+ }
317
+ if (data.some((entry) => typeof entry?.key !== 'string' || entry.value === undefined)) {
318
+ throw new Error(`Failed to get player storage values for '${address}': response carried a malformed entry`)
319
+ }
320
+
321
+ // Seed the per-key cache so subsequent get()/set() on returned keys can
322
+ // skip the network. Only keys with no live entry and no pending write
323
+ // are seeded: existing per-key state comes from a confirmed operation
324
+ // that this page snapshot — whose request started earlier — must not
325
+ // clobber with stale data. Absence is never seeded (prefix/pagination
326
+ // make it non-authoritative). A page larger than cacheMaxEntries churns
327
+ // the cache; entries repopulate lazily.
328
+ for (const entry of data) {
329
+ const ck = cacheKey(address, entry.key)
330
+ if (watcher.mutated(ck) || writes.isPending(ck) || inflightGets.has(ck)) continue
331
+ if (cache.get(ck) === undefined) {
332
+ cache.set(ck, { body: JSON.stringify({ value: entry.value }) })
333
+ }
334
+ }
335
+
336
+ const requestedOffset = offset ?? 0
337
+ const pagination = {
338
+ offset: response!.pagination?.offset ?? requestedOffset,
339
+ total: response!.pagination?.total ?? data.length
340
+ }
341
+
342
+ return { data, pagination }
343
+ }
344
+ }
345
+ }
@@ -0,0 +1,324 @@
1
+ import { getStorageServerUrl } from '../storage-url'
2
+ import { assertIsServer, wrapSignedFetch } from '../utils'
3
+ import {
4
+ createStorageConfig,
5
+ GetOptions,
6
+ GetValuesOptions,
7
+ GetValuesResult,
8
+ MODULE_NAME,
9
+ SetOptions,
10
+ StorageConfigState
11
+ } from './constants'
12
+ import { createValueCache } from './value-cache'
13
+ import { errorMessage } from './error-message'
14
+ import { assertStorageKey, serializeStorageValue } from './serialize'
15
+ import { createWriteQueue, SupersedingWriteFailed } from './write-queue'
16
+
17
+ /**
18
+ * Scene-scoped storage interface for key-value pairs from the Server Side Storage service.
19
+ * This is NOT filesystem storage - data is stored in the remote storage service.
20
+ */
21
+ export interface ISceneStorage {
22
+ /**
23
+ * Retrieves a value from scene storage by key from the Server Side Storage service.
24
+ *
25
+ * By default (cacheReads), values read or written during the last cacheMaxAgeMs
26
+ * are served from a local cache without a network request, including confirmed
27
+ * "not found" results. Concurrent gets for the same key share one request.
28
+ * Out-of-band writers (e.g. CLI storage commands) may not be visible for up to
29
+ * cacheMaxAgeMs — pass { fresh: true } to force a network read.
30
+ * A read first waits for writes to the same key that were pending when it was
31
+ * issued, so it reflects every write issued before it.
32
+ * @param key - The key to retrieve
33
+ * @param options - Optional { fresh } to bypass the read cache
34
+ * @returns A promise that resolves to the parsed JSON value, or null if not found
35
+ * @throws Error if the read fails, so a failure is never mistaken for a missing key
36
+ * @throws TypeError if the key is invalid
37
+ */
38
+ get<T = unknown>(key: string, options?: GetOptions): Promise<T | null>
39
+
40
+ /**
41
+ * Stores a value in scene storage in the Server Side Storage service.
42
+ * @param key - The key to store the value under
43
+ * @param value - The value to store (will be JSON serialized)
44
+ * @param options - Optional { skipIfUnchanged } to skip the network write when the value is already stored
45
+ * @returns true once stored, or once a newer write that replaced this one lands
46
+ * (rapid writes coalesce); false if the write, or its replacement, fails
47
+ * @throws TypeError if the key is invalid, or the value cannot be stored unchanged: undefined,
48
+ * circular, a BigInt, or containing a function, a symbol, a non-finite number, an undefined array
49
+ * element or hole, a Map, Set, typed array or other built-in with no JSON form, or text with an
50
+ * unpaired surrogate or a NUL character. An undefined object property is dropped; a Date is
51
+ * stored as its ISO string and a class instance as its own enumerable data.
52
+ */
53
+ set<T = unknown>(key: string, value: T, options?: SetOptions): Promise<boolean>
54
+
55
+ /**
56
+ * Deletes a value from scene storage in the Server Side Storage service.
57
+ * @param key - The key to delete
58
+ * @returns true once applied, or once a newer write that replaced it lands;
59
+ * false only for a confirmed 404
60
+ * @throws Error if the delete, or its replacement, fails; a failure is never reported as absence
61
+ * @throws TypeError if the key is invalid
62
+ */
63
+ delete(key: string): Promise<boolean>
64
+
65
+ /**
66
+ * Returns key-value entries from scene storage, optionally filtered by prefix.
67
+ * Supports pagination via limit and offset.
68
+ * @param options - Optional { prefix, limit, offset } for filtering and pagination.
69
+ * @returns A promise that resolves to { data, pagination: { offset, total } } for pagination UI
70
+ * @throws Error if the read fails, so a failure is never mistaken for an empty page
71
+ */
72
+ getValues(options?: GetValuesOptions): Promise<GetValuesResult>
73
+ }
74
+
75
+ /**
76
+ * Creates scene-scoped storage that provides methods to interact with
77
+ * scene-specific key-value pairs from the Server Side Storage service.
78
+ * This module only works when running on server-side scenes.
79
+ * @internal
80
+ */
81
+ export const createSceneStorage = (config: StorageConfigState = createStorageConfig()): ISceneStorage => {
82
+ const cache = createValueCache(config)
83
+ // Each in-flight GET is tracked by a wrapper object whose identity marks
84
+ // ownership: a landed write detaches the pending GET so its stale response is not cached.
85
+ const inflightGets = new Map<string, { promise: Promise<unknown> }>()
86
+ // Writes to the same key are serialized (and rapid ones coalesced to the
87
+ // latest value) so the service commits them in issue order — overlapping
88
+ // PUTs would otherwise leave both the kept value and the cached value to
89
+ // response-order chance.
90
+ const writes = createWriteQueue()
91
+
92
+ async function executeSet(key: string, body: string): Promise<boolean> {
93
+ let baseUrl: string
94
+ try {
95
+ baseUrl = await getStorageServerUrl()
96
+ } catch (error) {
97
+ cache.delete(key)
98
+ console.error(`Failed to set storage value '${key}': ${errorMessage(error)}`)
99
+ return false
100
+ }
101
+ const url = `${baseUrl}/values/${encodeURIComponent(key)}`
102
+
103
+ const [error] = await wrapSignedFetch({
104
+ url,
105
+ init: {
106
+ method: 'PUT',
107
+ headers: {
108
+ 'content-type': 'application/json'
109
+ },
110
+ body
111
+ }
112
+ })
113
+
114
+ // Either way the entry changed server-side (or may have): detach any
115
+ // overlapping in-flight GET so its stale response is not cached.
116
+ inflightGets.delete(key)
117
+
118
+ if (error) {
119
+ // The PUT may have reached the server, so the cached body is no
120
+ // longer reliable.
121
+ cache.delete(key)
122
+ return false
123
+ }
124
+
125
+ cache.set(key, { body })
126
+ return true
127
+ }
128
+
129
+ async function executeDelete(key: string): Promise<boolean> {
130
+ let baseUrl: string
131
+ try {
132
+ baseUrl = await getStorageServerUrl()
133
+ } catch (error) {
134
+ cache.delete(key)
135
+ throw new Error(`Failed to delete storage value '${key}': ${errorMessage(error)}`)
136
+ }
137
+ const url = `${baseUrl}/values/${encodeURIComponent(key)}`
138
+
139
+ const [error, , status] = await wrapSignedFetch({
140
+ url,
141
+ init: {
142
+ method: 'DELETE',
143
+ headers: {}
144
+ }
145
+ })
146
+
147
+ // Detach again: a GET may have started while the DELETE was in flight.
148
+ inflightGets.delete(key)
149
+
150
+ if (error) {
151
+ // A 404 still confirms the key is absent server-side.
152
+ if (status === 404) {
153
+ cache.setAbsent(key)
154
+ return false
155
+ }
156
+ // The DELETE may have reached the server, so the cached body is unreliable.
157
+ cache.delete(key)
158
+ throw new Error(`Failed to delete storage value '${key}': ${errorMessage(error)}`)
159
+ }
160
+
161
+ cache.setAbsent(key)
162
+ return true
163
+ }
164
+
165
+ return {
166
+ async get<T = unknown>(key: string, options?: GetOptions): Promise<T | null> {
167
+ assertIsServer(MODULE_NAME)
168
+ assertStorageKey(key, 'Storage.get()')
169
+
170
+ if (writes.isPending(key)) await writes.settled(key)
171
+
172
+ if (config.cacheReads && !options?.fresh) {
173
+ const entry = cache.get(key)
174
+ if (entry?.absent) return null
175
+ // Parse per hit so each caller gets a fresh object (no shared mutation).
176
+ if (entry?.body !== undefined) return JSON.parse(entry.body).value as T
177
+ }
178
+
179
+ // Coalesce concurrent gets (even fresh ones: an in-flight response is
180
+ // milliseconds old, not TTL-stale) into a single network request.
181
+ const joined = inflightGets.get(key)
182
+ if (joined) return joined.promise as Promise<T | null>
183
+
184
+ const inflight = {} as { promise: Promise<T | null> }
185
+ inflight.promise = (async () => {
186
+ try {
187
+ let baseUrl: string
188
+ try {
189
+ baseUrl = await getStorageServerUrl()
190
+ } catch (error) {
191
+ throw new Error(`Failed to get storage value '${key}': ${errorMessage(error)}`)
192
+ }
193
+ const url = `${baseUrl}/values/${encodeURIComponent(key)}`
194
+
195
+ const [error, data, status] = await wrapSignedFetch<{ value: T }>({ url })
196
+
197
+ const isOwner = inflightGets.get(key) === inflight
198
+
199
+ if (error) {
200
+ // A confirmed 404 is a first-class "absent" outcome, not a failure.
201
+ if (status === 404) {
202
+ if (isOwner) cache.setAbsent(key)
203
+ return null
204
+ }
205
+ throw new Error(`Failed to get storage value '${key}': ${errorMessage(error)}`)
206
+ }
207
+
208
+ // wrapSignedFetch parses an empty 2xx body as {}: a missing value is a fault, not an absence.
209
+ if (!data || data.value === undefined) {
210
+ throw new Error(`Failed to get storage value '${key}': response carried no value`)
211
+ }
212
+
213
+ // Same serialization shape as set()'s PUT body, so a read followed by
214
+ // an unchanged write can be skipped.
215
+ const body = JSON.stringify({ value: data.value })
216
+ if (isOwner) cache.set(key, { body })
217
+ return data.value
218
+ } finally {
219
+ if (inflightGets.get(key) === inflight) inflightGets.delete(key)
220
+ }
221
+ })()
222
+
223
+ inflightGets.set(key, inflight)
224
+ return inflight.promise
225
+ },
226
+
227
+ async set<T = unknown>(key: string, value: T, options?: SetOptions): Promise<boolean> {
228
+ assertIsServer(MODULE_NAME)
229
+ assertStorageKey(key, 'Storage.set()')
230
+
231
+ const body = serializeStorageValue(value, `Storage.set('${key}')`)
232
+ const skipIfUnchanged = options?.skipIfUnchanged ?? config.skipIfUnchanged
233
+
234
+ // Dedup against confirmed state only while no write is pending — a
235
+ // pending write makes the cache momentarily stale; enqueue() coalesces
236
+ // against pending writes instead.
237
+ if (skipIfUnchanged && writes.pending(key) === undefined && cache.get(key)?.body === body) {
238
+ return true
239
+ }
240
+
241
+ return writes.enqueue(key, body, (b) => executeSet(key, b as string), skipIfUnchanged)
242
+ },
243
+
244
+ async delete(key: string): Promise<boolean> {
245
+ assertIsServer(MODULE_NAME)
246
+ assertStorageKey(key, 'Storage.delete()')
247
+
248
+ return writes
249
+ .enqueue(key, null, () => executeDelete(key), true)
250
+ .catch((error: unknown) => {
251
+ if (error instanceof SupersedingWriteFailed) {
252
+ throw new Error(`Failed to delete storage value '${key}': ${error.message}`)
253
+ }
254
+ throw error
255
+ })
256
+ },
257
+
258
+ async getValues(options?: GetValuesOptions): Promise<GetValuesResult> {
259
+ assertIsServer(MODULE_NAME)
260
+
261
+ const { prefix, limit, offset } = options ?? {}
262
+ let baseUrl: string
263
+ try {
264
+ baseUrl = await getStorageServerUrl()
265
+ } catch (error) {
266
+ throw new Error(`Failed to get storage values: ${errorMessage(error)}`)
267
+ }
268
+ const parts: string[] = []
269
+
270
+ if (!!prefix) {
271
+ parts.push(`prefix=${encodeURIComponent(prefix)}`)
272
+ }
273
+
274
+ if (!!limit) {
275
+ parts.push(`limit=${limit}`)
276
+ }
277
+
278
+ if (!!offset) {
279
+ parts.push(`offset=${offset}`)
280
+ }
281
+
282
+ const query = parts.join('&')
283
+ const url = query ? `${baseUrl}/values?${query}` : `${baseUrl}/values`
284
+
285
+ const watcher = cache.watch()
286
+ const [error, response] = await wrapSignedFetch<GetValuesResult>({ url })
287
+ watcher.stop()
288
+
289
+ if (error) {
290
+ throw new Error(`Failed to get storage values: ${errorMessage(error)}`)
291
+ }
292
+
293
+ const data = response?.data
294
+ if (!Array.isArray(data)) {
295
+ throw new Error('Failed to get storage values: response carried no data array')
296
+ }
297
+ if (data.some((entry) => typeof entry?.key !== 'string' || entry.value === undefined)) {
298
+ throw new Error('Failed to get storage values: response carried a malformed entry')
299
+ }
300
+
301
+ // Seed the per-key cache so subsequent get()/set() on returned keys can
302
+ // skip the network. Only keys with no live entry and no pending write
303
+ // are seeded: existing per-key state comes from a confirmed operation
304
+ // that this page snapshot — whose request started earlier — must not
305
+ // clobber with stale data. Absence is never seeded (prefix/pagination
306
+ // make it non-authoritative). A page larger than cacheMaxEntries churns
307
+ // the cache; entries repopulate lazily.
308
+ for (const entry of data) {
309
+ if (watcher.mutated(entry.key) || writes.isPending(entry.key) || inflightGets.has(entry.key)) continue
310
+ if (cache.get(entry.key) === undefined) {
311
+ cache.set(entry.key, { body: JSON.stringify({ value: entry.value }) })
312
+ }
313
+ }
314
+
315
+ const requestedOffset = offset ?? 0
316
+ const pagination = {
317
+ offset: response!.pagination?.offset ?? requestedOffset,
318
+ total: response!.pagination?.total ?? data.length
319
+ }
320
+
321
+ return { data, pagination }
322
+ }
323
+ }
324
+ }