@cosmicdrift/kumiko-framework 0.165.2 → 0.165.3

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-framework",
3
- "version": "0.165.2",
3
+ "version": "0.165.3",
4
4
  "description": "Framework core — engine, pipeline, API, DB, and every other bit that makes Kumiko go.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -197,10 +197,10 @@
197
197
  "zod": "^4.4.3"
198
198
  },
199
199
  "peerDependencies": {
200
- "@cosmicdrift/kumiko-types": "^0.165.2"
200
+ "@cosmicdrift/kumiko-types": "^0.165.3"
201
201
  },
202
202
  "devDependencies": {
203
- "@cosmicdrift/kumiko-dispatcher-live": "0.165.2",
203
+ "@cosmicdrift/kumiko-dispatcher-live": "0.165.3",
204
204
  "bun-types": "^1.3.13",
205
205
  "pino-pretty": "^13.1.3"
206
206
  },
@@ -1,5 +1,14 @@
1
1
  import { describe, expect, mock, test } from "bun:test";
2
- import { createSseBroker, type SseEvent } from "../sse-broker";
2
+ import { createSseBroker, type SseBroker, type SseEvent } from "../sse-broker";
3
+
4
+ function requireAccessInvalidation(broker: SseBroker) {
5
+ const subscribe = broker.subscribeAccessInvalidation;
6
+ const publish = broker.publishAccessInvalidation;
7
+ if (!subscribe || !publish) {
8
+ throw new Error("createSseBroker must implement access-invalidation hooks");
9
+ }
10
+ return { subscribe, publish };
11
+ }
3
12
 
4
13
  describe("SSE broker", () => {
5
14
  test("adds client and tracks count", () => {
@@ -57,59 +66,59 @@ describe("SSE broker", () => {
57
66
  });
58
67
 
59
68
  test("subscribeAccessInvalidation fires only listeners on the same user's channel", () => {
60
- const broker = createSseBroker();
69
+ const { subscribe, publish } = requireAccessInvalidation(createSseBroker());
61
70
  const onInvalidateA = mock();
62
71
  const onInvalidateB = mock();
63
72
 
64
- broker.subscribeAccessInvalidation("user-a", onInvalidateA);
65
- broker.subscribeAccessInvalidation("user-b", onInvalidateB);
73
+ subscribe("user-a", onInvalidateA);
74
+ subscribe("user-b", onInvalidateB);
66
75
 
67
- broker.publishAccessInvalidation("user-a");
76
+ publish("user-a");
68
77
 
69
78
  expect(onInvalidateA).toHaveBeenCalledTimes(1);
70
79
  expect(onInvalidateB).not.toHaveBeenCalled();
71
80
  });
72
81
 
73
82
  test("subscribeAccessInvalidation supports multiple listeners on the same user", () => {
74
- const broker = createSseBroker();
83
+ const { subscribe, publish } = requireAccessInvalidation(createSseBroker());
75
84
  const first = mock();
76
85
  const second = mock();
77
86
 
78
- broker.subscribeAccessInvalidation("user-a", first);
79
- broker.subscribeAccessInvalidation("user-a", second);
80
- broker.publishAccessInvalidation("user-a");
87
+ subscribe("user-a", first);
88
+ subscribe("user-a", second);
89
+ publish("user-a");
81
90
 
82
91
  expect(first).toHaveBeenCalledTimes(1);
83
92
  expect(second).toHaveBeenCalledTimes(1);
84
93
  });
85
94
 
86
95
  test("unsubscribe (returned closure) stops further delivery", () => {
87
- const broker = createSseBroker();
96
+ const { subscribe, publish } = requireAccessInvalidation(createSseBroker());
88
97
  const onInvalidate = mock();
89
98
 
90
- const unsubscribe = broker.subscribeAccessInvalidation("user-a", onInvalidate);
99
+ const unsubscribe = subscribe("user-a", onInvalidate);
91
100
  unsubscribe();
92
- broker.publishAccessInvalidation("user-a");
101
+ publish("user-a");
93
102
 
94
103
  expect(onInvalidate).not.toHaveBeenCalled();
95
104
  });
96
105
 
97
106
  test("a fired listener can unsubscribe itself without skipping other listeners", () => {
98
- const broker = createSseBroker();
107
+ const { subscribe, publish } = requireAccessInvalidation(createSseBroker());
99
108
  let unsubscribeSelf: () => void = () => {};
100
109
  const self = mock(() => unsubscribeSelf());
101
110
  const other = mock();
102
111
 
103
- unsubscribeSelf = broker.subscribeAccessInvalidation("user-a", self);
104
- broker.subscribeAccessInvalidation("user-a", other);
105
- broker.publishAccessInvalidation("user-a");
112
+ unsubscribeSelf = subscribe("user-a", self);
113
+ subscribe("user-a", other);
114
+ publish("user-a");
106
115
 
107
116
  expect(self).toHaveBeenCalledTimes(1);
108
117
  expect(other).toHaveBeenCalledTimes(1);
109
118
  });
110
119
 
111
120
  test("publishAccessInvalidation to a user with no listeners does nothing", () => {
112
- const broker = createSseBroker();
113
- expect(() => broker.publishAccessInvalidation("nobody-listening")).not.toThrow();
121
+ const { publish } = requireAccessInvalidation(createSseBroker());
122
+ expect(() => publish("nobody-listening")).not.toThrow();
114
123
  });
115
124
  });
@@ -19,8 +19,9 @@ export type SseBroker = {
19
19
  getClientCount(channel: string): number;
20
20
  getTotalClientCount(): number;
21
21
  // Separate from addClient so it doesn't count towards getClientCount.
22
- subscribeAccessInvalidation(userId: string, onInvalidate: () => void): () => void;
23
- publishAccessInvalidation(userId: string): void;
22
+ // Optional: additive for external/test SseBroker impls (call sites use?.).
23
+ subscribeAccessInvalidation?(userId: string, onInvalidate: () => void): () => void;
24
+ publishAccessInvalidation?(userId: string): void;
24
25
  };
25
26
 
26
27
  export function createSseBroker(): SseBroker {
@@ -28,7 +29,7 @@ export function createSseBroker(): SseBroker {
28
29
  // Redis. Multi-replica deployments will not revoke SSE streams on other pods
29
30
  // (security control is single-node). Upgrade: Redis pub/sub on userAccessChannel.
30
31
  const channels = new Map<string, Map<string, SseClient>>();
31
- const accessInvalidationListeners = new Map<string, Map<string, () => void>>();
32
+ const accessInvalidationListeners = new Map<string, Set<() => void>>();
32
33
 
33
34
  function getOrCreateChannel(channel: string): Map<string, SseClient> {
34
35
  let clients = channels.get(channel);
@@ -78,18 +79,17 @@ export function createSseBroker(): SseBroker {
78
79
 
79
80
  subscribeAccessInvalidation(userId, onInvalidate) {
80
81
  const channel = userAccessChannel(userId);
81
- const listenerId = generateId();
82
82
  let listeners = accessInvalidationListeners.get(channel);
83
83
  if (!listeners) {
84
- listeners = new Map();
84
+ listeners = new Set();
85
85
  accessInvalidationListeners.set(channel, listeners);
86
86
  }
87
- listeners.set(listenerId, onInvalidate);
87
+ listeners.add(onInvalidate);
88
88
  return () => {
89
89
  const current = accessInvalidationListeners.get(channel);
90
90
  // skip: already unsubscribed (e.g. stream ended after a publish already fired)
91
91
  if (!current) return;
92
- current.delete(listenerId);
92
+ current.delete(onInvalidate);
93
93
  if (current.size === 0) accessInvalidationListeners.delete(channel);
94
94
  };
95
95
  },
@@ -101,7 +101,7 @@ export function createSseBroker(): SseBroker {
101
101
  if (!listeners) return;
102
102
  // Snapshot before iterating — a fired listener unsubscribes itself,
103
103
  // which would mutate `listeners` mid-iteration otherwise.
104
- for (const onInvalidate of [...listeners.values()]) {
104
+ for (const onInvalidate of [...listeners]) {
105
105
  onInvalidate();
106
106
  }
107
107
  },
@@ -61,6 +61,7 @@ export {
61
61
  } from "./request-kms-cache";
62
62
  export {
63
63
  collectPiiSubjectFields,
64
+ collectSearchableSubjectFields,
64
65
  type ResolveSubjectOptions,
65
66
  resolveSubjectForField,
66
67
  SubjectResolutionError,
@@ -89,3 +89,18 @@ export function collectPiiSubjectFields(entity: EntityDefinition): readonly stri
89
89
  )
90
90
  .map(([name]) => name);
91
91
  }
92
+
93
+ /** Subject-annotated fields that may be plaintext in the derived search index (#1610). */
94
+ export function collectSearchableSubjectFields(entity: EntityDefinition): readonly string[] {
95
+ return Object.entries(entity.fields)
96
+ .filter(([, field]) => {
97
+ const subject =
98
+ ("userOwned" in field && field.userOwned !== undefined) ||
99
+ ("tenantOwned" in field && field.tenantOwned === true) ||
100
+ ("pii" in field && field.pii === true);
101
+ if (!subject) return false;
102
+ if ("sensitive" in field && field.sensitive === true) return false;
103
+ return "searchable" in field && field.searchable === true;
104
+ })
105
+ .map(([name]) => name);
106
+ }
@@ -38,6 +38,10 @@ describe("splitSqlStatements", () => {
38
38
  expect(splitSqlStatements("SELECT a/*x*/AS b;")).toEqual(["SELECT a AS b;"]);
39
39
  });
40
40
 
41
+ test("nested block comments close only at matching depth (Postgres)", () => {
42
+ expect(splitSqlStatements("/* a /* b */ c */ SELECT 1;")).toEqual(["SELECT 1;"]);
43
+ });
44
+
41
45
  test("a block-comment opener inside a line comment does not swallow the next statement", () => {
42
46
  const sql = `
43
47
  -- note: see /* details below
@@ -81,6 +81,7 @@ export function splitSqlStatements(sqlText: string): readonly string[] {
81
81
  const statements: string[] = [];
82
82
  let current = "";
83
83
  let state: SqlScanState = "normal";
84
+ let blockCommentDepth = 0;
84
85
 
85
86
  for (let i = 0; i < sqlText.length; i++) {
86
87
  const ch = sqlText.charAt(i);
@@ -94,11 +95,21 @@ export function splitSqlStatements(sqlText: string): readonly string[] {
94
95
  continue;
95
96
  }
96
97
  if (state === "blockComment") {
98
+ // Postgres nests block comments — track depth so the first `*/` does
99
+ // not leave trailing comment text in the statement.
100
+ if (ch === "/" && next === "*") {
101
+ blockCommentDepth++;
102
+ i++;
103
+ continue;
104
+ }
97
105
  if (ch === "*" && next === "/") {
98
- state = "normal";
99
106
  i++;
100
- // Keep a space so `a/*x*/AS` does not become `aAS`.
101
- current += " ";
107
+ blockCommentDepth--;
108
+ if (blockCommentDepth === 0) {
109
+ state = "normal";
110
+ // Keep a space so `a/*x*/AS` does not become `aAS`.
111
+ current += " ";
112
+ }
102
113
  }
103
114
  continue;
104
115
  }
@@ -135,6 +146,7 @@ export function splitSqlStatements(sqlText: string): readonly string[] {
135
146
  }
136
147
  if (ch === "/" && next === "*") {
137
148
  state = "blockComment";
149
+ blockCommentDepth = 1;
138
150
  i++;
139
151
  continue;
140
152
  }
@@ -680,7 +680,7 @@ describe("validateBoot — lookupable / blind-index (#818)", () => {
680
680
  expect(() => validateBoot([feature])).toThrow(/only apply to text fields/);
681
681
  });
682
682
 
683
- test("searchable combined with a subject annotation throws", () => {
683
+ test("searchable combined with a subject annotation passes (#1610)", () => {
684
684
  const feature = defineFeature("test", (r) => {
685
685
  r.entity(
686
686
  "user",
@@ -691,7 +691,7 @@ describe("validateBoot — lookupable / blind-index (#818)", () => {
691
691
  }),
692
692
  );
693
693
  });
694
- expect(() => validateBoot([feature])).toThrow(/searchable.*cannot work/);
694
+ expect(() => validateBoot([feature])).not.toThrow();
695
695
  });
696
696
 
697
697
  test("sortable combined with a subject annotation throws", () => {
@@ -705,7 +705,25 @@ describe("validateBoot — lookupable / blind-index (#818)", () => {
705
705
  }),
706
706
  );
707
707
  });
708
- expect(() => validateBoot([feature])).toThrow(/sortable.*cannot work/);
708
+ expect(() => validateBoot([feature])).toThrow(/sortable/);
709
+ });
710
+
711
+ test("searchable combined with sensitive throws (#1610)", () => {
712
+ const feature = defineFeature("test", (r) => {
713
+ r.entity(
714
+ "user",
715
+ createEntity({
716
+ fields: {
717
+ passwordHash: createTextField({
718
+ pii: true,
719
+ sensitive: true,
720
+ searchable: true,
721
+ }),
722
+ },
723
+ }),
724
+ );
725
+ });
726
+ expect(() => validateBoot([feature])).toThrow(/sensitive.*searchable/);
709
727
  });
710
728
  });
711
729
 
@@ -758,7 +776,7 @@ describe("validateBoot — piiEncrypted (kumiko-platform#231/#456)", () => {
758
776
  expect(() => validateBoot([feature])).toThrow(/piiEncrypted.*without a subject annotation/);
759
777
  });
760
778
 
761
- test("piiEncrypted combined with searchable throws", () => {
779
+ test("piiEncrypted combined with searchable passes (#1610)", () => {
762
780
  const feature = defineFeature("test", (r) => {
763
781
  r.entity(
764
782
  "tenant",
@@ -774,7 +792,7 @@ describe("validateBoot — piiEncrypted (kumiko-platform#231/#456)", () => {
774
792
  }),
775
793
  );
776
794
  });
777
- expect(() => validateBoot([feature])).toThrow(/piiEncrypted.*searchable.*cannot work/);
795
+ expect(() => validateBoot([feature])).not.toThrow();
778
796
  });
779
797
 
780
798
  test("piiEncrypted combined with sortable throws", () => {
@@ -793,7 +811,7 @@ describe("validateBoot — piiEncrypted (kumiko-platform#231/#456)", () => {
793
811
  }),
794
812
  );
795
813
  });
796
- expect(() => validateBoot([feature])).toThrow(/piiEncrypted.*sortable.*cannot work/);
814
+ expect(() => validateBoot([feature])).toThrow(/sortable/);
797
815
  });
798
816
 
799
817
  test("piiEncrypted without access.read throws (kumiko-platform#460)", () => {
@@ -129,15 +129,25 @@ export function validatePiiAndRetention(feature: FeatureDefinition): void {
129
129
  );
130
130
  }
131
131
 
132
- // Substring-Suche/Sortierung auf Ciphertext ist prinzipbedingt
133
- // unmöglich searchable würde Plaintext-Kopien in den Suchindex
134
- // schieben, sortable sortiert Base64-Blobs. Equality lookupable.
135
- if (annotCount > 0 || piiEncryptedFlag.piiEncrypted === true) {
136
- const flags = field as { readonly searchable?: boolean; readonly sortable?: boolean }; // @cast-boundary schema-walk
137
- if (flags.searchable === true || flags.sortable === true) {
138
- const offending = flags.searchable === true ? "searchable" : "sortable";
132
+ // Sortierung liest die Projection-Spalte — die bleibt Ciphertext, also
133
+ // sortable + Subject-Annotation bleibt Boot-Fail. searchable ist seit
134
+ // #1610 erlaubt: der Search-Consumer decryptet in den abgeleiteten
135
+ // Index und forget purgt die Docs (siehe createSearchEventConsumer).
136
+ // sensitive + searchable bleibt verboten (nobody-may-read-back).
137
+ {
138
+ const flags = field as {
139
+ readonly searchable?: boolean;
140
+ readonly sortable?: boolean;
141
+ readonly sensitive?: boolean;
142
+ }; // @cast-boundary schema-walk
143
+ if ((annotCount > 0 || piiEncryptedFlag.piiEncrypted === true) && flags.sortable === true) {
139
144
  throw new Error(
140
- `[Feature ${feature.name}] Field "${fieldName}" on entity "${entityName}" combines a subject-key annotation or { piiEncrypted: true } with { ${offending}: true } — ${offending} on encrypted fields cannot work (ciphertext at rest). For equality lookups use { lookupable: true }; for search/sort the field must stay plaintext (allowPlaintext).`,
145
+ `[Feature ${feature.name}] Field "${fieldName}" on entity "${entityName}" combines a subject-key annotation or { piiEncrypted: true } with { sortable: true } — sorting reads the projection column, which is ciphertext at rest. For equality lookups use { lookupable: true }; drop sortable or keep the field plaintext (allowPlaintext).`,
146
+ );
147
+ }
148
+ if (flags.sensitive === true && flags.searchable === true) {
149
+ throw new Error(
150
+ `[Feature ${feature.name}] Field "${fieldName}" on entity "${entityName}" combines { sensitive: true } with { searchable: true } — sensitive means nobody may read the value back (passwords, tokens, tax IDs). Subject-annotated identity fields may be searchable (#1610); sensitive fields may not.`,
141
151
  );
142
152
  }
143
153
  }
@@ -0,0 +1,93 @@
1
+ // Per-feature changelog — each bundled feature has a `changes.json` that
2
+ // tracks breaking changes, improvements, and fixes per version. The CLI
3
+ // (`kumiko upgrade`) reads these to show apps what they need to migrate.
4
+ // All fields should be in English for consistency across the codebase.
5
+ //
6
+ // File I/O stays in the CLI (`bin/commands/upgrade.ts`) — this module is
7
+ // pure parse/validate so engine stays off the node:fs allowlist.
8
+
9
+ export type ChangelogType = "breaking" | "improvement" | "fix";
10
+
11
+ export type ChangelogEntry = {
12
+ readonly version: string;
13
+ readonly type: ChangelogType;
14
+ readonly title: string;
15
+ readonly detail?: string;
16
+ /** Required when type=breaking. Shown in `kumiko upgrade` output. */
17
+ readonly migration?: string;
18
+ };
19
+
20
+ export type FeatureChangelog = {
21
+ readonly feature: string;
22
+ readonly entries: readonly ChangelogEntry[];
23
+ };
24
+
25
+ /** Parse a changes.json body. Callers own file I/O. */
26
+ export function parseFeatureChangelog(raw: string, featureName: string): FeatureChangelog | null {
27
+ try {
28
+ const entries = JSON.parse(raw) as unknown;
29
+ if (!Array.isArray(entries)) return null;
30
+
31
+ const validated: ChangelogEntry[] = [];
32
+ for (const entry of entries) {
33
+ if (!isChangelogEntry(entry)) continue;
34
+ validated.push(entry);
35
+ }
36
+
37
+ return { feature: featureName, entries: validated };
38
+ } catch {
39
+ return null;
40
+ }
41
+ }
42
+
43
+ function isChangelogEntry(value: unknown): value is ChangelogEntry {
44
+ if (typeof value !== "object" || value === null) return false;
45
+ const obj = value as Record<string, unknown>;
46
+ if (typeof obj["version"] !== "string") return false;
47
+ if (!["breaking", "improvement", "fix"].includes(obj["type"] as string)) return false;
48
+ if (typeof obj["title"] !== "string") return false;
49
+ return true;
50
+ }
51
+
52
+ export function validateChangelog(entry: ChangelogEntry): string[] {
53
+ const errors: string[] = [];
54
+ if (entry.type === "breaking" && !entry.migration) {
55
+ errors.push(`breaking change "${entry.title}" missing migration field`);
56
+ }
57
+ if (entry.type === "breaking" && entry.migration?.trim() === "") {
58
+ errors.push(`breaking change "${entry.title}" has empty migration field`);
59
+ }
60
+ return errors;
61
+ }
62
+
63
+ export function compareVersions(a: string, b: string): number {
64
+ const pa = a.split(".").map(Number);
65
+ const pb = b.split(".").map(Number);
66
+ for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
67
+ const na = pa[i] ?? 0;
68
+ const nb = pb[i] ?? 0;
69
+ if (na > nb) return 1;
70
+ if (na < nb) return -1;
71
+ }
72
+ return 0;
73
+ }
74
+
75
+ export function filterEntriesAfter(
76
+ entries: readonly ChangelogEntry[],
77
+ version: string,
78
+ ): readonly ChangelogEntry[] {
79
+ return entries.filter((e) => compareVersions(e.version, version) > 0);
80
+ }
81
+
82
+ export function sortEntries(entries: readonly ChangelogEntry[]): readonly ChangelogEntry[] {
83
+ const order: Record<ChangelogType, number> = {
84
+ breaking: 0,
85
+ improvement: 1,
86
+ fix: 2,
87
+ };
88
+ return [...entries].sort((a, b) => {
89
+ const typeDiff = order[a.type] - order[b.type];
90
+ if (typeDiff !== 0) return typeDiff;
91
+ return compareVersions(b.version, a.version);
92
+ });
93
+ }
@@ -8,6 +8,7 @@
8
8
 
9
9
  import { compareByCodepoint } from "../utils";
10
10
  import { isEncryptedAtRest } from "./config-helpers";
11
+ import type { ChangelogEntry } from "./feature-changelog";
11
12
  import { qualifyEntityName } from "./qualified-name";
12
13
  import type { Registry, UiHints } from "./types/feature";
13
14
 
@@ -64,6 +65,9 @@ export type ManifestFeature = {
64
65
  readonly uiHints?: UiHints;
65
66
  /** Optionaler Herkunfts-Tag (z.B. "enterprise") — gesetzt via Options. */
66
67
  readonly tier?: string;
68
+ /** Per-feature changelog entries (from changes.json). Optional —
69
+ * absent when no changes.json exists or feature has no entries. */
70
+ readonly changelog?: readonly ChangelogEntry[];
67
71
  };
68
72
 
69
73
  export type FeatureManifest = {
@@ -163,6 +163,16 @@ export {
163
163
  replacePattern,
164
164
  VERSION_HEADER,
165
165
  } from "./feature-ast";
166
+ export {
167
+ type ChangelogEntry,
168
+ type ChangelogType,
169
+ compareVersions,
170
+ type FeatureChangelog,
171
+ filterEntriesAfter,
172
+ parseFeatureChangelog,
173
+ sortEntries,
174
+ validateChangelog,
175
+ } from "./feature-changelog";
166
176
  export {
167
177
  type BuildManifestOptions,
168
178
  buildManifestFromRegistry,
@@ -0,0 +1,18 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { schedulerIdForJobName } from "../job-runner";
3
+
4
+ describe("schedulerIdForJobName", () => {
5
+ test("strips dots and colons so BullMQ job ids stay under the 5-segment legacy heuristic", () => {
6
+ // Job id becomes repeat:<id>:<millis> — colons in <id> previously pushed
7
+ // the segment count to ≥5 and leaked a permanent hash per cron tick
8
+ // (fw#1603 / bullmq#3828).
9
+ const id = schedulerIdForJobName("publicstatus:job:uptime-probe");
10
+ expect(id).toBe("scheduler-publicstatus-job-uptime-probe");
11
+ expect(id.includes(":")).toBe(false);
12
+ expect(`repeat:${id}:1784992080000`.split(":").length).toBeLessThan(5);
13
+ });
14
+
15
+ test("still collapses dotted QNs", () => {
16
+ expect(schedulerIdForJobName("app.job.tick")).toBe("scheduler-app-job-tick");
17
+ });
18
+ });
package/src/jobs/index.ts CHANGED
@@ -1,2 +1,2 @@
1
1
  export type { JobLogEntry, JobMeta, JobRunner, JobRunnerOptions } from "./job-runner";
2
- export { createJobRunner } from "./job-runner";
2
+ export { createJobRunner, schedulerIdForJobName } from "./job-runner";
@@ -34,6 +34,22 @@ function queueNameFor(prefix: string, lane: JobRunIn): string {
34
34
  return `${prefix}-${lane}`;
35
35
  }
36
36
 
37
+ /**
38
+ * BullMQ job ids are `repeat:<schedulerId>:<millis>`. Colons inside the
39
+ * scheduler id push the segment count to ≥5, which BullMQ's legacy heuristic
40
+ * treated as old repeatables — spawning a new scheduler entry every tick and
41
+ * leaking permanent `repeat:*` hashes (taskforcesh/bullmq#3828, fw#1603 /
42
+ * publicstatus Redis OOM). Strip `.` and `:` from the job QN.
43
+ */
44
+ export function schedulerIdForJobName(jobName: string): string {
45
+ return `scheduler-${jobName.replace(/[.:]/g, "-")}`;
46
+ }
47
+
48
+ /** Pre-sanitize id (`.` only) — remove on boot so colon-form ghosts die. */
49
+ function legacySchedulerIdForJobName(jobName: string): string {
50
+ return `scheduler-${jobName.replace(/\./g, "-")}`;
51
+ }
52
+
37
53
  export type JobLogEntry = {
38
54
  level: "info" | "warn" | "error";
39
55
  message: string;
@@ -473,12 +489,26 @@ export function createJobRunner(options: JobRunnerOptions): JobRunner {
473
489
  for (const [name, jobDef] of allJobs) {
474
490
  if (laneForJob(jobDef) !== consumerLane) continue;
475
491
  if ("cron" in jobDef.trigger) {
492
+ const schedulerId = schedulerIdForJobName(name);
493
+ const legacyId = legacySchedulerIdForJobName(name);
494
+ // Drop pre-sanitize scheduler ids so colon-form ghosts stop firing.
495
+ if (legacyId !== schedulerId) {
496
+ try {
497
+ await consumerQueue.removeJobScheduler(legacyId);
498
+ } catch {
499
+ // skip: legacy scheduler absent (fresh install / already purged)
500
+ }
501
+ }
476
502
  await consumerQueue.upsertJobScheduler(
477
- `scheduler-${name.replace(/\./g, "-")}`,
503
+ schedulerId,
478
504
  { pattern: jobDef.trigger.cron },
479
505
  {
480
506
  name: jobDef.perTenant ? `_perTenant:${name}` : name,
481
507
  data: {},
508
+ opts: {
509
+ removeOnComplete: { count: 100 },
510
+ removeOnFail: { count: 50 },
511
+ },
482
512
  },
483
513
  );
484
514
  }
@@ -59,9 +59,12 @@ async function* executeStreamInner(
59
59
  const invalidated = new Promise<void>((resolve) => {
60
60
  resolveInvalidated = resolve;
61
61
  });
62
- const unsubscribeAccessInvalidation = ctx.sseBroker?.subscribeAccessInvalidation(user.id, () => {
63
- resolveInvalidated?.();
64
- });
62
+ const unsubscribeAccessInvalidation = ctx.sseBroker?.subscribeAccessInvalidation?.(
63
+ user.id,
64
+ () => {
65
+ resolveInvalidated?.();
66
+ },
67
+ );
65
68
 
66
69
  let iterator: AsyncIterator<unknown> | undefined;
67
70
  // When access is revoked mid-pull, `iterator.next()` is still in flight.
@@ -1,4 +1,11 @@
1
1
  import type { SseBroker } from "../api/sse-broker";
2
+ import {
3
+ collectSearchableSubjectFields,
4
+ configuredPiiSubjectKms,
5
+ decryptPiiFieldValues,
6
+ isPiiCiphertext,
7
+ PII_ERASED_SENTINEL,
8
+ } from "../crypto";
2
9
  import type { DbRow } from "../db/connection";
3
10
  import { tenantChannel } from "../engine/constants";
4
11
  import type { EntityId, JobRunnerRef, Registry, SessionUser } from "../engine/types";
@@ -53,10 +60,9 @@ export function createSearchEventConsumer(
53
60
  const verb = event.type.split(".").pop();
54
61
  const tenantId = event.tenantId;
55
62
 
56
- // skip: delete takes an early-return after removing the index entry —
57
- // the "reconstruct state" path below only makes sense for created/
58
- // updated/restored, which carry field data in the payload.
59
- if (verb === "deleted") {
63
+ // skip: delete/forgotten remove the index entry — reconstruct only
64
+ // makes sense for created/updated/restored (field data in payload).
65
+ if (verb === "deleted" || verb === "forgotten") {
60
66
  await searchAdapter.remove(tenantId, entityName, event.aggregateId);
61
67
  return;
62
68
  }
@@ -68,7 +74,13 @@ export function createSearchEventConsumer(
68
74
  return;
69
75
  }
70
76
 
71
- const state = reconstructStateForSearch(event.payload, verb);
77
+ let state = reconstructStateForSearch(event.payload, verb);
78
+ state = await decryptSearchableSubjectFields(entityName, state, registry);
79
+ // skip: erased subject — drop the doc so a rebuild cannot resurrect plaintext.
80
+ if (hasErasedSearchableSubjectField(entityName, state, registry)) {
81
+ await searchAdapter.remove(tenantId, entityName, event.aggregateId);
82
+ return;
83
+ }
72
84
  const doc = await buildSearchDocument(entityName, event.aggregateId, state, registry);
73
85
  if (!doc) {
74
86
  // skip: entity isn't searchable (no searchable fields declared)
@@ -79,6 +91,41 @@ export function createSearchEventConsumer(
79
91
  };
80
92
  }
81
93
 
94
+ // #1610 — subject-annotated searchable fields are ciphertext in the event
95
+ // payload; decrypt into the derived index only. No KMS → omit ciphertext
96
+ // values rather than indexing blobs.
97
+ async function decryptSearchableSubjectFields(
98
+ entityName: string,
99
+ state: Record<string, unknown>,
100
+ registry: Registry,
101
+ ): Promise<Record<string, unknown>> {
102
+ const entity = registry.getEntity(entityName);
103
+ if (!entity) return state;
104
+ const fields = collectSearchableSubjectFields(entity);
105
+ if (fields.length === 0) return state;
106
+ const kms = configuredPiiSubjectKms();
107
+ if (!kms) {
108
+ const out = { ...state };
109
+ for (const name of fields) {
110
+ if (isPiiCiphertext(out[name])) delete out[name];
111
+ }
112
+ return out;
113
+ }
114
+ return decryptPiiFieldValues(state, fields, kms, {
115
+ requestId: "system:consumer:search",
116
+ });
117
+ }
118
+
119
+ function hasErasedSearchableSubjectField(
120
+ entityName: string,
121
+ state: Record<string, unknown>,
122
+ registry: Registry,
123
+ ): boolean {
124
+ const entity = registry.getEntity(entityName);
125
+ if (!entity) return false;
126
+ return collectSearchableSubjectFields(entity).some((name) => state[name] === PII_ERASED_SENTINEL);
127
+ }
128
+
82
129
  // Rebuild the entity-state a search index needs from the event-payload alone.
83
130
  // Three shapes to handle — see event-store-executor.ts for the emitter side.
84
131
  function reconstructStateForSearch(
@@ -369,7 +416,7 @@ export function createAccessInvalidationEventConsumer(sseBroker: SseBroker): Eve
369
416
  // poison would otherwise permanently stop access-invalidation for
370
417
  // every user behind one bad row).
371
418
  if (typeof userId !== "string" || userId.length === 0) return;
372
- sseBroker.publishAccessInvalidation(userId);
419
+ sseBroker.publishAccessInvalidation?.(userId);
373
420
  }
374
421
 
375
422
  if (
@@ -380,7 +427,7 @@ export function createAccessInvalidationEventConsumer(sseBroker: SseBroker): Eve
380
427
  // skip: previous snapshot missing/malformed userId — same fail-open
381
428
  // reasoning as above.
382
429
  if (userId === undefined) return;
383
- sseBroker.publishAccessInvalidation(userId);
430
+ sseBroker.publishAccessInvalidation?.(userId);
384
431
  }
385
432
  },
386
433
  };
package/src/schema-cli.ts CHANGED
@@ -273,11 +273,10 @@ export async function runSchemaCli(
273
273
 
274
274
  // 3. Migration-content drift — replay the committed *.sql files and
275
275
  // diff the reconstructed schema against .snapshot.json.
276
- const committedSnapshot = existsSync(snapshotPath) ? loadSnapshotJson(snapshotPath) : null;
277
- if (existsSync(migrationsDir) && committedSnapshot !== null) {
276
+ if (existsSync(migrationsDir) && prevSnapshot !== null) {
278
277
  try {
279
278
  const replayed = replayMigrationsDir(migrationsDir);
280
- const mismatches = diffReplayAgainstSnapshot(replayed, committedSnapshot);
279
+ const mismatches = diffReplayAgainstSnapshot(replayed, prevSnapshot);
281
280
  if (mismatches.length === 0) {
282
281
  out.log(" ✓ migrations: table/column names match .snapshot.json");
283
282
  } else {
@@ -0,0 +1,164 @@
1
+ // fw#1610 — subject-annotated searchable fields: ciphertext in events,
2
+ // plaintext in derived search index, purged on subject erase.
3
+
4
+ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from "bun:test";
5
+ import {
6
+ configurePiiSubjectKms,
7
+ InMemoryKmsAdapter,
8
+ isPiiCiphertext,
9
+ resetPiiSubjectKmsForTests,
10
+ subjectIdToKey,
11
+ } from "../../crypto";
12
+ import { asRawClient, buildEntityTable, createEventStoreExecutor, createTenantDb } from "../../db";
13
+ import { createEntity, createTextField, defineFeature } from "../../engine";
14
+ import { createEventsTable } from "../../event-store";
15
+ import { setupTestStack, type TestStack, TestUsers, unsafeCreateEntityTable } from "../../stack";
16
+ import { purgeSearchDocumentsForSubject } from "../purge-subject";
17
+
18
+ const contactEntity = createEntity({
19
+ table: "read_search_pii_contacts",
20
+ fields: {
21
+ // pii: true → subject = entity id (self). searchable via derived index.
22
+ label: createTextField({ required: true, maxLength: 100, pii: true, searchable: true }),
23
+ note: createTextField({ required: true, maxLength: 100, searchable: true }),
24
+ },
25
+ });
26
+
27
+ const contactTable = buildEntityTable("contact", contactEntity);
28
+
29
+ const contactFeature = defineFeature("search-pii-probe", (r) => {
30
+ r.entity("contact", contactEntity);
31
+ });
32
+
33
+ let stack: TestStack;
34
+ let kms: InMemoryKmsAdapter;
35
+ const admin = TestUsers.admin;
36
+
37
+ beforeAll(async () => {
38
+ stack = await setupTestStack({ features: [contactFeature] });
39
+ await unsafeCreateEntityTable(stack.db, contactEntity, "contact");
40
+ await createEventsTable(stack.db);
41
+ });
42
+
43
+ afterAll(async () => {
44
+ await stack.cleanup();
45
+ });
46
+
47
+ beforeEach(() => {
48
+ kms = new InMemoryKmsAdapter();
49
+ configurePiiSubjectKms(kms);
50
+ });
51
+
52
+ afterEach(() => {
53
+ resetPiiSubjectKmsForTests();
54
+ });
55
+
56
+ function executor() {
57
+ return createEventStoreExecutor(contactTable, contactEntity, {
58
+ entityName: "contact",
59
+ searchAdapter: stack.search,
60
+ });
61
+ }
62
+
63
+ function tenantDb() {
64
+ return createTenantDb(stack.db, admin.tenantId, "system");
65
+ }
66
+
67
+ describe("searchable PII derived index (#1610)", () => {
68
+ test("create indexes plaintext; event payload stays ciphertext; erase purges search", async () => {
69
+ const plain = "UniqueSearchPiiLabel1610";
70
+ const created = await executor().create(
71
+ { label: plain, note: "public-note" },
72
+ admin,
73
+ tenantDb(),
74
+ );
75
+ if (!created.isSuccess) throw new Error("create failed");
76
+ const id = String(created.data.id);
77
+
78
+ const events = await asRawClient(stack.db).unsafe(
79
+ `SELECT payload FROM kumiko_events WHERE aggregate_id = $1 AND type = 'contact.created' LIMIT 1`,
80
+ [id],
81
+ );
82
+ const payload = (events as { payload: Record<string, unknown> }[])[0]?.payload;
83
+ expect(isPiiCiphertext(payload?.["label"])).toBe(true);
84
+ expect(payload?.["label"]).not.toBe(plain);
85
+
86
+ await stack.eventDispatcher?.runOnce();
87
+
88
+ const hits = await stack.search.search(admin.tenantId, plain, { filterType: "contact" });
89
+ expect(hits.some((h) => String(h.entityId) === id)).toBe(true);
90
+
91
+ // pii: true → subject key is the entity id itself.
92
+ const subject = { kind: "user" as const, userId: id };
93
+ await kms.eraseKey(subject);
94
+ await purgeSearchDocumentsForSubject(
95
+ stack.db,
96
+ stack.registry.features,
97
+ stack.search,
98
+ subjectIdToKey(subject),
99
+ subject,
100
+ );
101
+
102
+ const after = await stack.search.search(admin.tenantId, plain, { filterType: "contact" });
103
+ expect(after.some((h) => String(h.entityId) === id)).toBe(false);
104
+ });
105
+
106
+ test("consumer treats erased decrypt as remove (no sentinel index)", async () => {
107
+ const plain = "SentinelRebuildLabel1610";
108
+ const created = await executor().create({ label: plain, note: "x" }, admin, tenantDb());
109
+ if (!created.isSuccess) throw new Error("create failed");
110
+ const id = String(created.data.id);
111
+
112
+ await stack.eventDispatcher?.runOnce();
113
+ expect(
114
+ (await stack.search.search(admin.tenantId, plain, { filterType: "contact" })).some(
115
+ (h) => String(h.entityId) === id,
116
+ ),
117
+ ).toBe(true);
118
+
119
+ await kms.eraseKey({ kind: "user", userId: id });
120
+ // Force re-index path by updating a non-PII field — consumer decrypts
121
+ // label → [[erased]] → remove.
122
+ const updated = await executor().update(
123
+ { id: created.data.id, changes: { note: "y" } },
124
+ admin,
125
+ tenantDb(),
126
+ { skipOptimisticLock: true },
127
+ );
128
+ if (!updated.isSuccess) throw new Error("update failed");
129
+ await stack.eventDispatcher?.runOnce();
130
+
131
+ const after = await stack.search.search(admin.tenantId, plain, { filterType: "contact" });
132
+ expect(after.some((h) => String(h.entityId) === id)).toBe(false);
133
+ });
134
+ test("purge finds rows after anonymize rewrote ciphertext (#1610 bugbot)", async () => {
135
+ const plain = "AnonymizedStillPurge1610";
136
+ const created = await executor().create({ label: plain, note: "n" }, admin, tenantDb());
137
+ if (!created.isSuccess) throw new Error("create failed");
138
+ const id = String(created.data.id);
139
+ await stack.eventDispatcher?.runOnce();
140
+ expect(
141
+ (await stack.search.search(admin.tenantId, plain, { filterType: "contact" })).some(
142
+ (h) => String(h.entityId) === id,
143
+ ),
144
+ ).toBe(true);
145
+
146
+ // Simulate forget-cleanup anonymize: overwrite searchable PII with plaintext.
147
+ await asRawClient(stack.db).unsafe(
148
+ `UPDATE read_search_pii_contacts SET label = $1 WHERE id = $2`,
149
+ ["[[erased]]", id],
150
+ );
151
+
152
+ const subject = { kind: "user" as const, userId: id };
153
+ await purgeSearchDocumentsForSubject(
154
+ stack.db,
155
+ stack.registry.features,
156
+ stack.search,
157
+ subjectIdToKey(subject),
158
+ subject,
159
+ );
160
+
161
+ const after = await stack.search.search(admin.tenantId, plain, { filterType: "contact" });
162
+ expect(after.some((h) => String(h.entityId) === id)).toBe(false);
163
+ });
164
+ });
@@ -3,6 +3,7 @@
3
3
  // von SearchAdapter-Types. Apps die Meilisearch nicht nutzen, ziehen den
4
4
  // Client-Code nicht mit rein.
5
5
  export { createInMemorySearchAdapter } from "./in-memory-adapter";
6
+ export { purgeSearchDocumentsForSubject } from "./purge-subject";
6
7
  export type {
7
8
  ReindexEntityFailure,
8
9
  ReindexEntityOptions,
@@ -0,0 +1,135 @@
1
+ // Purge derived search documents for an erased PII subject (#1610).
2
+ //
3
+ // After kms.eraseKey the projection/event ciphertext is unreadable, but Meili
4
+ // still holds the plaintext that createSearchEventConsumer decrypted into the
5
+ // index. Discovery is dual-path:
6
+ // 1. Ownership: pii self-id / userOwned.ownerField / tenantOwned.tenantId
7
+ // (survives anonymize hooks that overwrite ciphertext with plaintext).
8
+ // 2. Ciphertext LIKE prefix (same as nullBlindIndexesForSubject) for rows
9
+ // that still carry the subject key in encrypted columns.
10
+
11
+ import type { SubjectId } from "../crypto/kms-adapter";
12
+ import { collectSearchableSubjectFields } from "../crypto/subject-resolver";
13
+ import type { DbRunner } from "../db/connection";
14
+ import { resolveTableName } from "../db/entity-table-meta";
15
+ import { executeRawQuery } from "../db/queries/raw-sql";
16
+ import type { FeatureDefinition } from "../engine/types";
17
+ import type { EntityDefinition } from "../engine/types/fields";
18
+ import type { EntityId, TenantId } from "../engine/types/identifiers";
19
+ import { toSnakeCase } from "../utils/case";
20
+ import type { SearchAdapter } from "./types";
21
+
22
+ function quoteIdent(name: string): string {
23
+ return `"${name.replace(/"/g, '""')}"`;
24
+ }
25
+
26
+ function escapeLikePattern(value: string): string {
27
+ return value.replace(/[\\%_]/g, (m) => `\\${m}`);
28
+ }
29
+
30
+ /** Build OR predicates for rows owned by `subject` (id / ownerField / tenant_id). */
31
+ function ownershipPredicates(
32
+ entity: EntityDefinition,
33
+ searchableFields: readonly string[],
34
+ subject: SubjectId,
35
+ nextParam: () => number,
36
+ ): { sql: string; params: unknown[] } | null {
37
+ const parts: string[] = [];
38
+ const params: unknown[] = [];
39
+ let selfIdN: number | undefined;
40
+ let tenantIdN: number | undefined;
41
+ const ownerFieldN = new Map<string, number>();
42
+
43
+ for (const fieldName of searchableFields) {
44
+ const field = entity.fields[fieldName];
45
+ if (!field) continue;
46
+ if (subject.kind === "user") {
47
+ if ("userOwned" in field && field.userOwned !== undefined) {
48
+ const col = toSnakeCase(field.userOwned.ownerField);
49
+ let n = ownerFieldN.get(col);
50
+ if (n === undefined) {
51
+ n = nextParam();
52
+ ownerFieldN.set(col, n);
53
+ params.push(subject.userId);
54
+ parts.push(`${quoteIdent(col)} = $${n}`);
55
+ }
56
+ } else if ("pii" in field && field.pii === true && selfIdN === undefined) {
57
+ selfIdN = nextParam();
58
+ params.push(subject.userId);
59
+ parts.push(`${quoteIdent("id")} = $${selfIdN}`);
60
+ }
61
+ } else if ("tenantOwned" in field && field.tenantOwned === true && tenantIdN === undefined) {
62
+ tenantIdN = nextParam();
63
+ params.push(subject.tenantId);
64
+ parts.push(`${quoteIdent("tenant_id")} = $${tenantIdN}`);
65
+ }
66
+ }
67
+ if (parts.length === 0) return null;
68
+ return { sql: parts.join(" OR "), params };
69
+ }
70
+
71
+ export async function purgeSearchDocumentsForSubject(
72
+ db: DbRunner,
73
+ features: ReadonlyMap<string, FeatureDefinition>,
74
+ search: SearchAdapter,
75
+ subjectKey: string,
76
+ /** When set, also match rows by ownership — needed after anonymize rewrites ciphertext. */
77
+ subject?: SubjectId,
78
+ ): Promise<void> {
79
+ const likePattern = `kumiko-pii:v%:${escapeLikePattern(subjectKey)}:%`;
80
+ const byTenant = new Map<string, { entityType: string; entityId: EntityId }[]>();
81
+ const seen = new Set<string>();
82
+
83
+ for (const feature of features.values()) {
84
+ for (const [entityName, entity] of Object.entries(feature.entities ?? {})) {
85
+ const fields = collectSearchableSubjectFields(entity);
86
+ if (fields.length === 0) continue;
87
+ const tableName = resolveTableName(entityName, entity, undefined);
88
+
89
+ let paramIdx = 0;
90
+ const nextParam = () => ++paramIdx;
91
+ const params: unknown[] = [];
92
+ const orParts: string[] = [];
93
+
94
+ const likeN = nextParam();
95
+ params.push(likePattern);
96
+ orParts.push(
97
+ `(${fields.map((f) => `${quoteIdent(toSnakeCase(f))} LIKE $${likeN}`).join(" OR ")})`,
98
+ );
99
+
100
+ if (subject) {
101
+ const owned = ownershipPredicates(entity, fields, subject, nextParam);
102
+ if (owned) {
103
+ params.push(...owned.params);
104
+ orParts.push(`(${owned.sql})`);
105
+ }
106
+ }
107
+
108
+ const rows = await executeRawQuery<{ id: string; tenant_id: string }>(
109
+ db,
110
+ `SELECT id, tenant_id FROM ${quoteIdent(tableName)} WHERE ${orParts.join(" OR ")}`,
111
+ params,
112
+ );
113
+ for (const row of rows) {
114
+ const key = `${row.tenant_id}:${entityName}:${row.id}`;
115
+ if (seen.has(key)) continue;
116
+ seen.add(key);
117
+ const list = byTenant.get(row.tenant_id) ?? [];
118
+ list.push({ entityType: entityName, entityId: row.id as EntityId });
119
+ byTenant.set(row.tenant_id, list);
120
+ }
121
+ }
122
+ }
123
+
124
+ for (const [tenantId, items] of byTenant) {
125
+ if (items.length === 0) continue;
126
+ const tid = tenantId as TenantId;
127
+ if (search.removeBatch) {
128
+ await search.removeBatch(tid, items);
129
+ } else {
130
+ for (const item of items) {
131
+ await search.remove(tid, item.entityType, item.entityId);
132
+ }
133
+ }
134
+ }
135
+ }
@@ -2,15 +2,18 @@ import { describe, expect, test } from "bun:test";
2
2
  import { waitFor } from "../wait-for";
3
3
 
4
4
  describe("waitFor", () => {
5
- test("returns immediately once fn succeeds on the first attempt", async () => {
5
+ test("calls fn exactly once when it passes on the first attempt (no prior sleep)", async () => {
6
6
  let calls = 0;
7
+ const started = Date.now();
7
8
  await waitFor(
8
9
  () => {
9
10
  calls++;
10
11
  },
11
- { delays: [1, 1, 1] },
12
+ { delays: [200, 200, 200] },
12
13
  );
13
14
  expect(calls).toBe(1);
15
+ // try-first: must not burn the first delay when the condition already holds
16
+ expect(Date.now() - started).toBeLessThan(100);
14
17
  });
15
18
 
16
19
  test("retries on failure and succeeds once fn passes", async () => {
@@ -35,8 +38,9 @@ describe("waitFor", () => {
35
38
  },
36
39
  { delays: [1, 1] },
37
40
  ),
38
- ).rejects.toThrow("fail-2");
39
- expect(calls).toBe(2);
41
+ ).rejects.toThrow("fail-3");
42
+ // N delays → N+1 attempts (final try after the last backoff)
43
+ expect(calls).toBe(3);
40
44
  });
41
45
 
42
46
  test("throws a descriptive error for an empty delay schedule", async () => {
@@ -1,8 +1,10 @@
1
1
  /**
2
2
  * Polls a condition with escalating timeouts.
3
3
  *
4
- * Default schedule: 250ms → 1s → 3s (3 attempts).
5
- * Returns immediately on success. Throws the last assertion error if all attempts fail.
4
+ * Default schedule: 250ms → 1s → 3s between attempts. Tries first (already-true
5
+ * returns immediately), then sleeps `delays[i]` after each failure before the
6
+ * next try — so N delays yield N+1 attempts and the full backoff budget.
7
+ * Throws the last assertion error if all attempts fail.
6
8
  *
7
9
  * Usage:
8
10
  * await waitFor(() => {
@@ -19,15 +21,18 @@ export async function waitFor(
19
21
  }
20
22
  let lastError: unknown;
21
23
 
22
- for (let i = 0; i < delays.length; i++) {
23
- await new Promise((r) => setTimeout(r, delays[i]));
24
+ for (let i = 0; ; i++) {
24
25
  try {
25
26
  await fn();
26
- // skip: retry attempt succeeded, no further polling needed
27
+ // skip: condition already true no further polling
27
28
  return;
28
29
  } catch (err) {
29
30
  lastError = err;
30
31
  }
32
+ if (i >= delays.length) {
33
+ break;
34
+ }
35
+ await new Promise((r) => setTimeout(r, delays[i]));
31
36
  }
32
37
 
33
38
  throw lastError;