@cosmicdrift/kumiko-framework 0.377.0 → 0.379.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 (118) hide show
  1. package/AGENTS.md +4 -1
  2. package/agents/examples/compose-stacks.md +1 -2
  3. package/agents/examples/currencies-global.md +2 -3
  4. package/agents/examples/delivery-notifications.md +25 -2
  5. package/agents/examples/embedded.md +177 -0
  6. package/agents/examples/event-sourcing.md +6 -5
  7. package/agents/examples/legal-pages.md +5 -5
  8. package/agents/examples/reference-data.md +26 -21
  9. package/agents/examples/relations.md +2 -1
  10. package/agents/features.md +5 -5
  11. package/agents/widgets.md +1 -0
  12. package/dist/api/entity-read-access.d.ts +2 -0
  13. package/dist/api/entity-read-access.js +27 -0
  14. package/dist/api/index.d.ts +1 -0
  15. package/dist/api/index.js +1 -0
  16. package/dist/api/server.js +10 -3
  17. package/dist/api/sse-route.d.ts +5 -0
  18. package/dist/api/sse-route.js +59 -9
  19. package/dist/db/entity-table-from-registry.d.ts +2 -1
  20. package/dist/db/entity-table-meta.d.ts +0 -2
  21. package/dist/db/entity-table-meta.js +1 -3
  22. package/dist/db/event-store-executor-write.js +21 -16
  23. package/dist/db/executor-managed-tables.d.ts +6 -0
  24. package/dist/db/executor-managed-tables.js +40 -0
  25. package/dist/db/index.d.ts +4 -2
  26. package/dist/db/index.js +4 -2
  27. package/dist/db/money.d.ts +3 -2
  28. package/dist/db/money.js +2 -7
  29. package/dist/db/queries/backfill-pii.js +22 -12
  30. package/dist/db/queries/database-now.d.ts +5 -0
  31. package/dist/db/queries/database-now.js +12 -0
  32. package/dist/db/queries/event-store-orphan-rows.d.ts +8 -0
  33. package/dist/db/queries/event-store-orphan-rows.js +32 -0
  34. package/dist/db/query.js +28 -0
  35. package/dist/db/reference-data.d.ts +5 -4
  36. package/dist/db/reference-data.js +23 -21
  37. package/dist/db/table-builder.js +2 -0
  38. package/dist/db/tenant-db-runner.d.ts +2 -0
  39. package/dist/db/tenant-db-runner.js +7 -0
  40. package/dist/db/tenant-db.d.ts +3 -0
  41. package/dist/db/tenant-db.js +54 -18
  42. package/dist/engine/boot-validator/entity-handler.js +12 -4
  43. package/dist/engine/boot-validator/form-derivations.d.ts +4 -0
  44. package/dist/engine/boot-validator/form-derivations.js +82 -0
  45. package/dist/engine/boot-validator/query-refs.js +8 -0
  46. package/dist/engine/boot-validator/screens.js +50 -12
  47. package/dist/engine/build-config-feature-schema.js +33 -10
  48. package/dist/engine/embedded-derived.d.ts +1 -1
  49. package/dist/engine/embedded-derived.js +7 -1
  50. package/dist/engine/feature-ui-extensions.js +4 -0
  51. package/dist/engine/form-derivations.d.ts +21 -0
  52. package/dist/engine/form-derivations.js +175 -0
  53. package/dist/engine/index.d.ts +4 -1
  54. package/dist/engine/index.js +2 -0
  55. package/dist/engine/minor-units.d.ts +2 -0
  56. package/dist/engine/minor-units.js +8 -0
  57. package/dist/engine/registry-state.js +2 -0
  58. package/dist/engine/registry-validate.js +4 -1
  59. package/dist/engine/schema-builder.js +18 -4
  60. package/dist/engine/screen-helpers.d.ts +1 -0
  61. package/dist/engine/screen-helpers.js +3 -0
  62. package/dist/engine/soft-delete-cleanup.js +53 -24
  63. package/dist/engine/steps/unsafe-projection-delete.js +2 -1
  64. package/dist/engine/template-variables.d.ts +2 -1
  65. package/dist/engine/template-variables.js +4 -0
  66. package/dist/engine/types/index.d.ts +2 -2
  67. package/dist/engine/with-form-derivations.d.ts +9 -0
  68. package/dist/engine/with-form-derivations.js +16 -0
  69. package/dist/entrypoint/index.d.ts +1 -3
  70. package/dist/errors/reasons.d.ts +1 -0
  71. package/dist/errors/reasons.js +4 -1
  72. package/dist/errors/serialize.d.ts +1 -0
  73. package/dist/errors/serialize.js +9 -5
  74. package/dist/errors/write-error-info.js +3 -2
  75. package/dist/errors/zod-bridge.js +7 -0
  76. package/dist/event-store/index.d.ts +1 -0
  77. package/dist/event-store/index.js +1 -0
  78. package/dist/files/action-form-upload.d.ts +16 -0
  79. package/dist/files/action-form-upload.js +45 -0
  80. package/dist/files/bind-pending-file-refs.d.ts +14 -0
  81. package/dist/files/bind-pending-file-refs.js +44 -4
  82. package/dist/files/create-file-ref.d.ts +3 -0
  83. package/dist/files/create-file-ref.js +41 -13
  84. package/dist/files/file-routes.js +29 -2
  85. package/dist/files/index.d.ts +2 -1
  86. package/dist/files/index.js +1 -0
  87. package/dist/files/purge-expired-file-ref-bytes.d.ts +3 -2
  88. package/dist/files/purge-expired-file-ref-bytes.js +11 -5
  89. package/dist/i18n/settings-hub-keys.js +1 -0
  90. package/dist/internal/testing.d.ts +2 -0
  91. package/dist/internal/testing.js +15 -1
  92. package/dist/migrations/pii-event-backfill.js +14 -7
  93. package/dist/observability/escape-hatch-report.d.ts +1 -1
  94. package/dist/observability/escape-hatch-report.js +14 -3
  95. package/dist/pipeline/cascade-handler.js +79 -10
  96. package/dist/pipeline/dispatch-shared.js +1 -9
  97. package/dist/pipeline/msp-rebuild.js +5 -3
  98. package/dist/pipeline/projection-rebuild.js +4 -2
  99. package/dist/pipeline/projections-runner.js +2 -1
  100. package/dist/rate-limit/index.d.ts +1 -1
  101. package/dist/rate-limit/index.js +1 -1
  102. package/dist/rate-limit/middleware.d.ts +4 -0
  103. package/dist/rate-limit/middleware.js +5 -2
  104. package/dist/search/configure-tenant-index.d.ts +4 -0
  105. package/dist/search/configure-tenant-index.js +8 -0
  106. package/dist/search/index.d.ts +1 -0
  107. package/dist/search/index.js +1 -0
  108. package/dist/ui-types/index.d.ts +4 -2
  109. package/dist/ui-types/index.js +3 -2
  110. package/dist/ui-types/list-row-meta.d.ts +3 -0
  111. package/dist/ui-types/list-row-meta.js +12 -1
  112. package/kumiko-es-tables.json +6 -0
  113. package/package.json +4 -3
  114. package/src/changes.json +142 -0
  115. package/src/scripts/codemod/README.md +20 -0
  116. package/src/scripts/codemod/codemod-support.ts +1 -1
  117. package/src/scripts/codemod/feature-layout.ts +416 -0
  118. package/src/scripts/codemod/package-merge.ts +0 -11
@@ -1,6 +1,6 @@
1
1
  import { Hono } from "hono";
2
2
  import { ROLES } from "../auth/roles.js";
3
- import { createProjectingTenantDb } from "../db/tenant-db.js";
3
+ import { createProjectingTenantDb, withExecutorManagedWrites } from "../db/tenant-db.js";
4
4
  import { createDerivativesContext } from "../derivatives/derivatives-context.js";
5
5
  import { buildAppSchema } from "../engine/build-app-schema.js";
6
6
  import { EXT_FILE_PROVIDER, EXT_PRINCIPAL_STATUS } from "../engine/extension-names.js";
@@ -35,6 +35,7 @@ import { authMiddleware, getAuthTokenExpiry, getUser, } from "./auth-middleware.
35
35
  import { assertSessionCallbacksPaired, createAuthRoutes, } from "./auth-routes.js";
36
36
  import { assertValidTrustedProxyHops, clientIpSourceFromHonoContext, createClientIpResolver, } from "./client-ip.js";
37
37
  import { csrfMiddleware } from "./csrf-middleware.js";
38
+ import { collectEntityReadAccess } from "./entity-read-access.js";
38
39
  import { ExtraRouteEntries, ExtraRouteRejection, } from "./extra-route.js";
39
40
  import { computeStrongEtag, etagMatches } from "./http-cache.js";
40
41
  import { createJwtHelper } from "./jwt.js";
@@ -392,9 +393,10 @@ export function buildServer(options) {
392
393
  const mspFiles = fileProviderResolver
393
394
  ? createTenantFileContext(fileProviderResolver, event.tenantId)
394
395
  : undefined;
396
+ const mspDb = withExecutorManagedWrites(baseDb);
395
397
  const applyCtx = createMultiStreamApplyContext({
396
398
  registry: options.registry,
397
- db: baseDb,
399
+ db: mspDb,
398
400
  tenantId: event.tenantId,
399
401
  userId: event.metadata.userId,
400
402
  ...(mspOwner && { callerFeature: mspOwner }),
@@ -410,7 +412,7 @@ export function buildServer(options) {
410
412
  }),
411
413
  }),
412
414
  });
413
- await applyFn(event, baseDb, applyCtx);
415
+ await applyFn(event, mspDb, applyCtx);
414
416
  // Keep ctx reachable to satisfy the EventConsumerHandler signature.
415
417
  void ctx;
416
418
  },
@@ -480,6 +482,10 @@ export function buildServer(options) {
480
482
  if (options.metrics) {
481
483
  registerMetricsRoute(app, observability.meter, options.metrics);
482
484
  }
485
+ else if (process.env["NODE_ENV"] === "production") {
486
+ console.warn("[kumiko:boot] /metrics is off: no `metrics` option, so nothing is measured or scraped. " +
487
+ "Set PROMETHEUS_METRICS_TOKEN (min 32 chars) and spread resolveObservabilityWiring(token) into the server options.");
488
+ }
483
489
  app.use("/api/*", requestIdMiddleware({
484
490
  resolver: clientIpResolver,
485
491
  ...(options.errorDocs && { errorDocs: options.errorDocs }),
@@ -662,6 +668,7 @@ export function buildServer(options) {
662
668
  }));
663
669
  app.route("/api", createSseRoute(sseBroker, {
664
670
  anonymousLiveEntities: collectAnonymousLiveEntities(options.registry),
671
+ entityReadAccess: collectEntityReadAccess(options.registry),
665
672
  }));
666
673
  // GET /api/schema — the only way a client obtains the AppSchema. Behind
667
674
  // jwtGuard (NON_PUBLIC_API_PATHS), so anonymousAccess's synthesised user
@@ -1,4 +1,5 @@
1
1
  import { Hono } from "hono";
2
+ import type { AccessRule } from "../engine/types/index.ts";
2
3
  import { type SseBroker } from "./sse-broker.ts";
3
4
  /**
4
5
  * Heartbeat-Cadence für SSE-Streams.
@@ -21,7 +22,11 @@ import { type SseBroker } from "./sse-broker.ts";
21
22
  * gegen versehentliches Hochsetzen.
22
23
  */
23
24
  export declare const SSE_HEARTBEAT_INTERVAL_MS = 15000;
25
+ export declare const SSE_STALLED_CLIENT_TIMEOUT_MS = 15000;
26
+ export declare const SSE_MAX_PENDING_WRITES_PER_CLIENT = 10000;
24
27
  export type SseRouteOptions = {
25
28
  readonly anonymousLiveEntities: ReadonlySet<string>;
29
+ readonly entityReadAccess: ReadonlyMap<string, readonly AccessRule[]>;
30
+ readonly now?: () => number;
26
31
  };
27
32
  export declare function createSseRoute(broker: SseBroker, options: SseRouteOptions): Hono<import("hono/types").BlankEnv, import("hono/types").BlankSchema, "/">;
@@ -1,5 +1,6 @@
1
1
  import { Hono } from "hono";
2
2
  import { streamSSE } from "hono/streaming";
3
+ import { hasAccess } from "../engine/access.js";
3
4
  import { tenantChannel } from "../engine/constants.js";
4
5
  import { isAnonymousSessionUser } from "../engine/system-user.js";
5
6
  import { Routes } from "./api-constants.js";
@@ -40,6 +41,11 @@ function isEntityEventData(data) {
40
41
  // cookie is stale. A downgraded stream answers 200, so EventSource never reports the
41
42
  // session end by itself.
42
43
  const SESSION_ENDED_SSE_EVENT = "session-ended";
44
+ // A broadcast batch legitimately queues many frames at once, so a client is dropped for
45
+ // making no progress, not for queue depth.
46
+ export const SSE_STALLED_CLIENT_TIMEOUT_MS = SSE_HEARTBEAT_INTERVAL_MS;
47
+ // Memory backstop only: a client that still makes progress but queues this many frames is dropped.
48
+ export const SSE_MAX_PENDING_WRITES_PER_CLIENT = 10_000;
43
49
  // Whitelist, never a spread: the wire frame is a signal without field
44
50
  // values even if a broker or pusher put a payload into event.data.
45
51
  function toEntitySignalWire(event, data) {
@@ -68,16 +74,26 @@ function isAddressedTo(event, userId) {
68
74
  const addressee = event.data["userId"];
69
75
  return typeof addressee === "string" && addressee === userId;
70
76
  }
71
- function mayReceiveEntitySignal(user, aggregateType, options) {
72
- return !isAnonymousSessionUser(user) || options.anonymousLiveEntities.has(aggregateType);
77
+ // A role-gated rule means every holder may read every row, so id and version leak nothing.
78
+ // An openToAll rule leaves row visibility to the handler (e.g. an owner check on form drafts),
79
+ // which the signal cannot know: such a connection only learns that the entity changed.
80
+ function entitySignalDetail(user, aggregateType, options) {
81
+ if (isAnonymousSessionUser(user)) {
82
+ return options.anonymousLiveEntities.has(aggregateType) ? "anonymous" : "none";
83
+ }
84
+ const granting = (options.entityReadAccess.get(aggregateType) ?? []).filter((rule) => hasAccess(user, rule));
85
+ if (granting.length === 0)
86
+ return "none";
87
+ return granting.some((rule) => "roles" in rule) ? "identified" : "anonymous";
73
88
  }
74
89
  function decideWireFrame(event, user, options) {
75
90
  const data = event.data;
76
91
  if (isEntityEventData(data)) {
77
- // skip: anonymous connections only get signals for entities an anonymous query declares
78
- if (!mayReceiveEntitySignal(user, data.aggregateType, options))
92
+ // skip: connections only get signals for entities a query lets their roles read
93
+ const detail = entitySignalDetail(user, data.aggregateType, options);
94
+ if (detail === "none")
79
95
  return undefined;
80
- const signal = isAnonymousSessionUser(user)
96
+ const signal = detail === "anonymous"
81
97
  ? toAnonymousEntitySignalWire(event, data)
82
98
  : toEntitySignalWire(event, data);
83
99
  return { name: data.aggregateType, data: signal };
@@ -88,6 +104,7 @@ function decideWireFrame(event, user, options) {
88
104
  return { name: event.type, data };
89
105
  }
90
106
  export function createSseRoute(broker, options) {
107
+ const now = options.now ?? Date.now;
91
108
  const route = new Hono();
92
109
  route.get(Routes.sse, async (c) => {
93
110
  const user = getUser(c);
@@ -116,12 +133,41 @@ export function createSseRoute(broker, options) {
116
133
  resolveEnded();
117
134
  void stream.close();
118
135
  };
136
+ let pendingWrites = 0;
137
+ let lastProgressAt = now();
138
+ const isStalled = () => pendingWrites > 0 && now() - lastProgressAt > SSE_STALLED_CLIENT_TIMEOUT_MS;
119
139
  const clientId = broker.addClient(channel, (event) => {
120
- const frame = decideWireFrame(event, user, options);
121
- // skip: decideWireFrame withholds frames this user must not see
122
- if (!frame)
140
+ // skip: the stream is already being torn down
141
+ if (stream.aborted || stream.closed)
123
142
  return;
124
- stream.writeSSE({ event: frame.name, data: JSON.stringify(frame.data) });
143
+ // The broker loops over all clients synchronously: one bad client must not stop the others.
144
+ try {
145
+ const frame = decideWireFrame(event, user, options);
146
+ // skip: decideWireFrame withholds frames this user must not see
147
+ if (!frame)
148
+ return;
149
+ if (pendingWrites >= SSE_MAX_PENDING_WRITES_PER_CLIENT || isStalled()) {
150
+ // abort (not close) cancels the pending writes; close would queue behind them
151
+ stream.abort();
152
+ // skip: this client is gone, the frame has no audience
153
+ return;
154
+ }
155
+ const data = JSON.stringify(frame.data);
156
+ if (pendingWrites === 0)
157
+ lastProgressAt = now();
158
+ pendingWrites += 1;
159
+ stream
160
+ .writeSSE({ event: frame.name, data })
161
+ .catch(closeStream)
162
+ .finally(() => {
163
+ pendingWrites -= 1;
164
+ lastProgressAt = now();
165
+ });
166
+ }
167
+ catch {
168
+ // skip: an unserializable frame ends this client's stream, not the broadcast
169
+ closeStream();
170
+ }
125
171
  }, closeStream);
126
172
  const unsubscribeAccessInvalidation = broker.subscribeAccessInvalidation(user.id, closeStream, accessInvalidationCredentialFor(user));
127
173
  const expiryTimer = scheduleTokenExpiry(tokenExpiresAtSec, closeStream);
@@ -142,6 +188,10 @@ export function createSseRoute(broker, options) {
142
188
  // header für die Layer-für-Layer-Begründung der 15s-Cadence.
143
189
  try {
144
190
  while (!stream.closed && !stream.aborted) {
191
+ if (isStalled()) {
192
+ stream.abort();
193
+ break;
194
+ }
145
195
  await stream.writeSSE({ event: "ping", data: "" });
146
196
  await Promise.race([stream.sleep(SSE_HEARTBEAT_INTERVAL_MS), ended]);
147
197
  }
@@ -1,4 +1,5 @@
1
+ import type { ExecutorOnly } from "@cosmicdrift/kumiko-types/executor-brand";
1
2
  import type { Registry } from "../engine/index.ts";
2
3
  import type { SchemaTable } from "./dialect.ts";
3
4
  import { buildEntityTable } from "./table-builder.ts";
4
- export declare function entityTableFromRegistry(registry: Registry, entityName: string, entity: Parameters<typeof buildEntityTable>[1]): SchemaTable;
5
+ export declare function entityTableFromRegistry(registry: Registry, entityName: string, entity: Parameters<typeof buildEntityTable>[1]): SchemaTable & ExecutorOnly;
@@ -13,8 +13,6 @@ export declare function resolveTableName(entityName: string, entity: EntityDefin
13
13
  * escape hatch (#1208).
14
14
  */
15
15
  export declare function deriveEntityTableMeta(entityName: string, entity: EntityDefinition, options?: BuildEntityTableMetaOptions): EntityTableMeta;
16
- /** @deprecated Use {@link deriveEntityTableMeta} — the old name read as an unmanaged escape hatch (#1208). */
17
- export declare const buildEntityTableMeta: typeof deriveEntityTableMeta;
18
16
  export declare function assertBackingTableSuperset(entityName: string, fieldMeta: EntityTableMeta, tableMeta: EntityTableMeta): void;
19
17
  /**
20
18
  * Hand-built EntityTableMeta for direct-write stores (no entity base columns).
@@ -219,7 +219,7 @@ export function deriveEntityTableMeta(entityName, entity, options) {
219
219
  const tableName = resolveTableName(entityName, entity, options?.featureName);
220
220
  const source = options?.source ?? "managed";
221
221
  if (source === "unmanaged") {
222
- assertUnmanagedTableName(tableName, "deriveEntityTableMeta/buildEntityTableMeta");
222
+ assertUnmanagedTableName(tableName, "deriveEntityTableMeta");
223
223
  }
224
224
  const idType = entity.idType ?? "uuid";
225
225
  if (source === "unmanaged" && idType === "serial") {
@@ -354,8 +354,6 @@ export function deriveEntityTableMeta(entityName, entity, options) {
354
354
  ...(entity.tenancy === "global" && { tenancy: "global" }),
355
355
  };
356
356
  }
357
- /** @deprecated Use {@link deriveEntityTableMeta} — the old name read as an unmanaged escape hatch (#1208). */
358
- export const buildEntityTableMeta = deriveEntityTableMeta;
359
357
  function sqlExpressionText(where) {
360
358
  return isSqlExpression(where) ? where.text : undefined;
361
359
  }
@@ -15,8 +15,13 @@ import { buildEventMetadata, entityEventName, isForeignTenantOnGlobalEntity, own
15
15
  import { isFrameworkSystemUser, isLockGuardBypassed } from "./lock-guard.js";
16
16
  import { projectionRegistryOf } from "./projection-registry-binding.js";
17
17
  import { runInSavepointIfSupported } from "./query.js";
18
- import { assertPersonalDataWrite, tableNameOf } from "./tenant-db.js";
18
+ import { assertPersonalDataWrite, tableNameOf, withExecutorManagedWrites } from "./tenant-db.js";
19
19
  import { tenantDbWriteRunner } from "./tenant-db-runner.js";
20
+ // The echoed event carries entity PII and must never reach a handler's JSON response, while
21
+ // hooks and projections still read it by property. Non-enumerable keeps it out of spread/stringify.
22
+ function withoutEventInJson(result) {
23
+ return Object.defineProperty(result, "event", { value: result.event, enumerable: false });
24
+ }
20
25
  // Custom projections for a write that did not go through the dispatcher (hook or
21
26
  // job writing a second aggregate). Savepoint like applyEntityEvent: a throwing
22
27
  // projection fails the write and the caller's transaction rolls it back.
@@ -338,7 +343,7 @@ export function createWriteVerbs(ctx) {
338
343
  // failed INSERT back; the error still propagates and stays catchable.
339
344
  let result;
340
345
  try {
341
- result = await runInSavepointIfSupported(runner, async (sp) => applyEntityEvent(event, table, entity, sp));
346
+ result = await runInSavepointIfSupported(withExecutorManagedWrites(runner), async (sp) => applyEntityEvent(event, table, entity, sp));
342
347
  }
343
348
  catch (e) {
344
349
  const mapped = tryMapUniqueViolation(e, entityName);
@@ -362,7 +367,7 @@ export function createWriteVerbs(ctx) {
362
367
  await projectEntityEvent(db, runner, echoEvent);
363
368
  return {
364
369
  isSuccess: true,
365
- data: {
370
+ data: withoutEventInJson({
366
371
  kind: "save",
367
372
  id: aggregateId,
368
373
  data: projection,
@@ -373,7 +378,7 @@ export function createWriteVerbs(ctx) {
373
378
  // Persisted event carries ciphertext by design — the caller-facing
374
379
  // echo must be plaintext like every other response field (#820).
375
380
  event: echoEvent,
376
- },
381
+ }),
377
382
  };
378
383
  },
379
384
  async update(payload, user, db, updateOptions) {
@@ -560,7 +565,7 @@ export function createWriteVerbs(ctx) {
560
565
  // failed nested one throws 500 instead of failing cleanly.
561
566
  let result;
562
567
  try {
563
- result = await runInSavepointIfSupported(runner, async (sp) => applyEntityEvent(event, table, entity, sp));
568
+ result = await runInSavepointIfSupported(withExecutorManagedWrites(runner), async (sp) => applyEntityEvent(event, table, entity, sp));
564
569
  }
565
570
  catch (e) {
566
571
  const mapped = tryMapUniqueViolation(e, entityName);
@@ -586,7 +591,7 @@ export function createWriteVerbs(ctx) {
586
591
  await projectEntityEvent(db, runner, echoEvent);
587
592
  return {
588
593
  isSuccess: true,
589
- data: {
594
+ data: withoutEventInJson({
590
595
  kind: "save",
591
596
  id: data["id"], // @cast-boundary engine-payload
592
597
  data,
@@ -595,7 +600,7 @@ export function createWriteVerbs(ctx) {
595
600
  isNew: false,
596
601
  entityName,
597
602
  event: echoEvent,
598
- },
603
+ }),
599
604
  };
600
605
  }
601
606
  catch (e) {
@@ -688,7 +693,7 @@ export function createWriteVerbs(ctx) {
688
693
  throw e;
689
694
  }
690
695
  // Live==Rebuild via applyEntityEvent. Savepoint like create/update.
691
- const deleteResult = await runInSavepointIfSupported(runner, async (sp) => applyEntityEvent(event, table, entity, sp));
696
+ const deleteResult = await runInSavepointIfSupported(withExecutorManagedWrites(runner), async (sp) => applyEntityEvent(event, table, entity, sp));
692
697
  if (deleteResult.kind !== "applied") {
693
698
  return writeFailure(new InternalError({ message: "projection delete: applyEntityEvent skipped" }));
694
699
  }
@@ -699,14 +704,14 @@ export function createWriteVerbs(ctx) {
699
704
  await projectEntityEvent(db, runner, echoEvent);
700
705
  return {
701
706
  isSuccess: true,
702
- data: {
707
+ data: withoutEventInJson({
703
708
  kind: "delete",
704
709
  id: payload.id,
705
710
  data: existing,
706
711
  entityName,
707
712
  event: echoEvent,
708
713
  ...(isLockGuardBypassed(user, options) && { lockGuardBypassed: true }),
709
- },
714
+ }),
710
715
  };
711
716
  },
712
717
  // Hard-purge (Art. 17). Same shape as delete(), but emits `forgotten` which
@@ -772,7 +777,7 @@ export function createWriteVerbs(ctx) {
772
777
  }
773
778
  throw e;
774
779
  }
775
- const forgetResult = await runInSavepointIfSupported(runner, async (sp) => applyEntityEvent(event, table, entity, sp));
780
+ const forgetResult = await runInSavepointIfSupported(withExecutorManagedWrites(runner), async (sp) => applyEntityEvent(event, table, entity, sp));
776
781
  if (forgetResult.kind !== "applied") {
777
782
  return writeFailure(new InternalError({ message: "projection forget: applyEntityEvent skipped" }));
778
783
  }
@@ -783,14 +788,14 @@ export function createWriteVerbs(ctx) {
783
788
  await projectEntityEvent(db, runner, echoEvent);
784
789
  return {
785
790
  isSuccess: true,
786
- data: {
791
+ data: withoutEventInJson({
787
792
  kind: "delete",
788
793
  id: payload.id,
789
794
  data: existing,
790
795
  entityName,
791
796
  event: echoEvent,
792
797
  lockGuardBypassed: true,
793
- },
798
+ }),
794
799
  };
795
800
  },
796
801
  async restore(payload, user, db, options) {
@@ -840,7 +845,7 @@ export function createWriteVerbs(ctx) {
840
845
  // Soft-delete unique indexes are partial (WHERE is_deleted = false), so a live row may have re-taken the value this restore brings back.
841
846
  let restoreResult;
842
847
  try {
843
- restoreResult = await runInSavepointIfSupported(runner, async (sp) => applyEntityEvent(event, table, entity, sp));
848
+ restoreResult = await runInSavepointIfSupported(withExecutorManagedWrites(runner), async (sp) => applyEntityEvent(event, table, entity, sp));
844
849
  }
845
850
  catch (e) {
846
851
  const mapped = tryMapUniqueViolation(e, entityName);
@@ -865,7 +870,7 @@ export function createWriteVerbs(ctx) {
865
870
  await projectEntityEvent(db, runner, echoEvent);
866
871
  return {
867
872
  isSuccess: true,
868
- data: {
873
+ data: withoutEventInJson({
869
874
  kind: "save",
870
875
  id: payload.id,
871
876
  data: restoredHydrated,
@@ -874,7 +879,7 @@ export function createWriteVerbs(ctx) {
874
879
  isNew: false,
875
880
  entityName,
876
881
  event: echoEvent,
877
- },
882
+ }),
878
883
  };
879
884
  },
880
885
  };
@@ -0,0 +1,6 @@
1
+ import { AccessDeniedError } from "../errors/index.ts";
2
+ export declare function markExecutorManagedTable(table: object): void;
3
+ export declare function isExecutorManagedTable(table: unknown): boolean;
4
+ export declare function registerExecutorManagedWriteRunner(runner: object): void;
5
+ export declare function hasExecutorManagedWriteCapability(runner: unknown): boolean;
6
+ export declare function executorManagedWriteDenied(runner: unknown, table: unknown, operation: string): AccessDeniedError | undefined;
@@ -0,0 +1,40 @@
1
+ import { AccessDeniedError } from "../errors/index.js";
2
+ import { asEntityTableMeta, tableNameOf } from "./table-introspection.js";
3
+ const executorManagedTables = new WeakSet();
4
+ const executorManagedWriteRunners = new WeakSet();
5
+ export function markExecutorManagedTable(table) {
6
+ executorManagedTables.add(table);
7
+ const meta = asEntityTableMeta(table);
8
+ if (meta !== undefined)
9
+ executorManagedTables.add(meta);
10
+ }
11
+ export function isExecutorManagedTable(table) {
12
+ if (typeof table !== "object" || table === null)
13
+ return false;
14
+ if (executorManagedTables.has(table))
15
+ return true;
16
+ const meta = asEntityTableMeta(table);
17
+ return meta !== undefined && executorManagedTables.has(meta);
18
+ }
19
+ export function registerExecutorManagedWriteRunner(runner) {
20
+ executorManagedWriteRunners.add(runner);
21
+ }
22
+ // WeakSet.has never reads a property off runner, so a fail-closed guard Proxy stays untouched.
23
+ // postgres.js clients are callable (tagged templates), so a runner can be a function.
24
+ export function hasExecutorManagedWriteCapability(runner) {
25
+ if (typeof runner !== "object" && typeof runner !== "function")
26
+ return false;
27
+ return runner !== null && executorManagedWriteRunners.has(runner);
28
+ }
29
+ export function executorManagedWriteDenied(runner, table, operation) {
30
+ if (!isExecutorManagedTable(table))
31
+ return undefined;
32
+ if (hasExecutorManagedWriteCapability(runner))
33
+ return undefined;
34
+ const tableName = tableNameOf(table);
35
+ return new AccessDeniedError({
36
+ message: `${operation}(${tableName}): "${tableName}" is fed by events (r.entity / r.projection). ` +
37
+ "Write it through the entity executor (r.crud, ctx.write, createEventStoreExecutor) or inside a " +
38
+ "projection apply; a direct write drifts from the event stream and a rebuild drops or resurrects the row.",
39
+ });
40
+ }
@@ -1,3 +1,4 @@
1
+ export { applyEntityEvent } from "./apply-entity-event.ts";
1
2
  export { assertExistsIn } from "./assert-exists-in.ts";
2
3
  export { nullBlindIndexesForSubject, recordEventsOwnedExclusivelyByTenant, recordRowExistsInTenant, recordRowOwningTenantId, subjectRowExistsInTenant, } from "./blind-index-cleanup.ts";
3
4
  export { collectTableMetas } from "./collect-table-metas.ts";
@@ -14,7 +15,7 @@ export { collectReferenceFields, enrichRowWithReferences, enrichWithReferences,
14
15
  export { collectEncryptedFieldNames, configuredEntityFieldEncryption, configureEntityFieldEncryption, decryptEntityFieldValues, encryptEntityFieldValues, } from "./entity-field-encryption.ts";
15
16
  export { entityTableFromRegistry } from "./entity-table-from-registry.ts";
16
17
  export type { BuildEntityTableMetaOptions, ColumnMeta, CompositePrimaryKeyMeta, EntityTableMeta, IndexMeta, PgType, UnmanagedTableInput, } from "./entity-table-meta.ts";
17
- export { buildEntityTableMeta, defineUnmanagedTable, deriveEntityTableMeta, } from "./entity-table-meta.ts";
18
+ export { defineUnmanagedTable, deriveEntityTableMeta, } from "./entity-table-meta.ts";
18
19
  export type { EntityLifecycleVerb, EventStoreExecutor, EventStoreExecutorOptions, LockGuardOption, WritePurpose, } from "./event-store-executor.ts";
19
20
  export { createEventStoreExecutor, entityEventName, WritePurposes, } from "./event-store-executor.ts";
20
21
  export { enumerateFeatureTableSources, type FeatureTableSource, } from "./feature-table-sources.ts";
@@ -24,6 +25,7 @@ export { type AppliedMigration, type ApplyResult, type BaselineResult, baselineM
24
25
  export { flattenMoney, type MoneyRead, moneyPayloadToMinorUnits, rehydrateMoney } from "./money.ts";
25
26
  export { constraintOf, extractPgError, isTableAlreadyExists, isUniqueViolation, type PgErrorInfo, } from "./pg-error.ts";
26
27
  export { acquireNamespacedAdvisoryLock } from "./queries/advisory-lock.ts";
28
+ export { readDatabaseNow } from "./queries/database-now.ts";
27
29
  export { toPostgresArrayLiteral } from "./queries/postgres-array-literal.ts";
28
30
  export { executeRawQuery, executeRawQueryRead } from "./queries/raw-sql.ts";
29
31
  export type { DeleteManyBatchedOptions, DeleteManyBatchedResult, IncrementCounterOptions, InnerJoinRow, InnerJoinSpec, InsertOnConflictDoNothingOptions, JoinColumnPair, JsonTextMatch, SelectOptions, TableInfo, UpsertOnConflictOptions, WhereObject, WhereOperator, WhereValue, } from "./query.ts";
@@ -36,4 +38,4 @@ export { findCommentedDropTables, isRetiredFrameworkTable, type RetiredFramework
36
38
  export { tableExists } from "./schema-inspection.ts";
37
39
  export { buildBaseColumns, buildEntityTable, declareGlobalTenancy, type EntityRow, type EntityTable, physicalColumnName, toSnakeCase, toTableName, } from "./table-builder.ts";
38
40
  export type { CreateTenantDbOptions, OutsideTransactionContext, TenantDb, TenantDbGrants, TenantDbMode, UncheckedSystemDb, } from "./tenant-db.ts";
39
- export { castTenantRows, createSystemDbView, createTenantDb, hasOutsideTransactionDb, requireOutsideTransactionDb, runInOwnTransaction, SYSTEM_SCOPE_CHECK_BRAND, scopeTenantDbToTenant, } from "./tenant-db.ts";
41
+ export { castTenantRows, createSystemDbView, createTenantDataDestroyDb, createTenantDb, hasOutsideTransactionDb, requireOutsideTransactionDb, runInOwnTransaction, SYSTEM_SCOPE_CHECK_BRAND, scopeTenantDbToTenant, } from "./tenant-db.ts";
package/dist/db/index.js CHANGED
@@ -1,3 +1,4 @@
1
+ export { applyEntityEvent } from "./apply-entity-event.js";
1
2
  export { assertExistsIn } from "./assert-exists-in.js";
2
3
  export { nullBlindIndexesForSubject, recordEventsOwnedExclusivelyByTenant, recordRowExistsInTenant, recordRowOwningTenantId, subjectRowExistsInTenant, } from "./blind-index-cleanup.js";
3
4
  export { collectTableMetas } from "./collect-table-metas.js";
@@ -9,7 +10,7 @@ export { bigint, bigserial, boolean, extractTableName, index, instant, instantTo
9
10
  export { collectReferenceFields, enrichRowWithReferences, enrichWithReferences, } from "./eagerload.js";
10
11
  export { collectEncryptedFieldNames, configuredEntityFieldEncryption, configureEntityFieldEncryption, decryptEntityFieldValues, encryptEntityFieldValues, } from "./entity-field-encryption.js";
11
12
  export { entityTableFromRegistry } from "./entity-table-from-registry.js";
12
- export { buildEntityTableMeta, defineUnmanagedTable, deriveEntityTableMeta, } from "./entity-table-meta.js";
13
+ export { defineUnmanagedTable, deriveEntityTableMeta, } from "./entity-table-meta.js";
13
14
  export { createEventStoreExecutor, entityEventName, WritePurposes, } from "./event-store-executor.js";
14
15
  export { enumerateFeatureTableSources, } from "./feature-table-sources.js";
15
16
  export { flattenLocatedTimestamp, rehydrateLocatedTimestamp, } from "./located-timestamp.js";
@@ -18,6 +19,7 @@ export { baselineMigrations, fetchAppliedMigrations, loadMigrationsFromDir, Migr
18
19
  export { flattenMoney, moneyPayloadToMinorUnits, rehydrateMoney } from "./money.js";
19
20
  export { constraintOf, extractPgError, isTableAlreadyExists, isUniqueViolation, } from "./pg-error.js";
20
21
  export { acquireNamespacedAdvisoryLock } from "./queries/advisory-lock.js";
22
+ export { readDatabaseNow } from "./queries/database-now.js";
21
23
  export { toPostgresArrayLiteral } from "./queries/postgres-array-literal.js";
22
24
  export { executeRawQuery, executeRawQueryRead } from "./queries/raw-sql.js";
23
25
  export { aggregateWhere, asEntityTableMeta, asRawClient, countWhere, deleteMany, deleteManyBatched, extractTableInfo, fetchOne, incrementCounter, insertMany, insertOnConflictDoNothing, insertOne, instantToTimestamptzParam, isTimestamptzType, requireEntityTableMeta, runInSavepoint, runInSavepointIfSupported, runnerCanBeginTransaction, selectInnerJoin, selectMany, transaction, unsafeReadRetrying, updateMany, upsertByPk, upsertOnConflict, } from "./query.js";
@@ -28,4 +30,4 @@ export { diffReplayAgainstSnapshot, replayMigrationsDir, } from "./replay-migrat
28
30
  export { findCommentedDropTables, isRetiredFrameworkTable, retiredFrameworkTables, } from "./retired-framework-tables.js";
29
31
  export { tableExists } from "./schema-inspection.js";
30
32
  export { buildBaseColumns, buildEntityTable, declareGlobalTenancy, physicalColumnName, toSnakeCase, toTableName, } from "./table-builder.js";
31
- export { castTenantRows, createSystemDbView, createTenantDb, hasOutsideTransactionDb, requireOutsideTransactionDb, runInOwnTransaction, SYSTEM_SCOPE_CHECK_BRAND, scopeTenantDbToTenant, } from "./tenant-db.js";
33
+ export { castTenantRows, createSystemDbView, createTenantDataDestroyDb, createTenantDb, hasOutsideTransactionDb, requireOutsideTransactionDb, runInOwnTransaction, SYSTEM_SCOPE_CHECK_BRAND, scopeTenantDbToTenant, } from "./tenant-db.js";
@@ -1,5 +1,6 @@
1
+ import { toMinorUnits } from "../engine/minor-units.ts";
1
2
  import type { EntityDefinition } from "../engine/types/index.ts";
2
- export declare function toMinorUnits(amount: number): number;
3
+ export { toMinorUnits };
3
4
  export declare function moneyPayloadToMinorUnits(raw: unknown): number | undefined;
4
5
  export declare function flattenMoney(payload: Record<string, unknown>, entity: EntityDefinition): Record<string, unknown>;
5
6
  /** Shape of a single rehydrated money field — {amount major, currency,
@@ -11,7 +12,7 @@ export type MoneyRead = {
11
12
  readonly currency: string;
12
13
  readonly amountScaled: number;
13
14
  /**
14
- * @deprecated Use `amountScaled` — this name implies ISO-4217 minor units
15
+ * @deprecated Removed in 0.390.0. Use `amountScaled` — this name implies ISO-4217 minor units
15
16
  * (cents), which is wrong once a currency needing a different scale than
16
17
  * the current flat MINOR_UNIT_SCALE=100 lands (e.g. JPY, 0 decimals).
17
18
  * Alias of `amountScaled`, kept until #1976/4 and #1976/5 migrate their
package/dist/db/money.js CHANGED
@@ -30,15 +30,10 @@
30
30
  // Anders als locatedTimestamp behalten wir den Field-Namen `<name>` als
31
31
  // amount-Spalte (Legacy DB-Convention für Money — `SUM(buying_price)` bleibt
32
32
  // idiomatisch). `<name>Currency` ist die zusätzliche Spalte.
33
+ import { MINOR_UNIT_SCALE, toMinorUnits } from "../engine/minor-units.js";
33
34
  import { DEFAULT_CURRENCIES } from "../engine/types/index.js";
34
35
  const FRAMEWORK_DEFAULT_CURRENCY = DEFAULT_CURRENCIES[0]; // "EUR"
35
- // 2 decimal places (cents) — covers every currently-supported currency
36
- // (EUR/USD/GBP/...). No ISO-4217 minor-unit table yet (JPY=0, BHD=3) —
37
- // upgrade path once a currency needing a different scale actually lands.
38
- const MINOR_UNIT_SCALE = 100;
39
- export function toMinorUnits(amount) {
40
- return Math.round(amount * MINOR_UNIT_SCALE);
41
- }
36
+ export { toMinorUnits };
42
37
  function toMajorUnits(amountScaled) {
43
38
  return amountScaled / MINOR_UNIT_SCALE;
44
39
  }
@@ -62,7 +62,7 @@ import { quoteIdent } from "../../crypto/ciphertext-pattern.js";
62
62
  import { configuredEventPiiCatalog } from "../../crypto/event-pii.js";
63
63
  import { isLocalKeyKmsAdapter, KeyErasedError, KeyNotFoundError, } from "../../crypto/kms-adapter.js";
64
64
  import { isPlainUserOwnedField } from "../../crypto/owner-transition-rekey.js";
65
- import { configuredPiiSubjectKms, decryptPiiValueForSubject, encryptPiiValueForSubject, isPiiCiphertext, PII_ERASED_SENTINEL, piiCiphertextSubject, } from "../../crypto/pii-field-encryption.js";
65
+ import { configuredPiiSubjectKms, decryptPiiValueForSubject, encryptPiiJsonValueForSubject, encryptPiiValueForSubject, isPiiCiphertext, PII_ERASED_SENTINEL, piiCiphertextSubject, } from "../../crypto/pii-field-encryption.js";
66
66
  import { collectPiiEncryptedFields, nonEmptyString, resolveEventSubject, resolveSubjectForField, SubjectResolutionError, } from "../../crypto/subject-resolver.js";
67
67
  import { resolveTableName } from "../entity-table-meta.js";
68
68
  import { asRawClient } from "../query.js";
@@ -343,11 +343,11 @@ export async function backfillEventPiiEncryptionBatch(db, registry, options = {}
343
343
  const value = section[field];
344
344
  if (value === null || value === undefined)
345
345
  continue;
346
- // jsonb PII is not retro-encrypted (consumer wave); a legacy string
347
- // value here would otherwise get the v2 text format into a jsonb column.
348
- if (target.entity.fields[field]?.type === "jsonb")
349
- continue;
350
- if (typeof value !== "string")
346
+ // A jsonb field holds any JSON value; a string that is neither ciphertext nor the
347
+ // sentinel is a plaintext scalar and gets the JSON format (the v2 text format must
348
+ // not reach a jsonb column).
349
+ const isJsonbField = target.entity.fields[field]?.type === "jsonb";
350
+ if (!isJsonbField && typeof value !== "string")
351
351
  continue;
352
352
  // Already-ciphertext/sentinel fields must never enter the encrypt/erase
353
353
  // owner resolution — an unresolvable owner on an already-handled field
@@ -374,7 +374,7 @@ export async function backfillEventPiiEncryptionBatch(db, registry, options = {}
374
374
  }
375
375
  if (resolution.viaProjection)
376
376
  counters.ownerFromProjection++;
377
- const outcome = await encryptField(section, field, resolution.subject);
377
+ const outcome = await encryptField(section, field, resolution.subject, isJsonbField);
378
378
  bump(outcome);
379
379
  }
380
380
  }
@@ -405,13 +405,11 @@ export async function backfillEventPiiEncryptionBatch(db, registry, options = {}
405
405
  counters.erased++;
406
406
  }
407
407
  }
408
- async function encryptField(section, field, subject) {
408
+ async function encryptField(section, field, subject, isJsonbField = false) {
409
409
  const value = section[field];
410
410
  if (value === null || value === undefined)
411
411
  return "unchanged";
412
- if (typeof value !== "string")
413
- return "unchanged";
414
- if (isPiiCiphertext(value) || value === PII_ERASED_SENTINEL)
412
+ if (!isBackfillCandidate(value, isJsonbField))
415
413
  return "unchanged";
416
414
  if (await isForgottenSubject(subject, row.aggregate_id)) {
417
415
  section[field] = PII_ERASED_SENTINEL;
@@ -420,7 +418,7 @@ export async function backfillEventPiiEncryptionBatch(db, registry, options = {}
420
418
  if (options.dryRun)
421
419
  return predictEncryptOutcome(subject);
422
420
  try {
423
- section[field] = await encryptPiiValueForSubject(subjectKms, subject, value, kmsCtx, field);
421
+ section[field] = await encryptBackfillValue(subjectKms, subject, value, kmsCtx, field, isJsonbField);
424
422
  return "encrypted";
425
423
  }
426
424
  catch (e) {
@@ -469,6 +467,18 @@ export async function backfillEventPiiEncryptionBatch(db, registry, options = {}
469
467
  return scanCache.destroyedTenants;
470
468
  }
471
469
  }
470
+ // Text fields only hold strings; a jsonb field holds any JSON value, scalars included.
471
+ // Ciphertext and the erased sentinel are already handled.
472
+ function isBackfillCandidate(value, isJsonbField) {
473
+ if (typeof value === "string")
474
+ return !isPiiCiphertext(value) && value !== PII_ERASED_SENTINEL;
475
+ return isJsonbField || (typeof value === "object" && value !== null);
476
+ }
477
+ function encryptBackfillValue(subjectKms, subject, value, kmsCtx, field, isJsonbField) {
478
+ return typeof value === "string" && !isJsonbField
479
+ ? encryptPiiValueForSubject(subjectKms, subject, value, kmsCtx, field)
480
+ : encryptPiiJsonValueForSubject(subjectKms, subject, value, kmsCtx, field);
481
+ }
472
482
  // aggregate_id is only unique per tenant: another tenant's projection row must not name the owner.
473
483
  function projectionOwnerRowOfTenant(owner, tenantId) {
474
484
  if (owner === undefined)
@@ -0,0 +1,5 @@
1
+ import type { AnyDb } from "../query.ts";
2
+ import type { TenantDb } from "../tenant-db.ts";
3
+ export declare function queryDatabaseNow(runner: AnyDb): Promise<Date>;
4
+ /** Postgres clock; inside a transaction `now()` is the transaction start, so repeated reads agree. */
5
+ export declare function readDatabaseNow(db: TenantDb): Promise<Date>;
@@ -0,0 +1,12 @@
1
+ import { executeRawQueryRead } from "./raw-sql.js";
2
+ // Called by createTenantDb with its inner runner; keeps raw SQL out of consumers.
3
+ export async function queryDatabaseNow(runner) {
4
+ const [row] = await executeRawQueryRead(runner, "SELECT now() AS now");
5
+ if (!row)
6
+ throw new Error("SELECT now() returned no row");
7
+ return row.now;
8
+ }
9
+ /** Postgres clock; inside a transaction `now()` is the transaction start, so repeated reads agree. */
10
+ export function readDatabaseNow(db) {
11
+ return db.databaseNow();
12
+ }
@@ -0,0 +1,8 @@
1
+ import type { TenantId } from "../../engine/types/index.ts";
2
+ import { type AnyDb } from "../query.ts";
3
+ export type DeleteRowsWithoutReplayableStreamArgs = {
4
+ readonly table: unknown;
5
+ readonly ids: readonly string[];
6
+ readonly tenantId?: TenantId;
7
+ };
8
+ export declare function deleteRowsWithoutReplayableStream(db: AnyDb, args: DeleteRowsWithoutReplayableStreamArgs): Promise<readonly string[]>;