@cosmicdrift/kumiko-framework 0.288.0 → 0.289.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 +4 -4
- package/src/changes.json +57 -0
- package/src/compliance/__tests__/sub-processors.test.ts +10 -0
- package/src/compliance/sub-processors.ts +14 -1
- package/src/crypto/__tests__/kek-source.test.ts +298 -0
- package/src/crypto/index.ts +3 -0
- package/src/crypto/kek-source.ts +189 -0
- package/src/crypto/kms-wiring.ts +20 -0
- package/src/db/__tests__/event-store-executor-write-verbs.integration.test.ts +191 -0
- package/src/db/__tests__/tenant-db-declared-unsafe-raw.test.ts +60 -1
- package/src/db/event-store-executor-context.ts +79 -0
- package/src/db/event-store-executor-write.ts +42 -5
- package/src/db/index.ts +1 -0
- package/src/db/tenant-db.ts +8 -1
- package/src/engine/__tests__/boot-validator-i18n-keys.test.ts +56 -0
- package/src/engine/__tests__/required-surface-keys.test.ts +196 -0
- package/src/engine/boot-validator/__tests__/anonymous-rate-limit-required.test.ts +63 -0
- package/src/engine/boot-validator/entity-handler.ts +10 -4
- package/src/engine/extensions/storage-provider.ts +17 -2
- package/src/engine/extensions/tenant-data.ts +9 -0
- package/src/engine/factories.ts +2 -0
- package/src/engine/screen-helpers.ts +17 -0
- package/src/errors/classes.ts +24 -0
- package/src/errors/index.ts +3 -0
- package/src/errors/member-resolution.ts +12 -0
- package/src/event-store/__tests__/provenance-append.integration.test.ts +33 -1
- package/src/event-store/provenance-append.ts +9 -1
- package/src/files/__tests__/file-handle.test.ts +28 -1
- package/src/files/file-handle.ts +26 -7
- package/src/files/index.ts +1 -1
- package/src/i18n/required-surface-keys.ts +24 -12
- package/src/pipeline/__tests__/member-resolution-read-only.test.ts +79 -0
- package/src/pipeline/dispatch-shared.ts +14 -13
- package/src/pipeline/dispatch-stream.ts +8 -3
- package/src/pipeline/dispatch-write.ts +3 -2
- package/src/pipeline/event-dispatcher.ts +18 -5
- package/src/pipeline/member-read-only-transaction.ts +2 -6
- package/src/stack/table-helpers.ts +6 -0
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { isExplicitDotFormKey } from "../engine/i18n-key";
|
|
2
|
+
import type { FieldsOrGroupsSection } from "../engine/screen-helpers";
|
|
2
3
|
import {
|
|
3
4
|
isExtensionEditSection,
|
|
4
5
|
isWriteFormEditSection,
|
|
5
6
|
normalizeListColumn,
|
|
7
|
+
sectionFieldSpecs,
|
|
6
8
|
} from "../engine/screen-helpers";
|
|
7
9
|
import type {
|
|
8
10
|
ActionFormScreenDefinition,
|
|
@@ -82,6 +84,16 @@ function editFieldName(f: string | { readonly field: string }): string {
|
|
|
82
84
|
return typeof f === "string" ? f : f.field;
|
|
83
85
|
}
|
|
84
86
|
|
|
87
|
+
// Group titles are translated exactly like the section title (computeEditViewModel).
|
|
88
|
+
function pushSectionTitles(
|
|
89
|
+
out: Set<string>,
|
|
90
|
+
section: { readonly title?: string } & FieldsOrGroupsSection,
|
|
91
|
+
treatAsKey = false,
|
|
92
|
+
): void {
|
|
93
|
+
pushKey(out, section.title, treatAsKey);
|
|
94
|
+
for (const group of section.groups ?? []) pushKey(out, group.title, treatAsKey);
|
|
95
|
+
}
|
|
96
|
+
|
|
85
97
|
function pushRowActionKeys(out: Set<string>, action: RowAction): void {
|
|
86
98
|
pushKey(out, action.label);
|
|
87
99
|
if (action.kind === "writeHandler" || action.kind === undefined) {
|
|
@@ -182,8 +194,8 @@ export function requiredKeysFromScreen(
|
|
|
182
194
|
continue;
|
|
183
195
|
}
|
|
184
196
|
if (section.kind === "relatedList") continue; // rejected at boot, unreachable here
|
|
185
|
-
|
|
186
|
-
for (const f of section
|
|
197
|
+
pushSectionTitles(out, section);
|
|
198
|
+
for (const f of sectionFieldSpecs(section)) {
|
|
187
199
|
const fieldName = editFieldName(f);
|
|
188
200
|
const override = edit.fieldLabels?.[fieldName];
|
|
189
201
|
if (override !== undefined) pushKey(out, override);
|
|
@@ -206,8 +218,8 @@ export function requiredKeysFromScreen(
|
|
|
206
218
|
continue;
|
|
207
219
|
}
|
|
208
220
|
if (section.kind === "relatedList") continue; // rejected at boot, unreachable here
|
|
209
|
-
|
|
210
|
-
for (const f of section
|
|
221
|
+
pushSectionTitles(out, section);
|
|
222
|
+
for (const f of sectionFieldSpecs(section)) {
|
|
211
223
|
const fieldName = editFieldName(f);
|
|
212
224
|
const override = form.fieldLabels?.[fieldName];
|
|
213
225
|
if (override !== undefined) pushKey(out, override);
|
|
@@ -230,8 +242,8 @@ export function requiredKeysFromScreen(
|
|
|
230
242
|
continue;
|
|
231
243
|
}
|
|
232
244
|
if (section.kind === "relatedList") continue; // rejected at boot, unreachable here
|
|
233
|
-
|
|
234
|
-
for (const f of section
|
|
245
|
+
pushSectionTitles(out, section);
|
|
246
|
+
for (const f of sectionFieldSpecs(section)) {
|
|
235
247
|
const fieldName = editFieldName(f);
|
|
236
248
|
out.add(fieldLabelKey(featureName, ACTION_FORM_ENTITY, fieldName));
|
|
237
249
|
}
|
|
@@ -254,8 +266,8 @@ export function requiredKeysFromScreen(
|
|
|
254
266
|
continue;
|
|
255
267
|
}
|
|
256
268
|
if (section.kind === "relatedList") continue; // rejected at boot, unreachable here
|
|
257
|
-
|
|
258
|
-
for (const f of section
|
|
269
|
+
pushSectionTitles(out, section);
|
|
270
|
+
for (const f of sectionFieldSpecs(section)) {
|
|
259
271
|
const fieldName = editFieldName(f);
|
|
260
272
|
out.add(fieldLabelKey(featureName, ACTION_FORM_ENTITY, fieldName));
|
|
261
273
|
}
|
|
@@ -277,8 +289,8 @@ export function requiredKeysFromScreen(
|
|
|
277
289
|
continue;
|
|
278
290
|
}
|
|
279
291
|
if (section.kind === "relatedList") continue; // rejected at boot, unreachable here
|
|
280
|
-
|
|
281
|
-
for (const f of section
|
|
292
|
+
pushSectionTitles(out, section, treatDotFormAsKey);
|
|
293
|
+
for (const f of sectionFieldSpecs(section)) {
|
|
282
294
|
const fieldName = editFieldName(f);
|
|
283
295
|
const override = config.fieldLabels?.[fieldName];
|
|
284
296
|
if (override !== undefined) pushKey(out, override, treatDotFormAsKey);
|
|
@@ -322,8 +334,8 @@ export function requiredKeysFromScreen(
|
|
|
322
334
|
}
|
|
323
335
|
continue;
|
|
324
336
|
}
|
|
325
|
-
|
|
326
|
-
for (const f of section
|
|
337
|
+
pushSectionTitles(out, section);
|
|
338
|
+
for (const f of sectionFieldSpecs(section)) {
|
|
327
339
|
const fieldName = editFieldName(f);
|
|
328
340
|
const override = detail.fieldLabels?.[fieldName];
|
|
329
341
|
if (override !== undefined) pushKey(out, override);
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// createUncheckedSystemDb's grantedUnsafeRawRunner (db/tenant-db.ts) hands out a
|
|
2
|
+
// raw runner without a grant check. Under a resolved member principal that door
|
|
3
|
+
// is closed only because applyMemberResolutionReadOnly drops systemDb and
|
|
4
|
+
// dbOutsideTransaction — this pins that, plus the write surfaces it neutralizes.
|
|
5
|
+
import { describe, expect, test } from "bun:test";
|
|
6
|
+
import type { HandlerContext } from "../../engine/types";
|
|
7
|
+
import { AccessDeniedError, FrameworkReasons } from "../../errors";
|
|
8
|
+
import { applyMemberResolutionReadOnly } from "../dispatch-shared";
|
|
9
|
+
|
|
10
|
+
const DROPPED_SURFACES = ["systemDb", "dbOutsideTransaction", "runPreSave", "files", "derivatives"];
|
|
11
|
+
|
|
12
|
+
const DENIED_CALLS = [
|
|
13
|
+
"write",
|
|
14
|
+
"writeAs",
|
|
15
|
+
"queryAs",
|
|
16
|
+
"appendEvent",
|
|
17
|
+
"unsafeAppendEvent",
|
|
18
|
+
"tryAppendEvent",
|
|
19
|
+
"fetchForWriting",
|
|
20
|
+
"archiveStream",
|
|
21
|
+
"restoreStream",
|
|
22
|
+
"snapshotAggregate",
|
|
23
|
+
"queryAsMember",
|
|
24
|
+
"resolveActiveMembership",
|
|
25
|
+
"notify",
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
function readOnlyContext(): Record<string, unknown> {
|
|
29
|
+
const wired = Object.fromEntries(
|
|
30
|
+
[...DROPPED_SURFACES, ...DENIED_CALLS, "scheduleAfterCommit"].map((key) => [
|
|
31
|
+
key,
|
|
32
|
+
() => "escalated",
|
|
33
|
+
]),
|
|
34
|
+
);
|
|
35
|
+
// @cast-boundary test stub — applyMemberResolutionReadOnly only spreads and overwrites.
|
|
36
|
+
const ctx = { ...wired, jobRunner: { handleEvent: () => "ran" } } as unknown as HandlerContext;
|
|
37
|
+
return applyMemberResolutionReadOnly(ctx) as unknown as Record<string, unknown>;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function surface(readOnly: Record<string, unknown>, key: string): unknown {
|
|
41
|
+
return readOnly[key];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
describe("applyMemberResolutionReadOnly", () => {
|
|
45
|
+
test("drops every surface that could reach an unchecked system-scope db", () => {
|
|
46
|
+
const readOnly = readOnlyContext();
|
|
47
|
+
|
|
48
|
+
for (const key of DROPPED_SURFACES) {
|
|
49
|
+
expect(surface(readOnly, key)).toBeUndefined();
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test.each(DENIED_CALLS)("%s rejects with the member-resolution reason", async (call) => {
|
|
54
|
+
// @cast-boundary test stub — every denied surface is a call returning a promise.
|
|
55
|
+
const denied = surface(readOnlyContext(), call) as () => Promise<unknown>;
|
|
56
|
+
|
|
57
|
+
const error = await denied().then(
|
|
58
|
+
() => undefined,
|
|
59
|
+
(e: unknown) => e,
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
expect(error).toBeInstanceOf(AccessDeniedError);
|
|
63
|
+
// @cast-boundary narrowed by the assertion above.
|
|
64
|
+
expect((error as AccessDeniedError).details).toMatchObject({
|
|
65
|
+
reason: FrameworkReasons.memberResolutionReadOnly,
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("scheduleAfterCommit and the jobRunner proxy throw synchronously", () => {
|
|
70
|
+
const readOnly = readOnlyContext();
|
|
71
|
+
// @cast-boundary test stub — sync surfaces, not promise-returning.
|
|
72
|
+
const scheduleAfterCommit = surface(readOnly, "scheduleAfterCommit") as () => void;
|
|
73
|
+
// @cast-boundary test stub — denyingJobRunnerProxy throws on any property read.
|
|
74
|
+
const jobRunner = surface(readOnly, "jobRunner") as { handleEvent: unknown };
|
|
75
|
+
|
|
76
|
+
expect(() => scheduleAfterCommit()).toThrow(AccessDeniedError);
|
|
77
|
+
expect(() => jobRunner.handleEvent).toThrow(AccessDeniedError);
|
|
78
|
+
});
|
|
79
|
+
});
|
|
@@ -31,10 +31,9 @@ import type {
|
|
|
31
31
|
import { isRateLimitDisabled } from "../engine/types";
|
|
32
32
|
import type { TenantId } from "../engine/types/identifiers";
|
|
33
33
|
import {
|
|
34
|
-
AccessDeniedError,
|
|
35
34
|
FeatureDisabledError,
|
|
36
|
-
FrameworkReasons,
|
|
37
35
|
InternalError,
|
|
36
|
+
memberResolutionReadOnlyDenied,
|
|
38
37
|
VersionConflictError,
|
|
39
38
|
type WriteErrorInfo,
|
|
40
39
|
} from "../errors";
|
|
@@ -176,7 +175,7 @@ async function appendDomainEvent(
|
|
|
176
175
|
callerFeature: string | undefined,
|
|
177
176
|
): Promise<void> {
|
|
178
177
|
// Sink behind every append surface, so a resolved member stays read-only even via fetchForWriting handles.
|
|
179
|
-
if (user
|
|
178
|
+
if (isMemberResolutionPrincipal(user)) throw memberResolutionReadOnlyDenied();
|
|
180
179
|
const { registry } = ctx;
|
|
181
180
|
const dbSource = resolveDbSource(ctx, tx);
|
|
182
181
|
if (!dbSource) {
|
|
@@ -222,12 +221,8 @@ function createSystemScopedDbGuard(
|
|
|
222
221
|
});
|
|
223
222
|
}
|
|
224
223
|
|
|
225
|
-
export function
|
|
226
|
-
return
|
|
227
|
-
message: "a resolved member principal (ctx.queryAsMember) cannot write — read-only",
|
|
228
|
-
details: { reason: FrameworkReasons.memberResolutionReadOnly },
|
|
229
|
-
...(cause instanceof Error && { cause }),
|
|
230
|
-
});
|
|
224
|
+
export function isMemberResolutionPrincipal(user: SessionUser): boolean {
|
|
225
|
+
return user.origin === "member-resolution";
|
|
231
226
|
}
|
|
232
227
|
|
|
233
228
|
async function denyMemberResolutionWrite(): Promise<never> {
|
|
@@ -246,10 +241,11 @@ function denyingJobRunnerProxy(): JobRunnerRef {
|
|
|
246
241
|
|
|
247
242
|
// Every write/side-effect surface throws, so "no writeAsMember" holds even
|
|
248
243
|
// if a handler tries ctx.write directly on a resolved member principal.
|
|
249
|
-
function applyMemberResolutionReadOnly(handlerContext: HandlerContext): HandlerContext {
|
|
244
|
+
export function applyMemberResolutionReadOnly(handlerContext: HandlerContext): HandlerContext {
|
|
250
245
|
return {
|
|
251
246
|
...handlerContext,
|
|
252
|
-
// `db` stays open — executeQuery runs the whole handler in a Postgres READ ONLY
|
|
247
|
+
// `db` stays open — executeQuery runs the whole handler in a Postgres READ ONLY
|
|
248
|
+
// transaction, and its `memberReadOnly` grant denies every raw-runner handout.
|
|
253
249
|
dbOutsideTransaction: undefined,
|
|
254
250
|
write: denyMemberResolutionWrite,
|
|
255
251
|
writeAs: denyMemberResolutionWrite,
|
|
@@ -319,7 +315,12 @@ export async function buildHandlerContext(
|
|
|
319
315
|
context.tracer,
|
|
320
316
|
context.meter,
|
|
321
317
|
signal,
|
|
322
|
-
{
|
|
318
|
+
{
|
|
319
|
+
globalWrites: writeEscapeHatch,
|
|
320
|
+
unsafeRaw: handlerEscapeHatch,
|
|
321
|
+
report: reportEscapeHatch,
|
|
322
|
+
memberReadOnly: isMemberResolutionPrincipal(user),
|
|
323
|
+
},
|
|
323
324
|
);
|
|
324
325
|
// Propagate the request's AbortSignal so every TenantDb query throws when
|
|
325
326
|
// the client has disconnected — handlers with many sequential queries skip
|
|
@@ -865,7 +866,7 @@ export async function buildHandlerContext(
|
|
|
865
866
|
} as HandlerContext; // @cast-boundary engine-bridge
|
|
866
867
|
|
|
867
868
|
// A resolved member principal is read-only by construction.
|
|
868
|
-
return user
|
|
869
|
+
return isMemberResolutionPrincipal(user)
|
|
869
870
|
? applyMemberResolutionReadOnly(handlerContext)
|
|
870
871
|
: handlerContext;
|
|
871
872
|
}
|
|
@@ -1,13 +1,18 @@
|
|
|
1
1
|
import { hasAccess } from "../engine/access";
|
|
2
2
|
import type { SessionUser } from "../engine/types";
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
AccessDeniedError,
|
|
5
|
+
memberResolutionReadOnlyDenied,
|
|
6
|
+
NotFoundError,
|
|
7
|
+
validationErrorFromZod,
|
|
8
|
+
} from "../errors";
|
|
4
9
|
import { assertNoSecretLeak } from "../secrets";
|
|
5
10
|
import {
|
|
6
11
|
buildHandlerContext,
|
|
7
12
|
type DispatchContext,
|
|
8
13
|
enforceRateLimit,
|
|
9
14
|
ensureFeatureEnabled,
|
|
10
|
-
|
|
15
|
+
isMemberResolutionPrincipal,
|
|
11
16
|
runStreamInstrumented,
|
|
12
17
|
} from "./dispatch-shared";
|
|
13
18
|
|
|
@@ -38,7 +43,7 @@ async function* executeStreamInner(
|
|
|
38
43
|
|
|
39
44
|
// A resolved member principal (ctx.queryAsMember) is read-only — streams
|
|
40
45
|
// are excluded the same way executeWriteInner excludes writes.
|
|
41
|
-
if (user
|
|
46
|
+
if (isMemberResolutionPrincipal(user)) {
|
|
42
47
|
throw memberResolutionReadOnlyDenied();
|
|
43
48
|
}
|
|
44
49
|
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
FrameworkReasons,
|
|
16
16
|
InternalError,
|
|
17
17
|
isKumikoError,
|
|
18
|
+
memberResolutionReadOnlyDenied,
|
|
18
19
|
NotFoundError,
|
|
19
20
|
ValidationError,
|
|
20
21
|
validationErrorFromZod,
|
|
@@ -28,7 +29,7 @@ import {
|
|
|
28
29
|
CONFIG_WRITE_SET_TYPE,
|
|
29
30
|
checkFeatureEnabled,
|
|
30
31
|
enforceRateLimit,
|
|
31
|
-
|
|
32
|
+
isMemberResolutionPrincipal,
|
|
32
33
|
resolveDbSource,
|
|
33
34
|
runHandlerInstrumented,
|
|
34
35
|
TENANT_TIMEZONE_CONFIG_KEY,
|
|
@@ -371,7 +372,7 @@ async function executeWriteInner(
|
|
|
371
372
|
|
|
372
373
|
// Defense in depth — covers runBatch and a spread-copied user that kept
|
|
373
374
|
// `origin`, beyond buildHandlerContext's own ctx.write replacement.
|
|
374
|
-
if (user
|
|
375
|
+
if (isMemberResolutionPrincipal(user)) {
|
|
375
376
|
return writeFailure(memberResolutionReadOnlyDenied());
|
|
376
377
|
}
|
|
377
378
|
|
|
@@ -127,6 +127,13 @@ export type EventDispatcher = {
|
|
|
127
127
|
// a full stop/start cycle. Production never needs this — start() runs
|
|
128
128
|
// it once on boot and the rows survive dispatcher lifetime.
|
|
129
129
|
ensureRegistered(): Promise<void>;
|
|
130
|
+
// Waits out a pass already in flight, without touching the timer or the
|
|
131
|
+
// LISTEN subscription — unlike stop(), the dispatcher keeps running
|
|
132
|
+
// afterwards. Test-teardown surface: a beforeEach/afterEach that resets
|
|
133
|
+
// the events table (TRUNCATE) must not race a pass this same dispatcher
|
|
134
|
+
// started (timer tick or NOTIFY wake-up) that is still writing into the
|
|
135
|
+
// tables the reset is about to wipe. No-op when nothing is in flight.
|
|
136
|
+
drain(): Promise<void>;
|
|
130
137
|
// Read-only view of the consumers this dispatcher is wired with. Exists
|
|
131
138
|
// for lane-filter assertions (Welle 2.6.b split-deploy tests) and for
|
|
132
139
|
// the boot-validator (Welle 2.6.c coverage check: every registered MSP
|
|
@@ -253,6 +260,14 @@ export function createEventDispatcher(options: EventDispatcherOptions): EventDis
|
|
|
253
260
|
// pattern so behaviour under races stays predictable.
|
|
254
261
|
let passInFlight: Promise<DispatcherPassResult> | null = null;
|
|
255
262
|
|
|
263
|
+
async function drainPassInFlight(): Promise<void> {
|
|
264
|
+
if (passInFlight) {
|
|
265
|
+
await passInFlight.catch(() => {
|
|
266
|
+
// skip: errors already recorded per-consumer inside the pass
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
256
271
|
async function runOnce(): Promise<DispatcherPassResult> {
|
|
257
272
|
if (!preRegistered) {
|
|
258
273
|
throw new Error(
|
|
@@ -529,11 +544,7 @@ export function createEventDispatcher(options: EventDispatcherOptions): EventDis
|
|
|
529
544
|
}
|
|
530
545
|
|
|
531
546
|
// Drain any in-flight pass so shutdown observes consistent state.
|
|
532
|
-
|
|
533
|
-
await passInFlight.catch(() => {
|
|
534
|
-
// skip: errors already recorded per-consumer inside the pass
|
|
535
|
-
});
|
|
536
|
-
}
|
|
547
|
+
await drainPassInFlight();
|
|
537
548
|
// preRegistered stays true — the rows survive stop(). runOnce()
|
|
538
549
|
// after a stop() still works (tests stop the timer and then drain
|
|
539
550
|
// deterministically).
|
|
@@ -544,6 +555,8 @@ export function createEventDispatcher(options: EventDispatcherOptions): EventDis
|
|
|
544
555
|
preRegistered = true;
|
|
545
556
|
},
|
|
546
557
|
|
|
558
|
+
drain: drainPassInFlight,
|
|
559
|
+
|
|
547
560
|
runOnce,
|
|
548
561
|
};
|
|
549
562
|
}
|
|
@@ -1,12 +1,8 @@
|
|
|
1
1
|
import type { DbTx } from "../db/connection";
|
|
2
2
|
import { extractPgError } from "../db/pg-error";
|
|
3
3
|
import { asRawClient, runInSavepoint, transaction } from "../db/query";
|
|
4
|
-
import { AccessDeniedError, InternalError } from "../errors";
|
|
5
|
-
import {
|
|
6
|
-
type DispatchContext,
|
|
7
|
-
memberResolutionReadOnlyDenied,
|
|
8
|
-
resolveDbSource,
|
|
9
|
-
} from "./dispatch-shared";
|
|
4
|
+
import { AccessDeniedError, InternalError, memberResolutionReadOnlyDenied } from "../errors";
|
|
5
|
+
import { type DispatchContext, resolveDbSource } from "./dispatch-shared";
|
|
10
6
|
|
|
11
7
|
const PG_READ_ONLY_SQLSTATE = "25006";
|
|
12
8
|
|
|
@@ -146,6 +146,12 @@ export async function resetEventStore(
|
|
|
146
146
|
stack: { db: unknown; eventDispatcher?: EventDispatcher },
|
|
147
147
|
extraTables: readonly (unknown | string)[] = [],
|
|
148
148
|
): Promise<void> {
|
|
149
|
+
// A pass the dispatcher's own timer/LISTEN already started (from a
|
|
150
|
+
// preceding test) may still be writing into the tables truncated below —
|
|
151
|
+
// drain it first so the TRUNCATE never races a write it didn't cause.
|
|
152
|
+
if (stack.eventDispatcher) {
|
|
153
|
+
await stack.eventDispatcher.drain();
|
|
154
|
+
}
|
|
149
155
|
const frameworkTables = [
|
|
150
156
|
"kumiko_events",
|
|
151
157
|
"kumiko_event_consumers",
|