@cosmicdrift/kumiko-framework 0.371.1 → 0.372.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.
Files changed (65) hide show
  1. package/AGENTS.md +4 -1
  2. package/agents/examples/custom-handlers.md +3 -5
  3. package/agents/examples/embedded-entity-form.md +1 -1
  4. package/agents/examples/pdf-audit-export.md +1 -1
  5. package/agents/examples/user-data-rights.md +176 -0
  6. package/dist/api/jwt.js +2 -1
  7. package/dist/api/observability-middleware.d.ts +1 -2
  8. package/dist/api/observability-middleware.js +9 -8
  9. package/dist/api/pii-leak-guard.js +2 -1
  10. package/dist/api/route-label.d.ts +3 -0
  11. package/dist/api/route-label.js +17 -0
  12. package/dist/api/server.d.ts +1 -2
  13. package/dist/api/server.js +3 -5
  14. package/dist/config/index.d.ts +5 -0
  15. package/dist/db/event-store-executor.d.ts +2 -1
  16. package/dist/db/index.d.ts +2 -2
  17. package/dist/db/index.js +1 -1
  18. package/dist/db/located-timestamp.d.ts +7 -0
  19. package/dist/db/query.d.ts +1 -1
  20. package/dist/db/query.js +8 -2
  21. package/dist/db/table-builder.d.ts +45 -11
  22. package/dist/db/tenant-db.js +3 -1
  23. package/dist/engine/entity-handlers.d.ts +3 -3
  24. package/dist/engine/factories.d.ts +2 -1
  25. package/dist/engine/factories.js +2 -0
  26. package/dist/engine/membership-roles.d.ts +1 -0
  27. package/dist/engine/membership-roles.js +8 -0
  28. package/dist/engine/strip-disabled-features-from-app-schema.js +30 -2
  29. package/dist/entrypoint/index.d.ts +1 -0
  30. package/dist/entrypoint/index.js +0 -2
  31. package/dist/files/assert-file-refs-belong-to-tenant.js +27 -1
  32. package/dist/files/create-file-ref-from-bytes.js +12 -6
  33. package/dist/files/create-file-ref.d.ts +20 -2
  34. package/dist/files/create-file-ref.js +10 -1
  35. package/dist/files/feature.d.ts +1 -0
  36. package/dist/files/feature.js +25 -0
  37. package/dist/files/file-handle.d.ts +4 -1
  38. package/dist/files/file-handle.js +30 -4
  39. package/dist/files/file-ref-entity.d.ts +7 -7
  40. package/dist/files/file-ref-table.d.ts +7 -7
  41. package/dist/files/file-routes.js +6 -1
  42. package/dist/files/index.d.ts +3 -1
  43. package/dist/files/index.js +3 -1
  44. package/dist/files/provider-resolver.js +3 -1
  45. package/dist/files/sweep-orphaned-uploads.d.ts +16 -0
  46. package/dist/files/sweep-orphaned-uploads.js +53 -0
  47. package/dist/files/upload-ledger.d.ts +10 -0
  48. package/dist/files/upload-ledger.js +47 -0
  49. package/dist/i18n/index.d.ts +1 -1
  50. package/dist/i18n/index.js +1 -1
  51. package/dist/i18n/request-locale.d.ts +15 -4
  52. package/dist/i18n/request-locale.js +26 -9
  53. package/dist/jobs/job-runner.js +2 -4
  54. package/dist/pipeline/dispatch-shared.js +2 -4
  55. package/dist/pipeline/index.d.ts +1 -0
  56. package/dist/pipeline/index.js +1 -0
  57. package/dist/rate-limit/index.d.ts +1 -0
  58. package/dist/rate-limit/index.js +1 -0
  59. package/dist/rate-limit/unlimited-resolver.d.ts +2 -0
  60. package/dist/rate-limit/unlimited-resolver.js +21 -0
  61. package/dist/schema-cli.js +46 -5
  62. package/package.json +5 -5
  63. package/src/changes.json +42 -0
  64. package/src/scripts/codemod/README.md +22 -0
  65. package/src/scripts/codemod/remove-engine-payload-casts.ts +318 -0
package/AGENTS.md CHANGED
@@ -57,7 +57,10 @@ verbs off with `verbs: { delete: false }` and register them yourself when
57
57
  they need different access or logic. Custom logic goes into
58
58
  `r.writeHandler({ name, schema, handler, access })` and
59
59
  `r.queryHandler({ name, schema, handler, access })`; inside, use the entity
60
- executor from `createEntityExecutor(name, entity)`.
60
+ executor from `createEntityExecutor(name, entity)`. Its `detail` and `list`
61
+ return rows typed from the entity (`EntityRow<typeof entity>`), so
62
+ `row.title` needs no cast; money and located-timestamp fields come back as
63
+ objects, and `create` / `update` payloads stay untyped.
61
64
 
62
65
  **Screen.** A declarative page: `r.screen({ id, type, ... })`. Types include
63
66
  `entityList`, `entityEdit`, `projectionList`, `projectionDetail`,
@@ -326,9 +326,6 @@ import {
326
326
  import { failNotFound } from "@cosmicdrift/kumiko-framework/errors";
327
327
  import { z } from "zod";
328
328
 
329
- // detail() returns an untyped row; parse the two fields the increment needs once.
330
- const counterVersionRowSchema = z.object({ count: z.number(), version: z.number() });
331
-
332
329
  export const counterEntity = createEntity({
333
330
  table: "read_sample_counters",
334
331
  fields: {
@@ -365,8 +362,9 @@ export const counterFeature = defineFeature("counters", (r) => {
365
362
  return failNotFound("counter", event.payload.id);
366
363
  }
367
364
 
368
- const { count, version } = counterVersionRowSchema.parse(current);
369
- const newCount = count + event.payload.amount;
365
+ // detail() returns the entity's own row type; count is nullable because the field is not required.
366
+ const { count, version } = current;
367
+ const newCount = (count ?? 0) + event.payload.amount;
370
368
  // Read-modify-write on counter: use the version we just read so two
371
369
  // concurrent increments don't clobber each other's changes.
372
370
  return counterExecutor.update(
@@ -427,7 +427,7 @@ export const prospectsFeature = defineFeature("prospects", (r) => {
427
427
  const flipped = await suggestionExecutor.update(
428
428
  {
429
429
  id: event.payload.suggestionId,
430
- version: suggestion["version"] as number,
430
+ version: suggestion["version"],
431
431
  changes: { status: "accepted" },
432
432
  },
433
433
  event.user,
@@ -14,7 +14,7 @@ A write handler exports tenant data as a PDF and keeps it as a FileRef.
14
14
 
15
15
  - `renderTablePdf` (`@cosmicdrift/kumiko-bundled-features/renderer-pdf`) draws a table document in pure JavaScript: repeating header, weighted columns, page footer. The handler passes ready-formatted strings.
16
16
  - The export is a `beforeTransaction` handler: it reads the rows (reads work in that phase), renders the PDF and calls `uploadFileBytesBeforeTransaction` (`@cosmicdrift/kumiko-framework/files`), which validates the bytes and writes them to the tenant-prefixed storage key. The upload runs before the transaction because storage I/O inside it would hold a pool connection for as long as the upload takes.
17
- - The handler itself calls `createFileRefFromUploadedBytes(ctx, event.prepared)` and creates the `file_refs` row and the `fileRef.created` event in the transaction. If the transaction does not commit, the phase's `release` deletes the uploaded bytes again (only when no row exists for the storage key). A crash between upload and commit leaves the bytes in storage; nothing sweeps them.
17
+ - The handler itself calls `createFileRefFromUploadedBytes(ctx, event.prepared)` and creates the `file_refs` row and the `fileRef.created` event in the transaction. If the transaction does not commit, the phase's `release` deletes the uploaded bytes again (only when no row exists for the storage key). A crash between upload and commit leaves the bytes in storage; the hourly `files:job:sweep-orphaned-uploads` job deletes them once they are older than the grace period (24 hours by default, `uploadSweepGraceHours`).
18
18
  - The result is a normal FileRef: `GET /api/files/:id` serves it with `Content-Type: application/pdf`, other tenants get 404.
19
19
 
20
20
  ## Source
@@ -83,6 +83,28 @@ Step-by-step proof:
83
83
  2. Deletion + expired grace → `runForgetCleanup` removes all notes
84
84
  3. Strategy `anonymize` → `authorId=null`, title/body remain
85
85
 
86
+ ## Retention with files
87
+
88
+ `runRetentionCleanup` is exported for app tests. A `hardDelete` entity with a file field loses
89
+ its bytes (and derivatives), its `file_refs` row and then the row itself; a storage error keeps
90
+ the row and reports `file_delete_failed` in `skipped`. `src/retained-docs.ts` holds the entity,
91
+ `src/__tests__/retention-files.integration.test.ts` drives the run with only public imports:
92
+
93
+ ```ts illustration
94
+ import { runRetentionCleanup } from "@cosmicdrift/kumiko-bundled-features/data-retention";
95
+ import { createFileContext } from "@cosmicdrift/kumiko-framework/files";
96
+
97
+ const result = await runRetentionCleanup({
98
+ db: stack.db,
99
+ registry: stack.registry,
100
+ tenantId,
101
+ preloadedTenantPreset: null,
102
+ now: getTemporal().Now.instant().add({ hours: 31 * 24 }), // keepFor 30d + 1 day
103
+ files: createFileContext(() => Promise.resolve(provider)), // the stack's storage provider
104
+ });
105
+ // result.hardDeleted === 1, bytes and file_refs row gone
106
+ ```
107
+
86
108
  ## Full app vs this recipe
87
109
 
88
110
  | | This recipe | [user-data-rights-demo](/en/samples/apps-user-data-rights-demo/) |
@@ -300,6 +322,120 @@ describe("user-data-rights recipe :: EXT_USER_DATA-Hooks integrieren Notes-Domai
300
322
  });
301
323
  ```
302
324
 
325
+ ### src/__tests__/retention-files.integration.test.ts
326
+
327
+ ```ts
328
+ // runRetentionCleanup is public API: an app test drives the retention run with its own `now`
329
+ // and the stack's storage provider, then checks that the file bytes, the file_refs row and the
330
+ // entity row are gone and the erasure is on the audit trail.
331
+
332
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
333
+ import {
334
+ createDataRetentionFeature,
335
+ runRetentionCleanup,
336
+ tenantRetentionOverrideEntity,
337
+ } from "@cosmicdrift/kumiko-bundled-features/data-retention";
338
+ import { selectMany } from "@cosmicdrift/kumiko-framework/db";
339
+ import { eventsTable } from "@cosmicdrift/kumiko-framework/event-store";
340
+ import {
341
+ createFileContext,
342
+ createFilesFeature,
343
+ createInMemoryFileProvider,
344
+ fileRefsTable,
345
+ } from "@cosmicdrift/kumiko-framework/files";
346
+ import { createTestUser, testTenantId } from "@cosmicdrift/kumiko-framework/seeding";
347
+ import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
348
+ import {
349
+ buildMultipartBody,
350
+ patchFileInstanceofForBunTest,
351
+ } from "@cosmicdrift/kumiko-testing/helpers";
352
+ import {
353
+ setupTestStack,
354
+ type TestStack,
355
+ unsafeCreateEntityTable,
356
+ } from "@cosmicdrift/kumiko-testing/stack";
357
+ import { retainedDocEntity, retainedDocsFeature } from "../retained-docs";
358
+
359
+ const CREATE = "retained-docs:write:retained-doc:create";
360
+ const DETAIL = "retained-docs:query:retained-doc:detail";
361
+ const tenantId = testTenantId(61);
362
+ const admin = createTestUser({ id: 61, tenantId, roles: ["Admin"] });
363
+ const provider = createInMemoryFileProvider();
364
+
365
+ let stack: TestStack;
366
+
367
+ beforeAll(async () => {
368
+ patchFileInstanceofForBunTest();
369
+ stack = await setupTestStack({
370
+ features: [createFilesFeature(), createDataRetentionFeature(), retainedDocsFeature],
371
+ files: { storageProvider: provider },
372
+ });
373
+ await unsafeCreateEntityTable(stack.db, retainedDocEntity, "retained-doc");
374
+ await unsafeCreateEntityTable(stack.db, tenantRetentionOverrideEntity);
375
+ });
376
+
377
+ afterAll(async () => {
378
+ await stack.cleanup();
379
+ });
380
+
381
+ async function upload(): Promise<string> {
382
+ const fd = new FormData();
383
+ fd.append("entityType", "retained-doc");
384
+ fd.append("fieldName", "attachment");
385
+ fd.append(
386
+ "file",
387
+ new File([Buffer.from("%PDF-1.4 minimal")], "doc.pdf", { type: "application/pdf" }),
388
+ );
389
+ const { body, contentType } = await buildMultipartBody(fd);
390
+ const res = await stack.app.request("/api/files", {
391
+ method: "POST",
392
+ headers: {
393
+ Authorization: `Bearer ${await stack.jwt.sign(admin)}`,
394
+ "Content-Type": contentType,
395
+ },
396
+ body,
397
+ });
398
+ expect(res.status).toBe(201);
399
+ return ((await res.json()) as { id: string }).id;
400
+ }
401
+
402
+ describe("user-data-rights recipe :: retention run removes a hardDelete entity's files", () => {
403
+ test("after keepFor + 1 day the row, its bytes and its file_refs row are gone; the erasure is audited", async () => {
404
+ const fileRefId = await upload();
405
+ const created = await stack.http.writeOk<{ id: string }>(
406
+ CREATE,
407
+ { title: "contract", attachment: fileRefId },
408
+ admin,
409
+ );
410
+ const [fileRef] = await selectMany<{ storageKey: string }>(stack.db, fileRefsTable, {
411
+ id: fileRefId,
412
+ });
413
+ const storageKey = String(fileRef?.storageKey);
414
+ expect(await provider.exists(storageKey)).toBe(true);
415
+
416
+ const result = await runRetentionCleanup({
417
+ db: stack.db,
418
+ registry: stack.registry,
419
+ tenantId,
420
+ preloadedTenantPreset: null,
421
+ now: getTemporal()
422
+ .Now.instant()
423
+ .add({ hours: 31 * 24 }),
424
+ files: createFileContext(() => Promise.resolve(provider)),
425
+ });
426
+
427
+ expect(result.hardDeleted).toBe(1);
428
+ expect(result.skipped).toEqual([]);
429
+ expect(await provider.exists(storageKey)).toBe(false);
430
+ expect(await selectMany(stack.db, fileRefsTable, { id: fileRefId })).toHaveLength(0);
431
+ expect(await stack.http.queryOk(DETAIL, { id: created.id }, admin)).toBeNull();
432
+
433
+ const forgotten = await selectMany(stack.db, eventsTable, { aggregateId: created.id });
434
+ expect(forgotten.map((e) => (e as { type: string }).type)).toContain("retained-doc.forgotten");
435
+ });
436
+ });
437
+ ```
438
+
303
439
  ### src/feature.ts
304
440
 
305
441
  ```ts
@@ -405,3 +541,43 @@ export const notesFeature = defineFeature("notes", (r) => {
405
541
  });
406
542
  });
407
543
  ```
544
+
545
+ ### src/retained-docs.ts
546
+
547
+ ```ts
548
+ // Retention with files: a hardDelete entity whose file field is cleaned up together with the
549
+ // row. runRetentionCleanup removes the bytes (and derivatives) and the file_refs row first,
550
+ // then the row itself; the cron in data-retention does the same with the tenant's FileContext.
551
+
552
+ import {
553
+ createEntity,
554
+ createFileField,
555
+ createTextField,
556
+ defineEntityCreateHandler,
557
+ defineEntityDetailHandler,
558
+ defineFeature,
559
+ } from "@cosmicdrift/kumiko-framework/engine";
560
+
561
+ export const retainedDocEntity = createEntity({
562
+ table: "store_retained_docs",
563
+ fields: {
564
+ title: createTextField({ personal: false, reason: "is_business_data", required: true }),
565
+ attachment: createFileField(),
566
+ },
567
+ retention: { keepFor: "30d", strategy: "hardDelete", reference: "createdAt" },
568
+ });
569
+
570
+ export const retainedDocsFeature = defineFeature("retained-docs", (r) => {
571
+ r.entity({ name: "retained-doc", ...retainedDocEntity });
572
+ r.writeHandler(
573
+ defineEntityCreateHandler("retained-doc", retainedDocEntity, {
574
+ access: { roles: ["Admin"] },
575
+ }),
576
+ );
577
+ r.queryHandler(
578
+ defineEntityDetailHandler("retained-doc", retainedDocEntity, {
579
+ access: { roles: ["Admin"] },
580
+ }),
581
+ );
582
+ });
583
+ ```
package/dist/api/jwt.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import * as jose from "jose";
2
+ import { stripReservedTokenRoles } from "../engine/membership-roles.js";
2
3
  import { parseTenantId } from "../engine/types/index.js";
3
4
  import { InternalError } from "../errors/index.js";
4
5
  function normalizeKeyring(secretOrKeyring) {
@@ -107,7 +108,7 @@ export function createJwtHelper(secretOrKeyring, issuer = "kumiko", ttlSeconds =
107
108
  const result = {
108
109
  sub: payload.sub,
109
110
  tenantId,
110
- roles,
111
+ roles: [...stripReservedTokenRoles(roles)],
111
112
  };
112
113
  if (typeof payload["timezone"] === "string") {
113
114
  result.timezone = payload["timezone"];
@@ -1,8 +1,7 @@
1
1
  import type { Context, Next } from "hono";
2
- import { type Meter, type SensitiveFilterConfig, type Tracer } from "../observability/index.ts";
2
+ import { type Meter, type Tracer } from "../observability/index.ts";
3
3
  export type ObservabilityMiddlewareOptions = {
4
4
  readonly tracer: Tracer;
5
5
  readonly meter: Meter;
6
- readonly sensitiveConfig: SensitiveFilterConfig;
7
6
  };
8
7
  export declare function observabilityMiddleware(opts: ObservabilityMiddlewareOptions): (c: Context, next: Next) => Promise<void>;
@@ -1,20 +1,21 @@
1
- import { emitHttpRequest, observabilityContext, redactQueryString, } from "../observability/index.js";
1
+ import { emitHttpRequest, observabilityContext, } from "../observability/index.js";
2
2
  import { getUser } from "./auth-middleware.js";
3
3
  import { requestContext } from "./request-context.js";
4
+ import { resolveRouteLabel } from "./route-label.js";
4
5
  export function observabilityMiddleware(opts) {
5
- const { tracer, meter, sensitiveConfig } = opts;
6
+ const { tracer, meter } = opts;
6
7
  return async (c, next) => {
7
8
  const method = c.req.method;
8
- const path = c.req.path;
9
- const target = redactQueryString(c.req.url.replace(/^https?:\/\/[^/]+/, ""), sensitiveConfig);
9
+ // The match list is fixed before the first middleware runs, so the label is known up front
10
+ // and also covers requests that a middleware answers or a handler throws on.
11
+ const route = resolveRouteLabel(c);
10
12
  // Start the root span for this request. kind=server marks it as an
11
13
  // incoming server-side span in OTel terms.
12
- const span = tracer.startSpan("http.request", {
14
+ const span = tracer.startSpan(`${method} ${route}`, {
13
15
  kind: "server",
14
16
  attributes: {
15
17
  "http.method": method,
16
- "http.route": path,
17
- "http.target": target,
18
+ "http.route": route,
18
19
  },
19
20
  });
20
21
  const reqCtx = requestContext.get();
@@ -61,7 +62,7 @@ export function observabilityMiddleware(opts) {
61
62
  // c.res may be undefined on very early throws (before any route handler);
62
63
  // fall back to 500 for the metric so the counter is always incremented.
63
64
  const status = c.res?.status ?? 500;
64
- emitHttpRequest(meter, { route: path, method, status }, durationSec);
65
+ emitHttpRequest(meter, { route, method, status }, durationSec);
65
66
  span.end();
66
67
  }
67
68
  };
@@ -1,4 +1,5 @@
1
1
  import { PII_CIPHERTEXT_PREFIX } from "../crypto/index.js";
2
+ import { resolveRouteLabel } from "./route-label.js";
2
3
  const isProductionEnv = () => process.env["NODE_ENV"] === "production";
3
4
  // Version-agnostic: catches both the current PII_CIPHERTEXT_PREFIX and any
4
5
  // older/decrypt-only format version still present in unmigrated rows.
@@ -27,7 +28,7 @@ export function piiCiphertextResponseGuard() {
27
28
  // skip: clean response — the common case
28
29
  if (!CIPHERTEXT_RE.test(text))
29
30
  return;
30
- const detail = `[api] JSON response for ${c.req.method} ${c.req.path} contains a PII ciphertext ` +
31
+ const detail = `[api] JSON response for ${c.req.method} ${resolveRouteLabel(c)} contains a PII ciphertext ` +
31
32
  `("${PII_CIPHERTEXT_PREFIX}…") — a raw DB read leaked to the API surface. ` +
32
33
  `Decrypt before returning (decryptStoredPii / executor read path).`;
33
34
  if (!isProductionEnv()) {
@@ -0,0 +1,3 @@
1
+ import type { Context } from "hono";
2
+ export declare const UNMATCHED_ROUTE_LABEL = "unmatched";
3
+ export declare function resolveRouteLabel(c: Context): string;
@@ -0,0 +1,17 @@
1
+ import { matchedRoutes } from "hono/route";
2
+ export const UNMATCHED_ROUTE_LABEL = "unmatched";
3
+ const MIDDLEWARE_METHOD = "ALL";
4
+ // Route pattern (`/api/heartbeat/:token`) instead of the raw path: raw paths carry path-param
5
+ // secrets and make every distinct id its own metric series. Middleware and the notFound fallback
6
+ // register as method "ALL", so only a method-specific match counts as a real route. Reading the
7
+ // match list instead of c.req.routeIndex keeps the label right when a middleware (401, 429)
8
+ // answers before the handler runs.
9
+ export function resolveRouteLabel(c) {
10
+ const routes = matchedRoutes(c);
11
+ for (let index = routes.length - 1; index >= 0; index--) {
12
+ const route = routes[index];
13
+ if (route && route.method !== MIDDLEWARE_METHOD)
14
+ return route.path;
15
+ }
16
+ return UNMATCHED_ROUTE_LABEL;
17
+ }
@@ -4,7 +4,7 @@ import type { PgListenClient } from "../db/connection.ts";
4
4
  import { type AppContext, type Registry, type RunIn, type WriteResult } from "../engine/types/index.ts";
5
5
  import type { ErrorDocsConfig } from "../errors/docs-url.ts";
6
6
  import type { Lifecycle } from "../lifecycle/index.ts";
7
- import { type ObservabilityOptions, type ObservabilityProvider } from "../observability/index.ts";
7
+ import { type ObservabilityProvider } from "../observability/index.ts";
8
8
  import type { DispatcherOptions } from "../pipeline/dispatcher.ts";
9
9
  import { type Dispatcher } from "../pipeline/dispatcher.ts";
10
10
  import type { EventDedup } from "../pipeline/event-dedup.ts";
@@ -46,7 +46,6 @@ export type ServerOptions = {
46
46
  pgClient?: PgListenClient;
47
47
  };
48
48
  observability?: ObservabilityProvider;
49
- observabilityOptions?: ObservabilityOptions;
50
49
  rateLimit?: {
51
50
  readonly global?: Omit<GlobalIpRateLimitOptions, "resolver">;
52
51
  readonly auth?: Omit<AuthEndpointRateLimitOptions, "resolver"> & {
@@ -10,13 +10,13 @@ import { resolveDisabledFeatureNames, stripDisabledFeaturesFromAppSchema, } from
10
10
  import { createAnonymousUser, createSystemUser, isAnonymousSessionUser, } from "../engine/system-user.js";
11
11
  import { isFileField, } from "../engine/types/index.js";
12
12
  import { assertSeedModeNotInProduction } from "../event-store/seed-clock.js";
13
- import { createFileContext } from "../files/file-handle.js";
13
+ import { createTenantFileContext } from "../files/file-handle.js";
14
14
  import { createFileRoutes, readFilesRouteOptions } from "../files/file-routes.js";
15
15
  import { makeFileProviderResolver } from "../files/provider-resolver.js";
16
16
  import { createRecordReadCheck } from "../files/record-read-check.js";
17
17
  import { createRecordWriteCheck } from "../files/record-write-check.js";
18
18
  import { createFallbackLogger } from "../logging/index.js";
19
- import { createNoopProvider, DEFAULT_SENSITIVE_CONFIG, mergeSensitiveConfig, registerStandardMetrics, wrapRedisClient, } from "../observability/index.js";
19
+ import { createNoopProvider, registerStandardMetrics, wrapRedisClient, } from "../observability/index.js";
20
20
  import { resolveTenantLifecyclePlugin } from "../pipeline/active-membership.js";
21
21
  import { createDispatcher } from "../pipeline/dispatcher.js";
22
22
  import { SHARED_INSTANCE_SENTINEL } from "../pipeline/event-consumer-state.js";
@@ -389,7 +389,7 @@ export function buildServer(options) {
389
389
  // cross-feature ctx.appendEvent calls at emit-site.
390
390
  const mspOwner = msp.name.split(":")[0];
391
391
  const mspFiles = fileProviderResolver
392
- ? createFileContext(() => fileProviderResolver(event.tenantId), event.tenantId)
392
+ ? createTenantFileContext(fileProviderResolver, event.tenantId)
393
393
  : undefined;
394
394
  const applyCtx = createMultiStreamApplyContext({
395
395
  registry: options.registry,
@@ -467,7 +467,6 @@ export function buildServer(options) {
467
467
  // (tenant-by-host), entry:"user" needs c.get("user") populated.
468
468
  const extraRoutePublicMatchers = compileExtraRoutePublicMatchers(options.extraRoutes);
469
469
  const isExtraRoutePublicPath = (c) => extraRoutePublicMatchers.some((m) => m.method === c.req.method && m.pattern.test(c.req.path));
470
- const sensitiveConfig = mergeSensitiveConfig(options.observabilityOptions?.sensitiveFilter ?? DEFAULT_SENSITIVE_CONFIG);
471
470
  registerHealthRoutes(app, {
472
471
  lifecycle: options.lifecycle,
473
472
  readiness: {
@@ -519,7 +518,6 @@ export function buildServer(options) {
519
518
  app.use("/api/*", observabilityMiddleware({
520
519
  tracer: observability.tracer,
521
520
  meter: observability.meter,
522
- sensitiveConfig,
523
521
  }));
524
522
  // Tripwire: a kumiko-pii: ciphertext in a JSON response is always a bug
525
523
  // (raw read leaked past a decrypt) — dev fails loud, prod redacts (#820).
@@ -1,3 +1,4 @@
1
+ import type { ValidateBootOptions } from "../engine/boot-validator.ts";
1
2
  import type { FeatureDefinition } from "../engine/types/index.ts";
2
3
  export type AppInfraPorts = {
3
4
  readonly postgres?: number;
@@ -19,6 +20,8 @@ export type AppClientConfig = {
19
20
  /** Client bundle entry files, relative to the app root (e.g. "src/client.tsx"). */
20
21
  readonly entries: readonly string[];
21
22
  };
23
+ /** The boot-validator options an app owns; `env` and the cipher are injected by the runners. */
24
+ export type AppValidateBootOptions = Pick<ValidateBootOptions, "navAllowlist" | "navAllowlistExempt" | "warnOnUniqueAccessRoles">;
22
25
  export type AppConfig = {
23
26
  /** Features mounted in every environment: dev, tests, prod. */
24
27
  readonly features: readonly FeatureDefinition[];
@@ -26,6 +29,8 @@ export type AppConfig = {
26
29
  readonly prodFeatures?: readonly FeatureDefinition[];
27
30
  readonly client: AppClientConfig;
28
31
  readonly ports: AppPorts;
32
+ /** Passed to validateBoot by `kumiko check` and, via the app's bin files, by the runners. */
33
+ readonly validateBootOptions?: AppValidateBootOptions;
29
34
  };
30
35
  export declare const APP_CONFIG_FILE = "kumiko.config.ts";
31
36
  declare const INFRA_PORT_NAMES: readonly ["postgres", "redis", "meilisearch", "s3"];
@@ -5,6 +5,7 @@ import type { EntityCache } from "../pipeline/entity-cache.ts";
5
5
  import type { SearchAdapter } from "../search/types.ts";
6
6
  import type { EnvelopeCipher } from "../secrets/envelope-cipher.ts";
7
7
  import { type Table } from "./event-store-executor-context.ts";
8
+ import type { EntityRow } from "./table-builder.ts";
8
9
  export type { EventStoreExecutor, LockGuardOption, WritePurpose, } from "@cosmicdrift/kumiko-types/event-store-executor-types";
9
10
  export { WritePurposes } from "@cosmicdrift/kumiko-types/event-store-executor-types";
10
11
  export type { EntityLifecycleVerb } from "./event-store-executor-context.ts";
@@ -18,4 +19,4 @@ export type EventStoreExecutorOptions = {
18
19
  /** Override the boot-injected subject KMS for pii-annotated fields. */
19
20
  kms?: LocalKeyKmsAdapter;
20
21
  };
21
- export declare function createEventStoreExecutor(table: Table, entity: EntityDefinition, options: EventStoreExecutorOptions): EventStoreExecutor;
22
+ export declare function createEventStoreExecutor<E extends EntityDefinition>(table: Table, entity: E, options: EventStoreExecutorOptions): EventStoreExecutor<EntityRow<E>>;
@@ -18,7 +18,7 @@ export { buildEntityTableMeta, defineUnmanagedTable, deriveEntityTableMeta, } fr
18
18
  export type { EntityLifecycleVerb, EventStoreExecutor, EventStoreExecutorOptions, LockGuardOption, WritePurpose, } from "./event-store-executor.ts";
19
19
  export { createEventStoreExecutor, entityEventName, WritePurposes, } from "./event-store-executor.ts";
20
20
  export { enumerateFeatureTableSources, type FeatureTableSource, } from "./feature-table-sources.ts";
21
- export { flattenLocatedTimestamp, rehydrateLocatedTimestamp } from "./located-timestamp.ts";
21
+ export { flattenLocatedTimestamp, type LocatedTimestampRead, rehydrateLocatedTimestamp, } from "./located-timestamp.ts";
22
22
  export { assertUniqueTableNames, assertValidMigrationName, diffSnapshots, type GenerateMigrationInput, type GenerateMigrationOutput, generateMigration, loadSnapshotJson, renderMigrationSql, type SchemaDiff, type Snapshot, snapshotFromMetas, writeSnapshotJson, } from "./migrate-generator.ts";
23
23
  export { type AppliedMigration, type ApplyResult, type BaselineResult, baselineMigrations, fetchAppliedMigrations, loadMigrationsFromDir, type Migration, MigrationChecksumMismatchError, readMigrationSqlTexts, runMigrations, runMigrationsFromDir, splitSqlStatements, } from "./migrate-runner.ts";
24
24
  export { flattenMoney, type MoneyRead, moneyPayloadToMinorUnits, rehydrateMoney } from "./money.ts";
@@ -34,6 +34,6 @@ export { renderTableDdl, renderTablesDdl } from "./render-ddl.ts";
34
34
  export { diffReplayAgainstSnapshot, type ReplayedSchema, type ReplayedTable, type ReplayMismatch, replayMigrationsDir, } from "./replay-migration-sql.ts";
35
35
  export { findCommentedDropTables, isRetiredFrameworkTable, type RetiredFrameworkTable, retiredFrameworkTables, } from "./retired-framework-tables.ts";
36
36
  export { tableExists } from "./schema-inspection.ts";
37
- export { buildBaseColumns, buildEntityTable, declareGlobalTenancy, type EntityTable, physicalColumnName, toSnakeCase, toTableName, } from "./table-builder.ts";
37
+ export { buildBaseColumns, buildEntityTable, declareGlobalTenancy, type EntityRow, type EntityTable, physicalColumnName, toSnakeCase, toTableName, } from "./table-builder.ts";
38
38
  export type { CreateTenantDbOptions, TenantDb, TenantDbGrants, TenantDbMode, UncheckedSystemDb, } from "./tenant-db.ts";
39
39
  export { castTenantRows, createSystemDbView, createTenantDb, runInOwnTransaction, SYSTEM_SCOPE_CHECK_BRAND, scopeTenantDbToTenant, } from "./tenant-db.ts";
package/dist/db/index.js CHANGED
@@ -12,7 +12,7 @@ export { entityTableFromRegistry } from "./entity-table-from-registry.js";
12
12
  export { buildEntityTableMeta, defineUnmanagedTable, deriveEntityTableMeta, } from "./entity-table-meta.js";
13
13
  export { createEventStoreExecutor, entityEventName, WritePurposes, } from "./event-store-executor.js";
14
14
  export { enumerateFeatureTableSources, } from "./feature-table-sources.js";
15
- export { flattenLocatedTimestamp, rehydrateLocatedTimestamp } from "./located-timestamp.js";
15
+ export { flattenLocatedTimestamp, rehydrateLocatedTimestamp, } from "./located-timestamp.js";
16
16
  export { assertUniqueTableNames, assertValidMigrationName, diffSnapshots, generateMigration, loadSnapshotJson, renderMigrationSql, snapshotFromMetas, writeSnapshotJson, } from "./migrate-generator.js";
17
17
  export { baselineMigrations, fetchAppliedMigrations, loadMigrationsFromDir, MigrationChecksumMismatchError, readMigrationSqlTexts, runMigrations, runMigrationsFromDir, splitSqlStatements, } from "./migrate-runner.js";
18
18
  export { flattenMoney, moneyPayloadToMinorUnits, rehydrateMoney } from "./money.js";
@@ -10,6 +10,13 @@ import type { EntityDefinition } from "../engine/types/index.ts";
10
10
  * Pure — mutiert nicht.
11
11
  */
12
12
  export declare function flattenLocatedTimestamp(payload: Record<string, unknown>, entity: EntityDefinition): Record<string, unknown>;
13
+ /** Shape of a single rehydrated locatedTimestamp field. `at` is the wall clock in `tz`
14
+ * (no offset), `utc` the ISO instant string; both are strings, not Temporal values. */
15
+ export type LocatedTimestampRead = {
16
+ readonly at: string;
17
+ readonly tz: string;
18
+ readonly utc: string;
19
+ };
13
20
  /**
14
21
  * DB → API: zwei flache Spalten zu combined { at, tz, utc } rehydraten.
15
22
  *
@@ -68,7 +68,7 @@ export declare function selectInnerJoin<TLeft = Record<string, unknown>, TRight
68
68
  export declare function insertMany<TRow = any>(db: AnyDb, table: WritableTable, rows: ReadonlyArray<Record<string, unknown>>): Promise<readonly TRow[]>;
69
69
  export declare function insertOne<TRow = any>(db: AnyDb, table: WritableTable, values: Record<string, unknown>): Promise<TRow | undefined>;
70
70
  export declare function updateMany<TRow = any>(db: AnyDb, table: WritableTable, set: Record<string, unknown>, where: WhereObject): Promise<readonly TRow[]>;
71
- export declare function deleteMany(db: AnyDb, table: WritableTable, where: WhereObject): Promise<void>;
71
+ export declare function deleteMany(db: AnyDb, table: WritableTable, where: WhereObject): Promise<number>;
72
72
  export declare function resolveConflictColumns(table: TableLike, info: TableInfo, conflictKeys: readonly string[] | undefined): readonly string[];
73
73
  export type UpsertOnConflictOptions = {
74
74
  readonly conflictKeys: readonly string[];
package/dist/db/query.js CHANGED
@@ -5,7 +5,7 @@
5
5
  // fetchOne<T>(db, table, where) → T | undefined
6
6
  // insertOne<T>(db, table, values) → T | undefined
7
7
  // updateMany<T>(db, table, set, where) → readonly T[]
8
- // deleteMany(db, table, where) → void
8
+ // deleteMany(db, table, where) → number of deleted rows
9
9
  // countWhere(db, table, where?) → number
10
10
  // upsertByPk / upsertOnConflict / incrementCounter
11
11
  // deleteManyBatched(db, table, where, { limit }) → { deleted, batches }
@@ -1108,10 +1108,16 @@ export async function deleteMany(db, table, where) {
1108
1108
  let sqlText = `DELETE FROM ${quoteIdent(info.name)}`;
1109
1109
  if (w.sqlText)
1110
1110
  sqlText += ` WHERE ${w.sqlText}`;
1111
- await unsafeLoggingClosedConnection(db, sqlText, w.values, {
1111
+ const result = await unsafeLoggingClosedConnection(db, sqlText, w.values, {
1112
1112
  operation: "deleteMany",
1113
1113
  table: info.name,
1114
1114
  });
1115
+ // postgres-js reports the affected-row count of a DELETE on the result, no RETURNING needed.
1116
+ const count = propertyOf(result, "count");
1117
+ if (typeof count !== "number") {
1118
+ throw new Error(`deleteMany(${info.name}): the driver result carries no affected-row count`);
1119
+ }
1120
+ return count;
1115
1121
  }
1116
1122
  function insertEntries(info, values) {
1117
1123
  return Object.entries(values)
@@ -3,6 +3,8 @@ import type { ExecutorOnly } from "@cosmicdrift/kumiko-types/executor-brand";
3
3
  import type { TenancyBrand } from "@cosmicdrift/kumiko-types/tenancy-brand";
4
4
  import type { EntityDefinition, EntityRelations, FieldDefinition, FieldsMap } from "../engine/types/index.ts";
5
5
  import type { Table } from "./event-store-executor-context.ts";
6
+ import type { LocatedTimestampRead } from "./located-timestamp.ts";
7
+ import type { MoneyRead } from "./money.ts";
6
8
  export { toSnakeCase } from "../utils/case.ts";
7
9
  import { type ColumnBuilder, type ColumnHandle, type SchemaTable, type TableColumns } from "./dialect.ts";
8
10
  export declare const SOFT_DELETE_LIVE_ROW_PREDICATE = "\"is_deleted\" = false";
@@ -10,25 +12,23 @@ export declare function physicalColumnName(table: Table, field: string): string;
10
12
  export type AnyPgColumn = ColumnHandle;
11
13
  export declare const READ_MODEL_PREFIX = "read_";
12
14
  export declare function toTableName(entityName: string): string;
13
- type Col<_T> = ColumnHandle & {
15
+ type Col<T> = ColumnHandle & {
14
16
  readonly __notNull: true;
17
+ readonly __value?: T;
15
18
  };
16
- type NullCol<_T> = ColumnHandle & {
19
+ type NullCol<T> = ColumnHandle & {
17
20
  readonly __notNull: false;
21
+ readonly __value?: T;
18
22
  };
19
- type ColumnsForField<K extends string, F extends FieldDefinition> = F extends {
23
+ type ColumnsForField<K extends string, F extends FieldDefinition> = ValueColumnsForField<K, F> & BlindIndexColumnForField<K, F>;
24
+ type BlindIndexColumnForField<K extends string, F extends FieldDefinition> = F extends {
20
25
  type: "text";
21
26
  lookupable: true;
22
- } ? (F extends {
23
- required: true;
24
27
  } ? {
25
- readonly [P in K]: Col<string>;
26
- } : {
27
- readonly [P in K]: NullCol<string>;
28
- }) & {
29
28
  readonly [P in `${K}Bidx`]: NullCol<string>;
30
- } : F extends {
31
- type: "text" | "select" | "tz";
29
+ } : unknown;
30
+ type ValueColumnsForField<K extends string, F extends FieldDefinition> = F extends {
31
+ type: "text" | "longText" | "select" | "tz";
32
32
  } ? F extends {
33
33
  required: true;
34
34
  } ? {
@@ -160,6 +160,40 @@ type SoftDeleteColumnsType = {
160
160
  readonly deletedAt: NullCol<Temporal.Instant>;
161
161
  readonly deletedById: NullCol<string>;
162
162
  };
163
+ type ColumnValue<C> = C extends {
164
+ readonly __notNull: true;
165
+ readonly __value?: infer T;
166
+ } ? T : C extends {
167
+ readonly __notNull: false;
168
+ readonly __value?: infer T;
169
+ } ? T | null : unknown;
170
+ type RowOfColumns<C> = {
171
+ readonly [K in keyof C]: ColumnValue<C[K]>;
172
+ };
173
+ type CompoundRowField<K extends string, V, F> = F extends {
174
+ required: true;
175
+ } ? {
176
+ readonly [P in K]: V;
177
+ } : {
178
+ readonly [P in K]?: V;
179
+ };
180
+ type RowForField<K extends string, F extends FieldDefinition> = F extends {
181
+ type: "money";
182
+ } ? CompoundRowField<K, MoneyRead, F> : F extends {
183
+ type: "locatedTimestamp";
184
+ } ? CompoundRowField<K, LocatedTimestampRead, F> : [ValueColumnsForField<K, F>] extends [never] ? {
185
+ readonly [P in K]: unknown;
186
+ } : RowOfColumns<ValueColumnsForField<K, F>>;
187
+ type RowForFields<F extends FieldsMap> = UnionToIntersection<{
188
+ [K in keyof F & string]: RowForField<K, F[K]>;
189
+ }[keyof F & string]>;
190
+ type SoftDeleteRow<E extends EntityDefinition> = E["softDelete"] extends true ? RowOfColumns<SoftDeleteColumnsType> : [E["softDelete"]] extends [false | undefined] ? unknown : Partial<RowOfColumns<SoftDeleteColumnsType>>;
191
+ type Flatten<T> = {
192
+ [K in keyof T]: T[K];
193
+ } & {};
194
+ /** The row `executor.list()` / `executor.detail()` return for entity `E`. A wide
195
+ * `EntityDefinition` (fields without literal keys) yields `Record<string, unknown>`. */
196
+ export type EntityRow<E extends EntityDefinition = EntityDefinition> = string extends keyof E["fields"] ? Record<string, unknown> : Flatten<RowOfColumns<BaseColumnsType<E>> & SoftDeleteRow<E> & RowForFields<E["fields"]>>;
163
197
  export type { ExecutorOnly, NotExecutorOnly } from "@cosmicdrift/kumiko-types/executor-brand";
164
198
  export type { EntityTenancy, TenancyBrand } from "@cosmicdrift/kumiko-types/tenancy-brand";
165
199
  type EntityTableTenancy<E extends EntityDefinition> = [NonNullable<E["tenancy"]>] extends [never] ? "tenant" : [NonNullable<E["tenancy"]>] extends ["global"] ? "global" : "tenant";
@@ -504,7 +504,9 @@ function buildTenantDb(options) {
504
504
  return Promise.reject(new Error("db.global().deleteMany without where would mass-delete every tenant's rows. Pass at least one where condition."));
505
505
  }
506
506
  report("global-write", globalWriteReason());
507
- return withDbSpan("delete", table, async () => bunDeleteMany(db, table, where));
507
+ return withDbSpan("delete", table, async () => {
508
+ await bunDeleteMany(db, table, where);
509
+ });
508
510
  },
509
511
  // @cast-boundary phantom-brand — GlobalWrites is only present in the type when TTable is not ExecutorOnly; the object above always carries the methods.
510
512
  };