@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
@@ -0,0 +1,236 @@
1
+ import { propertyOf } from "@cosmicdrift/kumiko-types/type-guards";
2
+ import { computeBlindIndex, configuredBlindIndexKey } from "../crypto/blind-index.js";
3
+ import { quoteIdent } from "../crypto/ciphertext-pattern.js";
4
+ import { prepareJsonbValue, prepareValue } from "./driver-value-preparation.js";
5
+ function isJsonTextMatchList(value) {
6
+ return Array.isArray(value);
7
+ }
8
+ function isWhereOperator(v) {
9
+ if (v === null || typeof v !== "object" || Array.isArray(v))
10
+ return false;
11
+ // Don't false-positive on Date / Temporal / other domain-objects.
12
+ // WhereOperator is plain object literal with at most these keys.
13
+ const keys = Object.keys(v);
14
+ if (keys.length === 0)
15
+ return false;
16
+ return keys.every((key) => Object.hasOwn(OPERATOR_EMITTERS, key));
17
+ }
18
+ function isJsonbScalar(value) {
19
+ return value !== null && typeof value !== "object";
20
+ }
21
+ function valueList(target, values) {
22
+ return values
23
+ .map((v) => {
24
+ const p = prepareValue(v, target.pgType);
25
+ return p.kind === "literal" ? p.literal : `${target.bind(p.bound)}${p.sql}`;
26
+ })
27
+ .join(", ");
28
+ }
29
+ // multiSelect (and any other jsonb-array-of-scalars column) stores an
30
+ // array — a filter value is one option, not the whole array, so eq/ne/in
31
+ // must check array containment (`@>`) instead of scalar `=`/`<>`/`IN`
32
+ // against the jsonb column, which Postgres rejects outright.
33
+ // Mirrors applyMultiSelectFilter in event-store-executor-read-filter.ts — keep in lock-step.
34
+ function jsonbContainsAny(target, candidates) {
35
+ const { column, ref, bind } = target;
36
+ if (candidates.length === 0)
37
+ return "FALSE";
38
+ const parts = [];
39
+ for (const v of candidates) {
40
+ const p = prepareJsonbValue([v]);
41
+ // prepareJsonbValue never returns kind:"literal" (only prepareValue's
42
+ // isSqlExpression branch does) — narrow anyway, PreparedValue is a union.
43
+ if (p && p.kind === "param") {
44
+ parts.push(`${ref(column)} @> ${bind(p.bound)}${p.sql}`);
45
+ }
46
+ // A jsonb column holding the scalar itself is never `@>` an array of it.
47
+ if (isJsonbScalar(v)) {
48
+ parts.push(`${ref(column)} = ${bind(JSON.stringify(v))}::text::jsonb`);
49
+ }
50
+ }
51
+ return parts.length > 0 ? `(${parts.join(" OR ")})` : "FALSE";
52
+ }
53
+ function isValidJsonTextMatch(match) {
54
+ if (match === null || typeof match !== "object")
55
+ return false;
56
+ const keys = propertyOf(match, "keys");
57
+ return (Array.isArray(keys) &&
58
+ keys.length > 0 &&
59
+ keys.every((k) => typeof k === "string") &&
60
+ typeof propertyOf(match, "eq") === "string");
61
+ }
62
+ function jsonTextMatchCondition(target, match) {
63
+ const { column, ref, bind } = target;
64
+ if (!isValidJsonTextMatch(match)) {
65
+ throw new Error(`where: jsonText on column "${column}" needs non-empty string keys and a string eq`);
66
+ }
67
+ const lookups = match.keys.map((key) => `${ref(column)}->>${bind(key)}::text`);
68
+ const lhs = lookups.length === 1 ? lookups[0] : `COALESCE(${lookups.join(", ")})`;
69
+ return `(${lhs} = ${bind(match.eq)})`;
70
+ }
71
+ function emitJsonText(target, operator) {
72
+ const { column, pgType } = target;
73
+ const operand = operator.jsonText;
74
+ if (operand === undefined)
75
+ return undefined;
76
+ if (Object.keys(operator).length > 1) {
77
+ throw new Error(`where: jsonText on column "${column}" cannot be combined with other operators`);
78
+ }
79
+ if (pgType !== "jsonb") {
80
+ throw new Error(`where: jsonText requires a jsonb column, "${column}" is ${pgType}`);
81
+ }
82
+ const matches = isJsonTextMatchList(operand) ? operand : [operand];
83
+ if (matches.length === 0)
84
+ return "FALSE";
85
+ return matches.map((match) => jsonTextMatchCondition(target, match)).join(" AND ");
86
+ }
87
+ function comparisonCondition(target, symbol, operand) {
88
+ const { column, pgType, ref, bind } = target;
89
+ const p = prepareValue(operand, pgType);
90
+ return p.kind === "literal"
91
+ ? `${ref(column)} ${symbol} ${p.literal}`
92
+ : `${ref(column)} ${symbol} ${bind(p.bound)}${p.sql}`;
93
+ }
94
+ function comparisonEmitter(key, symbol) {
95
+ return (target, operator) => {
96
+ const operand = operator[key];
97
+ if (operand === undefined)
98
+ return undefined;
99
+ // Mirrors applyMultiSelectFilter: ordering/pattern ops have no meaning on a jsonb
100
+ // option array (`like` would crash as `jsonb ~~ jsonb`), so match nothing.
101
+ if (target.pgType === "jsonb")
102
+ return "FALSE";
103
+ return comparisonCondition(target, symbol, operand);
104
+ };
105
+ }
106
+ // Mirrors applyMultiSelectFilter: an array `ne` means "does not contain all of these".
107
+ function jsonbNotEqualCondition(target, operand) {
108
+ const { column, ref, bind } = target;
109
+ if (Array.isArray(operand)) {
110
+ if (operand.length === 0)
111
+ return "FALSE";
112
+ const p = prepareJsonbValue(operand);
113
+ if (p && p.kind === "param") {
114
+ return `(${ref(column)} IS NULL OR NOT (${ref(column)} @> ${bind(p.bound)}${p.sql}))`;
115
+ }
116
+ }
117
+ if (isJsonbScalar(operand)) {
118
+ const p = prepareJsonbValue([operand]);
119
+ if (p && p.kind === "param") {
120
+ return `(${ref(column)} IS NULL OR NOT (${ref(column)} @> ${bind(p.bound)}${p.sql} OR ${ref(column)} = ${bind(JSON.stringify(operand))}::text::jsonb))`;
121
+ }
122
+ }
123
+ return undefined;
124
+ }
125
+ function emitNotEqual(target, operator) {
126
+ const operand = operator.ne;
127
+ if (operand === undefined)
128
+ return undefined;
129
+ if (operand === null && Object.keys(operator).length === 1) {
130
+ return `${target.ref(target.column)} IS NOT NULL`;
131
+ }
132
+ if (target.pgType === "jsonb") {
133
+ const jsonbCondition = jsonbNotEqualCondition(target, operand);
134
+ if (jsonbCondition !== undefined)
135
+ return jsonbCondition;
136
+ }
137
+ return comparisonCondition(target, "<>", operand);
138
+ }
139
+ function emitIn(target, operator) {
140
+ const operand = operator.in;
141
+ if (!Array.isArray(operand))
142
+ return undefined;
143
+ if (target.pgType === "jsonb")
144
+ return jsonbContainsAny(target, operand);
145
+ if (operand.length === 0)
146
+ return "FALSE";
147
+ return `${target.ref(target.column)} IN (${valueList(target, operand)})`;
148
+ }
149
+ function emitNotIn(target, operator) {
150
+ const operand = operator.notIn;
151
+ if (!Array.isArray(operand))
152
+ return undefined;
153
+ if (target.pgType === "jsonb") {
154
+ throw new Error(`where: notIn is not supported on jsonb column "${target.column}"`);
155
+ }
156
+ if (operand.length === 0)
157
+ return "TRUE";
158
+ return `${target.ref(target.column)} NOT IN (${valueList(target, operand)})`;
159
+ }
160
+ // Insertion order is the SQL emission order. jsonText comes first so its "cannot be combined" error wins over any other
161
+ // operator's error; the rest keep the historical order gt, gte, lt, lte, ne, like, in, notIn.
162
+ const OPERATOR_EMITTERS = {
163
+ jsonText: emitJsonText,
164
+ gt: comparisonEmitter("gt", ">"),
165
+ gte: comparisonEmitter("gte", ">="),
166
+ lt: comparisonEmitter("lt", "<"),
167
+ lte: comparisonEmitter("lte", "<="),
168
+ ne: emitNotEqual,
169
+ like: comparisonEmitter("like", "LIKE"),
170
+ in: emitIn,
171
+ notIn: emitNotIn,
172
+ };
173
+ function operatorConditions(target, operator) {
174
+ const conditions = [];
175
+ for (const emit of Object.values(OPERATOR_EMITTERS)) {
176
+ const condition = emit(target, operator);
177
+ if (condition !== undefined)
178
+ conditions.push(condition);
179
+ }
180
+ return conditions;
181
+ }
182
+ // Blind-index OR rewrite: if the column has a bidx
183
+ // counterpart (suffix convention `<col>_bidx`, framework-reserved)
184
+ // and a key is configured, equality matches both arms —
185
+ // plaintext legacy rows via the plaintext arm, encrypted rows
186
+ // via the HMAC. The `kumiko-pii:` prefix on ciphertexts makes
187
+ // a random match on the plaintext arm practically impossible.
188
+ function equalityCondition(target, value) {
189
+ const { info, column, pgType, ref, bind } = target;
190
+ const p = prepareValue(value, pgType);
191
+ if (p.kind === "literal")
192
+ return `${ref(column)} = ${p.literal}`;
193
+ const bidxKey = configuredBlindIndexKey();
194
+ const bidxCol = `${column}_bidx`;
195
+ if (bidxKey !== undefined &&
196
+ typeof value === "string" &&
197
+ !column.endsWith("_bidx") &&
198
+ info.hasColumn(bidxCol)) {
199
+ return `(${ref(column)} = ${bind(p.bound)} OR ${ref(bidxCol)} = ${bind(computeBlindIndex(bidxKey, value))})`;
200
+ }
201
+ return `${ref(column)} = ${bind(p.bound)}${p.sql}`;
202
+ }
203
+ function arrayCondition(target, values) {
204
+ if (values.length === 0)
205
+ return "FALSE";
206
+ if (target.pgType === "jsonb")
207
+ return jsonbContainsAny(target, values);
208
+ return `${target.ref(target.column)} IN (${valueList(target, values)})`;
209
+ }
210
+ function conditionsForValue(target, value) {
211
+ if (value === null)
212
+ return [`${target.ref(target.column)} IS NULL`];
213
+ if (Array.isArray(value))
214
+ return [arrayCondition(target, value)];
215
+ if (isWhereOperator(value))
216
+ return operatorConditions(target, value);
217
+ if (target.pgType === "jsonb" && isJsonbScalar(value)) {
218
+ return [jsonbContainsAny(target, [value])];
219
+ }
220
+ return [equalityCondition(target, value)];
221
+ }
222
+ export function buildWhereClause(info, where, startIndex, qualifier) {
223
+ const values = [];
224
+ // Single place that renders a column reference, so a join qualifier reaches
225
+ // every arm (blind index, jsonb containment, IS NULL, jsonText) alike.
226
+ const ref = (column) => qualifier === undefined ? quoteIdent(column) : `${quoteIdent(qualifier)}.${quoteIdent(column)}`;
227
+ const bind = (value) => {
228
+ values.push(value);
229
+ return `$${startIndex + values.length - 1}`;
230
+ };
231
+ const conditions = Object.entries(where).flatMap(([field, value]) => {
232
+ const column = info.columnOf(field);
233
+ return conditionsForValue({ info, column, pgType: info.pgTypeOf(column), ref, bind }, value);
234
+ });
235
+ return { sqlText: conditions.join(" AND "), values };
236
+ }
@@ -56,6 +56,7 @@ export async function deleteStoredFileAndDerivatives(originalKey, store, errorCo
56
56
  ...(await listDerivativeKeys(originalKey, store, errorContext)),
57
57
  ];
58
58
  const failedKeys = [];
59
+ const log = createFallbackLogger(errorContext);
59
60
  for (const key of keysToDelete) {
60
61
  try {
61
62
  assertSafeStorageKey(key);
@@ -63,7 +64,7 @@ export async function deleteStoredFileAndDerivatives(originalKey, store, errorCo
63
64
  }
64
65
  catch (err) {
65
66
  failedKeys.push(key);
66
- createFallbackLogger(errorContext).warn("storage delete failed", {
67
+ log.warn("storage delete failed", {
67
68
  key,
68
69
  error: err instanceof Error ? err.message : String(err),
69
70
  });
@@ -13,7 +13,7 @@ export function validateFeatureBootChecks(features) {
13
13
  }
14
14
  catch (err) {
15
15
  const message = err instanceof Error ? err.message : String(err);
16
- throw new Error(`[Feature ${feature.name}] r.bootCheck failed: ${message}. Fix the condition this check reports.`);
16
+ throw new Error(`[Feature ${feature.name}] r.bootCheck failed: ${message}`);
17
17
  }
18
18
  }
19
19
  }
@@ -1,8 +1,8 @@
1
1
  import { qualifyEntityName } from "../qualified-name.js";
2
- import { findEntityFeature } from "./screens.js";
2
+ import { findEntityFeature } from "./screens-kind-shared.js";
3
3
  // Entity name → the one screen that declares detailFor: "<entity>". Built
4
4
  // once, up front (before the per-feature validateScreens loop) so both this
5
- // module's own uniqueness check AND rowAction entity-targets (in screens.ts)
5
+ // module's own uniqueness check AND rowAction entity-targets (in screens-actions.ts)
6
6
  // resolve against the same map instead of re-walking every feature's
7
7
  // screens twice.
8
8
  export function collectDetailForScreens(features, featureMap) {
@@ -9,6 +9,7 @@ export declare function validateHandlerAccess(feature: FeatureDefinition): void;
9
9
  export declare function validateAdditionalRateLimits(featureName: string, handlerName: string, handler: FeatureDefinition["writeHandlers"][string]): void;
10
10
  export declare function validatePerCommandTransaction(featureName: string, handlerName: string, handler: FeatureDefinition["writeHandlers"][string]): void;
11
11
  export declare function validateTenantlessAnonymous(featureName: string, handlerName: string, handler: FeatureDefinition["writeHandlers"][string]): void;
12
+ export declare function validateRateLimitCost(featureName: string, handlerName: string, handler: FeatureDefinition["writeHandlers"][string]): void;
12
13
  export declare function validateRateLimitDisabledReason(featureName: string, kind: "write" | "query" | "stream", handlerName: string, rateLimit: FeatureDefinition["writeHandlers"][string]["rateLimit"]): void;
13
14
  export declare function validateRateLimitBucketList(featureName: string, kind: "write" | "query" | "stream", handlerName: string, rateLimit: FeatureDefinition["writeHandlers"][string]["rateLimit"]): void;
14
15
  export declare function validateNoAllRoleInHandlerAccess(featureName: string, kind: "write" | "query" | "stream", handlerName: string, access: NonNullable<FeatureDefinition["writeHandlers"][string]["access"]>): void;
@@ -139,6 +139,7 @@ export function validateHandlerAccess(feature) {
139
139
  for (const [name, handler] of Object.entries(feature.writeHandlers)) {
140
140
  validateTenantlessAnonymous(feature.name, name, handler);
141
141
  validateAdditionalRateLimits(feature.name, name, handler);
142
+ validateRateLimitCost(feature.name, name, handler);
142
143
  validatePerCommandTransaction(feature.name, name, handler);
143
144
  }
144
145
  }
@@ -235,6 +236,22 @@ export function validateTenantlessAnonymous(featureName, handlerName, handler) {
235
236
  `tenant/user bucket, so one caller could exhaust it for all. Use ${IP_SCOPED_RATE_LIMIT_PER.map((allowed) => `"${allowed}"`).join(" or ")}.`);
236
237
  }
237
238
  }
239
+ export function validateRateLimitCost(featureName, handlerName, handler) {
240
+ // skip: no payload-derived cost declared
241
+ if (!handler.rateLimitCost)
242
+ return;
243
+ if (handler.rateLimit === undefined || isRateLimitDisabled(handler.rateLimit)) {
244
+ throw new Error(`write handler "${featureName}:write:${handlerName}" declares rateLimitCost without an active rateLimit — ` +
245
+ `the cost is booked on the rateLimit buckets, so declare rateLimit or drop rateLimitCost.`);
246
+ }
247
+ const isAnonymous = "roles" in handler.access && handler.access.roles.includes("anonymous");
248
+ const hasIpBucket = declaredRateLimitOptions(handler.rateLimit).some(({ per }) => IP_SCOPED_RATE_LIMIT_PER.includes(per));
249
+ if (isAnonymous || hasIpBucket) {
250
+ throw new Error(`write handler "${featureName}:write:${handlerName}" declares rateLimitCost on an anonymous or ip-limited handler — ` +
251
+ `rateLimitCost books after the parse, so such a handler would lose its pre-parse protection against junk floods. ` +
252
+ `Bulk handlers are authenticated: use a user/tenant bucket and drop "anonymous", or drop rateLimitCost.`);
253
+ }
254
+ }
238
255
  export function validateRateLimitDisabledReason(featureName, kind, handlerName, rateLimit) {
239
256
  // skip: rate limit isn't disabled, so no reason is required
240
257
  if (!isRateLimitDisabled(rateLimit))
@@ -3,15 +3,46 @@ import { parseQn } from "../qualified-name.js";
3
3
  import { formDerivationsOfSchema } from "../with-form-derivations.js";
4
4
  const NUMERIC_FORM_FIELD_TYPES = ["money", "number", "decimal"];
5
5
  const NUMERIC_SUB_FIELD_TYPES = ["money", "number", "decimal"];
6
+ function isMoneyType(type) {
7
+ return type === "money";
8
+ }
9
+ // Money operands are computed in minor units, number/decimal in major units: mixing them
10
+ // silently scales the result by 100 or 10000.
11
+ function validateUnitCompatibility({ prefix, screen, target }, derivation) {
12
+ const targetIsMoney = isMoneyType(screen.fields[target]?.type);
13
+ const moneyFlags = derivation.from.map((source) => isMoneyType(screen.fields[source]?.type));
14
+ const moneyCount = moneyFlags.filter(Boolean).length;
15
+ if (derivation.op === "sum" || derivation.op === "subtract") {
16
+ if (moneyCount !== 0 && moneyCount !== moneyFlags.length) {
17
+ throw new Error(`${prefix}.${target}: "${derivation.op}" sources must be all money or all number/decimal.`);
18
+ }
19
+ if (moneyCount > 0 !== targetIsMoney) {
20
+ throw new Error(`${prefix}.${target}: a "${derivation.op}" over ${moneyCount > 0 ? "money" : "number/decimal"} sources needs a ${moneyCount > 0 ? "money" : "number/decimal"} target.`);
21
+ }
22
+ // skip: sum/subtract fully validated above.
23
+ return;
24
+ }
25
+ if (moneyCount > 1 || (derivation.op === "percent" && moneyFlags[1] === true)) {
26
+ throw new Error(`${prefix}.${target}: "${derivation.op}" takes at most one money source${derivation.op === "percent" ? " (the base, not the rate)" : ""}.`);
27
+ }
28
+ if ((moneyCount === 1) !== targetIsMoney) {
29
+ throw new Error(`${prefix}.${target}: the target must be money exactly when a "${derivation.op}" source is money.`);
30
+ }
31
+ }
6
32
  function validateFromList({ prefix, screen, target }, derivation) {
7
33
  if (derivation.op === "percent" && derivation.from.length !== 2) {
8
34
  throw new Error(`${prefix}.${target}: "percent" needs exactly two sources (base, rate).`);
9
35
  }
10
36
  for (const source of derivation.from) {
11
- if (!(source in screen.fields)) {
37
+ const sourceField = screen.fields[source];
38
+ if (sourceField === undefined) {
12
39
  throw new Error(`${prefix}.${target}: unknown source field "${source}". Name a field of this screen.`);
13
40
  }
41
+ if (!NUMERIC_FORM_FIELD_TYPES.includes(sourceField.type)) {
42
+ throw new Error(`${prefix}.${target}: source "${source}" must be money, number or decimal, not "${sourceField.type}".`);
43
+ }
14
44
  }
45
+ validateUnitCompatibility({ prefix, screen, target }, derivation);
15
46
  }
16
47
  function validateListSum(check, derivation) {
17
48
  const { prefix, screen, target } = check;
@@ -26,6 +57,9 @@ function validateListSum(check, derivation) {
26
57
  if (cellType === undefined || !NUMERIC_SUB_FIELD_TYPES.includes(cellType)) {
27
58
  throw new Error(`${prefix}.${target}: "${derivation.cell}" must be a money, number or decimal sub-field of "${derivation.list}".`);
28
59
  }
60
+ if (isMoneyType(cellType) !== isMoneyType(screen.fields[target]?.type)) {
61
+ throw new Error(`${prefix}.${target}: a listSum of a ${isMoneyType(cellType) ? "money" : "number/decimal"} cell needs a ${isMoneyType(cellType) ? "money" : "number/decimal"} target.`);
62
+ }
29
63
  const { whenEmpty } = derivation;
30
64
  // skip: whenEmpty is optional
31
65
  if (whenEmpty === undefined)
@@ -94,9 +94,11 @@ export function validateTranslationLocaleConsistency(features) {
94
94
  }
95
95
  }
96
96
  export function validateRequiredLocales(features, requiredLocales) {
97
- const gaps = findRequiredLocaleGaps(features, requiredLocales ?? []);
98
- if (gaps.length > 0) {
97
+ const gaps = requiredLocales === undefined || requiredLocales.length === 0
98
+ ? []
99
+ : findRequiredLocaleGaps(features, requiredLocales);
100
+ if (requiredLocales !== undefined && gaps.length > 0) {
99
101
  const lines = gaps.map((gap) => `[i18n] Feature "${gap.featureName}": translation key "${gap.key}" is missing required locale(s) ${gap.missingLocales.join(", ")}.`);
100
- throw new Error(`${lines.join("\n")}\nThe app requires locales [${(requiredLocales ?? []).join(", ")}] for every translation key; load the matching locale package or add the missing translations.`);
102
+ throw new Error(`${lines.join("\n")}\nThe app requires locales [${requiredLocales.join(", ")}] for every translation key; load the matching locale package or add the missing translations.`);
101
103
  }
102
104
  }
@@ -2,6 +2,7 @@ import { validateEntityFieldEncryptionAvailable } from "../../db/entity-field-en
2
2
  import { dedupeFeatures } from "../dedupe-features.js";
3
3
  import { FILE_STORAGE_PROVIDER_ENV } from "../extension-names.js";
4
4
  import { QnTypes, qualifyEntityName } from "../qualified-name.js";
5
+ import { validateProjectionAllowlist } from "../validate-projection-allowlist.js";
5
6
  import { validateAccessDeclarations } from "./access-declarations.js";
6
7
  import { warnOnUniqueAccessRoles } from "./access-roles.js";
7
8
  import { validateActionWiring, validateFieldWiring } from "./action-wiring.js";
@@ -238,6 +239,7 @@ export function validateBoot(rawFeatures, options) {
238
239
  validateGdprStoragePersistence(features);
239
240
  validateGlobalTenancyEntities(features);
240
241
  validateFeatureBootChecks(features);
242
+ validateProjectionAllowlist(features);
241
243
  if (hasEncryptedFields) {
242
244
  // Availability check, not env-presence: eagerly building the keyring
243
245
  // catches malformed keys (wrong length, bad base64) at boot instead of