@cosmicdrift/kumiko-framework 0.387.0 → 0.388.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 (240) hide show
  1. package/AGENTS.md +6 -1
  2. package/agents/examples/anonymous-access.md +1 -1
  3. package/agents/examples/apex-landing.md +204 -0
  4. package/agents/examples/api-worker-split.md +64 -0
  5. package/agents/examples/app-config.md +14 -0
  6. package/agents/examples/auth-mfa.md +115 -0
  7. package/agents/examples/env-composition.md +28 -1
  8. package/agents/examples/legal-pages.md +1 -1
  9. package/agents/examples/prod-boot-options.md +16 -0
  10. package/agents/examples/rate-limiting.md +48 -0
  11. package/agents/examples/record-detail-layout.md +692 -0
  12. package/agents/examples/seed-migration.md +212 -0
  13. package/agents/examples/wizard-form.md +941 -0
  14. package/agents/examples/writeform-section.md +548 -0
  15. package/agents/features.md +19 -2
  16. package/agents/widgets.md +1 -0
  17. package/dist/api/client-error-route.js +8 -2
  18. package/dist/api/index.d.ts +3 -1
  19. package/dist/api/index.js +3 -1
  20. package/dist/api/request-context.d.ts +1 -1
  21. package/dist/api/request-context.js +1 -1
  22. package/dist/api/server-boot-checks.d.ts +10 -0
  23. package/dist/api/server-boot-checks.js +157 -0
  24. package/dist/api/server-event-dispatcher.d.ts +26 -0
  25. package/dist/api/server-event-dispatcher.js +208 -0
  26. package/dist/api/server-extra-routes.d.ts +11 -0
  27. package/dist/api/server-extra-routes.js +203 -0
  28. package/dist/api/server-middleware.d.ts +20 -0
  29. package/dist/api/server-middleware.js +211 -0
  30. package/dist/api/server-routes.d.ts +19 -0
  31. package/dist/api/server-routes.js +293 -0
  32. package/dist/api/server-websocket-routes.d.ts +4 -0
  33. package/dist/api/server-websocket-routes.js +108 -0
  34. package/dist/api/server-wiring.d.ts +32 -0
  35. package/dist/api/server-wiring.js +236 -0
  36. package/dist/api/server.d.ts +10 -15
  37. package/dist/api/server.js +48 -1251
  38. package/dist/api/session-cookies.js +4 -4
  39. package/dist/api/sse-route.js +16 -12
  40. package/dist/crypto/owner-transition-rekey.d.ts +5 -1
  41. package/dist/crypto/owner-transition-rekey.js +1 -1
  42. package/dist/crypto/pii-field-encryption.d.ts +5 -1
  43. package/dist/crypto/pii-field-encryption.js +3 -1
  44. package/dist/db/aggregate-sql.d.ts +21 -0
  45. package/dist/db/aggregate-sql.js +155 -0
  46. package/dist/db/closed-connection.d.ts +9 -0
  47. package/dist/db/closed-connection.js +95 -0
  48. package/dist/db/dialect.js +1 -1
  49. package/dist/db/distinct-on.d.ts +1 -1
  50. package/dist/db/driver-row-coercion.d.ts +2 -0
  51. package/dist/db/driver-row-coercion.js +186 -0
  52. package/dist/db/driver-value-preparation.d.ts +16 -0
  53. package/dist/db/driver-value-preparation.js +99 -0
  54. package/dist/db/entity-field-encryption.d.ts +1 -0
  55. package/dist/db/entity-field-encryption.js +1 -1
  56. package/dist/db/entity-table-meta.js +103 -110
  57. package/dist/db/event-store-executor-context.d.ts +2 -1
  58. package/dist/db/event-store-executor-read-aggregate.d.ts +8 -0
  59. package/dist/db/event-store-executor-read-aggregate.js +112 -0
  60. package/dist/db/event-store-executor-read-filter.d.ts +31 -0
  61. package/dist/db/event-store-executor-read-filter.js +203 -0
  62. package/dist/db/event-store-executor-read-paging.d.ts +14 -0
  63. package/dist/db/event-store-executor-read-paging.js +99 -0
  64. package/dist/db/event-store-executor-read-rows.d.ts +6 -0
  65. package/dist/db/event-store-executor-read-rows.js +32 -0
  66. package/dist/db/event-store-executor-read-search.d.ts +28 -0
  67. package/dist/db/event-store-executor-read-search.js +221 -0
  68. package/dist/db/event-store-executor-read-sort.d.ts +41 -0
  69. package/dist/db/event-store-executor-read-sort.js +130 -0
  70. package/dist/db/event-store-executor-read-where.d.ts +24 -0
  71. package/dist/db/event-store-executor-read-where.js +81 -0
  72. package/dist/db/event-store-executor-read.d.ts +2 -9
  73. package/dist/db/event-store-executor-read.js +48 -701
  74. package/dist/db/event-store-executor-write-steps.d.ts +20 -0
  75. package/dist/db/event-store-executor-write-steps.js +100 -0
  76. package/dist/db/event-store-executor-write.js +395 -499
  77. package/dist/db/index.d.ts +6 -2
  78. package/dist/db/index.js +5 -1
  79. package/dist/db/list-aggregate-labels.d.ts +14 -0
  80. package/dist/db/list-aggregate-labels.js +26 -0
  81. package/dist/db/migrate-generator.js +71 -86
  82. package/dist/db/migrate-runner.js +4 -1
  83. package/dist/db/query.d.ts +2 -15
  84. package/dist/db/query.js +8 -761
  85. package/dist/db/reference-data.js +6 -1
  86. package/dist/db/table-builder.js +113 -166
  87. package/dist/db/table-info.d.ts +10 -0
  88. package/dist/db/table-info.js +45 -0
  89. package/dist/db/temporal-value-guards.d.ts +6 -0
  90. package/dist/db/temporal-value-guards.js +15 -0
  91. package/dist/db/tenant-db-runner.d.ts +1 -0
  92. package/dist/db/tenant-db-runner.js +3 -0
  93. package/dist/db/tenant-db.js +39 -9
  94. package/dist/db/where-clause.d.ts +6 -0
  95. package/dist/db/where-clause.js +236 -0
  96. package/dist/derivatives/delete-stored-file.js +2 -1
  97. package/dist/engine/boot-validator/boot-check.js +1 -1
  98. package/dist/engine/boot-validator/detail-screens.js +2 -2
  99. package/dist/engine/boot-validator/entity-handler.d.ts +1 -0
  100. package/dist/engine/boot-validator/entity-handler.js +17 -0
  101. package/dist/engine/boot-validator/form-derivations.js +35 -1
  102. package/dist/engine/boot-validator/i18n-keys.js +5 -3
  103. package/dist/engine/boot-validator/index.js +2 -0
  104. package/dist/engine/boot-validator/pii-retention.js +233 -155
  105. package/dist/engine/boot-validator/screens-actions.d.ts +1 -0
  106. package/dist/engine/boot-validator/screens-actions.js +8 -1
  107. package/dist/engine/boot-validator/screens-dashboard.js +2 -5
  108. package/dist/engine/boot-validator/screens-forms.js +2 -6
  109. package/dist/engine/boot-validator/screens-kind-config-edit.d.ts +3 -0
  110. package/dist/engine/boot-validator/screens-kind-config-edit.js +88 -0
  111. package/dist/engine/boot-validator/screens-kind-entity-edit.d.ts +3 -0
  112. package/dist/engine/boot-validator/screens-kind-entity-edit.js +55 -0
  113. package/dist/engine/boot-validator/screens-kind-entity-list.d.ts +3 -0
  114. package/dist/engine/boot-validator/screens-kind-entity-list.js +228 -0
  115. package/dist/engine/boot-validator/screens-kind-projection-detail.d.ts +3 -0
  116. package/dist/engine/boot-validator/screens-kind-projection-detail.js +235 -0
  117. package/dist/engine/boot-validator/screens-kind-projection-list.d.ts +3 -0
  118. package/dist/engine/boot-validator/screens-kind-projection-list.js +52 -0
  119. package/dist/engine/boot-validator/screens-kind-secret-mint.d.ts +3 -0
  120. package/dist/engine/boot-validator/screens-kind-secret-mint.js +28 -0
  121. package/dist/engine/boot-validator/screens-kind-shared.d.ts +37 -0
  122. package/dist/engine/boot-validator/screens-kind-shared.js +142 -0
  123. package/dist/engine/boot-validator/screens-lists.d.ts +2 -1
  124. package/dist/engine/boot-validator/screens-lists.js +12 -9
  125. package/dist/engine/boot-validator/screens.d.ts +0 -2
  126. package/dist/engine/boot-validator/screens.js +99 -860
  127. package/dist/engine/build-app-schema.js +56 -96
  128. package/dist/engine/define-feature.js +9 -2
  129. package/dist/engine/define-handler.js +1 -0
  130. package/dist/engine/entity-handlers.d.ts +13 -0
  131. package/dist/engine/entity-handlers.js +10 -21
  132. package/dist/engine/entity-list-runtime-options.d.ts +8 -0
  133. package/dist/engine/entity-list-runtime-options.js +18 -0
  134. package/dist/engine/feature-ast/extractors/handlers.js +1 -0
  135. package/dist/engine/feature-ast/extractors/round2.js +25 -6
  136. package/dist/engine/feature-ast/patch.js +4 -182
  137. package/dist/engine/feature-ast/pattern-change-schema.d.ts +3 -0
  138. package/dist/engine/feature-ast/pattern-change-schema.js +1 -0
  139. package/dist/engine/feature-ast/pattern-id-matchers.d.ts +7 -0
  140. package/dist/engine/feature-ast/pattern-id-matchers.js +210 -0
  141. package/dist/engine/feature-ast/patterns.d.ts +9 -0
  142. package/dist/engine/feature-ast/patterns.js +43 -51
  143. package/dist/engine/feature-ast/render.js +48 -87
  144. package/dist/engine/feature-entity-handlers.js +3 -0
  145. package/dist/engine/form-derivations.js +18 -7
  146. package/dist/engine/handler-field-references.js +1 -1
  147. package/dist/engine/project-app-schema-for-roles.d.ts +2 -0
  148. package/dist/engine/project-app-schema-for-roles.js +6 -4
  149. package/dist/engine/registry-ingest.js +1 -1
  150. package/dist/engine/schema-builder.js +228 -212
  151. package/dist/engine/screen-helpers.js +1 -1
  152. package/dist/engine/screen-kind.d.ts +7 -0
  153. package/dist/engine/screen-kind.js +1 -0
  154. package/dist/engine/strip-disabled-features-from-app-schema.js +51 -14
  155. package/dist/engine/validate-projection-allowlist.js +2 -1
  156. package/dist/entrypoint/index.d.ts +4 -1
  157. package/dist/entrypoint/index.js +7 -1
  158. package/dist/errors/docs-url.d.ts +1 -1
  159. package/dist/errors/docs-url.js +22 -2
  160. package/dist/es-ops/context.js +1 -1
  161. package/dist/event-store/admin-api.js +1 -1
  162. package/dist/event-store/event-store.js +10 -4
  163. package/dist/files/bind-pending-file-refs.js +3 -3
  164. package/dist/files/create-file-ref-from-bytes.js +4 -1
  165. package/dist/files/file-handle.js +2 -3
  166. package/dist/files/file-routes.js +11 -8
  167. package/dist/files/resolve-content-type.js +43 -0
  168. package/dist/files/upload-ledger.js +7 -0
  169. package/dist/i18n/required-surface-keys.js +182 -206
  170. package/dist/internal/stack-support.d.ts +2 -1
  171. package/dist/internal/stack-support.js +1 -1
  172. package/dist/jobs/job-runner-context.d.ts +32 -0
  173. package/dist/jobs/job-runner-context.js +305 -0
  174. package/dist/jobs/job-runner-execution.d.ts +7 -0
  175. package/dist/jobs/job-runner-execution.js +160 -0
  176. package/dist/jobs/job-runner-gates.d.ts +19 -0
  177. package/dist/jobs/job-runner-gates.js +198 -0
  178. package/dist/jobs/job-runner-helpers.d.ts +17 -3
  179. package/dist/jobs/job-runner-helpers.js +61 -2
  180. package/dist/jobs/job-runner-scope.d.ts +44 -0
  181. package/dist/jobs/job-runner-scope.js +102 -0
  182. package/dist/jobs/job-runner-types.d.ts +12 -0
  183. package/dist/jobs/job-runner.d.ts +0 -1
  184. package/dist/jobs/job-runner.js +47 -657
  185. package/dist/observability/escape-hatch-report.d.ts +3 -0
  186. package/dist/observability/escape-hatch-report.js +12 -1
  187. package/dist/pipeline/cascade-handler.js +165 -138
  188. package/dist/pipeline/dispatch-query.js +10 -3
  189. package/dist/pipeline/dispatch-shared.d.ts +13 -3
  190. package/dist/pipeline/dispatch-shared.js +58 -710
  191. package/dist/pipeline/dispatch-write.js +296 -189
  192. package/dist/pipeline/event-dispatcher-admin.js +2 -1
  193. package/dist/pipeline/event-dispatcher-delivery.js +3 -5
  194. package/dist/pipeline/handler-context-auth.d.ts +9 -0
  195. package/dist/pipeline/handler-context-auth.js +54 -0
  196. package/dist/pipeline/handler-context-config.d.ts +14 -0
  197. package/dist/pipeline/handler-context-config.js +113 -0
  198. package/dist/pipeline/handler-context-db.d.ts +24 -0
  199. package/dist/pipeline/handler-context-db.js +149 -0
  200. package/dist/pipeline/handler-context-dispatch.d.ts +21 -0
  201. package/dist/pipeline/handler-context-dispatch.js +89 -0
  202. package/dist/pipeline/handler-context-events.d.ts +43 -0
  203. package/dist/pipeline/handler-context-events.js +237 -0
  204. package/dist/pipeline/handler-context-notify.d.ts +12 -0
  205. package/dist/pipeline/handler-context-notify.js +55 -0
  206. package/dist/pipeline/handler-context-observability.d.ts +7 -0
  207. package/dist/pipeline/handler-context-observability.js +21 -0
  208. package/dist/pipeline/handler-context-scope.d.ts +48 -0
  209. package/dist/pipeline/handler-context-scope.js +88 -0
  210. package/dist/pipeline/notify-scope.js +12 -1
  211. package/dist/pipeline/pre-handler-gates.js +20 -4
  212. package/dist/pipeline/pre-transaction-reservations.d.ts +1 -0
  213. package/dist/pipeline/pre-transaction-reservations.js +9 -3
  214. package/dist/pipeline/projection-rebuild.js +2 -1
  215. package/dist/schema-cli/apply-command.d.ts +2 -0
  216. package/dist/schema-cli/apply-command.js +97 -0
  217. package/dist/schema-cli/baseline-command.d.ts +2 -0
  218. package/dist/schema-cli/baseline-command.js +33 -0
  219. package/dist/schema-cli/generate-command.d.ts +2 -0
  220. package/dist/schema-cli/generate-command.js +90 -0
  221. package/dist/schema-cli/shared.d.ts +39 -0
  222. package/dist/schema-cli/shared.js +95 -0
  223. package/dist/schema-cli/status-command.d.ts +2 -0
  224. package/dist/schema-cli/status-command.js +73 -0
  225. package/dist/schema-cli/validate-boot-input.d.ts +5 -0
  226. package/dist/schema-cli/validate-boot-input.js +130 -0
  227. package/dist/schema-cli/validate-command.d.ts +2 -0
  228. package/dist/schema-cli/validate-command.js +130 -0
  229. package/dist/schema-cli.d.ts +2 -18
  230. package/dist/schema-cli.js +47 -604
  231. package/dist/search/meilisearch-adapter.js +2 -1
  232. package/dist/upgrade-cli.js +149 -121
  233. package/package.json +4 -4
  234. package/src/changes.json +310 -0
  235. package/src/scripts/codemod/feature-layout.ts +10 -2
  236. package/src/scripts/codemod/object-form.ts +41 -4
  237. package/src/scripts/codemod/package-merge.ts +58 -8
  238. package/src/scripts/codemod/pii-personal-migration.ts +175 -145
  239. package/src/scripts/codemod/testing-injection.ts +122 -42
  240. package/src/scripts/codemod/testing-move.ts +29 -7
@@ -37,7 +37,12 @@ export async function seedReferenceData(defs, registry, db) {
37
37
  const keyValue = row[upsertKey];
38
38
  if (keyValue === undefined)
39
39
  continue;
40
- const existing = await fetchOne(db, table, { [upsertKey]: keyValue });
40
+ // Soft-deleted rows are invisible to executor.update, so treat them as missing and re-create.
41
+ const existing = await fetchOne(db, table, {
42
+ [upsertKey]: keyValue,
43
+ tenantId: SYSTEM_TENANT_ID,
44
+ ...(entity.softDelete && { isDeleted: false }),
45
+ });
41
46
  if (existing) {
42
47
  const changes = {};
43
48
  for (const [field, value] of Object.entries(row)) {
@@ -1,6 +1,5 @@
1
1
  import { KUMIKO_META_SYMBOL } from "@cosmicdrift/kumiko-types/schema-table-types";
2
2
  import { toSnakeCase } from "../utils/case.js";
3
- import { assertUnreachable } from "../utils/index.js";
4
3
  import { markExecutorManagedTable } from "./executor-managed-tables.js";
5
4
  import { columnHandleName } from "./table-introspection.js";
6
5
  export { toSnakeCase } from "../utils/case.js";
@@ -13,172 +12,120 @@ export const SOFT_DELETE_LIVE_ROW_PREDICATE = `"is_deleted" = false`;
13
12
  export function physicalColumnName(table, field) {
14
13
  return columnHandleName(table, field) ?? toSnakeCase(field);
15
14
  }
16
- // Returns column(s) for a field. Most fields return a single entry,
17
- // money returns two (amount + currency).
18
- //
19
- // `required: true` on a FieldDefinition always maps to .notNull() in the
20
- // database column. Previously this applied only to reference fields, while
21
- // text/select/number/etc. remained silently nullable even when the API
22
- // validator enforced required. That forced hand-written PgTable definitions
23
- // to add .notNull(), causing duplicate definitions and schema drift. Now
24
- // r.entity is the single source of truth.
25
- function fieldToColumns(name, field, entity) {
26
- const snakeName = toSnakeCase(name);
27
- switch (field.type) {
28
- case "text":
29
- case "longText": {
30
- // Both map to PG `text` (unbounded). The difference exists only in the
31
- // type layer: longText has no sortable/searchable/filterable support.
32
- // The order default() before notNull() matters: the column builder
33
- // chains both; without default(), the generated SQL has no DEFAULT
34
- // clause, which breaks ALTER TABLE ADD COLUMN on existing rows.
35
- // longText currently has no default field in its type, but checking
36
- // `field.default !== undefined` is defensive.
37
- const base = text(snakeName);
38
- const withDefault = "default" in field && field.default !== undefined ? base.default(field.default) : base;
39
- const mainCol = { [name]: field.required ? withDefault.notNull() : withDefault };
40
- // lookupable → nullable HMAC blind-index column (kept in lockstep with
41
- // fieldToColumnMeta in entity-table-meta.ts).
42
- if (field.type === "text" && field.lookupable === true) {
43
- return { ...mainCol, [`${name}Bidx`]: text(`${snakeName}_bidx`) };
44
- }
45
- return mainCol;
46
- }
47
- case "boolean":
48
- return {
49
- [name]: field.default !== undefined
50
- ? boolean(snakeName).default(field.default).notNull()
51
- : field.required
52
- ? boolean(snakeName).notNull()
53
- : boolean(snakeName),
54
- };
55
- case "select": {
56
- // Pass default() through because entity-table-meta renders declared
57
- // defaults. This builder must stay in lockstep; otherwise the metadata
58
- // on the buildEntityTable object describes a different column than the
59
- // migration.
60
- const base = text(snakeName);
61
- const col = field.default !== undefined ? base.default(field.default) : base;
62
- return { [name]: field.required ? col.notNull() : col };
63
- }
64
- case "multiSelect":
65
- // jsonb string array with default `[]`, always NOT NULL.
66
- //
67
- // The `required` flag on MultiSelectFieldDef is intentionally ignored:
68
- // with default `[]`, the field is structurally never null (an insert
69
- // without a value produces an empty array, not NULL). Read-side code
70
- // therefore needs no null check; this is an API guarantee. Code that
71
- // needs actual null (meaning the field has never been set) should use
72
- // a separate status field or an optional-typed reference instead of a
73
- // multi-select.
74
- return { [name]: jsonb(snakeName).default([]).notNull() };
75
- case "number": {
76
- const base = field.integer === true ? integer(snakeName) : doublePrecision(snakeName);
77
- const col = field.default !== undefined ? base.default(field.default) : base;
78
- return { [name]: field.required ? col.notNull() : col };
79
- }
80
- case "decimal": {
81
- const base = decimalColumn(snakeName, field.precision, field.scale);
82
- const col = field.default !== undefined ? base.default(field.default) : base;
83
- return { [name]: field.required ? col.notNull() : col };
84
- }
85
- case "bigInt": {
86
- // 64-bit integer for audit counters, byte sizes, and cumulative sums.
87
- // mode:"number" produces a JS `number` (safe up to 2^53 ≈ 9 PB) rather
88
- // than JS `bigint`, making it JSON-serializable and frontend-friendly.
89
- // Values above 2^53 should use a text field with a custom codec.
90
- const base = bigint(snakeName, { mode: "number" });
91
- const col = field.default !== undefined ? base.default(field.default) : base;
92
- return { [name]: field.required ? col.notNull() : col };
93
- }
94
- case "reference":
95
- // FK-style UUID column. Multi-mode stores UUIDs as a jsonb string array;
96
- // single-mode remains a conventional UUID column (NOT NULL only when
97
- // required).
98
- if (field.multiple === true) {
99
- return { [name]: jsonb(snakeName).default([]).notNull() };
100
- }
101
- return {
102
- [name]: field.required ? uuid(snakeName).notNull() : uuid(snakeName),
103
- };
104
- case "money":
105
- // BIGINT stores the integer minor unit (cents for EUR, yen for JPY —
106
- // the currency column identifies which). INTEGER would cap at about
107
- // 21 M EUR, which is too low for B2B invoices, property values, or
108
- // balance aggregates. BIGINT safely handles up to about 90 trillion
109
- // EUR in JS.
110
- // Currency always has a default, so it is structurally .notNull().
111
- return {
112
- [name]: field.required ? moneyAmount(snakeName).notNull() : moneyAmount(snakeName),
113
- [`${name}Currency`]: text(`${snakeName}_currency`)
114
- .default(entity.defaultCurrency ?? "EUR")
115
- .notNull(),
116
- };
117
- case "embedded":
118
- // jsonb with default `{}`, always NOT NULL, analogous to multiSelect.
119
- // `required` is intentionally ignored because the default makes the
120
- // field structurally never null. To make embedded data optional (so
121
- // the field can be omitted entirely), model it with a wrapper field
122
- // and boolean flag or a discriminated union.
123
- // multiple → the value is a list of rows, so the empty default is `[]`.
124
- return field.multiple === true
125
- ? { [name]: jsonb(snakeName).default([]).notNull() }
126
- : { [name]: jsonb(snakeName).default({}).notNull() };
127
- case "jsonb":
128
- // Free-form jsonb — keys are not schema-validated. Default `{}`, NOT NULL
129
- // (analogous to embedded). Use case: the host entity's custom-fields
130
- // bundle in the `customFields` column, whose keys are tenant-defined.
131
- return { [name]: jsonb(snakeName).default({}).notNull() };
132
- case "date": {
133
- // Real PG `date` — no time-of-day, no timezone. Temporal.PlainDate
134
- // end-to-end (see plainDate() in dialect.ts). Was aliased on instant()
135
- // = TIMESTAMPTZ (TZ-dependent round-trip).
136
- const col = plainDate(snakeName);
137
- return { [name]: field.required ? col.notNull() : col };
138
- }
139
- case "timestamp": {
140
- // UTC instant — stored as TIMESTAMPTZ in PG and read/written as
141
- // Temporal.Instant through the instant() customType (see dialect.ts).
142
- // Callers use only Temporal.Instant, never JS Date. Comparisons
143
- // (lte/gt/orderBy) also accept Instants directly, so no .toString()
144
- // cast is needed.
145
- const col = instant(snakeName);
146
- return { [name]: field.required ? col.notNull() : col };
147
- }
148
- case "tz": {
149
- // IANA time-zone name as TEXT — validated through the Zod schema in the
150
- // validator step. Snake-case convention: `pickup_tz`.
151
- const col = text(snakeName);
152
- return { [name]: field.required ? col.notNull() : col };
153
- }
154
- case "locatedTimestamp": {
155
- // Two columns form an atomic pair: <name>_utc TIMESTAMPTZ + <name>_tz TEXT.
156
- // _utc is instant() (Temporal.Instant), and _tz is text (IANA name).
157
- // The executor wires automatic conversion (at+tz → utc on insert;
158
- // utc+tz → at on read). required propagates to both columns.
159
- const utc = instant(`${snakeName}_utc`);
160
- const tz = text(`${snakeName}_tz`);
161
- return {
162
- [`${name}Utc`]: field.required ? utc.notNull() : utc,
163
- [`${name}Tz`]: field.required ? tz.notNull() : tz,
164
- };
165
- }
166
- case "file":
167
- case "image": {
168
- // Single file: stores fileRefId as UUID — must match fileRefsTable.id
169
- // (uuid column). Anything narrower (integer, text length-limited) would
170
- // silently truncate or type-coerce at INSERT time and the FK reference
171
- // would be unusable.
172
- const col = uuid(snakeName);
173
- return { [name]: field.required ? col.notNull() : col };
174
- }
175
- case "files":
176
- case "images":
177
- // Ordered fileRef UUID array, same shape as multiSelect: never null.
178
- return { [name]: jsonb(snakeName).default([]).notNull() };
179
- default:
180
- assertUnreachable(field, "field type");
15
+ // `required: true` maps to NOT NULL for every field type, so r.entity stays the
16
+ // single source of truth and hand-written tables never add .notNull() on top.
17
+ function notNullWhenRequired(column, required) {
18
+ return required ? column.notNull() : column;
19
+ }
20
+ // Without a DEFAULT clause ALTER TABLE ADD COLUMN breaks on existing rows, and
21
+ // entity-table-meta renders declared defaults too, so both builders stay in lockstep.
22
+ function withDeclaredDefault(column, value) {
23
+ return value !== undefined ? column.default(value) : column;
24
+ }
25
+ // longText differs from text only in the type layer (no sortable/searchable/filterable).
26
+ function textColumns(name, snakeName, field) {
27
+ const declaredDefault = "default" in field ? field.default : undefined;
28
+ const mainCol = {
29
+ [name]: notNullWhenRequired(withDeclaredDefault(text(snakeName), declaredDefault), field.required),
30
+ };
31
+ // Nullable HMAC blind-index column, in lockstep with fieldToColumnMeta in entity-table-meta.ts.
32
+ if (field.type === "text" && field.lookupable === true) {
33
+ return { ...mainCol, [`${name}Bidx`]: text(`${snakeName}_bidx`) };
181
34
  }
35
+ return mainCol;
36
+ }
37
+ function booleanColumns(name, snakeName, field) {
38
+ if (field.default !== undefined) {
39
+ return { [name]: boolean(snakeName).default(field.default).notNull() };
40
+ }
41
+ return { [name]: notNullWhenRequired(boolean(snakeName), field.required) };
42
+ }
43
+ function numberColumns(name, snakeName, field) {
44
+ const base = field.integer === true ? integer(snakeName) : doublePrecision(snakeName);
45
+ return { [name]: notNullWhenRequired(withDeclaredDefault(base, field.default), field.required) };
46
+ }
47
+ function referenceColumns(name, snakeName, field) {
48
+ if (field.multiple === true)
49
+ return { [name]: emptyJsonbArray(snakeName) };
50
+ return { [name]: notNullWhenRequired(uuid(snakeName), field.required) };
51
+ }
52
+ // BIGINT minor units: INTEGER would cap at about 21 M EUR. The currency column
53
+ // always has a default, so it is structurally NOT NULL.
54
+ function moneyColumns(name, snakeName, field, entity) {
55
+ return {
56
+ [name]: notNullWhenRequired(moneyAmount(snakeName), field.required),
57
+ [`${name}Currency`]: text(`${snakeName}_currency`)
58
+ .default(entity.defaultCurrency ?? "EUR")
59
+ .notNull(),
60
+ };
61
+ }
62
+ // <name>_utc + <name>_tz form an atomic pair the executor converts on write and
63
+ // read, so `required` propagates to both columns.
64
+ function locatedTimestampColumns(name, snakeName, field) {
65
+ return {
66
+ [`${name}Utc`]: notNullWhenRequired(instant(`${snakeName}_utc`), field.required),
67
+ [`${name}Tz`]: notNullWhenRequired(text(`${snakeName}_tz`), field.required),
68
+ };
69
+ }
70
+ // The `[]` default makes the column structurally never null, so `required` is
71
+ // intentionally ignored and read-side code needs no null check.
72
+ function emptyJsonbArray(snakeName) {
73
+ return jsonb(snakeName).default([]).notNull();
74
+ }
75
+ // Same reasoning as emptyJsonbArray; optional embedded data needs a wrapper field.
76
+ function emptyJsonbObject(snakeName) {
77
+ return jsonb(snakeName).default({}).notNull();
78
+ }
79
+ function singleColumnNotNullWhenRequired(column) {
80
+ return (name, snakeName, field) => ({
81
+ [name]: notNullWhenRequired(column(snakeName), field.required),
82
+ });
83
+ }
84
+ // Each entry has a type-level twin in ColumnsForField and a meta-level twin in
85
+ // fieldToColumnMeta (entity-table-meta.ts).
86
+ const COLUMN_EMITTERS = {
87
+ text: textColumns,
88
+ longText: textColumns,
89
+ boolean: booleanColumns,
90
+ select: (name, snakeName, field) => ({
91
+ [name]: notNullWhenRequired(withDeclaredDefault(text(snakeName), field.default), field.required),
92
+ }),
93
+ multiSelect: (name, snakeName) => ({ [name]: emptyJsonbArray(snakeName) }),
94
+ number: numberColumns,
95
+ decimal: (name, snakeName, field) => ({
96
+ [name]: notNullWhenRequired(withDeclaredDefault(decimalColumn(snakeName, field.precision, field.scale), field.default), field.required),
97
+ }),
98
+ // mode "number" keeps the value JSON-serializable (safe up to 2^53).
99
+ bigInt: (name, snakeName, field) => ({
100
+ [name]: notNullWhenRequired(withDeclaredDefault(bigint(snakeName, { mode: "number" }), field.default), field.required),
101
+ }),
102
+ reference: referenceColumns,
103
+ money: moneyColumns,
104
+ embedded: (name, snakeName, field) => ({
105
+ [name]: field.multiple === true ? emptyJsonbArray(snakeName) : emptyJsonbObject(snakeName),
106
+ }),
107
+ jsonb: (name, snakeName) => ({ [name]: emptyJsonbObject(snakeName) }),
108
+ date: singleColumnNotNullWhenRequired(plainDate),
109
+ timestamp: singleColumnNotNullWhenRequired(instant),
110
+ tz: singleColumnNotNullWhenRequired(text),
111
+ locatedTimestamp: locatedTimestampColumns,
112
+ // Must match fileRefsTable.id (uuid); anything narrower breaks the reference.
113
+ file: singleColumnNotNullWhenRequired(uuid),
114
+ image: singleColumnNotNullWhenRequired(uuid),
115
+ files: (name, snakeName) => ({ [name]: emptyJsonbArray(snakeName) }),
116
+ images: (name, snakeName) => ({ [name]: emptyJsonbArray(snakeName) }),
117
+ };
118
+ function emitFieldColumns(type, name, field, entity) {
119
+ // A type from untyped input must not reach an Object.prototype member.
120
+ if (!Object.hasOwn(COLUMN_EMITTERS, type)) {
121
+ throw new Error(`[Kumiko] unhandled field type: ${String(field)}`);
122
+ }
123
+ const emit = COLUMN_EMITTERS[type];
124
+ return emit(name, toSnakeCase(name), field, entity);
125
+ }
126
+ // Most fields return one column; money and locatedTimestamp return two.
127
+ function fieldToColumns(name, field, entity) {
128
+ return emitFieldColumns(field.type, name, field, entity);
182
129
  }
183
130
  // Accepts both camelCase (`tenantMembership`) and kebab-case (`tenant-membership`)
184
131
  // entity / field names. Implementation lives in utils/case — re-exported here
@@ -0,0 +1,10 @@
1
+ import type { TableLike } from "./query.ts";
2
+ export type TableInfo = {
3
+ readonly name: string;
4
+ readonly columnOf: (field: string) => string;
5
+ readonly pgTypeOf: (column: string) => string | undefined;
6
+ readonly bigintJsModeOf: (column: string) => "number" | "bigint" | undefined;
7
+ readonly fieldOf: (column: string) => string;
8
+ readonly hasColumn: (fieldOrColumn: string) => boolean;
9
+ };
10
+ export declare function extractTableInfo(table: TableLike): TableInfo;
@@ -0,0 +1,45 @@
1
+ import { camelCase as envCamelCase } from "../env/index.js";
2
+ import { toSnakeCase } from "./table-builder.js";
3
+ import { asEntityTableMeta } from "./table-introspection.js";
4
+ // Idempotent snake_case → camelCase. `env.camelCase` always lowercases first
5
+ // (designed for SHOUT_CASE input) — for already-camelCase keys (mock rows
6
+ // in tests, projection-aliased columns) it would silently produce "tenantid"
7
+ // instead of leaving "tenantId" alone. Guard with an underscore check so
8
+ // the conversion only fires when the key is actually snake-shaped.
9
+ function snakeToCamel(key) {
10
+ if (!key.includes("_"))
11
+ return key;
12
+ return envCamelCase(key);
13
+ }
14
+ export function extractTableInfo(table) {
15
+ const meta = asEntityTableMeta(table);
16
+ if (!meta) {
17
+ throw new Error("extractTableInfo: table is not a kumiko EntityTableMeta — " +
18
+ "build it via buildEntityTable / deriveEntityTableMeta / table().");
19
+ }
20
+ const colByField = new Map();
21
+ const fieldByCol = new Map();
22
+ const typeByCol = new Map();
23
+ const bigintModeByCol = new Map();
24
+ for (const c of meta.columns) {
25
+ typeByCol.set(c.name, c.pgType);
26
+ if (c.bigintJsMode !== undefined)
27
+ bigintModeByCol.set(c.name, c.bigintJsMode);
28
+ // EntityTableMeta column names are snake_case. Map snake → snake AND
29
+ // derive a camelCase JS field-name so result rows can be renamed back
30
+ // to the API shape (`aggregate_id` → `aggregateId`).
31
+ colByField.set(c.name, c.name);
32
+ const camel = snakeToCamel(c.name);
33
+ if (camel !== c.name)
34
+ colByField.set(camel, c.name);
35
+ fieldByCol.set(c.name, camel === c.name ? c.name : camel);
36
+ }
37
+ return {
38
+ name: meta.tableName,
39
+ columnOf: (field) => colByField.get(field) ?? toSnakeCase(field),
40
+ pgTypeOf: (col) => typeByCol.get(col),
41
+ bigintJsModeOf: (col) => bigintModeByCol.get(col),
42
+ fieldOf: (col) => fieldByCol.get(col) ?? snakeToCamel(col),
43
+ hasColumn: (fieldOrColumn) => colByField.has(fieldOrColumn) || fieldByCol.has(fieldOrColumn),
44
+ };
45
+ }
@@ -0,0 +1,6 @@
1
+ import type { Temporal } from "@cosmicdrift/kumiko-types/temporal";
2
+ export declare function isTemporalInstant(v: unknown): v is Temporal.Instant;
3
+ export declare function isTemporalPlainDate(v: unknown): v is Temporal.PlainDate;
4
+ export declare function isTemporalPlainDateTimeLike(v: unknown): v is {
5
+ toPlainDate(): Temporal.PlainDate;
6
+ };
@@ -0,0 +1,15 @@
1
+ import { propertyOf } from "@cosmicdrift/kumiko-types/type-guards";
2
+ export function isTemporalInstant(v) {
3
+ return propertyOf(v, Symbol.toStringTag) === "Temporal.Instant";
4
+ }
5
+ export function isTemporalPlainDate(v) {
6
+ return propertyOf(v, Symbol.toStringTag) === "Temporal.PlainDate";
7
+ }
8
+ export function isTemporalPlainDateTimeLike(v) {
9
+ const tag = propertyOf(v, Symbol.toStringTag);
10
+ return ((tag === "Temporal.PlainDateTime" || tag === "Temporal.ZonedDateTime") &&
11
+ typeof v === "object" &&
12
+ v !== null &&
13
+ "toPlainDate" in v &&
14
+ typeof v.toPlainDate === "function");
15
+ }
@@ -6,5 +6,6 @@ export declare function isDbRunner(value: unknown): value is DbRunner;
6
6
  export declare function bindTenantDbRunner(tenantDb: TenantDb, runner: DbRunner): void;
7
7
  export declare function tenantDbRunner(tenantDb: TenantDb): DbRunner;
8
8
  export declare function denyTenantDbWrites(tenantDb: TenantDb, reason: string): void;
9
+ export declare function tenantDbWritesDeniedReason(tenantDb: TenantDb): string | undefined;
9
10
  export declare function tenantDbWritesDeniedError(operation: string, reason: string): InternalError;
10
11
  export declare function tenantDbWriteRunner(tenantDb: TenantDb, operation: string): DbRunner;
@@ -26,6 +26,9 @@ const writesDeniedReasons = new WeakMap();
26
26
  export function denyTenantDbWrites(tenantDb, reason) {
27
27
  writesDeniedReasons.set(tenantDb, reason);
28
28
  }
29
+ export function tenantDbWritesDeniedReason(tenantDb) {
30
+ return writesDeniedReasons.get(tenantDb);
31
+ }
29
32
  export function tenantDbWritesDeniedError(operation, reason) {
30
33
  return new InternalError({
31
34
  message: `${operation}: writes are not available ${reason}; run the write through ctx.write so it gets its own transaction`,
@@ -10,7 +10,7 @@ import { bindProjectionRegistry, projectionRegistryOf } from "./projection-regis
10
10
  import { acquireNamespacedAdvisoryLock } from "./queries/advisory-lock.js";
11
11
  import { queryDatabaseNow } from "./queries/database-now.js";
12
12
  import { hasFieldProperty, tableNameOf } from "./table-introspection.js";
13
- import { bindTenantDbRunner, denyTenantDbWrites, tenantDbRunner, tenantDbWritesDeniedError, } from "./tenant-db-runner.js";
13
+ import { bindTenantDbRunner, denyTenantDbWrites, tenantDbRunner, tenantDbWritesDeniedError, tenantDbWritesDeniedReason, } from "./tenant-db-runner.js";
14
14
  export { SYSTEM_SCOPE_CHECK_BRAND, } from "@cosmicdrift/kumiko-types/tenant-db-types";
15
15
  const declaredUnsafeRawRunners = new WeakMap();
16
16
  // The CRUD executor writes through tenantDbRunner, not insertOne, so it asks the
@@ -105,9 +105,7 @@ export function assertPersonalDataWrite(db, tableName, keys, entity) {
105
105
  }
106
106
  // Framework-private (not re-exported from db/index.ts): same grant check + audit as unsafeRaw, for engine forwarding.
107
107
  export function unsafeRawForDeclaredStep(holder, reason) {
108
- if (reason.trim().length === 0) {
109
- throw new Error("unsafeRawForDeclaredStep requires a non-empty reason");
110
- }
108
+ assertReason("unsafeRawForDeclaredStep", reason);
111
109
  const runner = declaredUnsafeRawRunners.get(holder);
112
110
  if (!runner) {
113
111
  throw new InternalError({
@@ -257,7 +255,13 @@ export function createSystemDbView(db, dbOutsideTransaction, report = fallbackEs
257
255
  // same connection/transaction. For code that must run caller-supplied callbacks (e.g. config
258
256
  // validators) without handing them a cross-tenant view.
259
257
  export function scopeTenantDbToTenant(db, tenantId) {
260
- return createTenantDb({ db: tenantDbRunner(db), tenantId, mode: "tenant" });
258
+ const writesDeniedReason = tenantDbWritesDeniedReason(db);
259
+ return createTenantDb({
260
+ db: tenantDbRunner(db),
261
+ tenantId,
262
+ mode: "tenant",
263
+ ...(writesDeniedReason !== undefined && { grants: { writesDeniedReason } }),
264
+ });
261
265
  }
262
266
  // @cast-boundary tenant-db-row
263
267
  export function castTenantRows(rows) {
@@ -268,6 +272,18 @@ export { tableNameOf };
268
272
  function hasGrant(decl) {
269
273
  return decl !== undefined && decl.reason.trim().length > 0;
270
274
  }
275
+ // Tenant ids left over when the caller excludes some via ne/notIn; undefined for other operator forms.
276
+ function tenantIdsAfterExclusion(requested, allowed) {
277
+ if (!isPlainObject(requested))
278
+ return undefined;
279
+ if ("ne" in requested)
280
+ return allowed.filter((t) => t !== requested["ne"]);
281
+ if (Array.isArray(requested["notIn"])) {
282
+ const excluded = requested["notIn"];
283
+ return allowed.filter((t) => !excluded.includes(t));
284
+ }
285
+ return undefined;
286
+ }
271
287
  // Tenant ids a where.tenantId filter asks for; undefined for operator forms
272
288
  // that cannot narrow to a tenant list (ne, like, ...).
273
289
  export function requestedTenantIds(requested) {
@@ -429,7 +445,13 @@ function buildTenantDb(options) {
429
445
  const allowed = [tenantId, SYSTEM_TENANT_ID];
430
446
  const requested = where?.["tenantId"];
431
447
  if (requested !== undefined) {
432
- const requestedList = requestedTenantIds(requested) ?? [];
448
+ const requestedList = requestedTenantIds(requested);
449
+ // An explicit exclusion or empty list must keep excluding, never widen to the full scope.
450
+ if (requestedList === undefined) {
451
+ return { ...where, tenantId: tenantIdsAfterExclusion(requested, allowed) ?? allowed };
452
+ }
453
+ if (requestedList.length === 0)
454
+ return { ...where, tenantId: [] };
433
455
  const narrowed = requestedList.filter((t) => typeof t === "string" && allowed.includes(t));
434
456
  return { ...where, tenantId: narrowed.length > 0 ? narrowed : allowed };
435
457
  }
@@ -464,6 +486,14 @@ function buildTenantDb(options) {
464
486
  function globalWriteReason() {
465
487
  return grants?.globalWrites?.reason ?? "";
466
488
  }
489
+ // The table is the audit target: process-level dedup would otherwise collapse a cron's
490
+ // writes to several global tables into one audit entry.
491
+ function reportGlobalWrite(table) {
492
+ report("global-write", globalWriteReason(), {
493
+ id: tableNameOf(table),
494
+ tenantId: tenantId ?? SYSTEM_TENANT_ID,
495
+ });
496
+ }
467
497
  function personalDataDenied(table, keys) {
468
498
  if (!personalDataGate)
469
499
  return undefined;
@@ -535,7 +565,7 @@ function buildTenantDb(options) {
535
565
  personalDataDenied(table, Object.keys(values));
536
566
  if (denied)
537
567
  return Promise.reject(denied);
538
- report("global-write", globalWriteReason());
568
+ reportGlobalWrite(table);
539
569
  return withDbSpan("insert", table, async () => insertRow(table, values));
540
570
  },
541
571
  updateMany(set, where) {
@@ -549,7 +579,7 @@ function buildTenantDb(options) {
549
579
  if (!where || Object.keys(where).length === 0) {
550
580
  return Promise.reject(new Error("db.global().updateMany without where would mass-update every tenant's rows. Pass at least one where condition."));
551
581
  }
552
- report("global-write", globalWriteReason());
582
+ reportGlobalWrite(table);
553
583
  return withDbSpan("update", table, async () => updateRows(table, set, where));
554
584
  },
555
585
  deleteMany(where) {
@@ -561,7 +591,7 @@ function buildTenantDb(options) {
561
591
  if (!where || Object.keys(where).length === 0) {
562
592
  return Promise.reject(new Error("db.global().deleteMany without where would mass-delete every tenant's rows. Pass at least one where condition."));
563
593
  }
564
- report("global-write", globalWriteReason());
594
+ reportGlobalWrite(table);
565
595
  return withDbSpan("delete", table, async () => {
566
596
  await deleteRows(table, where);
567
597
  });
@@ -0,0 +1,6 @@
1
+ import type { WhereObject } from "@cosmicdrift/kumiko-types/where-clause-types";
2
+ import type { TableInfo } from "./table-info.ts";
3
+ export declare function buildWhereClause(info: TableInfo, where: WhereObject, startIndex: number, qualifier?: string): {
4
+ sqlText: string;
5
+ values: unknown[];
6
+ };