@johannc-dev/zyrk 0.2.0 → 0.2.2

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/client.js CHANGED
@@ -1,5 +1,5 @@
1
- import { Cause, Config, Context, Effect, Exit, Layer, Option, Redacted, Schema, } from "effect";
2
- import { decode, Job, Json, ProgramSummary, Started, toJobResult, } from "./contracts.js";
1
+ import { Cause, Config, Context, Effect, Exit, Layer, Option, Redacted, Result, Schema, } from "effect";
2
+ import { decode, Job, Json, ProgramDeleted, 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";
5
5
  const ServerError = Schema.Struct({
@@ -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,
@@ -137,6 +630,7 @@ export const make = (config) => {
137
630
  programs: () => request("GET", "programs").pipe(Effect.flatMap(decode(Schema.Array(ProgramSummary), "programs"))),
138
631
  inspect: (program) => request("GET", `programs/${segment(program)}`),
139
632
  pause: (program, paused = true) => request("POST", `programs/${segment(program)}/pause`, { paused }),
633
+ delete: (program) => request("DELETE", `programs/${segment(program)}`).pipe(Effect.flatMap(decode(ProgramDeleted, "delete response"))),
140
634
  executions: (program) => request("GET", `programs/${segment(program)}/executions`),
141
635
  start,
142
636
  execution,
@@ -202,6 +696,9 @@ export class FunctionsClient {
202
696
  pause(program, paused = true, signal) {
203
697
  return runPromise(this.client.pause(program, paused), signal);
204
698
  }
699
+ delete(program, signal) {
700
+ return runPromise(this.client.delete(program), signal);
701
+ }
205
702
  executions(program, signal) {
206
703
  return runPromise(this.client.executions(program), signal);
207
704
  }
@@ -277,6 +277,22 @@ export declare const ProgramSummary: Schema.Struct<{
277
277
  readonly paused: Schema.Boolean;
278
278
  }>;
279
279
  export type ProgramSummary = typeof ProgramSummary.Type;
280
+ /** What `DELETE programs/{program}` removed (counts). */
281
+ export declare const ProgramDeleted: Schema.Struct<{
282
+ readonly deleted: Schema.Boolean;
283
+ readonly program: Schema.String;
284
+ readonly removed: Schema.Struct<{
285
+ readonly versions: Schema.Number;
286
+ readonly stateKeys: Schema.Number;
287
+ readonly subscriptions: Schema.Number;
288
+ readonly executions: Schema.Number;
289
+ readonly cancelledExecutions: Schema.Number;
290
+ readonly replays: Schema.Number;
291
+ readonly cancelledReplays: Schema.Number;
292
+ readonly streamEvents: Schema.Number;
293
+ }>;
294
+ }>;
295
+ export type ProgramDeleted = typeof ProgramDeleted.Type;
280
296
  /** A schema that decodes without Effect services, so decoding is synchronous. */
281
297
  export type Decodable = Schema.Top & {
282
298
  readonly DecodingServices: never;
package/dist/contracts.js CHANGED
@@ -111,6 +111,20 @@ export const ProgramSummary = Schema.Struct({
111
111
  stateVersion: Schema.Number,
112
112
  paused: Schema.Boolean,
113
113
  });
114
+ export const ProgramDeleted = Schema.Struct({
115
+ deleted: Schema.Boolean,
116
+ program: Schema.String,
117
+ removed: Schema.Struct({
118
+ versions: Schema.Number,
119
+ stateKeys: Schema.Number,
120
+ subscriptions: Schema.Number,
121
+ executions: Schema.Number,
122
+ cancelledExecutions: Schema.Number,
123
+ replays: Schema.Number,
124
+ cancelledReplays: Schema.Number,
125
+ streamEvents: Schema.Number,
126
+ }),
127
+ });
114
128
  export const NonNegativeInt = Schema.Number.check(Schema.isInt(), Schema.isGreaterThanOrEqualTo(0));
115
129
  export const decodeWith = (schema, options, onError) => {
116
130
  const parse = Schema.decodeUnknownResult(schema);
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,