@cosmicdrift/kumiko-framework 0.181.0 → 0.182.1
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 +3 -3
- package/src/api/server.ts +30 -12
- package/src/bun-db/query.ts +33 -0
- package/src/db/__tests__/event-store-executor-write-verbs.integration.test.ts +33 -1
- package/src/db/event-store-executor-write.ts +42 -32
- package/src/db/query.ts +1 -0
- package/src/engine/__tests__/content-collection.test.ts +95 -0
- package/src/engine/__tests__/entity-handlers.test.ts +16 -0
- package/src/engine/entity-handlers.ts +2 -2
- package/src/engine/index.ts +1 -0
- package/src/entrypoint/__tests__/split-deploy.integration.test.ts +54 -0
- package/src/entrypoint/index.ts +10 -11
- package/src/stack/test-stack.ts +8 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.182.1",
|
|
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>",
|
|
@@ -182,7 +182,7 @@
|
|
|
182
182
|
"./package.json": "./package.json"
|
|
183
183
|
},
|
|
184
184
|
"dependencies": {
|
|
185
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
185
|
+
"@cosmicdrift/kumiko-types": "0.182.1",
|
|
186
186
|
"bullmq": "^5.76.7",
|
|
187
187
|
"bun-types": "^1.3.13",
|
|
188
188
|
"hono": "^4.12.27",
|
|
@@ -198,7 +198,7 @@
|
|
|
198
198
|
"zod": "^4.4.3"
|
|
199
199
|
},
|
|
200
200
|
"devDependencies": {
|
|
201
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
201
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.182.1",
|
|
202
202
|
"bun-types": "^1.3.13",
|
|
203
203
|
"pino-pretty": "^13.1.3"
|
|
204
204
|
},
|
package/src/api/server.ts
CHANGED
|
@@ -236,8 +236,34 @@ export type KumikoServer = {
|
|
|
236
236
|
// Echoed back so the caller has a single handle for both the app and the
|
|
237
237
|
// lifecycle. Only set when the caller passed one in.
|
|
238
238
|
lifecycle?: Lifecycle;
|
|
239
|
+
// The AppContext every handler on this server sees — options.context plus
|
|
240
|
+
// what buildServer wires onto it (_fileProviderResolver, rateLimit, the
|
|
241
|
+
// observability tracer/meter). Callers that build a second consumer of the
|
|
242
|
+
// same registry outside this server (a dev-server's job-runners) must pass
|
|
243
|
+
// THIS, not their own pre-buildServer literal, or that consumer reaches for
|
|
244
|
+
// fields only the request path has (#1232).
|
|
245
|
+
context: AppContext;
|
|
239
246
|
};
|
|
240
247
|
|
|
248
|
+
// The per-tenant file-provider resolver, built once for a registry+context so
|
|
249
|
+
// a job-runner and the server it runs beside share one instance (and one
|
|
250
|
+
// per-tenant provider cache). Mirrors buildServer's own resolution exactly —
|
|
251
|
+
// including NOT inventing a resolver when no `file-provider-*` plugin is
|
|
252
|
+
// mounted, which is what keeps buildServer's boot-guard below able to fire.
|
|
253
|
+
export function withFileProviderResolver(registry: Registry, context: AppContext): AppContext {
|
|
254
|
+
if (context._fileProviderResolver !== undefined) return context;
|
|
255
|
+
if (registry.getExtensionUsages(EXT_FILE_PROVIDER).length === 0) return context;
|
|
256
|
+
return {
|
|
257
|
+
...context,
|
|
258
|
+
_fileProviderResolver: makeFileProviderResolver({
|
|
259
|
+
registry,
|
|
260
|
+
_configAccessorFactory: context._configAccessorFactory,
|
|
261
|
+
secrets: context.secrets,
|
|
262
|
+
db: context.db,
|
|
263
|
+
}),
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
|
|
241
267
|
export function buildServer(options: ServerOptions): KumikoServer {
|
|
242
268
|
// File-storage is resolved per-tenant through file-foundation: a mounted
|
|
243
269
|
// `file-provider-*` plugin (inmemory/s3/s3-env) is the single source for
|
|
@@ -336,16 +362,8 @@ export function buildServer(options: ServerOptions): KumikoServer {
|
|
|
336
362
|
// resolver once (when a provider plugin is mounted) — the dispatcher uses it
|
|
337
363
|
// to materialise `ctx.files`, the upload routes + MSP-applies share it. The
|
|
338
364
|
// resolver reads config + the s3.secretAccessKey secret under SYSTEM identity.
|
|
339
|
-
const
|
|
340
|
-
|
|
341
|
-
(hasFileProvider
|
|
342
|
-
? makeFileProviderResolver({
|
|
343
|
-
registry: options.registry,
|
|
344
|
-
_configAccessorFactory: options.context._configAccessorFactory,
|
|
345
|
-
secrets: options.context.secrets,
|
|
346
|
-
db: options.context.db,
|
|
347
|
-
})
|
|
348
|
-
: undefined);
|
|
365
|
+
const contextWithFiles = withFileProviderResolver(options.registry, options.context);
|
|
366
|
+
const fileProviderResolver = contextWithFiles._fileProviderResolver;
|
|
349
367
|
// Auto-wire the rate-limit resolver, but ONLY when at least one
|
|
350
368
|
// handler actually declared a rateLimit option. Apps that don't use
|
|
351
369
|
// L3 pay zero cost: no resolver instance, no Lua-script registration
|
|
@@ -363,9 +381,8 @@ export function buildServer(options: ServerOptions): KumikoServer {
|
|
|
363
381
|
options.context.rateLimit ??
|
|
364
382
|
(wrappedRedis && wantsResolver ? createRateLimitResolver({ redis: wrappedRedis }) : undefined);
|
|
365
383
|
const contextWithObservability: AppContext = {
|
|
366
|
-
...
|
|
384
|
+
...contextWithFiles,
|
|
367
385
|
...(wrappedRedis ? { redis: wrappedRedis } : {}),
|
|
368
|
-
...(fileProviderResolver ? { _fileProviderResolver: fileProviderResolver } : {}),
|
|
369
386
|
...(rateLimitResolver ? { rateLimit: rateLimitResolver } : {}),
|
|
370
387
|
// Propagate the feature-toggle resolver to the context so the event-
|
|
371
388
|
// dispatcher (and any future context-reading consumer) sees the same
|
|
@@ -783,6 +800,7 @@ export function buildServer(options: ServerOptions): KumikoServer {
|
|
|
783
800
|
sseBroker,
|
|
784
801
|
observability,
|
|
785
802
|
dispatcher,
|
|
803
|
+
context: contextWithObservability,
|
|
786
804
|
...(eventDispatcher ? { eventDispatcher } : {}),
|
|
787
805
|
...(options.lifecycle ? { lifecycle: options.lifecycle } : {}),
|
|
788
806
|
};
|
package/src/bun-db/query.ts
CHANGED
|
@@ -30,6 +30,7 @@ import type {
|
|
|
30
30
|
import { Temporal } from "temporal-polyfill";
|
|
31
31
|
import { computeBlindIndex, configuredBlindIndexKey } from "../crypto/blind-index";
|
|
32
32
|
import type { EntityTableMeta } from "../db/entity-table-meta";
|
|
33
|
+
import { extractPgError } from "../db/pg-error";
|
|
33
34
|
import { type NotExecutorOnly, toSnakeCase } from "../db/table-builder";
|
|
34
35
|
import { camelCase as envCamelCase } from "../env";
|
|
35
36
|
import { parseJsonSafe } from "../utils/safe-json";
|
|
@@ -135,6 +136,38 @@ export async function runInSavepoint<T>(tx: unknown, fn: (sp: unknown) => Promis
|
|
|
135
136
|
return raw.savepoint(fn);
|
|
136
137
|
}
|
|
137
138
|
|
|
139
|
+
// Same error-confinement as runInSavepoint, but for call sites that don't
|
|
140
|
+
// know whether `db` is a bare pool connection or an active transaction
|
|
141
|
+
// (e.g. the CRUD executor, invoked both from a dispatcher tx and directly
|
|
142
|
+
// against the pool by seeds/tests). A pool connection has no savepoint() —
|
|
143
|
+
// and doesn't need one, since each statement there is its own auto-committed
|
|
144
|
+
// unit and a failed statement can't poison anything downstream.
|
|
145
|
+
//
|
|
146
|
+
// `.savepoint` being present isn't proof the transaction is still open: an
|
|
147
|
+
// afterCommit hook closure captures the same handlerContext (and therefore
|
|
148
|
+
// the same TransactionSql-shaped db) that was live during the write, but by
|
|
149
|
+
// the time the hook fires the outer tx has already committed — the object
|
|
150
|
+
// still exposes `.savepoint`, calling it now fails with PG 25P01 ("no
|
|
151
|
+
// active sql transaction") because there's no BEGIN left to nest into. That
|
|
152
|
+
// SAVEPOINT command is the first thing the driver sends, before `fn` runs,
|
|
153
|
+
// so catching 25P01 and retrying directly is safe — nothing from `fn` has
|
|
154
|
+
// executed yet.
|
|
155
|
+
export async function runInSavepointIfSupported<T>(
|
|
156
|
+
db: unknown,
|
|
157
|
+
fn: (sp: unknown) => Promise<T>,
|
|
158
|
+
): Promise<T> {
|
|
159
|
+
const raw = asRawClient(db) as unknown as {
|
|
160
|
+
savepoint?: <TR>(cb: (sp: unknown) => Promise<TR>) => Promise<TR>;
|
|
161
|
+
};
|
|
162
|
+
if (typeof raw.savepoint !== "function") return fn(db);
|
|
163
|
+
try {
|
|
164
|
+
return await raw.savepoint(fn);
|
|
165
|
+
} catch (e) {
|
|
166
|
+
if (extractPgError(e)?.code === "25P01") return fn(db);
|
|
167
|
+
throw e;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
138
171
|
/**
|
|
139
172
|
* When handlers call `selectMany(ctx.db, …)` instead of `ctx.db.selectMany(…)`,
|
|
140
173
|
* unwrap via asRawClient would bypass TenantDb scoping. Duck-type TenantDb and
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// verb entirely, and restore()'s two precondition failures.
|
|
6
6
|
|
|
7
7
|
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
8
|
-
import { asRawClient } from "../../db/query";
|
|
8
|
+
import { asRawClient, transaction } from "../../db/query";
|
|
9
9
|
import { createEntity, createTextField } from "../../engine";
|
|
10
10
|
import { from } from "../../engine/ownership";
|
|
11
11
|
import { createEventsTable } from "../../event-store";
|
|
@@ -419,6 +419,38 @@ describe("event-store-executor write-verbs — concurrent version race + cache",
|
|
|
419
419
|
expect(healthCheck[0]?.ok).toBe(1);
|
|
420
420
|
});
|
|
421
421
|
|
|
422
|
+
// kumiko-framework#1778 — a real write handler runs create() inside the
|
|
423
|
+
// dispatcher's transaction (sql.begin()), not on the bare pool like the
|
|
424
|
+
// race test above. postgres.js/Bun.SQL poison the WHOLE begin() block
|
|
425
|
+
// once any statement inside it errors, even if the JS layer already
|
|
426
|
+
// caught and classified that error — so without the runInSavepoint fix
|
|
427
|
+
// in event-store-executor-write.ts, the LOSER's transaction() call itself
|
|
428
|
+
// rejects with the raw PostgresError instead of resolving to the
|
|
429
|
+
// version_conflict writeFailure create() already produced.
|
|
430
|
+
test("two concurrent first-time creates of the same id inside a transaction → one succeeds, one converges to version_conflict", async () => {
|
|
431
|
+
const id = "11111111-1111-4111-8111-111111111111";
|
|
432
|
+
|
|
433
|
+
const [a, b] = await Promise.all([
|
|
434
|
+
transaction(testDb.db, (tx) =>
|
|
435
|
+
crud.create({ id, email: "a@test.de" }, admin, createTenantDb(tx, admin.tenantId)),
|
|
436
|
+
),
|
|
437
|
+
transaction(testDb.db, (tx) =>
|
|
438
|
+
crud.create({ id, email: "b@test.de" }, admin, createTenantDb(tx, admin.tenantId)),
|
|
439
|
+
),
|
|
440
|
+
]);
|
|
441
|
+
|
|
442
|
+
const results = [a, b];
|
|
443
|
+
expect(results.filter((r) => r.isSuccess)).toHaveLength(1);
|
|
444
|
+
expect(results.filter((r) => !r.isSuccess && r.error.code === "version_conflict")).toHaveLength(
|
|
445
|
+
1,
|
|
446
|
+
);
|
|
447
|
+
|
|
448
|
+
const healthCheck = (await asRawClient(testDb.db).unsafe(`SELECT 1 AS ok`)) as Array<{
|
|
449
|
+
ok: number;
|
|
450
|
+
}>;
|
|
451
|
+
expect(healthCheck[0]?.ok).toBe(1);
|
|
452
|
+
});
|
|
453
|
+
|
|
422
454
|
test("forget with entityCache clears the cache entry", async () => {
|
|
423
455
|
const created = await crud.create({ email: "cache-forget@test.de" }, admin, tdb);
|
|
424
456
|
if (!created.isSuccess) throw new Error("setup failed");
|
|
@@ -18,7 +18,7 @@ import {
|
|
|
18
18
|
import { generateId } from "../utils";
|
|
19
19
|
import { applyEntityEvent } from "./apply-entity-event";
|
|
20
20
|
import { flattenCompoundTypes, rehydrateCompoundTypes } from "./compound-types";
|
|
21
|
-
import type { DbRow } from "./connection";
|
|
21
|
+
import type { DbRow, DbRunner } from "./connection";
|
|
22
22
|
import type { EventStoreExecutor } from "./event-store-executor";
|
|
23
23
|
import {
|
|
24
24
|
buildEventMetadata,
|
|
@@ -26,7 +26,7 @@ import {
|
|
|
26
26
|
entityEventName,
|
|
27
27
|
tryMapUniqueViolation,
|
|
28
28
|
} from "./event-store-executor-context";
|
|
29
|
-
import { selectMany } from "./query";
|
|
29
|
+
import { runInSavepointIfSupported, selectMany } from "./query";
|
|
30
30
|
|
|
31
31
|
// The five write verbs (create/update/delete/forget/restore) of the event-
|
|
32
32
|
// store-executor. Split out of event-store-executor.ts (#1005, Welle 2) —
|
|
@@ -190,30 +190,34 @@ export function createWriteVerbs(
|
|
|
190
190
|
// selben catch (siehe line 493+).
|
|
191
191
|
let event: Awaited<ReturnType<typeof append>>;
|
|
192
192
|
try {
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
193
|
+
// Savepoint-scoped: postgres.js/Bun.SQL poison the WHOLE surrounding
|
|
194
|
+
// begin() once any statement inside it errors, even if the JS error
|
|
195
|
+
// is caught (kumiko-framework#1778) — a losing concurrent create's
|
|
196
|
+
// unique-violation would otherwise abort the caller's outer
|
|
197
|
+
// transaction and surface as internal_error at commit time instead
|
|
198
|
+
// of the version_conflict this catch classifies. runInSavepointIfSupported
|
|
199
|
+
// confines the failed INSERT to a nested scope that rolls back on
|
|
200
|
+
// its own (same pattern as ctx.tryAppendEvent), and falls back to a
|
|
201
|
+
// plain call when db.raw is a bare pool connection with no active
|
|
202
|
+
// transaction to poison (seeds/tests calling the executor directly).
|
|
203
|
+
event = await runInSavepointIfSupported(db.raw, (sp) =>
|
|
204
|
+
append(sp as DbRunner, {
|
|
205
|
+
aggregateId,
|
|
206
|
+
aggregateType: entityName,
|
|
207
|
+
tenantId: streamTenantFor(user),
|
|
208
|
+
expectedVersion: 0,
|
|
209
|
+
type: entityEventName(entityName, "created"),
|
|
210
|
+
payload: flatData,
|
|
211
|
+
metadata: buildEventMetadata(user),
|
|
212
|
+
}),
|
|
213
|
+
);
|
|
202
214
|
} catch (e) {
|
|
203
215
|
if (e instanceof EventStoreVersionConflict) {
|
|
204
|
-
// Try to look up the real stream-version for the diagnostic — but
|
|
205
|
-
// wrap defensively: when `append` raised the unique-violation, the
|
|
206
|
-
// current TX is already aborted, and a second query on the same
|
|
207
|
-
// runner would re-throw "current transaction is aborted". Update-
|
|
208
|
-
// path doesn't have this problem (it queries getStreamVersion
|
|
209
|
-
// BEFORE the try-block). Falling back to a sentinel keeps the
|
|
210
|
-
// version_conflict mapping reliable; the actual current version
|
|
211
|
-
// is recoverable client-side via a fresh detail-query if needed.
|
|
212
216
|
let currentVersion = -1;
|
|
213
217
|
try {
|
|
214
218
|
currentVersion = await getStreamVersion(db.raw, aggregateId, streamTenantFor(user));
|
|
215
219
|
} catch {
|
|
216
|
-
//
|
|
220
|
+
// Lookup failure — keep the sentinel.
|
|
217
221
|
}
|
|
218
222
|
return writeFailure(
|
|
219
223
|
new FrameworkVersionConflict({
|
|
@@ -412,18 +416,24 @@ export function createWriteVerbs(
|
|
|
412
416
|
// re-encrypt it before it's persisted so plaintext of pii/encrypted
|
|
413
417
|
// fields doesn't land in the immutable log (flatChanges is already
|
|
414
418
|
// ciphertext from encryptForStorage above).
|
|
415
|
-
const
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
419
|
+
const encryptedPrevious = await encryptForStorage(previous, user);
|
|
420
|
+
// Savepoint-scoped — see the create() append() above for why:
|
|
421
|
+
// confines a losing writer's unique-violation to a nested scope
|
|
422
|
+
// instead of poisoning the whole outer transaction.
|
|
423
|
+
const event = await runInSavepointIfSupported(db.raw, (sp) =>
|
|
424
|
+
append(sp as DbRunner, {
|
|
425
|
+
aggregateId: String(payload.id),
|
|
426
|
+
aggregateType: entityName,
|
|
427
|
+
tenantId: streamTenantFor(user),
|
|
428
|
+
expectedVersion: currentVersion,
|
|
429
|
+
type: entityEventName(entityName, "updated"),
|
|
430
|
+
payload: {
|
|
431
|
+
changes: flatChanges,
|
|
432
|
+
previous: encryptedPrevious,
|
|
433
|
+
},
|
|
434
|
+
metadata: buildEventMetadata(user),
|
|
435
|
+
}),
|
|
436
|
+
);
|
|
427
437
|
|
|
428
438
|
// Live==Rebuild via applyEntityEvent mit demselben StoredEvent —
|
|
429
439
|
// apply liest nur `changes`, und die sind live wie im Replay
|
package/src/db/query.ts
CHANGED
|
@@ -115,6 +115,65 @@ describe("r.contentCollection() — registration", () => {
|
|
|
115
115
|
expect(feature.contentCollections?.["signatures"]?.ownership).toBe("user");
|
|
116
116
|
});
|
|
117
117
|
|
|
118
|
+
test("records contentFormat so the client can resolve the right editor", () => {
|
|
119
|
+
const feature = defineFeature("mail", (r) => {
|
|
120
|
+
r.contentCollection({
|
|
121
|
+
id: "prompts",
|
|
122
|
+
kind: "ai-prompt",
|
|
123
|
+
contentFormat: "plain",
|
|
124
|
+
nav: { label: "mail:nav.prompts" },
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
expect(feature.contentCollections?.["prompts"]?.contentFormat).toBe("plain");
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("contentFormat is optional — undefined when the app didn't declare one", () => {
|
|
132
|
+
const feature = defineFeature("mail", (r) => {
|
|
133
|
+
r.contentCollection({ id: "templates", kind: "mail-html", nav: { label: "a" } });
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
expect(feature.contentCollections?.["templates"]?.contentFormat).toBeUndefined();
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("records contentFormat markdown as a third format alongside plain/rich", () => {
|
|
140
|
+
const feature = defineFeature("mail", (r) => {
|
|
141
|
+
r.contentCollection({
|
|
142
|
+
id: "notes",
|
|
143
|
+
kind: "text-block",
|
|
144
|
+
contentFormat: "markdown",
|
|
145
|
+
nav: { label: "mail:nav.notes" },
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
expect(feature.contentCollections?.["notes"]?.contentFormat).toBe("markdown");
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test("records variableSchema so the client can offer the right chips", () => {
|
|
153
|
+
const feature = defineFeature("mail", (r) => {
|
|
154
|
+
r.contentCollection({
|
|
155
|
+
id: "prompts",
|
|
156
|
+
kind: "ai-prompt",
|
|
157
|
+
contentFormat: "plain",
|
|
158
|
+
variableSchema: { customerName: "Max Mustermann", orderId: "A-1042" },
|
|
159
|
+
nav: { label: "mail:nav.prompts" },
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
expect(feature.contentCollections?.["prompts"]?.variableSchema).toEqual({
|
|
164
|
+
customerName: "Max Mustermann",
|
|
165
|
+
orderId: "A-1042",
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test("variableSchema is optional — undefined when the app didn't declare one", () => {
|
|
170
|
+
const feature = defineFeature("mail", (r) => {
|
|
171
|
+
r.contentCollection({ id: "templates", kind: "mail-html", nav: { label: "a" } });
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
expect(feature.contentCollections?.["templates"]?.variableSchema).toBeUndefined();
|
|
175
|
+
});
|
|
176
|
+
|
|
118
177
|
test("rejects a second collection with the same id", () => {
|
|
119
178
|
expect(() =>
|
|
120
179
|
defineFeature("mail", (r) => {
|
|
@@ -179,6 +238,7 @@ describe("buildAppSchema — content collections", () => {
|
|
|
179
238
|
r.contentCollection({
|
|
180
239
|
id: "templates",
|
|
181
240
|
kind: "mail-html",
|
|
241
|
+
contentFormat: "rich",
|
|
182
242
|
nav: { label: "mail:nav.templates", parent: "mail:nav:root" },
|
|
183
243
|
});
|
|
184
244
|
}),
|
|
@@ -190,6 +250,7 @@ describe("buildAppSchema — content collections", () => {
|
|
|
190
250
|
{
|
|
191
251
|
id: "templates",
|
|
192
252
|
kind: "mail-html",
|
|
253
|
+
contentFormat: "rich",
|
|
193
254
|
nav: { label: "mail:nav.templates", parent: "mail:nav:root" },
|
|
194
255
|
navQn: "mail:nav:templates",
|
|
195
256
|
},
|
|
@@ -199,6 +260,40 @@ describe("buildAppSchema — content collections", () => {
|
|
|
199
260
|
expect(mail?.navs?.map((n) => n.id)).toContain("templates");
|
|
200
261
|
});
|
|
201
262
|
|
|
263
|
+
test("projects variableSchema through to the client schema", () => {
|
|
264
|
+
const registry = createRegistry([
|
|
265
|
+
defineFeature("mail", (r) => {
|
|
266
|
+
r.contentCollection({
|
|
267
|
+
id: "prompts",
|
|
268
|
+
kind: "ai-prompt",
|
|
269
|
+
contentFormat: "plain",
|
|
270
|
+
variableSchema: { customerName: "Max Mustermann" },
|
|
271
|
+
nav: { label: "mail:nav.prompts" },
|
|
272
|
+
});
|
|
273
|
+
}),
|
|
274
|
+
]);
|
|
275
|
+
|
|
276
|
+
const schema = buildAppSchema(registry);
|
|
277
|
+
const mail = schema.features.find((f) => f.featureName === "mail");
|
|
278
|
+
expect(mail?.contentCollections?.[0]?.variableSchema).toEqual({
|
|
279
|
+
customerName: "Max Mustermann",
|
|
280
|
+
});
|
|
281
|
+
// buildAppSchema's JSON-safety check throws on undefined leaves — proves
|
|
282
|
+
// the new field survives that check instead of only the toEqual above.
|
|
283
|
+
expect(() =>
|
|
284
|
+
validateBoot([
|
|
285
|
+
defineFeature("mail2", (r) => {
|
|
286
|
+
r.contentCollection({
|
|
287
|
+
id: "prompts",
|
|
288
|
+
kind: "ai-prompt",
|
|
289
|
+
variableSchema: { customerName: "Max Mustermann" },
|
|
290
|
+
nav: { label: "mail2:nav.prompts" },
|
|
291
|
+
});
|
|
292
|
+
}),
|
|
293
|
+
]),
|
|
294
|
+
).not.toThrow();
|
|
295
|
+
});
|
|
296
|
+
|
|
202
297
|
test("omits the slot for features without collections", () => {
|
|
203
298
|
const registry = createRegistry([
|
|
204
299
|
defineFeature("shop", (r) => {
|
|
@@ -14,6 +14,9 @@ import {
|
|
|
14
14
|
registerEntityCrud,
|
|
15
15
|
} from "../entity-handlers";
|
|
16
16
|
import { createEntity, createTextField } from "../factories";
|
|
17
|
+
// Barrel import, not "../entity-handlers": covers that entityListSchema is
|
|
18
|
+
// actually re-exported through engine/index.ts.
|
|
19
|
+
import { entityListSchema } from "../index";
|
|
17
20
|
import type { QueryHandlerDef, WriteHandlerDef } from "../types";
|
|
18
21
|
|
|
19
22
|
const VALID_UUID = "00000000-0000-4000-8000-000000000001";
|
|
@@ -119,6 +122,19 @@ describe("defineEntityQueryHandler", () => {
|
|
|
119
122
|
expect(def.schema.safeParse({ sortDirection: "wrong" }).success).toBe(false);
|
|
120
123
|
});
|
|
121
124
|
|
|
125
|
+
test("list: schema is the exported entityListSchema, not a private copy", () => {
|
|
126
|
+
const def = defineEntityListHandler("note", noteEntity);
|
|
127
|
+
expect(def.schema).toBe(entityListSchema);
|
|
128
|
+
// money-horse#293: a consumer copy silently dropped these fields when it
|
|
129
|
+
// drifted from the handler's actual schema — assert they still round-trip.
|
|
130
|
+
expect(
|
|
131
|
+
def.schema.safeParse({
|
|
132
|
+
includeDeleted: true,
|
|
133
|
+
filters: [{ field: "title", op: "eq", value: "x" }],
|
|
134
|
+
}).success,
|
|
135
|
+
).toBe(true);
|
|
136
|
+
});
|
|
137
|
+
|
|
122
138
|
test("detail: schema requires id", () => {
|
|
123
139
|
const def = defineEntityDetailHandler("note", noteEntity);
|
|
124
140
|
expect(def.schema.safeParse({ id: VALID_UUID }).success).toBe(true);
|
|
@@ -102,7 +102,7 @@ type ListPayload = {
|
|
|
102
102
|
};
|
|
103
103
|
|
|
104
104
|
const idSchema = z.object({ id: z.uuid() });
|
|
105
|
-
const
|
|
105
|
+
export const entityListSchema = z.object({
|
|
106
106
|
cursor: z.string().optional(),
|
|
107
107
|
limit: z.number().optional(),
|
|
108
108
|
search: z.string().optional(),
|
|
@@ -292,7 +292,7 @@ export function defineEntityQueryHandler(
|
|
|
292
292
|
|
|
293
293
|
switch (verb) {
|
|
294
294
|
case "list":
|
|
295
|
-
schema =
|
|
295
|
+
schema = entityListSchema;
|
|
296
296
|
handler = async (query, ctx) => {
|
|
297
297
|
// Tier 2.7e Audit-Fix: SearchAdapter aus ctx durchreichen,
|
|
298
298
|
// damit payload.search zur Laufzeit gegen Meilisearch/InMem
|
package/src/engine/index.ts
CHANGED
|
@@ -63,6 +63,34 @@ const workerWriteFeature = defineFeature("workerWrite", (r) => {
|
|
|
63
63
|
});
|
|
64
64
|
});
|
|
65
65
|
|
|
66
|
+
// The job-runner is built BEFORE the server, so it used to capture the raw
|
|
67
|
+
// caller context — without the per-tenant file-provider resolver buildServer
|
|
68
|
+
// wires onto it. An event-triggered job reaching for ctx.files then died in
|
|
69
|
+
// the worker while the identical code worked on the request path.
|
|
70
|
+
const jobSawFileResolver: string[] = [];
|
|
71
|
+
|
|
72
|
+
const fileJobFeature = defineFeature("fileJob", (r) => {
|
|
73
|
+
const requested = r.defineEvent("bytes-requested", z.object({ storageKey: z.string() }), {
|
|
74
|
+
version: 1,
|
|
75
|
+
});
|
|
76
|
+
// Stands in for file-foundation, which this package cannot import.
|
|
77
|
+
r.extendsRegistrar("fileProvider", { onRegister: () => undefined });
|
|
78
|
+
r.useExtension("fileProvider", "spy", {
|
|
79
|
+
build: async () => {
|
|
80
|
+
throw new Error("no provider is built in this test — presence of the resolver is the point");
|
|
81
|
+
},
|
|
82
|
+
});
|
|
83
|
+
r.job(
|
|
84
|
+
"read-bytes",
|
|
85
|
+
{ trigger: { on: requested.name }, runIn: "worker" },
|
|
86
|
+
async (_payload, ctx) => {
|
|
87
|
+
jobSawFileResolver.push(typeof ctx._fileProviderResolver);
|
|
88
|
+
},
|
|
89
|
+
);
|
|
90
|
+
// Worker mode refuses to boot without a consumer to drain.
|
|
91
|
+
r.multiStreamProjection({ name: "noop", apply: { [requested.name]: async () => {} } });
|
|
92
|
+
});
|
|
93
|
+
|
|
66
94
|
async function waitForCondition(check: () => boolean, timeoutMs = 5000): Promise<void> {
|
|
67
95
|
const deadline = Date.now() + timeoutMs;
|
|
68
96
|
while (!check()) {
|
|
@@ -137,6 +165,32 @@ describe("entrypoint factories", () => {
|
|
|
137
165
|
await worker.stop();
|
|
138
166
|
});
|
|
139
167
|
|
|
168
|
+
test("Worker job-context carries the file-provider resolver, not just the request path", async () => {
|
|
169
|
+
const registry = createRegistry([fileJobFeature]);
|
|
170
|
+
const redisUrl = `redis://${testRedis.redis.options.host}:${testRedis.redis.options.port}/${testRedis.redis.options.db}`;
|
|
171
|
+
const worker = createWorkerEntrypoint({
|
|
172
|
+
registry,
|
|
173
|
+
context: { db: testDb.db, redis: testRedis.redis },
|
|
174
|
+
jwtSecret: JWT,
|
|
175
|
+
redisUrl,
|
|
176
|
+
queueNamePrefix: uniquePrefix("split-filejob"),
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
jobSawFileResolver.length = 0;
|
|
180
|
+
await worker.start();
|
|
181
|
+
try {
|
|
182
|
+
await worker.jobRunner.handleEvent(
|
|
183
|
+
"file-job:event:bytes-requested",
|
|
184
|
+
{ storageKey: "some/key.pdf" },
|
|
185
|
+
TestUsers.admin,
|
|
186
|
+
);
|
|
187
|
+
await waitForCondition(() => jobSawFileResolver.length > 0);
|
|
188
|
+
expect(jobSawFileResolver[0]).toBe("function");
|
|
189
|
+
} finally {
|
|
190
|
+
await worker.stop();
|
|
191
|
+
}
|
|
192
|
+
});
|
|
193
|
+
|
|
140
194
|
// An app-wired component running in the worker (analysis service, IMAP
|
|
141
195
|
// supervisor) has to persist its result, and persisting goes through the
|
|
142
196
|
// write-path — JobContext has no write/query. The dispatcher is the only
|
package/src/entrypoint/index.ts
CHANGED
|
@@ -34,7 +34,7 @@ import type { Hono } from "hono";
|
|
|
34
34
|
import type { AuthRoutesConfig } from "../api/auth-routes";
|
|
35
35
|
import type { JwtHelper, JwtKeyring } from "../api/jwt";
|
|
36
36
|
import type { KumikoServer, ServerOptions } from "../api/server";
|
|
37
|
-
import { buildServer } from "../api/server";
|
|
37
|
+
import { buildServer, withFileProviderResolver } from "../api/server";
|
|
38
38
|
import type { SseBroker } from "../api/sse-broker";
|
|
39
39
|
import type { PgClient } from "../db/connection";
|
|
40
40
|
import type { EffectiveFeaturesResolver } from "../engine/tier-resolver-extension";
|
|
@@ -322,6 +322,7 @@ function requireDispatcher(server: KumikoServer, mode: string): EventDispatcher
|
|
|
322
322
|
export function createApiEntrypoint(options: ApiEntrypointOptions): ApiEntrypoint {
|
|
323
323
|
const lifecycle = options.lifecycle ?? createLifecycle({ startReady: true });
|
|
324
324
|
const observability = resolveObservability(options.observability);
|
|
325
|
+
const context = withFileProviderResolver(options.registry, options.context);
|
|
325
326
|
|
|
326
327
|
// Boot-validation (Welle 2.6.c) — fail loud before traffic arrives:
|
|
327
328
|
// (a) Any jobs declared + no jobs-block → command-dispatcher would
|
|
@@ -356,7 +357,7 @@ export function createApiEntrypoint(options: ApiEntrypointOptions): ApiEntrypoin
|
|
|
356
357
|
? buildJobRunnerWithHook(
|
|
357
358
|
options.registry,
|
|
358
359
|
contextWithObservability(
|
|
359
|
-
|
|
360
|
+
context,
|
|
360
361
|
observability,
|
|
361
362
|
options.dispatcherOptions?.effectiveFeatures,
|
|
362
363
|
),
|
|
@@ -374,7 +375,7 @@ export function createApiEntrypoint(options: ApiEntrypointOptions): ApiEntrypoin
|
|
|
374
375
|
// apply anywhere.
|
|
375
376
|
const { runLocal: runLocalDispatcher, ...dispatcherTunables } = options.eventDispatcher ?? {};
|
|
376
377
|
const server = buildApiServer(
|
|
377
|
-
options,
|
|
378
|
+
{ ...options, context },
|
|
378
379
|
lifecycle,
|
|
379
380
|
runLocalDispatcher ? dispatcherTunables : { disabled: true },
|
|
380
381
|
apiJobRunner,
|
|
@@ -411,19 +412,16 @@ export function createApiEntrypoint(options: ApiEntrypointOptions): ApiEntrypoin
|
|
|
411
412
|
export function createWorkerEntrypoint(options: WorkerEntrypointOptions): WorkerEntrypoint {
|
|
412
413
|
const lifecycle = options.lifecycle ?? createLifecycle({ startReady: true });
|
|
413
414
|
const observability = resolveObservability(options.observability);
|
|
415
|
+
const context = withFileProviderResolver(options.registry, options.context);
|
|
414
416
|
const jobRunner = buildJobRunnerWithHook(
|
|
415
417
|
options.registry,
|
|
416
|
-
contextWithObservability(
|
|
417
|
-
options.context,
|
|
418
|
-
observability,
|
|
419
|
-
options.dispatcherOptions?.effectiveFeatures,
|
|
420
|
-
),
|
|
418
|
+
contextWithObservability(context, observability, options.dispatcherOptions?.effectiveFeatures),
|
|
421
419
|
options,
|
|
422
420
|
"worker",
|
|
423
421
|
lifecycle,
|
|
424
422
|
"jobRunner",
|
|
425
423
|
);
|
|
426
|
-
const server = buildWorkerServer(options, lifecycle, jobRunner);
|
|
424
|
+
const server = buildWorkerServer({ ...options, context }, lifecycle, jobRunner);
|
|
427
425
|
const eventDispatcher = requireDispatcher(server, "worker");
|
|
428
426
|
|
|
429
427
|
return {
|
|
@@ -448,8 +446,9 @@ export function createWorkerEntrypoint(options: WorkerEntrypointOptions): Worker
|
|
|
448
446
|
export function createAllInOneEntrypoint(options: AllInOneEntrypointOptions): AllInOneEntrypoint {
|
|
449
447
|
const lifecycle = options.lifecycle ?? createLifecycle({ startReady: true });
|
|
450
448
|
const observability = resolveObservability(options.observability);
|
|
449
|
+
const context = withFileProviderResolver(options.registry, options.context);
|
|
451
450
|
const jobRunnerContext = contextWithObservability(
|
|
452
|
-
|
|
451
|
+
context,
|
|
453
452
|
observability,
|
|
454
453
|
options.dispatcherOptions?.effectiveFeatures,
|
|
455
454
|
);
|
|
@@ -486,7 +485,7 @@ export function createAllInOneEntrypoint(options: AllInOneEntrypointOptions): Al
|
|
|
486
485
|
// the API-mode flag — all-in-one is always local, strip it.
|
|
487
486
|
const { runLocal: _runLocal, ...allInOneDispatcherTunables } = options.eventDispatcher ?? {};
|
|
488
487
|
const server = buildApiServer(
|
|
489
|
-
options,
|
|
488
|
+
{ ...options, context },
|
|
490
489
|
lifecycle,
|
|
491
490
|
allInOneDispatcherTunables,
|
|
492
491
|
workerJobRunner,
|
package/src/stack/test-stack.ts
CHANGED
|
@@ -6,7 +6,7 @@ import { createSseBroker, type SseBroker } from "../api/sse-broker";
|
|
|
6
6
|
import type { PgClient } from "../db/connection";
|
|
7
7
|
import { extractTableInfo } from "../db/query";
|
|
8
8
|
import { createRegistry } from "../engine/registry";
|
|
9
|
-
import type { FeatureDefinition, JobRunIn, Registry, TenantId } from "../engine/types";
|
|
9
|
+
import type { AppContext, FeatureDefinition, JobRunIn, Registry, TenantId } from "../engine/types";
|
|
10
10
|
import { createArchivedStreamsTable, createEventsTable } from "../event-store";
|
|
11
11
|
import { createJobRunner, type JobRunner } from "../jobs";
|
|
12
12
|
import type { Lifecycle } from "../lifecycle";
|
|
@@ -39,6 +39,12 @@ export type TestStack = {
|
|
|
39
39
|
// Command-dispatcher behind the HTTP routes — for direct system-writes
|
|
40
40
|
// in tests and dev-server extraRoutes (provider-webhook wiring).
|
|
41
41
|
dispatcher: Dispatcher;
|
|
42
|
+
// The AppContext buildServer handed the request path, incl. the fields it
|
|
43
|
+
// wires itself (_fileProviderResolver). A dev-server that starts its own
|
|
44
|
+
// lane job-runners beside this stack must hand them THIS, not a
|
|
45
|
+
// `{ db, registry }` literal — that's the #1232 drift, and it makes an
|
|
46
|
+
// event-triggered job reaching for ctx.files die where the request path works.
|
|
47
|
+
context: AppContext;
|
|
42
48
|
// Present whenever a system consumer (SSE, Search) or
|
|
43
49
|
// r.multiStreamProjection is wired. Tests drain it via runOnce() for
|
|
44
50
|
// deterministic assertion — no timer-induced flakiness.
|
|
@@ -441,6 +447,7 @@ export async function setupTestStack(options: TestStackOptions): Promise<TestSta
|
|
|
441
447
|
observability: server.observability,
|
|
442
448
|
sseBroker,
|
|
443
449
|
dispatcher: server.dispatcher,
|
|
450
|
+
context: server.context,
|
|
444
451
|
...(eventDispatcher ? { eventDispatcher } : {}),
|
|
445
452
|
...(server.lifecycle ? { lifecycle: server.lifecycle } : {}),
|
|
446
453
|
...(jobRunner ? { jobRunner } : {}),
|