okengine 0.16.0 → 0.17.0

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.
Files changed (149) hide show
  1. package/manifest.v1.schema.json +21 -2
  2. package/package.json +1 -1
  3. package/site/content/docs/elements/clock.mdx +7 -0
  4. package/site/content/docs/elements/flow.mdx +14 -12
  5. package/site/content/docs/elements/gate.mdx +58 -8
  6. package/site/content/docs/elements/signal.mdx +46 -17
  7. package/site/content/docs/elements/store.mdx +23 -17
  8. package/site/content/docs/elements/vault.mdx +23 -0
  9. package/site/content/docs/get-started/project-structure.mdx +4 -4
  10. package/site/content/docs/reference/cli.md +1 -1
  11. package/site/content/docs/reference/client.mdx +72 -17
  12. package/site/content/docs/reference/configuration.mdx +7 -4
  13. package/site/content/docs/reference/errors.mdx +24 -17
  14. package/site/content/docs/reference/fx.mdx +15 -9
  15. package/src/auth/api-key-sql.ts +11 -4
  16. package/src/auth/api-keys.ts +3 -0
  17. package/src/auth/config.ts +19 -0
  18. package/src/auth/index.ts +37 -0
  19. package/src/auth/plugin.ts +6 -1
  20. package/src/auth/sessions.ts +18 -0
  21. package/src/auth/tables.ts +32 -0
  22. package/src/auth/tenant-config.ts +74 -0
  23. package/src/auth/tenant-tables.ts +11 -0
  24. package/src/auth/tenants.test.ts +63 -0
  25. package/src/auth/tenants.ts +360 -0
  26. package/src/cli/build.ts +2 -2
  27. package/src/client/budget.test.ts +1 -1
  28. package/src/client/create.ts +75 -5
  29. package/src/client/index.ts +13 -0
  30. package/src/client/live.test.ts +422 -0
  31. package/src/client/live.ts +389 -0
  32. package/src/client/notes-contract.test.ts +41 -0
  33. package/src/client/types.ts +85 -6
  34. package/src/client-react/index.ts +85 -1
  35. package/src/client-react/use-live.test.ts +129 -0
  36. package/src/compiler/effects-infer.ts +22 -2
  37. package/src/compiler/extract.test.ts +143 -11
  38. package/src/compiler/extract.ts +210 -30
  39. package/src/compiler/fixtures/skyport/src/flows/bookings/index.ts +1 -2
  40. package/src/compiler/fixtures/skyport.expected.json +2 -3
  41. package/src/compiler/response.ts +41 -11
  42. package/src/console/server/app.ts +44 -11
  43. package/src/console/server/console.test.ts +6 -8
  44. package/src/console/server/gates.ts +2 -9
  45. package/src/console/server/store.test.ts +17 -0
  46. package/src/console/server/store.ts +43 -1
  47. package/src/console/ui-next/dist/assets/{access-page-CAGHrA9H.js → access-page-CXVWWMmD.js} +1 -1
  48. package/src/console/ui-next/dist/assets/{flows-page-B-OUtiAu.js → flows-page-XDpAJO8f.js} +1 -1
  49. package/src/console/ui-next/dist/assets/{index-CGoZkILK.js → index-BBj2QJCu.js} +3 -3
  50. package/src/console/ui-next/dist/assets/{observability-page-BDyXalNR.js → observability-page-BFaay44m.js} +1 -1
  51. package/src/console/ui-next/dist/assets/{store-page-Xh8Kn3rx.js → store-page-CS5-aETQ.js} +1 -1
  52. package/src/console/ui-next/dist/assets/{tree-expand-toggle-DkOXA12R.js → tree-expand-toggle-BtyhmWb4.js} +2 -1
  53. package/src/console/ui-next/dist/assets/{units-page-Dpk40kOQ.js → units-page-Ca2Z-E52.js} +1 -1
  54. package/src/console/ui-next/dist/assets/{vault-page-B1dB9Ft0.js → vault-page-BmOeAFwg.js} +1 -1
  55. package/src/console/ui-next/dist/index.html +1 -1
  56. package/src/console/ui-next/src/features/flows/fixture.ts +0 -1
  57. package/src/console/ui-next/src/features/units/detail/flow-contract-panel.tsx +5 -1
  58. package/src/console/ui-next/src/features/units/lib/unit-tree.test.ts +15 -4
  59. package/src/console/ui-next/ui-next-seed-manifest-surface.ts +2 -9
  60. package/src/console/ui-next/ui-next-seed-manifest.ts +0 -1
  61. package/src/drivers/index.ts +2 -0
  62. package/src/drivers/journal-postgres.ts +12 -3
  63. package/src/drivers/pg-rls.ts +26 -2
  64. package/src/drivers/pg-vault-rls.ts +68 -0
  65. package/src/drivers/signal-engine.ts +69 -15
  66. package/src/drivers/signal-live-iter.ts +65 -0
  67. package/src/drivers/signal-nats.ts +2 -1
  68. package/src/drivers/signal-postgres.ts +95 -12
  69. package/src/drivers/signal-redis.ts +2 -1
  70. package/src/drivers/signal-retention.ts +64 -0
  71. package/src/drivers/signal-types.ts +35 -5
  72. package/src/elements/clock/declare.ts +64 -2
  73. package/src/elements/clock/reconcile.ts +98 -25
  74. package/src/elements/clock/runtime.ts +13 -2
  75. package/src/elements/clock.test.ts +28 -0
  76. package/src/elements/clock.ts +9 -1
  77. package/src/elements/gate/permissions.ts +12 -0
  78. package/src/elements/gate.ts +6 -1
  79. package/src/elements/signal/declare.ts +44 -10
  80. package/src/elements/signal/delivery-modes.test.ts +90 -4
  81. package/src/elements/signal/order-lifecycle.test.ts +20 -4
  82. package/src/elements/signal/runtime.ts +42 -0
  83. package/src/elements/signal.test.ts +21 -8
  84. package/src/elements/signal.ts +1 -1
  85. package/src/elements/store/declare.ts +7 -0
  86. package/src/elements/store/rls-identity.test.ts +29 -0
  87. package/src/elements/store/rls-identity.ts +3 -0
  88. package/src/elements/store/schema-decl.ts +63 -3
  89. package/src/elements/store/schema-tenant.ts +41 -0
  90. package/src/elements/store/sql-rls-isolation.test.ts +39 -0
  91. package/src/elements/store.ts +2 -0
  92. package/src/elements/vault/builtin-adapter.ts +15 -2
  93. package/src/elements/vault/declare.ts +8 -0
  94. package/src/elements/vault/runtime.ts +7 -0
  95. package/src/elements/vault/sql-rls-isolation.test.ts +145 -0
  96. package/src/elements/vault/storage.ts +9 -0
  97. package/src/elements/vault/test-helpers.ts +2 -1
  98. package/src/i18n/catalogs/ar.ts +19 -0
  99. package/src/i18n/catalogs/en.ts +19 -0
  100. package/src/index.ts +1 -0
  101. package/src/kernel/adopt-routes.ts +56 -8
  102. package/src/kernel/app-tenant.ts +122 -0
  103. package/src/kernel/app.ts +183 -56
  104. package/src/kernel/auth-resolve.ts +3 -0
  105. package/src/kernel/boot-bind/clock.ts +9 -3
  106. package/src/kernel/boot.ts +2 -0
  107. package/src/kernel/budget.test.ts +1 -1
  108. package/src/kernel/clock-durable.ts +8 -0
  109. package/src/kernel/clock-per-tenant-name.ts +5 -0
  110. package/src/kernel/clock-reconcile.ts +8 -0
  111. package/src/kernel/errors-live-resume.ts +15 -0
  112. package/src/kernel/errors-tenant.ts +29 -0
  113. package/src/kernel/errors.registry.test.ts +31 -3
  114. package/src/kernel/errors.ts +43 -3
  115. package/src/kernel/flow.ts +26 -5
  116. package/src/kernel/fx-auth-keys.ts +6 -1
  117. package/src/kernel/fx-auth-tenants.test.ts +87 -0
  118. package/src/kernel/fx-auth-tenants.ts +286 -0
  119. package/src/kernel/fx-live-stream.ts +149 -0
  120. package/src/kernel/fx-live.test.ts +157 -0
  121. package/src/kernel/fx-runtime.ts +15 -0
  122. package/src/kernel/fx-tenant-store.ts +213 -0
  123. package/src/kernel/fx.test.ts +91 -0
  124. package/src/kernel/fx.ts +173 -13
  125. package/src/kernel/hooks.ts +2 -2
  126. package/src/kernel/http-resource.ts +9 -18
  127. package/src/kernel/index.ts +2 -0
  128. package/src/kernel/journal.ts +12 -0
  129. package/src/kernel/live-http.test.ts +78 -0
  130. package/src/kernel/live-http.ts +114 -0
  131. package/src/kernel/live-resume.test.ts +125 -0
  132. package/src/kernel/on.ts +51 -0
  133. package/src/kernel/pipeline-tenant.ts +49 -0
  134. package/src/kernel/pipeline.test.ts +1 -1
  135. package/src/kernel/pipeline.ts +37 -2
  136. package/src/kernel/resource-mount.test.ts +10 -27
  137. package/src/kernel/tenant-resolve.test.ts +101 -0
  138. package/src/kernel/tenant-resolve.ts +124 -0
  139. package/src/kernel/tenant-roles.test.ts +87 -0
  140. package/src/kernel/triggers.ts +59 -21
  141. package/src/manifest/diff.test.ts +11 -2
  142. package/src/manifest/diff.ts +53 -11
  143. package/src/manifest/fixtures/skyport.excerpt.json +1 -1
  144. package/src/manifest/fixtures/skyport.manifest.json +0 -1
  145. package/src/manifest/types.ts +52 -6
  146. package/src/release/build-lib.ts +13 -1
  147. package/src/release/limits.ts +2 -2
  148. package/src/release/measure.ts +55 -1
  149. package/src/client/live-gap.test.ts +0 -35
@@ -0,0 +1,422 @@
1
+ /**
2
+ * Client live subscribe — callback + unsubscribe + autoResubscribe.
3
+ */
4
+
5
+ import { describe, expect, test } from "bun:test";
6
+ import { createClient } from "./create.ts";
7
+ import * as client from "./index.ts";
8
+ import {
9
+ LIVE_RESUBSCRIBE_INITIAL_MS,
10
+ LIVE_RESUBSCRIBE_MAX_MS,
11
+ nextResubscribeDelay,
12
+ pickLiveExposure,
13
+ type LiveExposure,
14
+ } from "./live.ts";
15
+ import type { AppOf } from "./types.ts";
16
+
17
+ type EventsApp = AppOf<{
18
+ orders: {
19
+ events: {
20
+ in: { orderId: string };
21
+ out: { orderId: string; status: string };
22
+ method: "GET";
23
+ path: "/orders/:orderId/events";
24
+ live: "order-status";
25
+ matchKey: ["orderId"];
26
+ stream: true;
27
+ };
28
+ };
29
+ admin: {
30
+ adminFeed: {
31
+ out: { orderId: string; status: string };
32
+ method: "GET";
33
+ path: "/admin/order-status";
34
+ live: "order-status";
35
+ matchKey: [];
36
+ stream: true;
37
+ };
38
+ };
39
+ }>;
40
+
41
+ const orderStatus = {
42
+ name: "order-status",
43
+ _payload: undefined as { orderId: string; status: string } | undefined,
44
+ };
45
+
46
+ function sseResponse(frames: readonly unknown[], ids?: readonly string[]): Response {
47
+ const lines: string[] = [];
48
+ frames.forEach((payload, i) => {
49
+ const id = ids?.[i];
50
+ if (id !== undefined) lines.push(`id: ${id}`);
51
+ lines.push(`data: ${JSON.stringify(payload)}`);
52
+ lines.push("");
53
+ });
54
+ lines.push("data: [DONE]");
55
+ lines.push("");
56
+ return new Response(lines.join("\n"), {
57
+ status: 200,
58
+ headers: { "content-type": "text/event-stream" },
59
+ });
60
+ }
61
+
62
+ describe("pickLiveExposure", () => {
63
+ const member: LiveExposure = {
64
+ flow: "orders.events",
65
+ method: "GET",
66
+ path: "/orders/:orderId/events",
67
+ matchKey: ["orderId"],
68
+ };
69
+ const partner: LiveExposure = {
70
+ flow: "partners.events",
71
+ method: "GET",
72
+ path: "/partners/:orderId/events",
73
+ matchKey: ["orderId"],
74
+ };
75
+ const firehose: LiveExposure = {
76
+ flow: "admin.adminFeed",
77
+ method: "GET",
78
+ path: "/admin/order-status",
79
+ matchKey: [],
80
+ };
81
+
82
+ test("prefers the largest matchKey subset", () => {
83
+ expect(pickLiveExposure([member, firehose], { orderId: "ord_1" }).flow).toBe("orders.events");
84
+ expect(pickLiveExposure([member, firehose], {}).flow).toBe("admin.adminFeed");
85
+ });
86
+
87
+ test("tie requires via", () => {
88
+ expect(() => pickLiveExposure([member, partner], { orderId: "ord_1" })).toThrow(/via/);
89
+ expect(pickLiveExposure([member, partner], { orderId: "ord_1" }, "partners.events").flow).toBe(
90
+ "partners.events",
91
+ );
92
+ });
93
+ });
94
+
95
+ describe("createClient — live", () => {
96
+ test("public client module exports createClient (live is on the instance)", () => {
97
+ expect(Object.keys(client)).toContain("createClient");
98
+ });
99
+
100
+ test("root api.live is a function", () => {
101
+ const api = createClient<EventsApp>("http://app.test", {
102
+ fetch: async () => sseResponse([]),
103
+ $routes: {
104
+ orders: {
105
+ events: {
106
+ method: "GET",
107
+ path: "/orders/:orderId/events",
108
+ live: "order-status",
109
+ matchKey: ["orderId"],
110
+ stream: true,
111
+ },
112
+ },
113
+ admin: {
114
+ adminFeed: {
115
+ method: "GET",
116
+ path: "/admin/order-status",
117
+ live: "order-status",
118
+ matchKey: [],
119
+ stream: true,
120
+ },
121
+ },
122
+ },
123
+ });
124
+ expect(typeof api).toBe("function");
125
+ expect(typeof api.live).toBe("function");
126
+ });
127
+
128
+ test("onEvent receives schema-shaped payloads; unsubscribe stops delivery", async () => {
129
+ const seen: unknown[] = [];
130
+ let pull!: (chunk: Uint8Array) => void;
131
+ let close!: () => void;
132
+ const stream = new ReadableStream<Uint8Array>({
133
+ start(controller) {
134
+ pull = (chunk) => controller.enqueue(chunk);
135
+ close = () => controller.close();
136
+ },
137
+ });
138
+ const api = createClient<EventsApp>("http://app.test", {
139
+ fetch: async () =>
140
+ new Response(stream, {
141
+ status: 200,
142
+ headers: { "content-type": "text/event-stream" },
143
+ }),
144
+ $routes: {
145
+ orders: {
146
+ events: {
147
+ method: "GET",
148
+ path: "/orders/:orderId/events",
149
+ live: "order-status",
150
+ matchKey: ["orderId"],
151
+ stream: true,
152
+ },
153
+ },
154
+ },
155
+ });
156
+
157
+ const stop = api.live(
158
+ orderStatus,
159
+ { orderId: "ord_1" },
160
+ {
161
+ onEvent: (event) => {
162
+ seen.push(event);
163
+ },
164
+ },
165
+ );
166
+
167
+ const enc = new TextEncoder();
168
+ pull(enc.encode(`data: ${JSON.stringify({ orderId: "ord_1", status: "placed" })}\n\n`));
169
+ await waitFor(() => seen.length === 1);
170
+ expect(seen).toEqual([{ orderId: "ord_1", status: "placed" }]);
171
+ stop();
172
+ pull(enc.encode(`data: ${JSON.stringify({ orderId: "ord_1", status: "shipped" })}\n\n`));
173
+ close();
174
+ await new Promise((r) => setTimeout(r, 20));
175
+ expect(seen).toEqual([{ orderId: "ord_1", status: "placed" }]);
176
+ });
177
+
178
+ test("autoResubscribe true delivers after a drop; false does not", async () => {
179
+ let calls = 0;
180
+ const payload = { orderId: "ord_1", status: "placed" };
181
+ const routes = {
182
+ orders: {
183
+ events: {
184
+ method: "GET" as const,
185
+ path: "/orders/:orderId/events",
186
+ live: "order-status",
187
+ matchKey: ["orderId"] as const,
188
+ stream: true as const,
189
+ },
190
+ },
191
+ };
192
+
193
+ const recovered: unknown[] = [];
194
+ const closingThenEvent = async (): Promise<Response> => {
195
+ calls += 1;
196
+ if (calls === 1) {
197
+ return new Response("nope", { status: 500, headers: { "content-type": "text/plain" } });
198
+ }
199
+ return sseResponse([payload]);
200
+ };
201
+ const apiOn = createClient<EventsApp>("http://app.test", {
202
+ fetch: closingThenEvent,
203
+ $routes: routes,
204
+ });
205
+ const stopOn = apiOn.live(
206
+ orderStatus,
207
+ { orderId: "ord_1" },
208
+ {
209
+ onEvent: (e) => recovered.push(e),
210
+ autoResubscribe: true,
211
+ },
212
+ );
213
+ await waitFor(() => recovered.length === 1, 2000);
214
+ expect(recovered).toEqual([payload]);
215
+ expect(calls).toBe(2);
216
+ stopOn();
217
+
218
+ calls = 0;
219
+ const later: unknown[] = [];
220
+ let err: unknown;
221
+ const fetchOff = async (): Promise<Response> => {
222
+ calls += 1;
223
+ return new Response("nope", { status: 500, headers: { "content-type": "text/plain" } });
224
+ };
225
+ const apiOff = createClient<EventsApp>("http://app.test", { fetch: fetchOff, $routes: routes });
226
+ const stopOff = apiOff.live(
227
+ orderStatus,
228
+ { orderId: "ord_1" },
229
+ {
230
+ onEvent: (e) => later.push(e),
231
+ onError: (e) => {
232
+ err = e;
233
+ },
234
+ },
235
+ );
236
+ await waitFor(() => err !== undefined);
237
+ expect(later).toEqual([]);
238
+ expect(calls).toBe(1);
239
+ stopOff();
240
+ });
241
+
242
+ test("autoResubscribe backoff is exponential and capped", () => {
243
+ expect(LIVE_RESUBSCRIBE_INITIAL_MS).toBe(500);
244
+ expect(LIVE_RESUBSCRIBE_MAX_MS).toBe(30_000);
245
+ expect(nextResubscribeDelay(500)).toBe(1000);
246
+ expect(nextResubscribeDelay(1000)).toBe(2000);
247
+ expect(nextResubscribeDelay(16_000)).toBe(30_000);
248
+ expect(nextResubscribeDelay(30_000)).toBe(30_000);
249
+ });
250
+
251
+ test("autoResubscribe throttles immediately-closing streams (2s window)", async () => {
252
+ let calls = 0;
253
+ const api = createClient<EventsApp>("http://app.test", {
254
+ fetch: async () => {
255
+ calls += 1;
256
+ return new Response("data: [DONE]\n\n", {
257
+ status: 200,
258
+ headers: { "content-type": "text/event-stream" },
259
+ });
260
+ },
261
+ $routes: {
262
+ orders: {
263
+ events: {
264
+ method: "GET",
265
+ path: "/orders/:orderId/events",
266
+ live: "order-status",
267
+ matchKey: ["orderId"],
268
+ stream: true,
269
+ },
270
+ },
271
+ },
272
+ });
273
+ const stop = api.live(
274
+ orderStatus,
275
+ { orderId: "ord_1" },
276
+ {
277
+ onEvent: () => undefined,
278
+ autoResubscribe: true,
279
+ },
280
+ );
281
+ await new Promise((r) => setTimeout(r, 2000));
282
+ stop();
283
+ // Unthrottled: thousands. 500ms then 1s then 2s → 3 attempts in 2s.
284
+ expect(calls).toBeGreaterThanOrEqual(2);
285
+ expect(calls).toBeLessThan(10);
286
+ }, 10_000);
287
+
288
+ test("unsubscribe during backoff does not open another request", async () => {
289
+ let calls = 0;
290
+ const api = createClient<EventsApp>("http://app.test", {
291
+ fetch: async () => {
292
+ calls += 1;
293
+ return new Response("data: [DONE]\n\n", {
294
+ status: 200,
295
+ headers: { "content-type": "text/event-stream" },
296
+ });
297
+ },
298
+ $routes: {
299
+ orders: {
300
+ events: {
301
+ method: "GET",
302
+ path: "/orders/:orderId/events",
303
+ live: "order-status",
304
+ matchKey: ["orderId"],
305
+ stream: true,
306
+ },
307
+ },
308
+ },
309
+ });
310
+ const stop = api.live(
311
+ orderStatus,
312
+ { orderId: "ord_1" },
313
+ {
314
+ onEvent: () => undefined,
315
+ autoResubscribe: true,
316
+ },
317
+ );
318
+ await waitFor(() => calls === 1);
319
+ stop();
320
+ await new Promise((r) => setTimeout(r, 1500));
321
+ expect(calls).toBe(1);
322
+ }, 10_000);
323
+
324
+ test("reconnect sends Last-Event-ID; 410 clears cursor and replays", async () => {
325
+ const seenHeaders: Array<string | null> = [];
326
+ let calls = 0;
327
+ const events: unknown[] = [];
328
+ const errors: unknown[] = [];
329
+ const api = createClient<EventsApp>("http://app.test", {
330
+ fetch: async (_url, init) => {
331
+ calls += 1;
332
+ const h = new Headers(init?.headers);
333
+ seenHeaders.push(h.get("last-event-id"));
334
+ if (calls === 1) {
335
+ return sseResponse([{ orderId: "ord_1", status: "placed" }], ["evt-1"]);
336
+ }
337
+ if (calls === 2) {
338
+ return Response.json(
339
+ {
340
+ data: null,
341
+ error: { code: "LiveResumeGap", data: { signal: "order-status", afterId: "evt-1" } },
342
+ },
343
+ { status: 410 },
344
+ );
345
+ }
346
+ return sseResponse([{ orderId: "ord_1", status: "shipped" }], ["evt-2"]);
347
+ },
348
+ $routes: {
349
+ orders: {
350
+ events: {
351
+ method: "GET",
352
+ path: "/orders/:orderId/events",
353
+ live: "order-status",
354
+ matchKey: ["orderId"],
355
+ stream: true,
356
+ },
357
+ },
358
+ },
359
+ });
360
+ const stop = api.live(
361
+ orderStatus,
362
+ { orderId: "ord_1" },
363
+ {
364
+ onEvent: (e) => events.push(e),
365
+ onError: (e) => errors.push(e),
366
+ autoResubscribe: true,
367
+ },
368
+ );
369
+ await waitFor(() => events.length >= 2, 8_000);
370
+ stop();
371
+ expect(seenHeaders[0]).toBeNull();
372
+ expect(seenHeaders[1]).toBe("evt-1");
373
+ expect(seenHeaders[2]).toBeNull();
374
+ expect(events).toEqual([
375
+ { orderId: "ord_1", status: "placed" },
376
+ { orderId: "ord_1", status: "shipped" },
377
+ ]);
378
+ expect(errors.some((e) => e instanceof Error && e.message.includes("LiveResumeGap"))).toBe(
379
+ true,
380
+ );
381
+ }, 10_000);
382
+
383
+ test("flow-scoped subscribe is unambiguous", async () => {
384
+ const seen: unknown[] = [];
385
+ const api = createClient<EventsApp>("http://app.test", {
386
+ fetch: async (url) => {
387
+ expect(String(url)).toContain("/orders/ord_1/events");
388
+ return sseResponse([{ orderId: "ord_1", status: "placed" }]);
389
+ },
390
+ $routes: {
391
+ orders: {
392
+ events: {
393
+ method: "GET",
394
+ path: "/orders/:orderId/events",
395
+ live: "order-status",
396
+ matchKey: ["orderId"],
397
+ stream: true,
398
+ },
399
+ },
400
+ },
401
+ });
402
+ const stop = api.orders.events(
403
+ { orderId: "ord_1" },
404
+ {
405
+ onEvent: (event) => {
406
+ seen.push(event);
407
+ },
408
+ },
409
+ );
410
+ await waitFor(() => seen.length === 1);
411
+ expect(seen).toEqual([{ orderId: "ord_1", status: "placed" }]);
412
+ stop();
413
+ });
414
+ });
415
+
416
+ async function waitFor(pred: () => boolean, ms = 500): Promise<void> {
417
+ const start = Date.now();
418
+ while (!pred()) {
419
+ if (Date.now() - start > ms) throw new Error("timeout");
420
+ await new Promise((r) => setTimeout(r, 5));
421
+ }
422
+ }