@cosmicdrift/kumiko-framework 0.312.0 → 0.314.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/__tests__/upgrade-cli.test.ts +28 -0
- package/src/api/__tests__/origin-middleware.integration.test.ts +142 -1
- package/src/api/auth-middleware.ts +30 -1
- package/src/api/auth-routes.ts +27 -2
- package/src/api/origin-middleware.ts +23 -20
- package/src/api/server.ts +10 -1
- package/src/changes.json +48 -0
- package/src/crypto/pg-kms-adapter.ts +2 -1
- package/src/db/__tests__/connection-close-outage.integration.test.ts +110 -0
- package/src/db/api.ts +6 -0
- package/src/db/bun-provider.ts +8 -3
- package/src/db/connection.ts +3 -3
- package/src/db/eagerload.ts +2 -9
- package/src/db/index.ts +1 -1
- package/src/db/postgres-provider.ts +7 -4
- package/src/db/queries/event-consumer.ts +4 -3
- package/src/engine/boot-validator/__tests__/transfer-graph.test.ts +12 -0
- package/src/engine/boot-validator/transfer-graph.ts +2 -1
- package/src/engine/index.ts +3 -0
- package/src/engine/parse-ref-target.ts +6 -0
- package/src/engine/registry-validate.ts +3 -12
- package/src/engine/types/index.ts +1 -0
- package/src/files/__tests__/files.integration.test.ts +128 -1
- package/src/files/__tests__/storage-tracking.test.ts +46 -0
- package/src/files/file-routes.ts +10 -3
- package/src/files/index.ts +9 -1
- package/src/files/storage-tracking.ts +160 -15
- package/src/files/types.ts +57 -2
- package/src/jobs/__tests__/job-backoff.integration.test.ts +1 -5
- package/src/jobs/job-runner.ts +27 -0
- package/src/pipeline/__tests__/event-dispatcher-db-outage.integration.test.ts +27 -0
- package/src/pipeline/__tests__/job-trigger-consumer.integration.test.ts +34 -6
- package/src/pipeline/event-dispatcher-delivery.ts +22 -1
- package/src/pipeline/event-dispatcher.ts +25 -2
- package/src/pipeline/index.ts +4 -1
- package/src/pipeline/system-hooks.ts +6 -2
- package/src/stack/__tests__/drain-event-consumers.integration.test.ts +166 -0
- package/src/stack/__tests__/setup-test-stack-jobs.integration.test.ts +60 -0
- package/src/stack/drain-event-consumers.ts +74 -0
- package/src/stack/index.ts +1 -0
- package/src/stack/redis.ts +3 -0
- package/src/stack/test-stack.ts +13 -4
- package/src/upgrade-cli.ts +17 -3
|
@@ -11,18 +11,28 @@
|
|
|
11
11
|
// consumer-cursor row. Apps that want it pass filesStorageTrackingFeature
|
|
12
12
|
// into createApp / setupTestStack alongside their domain features.
|
|
13
13
|
|
|
14
|
-
import { entityEventName } from "../db";
|
|
14
|
+
import { type DbRunner, entityEventName, executeRawQueryRead } from "../db";
|
|
15
15
|
import { bigint, instant, integer, table as pgTable, sql, uuid } from "../db/dialect";
|
|
16
16
|
import { incrementCounter } from "../db/query";
|
|
17
|
-
import { defineFeature } from "../engine";
|
|
17
|
+
import { defineFeature, qn, type Registry, toKebab } from "../engine";
|
|
18
|
+
import {
|
|
19
|
+
type PendingGapEntry,
|
|
20
|
+
SHARED_INSTANCE_SENTINEL,
|
|
21
|
+
selectConsumerCursorForUpdate,
|
|
22
|
+
} from "../pipeline";
|
|
23
|
+
import { parseJsonOrThrow } from "../utils";
|
|
24
|
+
|
|
25
|
+
const FILE_REF_AGGREGATE_TYPE = "fileRef";
|
|
26
|
+
const STORAGE_TRACKING_FEATURE_NAME = "files-storage-tracking";
|
|
27
|
+
const TENANT_STORAGE_USAGE_MSP_NAME = "tenant-storage-usage";
|
|
18
28
|
|
|
19
29
|
// fileRef is a standard ES entity, so usage tracking subscribes to its
|
|
20
30
|
// auto-verb events. `fileRef.created` payload carries the entity fields
|
|
21
31
|
// (incl. size); `fileRef.deleted` / `fileRef.restored` carry `{ previous }`
|
|
22
32
|
// (the pre-event row), so the byte count to apply lives at previous.size.
|
|
23
|
-
const FILE_REF_CREATED = entityEventName(
|
|
24
|
-
const FILE_REF_DELETED = entityEventName(
|
|
25
|
-
const FILE_REF_RESTORED = entityEventName(
|
|
33
|
+
const FILE_REF_CREATED = entityEventName(FILE_REF_AGGREGATE_TYPE, "created");
|
|
34
|
+
const FILE_REF_DELETED = entityEventName(FILE_REF_AGGREGATE_TYPE, "deleted");
|
|
35
|
+
const FILE_REF_RESTORED = entityEventName(FILE_REF_AGGREGATE_TYPE, "restored");
|
|
26
36
|
|
|
27
37
|
function readNumber(value: unknown): number {
|
|
28
38
|
return typeof value === "number" ? value : 0;
|
|
@@ -36,6 +46,28 @@ function sizeFromPrevious(payload: Record<string, unknown>): number {
|
|
|
36
46
|
return 0;
|
|
37
47
|
}
|
|
38
48
|
|
|
49
|
+
// Single source of truth for "how much does this fileRef event move the
|
|
50
|
+
// tenant's storage counters" — shared by the MSP's own apply handlers and by
|
|
51
|
+
// transferTenantStorageUsage's replay of already-applied events during a
|
|
52
|
+
// tenant handover.
|
|
53
|
+
export function fileRefStorageDelta(event: {
|
|
54
|
+
readonly type: string;
|
|
55
|
+
readonly payload: Record<string, unknown>;
|
|
56
|
+
}): { readonly bytes: number; readonly files: number } | null {
|
|
57
|
+
switch (event.type) {
|
|
58
|
+
case FILE_REF_CREATED:
|
|
59
|
+
return { bytes: readNumber(event.payload["size"]), files: 1 };
|
|
60
|
+
case FILE_REF_DELETED:
|
|
61
|
+
// `0 - x`, not `-x`: a zero-byte previous size must stay `0`, not `-0`
|
|
62
|
+
// (toEqual/JSON round-trips treat -0 as distinct from 0).
|
|
63
|
+
return { bytes: 0 - sizeFromPrevious(event.payload), files: -1 };
|
|
64
|
+
case FILE_REF_RESTORED:
|
|
65
|
+
return { bytes: sizeFromPrevious(event.payload), files: 1 };
|
|
66
|
+
default:
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
39
71
|
// bigint in `mode: "number"` returns a JS number (safe up to 2^53 ≈ 9e15
|
|
40
72
|
// bytes ≈ 8 petabytes per tenant — large enough for any practical storage
|
|
41
73
|
// quota). Default "bigint" mode would hand back a bigint value, which
|
|
@@ -48,13 +80,15 @@ export const tenantStorageUsageTable = pgTable("read_tenant_storage_usage", {
|
|
|
48
80
|
lastUpdatedAt: instant("last_updated_at").default(sql`now()`).notNull(),
|
|
49
81
|
});
|
|
50
82
|
|
|
51
|
-
export const filesStorageTrackingFeature = defineFeature(
|
|
83
|
+
export const filesStorageTrackingFeature = defineFeature(STORAGE_TRACKING_FEATURE_NAME, (r) => {
|
|
52
84
|
r.multiStreamProjection({
|
|
53
|
-
name:
|
|
85
|
+
name: TENANT_STORAGE_USAGE_MSP_NAME,
|
|
54
86
|
table: tenantStorageUsageTable,
|
|
55
87
|
apply: {
|
|
56
88
|
[FILE_REF_CREATED]: async (event, tx) => {
|
|
57
|
-
const
|
|
89
|
+
const delta = fileRefStorageDelta(event);
|
|
90
|
+
// skip: not a tracked fileRef storage change
|
|
91
|
+
if (!delta) return;
|
|
58
92
|
|
|
59
93
|
// UPSERT: INSERT on first upload per tenant, otherwise atomic increment.
|
|
60
94
|
// The SQL increment guarantees correctness under concurrent dispatcher
|
|
@@ -63,13 +97,15 @@ export const filesStorageTrackingFeature = defineFeature("files-storage-tracking
|
|
|
63
97
|
await incrementCounter(
|
|
64
98
|
tx,
|
|
65
99
|
tenantStorageUsageTable,
|
|
66
|
-
{ tenantId: event.tenantId, totalBytes:
|
|
67
|
-
{ totalBytes:
|
|
100
|
+
{ tenantId: event.tenantId, totalBytes: delta.bytes, fileCount: delta.files },
|
|
101
|
+
{ totalBytes: delta.bytes, fileCount: delta.files },
|
|
68
102
|
{ set: { lastUpdatedAt: sql`now()` } },
|
|
69
103
|
);
|
|
70
104
|
},
|
|
71
105
|
[FILE_REF_DELETED]: async (event, tx) => {
|
|
72
|
-
const
|
|
106
|
+
const delta = fileRefStorageDelta(event);
|
|
107
|
+
// skip: not a tracked fileRef storage change
|
|
108
|
+
if (!delta) return;
|
|
73
109
|
// Decrement on delete. INSERT values are 0/0 so a delete that somehow
|
|
74
110
|
// precedes any upload can't create negative usage; the on-conflict
|
|
75
111
|
// path applies the real negative delta. Async (dispatcher) —
|
|
@@ -78,7 +114,7 @@ export const filesStorageTrackingFeature = defineFeature("files-storage-tracking
|
|
|
78
114
|
tx,
|
|
79
115
|
tenantStorageUsageTable,
|
|
80
116
|
{ tenantId: event.tenantId, totalBytes: 0, fileCount: 0 },
|
|
81
|
-
{ totalBytes:
|
|
117
|
+
{ totalBytes: delta.bytes, fileCount: delta.files },
|
|
82
118
|
{ set: { lastUpdatedAt: sql`now()` } },
|
|
83
119
|
);
|
|
84
120
|
},
|
|
@@ -86,15 +122,124 @@ export const filesStorageTrackingFeature = defineFeature("files-storage-tracking
|
|
|
86
122
|
// Restore re-increments by the soft-deleted row's size — symmetric
|
|
87
123
|
// to delete. Without this handler totalBytes/fileCount drift low
|
|
88
124
|
// after every delete→restore round-trip.
|
|
89
|
-
const
|
|
125
|
+
const delta = fileRefStorageDelta(event);
|
|
126
|
+
// skip: not a tracked fileRef storage change
|
|
127
|
+
if (!delta) return;
|
|
90
128
|
await incrementCounter(
|
|
91
129
|
tx,
|
|
92
130
|
tenantStorageUsageTable,
|
|
93
|
-
{ tenantId: event.tenantId, totalBytes:
|
|
94
|
-
{ totalBytes:
|
|
131
|
+
{ tenantId: event.tenantId, totalBytes: delta.bytes, fileCount: delta.files },
|
|
132
|
+
{ totalBytes: delta.bytes, fileCount: delta.files },
|
|
95
133
|
{ set: { lastUpdatedAt: sql`now()` } },
|
|
96
134
|
);
|
|
97
135
|
},
|
|
98
136
|
},
|
|
99
137
|
});
|
|
100
138
|
});
|
|
139
|
+
|
|
140
|
+
// Qualified name of the tenant-storage-usage MSP consumer row in
|
|
141
|
+
// kumiko_event_consumers — the same qualification registry-ingest.ts applies
|
|
142
|
+
// to every multiStreamProjection (qualify(featureName, "projection", name)).
|
|
143
|
+
const TENANT_STORAGE_USAGE_MSP_QN = qn(
|
|
144
|
+
toKebab(STORAGE_TRACKING_FEATURE_NAME),
|
|
145
|
+
"projection",
|
|
146
|
+
toKebab(TENANT_STORAGE_USAGE_MSP_NAME),
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
const TRACKED_FILE_REF_EVENT_TYPES = [FILE_REF_CREATED, FILE_REF_DELETED, FILE_REF_RESTORED];
|
|
150
|
+
|
|
151
|
+
function isWithinPendingGap(eventId: bigint, gaps: readonly PendingGapEntry[]): boolean {
|
|
152
|
+
return gaps.some((gap) => eventId >= BigInt(gap.from) && eventId <= BigInt(gap.to));
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Moves the already-applied share of the tenant-storage-usage counters from
|
|
156
|
+
// source to destination tenant when a tenant-handover claim moves fileRef
|
|
157
|
+
// rows. Must run BEFORE moveEventHistory rewrites the events' tenant_id —
|
|
158
|
+
// the query below finds "already applied" events by sourceTenantId, and once
|
|
159
|
+
// the rewrite runs there is no way to tell an already-applied event from a
|
|
160
|
+
// not-yet-applied one by tenant_id alone.
|
|
161
|
+
//
|
|
162
|
+
// Events with id above the cursor (or inside a pending gap) are NOT summed
|
|
163
|
+
// here: the dispatcher hasn't applied them yet, they will carry the
|
|
164
|
+
// destination tenant_id once moveEventHistory runs, and the dispatcher will
|
|
165
|
+
// apply them against the destination counter on its own next pass.
|
|
166
|
+
export async function transferTenantStorageUsage(args: {
|
|
167
|
+
readonly db: DbRunner;
|
|
168
|
+
readonly registry: Registry;
|
|
169
|
+
readonly fileRefIds: readonly string[];
|
|
170
|
+
readonly sourceTenantId: string;
|
|
171
|
+
readonly destinationTenantId: string;
|
|
172
|
+
}): Promise<void> {
|
|
173
|
+
const { db, registry, fileRefIds, sourceTenantId, destinationTenantId } = args;
|
|
174
|
+
// skip: no fileRefs moved, nothing to transfer
|
|
175
|
+
if (fileRefIds.length === 0) return;
|
|
176
|
+
// skip: storage tracking is not mounted in this app
|
|
177
|
+
if (!registry.getAllMultiStreamProjections().has(TENANT_STORAGE_USAGE_MSP_QN)) return;
|
|
178
|
+
|
|
179
|
+
// Locks the same consumer row the dispatcher's own FOR UPDATE SKIP LOCKED
|
|
180
|
+
// pass takes before applying a batch — serializing against it here means no
|
|
181
|
+
// fileRef event for these ids can be applied between reading the cursor
|
|
182
|
+
// below and the tenant move that follows in the caller.
|
|
183
|
+
const cursor = await selectConsumerCursorForUpdate(
|
|
184
|
+
db,
|
|
185
|
+
TENANT_STORAGE_USAGE_MSP_QN,
|
|
186
|
+
SHARED_INSTANCE_SENTINEL,
|
|
187
|
+
);
|
|
188
|
+
// skip: no consumer row means the MSP never applied anything yet
|
|
189
|
+
if (!cursor) return;
|
|
190
|
+
|
|
191
|
+
const rows = await executeRawQueryRead<{ id: string; type: string; payload: string }>(
|
|
192
|
+
db,
|
|
193
|
+
`SELECT "id"::text AS id, "type", "payload"::text AS payload FROM kumiko_events
|
|
194
|
+
WHERE "tenant_id" = $1 AND "aggregate_type" = $2 AND "aggregate_id" = ANY($3)
|
|
195
|
+
AND "type" = ANY($4) AND "id" <= $5`,
|
|
196
|
+
[
|
|
197
|
+
sourceTenantId,
|
|
198
|
+
FILE_REF_AGGREGATE_TYPE,
|
|
199
|
+
fileRefIds,
|
|
200
|
+
TRACKED_FILE_REF_EVENT_TYPES,
|
|
201
|
+
cursor.lastProcessedEventId,
|
|
202
|
+
],
|
|
203
|
+
);
|
|
204
|
+
|
|
205
|
+
let bytes = 0;
|
|
206
|
+
let files = 0;
|
|
207
|
+
for (const row of rows) {
|
|
208
|
+
if (isWithinPendingGap(BigInt(row.id), cursor.pendingGaps)) continue;
|
|
209
|
+
const delta = fileRefStorageDelta({
|
|
210
|
+
type: row.type,
|
|
211
|
+
payload: parseJsonOrThrow<Record<string, unknown>>(
|
|
212
|
+
row.payload,
|
|
213
|
+
"transferTenantStorageUsage: kumiko_events.payload",
|
|
214
|
+
),
|
|
215
|
+
});
|
|
216
|
+
if (!delta) continue;
|
|
217
|
+
bytes += delta.bytes;
|
|
218
|
+
files += delta.files;
|
|
219
|
+
}
|
|
220
|
+
// skip: the moved fileRefs carry no applied storage share
|
|
221
|
+
if (bytes === 0 && files === 0) return;
|
|
222
|
+
|
|
223
|
+
// Source: insert-values stay 0/0 (same guard as the DELETED handler above)
|
|
224
|
+
// — the row this subtracts from is expected to already exist (the MSP
|
|
225
|
+
// already applied the create against it), and if it somehow doesn't, this
|
|
226
|
+
// must not conjure a negative balance out of thin air.
|
|
227
|
+
await incrementCounter(
|
|
228
|
+
db,
|
|
229
|
+
tenantStorageUsageTable,
|
|
230
|
+
{ tenantId: sourceTenantId, totalBytes: 0, fileCount: 0 },
|
|
231
|
+
{ totalBytes: -bytes, fileCount: -files },
|
|
232
|
+
{ set: { lastUpdatedAt: sql`now()` } },
|
|
233
|
+
);
|
|
234
|
+
// Destination: insert-values ARE the transferred amount (same as CREATED/
|
|
235
|
+
// RESTORED above) — a tenant claiming its first-ever fileRef must start
|
|
236
|
+
// its counter row at the transferred total, not at 0 with an increment
|
|
237
|
+
// that only fires once a row already exists.
|
|
238
|
+
await incrementCounter(
|
|
239
|
+
db,
|
|
240
|
+
tenantStorageUsageTable,
|
|
241
|
+
{ tenantId: destinationTenantId, totalBytes: bytes, fileCount: files },
|
|
242
|
+
{ totalBytes: bytes, fileCount: files },
|
|
243
|
+
{ set: { lastUpdatedAt: sql`now()` } },
|
|
244
|
+
);
|
|
245
|
+
}
|
package/src/files/types.ts
CHANGED
|
@@ -34,6 +34,10 @@ export function parseMaxSize(maxSize: string): number {
|
|
|
34
34
|
}
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
+
function extractExtension(fileName: string): string | undefined {
|
|
38
|
+
return fileName.split(".").pop()?.toLowerCase();
|
|
39
|
+
}
|
|
40
|
+
|
|
37
41
|
// Extension → acceptable MIME-type whitelist. Guards against a client
|
|
38
42
|
// uploading e.g. name="x.jpg" with mimeType="application/pdf" to slip an
|
|
39
43
|
// executable past the extension-only check. Kept small & conservative — add
|
|
@@ -54,6 +58,21 @@ const EXTENSION_MIME_WHITELIST: Record<string, readonly string[]> = {
|
|
|
54
58
|
docx: ["application/vnd.openxmlformats-officedocument.wordprocessingml.document"],
|
|
55
59
|
} satisfies Record<string, readonly string[]>;
|
|
56
60
|
|
|
61
|
+
// Extension → declared MIME → canonical MIME. Windows resolves .csv's upload
|
|
62
|
+
// MIME from Explorer's Excel file association, sending "application/vnd.ms-excel" for plain-text CSV.
|
|
63
|
+
const EXTENSION_MIME_ALIASES: Readonly<Record<string, Readonly<Record<string, string>>>> = {
|
|
64
|
+
csv: { "application/vnd.ms-excel": "text/csv" },
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
// Object.hasOwn at both levels: a plain `in` check on `{}` resolves to
|
|
68
|
+
// Object.prototype members ("constructor", …) for any ext/mimeType.
|
|
69
|
+
function lookupMimeAlias(ext: string, normalizedMimeType: string): string | undefined {
|
|
70
|
+
if (!Object.hasOwn(EXTENSION_MIME_ALIASES, ext)) return undefined;
|
|
71
|
+
const aliasesForExt = EXTENSION_MIME_ALIASES[ext];
|
|
72
|
+
if (!aliasesForExt || !Object.hasOwn(aliasesForExt, normalizedMimeType)) return undefined;
|
|
73
|
+
return aliasesForExt[normalizedMimeType];
|
|
74
|
+
}
|
|
75
|
+
|
|
57
76
|
// Magic-byte signatures for the subset of EXTENSION_MIME_WHITELIST that has
|
|
58
77
|
// a reliable binary signature. Used at SERVE time — never trust the stored/
|
|
59
78
|
// client-declared mimeType for Content-Type on its own, sniff the actual
|
|
@@ -108,6 +127,14 @@ function startsWithAscii(bytes: Uint8Array, ascii: string, offset = 0): boolean
|
|
|
108
127
|
return true;
|
|
109
128
|
}
|
|
110
129
|
|
|
130
|
+
/**
|
|
131
|
+
* Canonical MIME type for a known binary magic-byte signature (png, jpeg,
|
|
132
|
+
* gif, webp, pdf, OLE, ZIP), or null for everything else — text formats
|
|
133
|
+
* (csv, txt, json, svg, md) have no signature and always return null. Every
|
|
134
|
+
* OLE Compound File (.doc/.xls/.ppt) returns "application/msword" and every
|
|
135
|
+
* ZIP (.docx/.xlsx/.pptx/.zip) returns the docx MIME — callers can't tell
|
|
136
|
+
* those apart by bytes alone.
|
|
137
|
+
*/
|
|
111
138
|
export function sniffMimeType(bytes: Uint8Array): string | null {
|
|
112
139
|
for (const signature of MAGIC_BYTE_SIGNATURES) {
|
|
113
140
|
if (signature.matches(bytes)) return signature.mimeType;
|
|
@@ -165,7 +192,7 @@ export function validateFile(
|
|
|
165
192
|
}
|
|
166
193
|
|
|
167
194
|
if (options.accept && options.accept.length > 0) {
|
|
168
|
-
const ext = metadata.fileName
|
|
195
|
+
const ext = extractExtension(metadata.fileName);
|
|
169
196
|
if (!ext || !options.accept.includes(ext)) {
|
|
170
197
|
return `invalid_file_type: ".${ext}" is not in [${options.accept.join(", ")}]`;
|
|
171
198
|
}
|
|
@@ -176,7 +203,8 @@ export function validateFile(
|
|
|
176
203
|
const allowedMimes = EXTENSION_MIME_WHITELIST[ext];
|
|
177
204
|
if (allowedMimes && metadata.mimeType) {
|
|
178
205
|
const normalized = normalizeMimeType(metadata.mimeType);
|
|
179
|
-
|
|
206
|
+
const isAliasedMime = lookupMimeAlias(ext, normalized) !== undefined;
|
|
207
|
+
if (!allowedMimes.includes(normalized) && !isAliasedMime) {
|
|
180
208
|
return `mime_mismatch: extension ".${ext}" does not match mimeType "${metadata.mimeType}"`;
|
|
181
209
|
}
|
|
182
210
|
}
|
|
@@ -198,6 +226,33 @@ export function validateFileContent(mimeType: string, content: Uint8Array): stri
|
|
|
198
226
|
return null;
|
|
199
227
|
}
|
|
200
228
|
|
|
229
|
+
export type UploadMimeTypeResolution =
|
|
230
|
+
| { readonly kind: "ok"; readonly mimeType: string }
|
|
231
|
+
| { readonly kind: "rejected"; readonly error: string };
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Rewrites an aliased declared mimeType to its canonical form and, in the
|
|
235
|
+
* same call, sniffs the bytes — a renamed binary (a real .xls/.xlsx saved
|
|
236
|
+
* as .csv) must fail here rather than through an optional, skippable step.
|
|
237
|
+
*/
|
|
238
|
+
export function resolveUploadMimeType(
|
|
239
|
+
fileName: string,
|
|
240
|
+
declaredMimeType: string,
|
|
241
|
+
content: Uint8Array,
|
|
242
|
+
): UploadMimeTypeResolution {
|
|
243
|
+
const ext = extractExtension(fileName);
|
|
244
|
+
const normalizedDeclared = normalizeMimeType(declaredMimeType);
|
|
245
|
+
const canonical = ext ? lookupMimeAlias(ext, normalizedDeclared) : undefined;
|
|
246
|
+
if (!canonical) return { kind: "ok", mimeType: declaredMimeType };
|
|
247
|
+
if (sniffMimeType(content) !== null) {
|
|
248
|
+
return {
|
|
249
|
+
kind: "rejected",
|
|
250
|
+
error: `content_mismatch: bytes of ".${ext}" upload declared as "${declaredMimeType}" are a binary format`,
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
return { kind: "ok", mimeType: canonical };
|
|
254
|
+
}
|
|
255
|
+
|
|
201
256
|
export function assertSafeStorageKey(key: string): void {
|
|
202
257
|
if (key.split("/").some((segment) => segment === "." || segment === "..")) {
|
|
203
258
|
throw new Error(`storage key contains a path-traversal segment: "${key}"`);
|
|
@@ -14,7 +14,6 @@ import * as z from "zod";
|
|
|
14
14
|
import { defineFeature } from "../../engine";
|
|
15
15
|
import { setupTestStack, type TestStack, TestUsers } from "../../stack";
|
|
16
16
|
import { waitFor } from "../../testing";
|
|
17
|
-
import { generateId } from "../../utils";
|
|
18
17
|
|
|
19
18
|
const exponentialDefaultStarts: number[] = [];
|
|
20
19
|
const exponentialCustomStarts: number[] = [];
|
|
@@ -92,10 +91,7 @@ let stack: TestStack;
|
|
|
92
91
|
beforeAll(async () => {
|
|
93
92
|
stack = await setupTestStack({
|
|
94
93
|
features: [backoffFixtureFeature],
|
|
95
|
-
|
|
96
|
-
// Redis is consumed by every parallel test process, and a foreign worker
|
|
97
|
-
// grabbing a promoted delayed retry fails it as "Unknown job".
|
|
98
|
-
jobs: { consumerLane: "worker", queueNamePrefix: `backoff-test-${generateId()}` },
|
|
94
|
+
jobs: { consumerLane: "worker" },
|
|
99
95
|
});
|
|
100
96
|
});
|
|
101
97
|
|
package/src/jobs/job-runner.ts
CHANGED
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
type JobContext,
|
|
14
14
|
type JobDefinition,
|
|
15
15
|
type JobRunIn,
|
|
16
|
+
type JobTriggerEvent,
|
|
16
17
|
type MemberReader,
|
|
17
18
|
type Registry,
|
|
18
19
|
type SessionUser,
|
|
@@ -42,6 +43,22 @@ import {
|
|
|
42
43
|
} from "../pipeline/write-origin";
|
|
43
44
|
import { bridgeStub } from "../testing/handler-context";
|
|
44
45
|
|
|
46
|
+
// Shape-checked after the BullMQ/Redis JSON round-trip. Unlike `_writeOrigin`
|
|
47
|
+
// (a security-relevant escape hatch that must fail the run closed), a
|
|
48
|
+
// malformed `_triggerEvent` only costs an idempotency-key convenience — the
|
|
49
|
+
// run proceeds without it rather than failing over what would be a framework
|
|
50
|
+
// bug, not untrusted input.
|
|
51
|
+
function isJobTriggerEvent(value: unknown): value is JobTriggerEvent {
|
|
52
|
+
if (typeof value !== "object" || value === null) return false;
|
|
53
|
+
const candidate = value as Record<string, unknown>;
|
|
54
|
+
if (typeof candidate["id"] !== "string") return false;
|
|
55
|
+
const headers = candidate["headers"];
|
|
56
|
+
if (typeof headers !== "object" || headers === null) return false;
|
|
57
|
+
return Object.values(headers as Record<string, unknown>).every(
|
|
58
|
+
(v) => typeof v === "string" || typeof v === "number" || typeof v === "boolean",
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
45
62
|
// A payload's own `_writeOrigin` is never trusted; only the ambient gated origin is stamped.
|
|
46
63
|
function stampGatedWriteOrigin(data: Record<string, unknown>): void {
|
|
47
64
|
delete data["_writeOrigin"];
|
|
@@ -216,6 +233,7 @@ export type JobRunner = {
|
|
|
216
233
|
eventName: string,
|
|
217
234
|
payload: Record<string, unknown>,
|
|
218
235
|
user?: SessionUser,
|
|
236
|
+
triggerEvent?: JobTriggerEvent,
|
|
219
237
|
): Promise<void>;
|
|
220
238
|
// Wires JobContext.write/writeAs/queryAs to the real dispatcher — called
|
|
221
239
|
// once at boot, after the dispatcher exists (job-runner construction happens
|
|
@@ -759,6 +777,12 @@ export function createJobRunner(options: JobRunnerOptions): JobRunner {
|
|
|
759
777
|
// don't dig through the raw payload themselves.
|
|
760
778
|
const triggerName = rawData["_triggerName"] as string | undefined; // @cast-boundary dynamic-key
|
|
761
779
|
|
|
780
|
+
// Same carry-through for `_triggerEvent` — handleEvent injects it when
|
|
781
|
+
// the job-trigger event consumer supplied the stored event that fired
|
|
782
|
+
// this run (see createJobTriggerEventConsumer).
|
|
783
|
+
const rawTriggerEvent = rawData["_triggerEvent"];
|
|
784
|
+
const triggerEvent = isJobTriggerEvent(rawTriggerEvent) ? rawTriggerEvent : undefined;
|
|
785
|
+
|
|
762
786
|
// Absent = legacy or ungated root; present but invalid fails the run closed.
|
|
763
787
|
const rawWriteOrigin = rawData["_writeOrigin"]; // @cast-boundary dynamic-key
|
|
764
788
|
let jobOrigin: WriteOrigin | undefined;
|
|
@@ -866,6 +890,7 @@ export function createJobRunner(options: JobRunnerOptions): JobRunner {
|
|
|
866
890
|
triggeredBy: triggeredById !== null ? { id: triggeredById, tenantId } : null,
|
|
867
891
|
log: createJobLogger(logs),
|
|
868
892
|
...(triggerName !== undefined && { triggerName }),
|
|
893
|
+
...(triggerEvent !== undefined && { triggerEvent }),
|
|
869
894
|
write: (qn: string, payload: unknown) => {
|
|
870
895
|
if (!dispatchWriteRef) {
|
|
871
896
|
throw new Error(
|
|
@@ -1258,6 +1283,7 @@ export function createJobRunner(options: JobRunnerOptions): JobRunner {
|
|
|
1258
1283
|
eventName: string,
|
|
1259
1284
|
payload: Record<string, unknown>,
|
|
1260
1285
|
user?: SessionUser,
|
|
1286
|
+
triggerEvent?: JobTriggerEvent,
|
|
1261
1287
|
): Promise<void> {
|
|
1262
1288
|
const traceCtx = captureTraceContext(tracer);
|
|
1263
1289
|
// Same correlation propagation as dispatch(): events triggered from
|
|
@@ -1285,6 +1311,7 @@ export function createJobRunner(options: JobRunnerOptions): JobRunner {
|
|
|
1285
1311
|
// setzen wir es auch — kostet nichts und vereinfacht Handler-Code
|
|
1286
1312
|
// (kein "ist es Multi?"-Branch nötig).
|
|
1287
1313
|
data["_triggerName"] = eventName;
|
|
1314
|
+
if (triggerEvent) data["_triggerEvent"] = triggerEvent;
|
|
1288
1315
|
if (traceCtx) data[TRACE_CONTEXT_KEY] = traceCtx;
|
|
1289
1316
|
if (reqCtx?.correlationId) data["_correlationId"] = reqCtx.correlationId;
|
|
1290
1317
|
// Same maxPerTenant guard as dispatch — events that fan into many
|
|
@@ -195,3 +195,30 @@ describe("E: dispatcher survives a DB outage and logs the recovery", () => {
|
|
|
195
195
|
}
|
|
196
196
|
});
|
|
197
197
|
});
|
|
198
|
+
|
|
199
|
+
describe("F: stop() waits for a pass still mid-flight", () => {
|
|
200
|
+
test("stop() waits for a pass still in its idle pre-check", async () => {
|
|
201
|
+
const consumer: EventConsumer = {
|
|
202
|
+
name: "dboutage:consumer-stop-race",
|
|
203
|
+
handler: async () => {},
|
|
204
|
+
};
|
|
205
|
+
const dispatcher: EventDispatcher = createEventDispatcher({
|
|
206
|
+
db: stack.db,
|
|
207
|
+
consumers: [consumer],
|
|
208
|
+
context: { db: stack.db },
|
|
209
|
+
pollIntervalMs: 60_000,
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
await dispatcher.start();
|
|
213
|
+
let passSettled = false;
|
|
214
|
+
// A pass woken by runOnce() has not registered any turn yet while it is
|
|
215
|
+
// still awaiting the idle pre-check (selectIdleConsumerKeys) — stop()
|
|
216
|
+
// must wait for it too, or it queries the pool stop() is closing.
|
|
217
|
+
const pass = dispatcher.runOnce().finally(() => {
|
|
218
|
+
passSettled = true;
|
|
219
|
+
});
|
|
220
|
+
await dispatcher.stop();
|
|
221
|
+
expect(passSettled).toBe(true);
|
|
222
|
+
await pass;
|
|
223
|
+
});
|
|
224
|
+
});
|
|
@@ -19,7 +19,8 @@
|
|
|
19
19
|
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
20
20
|
import * as z from "zod";
|
|
21
21
|
import { entityEventName } from "../../db";
|
|
22
|
-
import { defineFeature } from "../../engine";
|
|
22
|
+
import { defineFeature, type JobContext } from "../../engine";
|
|
23
|
+
import { loadAggregate } from "../../event-store";
|
|
23
24
|
import { createInMemoryFileProvider, type InMemoryFileProvider } from "../../files";
|
|
24
25
|
import { setupTestStack, type TestStack, TestUsers } from "../../stack";
|
|
25
26
|
import { waitFor } from "../../testing";
|
|
@@ -27,8 +28,13 @@ import { generateId } from "../../utils";
|
|
|
27
28
|
|
|
28
29
|
const ITEM_REQUESTED_EVENT_QN = "job-trigger-fixture:event:item-requested";
|
|
29
30
|
const FILE_REF_CREATED = entityEventName("fileRef", "created");
|
|
31
|
+
const PROVIDER_EVENT_ID_HEADER = "providerEventId";
|
|
30
32
|
|
|
31
|
-
const processedItems: Array<{
|
|
33
|
+
const processedItems: Array<{
|
|
34
|
+
readonly fileRefId: string;
|
|
35
|
+
readonly triggerEvent: JobContext["triggerEvent"];
|
|
36
|
+
readonly rawDataKeys: readonly string[];
|
|
37
|
+
}> = [];
|
|
32
38
|
|
|
33
39
|
const jobTriggerFixtureFeature = defineFeature("job-trigger-fixture", (r) => {
|
|
34
40
|
r.defineEvent("item-requested", z.object({ fileRefId: z.string().min(1) }), {
|
|
@@ -37,7 +43,8 @@ const jobTriggerFixtureFeature = defineFeature("job-trigger-fixture", (r) => {
|
|
|
37
43
|
|
|
38
44
|
// Mirrors document-ingest-foundation's request-ingest MSP exactly: reacts
|
|
39
45
|
// to fileRef.created, appends a NEW event via unsafeAppendEvent — no
|
|
40
|
-
// write-handler behind the appended event itself.
|
|
46
|
+
// write-handler behind the appended event itself. headers carries a
|
|
47
|
+
// provider-style idempotency key so the job side can assert it round-trips.
|
|
41
48
|
r.multiStreamProjection({
|
|
42
49
|
name: "request-item",
|
|
43
50
|
apply: {
|
|
@@ -47,6 +54,7 @@ const jobTriggerFixtureFeature = defineFeature("job-trigger-fixture", (r) => {
|
|
|
47
54
|
aggregateType: "job-trigger-fixture-request",
|
|
48
55
|
type: ITEM_REQUESTED_EVENT_QN,
|
|
49
56
|
payload: { fileRefId: event.aggregateId },
|
|
57
|
+
headers: { [PROVIDER_EVENT_ID_HEADER]: "provider-evt-42" },
|
|
50
58
|
});
|
|
51
59
|
},
|
|
52
60
|
},
|
|
@@ -57,8 +65,12 @@ const jobTriggerFixtureFeature = defineFeature("job-trigger-fixture", (r) => {
|
|
|
57
65
|
r.job(
|
|
58
66
|
"process-item",
|
|
59
67
|
{ trigger: { on: ITEM_REQUESTED_EVENT_QN }, runIn: "worker" },
|
|
60
|
-
async (payload) => {
|
|
61
|
-
processedItems.push({
|
|
68
|
+
async (payload, ctx) => {
|
|
69
|
+
processedItems.push({
|
|
70
|
+
fileRefId: payload["fileRefId"] as string,
|
|
71
|
+
triggerEvent: ctx.triggerEvent,
|
|
72
|
+
rawDataKeys: Object.keys(payload),
|
|
73
|
+
});
|
|
62
74
|
},
|
|
63
75
|
);
|
|
64
76
|
});
|
|
@@ -169,7 +181,23 @@ describe("job-trigger event consumer", () => {
|
|
|
169
181
|
expect(processedItems).toHaveLength(1);
|
|
170
182
|
});
|
|
171
183
|
|
|
172
|
-
|
|
184
|
+
const processed = processedItems[0];
|
|
185
|
+
expect(processed?.fileRefId).toBeTruthy();
|
|
186
|
+
|
|
187
|
+
// aggregateId of the appended item-requested event is the fileRef's id
|
|
188
|
+
// (the MSP apply carries event.aggregateId straight through).
|
|
189
|
+
const fileRefId = processed?.fileRefId as string;
|
|
190
|
+
const storedEvents = await loadAggregate(stack.db, fileRefId, TestUsers.admin.tenantId);
|
|
191
|
+
const itemRequested = storedEvents.find((e) => e.type === ITEM_REQUESTED_EVENT_QN);
|
|
192
|
+
expect(itemRequested).toBeDefined();
|
|
193
|
+
|
|
194
|
+
// triggerEvent carries the stored event's own id and headers through to
|
|
195
|
+
// the job — the idempotency-key surface at-least-once delivery needs.
|
|
196
|
+
expect(processed?.triggerEvent?.id).toBe(itemRequested?.id);
|
|
197
|
+
expect(processed?.triggerEvent?.headers[PROVIDER_EVENT_ID_HEADER]).toBe("provider-evt-42");
|
|
198
|
+
|
|
199
|
+
// Reserved keys never leak into the payload the handler receives.
|
|
200
|
+
expect(processed?.rawDataKeys).not.toContain("_triggerEvent");
|
|
173
201
|
});
|
|
174
202
|
});
|
|
175
203
|
|
|
@@ -3,12 +3,13 @@
|
|
|
3
3
|
// resolve against (same #1438 dual-package-hazard pattern as event-store.ts).
|
|
4
4
|
import { Temporal as TemporalPolyfill } from "temporal-polyfill";
|
|
5
5
|
import { type RequestContextData, requestContext } from "../api/request-context";
|
|
6
|
-
import type { DbConnection, DbTx } from "../db/connection";
|
|
6
|
+
import type { DbConnection, DbRunner, DbTx } from "../db/connection";
|
|
7
7
|
import {
|
|
8
8
|
insertConsumerIfAbsent,
|
|
9
9
|
markConsumerProcessing,
|
|
10
10
|
rearmDeadConsumer,
|
|
11
11
|
recordConsumerPassFailure,
|
|
12
|
+
selectConsumerForUpdate,
|
|
12
13
|
selectConsumerForUpdateSkipLocked,
|
|
13
14
|
selectProvablyIdleConsumerPairs,
|
|
14
15
|
updateConsumerDeliveryOutcome,
|
|
@@ -160,6 +161,26 @@ export async function acquireConsumerState(
|
|
|
160
161
|
return { state, skip: null };
|
|
161
162
|
}
|
|
162
163
|
|
|
164
|
+
export type ConsumerCursor = {
|
|
165
|
+
readonly lastProcessedEventId: bigint;
|
|
166
|
+
readonly pendingGaps: readonly PendingGapEntry[];
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
// Blocking FOR UPDATE (not SKIP LOCKED): callers of this one need to actually
|
|
170
|
+
// wait for the dispatcher's own turn to finish, not skip past it, so their
|
|
171
|
+
// read of the already-applied cursor is guaranteed to be up to date rather
|
|
172
|
+
// than possibly stale.
|
|
173
|
+
export async function selectConsumerCursorForUpdate(
|
|
174
|
+
db: DbRunner,
|
|
175
|
+
name: string,
|
|
176
|
+
instanceId: string,
|
|
177
|
+
): Promise<ConsumerCursor | undefined> {
|
|
178
|
+
const rawState = await selectConsumerForUpdate(db, name, instanceId);
|
|
179
|
+
if (!rawState) return undefined;
|
|
180
|
+
const state = coerceRow(rawState, extractTableInfo(eventConsumerStateTable)) as ConsumerStateRow;
|
|
181
|
+
return { lastProcessedEventId: state.lastProcessedEventId, pendingGaps: state.pendingGaps };
|
|
182
|
+
}
|
|
183
|
+
|
|
163
184
|
// Read-only pre-check run once per doPass, before any consumer's turn opens
|
|
164
185
|
// a transaction (event-dispatcher.ts's doPass/runConsumerTurn). Keyed like
|
|
165
186
|
// consumerBackoff/inFlightTurns: `${name}:${instanceId}`. Consumers not
|
|
@@ -385,13 +385,34 @@ export function createEventDispatcher(options: EventDispatcherOptions): EventDis
|
|
|
385
385
|
);
|
|
386
386
|
}
|
|
387
387
|
|
|
388
|
+
// A pass still in doPass's idle pre-check (selectIdleConsumerKeys) hasn't
|
|
389
|
+
// registered any consumer turn in inFlightTurns yet — stop() must wait for
|
|
390
|
+
// it too, or it keeps querying the pool stop() is about to close.
|
|
391
|
+
const inFlightPasses = new Set<Promise<DispatcherPassResult>>();
|
|
392
|
+
|
|
393
|
+
async function drainInFlightPasses(): Promise<void> {
|
|
394
|
+
await Promise.all(
|
|
395
|
+
[...inFlightPasses].map((pass) =>
|
|
396
|
+
pass.catch(() => {
|
|
397
|
+
// skip: errors already recorded per-consumer inside the pass
|
|
398
|
+
}),
|
|
399
|
+
),
|
|
400
|
+
);
|
|
401
|
+
}
|
|
402
|
+
|
|
388
403
|
async function runOnce(): Promise<DispatcherPassResult> {
|
|
389
404
|
if (!preRegistered) {
|
|
390
405
|
throw new Error(
|
|
391
406
|
"EventDispatcher.runOnce() called before start() — consumer state rows are not registered. Call start() first (production) or ensureRegistered() (tests after truncating kumiko_event_consumers).",
|
|
392
407
|
);
|
|
393
408
|
}
|
|
394
|
-
|
|
409
|
+
const pass = doPass();
|
|
410
|
+
inFlightPasses.add(pass);
|
|
411
|
+
try {
|
|
412
|
+
return await pass;
|
|
413
|
+
} finally {
|
|
414
|
+
inFlightPasses.delete(pass);
|
|
415
|
+
}
|
|
395
416
|
}
|
|
396
417
|
|
|
397
418
|
async function doPass(): Promise<DispatcherPassResult> {
|
|
@@ -764,7 +785,9 @@ export function createEventDispatcher(options: EventDispatcherOptions): EventDis
|
|
|
764
785
|
emitEventDispatcherListenConnected(meter, false);
|
|
765
786
|
}
|
|
766
787
|
|
|
767
|
-
// Drain any in-flight pass so shutdown observes consistent state
|
|
788
|
+
// Drain any in-flight pass so shutdown observes consistent state —
|
|
789
|
+
// including one still stuck in the idle pre-check above inFlightTurns.
|
|
790
|
+
await drainInFlightPasses();
|
|
768
791
|
await drainInFlightTurns();
|
|
769
792
|
// preRegistered stays true — the rows survive stop(). runOnce()
|
|
770
793
|
// after a stop() still works (tests stop the timer and then drain
|
package/src/pipeline/index.ts
CHANGED
|
@@ -15,11 +15,12 @@ export type { DistributedLock } from "./distributed-lock";
|
|
|
15
15
|
export { createDistributedLock } from "./distributed-lock";
|
|
16
16
|
export type { EntityCache, EntityCacheOptions } from "./entity-cache";
|
|
17
17
|
export { createEntityCache } from "./entity-cache";
|
|
18
|
-
export type { ConsumerStatus } from "./event-consumer-state";
|
|
18
|
+
export type { ConsumerStatus, PendingGapEntry } from "./event-consumer-state";
|
|
19
19
|
export {
|
|
20
20
|
ConsumerStatuses,
|
|
21
21
|
createEventConsumerStateTable,
|
|
22
22
|
eventConsumerStateTable,
|
|
23
|
+
SHARED_INSTANCE_SENTINEL,
|
|
23
24
|
} from "./event-consumer-state";
|
|
24
25
|
export type { EventDedup } from "./event-dedup";
|
|
25
26
|
export { createEventDedup } from "./event-dedup";
|
|
@@ -43,6 +44,8 @@ export {
|
|
|
43
44
|
restartConsumer,
|
|
44
45
|
skipPoisonEvent,
|
|
45
46
|
} from "./event-dispatcher";
|
|
47
|
+
export type { ConsumerCursor } from "./event-dispatcher-delivery";
|
|
48
|
+
export { selectConsumerCursorForUpdate } from "./event-dispatcher-delivery";
|
|
46
49
|
export type { PruneEventsOptions, PruneEventsResult } from "./event-retention";
|
|
47
50
|
export { ConsumerLagError, pruneEvents } from "./event-retention";
|
|
48
51
|
export type { IdempotencyGuard } from "./idempotency";
|