@ardrive/turbo-sdk 1.42.0-alpha.13 → 1.42.0-alpha.14

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.
@@ -0,0 +1,394 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.createMemoryFolderIndex = createMemoryFolderIndex;
4
+ exports.createChainFolderIndex = createChainFolderIndex;
5
+ exports.composeFolderIndex = composeFolderIndex;
6
+ const base64_js_1 = require("../utils/base64.js");
7
+ const common_js_1 = require("../utils/common.js");
8
+ const errors_js_1 = require("../utils/errors.js");
9
+ const folderIndex_js_1 = require("../utils/folderIndex.js");
10
+ /**
11
+ * The base64url address a gateway indexes an upload under.
12
+ *
13
+ * Deliberately refuses to guess. A gateway matches `owners:` on the base64url
14
+ * sha-256 of the signer's public key, and a raw 32 byte ed25519 public key
15
+ * base64urls to exactly 43 characters -- indistinguishable from that address.
16
+ * Sniffing the shape of a bare string therefore has a silent failure mode that
17
+ * costs real money: the unhashed key matches nothing, every file misses, and
18
+ * the whole folder is re-uploaded with no error anywhere. So the caller says
19
+ * which one they have, and `getPublicKey()` bytes are taken directly.
20
+ */
21
+ function resolveOwnerAddress(owner) {
22
+ const fromPublicKey = (publicKey) => {
23
+ if (typeof publicKey === 'string' && !/^[a-zA-Z0-9_-]+$/.test(publicKey)) {
24
+ throw new errors_js_1.ProvidedInputError('createChainFolderIndex owner.publicKey must be base64url, or the raw bytes from await turbo.signer.getPublicKey()');
25
+ }
26
+ const raw = typeof publicKey === 'string'
27
+ ? (0, base64_js_1.fromB64Url)(publicKey)
28
+ : Buffer.from(publicKey);
29
+ // Declaring which one you have is not the same as having it. Anything gets
30
+ // hashed to a well formed address that simply matches nothing, which is the
31
+ // same silent full price re-upload the tagged union exists to prevent, one
32
+ // level in. These are the key sizes this SDK's signers produce.
33
+ if (!publicKeyByteLengths.has(raw.length)) {
34
+ throw new errors_js_1.ProvidedInputError(`createChainFolderIndex owner.publicKey must be an ed25519 (32 byte), secp256k1 (65 byte) or RSA (512 byte) public key, got ${raw.length} bytes. Pass await turbo.signer.getPublicKey(), or use { address } if what you have is an owner address.`);
35
+ }
36
+ return (0, base64_js_1.ownerToAddress)((0, base64_js_1.toB64Url)(raw));
37
+ };
38
+ if (owner instanceof Uint8Array) {
39
+ return fromPublicKey(owner);
40
+ }
41
+ if (owner !== null && typeof owner === 'object') {
42
+ if (owner.publicKey !== undefined) {
43
+ return fromPublicKey(owner.publicKey);
44
+ }
45
+ if (typeof owner.address === 'string') {
46
+ if (!(0, common_js_1.isValidArweaveBase64URL)(owner.address)) {
47
+ throw new errors_js_1.ProvidedInputError(`createChainFolderIndex owner.address must be a 43 character base64url address, got '${owner.address}'. A native address (an 0x... or a base58 Solana address) is not what a gateway indexes uploads under.`);
48
+ }
49
+ return owner.address;
50
+ }
51
+ }
52
+ throw new errors_js_1.ProvidedInputError('createChainFolderIndex needs owner: await turbo.signer.getPublicKey(), or { publicKey } or { address }. ' +
53
+ 'A bare string is ambiguous -- a 32 byte ed25519 public key and an owner address are both 43 base64url characters -- ' +
54
+ 'and guessing wrong re-uploads the whole folder without an error.');
55
+ }
56
+ /**
57
+ * What `getPublicKey()` returns across the supported signers: ed25519 for
58
+ * Solana and ario, uncompressed secp256k1 for Ethereum, Base, Polygon and KYVE,
59
+ * and a 4096 bit RSA modulus for Arweave.
60
+ *
61
+ * A 32 byte value declared as a `publicKey` is still taken at its word, since
62
+ * an owner address is also 32 bytes and nothing can tell them apart -- that is
63
+ * why the caller has to say which one they mean.
64
+ */
65
+ const publicKeyByteLengths = new Set([32, 65, 512]);
66
+ function requirePositiveInteger(value, name, max) {
67
+ const parsed = Number(value);
68
+ // `1e21` is an integer to Number.isInteger and stringifies as "1e+21", which
69
+ // is not a GraphQL Int. An upper bound settles both that and a nonsense page.
70
+ if (!Number.isInteger(parsed) || parsed < 1 || parsed > max) {
71
+ throw new errors_js_1.ProvidedInputError(`createChainFolderIndex ${name} must be an integer between 1 and ${max}, got '${value}'`);
72
+ }
73
+ return parsed;
74
+ }
75
+ /**
76
+ * One signal that aborts on whichever comes first, the caller giving up or the
77
+ * request timing out. `AbortSignal.any` is not available on every supported
78
+ * runtime, so this is wired by hand.
79
+ */
80
+ function abortAfter(timeoutMs, signal) {
81
+ const controller = new AbortController();
82
+ const timer = setTimeout(() => controller.abort(new Error(`Timed out after ${timeoutMs}ms`)), timeoutMs);
83
+ const onAbort = () => controller.abort(signal?.reason);
84
+ if (signal !== undefined) {
85
+ if (signal.aborted) {
86
+ controller.abort(signal.reason);
87
+ }
88
+ else {
89
+ signal.addEventListener('abort', onAbort, { once: true });
90
+ }
91
+ }
92
+ return {
93
+ signal: controller.signal,
94
+ done: () => {
95
+ clearTimeout(timer);
96
+ signal?.removeEventListener('abort', onAbort);
97
+ },
98
+ };
99
+ }
100
+ const contentHashesOf = (keys) => new Set([...keys].map(folderIndex_js_1.contentHashFromFolderIndexKey));
101
+ /**
102
+ * An in-memory folder index.
103
+ *
104
+ * On its own it only deduplicates identical files within a single
105
+ * `uploadFolder` call, which `uploadFolder` already does. Its real use is as
106
+ * the writable floor of a {@link composeFolderIndex} stack, or seeded from a
107
+ * mapping the caller persisted itself.
108
+ */
109
+ function createMemoryFolderIndex(seed = {}) {
110
+ const map = new Map(Object.entries(seed).filter(([key, id]) => (0, folderIndex_js_1.isValidFolderIndexKey)(key) && (0, common_js_1.isValidArweaveBase64URL)(id)));
111
+ return {
112
+ name: 'memory',
113
+ get: (key) => map.get(key),
114
+ set: (key, id) => {
115
+ map.set(key, id);
116
+ },
117
+ knownContentHashes: (contentHashes) => {
118
+ const known = contentHashesOf(map.keys());
119
+ return contentHashes.filter((contentHash) => known.has(contentHash));
120
+ },
121
+ entries: () => Object.fromEntries(map),
122
+ };
123
+ }
124
+ /**
125
+ * Rebuilds a folder index by sweeping the uploader's own past uploads over a
126
+ * gateway's GraphQL endpoint, filtering on the `File-SHA256` tag that
127
+ * index-backed uploads always write.
128
+ *
129
+ * Read only, and the only layer that survives a fresh checkout on a machine
130
+ * that has never deployed before -- a CI runner, most obviously.
131
+ *
132
+ * Known limitation: gateways index an upload minutes after it lands, so two
133
+ * machines deploying the same new file at the same moment will each pay for it
134
+ * once. The manifest is correct either way; only the bill is affected, and only
135
+ * for genuinely new bytes.
136
+ */
137
+ function createChainFolderIndex({ owner, appName, gatewayUrl = 'https://arweave.net', hashTagName = folderIndex_js_1.contentHashTagName, maxPages = 20, pageSize = 100, timeoutMs = 30_000, fetchImpl = fetch, logger, }) {
138
+ const ownerAddress = resolveOwnerAddress(owner);
139
+ const first = requirePositiveInteger(pageSize, 'pageSize', 1000);
140
+ const pageLimit = requirePositiveInteger(maxPages, 'maxPages', 10_000);
141
+ const requestTimeoutMs = requirePositiveInteger(timeoutMs, 'timeoutMs', 24 * 60 * 60 * 1000);
142
+ const map = new Map();
143
+ // Every content hash a sweep has actually seen on chain, whatever tags the
144
+ // item carried. The bytes-only GraphQL filter hands this over for free, and
145
+ // it is the only evidence anywhere that a file's content is already paid for
146
+ // under a different tag set.
147
+ const seenContentHashes = new Set();
148
+ // Filtering on the hash tag itself keeps the sweep to items this run cares
149
+ // about, so a long deployment history costs nothing to walk past.
150
+ const tagFilter = appName !== undefined
151
+ ? `tags:[{name:"App-Name",values:[${JSON.stringify(appName)}]},{name:${JSON.stringify(hashTagName)},values:$hashes}]`
152
+ : `tags:[{name:${JSON.stringify(hashTagName)},values:$hashes}]`;
153
+ const query = `query($owner:String!,$hashes:[String!]!,$after:String){
154
+ transactions(owners:[$owner] ${tagFilter} sort:HEIGHT_DESC first:${first} after:$after){
155
+ pageInfo{hasNextPage}
156
+ edges{cursor node{id tags{name value}}}
157
+ }
158
+ }`;
159
+ return {
160
+ name: `chain:${gatewayUrl}`,
161
+ readOnly: true,
162
+ // Declared so `uploadFolder` writes the tag this sweep filters on. Without
163
+ // it a non-default `hashTagName` matches nothing, on every run, silently.
164
+ hashTagName,
165
+ get: (key) => map.get(key),
166
+ set: () => undefined,
167
+ knownContentHashes: (contentHashes) => contentHashes.filter((contentHash) => seenContentHashes.has(contentHash)),
168
+ resolve: async (keys, options) => {
169
+ const wanted = new Set(keys.filter(folderIndex_js_1.isValidFolderIndexKey));
170
+ if (wanted.size === 0) {
171
+ return {};
172
+ }
173
+ const hashes = [...contentHashesOf(wanted)];
174
+ const found = {};
175
+ let cursor = null;
176
+ let pagesWalked = 0;
177
+ let moreToWalk = false;
178
+ for (let page = 0; page < pageLimit && Object.keys(found).length < wanted.size; page++) {
179
+ pagesWalked++;
180
+ const abort = abortAfter(requestTimeoutMs, options?.signal);
181
+ let body;
182
+ try {
183
+ const response = await fetchImpl(`${gatewayUrl}/graphql`, {
184
+ method: 'POST',
185
+ headers: { 'content-type': 'application/json' },
186
+ body: JSON.stringify({
187
+ query,
188
+ variables: { owner: ownerAddress, hashes, after: cursor },
189
+ }),
190
+ signal: abort.signal,
191
+ });
192
+ if (!response.ok) {
193
+ throw new Error(`Failed to query ${gatewayUrl}/graphql for a folder index: ${response.status}`);
194
+ }
195
+ // Read inside the same guard. Headers arriving is not the request
196
+ // finishing: a gateway that flushes them and then stalls the body
197
+ // would hang here forever with the timer already cleared and the
198
+ // caller's abort listener already removed, and `uploadFolder` awaits
199
+ // this inline.
200
+ body = await response.json();
201
+ }
202
+ finally {
203
+ abort.done();
204
+ }
205
+ const transactions = body?.data?.transactions;
206
+ // A gateway may answer with an error payload, a null field, or a shape
207
+ // this code has never seen, and every node in a page it does return is
208
+ // equally untrusted.
209
+ if (!Array.isArray(transactions?.edges)) {
210
+ throw new Error(`Failed to query ${gatewayUrl}/graphql for a folder index: ${JSON.stringify(body?.errors ?? body)}`);
211
+ }
212
+ if (transactions.edges.length === 0) {
213
+ // `after` only advances from an edge, so a page with none would
214
+ // re-issue the identical query until maxPages ran out.
215
+ break;
216
+ }
217
+ for (const edge of transactions.edges) {
218
+ if (typeof edge?.cursor === 'string') {
219
+ cursor = edge.cursor;
220
+ }
221
+ const node = edge?.node;
222
+ const tags = node?.tags;
223
+ if (typeof node?.id !== 'string' || !Array.isArray(tags)) {
224
+ continue;
225
+ }
226
+ const contentHash = tags.find((tag) => tag?.name === hashTagName)
227
+ ?.value;
228
+ if (!(0, folderIndex_js_1.isValidContentHash)(contentHash)) {
229
+ continue;
230
+ }
231
+ // Seen on chain under whatever tags this item happens to carry. A
232
+ // key that does not match is still worth remembering as bytes that
233
+ // are already paid for.
234
+ seenContentHashes.add(contentHash);
235
+ // A node carries exactly the tags that were written, so the key can
236
+ // be recomputed and matched against the ones this run needs.
237
+ const key = await (0, folderIndex_js_1.folderIndexKey)({ contentHash, tags });
238
+ // Newest first, and every upload of these exact bytes and tags is
239
+ // equally valid, so the first sighting wins.
240
+ if (wanted.has(key) && found[key] === undefined) {
241
+ found[key] = node.id;
242
+ map.set(key, node.id);
243
+ }
244
+ }
245
+ if (transactions.pageInfo?.hasNextPage !== true) {
246
+ break;
247
+ }
248
+ moreToWalk = true;
249
+ }
250
+ // Running out of pages is not the same as running out of matches, and it
251
+ // is the one outcome that costs money without looking like anything: the
252
+ // unresolved files are re-uploaded at full price, on every deploy, and
253
+ // the summary reports them as ordinary new files. `pageSize * maxPages`
254
+ // caps how many items a sweep can ever see, so a folder larger than that
255
+ // cannot resolve in full however many times it is run.
256
+ const resolved = Object.keys(found).length;
257
+ if (pagesWalked >= pageLimit && moreToWalk && resolved < wanted.size) {
258
+ logger?.warn(`The folder index sweep of ${gatewayUrl} stopped at its ${pageLimit} page limit with ` +
259
+ `${wanted.size - resolved} of ${wanted.size} file(s) still unresolved, and the gateway ` +
260
+ 'had more to give. Those files are about to be uploaded and paid for again even though ' +
261
+ `they may already be on Arweave. A sweep can see at most pageSize * maxPages items ` +
262
+ `(${first} * ${pageLimit} = ${first * pageLimit} here), so raise maxPages or pageSize, ` +
263
+ 'or put a persistent local index in front of this one.');
264
+ }
265
+ return found;
266
+ },
267
+ entries: () => Object.fromEntries(map),
268
+ };
269
+ }
270
+ /**
271
+ * Layers folder indexes: reads fall through in order, writes go to every layer
272
+ * that is not read only.
273
+ *
274
+ * The usual stack is a local cache in front of a chain index -- the cache
275
+ * answers instantly on a developer machine, and the chain index is what a CI
276
+ * runner with an empty working directory falls back to.
277
+ *
278
+ * A layer that throws is skipped, not propagated. That is the whole point of
279
+ * stacking them: a full disk under the file layer must not stop the memory
280
+ * layer from holding ids the run has already paid for, and an unreachable
281
+ * gateway must not stop the local cache from answering.
282
+ */
283
+ function composeFolderIndex(layers, { logger } = {}) {
284
+ const stack = layers.filter((layer) => layer !== undefined);
285
+ if (stack.length === 0) {
286
+ return createMemoryFolderIndex();
287
+ }
288
+ const writableLayers = stack.filter((layer) => layer.readOnly !== true);
289
+ // Every layer that cares which tag holds a content hash has to want the same
290
+ // one, because `uploadFolder` writes exactly one tag per file. Two layers
291
+ // disagreeing is not something a run can degrade around: whichever one loses
292
+ // matches nothing, for ever, without an error.
293
+ const declaredHashTagNames = [
294
+ ...new Set(stack
295
+ .map((layer) => layer.hashTagName)
296
+ .filter((name) => name !== undefined)),
297
+ ];
298
+ if (declaredHashTagNames.length > 1) {
299
+ throw new errors_js_1.ProvidedInputError(`composeFolderIndex layers disagree about which tag holds a file's content hash: ${declaredHashTagNames
300
+ .map((name) => `'${name}'`)
301
+ .join(', ')}. uploadFolder writes one tag per file, so every layer that declares one must declare the same one.`);
302
+ }
303
+ const failed = (layer, what, error) => logger?.error(`Folder index layer ${layer.name ?? 'anonymous'} failed to ${what}, skipping it`, error);
304
+ const set = async (key, id) => {
305
+ for (const layer of writableLayers) {
306
+ try {
307
+ await layer.set(key, id);
308
+ }
309
+ catch (error) {
310
+ failed(layer, 'write', error);
311
+ }
312
+ }
313
+ };
314
+ return {
315
+ name: `composed(${stack
316
+ .map((layer) => layer.name ?? 'anonymous')
317
+ .join(', ')})`,
318
+ readOnly: writableLayers.length === 0,
319
+ hashTagName: declaredHashTagNames[0],
320
+ get: async (key) => {
321
+ for (const layer of stack) {
322
+ try {
323
+ const id = await layer.get(key);
324
+ if ((0, common_js_1.isValidArweaveBase64URL)(id ?? '')) {
325
+ return id;
326
+ }
327
+ }
328
+ catch (error) {
329
+ failed(layer, 'read', error);
330
+ }
331
+ }
332
+ return undefined;
333
+ },
334
+ set,
335
+ knownContentHashes: async (contentHashes) => {
336
+ const known = new Set();
337
+ for (const layer of stack) {
338
+ try {
339
+ for (const contentHash of (await layer.knownContentHashes?.(contentHashes)) ?? []) {
340
+ known.add(contentHash);
341
+ }
342
+ }
343
+ catch (error) {
344
+ failed(layer, 'report known content hashes', error);
345
+ }
346
+ }
347
+ return [...known];
348
+ },
349
+ resolve: async (keys, options) => {
350
+ const found = {};
351
+ let remaining = keys;
352
+ for (const layer of stack) {
353
+ if (remaining.length === 0) {
354
+ break;
355
+ }
356
+ if (layer.resolve === undefined) {
357
+ continue;
358
+ }
359
+ try {
360
+ const resolved = await layer.resolve(remaining, options);
361
+ for (const [key, id] of Object.entries(resolved)) {
362
+ if (!(0, common_js_1.isValidArweaveBase64URL)(id)) {
363
+ continue;
364
+ }
365
+ found[key] = id;
366
+ }
367
+ remaining = remaining.filter((key) => found[key] === undefined);
368
+ }
369
+ catch (error) {
370
+ failed(layer, 'resolve', error);
371
+ }
372
+ }
373
+ // An id recovered from a read only layer is worth caching in the writable
374
+ // ones so the next run can skip the network entirely.
375
+ for (const [key, id] of Object.entries(found)) {
376
+ await set(key, id);
377
+ }
378
+ return found;
379
+ },
380
+ entries: async () => {
381
+ const entries = {};
382
+ // Reverse so the front of the stack wins on conflict.
383
+ for (const layer of [...stack].reverse()) {
384
+ try {
385
+ Object.assign(entries, (await layer.entries?.()) ?? {});
386
+ }
387
+ catch (error) {
388
+ failed(layer, 'enumerate', error);
389
+ }
390
+ }
391
+ return entries;
392
+ },
393
+ };
394
+ }
@@ -30,6 +30,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
30
30
  * limitations under the License.
31
31
  */
32
32
  __exportStar(require("./upload.js"), exports);
33
+ __exportStar(require("./folderIndex.js"), exports);
33
34
  __exportStar(require("./payment.js"), exports);
34
35
  // The ANT-owner helpers. Without this the documented
35
36
  // `import { solanaOwnerSigner } from '@ardrive/turbo-sdk'` does not resolve.