tapimo 0.0.2 → 0.1.0
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/README.md +11 -2
- package/dist/ApiKey.d.ts +12 -11
- package/dist/ApiKey.d.ts.map +1 -1
- package/dist/ApiKey.js +10 -12
- package/dist/ApiKey.js.map +1 -1
- package/dist/ApiKeys.d.ts +139 -0
- package/dist/ApiKeys.d.ts.map +1 -0
- package/dist/ApiKeys.js +166 -0
- package/dist/ApiKeys.js.map +1 -0
- package/dist/App.d.ts +920 -910
- package/dist/App.d.ts.map +1 -1
- package/dist/App.js +36 -12
- package/dist/App.js.map +1 -1
- package/dist/Handler.d.ts +86 -0
- package/dist/Handler.d.ts.map +1 -0
- package/dist/Handler.js +83 -0
- package/dist/Handler.js.map +1 -0
- package/dist/Scope.d.ts +4 -1
- package/dist/Scope.d.ts.map +1 -1
- package/dist/Scope.js +5 -1
- package/dist/Scope.js.map +1 -1
- package/dist/Webhooks.d.ts +3 -5
- package/dist/Webhooks.d.ts.map +1 -1
- package/dist/Webhooks.js +9 -2
- package/dist/Webhooks.js.map +1 -1
- package/dist/admin/Access.d.ts +1 -3
- package/dist/admin/Access.d.ts.map +1 -1
- package/dist/admin/Access.js +1 -3
- package/dist/admin/Access.js.map +1 -1
- package/dist/admin/App.d.ts +98 -209
- package/dist/admin/App.d.ts.map +1 -1
- package/dist/admin/App.js +2 -4
- package/dist/admin/App.js.map +1 -1
- package/dist/admin/apps/api-keys.d.ts +7 -5
- package/dist/admin/apps/api-keys.d.ts.map +1 -1
- package/dist/admin/apps/api-keys.js +8 -9
- package/dist/admin/apps/api-keys.js.map +1 -1
- package/dist/admin/apps/scopes.d.ts +3 -0
- package/dist/admin/apps/scopes.d.ts.map +1 -1
- package/dist/admin/apps/verified-tokens.d.ts +101 -215
- package/dist/admin/apps/verified-tokens.d.ts.map +1 -1
- package/dist/admin/apps/verified-tokens.js +17 -38
- package/dist/admin/apps/verified-tokens.js.map +1 -1
- package/dist/admin/ui.generated.d.ts +1 -1
- package/dist/admin/ui.generated.js +1 -1
- package/dist/apps/balances.d.ts +2 -2
- package/dist/apps/receipts.d.ts +43 -43
- package/dist/apps/tokens.d.ts +454 -454
- package/dist/apps/transactions.d.ts +43 -43
- package/dist/apps/transfers.d.ts +29 -29
- package/dist/apps/webhooks.d.ts +5 -2
- package/dist/apps/webhooks.d.ts.map +1 -1
- package/dist/apps/webhooks.js +38 -34
- package/dist/apps/webhooks.js.map +1 -1
- package/dist/{Cli.d.ts → cli/Cli.d.ts} +13 -2
- package/dist/cli/Cli.d.ts.map +1 -0
- package/dist/cli/Cli.js +19 -0
- package/dist/cli/Cli.js.map +1 -0
- package/dist/cli/bin.d.ts +3 -0
- package/dist/cli/bin.d.ts.map +1 -0
- package/dist/cli/bin.js +4 -0
- package/dist/cli/bin.js.map +1 -0
- package/dist/cli/commands/admin.d.ts +15 -0
- package/dist/cli/commands/admin.d.ts.map +1 -0
- package/dist/cli/commands/admin.js +60 -0
- package/dist/cli/commands/admin.js.map +1 -0
- package/dist/cloudflare.d.ts +7 -0
- package/dist/cloudflare.d.ts.map +1 -1
- package/dist/cloudflare.js +9 -0
- package/dist/cloudflare.js.map +1 -1
- package/dist/db/Db.d.ts +103 -0
- package/dist/db/Db.d.ts.map +1 -0
- package/dist/db/Db.js +274 -0
- package/dist/db/Db.js.map +1 -0
- package/dist/db/Schema.d.ts +88 -0
- package/dist/db/Schema.d.ts.map +1 -0
- package/dist/db/Schema.js +130 -0
- package/dist/db/Schema.js.map +1 -0
- package/dist/db/tables/organizations.d.ts +79 -0
- package/dist/db/tables/organizations.d.ts.map +1 -0
- package/dist/db/tables/organizations.js +81 -0
- package/dist/db/tables/organizations.js.map +1 -0
- package/dist/db/tables/verifiedTokens.d.ts +53 -0
- package/dist/db/tables/verifiedTokens.d.ts.map +1 -0
- package/dist/db/tables/verifiedTokens.js +74 -0
- package/dist/db/tables/verifiedTokens.js.map +1 -0
- package/dist/db/tables/webhookDeliveries.d.ts +71 -0
- package/dist/db/tables/webhookDeliveries.d.ts.map +1 -0
- package/dist/db/tables/webhookDeliveries.js +119 -0
- package/dist/db/tables/webhookDeliveries.js.map +1 -0
- package/dist/db/tables/webhookSubscriptions.d.ts +158 -0
- package/dist/db/tables/webhookSubscriptions.d.ts.map +1 -0
- package/dist/db/tables/webhookSubscriptions.js +272 -0
- package/dist/db/tables/webhookSubscriptions.js.map +1 -0
- package/dist/handlers/internal/executionError.d.ts +25 -0
- package/dist/handlers/internal/executionError.d.ts.map +1 -0
- package/dist/handlers/internal/executionError.js +184 -0
- package/dist/handlers/internal/executionError.js.map +1 -0
- package/dist/handlers/internal/multisig.d.ts +237 -0
- package/dist/handlers/internal/multisig.d.ts.map +1 -0
- package/dist/handlers/internal/multisig.js +567 -0
- package/dist/handlers/internal/multisig.js.map +1 -0
- package/dist/handlers/internal/requestListener.d.ts +124 -0
- package/dist/handlers/internal/requestListener.d.ts.map +1 -0
- package/dist/handlers/internal/requestListener.js +173 -0
- package/dist/handlers/internal/requestListener.js.map +1 -0
- package/dist/handlers/internal/sponsorship.d.ts +83 -0
- package/dist/handlers/internal/sponsorship.d.ts.map +1 -0
- package/dist/handlers/internal/sponsorship.js +103 -0
- package/dist/handlers/internal/sponsorship.js.map +1 -0
- package/dist/handlers/internal/types.d.ts +284 -0
- package/dist/handlers/internal/types.d.ts.map +1 -0
- package/dist/handlers/internal/types.js +2 -0
- package/dist/handlers/internal/types.js.map +1 -0
- package/dist/handlers/internal/utils.d.ts +34 -0
- package/dist/handlers/internal/utils.d.ts.map +1 -0
- package/dist/handlers/internal/utils.js +131 -0
- package/dist/handlers/internal/utils.js.map +1 -0
- package/dist/handlers/relay.d.ts +119 -0
- package/dist/handlers/relay.d.ts.map +1 -0
- package/dist/handlers/relay.js +1264 -0
- package/dist/handlers/relay.js.map +1 -0
- package/dist/index.d.ts +4 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -2
- package/dist/index.js.map +1 -1
- package/dist/internal/Auth.d.ts +16 -10
- package/dist/internal/Auth.d.ts.map +1 -1
- package/dist/internal/Auth.js +72 -12
- package/dist/internal/Auth.js.map +1 -1
- package/dist/internal/RpcSchema.d.ts +10 -0
- package/dist/internal/RpcSchema.d.ts.map +1 -0
- package/dist/internal/RpcSchema.js +2 -0
- package/dist/internal/RpcSchema.js.map +1 -0
- package/dist/internal/Store.d.ts +42 -3
- package/dist/internal/Store.d.ts.map +1 -1
- package/dist/internal/Store.js +65 -3
- package/dist/internal/Store.js.map +1 -1
- package/dist/internal/VerifiedTokens.d.ts +15 -34
- package/dist/internal/VerifiedTokens.d.ts.map +1 -1
- package/dist/internal/VerifiedTokens.js +115 -159
- package/dist/internal/VerifiedTokens.js.map +1 -1
- package/dist/internal/Webhooks.d.ts +49 -35
- package/dist/internal/Webhooks.d.ts.map +1 -1
- package/dist/internal/Webhooks.js +92 -205
- package/dist/internal/Webhooks.js.map +1 -1
- package/package.json +7 -2
- package/src/ApiKey.test.ts +3 -3
- package/src/ApiKey.ts +10 -12
- package/src/ApiKeys.test.ts +175 -0
- package/src/ApiKeys.ts +205 -0
- package/src/App.test-d.ts +1 -1
- package/src/App.test.ts +10768 -351
- package/src/App.ts +79 -36
- package/src/Client.test-d.ts +1 -1
- package/src/Handler.ts +154 -0
- package/src/Scope.ts +5 -1
- package/src/Webhooks.test.ts +8 -7
- package/src/Webhooks.ts +11 -5
- package/src/admin/Access.ts +1 -3
- package/src/admin/App.ts +19 -24
- package/src/admin/apps/api-keys.test.ts +19 -18
- package/src/admin/apps/api-keys.ts +8 -9
- package/src/admin/apps/scopes.test.ts +4 -0
- package/src/admin/apps/verified-tokens.test.ts +3 -30
- package/src/admin/apps/verified-tokens.ts +17 -36
- package/src/admin/ui.generated.ts +1 -1
- package/src/apps/balances.test.ts +4 -3
- package/src/apps/blocks.test.ts +1 -1
- package/src/apps/exchanges.test.ts +1 -1
- package/src/apps/indexer.test.ts +1 -1
- package/src/apps/receipts.test.ts +1 -1
- package/src/apps/rpc.test.ts +100 -10
- package/src/apps/tokenlist.test.ts +5 -7
- package/src/apps/tokens.test.ts +4 -3
- package/src/apps/transactions.test.ts +1 -1
- package/src/apps/transfers.test.ts +1 -1
- package/src/apps/verified-tokens.test.ts +24 -12
- package/src/apps/webhooks.test.ts +130 -143
- package/src/apps/webhooks.ts +45 -47
- package/src/{Cli.test.ts → cli/Cli.test.ts} +10 -9
- package/src/{Cli.ts → cli/Cli.ts} +16 -9
- package/src/cli/bin.ts +4 -0
- package/src/cli/commands/admin.test.ts +71 -0
- package/src/cli/commands/admin.ts +60 -0
- package/src/cloudflare.ts +10 -0
- package/src/db/Db.test.ts +84 -0
- package/src/db/Db.ts +383 -0
- package/src/db/Schema.ts +164 -0
- package/src/db/tables/organizations.test.ts +124 -0
- package/src/db/tables/organizations.ts +127 -0
- package/src/db/tables/verifiedTokens.ts +111 -0
- package/src/db/tables/webhookDeliveries.ts +159 -0
- package/src/db/tables/webhookSubscriptions.ts +358 -0
- package/src/handlers/internal/executionError.ts +205 -0
- package/src/handlers/internal/multisig.localnet.test.ts +146 -0
- package/src/handlers/internal/multisig.test.ts +744 -0
- package/src/handlers/internal/multisig.ts +858 -0
- package/src/handlers/internal/relay.localnet.test.ts +2035 -0
- package/src/handlers/internal/requestListener.ts +273 -0
- package/src/handlers/internal/sponsorship.ts +186 -0
- package/src/handlers/internal/types.ts +378 -0
- package/src/handlers/internal/utils.ts +153 -0
- package/src/handlers/relay.ts +1605 -0
- package/src/index.ts +4 -2
- package/src/internal/Auth.test.ts +21 -21
- package/src/internal/Auth.ts +92 -19
- package/src/internal/RateLimit.test.ts +1 -1
- package/src/internal/RpcSchema.ts +10 -0
- package/src/internal/Store.test.ts +98 -10
- package/src/internal/Store.ts +112 -6
- package/src/internal/VerifiedTokens.test.ts +99 -115
- package/src/internal/VerifiedTokens.ts +123 -175
- package/src/internal/Webhooks.test.ts +107 -107
- package/src/internal/Webhooks.ts +102 -220
- package/dist/ApiKeySource.d.ts +0 -166
- package/dist/ApiKeySource.d.ts.map +0 -1
- package/dist/ApiKeySource.js +0 -234
- package/dist/ApiKeySource.js.map +0 -1
- package/dist/Cli.d.ts.map +0 -1
- package/dist/Cli.js +0 -13
- package/dist/Cli.js.map +0 -1
- package/seeds/31318/icons/0x20c0000000000000000000000000000000000000.svg +0 -1
- package/seeds/31318/icons/0x20c0000000000000000000000000000000000001.svg +0 -1
- package/seeds/31318/icons/0x20c0000000000000000000000000000000000002.svg +0 -1
- package/seeds/31318/icons/0x20c0000000000000000000000000000000000003.svg +0 -1
- package/seeds/31318/verified-tokens.json +0 -30
- package/seeds/4217/icons/0x20c0000000000000000000000000000000000000.svg +0 -25
- package/seeds/4217/icons/0x20c000000000000000000000048c8f36df1c9a4a.svg +0 -1
- package/seeds/4217/icons/0x20c0000000000000000000000520792dcccccccc.svg +0 -1
- package/seeds/4217/icons/0x20c0000000000000000000000a6da882d075a4c3.svg +0 -4
- package/seeds/4217/icons/0x20c000000000000000000000111111111e910f0f.svg +0 -30
- package/seeds/4217/icons/0x20c00000000000000000000014f22ca97301eb73.svg +0 -1
- package/seeds/4217/icons/0x20c0000000000000000000001621e21f71cf12fb.svg +0 -1
- package/seeds/4217/icons/0x20c0000000000000000000002f52d5cc21a3207b.svg +0 -1
- package/seeds/4217/icons/0x20c0000000000000000000003158081efd85bfc2.svg +0 -11
- package/seeds/4217/icons/0x20c0000000000000000000003554d28269e0f3c2.svg +0 -1
- package/seeds/4217/icons/0x20c000000000000000000000383a23bacb546ab9.svg +0 -1
- package/seeds/4217/icons/0x20c00000000000000000000042109aef2f8b28e1.png +0 -0
- package/seeds/4217/icons/0x20c0000000000000000000005c0bac7cef389a11.svg +0 -1
- package/seeds/4217/icons/0x20c0000000000000000000006fd9a167923ba194.png +0 -0
- package/seeds/4217/icons/0x20c0000000000000000000007f7ba549dd0251b9.svg +0 -1
- package/seeds/4217/icons/0x20c0000000000000000000008191667423f70e67.svg +0 -5
- package/seeds/4217/icons/0x20c0000000000000000000008ee4fcff88888888.svg +0 -1
- package/seeds/4217/icons/0x20c0000000000000000000009a4a4b17e0dc6651.svg +0 -1
- package/seeds/4217/icons/0x20c000000000000000000000ab02d39df30bd17e.svg +0 -1
- package/seeds/4217/icons/0x20c000000000000000000000ae247a1130450f09.svg +0 -1
- package/seeds/4217/icons/0x20c000000000000000000000aeed2ec36a54d0e5.svg +0 -1
- package/seeds/4217/icons/0x20c000000000000000000000b9537d11c60e8b50.svg +0 -1
- package/seeds/4217/icons/0x20c000000000000000000000bd95bfb69fbe6ce3.svg +0 -1
- package/seeds/4217/icons/0x20c000000000000000000000c412ec89d0c08be5.svg +0 -1
- package/seeds/4217/icons/0x20c000000000000000000000f047dd7018e50367.png +0 -0
- package/seeds/4217/verified-tokens.json +0 -177
- package/seeds/42431/icons/0x20c0000000000000000000000000000000000000.svg +0 -25
- package/seeds/42431/icons/0x20c0000000000000000000000000000000000001.svg +0 -1
- package/seeds/42431/icons/0x20c0000000000000000000000000000000000002.svg +0 -1
- package/seeds/42431/icons/0x20c0000000000000000000000000000000000003.svg +0 -1
- package/seeds/42431/icons/0x20c0000000000000000000009e8d7eb59b783726.svg +0 -1
- package/seeds/42431/icons/0x20c000000000000000000000d72572838bbee59c.svg +0 -1
- package/seeds/42431/verified-tokens.json +0 -44
- package/seeds/openrpc/README.md +0 -27
- package/seeds/openrpc/execution-apis.json +0 -8989
- package/src/ApiKeySource.test-d.ts +0 -11
- package/src/ApiKeySource.test.ts +0 -295
- package/src/ApiKeySource.ts +0 -303
package/src/internal/Store.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { Json } from 'ox'
|
|
2
|
+
|
|
1
3
|
/** Minimal string store contract used by Tempo API internals. */
|
|
2
4
|
export type Store = {
|
|
3
5
|
/** Deletes a key. */
|
|
@@ -26,6 +28,22 @@ export type Store = {
|
|
|
26
28
|
}>
|
|
27
29
|
/** Writes a string value, optionally with a time-to-live. */
|
|
28
30
|
put(key: string, value: string, options?: Store.PutOptions): Promise<void>
|
|
31
|
+
/**
|
|
32
|
+
* Optional atomic compare-and-swap: writes `next` only when the current
|
|
33
|
+
* value equals `expected` (`null` = only-if-absent) and reports whether the
|
|
34
|
+
* write happened. Implement when the backend can execute the compare and
|
|
35
|
+
* write in one place (in-memory, inside a Durable Object); consumers go
|
|
36
|
+
* through the {@link update} helper, which falls back to a get + put when
|
|
37
|
+
* this is absent.
|
|
38
|
+
*/
|
|
39
|
+
swap?:
|
|
40
|
+
| ((
|
|
41
|
+
key: string,
|
|
42
|
+
expected: null | string,
|
|
43
|
+
next: string,
|
|
44
|
+
options?: Store.PutOptions,
|
|
45
|
+
) => Promise<boolean>)
|
|
46
|
+
| undefined
|
|
29
47
|
/**
|
|
30
48
|
* Store kind discriminant. `'state'` marks an authoritative, enumerable
|
|
31
49
|
* store ({@link State}); `'cache'` marks a cache (e.g. the Web Cache,
|
|
@@ -102,6 +120,50 @@ export async function increment(
|
|
|
102
120
|
return count
|
|
103
121
|
}
|
|
104
122
|
|
|
123
|
+
/**
|
|
124
|
+
* Atomically transforms a value on a store: reads the current value, applies
|
|
125
|
+
* `fn`, and writes the result, returning the written value.
|
|
126
|
+
*
|
|
127
|
+
* Uses the store's native {@link Store.swap} compare-and-swap when implemented,
|
|
128
|
+
* re-running `fn` on contention (optimistic concurrency, bounded by
|
|
129
|
+
* `attempts`). Otherwise falls back to a get + put read-modify-write, which
|
|
130
|
+
* can lose concurrent writes — fine for dev/in-process backends, not for
|
|
131
|
+
* contended distributed state.
|
|
132
|
+
*/
|
|
133
|
+
export async function update(
|
|
134
|
+
store: Store,
|
|
135
|
+
key: string,
|
|
136
|
+
fn: (current: null | string) => string,
|
|
137
|
+
options: update.Options = {},
|
|
138
|
+
): Promise<string> {
|
|
139
|
+
const { attempts = 8, ...put } = options
|
|
140
|
+
|
|
141
|
+
if (!store.swap) {
|
|
142
|
+
const next = fn(await store.get(key))
|
|
143
|
+
await store.put(key, next, put)
|
|
144
|
+
return next
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
for (let attempt = 0; attempt < attempts; attempt++) {
|
|
148
|
+
const current = await store.get(key)
|
|
149
|
+
const next = fn(current)
|
|
150
|
+
if (await store.swap(key, current, next, put)) return next
|
|
151
|
+
}
|
|
152
|
+
throw new UpdateContentionError(key, attempts)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export declare namespace update {
|
|
156
|
+
/** Options for {@link update}. */
|
|
157
|
+
type Options = Store.PutOptions & {
|
|
158
|
+
/**
|
|
159
|
+
* Maximum compare-and-swap attempts before failing with
|
|
160
|
+
* {@link UpdateContentionError}.
|
|
161
|
+
* @default 8
|
|
162
|
+
*/
|
|
163
|
+
attempts?: number | undefined
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
105
167
|
/** Creates an in-memory store with TTL support. */
|
|
106
168
|
export function memory(options: memory.Options = {}): State {
|
|
107
169
|
// Track value + optional expiration in one cell so `get` can lazily evict
|
|
@@ -148,6 +210,14 @@ export function memory(options: memory.Options = {}): State {
|
|
|
148
210
|
const expiresAt = options.ttl === undefined ? undefined : Date.now() + options.ttl
|
|
149
211
|
cells.set(key, { expiresAt, value })
|
|
150
212
|
},
|
|
213
|
+
// Native swap: the compare and write are synchronous, so concurrent
|
|
214
|
+
// consumers cannot interleave between them.
|
|
215
|
+
async swap(key, expected, next, options = {}) {
|
|
216
|
+
if ((read(key)?.value ?? null) !== expected) return false
|
|
217
|
+
const expiresAt = options.ttl === undefined ? undefined : Date.now() + options.ttl
|
|
218
|
+
cells.set(key, { expiresAt, value: next })
|
|
219
|
+
return true
|
|
220
|
+
},
|
|
151
221
|
})
|
|
152
222
|
}
|
|
153
223
|
|
|
@@ -302,6 +372,19 @@ function durableObjectStorage(storage: durableObject.Storage): State {
|
|
|
302
372
|
|
|
303
373
|
await storage.put(key, durableObjectValue(value, options))
|
|
304
374
|
},
|
|
375
|
+
// Native swap: the object is single-threaded and its input gate holds
|
|
376
|
+
// other events while storage operations are in flight, so the compare and
|
|
377
|
+
// write cannot interleave with another request.
|
|
378
|
+
async swap(key, expected, next, options = {}) {
|
|
379
|
+
const current = (await durableObjectRead(storage, key, await storage.get(key)))?.value ?? null
|
|
380
|
+
if (current !== expected) return false
|
|
381
|
+
if (options.ttl !== undefined && options.ttl <= 0) {
|
|
382
|
+
await storage.delete(key)
|
|
383
|
+
return true
|
|
384
|
+
}
|
|
385
|
+
await storage.put(key, durableObjectValue(next, options))
|
|
386
|
+
return true
|
|
387
|
+
},
|
|
305
388
|
})
|
|
306
389
|
}
|
|
307
390
|
|
|
@@ -341,12 +424,20 @@ export declare namespace durableObject {
|
|
|
341
424
|
/**
|
|
342
425
|
* Minimal Durable Object RPC stub shape used by this adapter — just the store
|
|
343
426
|
* operations (no `type` discriminant, which an RPC stub doesn't carry).
|
|
344
|
-
* `increment`
|
|
345
|
-
* `DurableObject` class, whose RPC surface implements
|
|
427
|
+
* `increment` and `swap` are required: the stub mirrors the
|
|
428
|
+
* `tapimo/cloudflare` `DurableObject` class, whose RPC surface implements
|
|
429
|
+
* them.
|
|
346
430
|
*/
|
|
347
|
-
type Stub = Omit<Store, 'increment' | 'type'> & {
|
|
431
|
+
type Stub = Omit<Store, 'increment' | 'swap' | 'type'> & {
|
|
348
432
|
/** Atomically increments an integer counter inside the object. */
|
|
349
433
|
increment(key: string, options?: Store.PutOptions): Promise<number>
|
|
434
|
+
/** Atomically compares-and-swaps a value inside the object. */
|
|
435
|
+
swap(
|
|
436
|
+
key: string,
|
|
437
|
+
expected: null | string,
|
|
438
|
+
next: string,
|
|
439
|
+
options?: Store.PutOptions,
|
|
440
|
+
): Promise<boolean>
|
|
350
441
|
}
|
|
351
442
|
}
|
|
352
443
|
|
|
@@ -364,7 +455,7 @@ export async function memoize<value>(
|
|
|
364
455
|
const raw = await store.get(key)
|
|
365
456
|
if (raw) {
|
|
366
457
|
try {
|
|
367
|
-
return
|
|
458
|
+
return Json.parse(raw) as value
|
|
368
459
|
} catch {
|
|
369
460
|
// Corrupt entries are treated as misses so the next call refreshes.
|
|
370
461
|
}
|
|
@@ -379,7 +470,7 @@ export async function memoize<value>(
|
|
|
379
470
|
// fetch is still in flight.
|
|
380
471
|
const flight = (async () => {
|
|
381
472
|
const value = await fetch()
|
|
382
|
-
await store.put(key,
|
|
473
|
+
await store.put(key, Json.stringify(value), { ttl })
|
|
383
474
|
return value
|
|
384
475
|
})()
|
|
385
476
|
flights.set(key, flight)
|
|
@@ -450,6 +541,12 @@ function durableObjectStub(
|
|
|
450
541
|
async put(key, value, options) {
|
|
451
542
|
await durableObjectRetry(() => stub(key).put(key, value, options))
|
|
452
543
|
},
|
|
544
|
+
// Native RPC swap: the compare-and-write runs inside the object. A lost
|
|
545
|
+
// response retried after a successful write compares stale and returns
|
|
546
|
+
// false, which optimistic callers already handle as contention.
|
|
547
|
+
async swap(key, expected, next, options) {
|
|
548
|
+
return durableObjectRetry(() => stub(key).swap(key, expected, next, options))
|
|
549
|
+
},
|
|
453
550
|
})
|
|
454
551
|
}
|
|
455
552
|
|
|
@@ -482,7 +579,7 @@ function cloudflareKvExpirationTtl(ttl: number | undefined) {
|
|
|
482
579
|
return seconds
|
|
483
580
|
}
|
|
484
581
|
|
|
485
|
-
const durableObjectValueType = '
|
|
582
|
+
const durableObjectValueType = 'tempo-api:store:v1'
|
|
486
583
|
|
|
487
584
|
type DurableObjectValue = {
|
|
488
585
|
expiresAt?: number | undefined
|
|
@@ -524,3 +621,12 @@ function isDurableObjectValue(value: unknown): value is DurableObjectValue {
|
|
|
524
621
|
typeof value.expiresAt === 'number')
|
|
525
622
|
)
|
|
526
623
|
}
|
|
624
|
+
|
|
625
|
+
/** Thrown when {@link update} exhausts its compare-and-swap attempts. */
|
|
626
|
+
export class UpdateContentionError extends Error {
|
|
627
|
+
override name = 'Store.UpdateContentionError'
|
|
628
|
+
|
|
629
|
+
constructor(key: string, attempts: number) {
|
|
630
|
+
super(`Store update on "${key}" lost ${attempts} compare-and-swap attempts.`)
|
|
631
|
+
}
|
|
632
|
+
}
|
|
@@ -1,49 +1,38 @@
|
|
|
1
1
|
import type { Context } from 'hono'
|
|
2
2
|
|
|
3
|
+
import * as TestApp from '../../test/App.js'
|
|
3
4
|
import type * as App from '../App.js'
|
|
4
|
-
import * as
|
|
5
|
+
import * as Db from '../db/Db.js'
|
|
6
|
+
import * as core_VerifiedTokens from '../db/tables/verifiedTokens.js'
|
|
5
7
|
import * as VerifiedTokens from './VerifiedTokens.js'
|
|
6
8
|
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
+
// Publishes a list at an explicit version so `read`/`snapshot` can load it
|
|
10
|
+
// (the write API generates monotonic versions; tests pin readable ones). Skips
|
|
11
|
+
// a matching version so vitest retries on a shared db stay idempotent.
|
|
9
12
|
async function seed(
|
|
10
|
-
|
|
13
|
+
db: Db.Db,
|
|
11
14
|
chainId: number,
|
|
12
15
|
version: string,
|
|
13
16
|
list: readonly VerifiedTokens.Token[],
|
|
14
17
|
) {
|
|
15
|
-
|
|
16
|
-
await
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
`verified-token:v1:${chainId}:head`,
|
|
22
|
-
JSON.stringify({ chainId, count: list.length, schema: 1, updatedAt, version }),
|
|
23
|
-
)
|
|
18
|
+
if ((await core_VerifiedTokens.head(db, chainId))?.version === version) return
|
|
19
|
+
await core_VerifiedTokens.publish(db, chainId, {
|
|
20
|
+
tokens: list,
|
|
21
|
+
updatedAt: '2024-01-01T00:00:00.000Z',
|
|
22
|
+
version,
|
|
23
|
+
})
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
return {
|
|
31
|
-
counts,
|
|
32
|
-
store: Store.from({
|
|
33
|
-
...store,
|
|
34
|
-
get(key: string) {
|
|
35
|
-
if (key.endsWith(':head')) counts.head++
|
|
36
|
-
else if (key.includes(':snapshot:')) counts.snapshot++
|
|
37
|
-
return store.get(key)
|
|
38
|
-
},
|
|
39
|
-
}),
|
|
40
|
-
}
|
|
41
|
-
}
|
|
26
|
+
// A database whose every query fails (nothing listens on port 1), proving a
|
|
27
|
+
// code path performed zero database I/O when a test passes with it.
|
|
28
|
+
const unreachable = () =>
|
|
29
|
+
Db.postgres({ connectionString: 'postgresql://postgres:postgres@127.0.0.1:1/none' })
|
|
42
30
|
|
|
43
|
-
// Minimal context
|
|
44
|
-
|
|
31
|
+
// Minimal context serving the verified-tokens feature config and the app db
|
|
32
|
+
// (both the fresh and cached bindings, as `App.create` sets them).
|
|
33
|
+
function context(db: Db.Db, config?: App.create.VerifiedTokens): Context<App.Environment> {
|
|
45
34
|
return {
|
|
46
|
-
get: (key: string) => (key === 'verifiedTokens' ? config : undefined),
|
|
35
|
+
get: (key: string) => (key === 'verifiedTokens' ? (config ?? {}) : key === 'db' || key === 'dbCached' ? db : undefined), // prettier-ignore
|
|
47
36
|
} as unknown as Context<App.Environment>
|
|
48
37
|
}
|
|
49
38
|
|
|
@@ -111,14 +100,14 @@ describe('compile', () => {
|
|
|
111
100
|
})
|
|
112
101
|
|
|
113
102
|
describe('read', () => {
|
|
114
|
-
test('returns null when the chain has no
|
|
115
|
-
expect(await VerifiedTokens.read(
|
|
103
|
+
test('returns null when the chain has no list', async () => {
|
|
104
|
+
expect(await VerifiedTokens.read(TestApp.database(), 4217)).toBe(null)
|
|
116
105
|
})
|
|
117
106
|
|
|
118
|
-
test('compiles the snapshot
|
|
119
|
-
const
|
|
120
|
-
await seed(
|
|
121
|
-
const snapshot = await VerifiedTokens.read(
|
|
107
|
+
test('compiles the latest snapshot', async () => {
|
|
108
|
+
const db = TestApp.database()
|
|
109
|
+
await seed(db, 4217, 'v1', tokens)
|
|
110
|
+
const snapshot = await VerifiedTokens.read(db, 4217)
|
|
122
111
|
expect(snapshot?.version).toBe('v1')
|
|
123
112
|
expect(snapshot?.list.map((token) => token.symbol)).toMatchInlineSnapshot(`
|
|
124
113
|
[
|
|
@@ -129,21 +118,13 @@ describe('read', () => {
|
|
|
129
118
|
`)
|
|
130
119
|
})
|
|
131
120
|
|
|
132
|
-
test('
|
|
133
|
-
const
|
|
134
|
-
await
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
schema: 1,
|
|
140
|
-
updatedAt: '2024-01-01T00:00:00.000Z',
|
|
141
|
-
version: 'ghost',
|
|
142
|
-
}),
|
|
143
|
-
)
|
|
144
|
-
await expect(VerifiedTokens.read(store, 4217)).rejects.toThrowErrorMatchingInlineSnapshot(
|
|
145
|
-
`[VerifiedTokens.TornStateError: verified-token head for chain 4217 points at missing snapshot version "ghost".]`,
|
|
146
|
-
)
|
|
121
|
+
test('serves the latest publish as the head', async () => {
|
|
122
|
+
const db = TestApp.database()
|
|
123
|
+
await seed(db, 4217, 'v1', tokens)
|
|
124
|
+
await seed(db, 4217, 'v2', tokens.slice(0, 1))
|
|
125
|
+
const snapshot = await VerifiedTokens.read(db, 4217)
|
|
126
|
+
expect(snapshot?.version).toBe('v2')
|
|
127
|
+
expect(snapshot?.list.map((token) => token.symbol)).toEqual(['aUSD'])
|
|
147
128
|
})
|
|
148
129
|
})
|
|
149
130
|
|
|
@@ -157,51 +138,54 @@ describe('snapshot', () => {
|
|
|
157
138
|
expect(snapshot.list).toEqual([])
|
|
158
139
|
})
|
|
159
140
|
|
|
160
|
-
// The following cases run in order and share the module-level per-isolate
|
|
161
|
-
// for chain 42431: cold load → fresh → soft-stale
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
141
|
+
// The following cases run in order and share the module-level per-isolate
|
|
142
|
+
// cache for chain 42431 (and one seeded db): cold load → fresh → soft-stale
|
|
143
|
+
// unchanged → soft-stale changed.
|
|
144
|
+
const db = TestApp.database()
|
|
145
|
+
|
|
146
|
+
test('cold-loads from the database when a list exists', async () => {
|
|
147
|
+
await seed(db, 42431, 'v1', tokens)
|
|
148
|
+
const snapshot = await VerifiedTokens.snapshot(context(db), 42431)
|
|
166
149
|
expect(snapshot.version).toBe('v1')
|
|
167
150
|
expect(snapshot.byAddress.size).toBe(tokens.length)
|
|
168
151
|
})
|
|
169
152
|
|
|
170
|
-
test('serves fresh lookups with zero
|
|
171
|
-
|
|
172
|
-
|
|
153
|
+
test('serves fresh lookups with zero database I/O', async () => {
|
|
154
|
+
// Every query against this db fails, so a pass proves the fresh window
|
|
155
|
+
// never touched the database.
|
|
156
|
+
const snapshot = await VerifiedTokens.snapshot(
|
|
157
|
+
context(unreachable(), { refreshMs: 60_000 }),
|
|
158
|
+
42431,
|
|
159
|
+
)
|
|
173
160
|
expect(snapshot.version).toBe('v1')
|
|
174
|
-
expect(counts).toEqual({ head: 0, snapshot: 0 })
|
|
175
161
|
})
|
|
176
162
|
|
|
177
|
-
test('soft-stale
|
|
178
|
-
const base = Store.memory()
|
|
179
|
-
await seed(base, 42431, 'v1', tokens)
|
|
180
|
-
const { counts, store } = counting(base)
|
|
163
|
+
test('soft-stale keeps serving an unchanged version', async () => {
|
|
181
164
|
// refreshMs 0 forces the soft-stale path; the current snapshot is returned
|
|
182
165
|
// immediately and the head-only refresh runs in the background.
|
|
183
|
-
const snapshot = await VerifiedTokens.snapshot(context({ refreshMs: 0
|
|
166
|
+
const snapshot = await VerifiedTokens.snapshot(context(db, { refreshMs: 0 }), 42431)
|
|
184
167
|
expect(snapshot.version).toBe('v1')
|
|
185
168
|
await flush()
|
|
186
|
-
expect(counts).toEqual({ head: 1, snapshot: 0 })
|
|
187
169
|
})
|
|
188
170
|
|
|
189
171
|
test('soft-stale recompiles when the version changed', async () => {
|
|
190
|
-
|
|
191
|
-
await seed(store, 42431, 'v2', tokens.slice(0, 1))
|
|
172
|
+
await seed(db, 42431, 'v2', tokens.slice(0, 1))
|
|
192
173
|
// Stale call returns the existing v1 immediately, refreshes to v2 in background.
|
|
193
|
-
const stale = await VerifiedTokens.snapshot(context({ refreshMs: 0
|
|
174
|
+
const stale = await VerifiedTokens.snapshot(context(db, { refreshMs: 0 }), 42431)
|
|
194
175
|
expect(stale.version).toBe('v1')
|
|
195
|
-
|
|
196
|
-
//
|
|
197
|
-
|
|
198
|
-
|
|
176
|
+
// The refresh round-trips Postgres, so poll the (fresh-window, zero-I/O)
|
|
177
|
+
// cell until it lands rather than racing a single macrotask.
|
|
178
|
+
await vi.waitFor(async () => {
|
|
179
|
+
const fresh = await VerifiedTokens.snapshot(context(db, { refreshMs: 60_000 }), 42431)
|
|
180
|
+
expect(fresh.version).toBe('v2')
|
|
181
|
+
})
|
|
182
|
+
const fresh = await VerifiedTokens.snapshot(context(db, { refreshMs: 60_000 }), 42431)
|
|
199
183
|
expect(fresh.list.map((token) => token.symbol)).toEqual(['aUSD'])
|
|
200
184
|
})
|
|
201
185
|
|
|
202
|
-
test('cold-loads an empty snapshot when the
|
|
203
|
-
// Chain 4217 has no configured cell yet and an empty
|
|
204
|
-
const snapshot = await VerifiedTokens.snapshot(context(
|
|
186
|
+
test('cold-loads an empty snapshot when the chain has no rows', async () => {
|
|
187
|
+
// Chain 4217 has no configured cell yet and an empty database → empty snapshot.
|
|
188
|
+
const snapshot = await VerifiedTokens.snapshot(context(TestApp.database()), 4217)
|
|
205
189
|
expect(snapshot.version).toBe('empty')
|
|
206
190
|
expect(snapshot.list).toEqual([])
|
|
207
191
|
})
|
|
@@ -217,9 +201,9 @@ const newToken = {
|
|
|
217
201
|
|
|
218
202
|
describe('replace', () => {
|
|
219
203
|
test('publishes the list and round-trips through read', async () => {
|
|
220
|
-
const
|
|
221
|
-
const { snapshot } = await VerifiedTokens.replace(
|
|
222
|
-
const read = await VerifiedTokens.read(
|
|
204
|
+
const db = TestApp.database()
|
|
205
|
+
const { snapshot } = await VerifiedTokens.replace(db, 42431, tokens)
|
|
206
|
+
const read = await VerifiedTokens.read(db, 42431)
|
|
223
207
|
expect(read?.version).toBe(snapshot.version)
|
|
224
208
|
expect(read?.list.map((token) => token.symbol)).toEqual(['aUSD', 'bEUR', 'cUSD'])
|
|
225
209
|
// Addresses are normalized to lowercase on write.
|
|
@@ -227,16 +211,16 @@ describe('replace', () => {
|
|
|
227
211
|
})
|
|
228
212
|
|
|
229
213
|
test('advances the head version on each publish', async () => {
|
|
230
|
-
const
|
|
231
|
-
const first = await VerifiedTokens.replace(
|
|
232
|
-
const second = await VerifiedTokens.replace(
|
|
214
|
+
const db = TestApp.database()
|
|
215
|
+
const first = await VerifiedTokens.replace(db, 42431, tokens)
|
|
216
|
+
const second = await VerifiedTokens.replace(db, 42431, tokens.slice(0, 1))
|
|
233
217
|
expect(second.snapshot.version > first.snapshot.version).toBe(true)
|
|
234
218
|
})
|
|
235
219
|
|
|
236
220
|
test('rejects a duplicate address in the input', async () => {
|
|
237
|
-
const
|
|
221
|
+
const db = TestApp.database()
|
|
238
222
|
await expect(
|
|
239
|
-
VerifiedTokens.replace(
|
|
223
|
+
VerifiedTokens.replace(db, 42431, [tokens[0]!, tokens[0]!]),
|
|
240
224
|
).rejects.toThrowErrorMatchingInlineSnapshot(
|
|
241
225
|
`[VerifiedTokens.DuplicateAddressError: verified token 0xaaa0000000000000000000000000000000000001 already exists on chain 42431.]`,
|
|
242
226
|
)
|
|
@@ -245,35 +229,35 @@ describe('replace', () => {
|
|
|
245
229
|
|
|
246
230
|
describe('create', () => {
|
|
247
231
|
test('appends a token preserving order', async () => {
|
|
248
|
-
const
|
|
249
|
-
await VerifiedTokens.replace(
|
|
250
|
-
const { snapshot, token } = await VerifiedTokens.create(
|
|
232
|
+
const db = TestApp.database()
|
|
233
|
+
await VerifiedTokens.replace(db, 42431, tokens)
|
|
234
|
+
const { snapshot, token } = await VerifiedTokens.create(db, { ...newToken, chainId: 42431 })
|
|
251
235
|
expect(token.address).toBe('0xddd0000000000000000000000000000000000004')
|
|
252
236
|
expect(snapshot.list.map((t) => t.symbol)).toEqual(['aUSD', 'bEUR', 'cUSD', 'dGBP'])
|
|
253
237
|
})
|
|
254
238
|
|
|
255
239
|
test('rejects a duplicate address', async () => {
|
|
256
|
-
const
|
|
257
|
-
await VerifiedTokens.replace(
|
|
258
|
-
await expect(
|
|
259
|
-
VerifiedTokens.
|
|
260
|
-
)
|
|
240
|
+
const db = TestApp.database()
|
|
241
|
+
await VerifiedTokens.replace(db, 42431, tokens)
|
|
242
|
+
await expect(VerifiedTokens.create(db, { ...tokens[0]!, chainId: 42431 })).rejects.toThrowError(
|
|
243
|
+
VerifiedTokens.DuplicateAddressError,
|
|
244
|
+
)
|
|
261
245
|
})
|
|
262
246
|
|
|
263
247
|
test('rejects a case-insensitive duplicate symbol', async () => {
|
|
264
|
-
const
|
|
265
|
-
await VerifiedTokens.replace(
|
|
248
|
+
const db = TestApp.database()
|
|
249
|
+
await VerifiedTokens.replace(db, 42431, tokens)
|
|
266
250
|
await expect(
|
|
267
|
-
VerifiedTokens.create(
|
|
251
|
+
VerifiedTokens.create(db, { ...newToken, chainId: 42431, symbol: 'AUSD' }),
|
|
268
252
|
).rejects.toThrowError(VerifiedTokens.DuplicateSymbolError)
|
|
269
253
|
})
|
|
270
254
|
})
|
|
271
255
|
|
|
272
256
|
describe('patch', () => {
|
|
273
257
|
test('edits fields in place, preserving order', async () => {
|
|
274
|
-
const
|
|
275
|
-
await VerifiedTokens.replace(
|
|
276
|
-
const { snapshot, token } = await VerifiedTokens.patch(
|
|
258
|
+
const db = TestApp.database()
|
|
259
|
+
await VerifiedTokens.replace(db, 42431, tokens)
|
|
260
|
+
const { snapshot, token } = await VerifiedTokens.patch(db, 42431, tokens[1]!.address, {
|
|
277
261
|
name: 'Beta Euro',
|
|
278
262
|
})
|
|
279
263
|
expect(token.name).toBe('Beta Euro')
|
|
@@ -283,35 +267,35 @@ describe('patch', () => {
|
|
|
283
267
|
})
|
|
284
268
|
|
|
285
269
|
test('throws NotFoundError for an unknown address', async () => {
|
|
286
|
-
const
|
|
287
|
-
await VerifiedTokens.replace(
|
|
270
|
+
const db = TestApp.database()
|
|
271
|
+
await VerifiedTokens.replace(db, 42431, tokens)
|
|
288
272
|
await expect(
|
|
289
|
-
VerifiedTokens.patch(
|
|
273
|
+
VerifiedTokens.patch(db, 42431, newToken.address, { name: 'x' }),
|
|
290
274
|
).rejects.toThrowError(VerifiedTokens.NotFoundError)
|
|
291
275
|
})
|
|
292
276
|
|
|
293
277
|
test('rejects an If-Match version mismatch', async () => {
|
|
294
|
-
const
|
|
295
|
-
await VerifiedTokens.replace(
|
|
278
|
+
const db = TestApp.database()
|
|
279
|
+
await VerifiedTokens.replace(db, 42431, tokens)
|
|
296
280
|
await expect(
|
|
297
|
-
VerifiedTokens.patch(
|
|
281
|
+
VerifiedTokens.patch(db, 42431, tokens[0]!.address, { name: 'x' }, { ifMatch: 'stale' }),
|
|
298
282
|
).rejects.toThrowError(VerifiedTokens.VersionMismatchError)
|
|
299
283
|
})
|
|
300
284
|
})
|
|
301
285
|
|
|
302
286
|
describe('remove', () => {
|
|
303
287
|
test('removes a token and publishes', async () => {
|
|
304
|
-
const
|
|
305
|
-
await VerifiedTokens.replace(
|
|
306
|
-
const { snapshot } = await VerifiedTokens.remove(
|
|
288
|
+
const db = TestApp.database()
|
|
289
|
+
await VerifiedTokens.replace(db, 42431, tokens)
|
|
290
|
+
const { snapshot } = await VerifiedTokens.remove(db, 42431, tokens[0]!.address)
|
|
307
291
|
expect(snapshot.list.map((t) => t.symbol)).toEqual(['bEUR', 'cUSD'])
|
|
308
292
|
expect(snapshot.byAddress.has(tokens[0]!.address.toLowerCase())).toBe(false)
|
|
309
293
|
})
|
|
310
294
|
|
|
311
295
|
test('throws NotFoundError for an unknown address', async () => {
|
|
312
|
-
const
|
|
313
|
-
await VerifiedTokens.replace(
|
|
314
|
-
await expect(VerifiedTokens.remove(
|
|
296
|
+
const db = TestApp.database()
|
|
297
|
+
await VerifiedTokens.replace(db, 42431, tokens)
|
|
298
|
+
await expect(VerifiedTokens.remove(db, 42431, newToken.address)).rejects.toThrowError(
|
|
315
299
|
VerifiedTokens.NotFoundError,
|
|
316
300
|
)
|
|
317
301
|
})
|
|
@@ -326,8 +310,8 @@ describe('prime', () => {
|
|
|
326
310
|
version: 'primed',
|
|
327
311
|
})
|
|
328
312
|
VerifiedTokens.prime(42431, primed)
|
|
329
|
-
// Within the fresh window the primed snapshot is served with no
|
|
330
|
-
const snapshot = await VerifiedTokens.snapshot(context({ refreshMs: 60_000
|
|
313
|
+
// Within the fresh window the primed snapshot is served with no database read.
|
|
314
|
+
const snapshot = await VerifiedTokens.snapshot(context(unreachable(), { refreshMs: 60_000 }), 42431) // prettier-ignore
|
|
331
315
|
expect(snapshot).toBe(primed)
|
|
332
316
|
})
|
|
333
317
|
})
|