@johannc-dev/zyrk 0.2.0 → 0.2.1

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/dist/export.d.ts CHANGED
@@ -7,14 +7,17 @@ export interface ExportConnection {
7
7
  readonly apiKey: string;
8
8
  /** At most this many requests per minute (default: as fast as the server allows). */
9
9
  readonly rpm?: number;
10
- /** Network or 5xx failures tolerated in a row before giving up (default 8). */
10
+ /** Network failures, 502, 503 and 504 tolerated in a row before giving up (default 8); another 5xx is retried once. */
11
11
  readonly maxRetries?: number;
12
- /** Longest wait accepted on a 429 before stopping (default 5 minutes): a daily quota stops the export. */
12
+ /** Longest wait accepted on 429s in a row before stopping (default 5 minutes): a daily quota stops the export. */
13
13
  readonly maxWaitMs?: number;
14
14
  readonly fetch?: typeof fetch;
15
15
  readonly signal?: AbortSignal;
16
16
  readonly onProgress?: (progress: ExportProgress) => void;
17
- /** Hours that may still change (open, not sealed), files downloaded again. */
17
+ /**
18
+ * Hours that may still change (open, not sealed), files downloaded again,
19
+ * errors saved as the answer of a call.
20
+ */
18
21
  readonly onWarning?: (message: string) => void;
19
22
  }
20
23
  export interface ExportProgress {
@@ -52,7 +55,7 @@ export interface ExportSummary {
52
55
  }
53
56
  /** `[since, until)` in parts of at most 30 days, ending on UTC day boundaries. */
54
57
  export declare function parts(sinceMs: number, untilMs: number): [number, number][];
55
- /** Meta of an export answer (`history_columns::export::hour_response`). */
58
+ /** Meta of an export answer. */
56
59
  interface HourMeta {
57
60
  readonly format?: string;
58
61
  readonly coverage?: ReadMeta["coverage"];
@@ -76,12 +79,18 @@ export declare function parseFrame(bytes: Uint8Array): {
76
79
  export declare function staleFrom(meta: ReadMeta, dir: string): number | null;
77
80
  /** Exports one read (cut into parts of at most 30 days). Resumes an interrupted export. */
78
81
  export declare function exportHistory(connection: ExportConnection, out: string, read: ExportRead): Promise<ExportSummary>;
79
- /** Saves `/v1/tokens/{mint}/totals` for `ctx.data.tokenTotals`. */
82
+ /**
83
+ * Saves the totals of a mint for `ctx.data.tokenTotals`, and its info for
84
+ * `ctx.data.tokenInfo`, as hosted programs receive them. Returns the totals,
85
+ * or the error answer saved for them (`{ error: { kind, code, message } }`).
86
+ */
80
87
  export declare function exportTotals(connection: ExportConnection, out: string, mint: string, quote?: string | null): Promise<Json>;
81
88
  /**
82
- * Saves every page of `/v1/volume/search` for `params`, following the
89
+ * Saves every page of a volume search for `params`, following the
83
90
  * cursor exactly as `ctx.data.volumeSearch` does, so that the same call in
84
- * a local run finds each page.
91
+ * a local run finds each page. A page answered by an error (an invalid
92
+ * parameter...) is saved as that error, and ends the search, as it does in a
93
+ * hosted call.
85
94
  */
86
95
  export declare function exportVolumeSearch(connection: ExportConnection, out: string, params: VolumeSearchParams): Promise<number>;
87
96
  export {};
package/dist/export.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync, } from "node:fs";
2
- import { join } from "node:path";
2
+ import { dirname, join } from "node:path";
3
3
  import { decompressHour } from "./hourfile.js";
4
- import { HOUR_MS, READ_FORMAT, sha256, readId, spec as readSpec, totalsName, volumeName, } from "./local-data.js";
4
+ import { HOUR_MS, READ_FORMAT, infoName, sha256, readId, spec as readSpec, totalsName, volumeName, } from "./local-data.js";
5
5
  import { MAX_WINDOW_MS } from "./limits.js";
6
6
  const DAY_MS = 86_400_000;
7
7
  const PART_MS = 30 * DAY_MS;
@@ -40,6 +40,8 @@ const writeAtomic = (file, data) => {
40
40
  writeFileSync(tmp, data);
41
41
  renameSync(tmp, file);
42
42
  };
43
+ const MAX_BUSY_ATTEMPTS = 12;
44
+ const MAX_SERVER_ERRORS = 1;
43
45
  class Http {
44
46
  c;
45
47
  requests = 0;
@@ -47,12 +49,15 @@ class Http {
47
49
  constructor(c) {
48
50
  this.c = c;
49
51
  }
50
- async raw(path, query) {
52
+ async raw(path, query, keep) {
51
53
  const f = this.c.fetch ?? fetch;
52
54
  const url = new URL(this.c.url.replace(/\/+$/, "") + path);
53
55
  for (const [k, v] of Object.entries(query))
54
56
  url.searchParams.set(k, v);
55
57
  let failures = 0;
58
+ let serverErrors = 0;
59
+ let busy = 0;
60
+ let waited = 0;
56
61
  for (;;) {
57
62
  if (this.c.rpm) {
58
63
  const gap = 60_000 / this.c.rpm - (Date.now() - this.#last);
@@ -88,6 +93,8 @@ class Http {
88
93
  catch {
89
94
  body = null;
90
95
  }
96
+ if (keep?.(response.status, body))
97
+ return { status: response.status, contentType, bytes };
91
98
  const err = body?.error;
92
99
  const code = typeof err === "string"
93
100
  ? err
@@ -105,12 +112,26 @@ class Http {
105
112
  ? header * 1000
106
113
  : undefined;
107
114
  if (response.status === 429) {
108
- const wait = retryMs ?? 60_000;
109
- if (wait > (this.c.maxWaitMs ?? 300_000))
110
- throw new ExportError(`${code}: ${message} Retry after ${Math.round(wait / 1000)} s: run the same command again then, the export resumes where it stopped.`, code, 429);
115
+ const maxWait = this.c.maxWaitMs ?? 300_000;
116
+ const asked = retryMs ?? 60_000;
117
+ if (asked > maxWait)
118
+ throw new ExportError(`${code}: ${message} Retry after ${Math.round(asked / 1000)} s: run the same command again then, the export resumes where it stopped.`, code, 429);
119
+ const wait = Math.max(asked, Math.min(30_000, 1000 * 2 ** busy));
120
+ if (++busy >= MAX_BUSY_ATTEMPTS || waited + wait > maxWait)
121
+ throw new ExportError(`${code}: ${message} The server answered 429 ${busy} times in a row (${Math.round(waited / 1000)} s): run the same command again later, the export resumes where it stopped.`, code, 429);
122
+ waited += wait;
111
123
  await sleep(wait, this.c.signal);
112
124
  continue;
113
125
  }
126
+ if (response.status >= 500 &&
127
+ response.status !== 502 &&
128
+ response.status !== 503 &&
129
+ response.status !== 504) {
130
+ if (++serverErrors > MAX_SERVER_ERRORS)
131
+ throw new ExportError(`${response.status} ${code}: ${message}`, code, response.status);
132
+ await sleep(Math.min(30_000, 500 * 2 ** serverErrors), this.c.signal);
133
+ continue;
134
+ }
114
135
  if (response.status >= 500 && code !== "history_timeout") {
115
136
  if (++failures > (this.c.maxRetries ?? 8))
116
137
  throw new ExportError(`${response.status} ${code}: ${message}`, code, response.status);
@@ -120,11 +141,14 @@ class Http {
120
141
  throw new ExportError(`${response.status} ${code}: ${message}`, code, response.status);
121
142
  }
122
143
  }
123
- async get(path, query) {
124
- const answer = await this.raw(path, query);
144
+ async get(path, query, keep) {
145
+ const answer = await this.raw(path, query, keep);
125
146
  const text = new TextDecoder().decode(answer.bytes);
126
147
  try {
127
- return text ? JSON.parse(text) : null;
148
+ return {
149
+ status: answer.status,
150
+ body: text ? JSON.parse(text) : null,
151
+ };
128
152
  }
129
153
  catch {
130
154
  throw new ExportError(`${path}: the answer is not JSON`, "decode");
@@ -171,9 +195,9 @@ export function staleFrom(meta, dir) {
171
195
  break;
172
196
  let live = false;
173
197
  try {
198
+ const { header } = decompressHour(readFileSync(join(dir, `${hour}.zhb`)));
174
199
  live =
175
- decompressHour(readFileSync(join(dir, `${hour}.zhb`))).header.source ===
176
- "live";
200
+ header.source === "live" || (header.market && !header.virtualSides);
177
201
  }
178
202
  catch {
179
203
  live = true;
@@ -207,7 +231,7 @@ async function exportPart(http, out, s, sinceMs, untilMs, progress, refresh, onP
207
231
  if (meta.done) {
208
232
  const from = staleFrom(meta, dir);
209
233
  if (from !== null) {
210
- onWarning?.(`${id}: hours from ${new Date(from * HOUR_MS).toISOString()} were open or not sealed when exported; downloading them again`);
234
+ onWarning?.(`${id}: hours from ${new Date(from * HOUR_MS).toISOString()} were open or not sealed when exported, or exported by an older version; downloading them again`);
211
235
  const hours = meta.hours.filter((h) => h < from);
212
236
  meta = {
213
237
  ...meta,
@@ -307,12 +331,67 @@ export async function exportHistory(connection, out, read) {
307
331
  }
308
332
  return { reads, requests: http.requests, bytes: progress.bytes };
309
333
  }
334
+ const RECORDED = new Set([
335
+ "invalid_window",
336
+ "invalid_parameter",
337
+ "invalid_limit",
338
+ "invalid_amount",
339
+ "invalid_cursor",
340
+ "usd_volume_unavailable",
341
+ "partial_coverage",
342
+ "query_work_limit",
343
+ ]);
344
+ const recordable = (status, body) => {
345
+ const e = body?.error;
346
+ return (status === 400 &&
347
+ typeof e === "object" &&
348
+ e !== null &&
349
+ typeof e.kind === "string" &&
350
+ typeof e.code === "string" &&
351
+ typeof e.message === "string" &&
352
+ RECORDED.has(e.code));
353
+ };
354
+ async function getNew(http, path, query, what) {
355
+ try {
356
+ const { status, body } = await http.get(path, query, recordable);
357
+ if (status !== 400)
358
+ return { ok: body };
359
+ const e = body.error;
360
+ const retry = body.retryAfterMs;
361
+ return {
362
+ code: e.code,
363
+ error: {
364
+ error: { kind: e.kind, code: e.code, message: e.message },
365
+ ...(typeof retry === "number" ? { retryAfterMs: retry } : {}),
366
+ },
367
+ };
368
+ }
369
+ catch (error) {
370
+ if (error instanceof ExportError && error.status === 404)
371
+ throw new ExportError(`This server does not export ${what} yet (it answered 404): it must be upgraded first. Trades (--token, --wallet) export as before.`, "unsupported_server", 404);
372
+ throw error;
373
+ }
374
+ }
375
+ function save(connection, out, [dir, name], answer, content, call) {
376
+ const ok = join(out, dir, name);
377
+ const failed = join(out, "errors", dir, name);
378
+ const [file, other] = "ok" in answer ? [ok, failed] : [failed, ok];
379
+ mkdirSync(dirname(file), { recursive: true });
380
+ writeAtomic(file, JSON.stringify(content));
381
+ rmSync(other, { force: true });
382
+ if (!("ok" in answer))
383
+ connection.onWarning?.(`${call} answered ${answer.code}: saved as its answer; the same call in a local run throws this error, as a hosted call does now`);
384
+ }
310
385
  export async function exportTotals(connection, out, mint, quote = null) {
311
386
  const http = new Http(connection);
312
- const body = await http.get(`/v1/tokens/${encodeURIComponent(mint)}/totals`, quote ? { quote } : {});
313
- mkdirSync(join(out, "totals"), { recursive: true });
314
- writeAtomic(join(out, "totals", totalsName(mint, quote)), JSON.stringify(body));
315
- return body;
387
+ const key = encodeURIComponent(mint);
388
+ const body = await getNew(http, `/v1/functions/totals/${key}`, quote ? { quote } : {}, "totals (--totals)");
389
+ const info = await getNew(http, `/v1/functions/info/${key}`, {}, "totals (--totals)");
390
+ const content = (a) => ("ok" in a ? a.ok : a.error);
391
+ const totals = content(body);
392
+ save(connection, out, ["totals", totalsName(mint, quote)], body, totals, "tokenTotals");
393
+ save(connection, out, ["info", infoName(mint)], info, content(info), "tokenInfo");
394
+ return totals;
316
395
  }
317
396
  export async function exportVolumeSearch(connection, out, params) {
318
397
  const http = new Http(connection);
@@ -322,8 +401,13 @@ export async function exportVolumeSearch(connection, out, params) {
322
401
  const flat = Object.entries(p)
323
402
  .filter(([, v]) => v !== undefined && v !== null)
324
403
  .map(([k, v]) => [k, String(v)]);
325
- const page = await http.get("/v1/volume/search", Object.fromEntries(flat));
326
- writeAtomic(join(out, "volume", `${volumeName(flat)}.json`), JSON.stringify({ params: flat, page }));
404
+ const answer = await getNew(http, "/v1/functions/volume/search", Object.fromEntries(flat), "volume searches (--volume-search)");
405
+ save(connection, out, ["volume", `${volumeName(flat)}.json`], answer, "ok" in answer
406
+ ? { params: flat, page: answer.ok }
407
+ : { params: flat, ...answer.error }, `volumeSearch (page ${pages})`);
408
+ if (!("ok" in answer))
409
+ return pages;
410
+ const page = answer.ok;
327
411
  const next = page?.nextCursor;
328
412
  if (next === null || next === undefined)
329
413
  return pages;
@@ -1,4 +1,4 @@
1
- import type { HourBatch } from "./sdk.js";
1
+ import { type HourBatch } from "./sdk.js";
2
2
  export declare const HOUR_MAGIC = "ZHB1";
3
3
  export type ColumnType = "u8" | "u16" | "u32" | "i32" | "u64" | "f64";
4
4
  export interface HourHeader {
@@ -12,6 +12,8 @@ export interface HourHeader {
12
12
  /** Exports: timestamp of each operation counted in `skipped` (`null`: unknown). */
13
13
  readonly skippedTimes?: readonly (number | null)[];
14
14
  readonly market: boolean;
15
+ /** Market flags with one bit per virtual side; absent from older files. */
16
+ readonly virtualSides?: boolean;
15
17
  readonly order: boolean;
16
18
  /** Absent from files written before transaction ids. */
17
19
  readonly tx?: boolean;
package/dist/hourfile.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { constants, zstdCompressSync, zstdDecompressSync } from "node:zlib";
2
+ import { MarketFlag } from "./sdk.js";
2
3
  export const HOUR_MAGIC = "ZHB1";
3
4
  export const nodeRealm = {
4
5
  Uint8Array,
@@ -237,6 +238,12 @@ export function batchOf(raw, realm = nodeRealm) {
237
238
  const m = new realm.Object();
238
239
  for (const [name, type] of MARKET_COLUMNS)
239
240
  m[name] = column(`market.${name}`, type);
241
+ const flags = m.flags;
242
+ if (!header.virtualSides)
243
+ for (let i = 0; i < flags.length; i++)
244
+ if ((flags[i] & MarketFlag.virtualReserves) !== 0)
245
+ flags[i] |=
246
+ MarketFlag.virtualTokenReserve | MarketFlag.virtualQuoteReserve;
240
247
  batch.market = m;
241
248
  }
242
249
  if (header.order) {
package/dist/limits.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Limits of funrun v2, mirrored from the server defaults. The server is the
2
+ * Limits of hosted functions, mirrored from the server defaults. The server is the
3
3
  * authority; these constants let tools and programs plan around them.
4
4
  *
5
5
  * No dependency: safe to import from guest code.
@@ -69,7 +69,7 @@ export declare class ReadFailed extends Error {
69
69
  export declare const sha256: (bytes: Uint8Array) => string;
70
70
  export declare function windowComplete(c: Coverage, since: number, until: number): boolean;
71
71
  export declare function hourComplete(c: Coverage, hour: number, since: number, until: number): boolean;
72
- /** `funrun_sandbox::natives::batch_bytes`: what a batch costs against `readBytes`. */
72
+ /** What a batch costs against `readBytes`, as the service counts it. */
73
73
  export declare function batchBytes(h: HourHeader): number;
74
74
  export interface HourRead {
75
75
  readonly batch: HourBatch;
@@ -93,14 +93,23 @@ export declare class LocalData {
93
93
  find(s: ReadSpec, since: number, until: number): ReadMeta | undefined;
94
94
  /**
95
95
  * The next batch of a read after `afterHour` (`null`: none left), as
96
- * `ColumnsReader::read_hour` answers it. Throws {@link ReadFailed}.
96
+ * the service's reader answers it. Throws {@link ReadFailed}.
97
97
  */
98
98
  readHour(s: ReadSpec, since: number, until: number, allowPartial: boolean, afterHour: number | null, realm?: Realm): HourRead | null;
99
- /** `/v1/tokens/{mint}/totals` saved by the export, or `undefined`. */
99
+ /** Totals of a mint saved by the export, or `undefined`. */
100
100
  totals(mint: string, quote: string | null): Json | undefined;
101
- /** A `/v1/volume/search` page saved by the export for exactly these parameters. */
101
+ /** Info of a mint saved by the export (`--totals`), or `undefined`. */
102
+ info(mint: string): Json | undefined;
103
+ /** The error `tokenTotals` answered at export, or `undefined`. */
104
+ totalsFailure(mint: string, quote: string | null): ReadFailure | undefined;
105
+ /** The error `tokenInfo` answered at export, or `undefined`. */
106
+ infoFailure(mint: string): ReadFailure | undefined;
107
+ /** The error a volume search page answered at export, or `undefined`. */
108
+ volumeFailure(params: readonly (readonly [string, string])[]): ReadFailure | undefined;
109
+ /** A volume search page saved by the export for exactly these parameters. */
102
110
  volumePage(params: readonly (readonly [string, string])[]): Json | undefined;
103
111
  }
104
112
  export declare const totalsName: (mint: string, quote: string | null) => string;
113
+ export declare const infoName: (mint: string) => string;
105
114
  /** Parameters as the sandbox passes them (`[key, string value]`), sorted. */
106
115
  export declare function volumeName(params: readonly (readonly [string, string])[]): string;
@@ -315,7 +315,8 @@ export class LocalData {
315
315
  batch: batchOf(raw, realm),
316
316
  bytes: batchBytes(raw.header),
317
317
  recut: recuts,
318
- stale: stale(raw.header.source, hour),
318
+ stale: stale(raw.header.source, hour) ||
319
+ (raw.header.market && !raw.header.virtualSides),
319
320
  };
320
321
  }
321
322
  return null;
@@ -330,15 +331,42 @@ export class LocalData {
330
331
  throw corrupt(file, e, again);
331
332
  }
332
333
  }
334
+ #failure(file, again) {
335
+ const saved = this.#json(join(this.dir, "errors", file), again);
336
+ if (saved === undefined)
337
+ return undefined;
338
+ const { code, message } = saved?.error ?? {};
339
+ if (typeof code !== "string" || typeof message !== "string")
340
+ throw corrupt(join(this.dir, "errors", file), new Error("not an error answer"), again);
341
+ const retry = saved.retryAfterMs;
342
+ return {
343
+ code,
344
+ message,
345
+ ...(typeof retry === "number" ? { retryAfterMs: retry } : {}),
346
+ };
347
+ }
333
348
  totals(mint, quote) {
334
349
  return this.#json(join(this.dir, "totals", totalsName(mint, quote)), `zyrk export --totals ${mint}${quote ? ` --quote ${quote}` : ""}`);
335
350
  }
351
+ info(mint) {
352
+ return this.#json(join(this.dir, "info", infoName(mint)), `zyrk export --totals ${mint}`);
353
+ }
354
+ totalsFailure(mint, quote) {
355
+ return this.#failure(join("totals", totalsName(mint, quote)), `zyrk export --totals ${mint}${quote ? ` --quote ${quote}` : ""}`);
356
+ }
357
+ infoFailure(mint) {
358
+ return this.#failure(join("info", infoName(mint)), `zyrk export --totals ${mint}`);
359
+ }
360
+ volumeFailure(params) {
361
+ return this.#failure(join("volume", `${volumeName(params)}.json`), "zyrk export --volume-search '<params>'");
362
+ }
336
363
  volumePage(params) {
337
364
  const saved = this.#json(join(this.dir, "volume", `${volumeName(params)}.json`), "zyrk export --volume-search '<params>'");
338
365
  return saved?.page;
339
366
  }
340
367
  }
341
368
  export const totalsName = (mint, quote) => quote ? `${mint}~${quote}.json` : `${mint}.json`;
369
+ export const infoName = (mint) => `${mint}.json`;
342
370
  export function volumeName(params) {
343
371
  const canonical = JSON.stringify([...params].sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0)));
344
372
  return createHash("sha256").update(canonical).digest("hex").slice(0, 32);
@@ -1,15 +1,15 @@
1
1
  import { type LocalData, type ReadMeta } from "./local-data.js";
2
2
  import { type HourBatch, type Subscription } from "./sdk.js";
3
- /** `windows::LAG_MS`: the first 30 s after coverage starts are not trusted. */
3
+ /** The first 30 s after coverage starts are not trusted (as the service). */
4
4
  export declare const LAG_MS = 30000;
5
5
  /** Events per delivery, as `delivery_batch` of the service. */
6
6
  export declare const DELIVERY_BATCH = 32;
7
- /** Largest `batchSize` of a replay (`replay::MAX_BATCH` of the service). */
7
+ /** Largest `batchSize` of a replay of the service. */
8
8
  export declare const REPLAY_MAX_BATCH = 1000;
9
- /** History items per source page of the service (`ReplayLimits::page`). */
9
+ /** History items per source page of the service's replays. */
10
10
  export declare const SOURCE_PAGE = 500;
11
11
  /**
12
- * Fields of the live operation JSON (`query::operation`) that exported
12
+ * Fields of the live operation JSON that exported
13
13
  * columns do not hold: absent from replayed operations.
14
14
  */
15
15
  export declare const MISSING_OPERATION_FIELDS: readonly ["prices (provider strings: read batch.price… instead)", "usdValue", "finality", "provenance", "market (the provider's raw object)"];
@@ -26,7 +26,7 @@ export interface ReplayProgress {
26
26
  gaps: number;
27
27
  orderFallbacks: number;
28
28
  }
29
- /** Decimal string of a raw amount (`query::dec_text`). */
29
+ /** Decimal string of a raw amount, as the live JSON writes it. */
30
30
  export declare function formatRaw(raw: bigint, decimals: number): string;
31
31
  /** Row `i` as the live publication shapes it (without {@link MISSING_OPERATION_FIELDS}). */
32
32
  export declare function operationOf(b: HourBatch, i: number): Record<string, unknown>;
@@ -82,7 +82,8 @@ function poolEventOf(b, i, kind) {
82
82
  const m = b.market;
83
83
  const flags = m.flags[i];
84
84
  const reserves = (flags & MarketFlag.reserves) !== 0;
85
- const virtual = (flags & MarketFlag.virtualReserves) !== 0;
85
+ const vToken = (flags & MarketFlag.virtualTokenReserve) !== 0;
86
+ const vQuote = (flags & MarketFlag.virtualQuoteReserve) !== 0;
86
87
  const op = operationOf(b, i);
87
88
  return {
88
89
  ...op,
@@ -93,10 +94,10 @@ function poolEventOf(b, i, kind) {
93
94
  market: {
94
95
  tokenReserveRaw: reserves ? m.tokenReserve[i].toString() : null,
95
96
  quoteReserveRaw: reserves ? m.quoteReserve[i].toString() : null,
96
- virtualTokenReserveRaw: virtual
97
+ virtualTokenReserveRaw: vToken
97
98
  ? m.virtualTokenReserve[i].toString()
98
99
  : null,
99
- virtualQuoteReserveRaw: virtual
100
+ virtualQuoteReserveRaw: vQuote
100
101
  ? m.virtualQuoteReserve[i].toString()
101
102
  : null,
102
103
  signer: addressAt(b, m.signer[i]),
@@ -9,9 +9,9 @@ export declare const localStorageLimits: {
9
9
  readonly maxUploads: 4;
10
10
  /** Bytes read plus written by one execution (`io_limit`). */
11
11
  readonly ioBytes: number;
12
- /** Quota cost floor of one object (`StorageOptions::min_object_bytes`). */
12
+ /** Quota cost floor of one object. */
13
13
  readonly minObjectBytes: 4096;
14
- /** Objects per area (`AreaOptions::max_objects`). */
14
+ /** Objects per area. */
15
15
  readonly maxObjects: 100000;
16
16
  };
17
17
  export interface AreaConfig {
package/dist/local.d.ts CHANGED
@@ -199,9 +199,9 @@ export interface KeepStorage {
199
199
  readonly from?: "short" | "long" | "durable";
200
200
  readonly keys?: readonly string[];
201
201
  }
202
- /** The service's `ReplayLimits::max_duration_ms` (30 min). */
202
+ /** Longest replay of the service (30 min). */
203
203
  export declare const REPLAY_MAX_DURATION_MS: number;
204
- /** The service's `FeedLimits::replay_bytes` (16 MiB). */
204
+ /** Unread feed of one replay on the service (16 MiB). */
205
205
  export declare const REPLAY_FEED_BYTES: number;
206
206
  /** What {@link LocalRunner.replay} answers (the service's replay record, locally). */
207
207
  export interface LocalReplay {
package/dist/local.js CHANGED
@@ -6,10 +6,21 @@ import { getHeapStatistics } from "node:v8";
6
6
  import vm from "node:vm";
7
7
  import { buildProgram } from "./build.js";
8
8
  import { DELIVERY_BATCH, REPLAY_MAX_BATCH, MISSING_OPERATION_FIELDS, intervalEvents, replayEvents, } from "./local-replay.js";
9
- import { LocalData, ReadFailed, spec as readSpec } from "./local-data.js";
9
+ import { LocalData, ReadFailed, spec as readSpec, } from "./local-data.js";
10
10
  import { LocalStorage, StorageOpError, } from "./local-storage.js";
11
11
  import { accountLimits, execLimits, subscriptionLimits } from "./limits.js";
12
12
  const here = dirname(fileURLToPath(import.meta.url));
13
+ let batchReadsSaid = false;
14
+ function batchReadsFromEnv() {
15
+ const raw = process.env.FUNRUN_BATCH_READS;
16
+ const v = (raw ?? "").trim();
17
+ const on = v === "on" || v === "1" || v === "true";
18
+ if (raw !== undefined && !batchReadsSaid) {
19
+ batchReadsSaid = true;
20
+ process.stderr.write(`zyrk local runner: FUNRUN_BATCH_READS=${JSON.stringify(raw)}: batched history reads ${on ? "on" : "off"} (hosted functions batch only when the service has the same setting; off there by default)\n`);
21
+ }
22
+ return on;
23
+ }
13
24
  let bootstrapText;
14
25
  export function sandboxBootstrap() {
15
26
  return (bootstrapText ??= readFileSync(join(here, "sandbox-bootstrap.js"), "utf8"));
@@ -60,6 +71,13 @@ const WEBHOOK_PAYLOAD_BYTES = 16 * 1024;
60
71
  const WEBHOOK_OVERHEAD_BYTES = 256;
61
72
  const WEBHOOK_QUEUE_LIMIT = 1000;
62
73
  const MAX_MESSAGE_BYTES = 2048;
74
+ const openWhenExported = (v) => {
75
+ const c = v?.coverage;
76
+ return (typeof c === "object" &&
77
+ c !== null &&
78
+ (c.unsealed === true ||
79
+ (c.liveUntilMs !== null && c.liveUntilMs !== undefined)));
80
+ };
63
81
  const jsonBytes = (v) => Buffer.byteLength(JSON.stringify(v) ?? "null");
64
82
  const truncate = (m) => Buffer.byteLength(m) <= MAX_MESSAGE_BYTES
65
83
  ? m
@@ -459,8 +477,31 @@ export class LocalRunner {
459
477
  throw sdkError(program("local_data_missing", "No data directory: export history with zyrk export, then pass --data (or { data })."));
460
478
  return this.data;
461
479
  };
480
+ const saved = (answer, failure, missing) => {
481
+ let value;
482
+ let failed;
483
+ try {
484
+ value = answer();
485
+ failed = value === undefined ? failure() : undefined;
486
+ }
487
+ catch (error) {
488
+ if (error instanceof ReadFailed)
489
+ throw sdkError(fromRead(error.failure));
490
+ throw error;
491
+ }
492
+ if (failed)
493
+ throw sdkError(fromRead(failed));
494
+ if (value === undefined)
495
+ throw sdkError(program("local_data_missing", missing));
496
+ if (openWhenExported(value))
497
+ this.#warn("Totals or volume search pages were exported while an hour was still open: the service may now answer otherwise. Run zyrk export again (--totals, --volume-search) to download them again.");
498
+ charge(jsonBytes(value));
499
+ return guestJson(value);
500
+ };
462
501
  return {
463
502
  maxDepth: this.limits.account.maxDepth,
503
+ batchReads: batchReadsFromEnv(),
504
+ hint: () => undefined,
464
505
  fail: (code, message) => sdkError(new HostError(kindOfSdkCode(String(code)), String(code), String(message))),
465
506
  op: (text, awaited) => this.#op(e, text, !!awaited, realm, mode, sdkError, guestJson, sample),
466
507
  read: (kind, key, since, until, side, allowPartial, withSignatures, after, withMarket, withEvents, withOrder, withTx) => {
@@ -505,7 +546,7 @@ export class LocalRunner {
505
546
  if (hour === null)
506
547
  return null;
507
548
  if (hour.stale)
508
- this.#warn("Some hours were open or not sealed when they were exported: the service may now answer otherwise. Run zyrk export again to download them again.");
549
+ this.#warn("Some hours were open or not sealed when they were exported, or were exported by an older version: the service may now answer otherwise. Run zyrk export again to download them again.");
509
550
  if (hour.recut)
510
551
  this.#warn("A read used a narrower window than its export: its edge hours were cut locally. Values are the service's; the address indexes and market.flags (chainBreak, chainUnchecked) of a cut hour may differ. Export the exact window for byte-identical batches.");
511
552
  charge(hour.bytes);
@@ -517,47 +558,20 @@ export class LocalRunner {
517
558
  if (typeof mint !== "string" ||
518
559
  (quote !== null && typeof quote !== "string"))
519
560
  throw sdkError(program("invalid_argument", "Expected a mint address"));
520
- const value = data().totals(mint, quote);
521
- if (value === undefined)
522
- throw sdkError(program("local_data_missing", `No exported totals for ${mint}: zyrk export --totals ${mint}${quote ? ` --quote ${quote}` : ""}`));
523
- charge(jsonBytes(value));
524
- return guestJson(value);
561
+ return saved(() => data().totals(mint, quote), () => data().totalsFailure(mint, quote), `No exported totals for ${mint}: zyrk export --totals ${mint}${quote ? ` --quote ${quote}` : ""}`);
525
562
  },
526
563
  info: (mint) => {
527
564
  readAllowed();
528
565
  e.usage.readCalls++;
529
566
  if (typeof mint !== "string")
530
567
  throw sdkError(program("invalid_argument", "Expected a mint address"));
531
- const t = data().totals(mint, null);
532
- if (t === undefined)
533
- throw sdkError(program("local_data_missing", `No exported totals for ${mint}: zyrk export --totals ${mint}`));
534
- const creation = (t.creation ?? null);
535
- const metadata = (t.metadata ?? null);
536
- const created = creation !== null &&
537
- typeof creation === "object" &&
538
- !Array.isArray(creation) &&
539
- typeof creation.timestamp === "number"
540
- ? creation.timestamp
541
- : (t.createdMs ?? null);
542
- const value = {
543
- mint,
544
- known: creation !== null || metadata !== null || created !== null,
545
- createdMs: created,
546
- creation,
547
- metadata,
548
- };
549
- charge(jsonBytes(value));
550
- return guestJson(value);
568
+ return saved(() => data().info(mint), () => data().infoFailure(mint), `No exported info for ${mint} (exports by older versions saved only its totals): zyrk export --totals ${mint}`);
551
569
  },
552
570
  volume: (text) => {
553
571
  readAllowed();
554
572
  e.usage.readCalls++;
555
573
  const params = JSON.parse(String(text));
556
- const page = data().volumePage(params);
557
- if (page === undefined)
558
- throw sdkError(program("local_data_missing", `No exported volume page for these parameters: zyrk export --volume-search '${JSON.stringify(Object.fromEntries(params.filter(([k]) => k !== "cursor")))}'`));
559
- charge(jsonBytes(page));
560
- return guestJson(page);
574
+ return saved(() => data().volumePage(params), () => data().volumeFailure(params), `No exported volume page for these parameters: zyrk export --volume-search '${JSON.stringify(Object.fromEntries(params.filter(([k]) => k !== "cursor")))}'`);
561
575
  },
562
576
  encode: (text) => {
563
577
  const bytes = Buffer.from(String(text), "utf8");