@johannc-dev/zyrk 0.0.0-stage → 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/client.js ADDED
@@ -0,0 +1,909 @@
1
+ import { Cause, Config, Context, Effect, Exit, Layer, Option, Redacted, Result, Schema, } from "effect";
2
+ import { decode, Job, Json, ProgramSummary, Started, toJobResult, } from "./contracts.js";
3
+ import { ApiError, ConfigError, DecodeError, ExecutionEnded, ExecutionFailed, Failure, messageOf, NetworkError, WaitTimeout, } from "./errors.js";
4
+ export * from "./errors.js";
5
+ const ServerError = Schema.Struct({
6
+ error: Schema.Union([Schema.String, Failure]),
7
+ message: Schema.optionalKey(Schema.String),
8
+ });
9
+ const apiError = (status, body) => {
10
+ const parsed = Schema.decodeUnknownOption(ServerError)(body);
11
+ if (Option.isNone(parsed))
12
+ return new ApiError({
13
+ status,
14
+ code: `http_${status}`,
15
+ message: `HTTP ${status}`,
16
+ body,
17
+ });
18
+ const { error, message } = parsed.value;
19
+ return typeof error === "string"
20
+ ? new ApiError({ status, code: error, message: message ?? error, body })
21
+ : new ApiError({
22
+ status,
23
+ code: error.code,
24
+ message: error.message,
25
+ failure: error,
26
+ body,
27
+ });
28
+ };
29
+ const parseJson = (text) => {
30
+ if (text === "")
31
+ return Option.some(null);
32
+ try {
33
+ return Option.some(JSON.parse(text));
34
+ }
35
+ catch {
36
+ return Option.none();
37
+ }
38
+ };
39
+ const segment = encodeURIComponent;
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
+ });
82
+ export const make = (config) => {
83
+ const base = `${config.url.replace(/\/$/, "")}/v1/functions/`;
84
+ const send = Effect.fnUntraced(function* (method, path, body) {
85
+ const url = base + path;
86
+ const response = yield* Effect.tryPromise({
87
+ try: async (signal) => {
88
+ const response = await fetch(url, {
89
+ method,
90
+ headers: {
91
+ authorization: `Bearer ${config.apiKey}`,
92
+ "content-type": "application/json",
93
+ },
94
+ ...(body === undefined ? {} : { body: JSON.stringify(body) }),
95
+ redirect: "error",
96
+ signal,
97
+ });
98
+ return {
99
+ status: response.status,
100
+ ok: response.ok,
101
+ waited: response.headers?.get?.(WAITED_HEADER) === "1",
102
+ text: await response.text(),
103
+ };
104
+ },
105
+ catch: (cause) => new NetworkError({
106
+ url,
107
+ message: `${method} ${url}: ${messageOf(cause)}`,
108
+ cause,
109
+ }),
110
+ });
111
+ const parsed = parseJson(response.text);
112
+ if (!response.ok)
113
+ return yield* apiError(response.status, Option.getOrElse(parsed, () => response.text));
114
+ if (Option.isNone(parsed))
115
+ return yield* new DecodeError({
116
+ what: `${method} ${path}`,
117
+ message: "Response body is not JSON",
118
+ });
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 });
165
+ });
166
+ const poll = (check, options, onTimeout) => {
167
+ const deadline = options.timeoutMs === undefined
168
+ ? Infinity
169
+ : Date.now() + options.timeoutMs;
170
+ const step = check.pipe(Effect.flatMap(Option.match({
171
+ onSome: Effect.succeed,
172
+ onNone: () => {
173
+ const left = deadline - Date.now();
174
+ if (left <= 0)
175
+ return onTimeout();
176
+ return Effect.sleep(Math.min(options.pollMs ?? 1000, left)).pipe(Effect.flatMap(() => step));
177
+ },
178
+ })));
179
+ return step;
180
+ };
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) => {
185
+ switch (r.status) {
186
+ case "succeeded":
187
+ return Effect.succeed(Option.some(r.result));
188
+ case "failed":
189
+ return Effect.fail(new ExecutionFailed({ executionId: id, failure: r.error }));
190
+ case "cancelled":
191
+ case "expired":
192
+ case "acknowledged":
193
+ return Effect.fail(new ExecutionEnded({ executionId: id, status: r.status }));
194
+ case "queued":
195
+ case "running":
196
+ return Effect.succeed(Option.none());
197
+ }
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(() => {
326
+ const latest = new Map();
327
+ const entries = ids.map((id) => ({ id, cursor: cursorAt(id) }));
328
+ const snapshot = () => ids.map((id) => 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)))
330
+ ? Option.some(snapshot())
331
+ : Option.none()));
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
+ })));
619
+ };
620
+ const start = (program, input) => request("POST", `programs/${segment(program)}/invoke`, {
621
+ ...input,
622
+ requestId: input.requestId ?? crypto.randomUUID(),
623
+ }).pipe(Effect.flatMap(decode(Started, "start response")), Effect.map((started) => started.id));
624
+ return {
625
+ request,
626
+ deploy: (program, code, options = {}) => request("PUT", `programs/${segment(program)}`, {
627
+ code,
628
+ force: options.force ?? false,
629
+ }),
630
+ programs: () => request("GET", "programs").pipe(Effect.flatMap(decode(Schema.Array(ProgramSummary), "programs"))),
631
+ inspect: (program) => request("GET", `programs/${segment(program)}`),
632
+ pause: (program, paused = true) => request("POST", `programs/${segment(program)}/pause`, { paused }),
633
+ executions: (program) => request("GET", `programs/${segment(program)}/executions`),
634
+ start,
635
+ execution,
636
+ result,
637
+ wait,
638
+ waitAll,
639
+ run: (program, input, options) => start(program, input).pipe(Effect.flatMap((id) => wait(id, options))),
640
+ acknowledge: (id) => request("POST", `executions/${segment(id)}/ack`, {}),
641
+ cancel: (id) => request("POST", `executions/${segment(id)}/cancel`, {}),
642
+ events: (afterSeq) => request("GET", afterSeq === undefined ? "events" : `events?after_seq=${afterSeq}`),
643
+ measurements: () => request("GET", "measurements"),
644
+ webhook: () => request("GET", "webhook"),
645
+ setWebhook: (config) => request("PUT", "webhook", config),
646
+ deleteWebhook: () => request("DELETE", "webhook"),
647
+ rotateWebhookSecret: (options = {}) => request("POST", "webhook/rotate", options),
648
+ testWebhook: () => request("POST", "webhook/test", {}),
649
+ webhookDeliveries: (limit) => request("GET", limit === undefined
650
+ ? "webhook/deliveries"
651
+ : `webhook/deliveries?limit=${limit}`),
652
+ };
653
+ };
654
+ export const clientConfig = Config.all({
655
+ url: Config.String("TH_API_URL"),
656
+ apiKey: Config.Redacted("TH_API_KEY"),
657
+ }).pipe(Effect.map(({ url, apiKey }) => ({ url, apiKey: Redacted.value(apiKey) })), Effect.mapError((error) => new ConfigError({
658
+ message: `Set TH_API_URL and TH_API_KEY: ${error.message.split("\n")[0]}`,
659
+ })));
660
+ export class ZyrkClient extends Context.Service()("@johannc-dev/zyrk/ZyrkClient") {
661
+ static layer = (config) => Layer.succeed(ZyrkClient, make(config));
662
+ static layerConfig = Layer.effect(ZyrkClient, Effect.map(clientConfig, make));
663
+ }
664
+ const runPromise = async (effect, signal) => {
665
+ signal?.throwIfAborted();
666
+ const exit = await Effect.runPromiseExit(effect, { signal });
667
+ if (Exit.isSuccess(exit))
668
+ return exit.value;
669
+ if (signal?.aborted && Cause.hasInterrupts(exit.cause))
670
+ throw signal.reason;
671
+ throw Cause.squash(exit.cause);
672
+ };
673
+ export class FunctionsClient {
674
+ client;
675
+ config;
676
+ constructor(config) {
677
+ this.client = make(config);
678
+ this.config = config;
679
+ }
680
+ storage(area) {
681
+ return httpStorageArea(this.config, area);
682
+ }
683
+ request(method, path, body, signal) {
684
+ return runPromise(this.client.request(method, path, body), signal);
685
+ }
686
+ deploy(program, code, options = {}) {
687
+ return runPromise(this.client.deploy(program, code, options), options.signal);
688
+ }
689
+ programs(signal) {
690
+ return runPromise(this.client.programs(), signal);
691
+ }
692
+ inspect(program, signal) {
693
+ return runPromise(this.client.inspect(program), signal);
694
+ }
695
+ pause(program, paused = true, signal) {
696
+ return runPromise(this.client.pause(program, paused), signal);
697
+ }
698
+ executions(program, signal) {
699
+ return runPromise(this.client.executions(program), signal);
700
+ }
701
+ start(program, input, signal) {
702
+ return runPromise(this.client.start(program, input), signal);
703
+ }
704
+ execution(id, signal) {
705
+ return runPromise(this.client.execution(id), signal);
706
+ }
707
+ result(id, signal) {
708
+ return runPromise(this.client.result(id), signal);
709
+ }
710
+ wait(id, options = {}) {
711
+ return runPromise(this.client.wait(id, options), options.signal);
712
+ }
713
+ waitAll(ids, options = {}) {
714
+ return runPromise(this.client.waitAll(ids, options), options.signal);
715
+ }
716
+ run(program, input, options = {}) {
717
+ return runPromise(this.client.run(program, input, options), options.signal);
718
+ }
719
+ acknowledge(id, signal) {
720
+ return runPromise(this.client.acknowledge(id), signal);
721
+ }
722
+ cancel(id, signal) {
723
+ return runPromise(this.client.cancel(id), signal);
724
+ }
725
+ events(afterSeq, signal) {
726
+ return runPromise(this.client.events(afterSeq), signal);
727
+ }
728
+ measurements(signal) {
729
+ return runPromise(this.client.measurements(), signal);
730
+ }
731
+ webhook(signal) {
732
+ return runPromise(this.client.webhook(), signal);
733
+ }
734
+ setWebhook(config, signal) {
735
+ return runPromise(this.client.setWebhook(config), signal);
736
+ }
737
+ deleteWebhook(signal) {
738
+ return runPromise(this.client.deleteWebhook(), signal);
739
+ }
740
+ rotateWebhookSecret(options = {}, signal) {
741
+ return runPromise(this.client.rotateWebhookSecret(options), signal);
742
+ }
743
+ testWebhook(signal) {
744
+ return runPromise(this.client.testWebhook(), signal);
745
+ }
746
+ webhookDeliveries(limit, signal) {
747
+ return runPromise(this.client.webhookDeliveries(limit), signal);
748
+ }
749
+ replay(program, request, signal) {
750
+ return this.request("POST", `programs/${encodeURIComponent(program)}/replay`, request, signal);
751
+ }
752
+ replayStatus(id, signal) {
753
+ return this.request("GET", `replays/${encodeURIComponent(id)}`, undefined, signal);
754
+ }
755
+ cancelReplay(id, signal) {
756
+ return this.request("POST", `replays/${encodeURIComponent(id)}/cancel`, {}, signal);
757
+ }
758
+ replayExecutions(id, after = 0, signal) {
759
+ return this.request("GET", `replays/${encodeURIComponent(id)}/executions?after=${after}`, undefined, signal);
760
+ }
761
+ replayEmits(id, afterSeq = 0, signal) {
762
+ return this.request("GET", `replays/${encodeURIComponent(id)}/emits?after_seq=${afterSeq}`, undefined, signal);
763
+ }
764
+ }
765
+ export const storageKeyPath = (key) => {
766
+ const parts = key.split("/");
767
+ if (key === "" || parts.some((p) => p === "" || p === "." || p === ".."))
768
+ throw new ApiError({
769
+ status: 400,
770
+ code: "invalid_request",
771
+ message: "Over HTTP, a key cannot have empty, '.' or '..' parts",
772
+ body: null,
773
+ });
774
+ return parts.map(encodeURIComponent).join("/");
775
+ };
776
+ const httpStorageArea = (config, area) => {
777
+ const base = `${config.url.replace(/\/$/, "")}/v1/functions/storage/`;
778
+ const call = async (method, path, init = {}) => {
779
+ const url = base + path;
780
+ try {
781
+ return await fetch(url, {
782
+ method,
783
+ headers: { authorization: `Bearer ${config.apiKey}`, ...init.headers },
784
+ ...(init.body === undefined ? {} : { body: init.body }),
785
+ redirect: "error",
786
+ ...(init.signal ? { signal: init.signal } : {}),
787
+ });
788
+ }
789
+ catch (cause) {
790
+ throw new NetworkError({
791
+ url,
792
+ message: `${method} ${url}: ${messageOf(cause)}`,
793
+ cause,
794
+ });
795
+ }
796
+ };
797
+ const fail = async (response) => {
798
+ const text = await response.text();
799
+ throw apiError(response.status, Option.getOrElse(parseJson(text), () => text));
800
+ };
801
+ const json = async (response) => {
802
+ if (!response.ok)
803
+ return fail(response);
804
+ return (await response.json());
805
+ };
806
+ const headOf = (key, response) => {
807
+ const h = response.headers;
808
+ const metadata = {};
809
+ h.forEach((value, name) => {
810
+ if (name.startsWith("x-meta-"))
811
+ metadata[name.slice(7)] = value;
812
+ });
813
+ const range = h.get("content-range");
814
+ const total = range
815
+ ? Number(range.split("/")[1])
816
+ : Number(h.get("content-length") ?? 0);
817
+ return {
818
+ key,
819
+ size: total,
820
+ contentType: h.get("content-type") ?? "application/octet-stream",
821
+ metadata,
822
+ lastModified: h.get("last-modified") ?? "",
823
+ expiresAt: h.get("x-expires-at"),
824
+ etag: (h.get("etag") ?? "").replace(/^"|"$/g, ""),
825
+ };
826
+ };
827
+ const objectPath = (key) => `${area}/${storageKeyPath(key)}`;
828
+ return {
829
+ get: async (key, options = {}) => {
830
+ if (options.length === 0) {
831
+ const response = await call("HEAD", objectPath(key), {
832
+ signal: options.signal,
833
+ });
834
+ if (response.status === 404)
835
+ return null;
836
+ if (!response.ok)
837
+ return fail(response);
838
+ return { ...headOf(key, response), body: new Uint8Array(0) };
839
+ }
840
+ const headers = {};
841
+ if (options.offset !== undefined || options.length !== undefined) {
842
+ const start = options.offset ?? 0;
843
+ headers.range =
844
+ options.length === undefined
845
+ ? `bytes=${start}-`
846
+ : `bytes=${start}-${start + options.length - 1}`;
847
+ }
848
+ const response = await call("GET", objectPath(key), {
849
+ headers,
850
+ signal: options.signal,
851
+ });
852
+ if (response.status === 404)
853
+ return null;
854
+ if (!response.ok)
855
+ return fail(response);
856
+ return {
857
+ ...headOf(key, response),
858
+ body: new Uint8Array(await response.arrayBuffer()),
859
+ };
860
+ },
861
+ head: async (key, signal) => {
862
+ const response = await call("HEAD", objectPath(key), { signal });
863
+ if (response.status === 404)
864
+ return null;
865
+ if (!response.ok)
866
+ return fail(response);
867
+ return headOf(key, response);
868
+ },
869
+ put: async (key, body, options = {}) => {
870
+ const headers = {
871
+ "content-type": options.contentType ??
872
+ (typeof body === "string"
873
+ ? "text/plain; charset=utf-8"
874
+ : "application/octet-stream"),
875
+ };
876
+ for (const [name, value] of Object.entries(options.metadata ?? {}))
877
+ headers[`x-meta-${name}`] = value;
878
+ if (options.ifAbsent)
879
+ headers["if-none-match"] = "*";
880
+ if (options.ifMatch !== undefined)
881
+ headers["if-match"] = `"${options.ifMatch}"`;
882
+ return json(await call("PUT", objectPath(key), {
883
+ headers,
884
+ body: body,
885
+ signal: options.signal,
886
+ }));
887
+ },
888
+ delete: async (key, options = {}) => {
889
+ const headers = {};
890
+ if (options.ifMatch !== undefined)
891
+ headers["if-match"] = `"${options.ifMatch}"`;
892
+ const response = await call("DELETE", objectPath(key), {
893
+ headers,
894
+ signal: options.signal,
895
+ });
896
+ if (!response.ok)
897
+ await fail(response);
898
+ },
899
+ list: async (prefix = "", options = {}) => {
900
+ const query = new URLSearchParams({ prefix });
901
+ if (options.cursor)
902
+ query.set("cursor", options.cursor);
903
+ if (options.limit !== undefined)
904
+ query.set("limit", String(options.limit));
905
+ return (await json(await call("GET", `${area}?${query}`, { signal: options.signal })));
906
+ },
907
+ usage: async (signal) => json(await call("GET", "usage", { signal })),
908
+ };
909
+ };