@cosmicdrift/kumiko-bundled-features 0.310.0 → 0.311.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-bundled-features",
3
- "version": "0.310.0",
3
+ "version": "0.311.0",
4
4
  "description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -133,12 +133,12 @@
133
133
  "./workflow-runner": "./src/workflow-runner/index.ts"
134
134
  },
135
135
  "dependencies": {
136
- "@cosmicdrift/kumiko-dispatcher-live": "0.310.0",
137
- "@cosmicdrift/kumiko-framework": "0.310.0",
138
- "@cosmicdrift/kumiko-headless": "0.310.0",
139
- "@cosmicdrift/kumiko-renderer": "0.310.0",
140
- "@cosmicdrift/kumiko-renderer-web": "0.310.0",
141
- "@cosmicdrift/kumiko-types": "0.310.0",
136
+ "@cosmicdrift/kumiko-dispatcher-live": "0.311.0",
137
+ "@cosmicdrift/kumiko-framework": "0.311.0",
138
+ "@cosmicdrift/kumiko-headless": "0.311.0",
139
+ "@cosmicdrift/kumiko-renderer": "0.311.0",
140
+ "@cosmicdrift/kumiko-renderer-web": "0.311.0",
141
+ "@cosmicdrift/kumiko-types": "0.311.0",
142
142
  "@mollie/api-client": "^4.5.0",
143
143
  "@node-rs/argon2": "^2.0.2",
144
144
  "@types/mailparser": "^3.4.6",
@@ -167,8 +167,8 @@
167
167
  ],
168
168
  "devDependencies": {
169
169
  "@testing-library/user-event": "^14.6.1",
170
- "@cosmicdrift/kumiko-locale-de": "0.310.0",
171
- "@cosmicdrift/kumiko-locale-es": "0.310.0",
170
+ "@cosmicdrift/kumiko-locale-de": "0.311.0",
171
+ "@cosmicdrift/kumiko-locale-es": "0.311.0",
172
172
  "jsqr": "^1.4.0"
173
173
  }
174
174
  }
@@ -12,6 +12,7 @@ import {
12
12
  type TestStack,
13
13
  testTenantId,
14
14
  } from "@cosmicdrift/kumiko-framework/stack";
15
+ import { waitFor } from "@cosmicdrift/kumiko-framework/testing";
15
16
  import * as z from "zod";
16
17
  import { createConfigFeature } from "../../config";
17
18
  import { createTenantFeature } from "../../tenant";
@@ -71,18 +72,21 @@ type AuditRow = {
71
72
  type AuditResponse = { rows: AuditRow[]; nextBefore: string | null };
72
73
 
73
74
  async function pollForEscapeHatchRow(): Promise<AuditRow> {
74
- const deadline = Date.now() + 2000;
75
- while (Date.now() < deadline) {
76
- const res = await stack.http.queryOk<AuditResponse>(
77
- AuditQueries.list,
78
- { eventType: ESCAPE_HATCH_USED_EVENT },
79
- adminOfSameTenant,
80
- );
81
- const row = res.rows[0];
82
- if (row) return row;
83
- await new Promise((resolve) => setTimeout(resolve, 50));
84
- }
85
- throw new Error("escape-hatch-used audit row did not appear within 2s");
75
+ let row: AuditRow | undefined;
76
+ await waitFor(
77
+ async () => {
78
+ const res = await stack.http.queryOk<AuditResponse>(
79
+ AuditQueries.list,
80
+ { eventType: ESCAPE_HATCH_USED_EVENT },
81
+ adminOfSameTenant,
82
+ );
83
+ row = res.rows[0];
84
+ return row !== undefined;
85
+ },
86
+ { delays: Array(40).fill(50) },
87
+ );
88
+ if (row === undefined) throw new Error("escape-hatch-used audit row did not appear within 2s");
89
+ return row;
86
90
  }
87
91
 
88
92
  describe("createEscapeHatchAuditSink — persists audit:event:escape-hatch-used", () => {
@@ -26,6 +26,7 @@ import {
26
26
  testTenantId,
27
27
  unsafePushTables,
28
28
  } from "@cosmicdrift/kumiko-framework/stack";
29
+ import { waitFor } from "@cosmicdrift/kumiko-framework/testing";
29
30
  import * as z from "zod";
30
31
  import { createChannelEmailFeature } from "../../channel-email/feature";
31
32
  import { createInMemoryTransport, type EmailMessage } from "../../channel-email/types";
@@ -1502,21 +1503,6 @@ describe("flow 16: repeated unsubscribe clicks are idempotent", () => {
1502
1503
  // non-string `to` as a broadcast target ("tenant" in to).
1503
1504
  const asyncRecipient = "7";
1504
1505
 
1505
- async function waitFor(check: () => Promise<void>, timeoutMs = 10000): Promise<void> {
1506
- const start = Date.now();
1507
- let lastErr: unknown;
1508
- for (;;) {
1509
- try {
1510
- await check();
1511
- return;
1512
- } catch (err) {
1513
- lastErr = err;
1514
- if (Date.now() - start > timeoutMs) throw lastErr;
1515
- await new Promise((resolve) => setTimeout(resolve, 50));
1516
- }
1517
- }
1518
- }
1519
-
1520
1506
  function makeStubRunner(): {
1521
1507
  runner: JobRunner;
1522
1508
  dispatched: Array<{
@@ -1580,13 +1566,16 @@ describe("flow 17: async render→send pipeline", () => {
1580
1566
 
1581
1567
  // Email: queued → render job → send job → sent. Proves the framework
1582
1568
  // jobRunner-into-job-ctx injection (render dispatches send) end-to-end.
1583
- await waitFor(async () => {
1584
- const rows = await selectMany(db, deliveryAttemptsTable, {
1585
- notificationType: "app:notify:async-e2e",
1586
- channel: "email",
1587
- });
1588
- expect(rows.some((r) => r["status"] === "sent")).toBe(true);
1589
- });
1569
+ await waitFor(
1570
+ async () => {
1571
+ const rows = await selectMany(db, deliveryAttemptsTable, {
1572
+ notificationType: "app:notify:async-e2e",
1573
+ channel: "email",
1574
+ });
1575
+ expect(rows.some((r) => r["status"] === "sent")).toBe(true);
1576
+ },
1577
+ { delays: Array(40).fill(250) },
1578
+ );
1590
1579
  const email = emailTransport.sent.find((m) => m.to === testEmail(asyncRecipient));
1591
1580
  expect(email).toBeDefined();
1592
1581
  expect(email?.subject).toBe("Async Subject");
@@ -1594,13 +1583,16 @@ describe("flow 17: async render→send pipeline", () => {
1594
1583
  expect(email?.html).toContain("<!DOCTYPE html>");
1595
1584
 
1596
1585
  // Push: no render step → dispatched straight to delivery.send.
1597
- await waitFor(async () => {
1598
- const rows = await selectMany(db, deliveryAttemptsTable, {
1599
- notificationType: "app:notify:async-e2e",
1600
- channel: "push",
1601
- });
1602
- expect(rows.some((r) => r["status"] === "sent")).toBe(true);
1603
- });
1586
+ await waitFor(
1587
+ async () => {
1588
+ const rows = await selectMany(db, deliveryAttemptsTable, {
1589
+ notificationType: "app:notify:async-e2e",
1590
+ channel: "push",
1591
+ });
1592
+ expect(rows.some((r) => r["status"] === "sent")).toBe(true);
1593
+ },
1594
+ { delays: Array(40).fill(250) },
1595
+ );
1604
1596
  expect(
1605
1597
  pushTransport.sent.find((m) => m.token === testPushToken(asyncRecipient)),
1606
1598
  ).toBeDefined();
@@ -5,6 +5,7 @@
5
5
  import { afterAll, beforeEach, describe, expect, mock, test } from "bun:test";
6
6
  import { EventEmitter } from "node:events";
7
7
  import { createSecret } from "@cosmicdrift/kumiko-framework/secrets";
8
+ import { sleep, waitFor } from "@cosmicdrift/kumiko-framework/testing";
8
9
  import {
9
10
  type InboundMailContext,
10
11
  isInboundAuthError,
@@ -152,13 +153,6 @@ const account: MailAccountRecord = {
152
153
  watchState: "idle",
153
154
  };
154
155
 
155
- async function waitFor(predicate: () => boolean, timeoutMs = 2000): Promise<void> {
156
- const t0 = Date.now();
157
- while (!predicate() && Date.now() - t0 < timeoutMs) {
158
- await Bun.sleep(5);
159
- }
160
- }
161
-
162
156
  function ctxWithDoc(doc: string | null): InboundMailContext {
163
157
  return {
164
158
  _userId: "imap-plugin-mocked",
@@ -332,7 +326,9 @@ describe("imapInboundMailPlugin — mocked imapflow", () => {
332
326
 
333
327
  // drainNew() does MIME-parsing before onMessages fires — a fixed sleep
334
328
  // flakes on a loaded CI runner; poll instead.
335
- await waitFor(() => received.some((batch) => batch.some((m) => m.subject === "pushed")));
329
+ await waitFor(() => received.some((batch) => batch.some((m) => m.subject === "pushed")), {
330
+ delays: Array(40).fill(5),
331
+ });
336
332
  expect(received.some((batch) => batch.some((m) => m.subject === "pushed"))).toBe(true);
337
333
 
338
334
  await stop();
@@ -347,11 +343,13 @@ describe("imapInboundMailPlugin — mocked imapflow", () => {
347
343
  },
348
344
  });
349
345
  lastIdleClient?.emit("error", new Error("socket hang up"));
350
- await waitFor(() => errors >= 1);
346
+ await waitFor(() => errors >= 1, { delays: Array(40).fill(5) });
351
347
  lastIdleClient?.emit("error", new Error("second"));
352
- // Confirms onError stays unsubscribed after the first error — polls the
353
- // same bounded window rather than betting on a fixed sleep outlasting it.
354
- await waitFor(() => errors >= 2, 100);
348
+ // Confirms onError stays unsubscribed after the first error. This is a
349
+ // grace period for a negative outcome, not a wait-for-true condition, so
350
+ // it's a single sleep (not a poll loop) rather than waitFor, which would
351
+ // throw once errors never reaches 2.
352
+ await sleep(100);
355
353
  expect(errors).toBe(1);
356
354
  await stop().catch(() => {});
357
355
  });
@@ -40,7 +40,7 @@ import {
40
40
  unsafeCreateEntityTable,
41
41
  unsafePushTables,
42
42
  } from "@cosmicdrift/kumiko-framework/stack";
43
- import { sleep } from "@cosmicdrift/kumiko-framework/testing";
43
+ import { waitFor } from "@cosmicdrift/kumiko-framework/testing";
44
44
  import { createJobsFeature } from "../feature";
45
45
  import { createJobRunLogger } from "../job-run-logger";
46
46
  import { jobRunLogsTable, jobRunsTable } from "../job-run-table";
@@ -148,7 +148,7 @@ describe("projection-rebuild job (jobs feature composed)", () => {
148
148
  }
149
149
 
150
150
  // Poll until the worker drained the queue and the rebuild refilled.
151
- for (let i = 0; i < 40 && (await getCount()) !== 2; i++) await sleep(200);
151
+ await waitFor(async () => (await getCount()) === 2, { delays: Array(40).fill(200) });
152
152
  expect(await getCount()).toBe(2);
153
153
 
154
154
  // getCount()==2 only proves rebuildProjection's own writes landed — the
@@ -156,13 +156,15 @@ describe("projection-rebuild job (jobs feature composed)", () => {
156
156
  // async append that starts only after the handler returns, so it can
157
157
  // still be in flight here. Poll status too instead of racing it.
158
158
  let runs: readonly { jobName: string; status: string }[] = [];
159
- for (let i = 0; i < 40; i++) {
160
- runs = await selectMany<{ jobName: string; status: string }>(db, jobRunsTable, {
161
- jobName: PROJECTION_REBUILD_JOB,
162
- });
163
- if (runs.some((r) => r.status === "completed")) break;
164
- await sleep(200);
165
- }
159
+ await waitFor(
160
+ async () => {
161
+ runs = await selectMany<{ jobName: string; status: string }>(db, jobRunsTable, {
162
+ jobName: PROJECTION_REBUILD_JOB,
163
+ });
164
+ return runs.some((r) => r.status === "completed");
165
+ },
166
+ { delays: Array(40).fill(200) },
167
+ );
166
168
  expect(runs.length).toBeGreaterThanOrEqual(1);
167
169
  expect(runs.some((r) => r.status === "completed")).toBe(true);
168
170
  }, 30000);