@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/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @johannc-dev/zyrk 0.2
2
2
 
3
- SDK et CLI des fonctions hébergées Zyrk (runtime funrun v2, V8). Node.js 24
3
+ SDK et CLI des fonctions hébergées Zyrk (V8). Node.js 24
4
4
  minimum. Ce package contient les types du programme, quelques aides de
5
5
  lecture, le compilateur `zyrk build`, le client HTTP et le flux d'événements.
6
6
  Le moteur, le stockage et le sandbox restent sur le serveur.
package/dist/cli.d.ts CHANGED
@@ -1,4 +1,4 @@
1
1
  #!/usr/bin/env node
2
- export declare const VERSION = "0.2.0";
2
+ export declare const VERSION = "0.2.1";
3
3
  /** `2026-11-07`, `2026-11-07T13:00:00Z` or milliseconds since the epoch. */
4
4
  export declare function timeOf(text: string): number;
package/dist/cli.js CHANGED
@@ -8,7 +8,7 @@ import { decode, Json } from "./contracts.js";
8
8
  import { exportHistory, exportTotals, exportVolumeSearch, } from "./export.js";
9
9
  import { LocalRunner } from "./local.js";
10
10
  import { DecodeError, messageOf, } from "./errors.js";
11
- export const VERSION = "0.2.0";
11
+ export const VERSION = "0.2.1";
12
12
  const print = (value) => Console.log(JSON.stringify(value, null, 2));
13
13
  const entry = Argument.String("entry.ts").pipe(Argument.withDescription("TypeScript or JavaScript entry point of the program"));
14
14
  const program = Argument.String("program").pipe(Argument.withDescription("Program name"));
@@ -65,8 +65,8 @@ const exportCommand = Command.make("export", {
65
65
  "with-events": flag("with-events", "Export pool events too (implies --with-market)"),
66
66
  "with-order": flag("with-order", "Export batch.order (on-chain order)"),
67
67
  "with-tx": flag("with-tx", "Export batch.tx (transaction of each trade, place in it)"),
68
- refresh: flag("refresh", "Download every hour again (a finished export only re-downloads the hours that were open or not sealed)"),
69
- totals: optionalString("totals", "Also save /v1/tokens/{mint}/totals of this mint"),
68
+ refresh: flag("refresh", "Download every hour again (a finished export only re-downloads the hours that were open or not sealed, or exported by an older version)"),
69
+ totals: optionalString("totals", "Also save the totals of this mint (ctx.data.tokenTotals, tokenInfo)"),
70
70
  quote: optionalString("quote", "Quote mint of --totals"),
71
71
  "volume-search": optionalString("volume-search", "Also save every page of ctx.data.volumeSearch(<json params>)"),
72
72
  rpm: Flag.Int("rpm").pipe(Flag.withDescription("At most this many requests per minute"), Flag.optional),
@@ -139,7 +139,7 @@ const devCommand = Command.make("dev", {
139
139
  replay: optionalString("replay", "Replay this subscription over --since/--until: <function> is ignored, json is unused"),
140
140
  since: optionalString("since", "Replay start"),
141
141
  until: optionalString("until", "Replay end"),
142
- "events-per-delivery": Flag.Int("events-per-delivery").pipe(Flag.withDescription("Events per replayed delivery (1 to 32, default 1)"), Flag.optional),
142
+ "events-per-delivery": Flag.Int("events-per-delivery").pipe(Flag.withDescription("Events per replayed delivery (1 to 32, default 32; interval triggers: 1)"), Flag.optional),
143
143
  quiet: flag("quiet", "Do not print logs and emits as they happen"),
144
144
  "no-typecheck": noTypecheck,
145
145
  }, (input) => local("dev", async () => {
package/dist/client.d.ts CHANGED
@@ -18,7 +18,11 @@ export interface StartInput {
18
18
  readonly requestId?: string;
19
19
  }
20
20
  export interface WaitOptions {
21
- /** Delay between two status checks, in milliseconds (default 1000). */
21
+ /**
22
+ * Delay between two status checks, in milliseconds. Without it, each check
23
+ * is held by the service until the execution ends (at most 10 s), and a
24
+ * service that cannot hold is checked once a second.
25
+ */
22
26
  readonly pollMs?: number;
23
27
  /** Give up after this many milliseconds (default: never). The execution keeps running. */
24
28
  readonly timeoutMs?: number;
@@ -154,7 +158,7 @@ export declare class FunctionsClient {
154
158
  */
155
159
  replayEmits(id: string, afterSeq?: number, signal?: AbortSignal): Promise<ReplayFeedItem[]>;
156
160
  }
157
- /** Lakebed's `StorageObjectHead` as the HTTP headers describe it. */
161
+ /** The head of a stored object as the HTTP headers describe it. */
158
162
  export interface HttpStorageHead {
159
163
  readonly key: string;
160
164
  readonly size: number;
package/dist/client.js CHANGED
@@ -1,4 +1,4 @@
1
- import { Cause, Config, Context, Effect, Exit, Layer, Option, Redacted, Schema, } from "effect";
1
+ import { Cause, Config, Context, Effect, Exit, Layer, Option, Redacted, Result, Schema, } from "effect";
2
2
  import { decode, Job, Json, ProgramSummary, Started, toJobResult, } from "./contracts.js";
3
3
  import { ApiError, ConfigError, DecodeError, ExecutionEnded, ExecutionFailed, Failure, messageOf, NetworkError, WaitTimeout, } from "./errors.js";
4
4
  export * from "./errors.js";
@@ -38,9 +38,50 @@ const parseJson = (text) => {
38
38
  };
39
39
  const segment = encodeURIComponent;
40
40
  const isFinal = (r) => r.status !== "queued" && r.status !== "running";
41
+ const MAX_HOLD_MS = 10_000;
42
+ const POLL_MS = 1_000;
43
+ const FOLLOW_SLACK_MS = 25;
44
+ const MAX_WAIT_IDS = 100;
45
+ const WAITED_HEADER = "x-functions-waited";
46
+ const rhythmFrom = (start) => ({
47
+ anchor: start,
48
+ last: start,
49
+ rtt: undefined,
50
+ });
51
+ const periodOf = (rhythm) => POLL_MS + (rhythm.rtt ?? 0);
52
+ const tickFrom = (rhythm, t) => {
53
+ const period = periodOf(rhythm);
54
+ const from = Math.max(t, rhythm.last + 1);
55
+ const k = Math.max(0, Math.ceil((from - rhythm.anchor) / period));
56
+ return rhythm.anchor + k * period;
57
+ };
58
+ const tickBefore = (rhythm, t) => t < rhythm.anchor
59
+ ? -Infinity
60
+ : rhythm.anchor +
61
+ Math.floor((t - rhythm.anchor) / periodOf(rhythm)) * periodOf(rhythm);
62
+ const answered = (rhythm, sentAt, now, tick = sentAt) => {
63
+ rhythm.rtt = now - sentAt;
64
+ rhythm.anchor = tick + rhythm.rtt + POLL_MS;
65
+ };
66
+ const planFor = (rhythm, sentAt, rtt, offset, cap) => {
67
+ if (!(cap >= POLL_MS))
68
+ return undefined;
69
+ const cappedAt = Math.min(cap, MAX_HOLD_MS);
70
+ const tick = tickBefore(rhythm, sentAt + rtt + cappedAt - offset);
71
+ const waitMs = tick + offset - sentAt - rtt;
72
+ return tick > rhythm.last && waitMs >= POLL_MS
73
+ ? { waitMs, tick }
74
+ : { waitMs: cappedAt, tick: undefined };
75
+ };
76
+ const transient = (error) => error instanceof NetworkError ||
77
+ (error instanceof ApiError && [502, 503, 504].includes(error.status));
78
+ const sleepUntil = (t) => Effect.suspend(() => {
79
+ const ms = t - Date.now();
80
+ return ms > 0 ? Effect.sleep(ms) : Effect.void;
81
+ });
41
82
  export const make = (config) => {
42
83
  const base = `${config.url.replace(/\/$/, "")}/v1/functions/`;
43
- const request = Effect.fnUntraced(function* (method, path, body) {
84
+ const send = Effect.fnUntraced(function* (method, path, body) {
44
85
  const url = base + path;
45
86
  const response = yield* Effect.tryPromise({
46
87
  try: async (signal) => {
@@ -57,6 +98,7 @@ export const make = (config) => {
57
98
  return {
58
99
  status: response.status,
59
100
  ok: response.ok,
101
+ waited: response.headers?.get?.(WAITED_HEADER) === "1",
60
102
  text: await response.text(),
61
103
  };
62
104
  },
@@ -74,15 +116,53 @@ export const make = (config) => {
74
116
  what: `${method} ${path}`,
75
117
  message: "Response body is not JSON",
76
118
  });
77
- return yield* decode(Json, `${method} ${path}`)(parsed.value);
119
+ const json = yield* decode(Json, `${method} ${path}`)(parsed.value);
120
+ return { json, waited: response.waited };
121
+ });
122
+ const request = (method, path, body) => send(method, path, body).pipe(Effect.map((r) => r.json));
123
+ const executionAt = (id, query = "") => send("GET", `executions/${segment(id)}${query}`).pipe(Effect.flatMap((reply) => decode(Job, "execution")(reply.json).pipe(Effect.map((job) => ({ job, waited: reply.waited })))));
124
+ const execution = (id) => executionAt(id).pipe(Effect.map((r) => r.job));
125
+ const followWith = (id, cursor, query) => Effect.suspend(() => executionAt(cursor.current, query()).pipe(Effect.flatMap(({ job, waited }) => {
126
+ if (job.status !== "continued" || cursor.hops >= 10_000)
127
+ return Effect.succeed({
128
+ result: { ...toJobResult(job), id },
129
+ waited,
130
+ });
131
+ cursor.current = job.nextExecutionId;
132
+ cursor.hops++;
133
+ return followWith(id, cursor, query);
134
+ })));
135
+ const follow = (id, cursor) => followWith(id, cursor, () => "").pipe(Effect.map((r) => r.result));
136
+ const cursorAt = (id) => ({ current: id, hops: 0 });
137
+ const result = (id) => follow(id, cursorAt(id));
138
+ const deadlineOf = (options) => options.timeoutMs === undefined ? Infinity : Date.now() + options.timeoutMs;
139
+ const waitQuery = (plan) => plan === undefined ? "" : `?waitMs=${plan.waitMs}`;
140
+ const guarded = (check, held, deadline) => held && deadline !== Infinity
141
+ ? check.pipe(Effect.timeoutOption(Math.max(0, deadline - Date.now())))
142
+ : check.pipe(Effect.map(Option.some));
143
+ const recover = (rhythm, failedAt, deadline, check, one = rhythm.rtt, offset = 0) => Effect.gen(function* () {
144
+ if (failedAt >= deadline)
145
+ return Option.none();
146
+ const safe = failedAt + POLL_MS;
147
+ const due = one === undefined
148
+ ? undefined
149
+ : tickFrom(rhythm, failedAt - one - offset);
150
+ const at = Math.min(due === undefined ? safe : Math.max(due + offset, failedAt), deadline);
151
+ const tick = due !== undefined && due < deadline ? due : undefined;
152
+ yield* sleepUntil(at);
153
+ rhythm.last = Math.max(rhythm.last, tick ?? at);
154
+ const sentAt = Date.now();
155
+ const first = yield* Effect.result(check);
156
+ if (Result.isSuccess(first))
157
+ return Option.some({ value: first.success, sentAt, tick });
158
+ if (!transient(first.failure) || sentAt >= safe || Date.now() >= deadline)
159
+ return yield* Effect.fail(first.failure);
160
+ yield* sleepUntil(Math.min(safe, deadline));
161
+ const again = Date.now();
162
+ rhythm.last = Math.max(rhythm.last, again);
163
+ const value = yield* check;
164
+ return Option.some({ value, sentAt: again, tick: undefined });
78
165
  });
79
- const execution = (id) => request("GET", `executions/${segment(id)}`).pipe(Effect.flatMap(decode(Job, "execution")));
80
- const result = (id) => {
81
- const follow = (current, hops) => execution(current).pipe(Effect.flatMap((job) => job.status === "continued" && hops < 10_000
82
- ? follow(job.nextExecutionId, hops + 1)
83
- : Effect.succeed({ ...toJobResult(job), id })));
84
- return follow(id, 0);
85
- };
86
166
  const poll = (check, options, onTimeout) => {
87
167
  const deadline = options.timeoutMs === undefined
88
168
  ? Infinity
@@ -98,7 +178,10 @@ export const make = (config) => {
98
178
  })));
99
179
  return step;
100
180
  };
101
- const wait = (id, options = {}) => poll(result(id).pipe(Effect.flatMap((r) => {
181
+ const wait = (id, options = {}) => Effect.suspend(() => options.pollMs === undefined
182
+ ? waitHeld(id, cursorAt(id), options)
183
+ : waitFrom(id, cursorAt(id), options));
184
+ const outcome = (id, r) => {
102
185
  switch (r.status) {
103
186
  case "succeeded":
104
187
  return Effect.succeed(Option.some(r.result));
@@ -112,17 +195,427 @@ export const make = (config) => {
112
195
  case "running":
113
196
  return Effect.succeed(Option.none());
114
197
  }
115
- })), options, () => Effect.fail(new WaitTimeout({
116
- executionId: id,
117
- timeoutMs: options.timeoutMs ?? 0,
118
- })));
119
- const waitAll = (ids, options = {}) => {
198
+ };
199
+ const timedOut = (id, options) => Effect.fail(new WaitTimeout({ executionId: id, timeoutMs: options.timeoutMs ?? 0 }));
200
+ const waitFrom = (id, cursor, options) => poll(follow(id, cursor).pipe(Effect.flatMap((r) => outcome(id, r))), options, () => timedOut(id, options));
201
+ const holdOne = (id, cursor, options) => Effect.gen(function* () {
202
+ const start = Date.now();
203
+ const deadline = deadlineOf(options);
204
+ const rhythm = rhythmFrom(start);
205
+ const plain = followWith(id, cursor, () => "");
206
+ let failures = 0;
207
+ let polling = false;
208
+ let at = start;
209
+ let tick = start;
210
+ let sentAt = start;
211
+ let plan;
212
+ const planNow = () => {
213
+ sentAt = Date.now();
214
+ const rtt = rhythm.rtt ?? 0;
215
+ plan = polling
216
+ ? undefined
217
+ : planFor(rhythm, sentAt, rtt, 0, deadline - sentAt - rtt);
218
+ };
219
+ for (;;) {
220
+ yield* sleepUntil(at);
221
+ if (tick !== undefined)
222
+ rhythm.last = Math.max(rhythm.last, tick);
223
+ planNow();
224
+ const held = plan !== undefined;
225
+ const read = Effect.suspend(() => executionAt(cursor.current, waitQuery(plan)));
226
+ const tried = yield* Effect.result(guarded(read, held, deadline));
227
+ let result;
228
+ let now;
229
+ if (Result.isFailure(tried)) {
230
+ if (!held || !transient(tried.failure))
231
+ return yield* Effect.fail(tried.failure);
232
+ if (++failures >= 2)
233
+ polling = true;
234
+ const recovered = yield* recover(rhythm, Date.now(), deadline, plain);
235
+ if (Option.isNone(recovered))
236
+ return { latest: undefined };
237
+ const { value, sentAt: s, tick: due } = recovered.value;
238
+ now = Date.now();
239
+ result = value.result;
240
+ if (due !== undefined)
241
+ answered(rhythm, s, now, due);
242
+ else if (rhythm.rtt === undefined)
243
+ rhythm.rtt = now - s;
244
+ const lag = due === undefined ? 0 : s - due;
245
+ tick = tickFrom(rhythm, now - lag);
246
+ at = Math.max(tick, now);
247
+ }
248
+ else if (Option.isNone(tried.success)) {
249
+ const { result: last } = yield* plain;
250
+ return { latest: last };
251
+ }
252
+ else {
253
+ const { job, waited } = tried.success.value;
254
+ now = Date.now();
255
+ const asked = plan?.waitMs ?? 0;
256
+ const heldMs = now - sentAt;
257
+ const full = asked > 0 && heldMs >= Math.max(POLL_MS, 0.9 * asked);
258
+ if (job.status === "continued" && cursor.hops < 10_000) {
259
+ cursor.current = job.nextExecutionId;
260
+ cursor.hops++;
261
+ if (!waited || heldMs <= (rhythm.rtt ?? 0) + FOLLOW_SLACK_MS) {
262
+ if (tick !== undefined) {
263
+ if (!waited)
264
+ rhythm.rtt = heldMs;
265
+ rhythm.anchor = now + (rhythm.rtt ?? 0) + POLL_MS;
266
+ }
267
+ at = now;
268
+ }
269
+ else {
270
+ failures = 0;
271
+ const rtt = rhythm.rtt ?? 0;
272
+ tick = tickFrom(rhythm, now - rtt);
273
+ at = tick + rtt;
274
+ rhythm.anchor = at + rtt + POLL_MS;
275
+ }
276
+ if (at >= deadline) {
277
+ at = deadline;
278
+ tick = undefined;
279
+ }
280
+ continue;
281
+ }
282
+ result = { ...toJobResult(job), id };
283
+ if (!waited) {
284
+ if (tick !== undefined)
285
+ answered(rhythm, sentAt, now);
286
+ else if (rhythm.rtt === undefined)
287
+ rhythm.rtt = heldMs;
288
+ tick = at = tickFrom(rhythm, now);
289
+ }
290
+ else if (!full) {
291
+ rhythm.anchor = now + POLL_MS;
292
+ tick = at = rhythm.anchor;
293
+ }
294
+ else {
295
+ failures = 0;
296
+ if (rhythm.rtt === undefined) {
297
+ rhythm.rtt = Math.max(0, heldMs - asked);
298
+ tick = at = tickFrom(rhythm, now);
299
+ }
300
+ else if (plan?.tick !== undefined) {
301
+ tick = plan.tick;
302
+ at = Math.max(now, plan.tick);
303
+ }
304
+ else
305
+ tick = at = tickFrom(rhythm, now);
306
+ }
307
+ }
308
+ if (isFinal(result) || now >= deadline)
309
+ return { latest: result };
310
+ if (at >= deadline) {
311
+ at = deadline;
312
+ tick = undefined;
313
+ }
314
+ }
315
+ });
316
+ const waitHeld = (id, cursor, options) => holdOne(id, cursor, options).pipe(Effect.flatMap(({ latest }) => latest === undefined
317
+ ? timedOut(id, options)
318
+ : outcome(id, latest).pipe(Effect.flatMap(Option.match({
319
+ onSome: Effect.succeed,
320
+ onNone: () => timedOut(id, options),
321
+ })))));
322
+ const waitAll = (ids, options = {}) => options.pollMs === undefined
323
+ ? waitAllHeld(ids, options)
324
+ : waitAllPolled(ids, options);
325
+ const waitAllPolled = (ids, options) => Effect.suspend(() => {
120
326
  const latest = new Map();
327
+ const entries = ids.map((id) => ({ id, cursor: cursorAt(id) }));
121
328
  const snapshot = () => ids.map((id) => latest.get(id));
122
- const check = Effect.forEach(ids.filter((id) => !latest.has(id) || !isFinal(latest.get(id))), (id) => result(id).pipe(Effect.tap((r) => Effect.sync(() => latest.set(id, r)))), { concurrency: 8 }).pipe(Effect.map(() => ids.every((id) => isFinal(latest.get(id)))
329
+ const check = Effect.forEach(entries, ({ id, cursor }) => follow(id, cursor).pipe(Effect.tap((r) => Effect.sync(() => latest.set(id, r)))), { concurrency: 8 }).pipe(Effect.map(() => ids.every((id) => isFinal(latest.get(id)))
123
330
  ? Option.some(snapshot())
124
331
  : Option.none()));
125
332
  return poll(check, options, () => Effect.sync(snapshot));
333
+ });
334
+ const waitAllHeld = (ids, options) => Effect.suspend(() => {
335
+ if (ids.length === 1) {
336
+ const id = ids[0];
337
+ const cursor = cursorAt(id);
338
+ return holdOne(id, cursor, options).pipe(Effect.flatMap(({ latest }) => latest === undefined ? follow(id, cursor) : Effect.succeed(latest)), Effect.map((r) => [r]));
339
+ }
340
+ return waitMany(ids, options);
341
+ });
342
+ const waitMany = (ids, options) => Effect.gen(function* () {
343
+ const start = Date.now();
344
+ const deadline = deadlineOf(options);
345
+ const rhythm = rhythmFrom(start);
346
+ const latest = new Map();
347
+ const entries = ids.map((id) => ({ id, cursor: cursorAt(id) }));
348
+ const snapshot = () => ids.map((id) => latest.get(id));
349
+ const allFinal = () => ids.every((id) => isFinal(latest.get(id)));
350
+ const seen = (entry, r) => {
351
+ latest.set(entry.id, r);
352
+ entry.readAt = entry.cursor.current;
353
+ };
354
+ let sent = 0;
355
+ const pollingSent = (t) => Math.max(Math.min(ids.length, 8), (ids.length * (t - start)) / periodOf(rhythm));
356
+ let rtt1;
357
+ const pool = (walks) => {
358
+ const slots = Array(Math.min(8, entries.length)).fill(0);
359
+ const starts = entries.map((_, j) => {
360
+ const i = slots.indexOf(Math.min(...slots));
361
+ const startsAt = slots[i];
362
+ slots[i] = startsAt + (1 + (walks?.[j] ?? 0)) * (rtt1 ?? 0);
363
+ return startsAt;
364
+ });
365
+ return { starts, length: Math.max(0, ...slots) };
366
+ };
367
+ const roundTime = (walks) => pool(walks).length;
368
+ const roundEnded = (sentAt, now, tick = sentAt) => {
369
+ answered(rhythm, sentAt, now, tick);
370
+ rhythm.rtt = roundTime();
371
+ };
372
+ const offsetOf = () => Math.max(0, (rhythm.rtt ?? 0) - (rtt1 ?? 0));
373
+ const round = (list, query) => Effect.suspend(() => {
374
+ sent += list.length;
375
+ return Effect.forEach(list, (entry) => Effect.suspend(() => {
376
+ const t = Date.now();
377
+ return followWith(entry.id, entry.cursor, query).pipe(Effect.tap((r) => Effect.sync(() => seen(entry, r.result))), Effect.map((r) => ({
378
+ waited: r.waited,
379
+ took: Date.now() - t,
380
+ })));
381
+ }), { concurrency: 8 });
382
+ }).pipe(Effect.map((reads) => {
383
+ for (const r of reads)
384
+ rtt1 = Math.min(rtt1 ?? Infinity, r.took);
385
+ return reads.some((r) => r.waited);
386
+ }));
387
+ const plainRound = () => round(entries, () => "");
388
+ const fresh = Effect.suspend(() => {
389
+ const stale = entries.filter((e) => e.readAt !== e.cursor.current);
390
+ return stale.length === 0
391
+ ? Effect.sync(snapshot)
392
+ : round(stale, () => "").pipe(Effect.map(snapshot));
393
+ });
394
+ const multi = (waitMs) => {
395
+ const distinct = [...new Set(entries.map((e) => e.cursor.current))];
396
+ const chunks = [];
397
+ for (let i = 0; i < distinct.length; i += MAX_WAIT_IDS)
398
+ chunks.push(distinct.slice(i, i + MAX_WAIT_IDS));
399
+ sent += chunks.length;
400
+ return Effect.forEach(chunks, (chunk) => send("POST", "executions/wait", { ids: chunk, waitMs }).pipe(Effect.flatMap((reply) => readMulti(chunk, reply))), { concurrency: 8 }).pipe(Effect.catchIf((e) => e instanceof ApiError && [400, 404, 405].includes(e.status), () => Effect.succeed(null)), Effect.flatMap((answers) => {
401
+ if (answers === null)
402
+ return Effect.succeed(null);
403
+ const records = new Map();
404
+ for (const answer of answers)
405
+ for (const [id, record] of answer.records)
406
+ records.set(id, record);
407
+ let advanced = false;
408
+ let progressed = false;
409
+ const walks = entries.map(() => 0);
410
+ for (const [j, entry] of entries.entries()) {
411
+ const { cursor } = entry;
412
+ const record = records.get(cursor.current);
413
+ if (record instanceof ApiError)
414
+ return Effect.fail(record);
415
+ if (record.status === "continued" && cursor.hops < 10_000) {
416
+ cursor.current = record.nextExecutionId;
417
+ cursor.hops++;
418
+ advanced = true;
419
+ walks[j] = 1;
420
+ }
421
+ else {
422
+ const r = { ...toJobResult(record), id: entry.id };
423
+ const before = latest.get(entry.id);
424
+ if (before !== undefined && !isFinal(before) && isFinal(r))
425
+ progressed = true;
426
+ seen(entry, r);
427
+ }
428
+ }
429
+ return Effect.succeed({
430
+ waited: answers.every((a) => a.waited),
431
+ advanced,
432
+ progressed,
433
+ walks,
434
+ });
435
+ }));
436
+ };
437
+ if (ids.length === 0)
438
+ return [];
439
+ let mode = "probe";
440
+ let failures = 0;
441
+ let at = start;
442
+ let tick = start;
443
+ let capped = false;
444
+ let current = start;
445
+ let onRound = true;
446
+ let following = false;
447
+ let roundWalks = entries.map(() => 0);
448
+ let nextWalks;
449
+ for (;;) {
450
+ yield* sleepUntil(at);
451
+ if (tick !== undefined) {
452
+ rhythm.last = current = tick;
453
+ roundWalks = nextWalks ?? entries.map(() => 0);
454
+ nextWalks = undefined;
455
+ }
456
+ onRound = tick !== undefined || (following && onRound);
457
+ following = false;
458
+ const checkAt = Date.now();
459
+ const offset = capped ? offsetOf() : 0;
460
+ let now;
461
+ if (mode !== "multi") {
462
+ const waited = yield* round(entries, mode === "probe" ? () => "?waitMs=0" : () => "");
463
+ now = Date.now();
464
+ if (tick !== undefined)
465
+ roundEnded(checkAt, now);
466
+ if (mode === "probe" && waited)
467
+ mode = "multi";
468
+ if (allFinal() || now >= deadline)
469
+ return yield* fresh;
470
+ tick = tickFrom(rhythm, now);
471
+ at = tick;
472
+ }
473
+ else {
474
+ const one = rtt1 ?? 0;
475
+ const plan = planFor(rhythm, checkAt, one, offset, deadline - checkAt - one);
476
+ const tried = yield* Effect.result(guarded(multi(plan?.waitMs ?? 0), plan !== undefined, deadline));
477
+ now = Date.now();
478
+ if (Result.isFailure(tried)) {
479
+ if (plan === undefined || !transient(tried.failure))
480
+ return yield* Effect.fail(tried.failure);
481
+ if (++failures >= 2)
482
+ mode = "rounds";
483
+ const late = offsetOf();
484
+ const recovered = yield* recover(rhythm, now, deadline, multi(0), one, late);
485
+ if (Option.isNone(recovered))
486
+ return yield* fresh;
487
+ now = Date.now();
488
+ const { value: r, sentAt, tick: due } = recovered.value;
489
+ if (r === null) {
490
+ mode = "rounds";
491
+ yield* plainRound();
492
+ now = Date.now();
493
+ }
494
+ if (due !== undefined)
495
+ rhythm.anchor = due + roundTime() + POLL_MS;
496
+ if ((r === null || !r.advanced) && allFinal())
497
+ return yield* fresh;
498
+ if (now >= deadline)
499
+ return yield* fresh;
500
+ if (r !== null && r.advanced) {
501
+ tick = undefined;
502
+ at = now;
503
+ following = due !== undefined;
504
+ }
505
+ else {
506
+ const lag = due === undefined ? 0 : sentAt - (due + late);
507
+ tick = tickFrom(rhythm, now - lag);
508
+ at = Math.max(tick, now);
509
+ }
510
+ }
511
+ else if (Option.isNone(tried.success)) {
512
+ yield* multi(0);
513
+ return yield* fresh;
514
+ }
515
+ else if (tried.success.value === null) {
516
+ mode = "rounds";
517
+ yield* plainRound();
518
+ const end = Date.now();
519
+ roundEnded(now, end);
520
+ if (allFinal() || end >= deadline)
521
+ return yield* fresh;
522
+ tick = tickFrom(rhythm, end);
523
+ at = tick;
524
+ }
525
+ else {
526
+ const r = tried.success.value;
527
+ if (!r.advanced && allFinal())
528
+ return yield* fresh;
529
+ if (now >= deadline)
530
+ return yield* fresh;
531
+ const asked = plan?.waitMs ?? 0;
532
+ const heldMs = now - checkAt;
533
+ const full = r.waited && asked > 0 && heldMs >= Math.max(POLL_MS, 0.9 * asked);
534
+ if (full || (r.waited && (r.progressed || r.advanced)))
535
+ failures = 0;
536
+ capped = !r.waited;
537
+ const after = capped ? offsetOf() : 0;
538
+ const walked = r.advanced && r.waited && heldMs > one + FOLLOW_SLACK_MS;
539
+ if (onRound && !walked)
540
+ r.walks.forEach((w, j) => (roundWalks[j] += w));
541
+ const last = current + Math.max(0, roundTime(roundWalks) - one);
542
+ const again = capped && onRound && !r.advanced && last > now;
543
+ if (capped && onRound && !r.advanced && !again)
544
+ rhythm.anchor = now + POLL_MS;
545
+ const next = again ? current : tickFrom(rhythm, now);
546
+ const nextAt = again ? last : next + after;
547
+ if (walked && entries.length > 8) {
548
+ const { starts } = pool(roundWalks);
549
+ r.walks.forEach((w, j) => {
550
+ if (w === 0)
551
+ return;
552
+ if (current + starts[j] + one >= now)
553
+ roundWalks[j] += w;
554
+ else
555
+ (nextWalks ??= entries.map(() => 0))[j] += w;
556
+ });
557
+ rhythm.anchor = current + roundTime(roundWalks) + POLL_MS;
558
+ }
559
+ if (walked && entries.length <= 8) {
560
+ const length = roundTime(r.walks);
561
+ tick = tickFrom(rhythm, now - one);
562
+ at = tick + Math.max(0, length - one);
563
+ rhythm.anchor = tick + length + POLL_MS;
564
+ nextWalks = r.walks;
565
+ }
566
+ else if (r.advanced ||
567
+ (r.waited && r.progressed) ||
568
+ (full && plan?.tick === undefined)) {
569
+ if (r.advanced && onRound && r.waited && !walked)
570
+ rhythm.anchor = now + one + POLL_MS;
571
+ const chunks = Math.ceil(entries.length / MAX_WAIT_IDS);
572
+ if (sent + chunks <= pollingSent(now)) {
573
+ tick = undefined;
574
+ at = now;
575
+ following = r.advanced;
576
+ }
577
+ else {
578
+ tick = next;
579
+ at = nextAt;
580
+ }
581
+ }
582
+ else if (full && plan?.tick !== undefined) {
583
+ tick = plan.tick;
584
+ at = Math.max(now, plan.tick + offset);
585
+ }
586
+ else {
587
+ tick = next;
588
+ at = nextAt;
589
+ }
590
+ }
591
+ }
592
+ if (at >= deadline) {
593
+ at = deadline;
594
+ tick = undefined;
595
+ }
596
+ }
597
+ });
598
+ const readMulti = (chunk, reply) => {
599
+ const body = reply.json;
600
+ const list = body?.executions;
601
+ if (!Array.isArray(list) || list.length !== chunk.length)
602
+ return Effect.fail(new DecodeError({
603
+ what: "POST executions/wait",
604
+ message: "expected one record per id",
605
+ }));
606
+ return Effect.forEach(list, (item, i) => {
607
+ const missing = typeof item === "object" &&
608
+ item !== null &&
609
+ item.error !== undefined;
610
+ if (missing) {
611
+ const error = { error: item.error };
612
+ return Effect.succeed([chunk[i], apiError(404, error)]);
613
+ }
614
+ return decode(Job, "execution")(item).pipe(Effect.map((job) => [chunk[i], job]));
615
+ }).pipe(Effect.map((pairs) => ({
616
+ records: new Map(pairs),
617
+ waited: reply.waited && body?.waited === true,
618
+ })));
126
619
  };
127
620
  const start = (program, input) => request("POST", `programs/${segment(program)}/invoke`, {
128
621
  ...input,
package/dist/errors.d.ts CHANGED
@@ -20,8 +20,16 @@ declare const NetworkError_base: Schema.Class<NetworkError, Schema.TaggedStruct<
20
20
  readonly url: Schema.String;
21
21
  readonly message: Schema.String;
22
22
  readonly cause: Schema.Unknown;
23
+ readonly reason: Schema.optional<Schema.String>;
23
24
  }>, import("effect/Cause").YieldableError>;
24
- /** The service could not be reached (DNS, TLS, connection reset, abort). */
25
+ /**
26
+ * The service could not be reached (DNS, TLS, connection reset, abort), or
27
+ * it ended a live stream. `reason` is set by the stream:
28
+ * - `functions_disabled`: Functions access was removed from the account;
29
+ * - `channel_closed`: the server closed the stream (subscribe again with
30
+ * `afterSeq`);
31
+ * - the server's reason of a refused join (e.g. `functions_unavailable`).
32
+ */
25
33
  export declare class NetworkError extends NetworkError_base {
26
34
  }
27
35
  declare const ApiError_base: Schema.Class<ApiError, Schema.TaggedStruct<"ApiError", {
package/dist/errors.js CHANGED
@@ -13,7 +13,12 @@ export const Failure = Schema.Struct({
13
13
  export const messageOf = (cause) => cause instanceof Error ? cause.message : String(cause);
14
14
  export class ConfigError extends Schema.TaggedError()("ConfigError", { message: Schema.String }) {
15
15
  }
16
- export class NetworkError extends Schema.TaggedError()("NetworkError", { url: Schema.String, message: Schema.String, cause: Schema.Unknown }) {
16
+ export class NetworkError extends Schema.TaggedError()("NetworkError", {
17
+ url: Schema.String,
18
+ message: Schema.String,
19
+ cause: Schema.Unknown,
20
+ reason: Schema.optional(Schema.String),
21
+ }) {
17
22
  }
18
23
  export class ApiError extends Schema.TaggedError()("ApiError", {
19
24
  status: Schema.Number,