@cosmicdrift/kumiko-framework 0.367.0 → 0.369.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 +1 -7
  2. package/README.md +1 -1
  3. package/agents/examples/access-control.md +2 -2
  4. package/agents/examples/anonymous-access-multitenant.md +2 -2
  5. package/agents/examples/anonymous-access.md +3 -3
  6. package/agents/examples/apex-landing.md +1 -1
  7. package/agents/examples/apex-surface-auth.md +6 -7
  8. package/agents/examples/api-worker-split.md +7 -8
  9. package/agents/examples/auth-claims.md +2 -1
  10. package/agents/examples/auth-foundation-providers.md +3 -3
  11. package/agents/examples/auth-mfa.md +3 -4
  12. package/agents/examples/basic-entity.md +3 -4
  13. package/agents/examples/chat-channels.md +4 -4
  14. package/agents/examples/compliance-profiles-demo.md +2 -3
  15. package/agents/examples/cross-feature-events.md +4 -4
  16. package/agents/examples/crypto-shredding-hr.md +14 -9
  17. package/agents/examples/currencies-global.md +3 -4
  18. package/agents/examples/currencies-per-tenant.md +3 -4
  19. package/agents/examples/custom-fields-basic.md +2 -3
  20. package/agents/examples/custom-handlers.md +3 -5
  21. package/agents/examples/dashboard-stats.md +2 -3
  22. package/agents/examples/delivery-notifications.md +5 -6
  23. package/agents/examples/e2e-generator.md +1 -1
  24. package/agents/examples/embedded-entity-form.md +2 -3
  25. package/agents/examples/embedded.md +2 -3
  26. package/agents/examples/encrypted-tenant-config.md +5 -6
  27. package/agents/examples/error-contract.md +3 -4
  28. package/agents/examples/event-sourcing.md +19 -23
  29. package/agents/examples/feature-toggles.md +4 -4
  30. package/agents/examples/field-access.md +3 -4
  31. package/agents/examples/files-post-processing.md +12 -13
  32. package/agents/examples/golden-path.md +208 -0
  33. package/agents/examples/idempotency.md +2 -2
  34. package/agents/examples/inbound-mail-triage.md +7 -7
  35. package/agents/examples/lane-routing.md +3 -3
  36. package/agents/examples/ledger-banking.md +2 -3
  37. package/agents/examples/ledger-invoicing.md +2 -3
  38. package/agents/examples/legal-pages.md +1 -1
  39. package/agents/examples/lifecycle-hooks.md +3 -3
  40. package/agents/examples/managed-config.md +3 -4
  41. package/agents/examples/managed-pages.md +2 -2
  42. package/agents/examples/notes-history-basic.md +4 -5
  43. package/agents/examples/ownership.md +3 -5
  44. package/agents/examples/pdf-audit-export.md +2 -3
  45. package/agents/examples/pipeline-basics.md +2 -2
  46. package/agents/examples/public-share-token.md +3 -3
  47. package/agents/examples/rate-limiting.md +2 -1
  48. package/agents/examples/realtime-sse.md +2 -3
  49. package/agents/examples/record-detail-layout.md +1 -1
  50. package/agents/examples/reference-data.md +3 -3
  51. package/agents/examples/relations.md +2 -2
  52. package/agents/examples/screens-nav.md +1 -1
  53. package/agents/examples/search.md +2 -2
  54. package/agents/examples/secrets-demo.md +8 -8
  55. package/agents/examples/security-baseline.md +2 -2
  56. package/agents/examples/session-revocation.md +7 -8
  57. package/agents/examples/state-machine.md +181 -165
  58. package/agents/examples/tags-basic.md +2 -3
  59. package/agents/examples/tenant-isolation.md +2 -3
  60. package/agents/examples/tenant-settings.md +2 -2
  61. package/agents/examples/tier-admin.md +3 -4
  62. package/agents/examples/timezones.md +3 -3
  63. package/agents/examples/user-data-rights.md +12 -8
  64. package/agents/examples/user-profile.md +3 -3
  65. package/agents/examples/webhook-step.md +2 -3
  66. package/agents/examples/wizard-form.md +4 -5
  67. package/agents/examples/workflow-engine.md +7 -3
  68. package/agents/examples/writeform-section.md +1 -1
  69. package/agents/examples.md +1 -0
  70. package/agents/features.md +4 -4
  71. package/dist/consumer-cli.js +2 -3
  72. package/dist/crypto/index.d.ts +1 -1
  73. package/dist/crypto/index.js +1 -1
  74. package/dist/crypto/owner-transition-rekey.js +2 -0
  75. package/dist/crypto/pg-kms-adapter.js +1 -1
  76. package/dist/crypto/subject-resolver.d.ts +2 -0
  77. package/dist/crypto/subject-resolver.js +30 -15
  78. package/dist/db/bun-db-deprecated.d.ts +10 -0
  79. package/dist/{bun-db/index.js → db/bun-db-deprecated.js} +4 -3
  80. package/dist/db/config-seed.js +1 -1
  81. package/dist/db/connection.d.ts +23 -5
  82. package/dist/db/connection.js +10 -5
  83. package/dist/db/dialect.js +1 -1
  84. package/dist/db/entity-field-encryption.d.ts +5 -0
  85. package/dist/db/entity-field-encryption.js +29 -0
  86. package/dist/db/entity-table-meta.js +6 -5
  87. package/dist/db/event-store-executor-read.js +26 -5
  88. package/dist/db/index.d.ts +4 -4
  89. package/dist/db/index.js +1 -1
  90. package/dist/db/migrate-runner.js +1 -1
  91. package/dist/db/queries/backfill-pii.js +16 -9
  92. package/dist/db/queries/ddl.js +1 -1
  93. package/dist/db/queries/event-store.d.ts +2 -0
  94. package/dist/db/queries/event-store.js +26 -0
  95. package/dist/db/queries/test-stack.js +1 -1
  96. package/dist/db/query.d.ts +105 -1
  97. package/dist/db/query.js +1450 -3
  98. package/dist/{stack/table-helpers.d.ts → db/table-push.d.ts} +5 -15
  99. package/dist/{stack/table-helpers.js → db/table-push.js} +6 -46
  100. package/dist/db/tenant-db.d.ts +11 -0
  101. package/dist/db/tenant-db.js +51 -12
  102. package/dist/derivatives/derivatives-context.d.ts +1 -1
  103. package/dist/derivatives/derivatives-context.js +1 -1
  104. package/dist/engine/boot-validator/gdpr-storage.js +3 -3
  105. package/dist/engine/boot-validator/pii-retention.js +3 -3
  106. package/dist/engine/boot-validator/record-owned.js +3 -3
  107. package/dist/engine/boot-validator/screens.js +37 -1
  108. package/dist/engine/bridge-stub.d.ts +4 -0
  109. package/dist/engine/bridge-stub.js +62 -0
  110. package/dist/engine/build-config-feature-schema.js +197 -65
  111. package/dist/engine/event-helpers.d.ts +2 -1
  112. package/dist/engine/event-helpers.js +1 -0
  113. package/dist/engine/extension-selector-plugins.d.ts +3 -0
  114. package/dist/engine/extension-selector-plugins.js +14 -0
  115. package/dist/engine/feature-config-events-jobs.d.ts +2 -0
  116. package/dist/engine/feature-config-events-jobs.js +3 -2
  117. package/dist/engine/index.d.ts +1 -0
  118. package/dist/engine/index.js +1 -0
  119. package/dist/es-ops/operations-schema.js +1 -1
  120. package/dist/event-store/admin-api.js +1 -1
  121. package/dist/event-store/archive.js +1 -1
  122. package/dist/event-store/errors.d.ts +2 -1
  123. package/dist/event-store/errors.js +6 -2
  124. package/dist/event-store/event-store.js +17 -9
  125. package/dist/event-store/events-schema.js +1 -1
  126. package/dist/event-store/rebuild-dead-letter.js +3 -3
  127. package/dist/event-store/snapshot.js +1 -1
  128. package/dist/event-store/upcaster-dead-letter.js +3 -3
  129. package/dist/files/create-file-ref-from-bytes.js +1 -1
  130. package/dist/files/file-routes.js +1 -1
  131. package/dist/i18n/settings-hub-keys.js +1 -2
  132. package/dist/internal/stack-support.d.ts +12 -0
  133. package/dist/internal/stack-support.js +12 -0
  134. package/dist/internal/testing.d.ts +4 -0
  135. package/dist/internal/testing.js +5 -0
  136. package/dist/jobs/job-runner.js +23 -11
  137. package/dist/migrations/pending-rebuilds.js +1 -1
  138. package/dist/migrations/pii-event-backfill.js +2 -2
  139. package/dist/pipeline/dispatch-shared.js +43 -23
  140. package/dist/pipeline/dispatch-write.js +6 -2
  141. package/dist/pipeline/dispatcher-utils.js +6 -2
  142. package/dist/pipeline/event-consumer-state.js +1 -1
  143. package/dist/pipeline/projection-rebuild.d.ts +1 -0
  144. package/dist/pipeline/projection-rebuild.js +1 -1
  145. package/dist/pipeline/projection-state.js +1 -1
  146. package/dist/pipeline/system-hooks.js +1 -1
  147. package/dist/schema-cli.js +3 -3
  148. package/dist/search/purge-subject.js +20 -10
  149. package/dist/seeding/index.d.ts +1 -0
  150. package/dist/seeding/index.js +1 -0
  151. package/dist/stack/index.d.ts +10 -9
  152. package/dist/stack/index.js +11 -19
  153. package/dist/testing/e2e-generator.d.ts +5 -49
  154. package/dist/testing/e2e-generator.js +5 -382
  155. package/dist/testing/handler-context.d.ts +5 -4
  156. package/dist/testing/handler-context.js +5 -69
  157. package/dist/testing/index.d.ts +10 -26
  158. package/dist/testing/index.js +10 -34
  159. package/dist/testing/real-providers.d.ts +5 -3
  160. package/dist/testing/real-providers.js +5 -21
  161. package/dist/upgrade-cli.js +6 -4
  162. package/package.json +37 -9
  163. package/src/changes.json +64 -0
  164. package/src/scripts/codemod/README.md +43 -0
  165. package/src/scripts/codemod/codemod-support.ts +276 -0
  166. package/src/scripts/codemod/db-factory.ts +810 -0
  167. package/src/scripts/codemod/feature-factory.ts +429 -0
  168. package/src/scripts/codemod/s3-imports.ts +274 -0
  169. package/src/scripts/codemod/tenant-db-options.ts +454 -0
  170. package/src/scripts/codemod/testing-move-targets.ts +161 -0
  171. package/src/scripts/codemod/testing-move.ts +520 -0
  172. package/dist/bun-db/connection.d.ts +0 -17
  173. package/dist/bun-db/connection.js +0 -40
  174. package/dist/bun-db/index.d.ts +0 -4
  175. package/dist/bun-db/query.d.ts +0 -106
  176. package/dist/bun-db/query.js +0 -1457
  177. package/dist/db/api.d.ts +0 -19
  178. package/dist/db/api.js +0 -23
  179. package/dist/db/bun-provider.d.ts +0 -2
  180. package/dist/db/bun-provider.js +0 -28
  181. package/dist/db/postgres-provider.d.ts +0 -2
  182. package/dist/db/postgres-provider.js +0 -21
  183. package/dist/db/query-api.d.ts +0 -2
  184. package/dist/db/query-api.js +0 -6
  185. package/dist/stack/db.d.ts +0 -22
  186. package/dist/stack/db.js +0 -67
  187. package/dist/stack/drain-event-consumers.d.ts +0 -4
  188. package/dist/stack/drain-event-consumers.js +0 -51
  189. package/dist/stack/drain-jobs.d.ts +0 -22
  190. package/dist/stack/drain-jobs.js +0 -141
  191. package/dist/stack/event-collector.d.ts +0 -9
  192. package/dist/stack/event-collector.js +0 -12
  193. package/dist/stack/push-entity-projection-tables.d.ts +0 -24
  194. package/dist/stack/push-entity-projection-tables.js +0 -74
  195. package/dist/stack/redis.d.ts +0 -17
  196. package/dist/stack/redis.js +0 -52
  197. package/dist/stack/request-helper.d.ts +0 -35
  198. package/dist/stack/request-helper.js +0 -144
  199. package/dist/stack/test-stack.d.ts +0 -192
  200. package/dist/stack/test-stack.js +0 -429
  201. package/dist/testing/access-assertions.d.ts +0 -2
  202. package/dist/testing/access-assertions.js +0 -13
  203. package/dist/testing/assertions.d.ts +0 -10
  204. package/dist/testing/assertions.js +0 -22
  205. package/dist/testing/boot-validator-fixture.d.ts +0 -3
  206. package/dist/testing/boot-validator-fixture.js +0 -101
  207. package/dist/testing/closed-connection-error.d.ts +0 -7
  208. package/dist/testing/closed-connection-error.js +0 -46
  209. package/dist/testing/db-cleanup.d.ts +0 -9
  210. package/dist/testing/db-cleanup.js +0 -31
  211. package/dist/testing/expect-error.d.ts +0 -2
  212. package/dist/testing/expect-error.js +0 -22
  213. package/dist/testing/file-provider-contract.d.ts +0 -2
  214. package/dist/testing/file-provider-contract.js +0 -102
  215. package/dist/testing/http-cookies.d.ts +0 -7
  216. package/dist/testing/http-cookies.js +0 -47
  217. package/dist/testing/late-bound.d.ts +0 -9
  218. package/dist/testing/late-bound.js +0 -31
  219. package/dist/testing/manual-clock.d.ts +0 -14
  220. package/dist/testing/manual-clock.js +0 -12
  221. package/dist/testing/multipart-helper.d.ts +0 -22
  222. package/dist/testing/multipart-helper.js +0 -77
  223. package/dist/testing/mutable-master-key-provider.d.ts +0 -7
  224. package/dist/testing/mutable-master-key-provider.js +0 -35
  225. package/dist/testing/observability-recorder.d.ts +0 -9
  226. package/dist/testing/observability-recorder.js +0 -29
  227. package/dist/testing/seed.d.ts +0 -9
  228. package/dist/testing/seed.js +0 -21
  229. package/dist/testing/shared-entities.d.ts +0 -286
  230. package/dist/testing/shared-entities.js +0 -56
  231. package/dist/testing/utils.d.ts +0 -1
  232. package/dist/testing/utils.js +0 -1
  233. package/dist/testing/wait-for.d.ts +0 -20
  234. package/dist/testing/wait-for.js +0 -47
  235. package/dist/testing/without-ambient-temporal.d.ts +0 -5
  236. package/dist/testing/without-ambient-temporal.js +0 -17
  237. package/dist/testing/zip-fixture.d.ts +0 -1
  238. package/dist/testing/zip-fixture.js +0 -33
  239. /package/dist/{stack/test-users.d.ts → seeding/seed-users.d.ts} +0 -0
  240. /package/dist/{stack/test-users.js → seeding/seed-users.js} +0 -0
@@ -17,9 +17,10 @@
17
17
  import { isOptionsQueryFieldRef } from "@cosmicdrift/kumiko-types/fields";
18
18
  import { CONFIG_EDIT_ENTITY, fieldLabelKey } from "../i18n/required-surface-keys.js";
19
19
  import { isEncryptedAtRest } from "./config-helpers.js";
20
- import { EXTENSION_SELECTOR_HINT_KEY, SELECTED_EXTENSIONS_QUERY, selectablePluginIds, } from "./extension-selector-plugins.js";
20
+ import { EXTENSION_SELECTOR_HINT_KEY, isSelectableInSettings, settingsSelectablePluginIds, } from "./extension-selector-plugins.js";
21
21
  import { createBooleanField, createNumberField, createSelectField, createTextField, } from "./factories.js";
22
22
  import { isKebabSegment, toKebab } from "./qualified-name.js";
23
+ import { isFieldsEditSection, sectionFieldSpecs } from "./screen-helpers.js";
23
24
  import { isOpenToAllGranted } from "./types/handlers.js";
24
25
  // Namespace, unter dem buildAppSchema die generierten Screens/Navs einhängt
25
26
  // (find-or-create FeatureSchema). MUSS gleich CONFIG_FEATURE aus dem config
@@ -45,7 +46,7 @@ const PROVIDER_PANEL_TITLE_ENTRY = {
45
46
  es: "Proveedor",
46
47
  };
47
48
  const PROVIDER_SECTION_TITLE_KEY = "config.settings.provider";
48
- const SAVE_PROVIDER_LABEL_KEY = "config.settings.saveProvider";
49
+ const SECRETS_SECTION_DESCRIPTION_KEY = "config.secrets.description";
49
50
  const audienceNavShortId = (scope) => `audience-${scope}`;
50
51
  // Generated post-boot, never via r.nav() — the boot validator exempts exactly these QNs (an app references one to place the settings group inline).
51
52
  export const SETTINGS_HUB_AUDIENCE_NAV_QNS = SCOPES_BROAD_TO_DEEP.map((scope) => `${SETTINGS_HUB_FEATURE}:nav:${audienceNavShortId(scope)}`);
@@ -73,25 +74,43 @@ const NO_SELECTOR_GATING = {
73
74
  };
74
75
  // Verbatim (unprefixed) keys as the client sees them — NOT getAllTranslations()
75
76
  // (server-merged, "feature:"-prefixed, see build-app-schema.ts:77-81).
77
+ // Merged per locale: a locale feature (localeDe) declares the same key with only its
78
+ // own language, and mounted first it must not drop the owning feature's en text.
76
79
  function collectDeclaredTranslations(registry) {
77
80
  const declared = new Map();
78
81
  for (const f of registry.features.values()) {
79
82
  for (const [key, values] of Object.entries(f.translations ?? {})) {
80
- if (!declared.has(key))
81
- declared.set(key, values);
83
+ declared.set(key, { ...values, ...declared.get(key) });
82
84
  }
83
85
  }
84
86
  return declared;
85
87
  }
88
+ // A selector whose plugins are all hidden from settings still renders as a select
89
+ // (with no option), never as a free text field.
86
90
  function collectSelectorOptions(registry) {
87
91
  const options = new Map();
88
92
  for (const [extensionName, selectorKey] of registry.getAllExtensionSelectors()) {
89
- const pluginIds = selectablePluginIds(registry, extensionName);
90
- if (pluginIds.length > 0)
91
- options.set(selectorKey, pluginIds);
93
+ if (registry.getExtensionUsages(extensionName).length === 0)
94
+ continue;
95
+ options.set(selectorKey, settingsSelectablePluginIds(registry, extensionName));
92
96
  }
93
97
  return options;
94
98
  }
99
+ // selector key → plugin id → plugin feature, for every selectable plugin, also those
100
+ // without settings of their own (no panel, but still an option with a name).
101
+ function collectSelectorOptionFeatures(registry) {
102
+ const byKey = new Map();
103
+ for (const [extensionName, selectorKey] of registry.getAllExtensionSelectors()) {
104
+ const featureByPluginId = new Map();
105
+ for (const usage of registry.getExtensionUsages(extensionName).filter(isSelectableInSettings)) {
106
+ if (usage.featureName === undefined || featureByPluginId.has(usage.entityName))
107
+ continue;
108
+ featureByPluginId.set(usage.entityName, toKebab(usage.featureName));
109
+ }
110
+ byKey.set(selectorKey, featureByPluginId);
111
+ }
112
+ return byKey;
113
+ }
95
114
  function collectVisibleSelectorOwners(registry, visible) {
96
115
  const ownerGroupByExtension = new Map();
97
116
  const selectorFeatures = new Set();
@@ -141,6 +160,9 @@ function planSelectorGating(registry, visible, declaredSecrets, secretsEnabled)
141
160
  const secretQns = new Set();
142
161
  for (const [extension, ownerGroup] of [...ownerGroupByExtension].sort(([a], [b]) => a.localeCompare(b))) {
143
162
  const usages = [...registry.getExtensionUsages(extension)].sort((a, b) => (a.featureName ?? "").localeCompare(b.featureName ?? ""));
163
+ const selectorQn = registry.getAllExtensionSelectors().get(extension);
164
+ if (selectorQn === undefined)
165
+ continue;
144
166
  for (const usage of usages) {
145
167
  if (usage.featureName === undefined)
146
168
  continue;
@@ -148,11 +170,22 @@ function planSelectorGating(registry, visible, declaredSecrets, secretsEnabled)
148
170
  if (usageCountByFeature.get(feature) !== 1 || selectorFeatures.has(feature))
149
171
  continue;
150
172
  const scopedKeys = visible.filter((v) => v.key.ownerFeature === feature);
151
- const secrets = secretsEnabled ? declaredSecrets.filter((s) => s.feature === feature) : [];
173
+ // The provider form has no per-field access gate, so a secret with its own write
174
+ // roles stays on the secrets screen, which enforces them.
175
+ const secrets = secretsEnabled
176
+ ? declaredSecrets.filter((s) => s.feature === feature && s.def.writeRoles === undefined)
177
+ : [];
152
178
  if (scopedKeys.length === 0 && secrets.length === 0)
153
179
  continue;
154
180
  const plugins = pluginsByOwnerGroup.get(ownerGroup) ?? [];
155
- plugins.push({ feature, extension, pluginId: usage.entityName, scopedKeys, secrets });
181
+ plugins.push({
182
+ feature,
183
+ extension,
184
+ selectorQn,
185
+ pluginId: usage.entityName,
186
+ scopedKeys,
187
+ secrets,
188
+ });
156
189
  pluginsByOwnerGroup.set(ownerGroup, plugins);
157
190
  pluginFeatures.add(feature);
158
191
  for (const s of secrets)
@@ -204,9 +237,6 @@ function buildFeatureScreensAndNavs(scope, visible, hub, gating, secretsAccess)
204
237
  }
205
238
  return out;
206
239
  }
207
- function pluginTenantScreenId(plugin) {
208
- return `${plugin.feature}-tenant`;
209
- }
210
240
  function assertUniquePanelIds(ownerGroup, panels) {
211
241
  const seen = new Set();
212
242
  for (const panel of panels) {
@@ -218,63 +248,38 @@ function assertUniquePanelIds(ownerGroup, panels) {
218
248
  }
219
249
  return panels;
220
250
  }
221
- // Plugin screens keep their `${F}-tenant` ids so consumer title keys stay valid;
222
- // they are dormant because only this dashboard embeds them.
251
+ // One form for the provider and the settings of every provider: a single save writes
252
+ // all of it, so switching sections can't drop typed input. Each plugin's fields are
253
+ // shown only while its provider is picked in the form; hidden fields are not saved.
223
254
  function buildSelectorOwnerDashboard(ownerGroup, ownerKeys, ownerAccess, plugins, ownerPanels, hub, secretsAccess) {
224
255
  const selectionScreenId = `${ownerGroup}-tenant-selection`;
225
- const selectionScreen = {
226
- ...buildScreen(selectionScreenId, "tenant", ownerGroup, ownerKeys, ownerAccess, hub, {
227
- description: EXTENSION_SELECTOR_HINT_KEY,
228
- sectionTitle: PROVIDER_SECTION_TITLE_KEY,
229
- submitLabel: SAVE_PROVIDER_LABEL_KEY,
230
- }),
231
- dormant: true,
232
- };
233
- const screens = [selectionScreen];
234
- const configPanels = [];
235
- const secretsPanels = [];
256
+ const form = new ProviderFormBuilder(selectionScreenId, buildScreen(selectionScreenId, "tenant", ownerGroup, ownerKeys, ownerAccess, hub, {
257
+ description: EXTENSION_SELECTOR_HINT_KEY,
258
+ sectionTitle: PROVIDER_SECTION_TITLE_KEY,
259
+ }));
236
260
  const accessRules = [ownerAccess];
237
261
  const translations = {};
238
262
  translations[`screen:${selectionScreenId}.title`] = PROVIDER_PANEL_TITLE_ENTRY;
239
- Object.assign(translations, providerOptionTranslations(selectionScreen, plugins, hub));
263
+ Object.assign(translations, providerOptionTranslations(form.base, hub));
240
264
  for (const plugin of plugins) {
241
- const visibleWhen = {
242
- query: SELECTED_EXTENSIONS_QUERY,
243
- field: plugin.extension,
244
- eq: plugin.pluginId,
245
- };
265
+ const providerFieldId = form.fieldIdOfConfigKey(plugin.selectorQn);
266
+ const visible = { field: providerFieldId, eq: plugin.pluginId };
267
+ let pluginSection;
246
268
  if (plugin.scopedKeys.length > 0) {
247
269
  const pluginAccess = rolesToAccess(plugin.scopedKeys.flatMap((v) => v.roles));
248
- const ordered = sortByMaskOrder(plugin.scopedKeys.map((v) => v.key));
249
- screens.push({
250
- ...buildScreen(pluginTenantScreenId(plugin), "tenant", plugin.feature, ordered, pluginAccess, hub, { description: null }),
251
- dormant: true,
252
- });
270
+ const pluginScreen = buildScreen(`${plugin.feature}-tenant`, "tenant", plugin.feature, sortByMaskOrder(plugin.scopedKeys.map((v) => v.key)), pluginAccess, hub, { description: null, fieldIdOf: qualifiedFieldId });
271
+ pluginSection = form.addConfigFields(pluginScreen, visible);
253
272
  accessRules.push(pluginAccess);
254
- configPanels.push({
255
- kind: "screen",
256
- id: `${plugin.feature}-config`,
257
- screen: pluginTenantScreenId(plugin),
258
- visibleWhen,
259
- chromeless: true,
260
- });
261
273
  }
262
274
  if (plugin.secrets.length > 0) {
263
- const secretsScreenId = `${plugin.feature}-tenant-secrets`;
264
- const generated = buildSecretsEditScreen(secretsScreenId, plugin.secrets, secretsAccess, hub.declaredTranslationKeys);
265
- screens.push({ ...generated.screen, dormant: true });
275
+ const generated = buildSecretsEditScreen(`${plugin.feature}-tenant-secrets`, plugin.secrets, secretsAccess, hub.declaredTranslationKeys);
276
+ form.addSecretFields(generated.screen, plugin.feature, visible, pluginSection);
266
277
  Object.assign(translations, generated.translations);
267
278
  accessRules.push(secretsAccess);
268
- secretsPanels.push({
269
- kind: "screen",
270
- id: `${plugin.feature}-secrets`,
271
- screen: secretsScreenId,
272
- visibleWhen,
273
- chromeless: true,
274
- });
275
279
  }
276
280
  }
277
281
  const access = unionAccessRules(accessRules);
282
+ const selectionScreen = { ...form.build(access), dormant: true };
278
283
  const dashboard = {
279
284
  id: `${ownerGroup}-tenant`,
280
285
  type: "dashboard",
@@ -282,16 +287,136 @@ function buildSelectorOwnerDashboard(ownerGroup, ownerKeys, ownerAccess, plugins
282
287
  panels: assertUniquePanelIds(ownerGroup, [
283
288
  { kind: "screen", id: "selection", screen: selectionScreenId, chromeless: true },
284
289
  ...ownerPanels,
285
- ...configPanels,
286
- ...secretsPanels,
287
290
  ]),
288
291
  access,
289
292
  };
290
- return { screens: [dashboard, ...screens], access, translations };
293
+ return { screens: [dashboard, selectionScreen], access, translations };
294
+ }
295
+ // A plugin key keeps its feature in the field id: the provider form holds the keys of
296
+ // several features side by side.
297
+ function qualifiedFieldId(k) {
298
+ return `${k.ownerFeature}-${k.shortKey}`;
299
+ }
300
+ class ProviderFormBuilder {
301
+ screenId;
302
+ base;
303
+ configKeys;
304
+ secretKeys = {};
305
+ fields;
306
+ fieldLabels;
307
+ fieldDescriptions;
308
+ requiredFields;
309
+ sections;
310
+ constructor(screenId, base) {
311
+ this.screenId = screenId;
312
+ this.base = base;
313
+ this.configKeys = { ...base.configKeys };
314
+ this.fields = { ...base.fields };
315
+ this.fieldLabels = { ...base.fieldLabels };
316
+ this.fieldDescriptions = { ...base.fieldDescriptions };
317
+ this.requiredFields = [...(base.requiredFields ?? [])];
318
+ this.sections = base.layout.sections.filter(isFieldsEditSection);
319
+ }
320
+ fieldIdOfConfigKey(qn) {
321
+ const entry = Object.entries(this.base.configKeys).find(([, keyQn]) => keyQn === qn);
322
+ if (entry === undefined) {
323
+ throw new Error(`[Settings-Hub] selector key "${qn}" is not on "${this.screenId}".`);
324
+ }
325
+ return entry[0];
326
+ }
327
+ addConfigFields(pluginScreen, visible) {
328
+ for (const [id, qn] of Object.entries(pluginScreen.configKeys)) {
329
+ this.claim(id, qn);
330
+ this.configKeys[id] = qn;
331
+ const field = pluginScreen.fields[id];
332
+ if (field !== undefined)
333
+ this.fields[id] = field;
334
+ }
335
+ Object.assign(this.fieldLabels, pluginScreen.fieldLabels);
336
+ Object.assign(this.fieldDescriptions, pluginScreen.fieldDescriptions);
337
+ this.requiredFields.push(...(pluginScreen.requiredFields ?? []));
338
+ const section = pluginScreen.layout.sections.find(isFieldsEditSection);
339
+ if (section === undefined)
340
+ return undefined;
341
+ const { groups: _groups, ...flatSection } = section;
342
+ const gated = {
343
+ ...flatSection,
344
+ fields: gateFields(sectionFieldSpecs(section), visible),
345
+ };
346
+ this.sections.push(gated);
347
+ return gated;
348
+ }
349
+ // A plugin's sign-in data gets its own section when the plugin names one, else it
350
+ // continues the plugin's settings section.
351
+ addSecretFields(secretsScreen, feature, visible, pluginSection) {
352
+ for (const [id, qn] of Object.entries(secretsScreen.secretKeys)) {
353
+ this.claim(id, qn);
354
+ this.secretKeys[id] = qn;
355
+ this.fields[id] = createTextField({
356
+ personal: false,
357
+ reason: "system_metadata",
358
+ writeOnly: true,
359
+ });
360
+ }
361
+ Object.assign(this.fieldLabels, secretsScreen.fieldLabels);
362
+ Object.assign(this.fieldDescriptions, secretsScreen.fieldHints);
363
+ this.requiredFields.push(...(secretsScreen.requiredFields ?? []));
364
+ const secretsTitleKey = `${feature}.settings.secrets`;
365
+ for (const section of secretsScreen.sections) {
366
+ // kumiko-lint-ignore section-fields-raw a secretsEdit section is a flat key list without groups
367
+ const fields = gateFields(section.fields, visible);
368
+ const ownSection = section.title === secretsTitleKey || pluginSection === undefined;
369
+ if (ownSection) {
370
+ this.sections.push({
371
+ title: section.title ?? `${feature}.settings`,
372
+ description: SECRETS_SECTION_DESCRIPTION_KEY,
373
+ fields,
374
+ });
375
+ continue;
376
+ }
377
+ const index = this.sections.indexOf(pluginSection);
378
+ this.sections[index] = {
379
+ ...pluginSection,
380
+ fields: [...sectionFieldSpecs(pluginSection), ...fields],
381
+ };
382
+ }
383
+ }
384
+ build(access) {
385
+ const { fieldDescriptions: _baseDescriptions, requiredFields: _baseRequired, ...base } = this.base;
386
+ return {
387
+ ...base,
388
+ configKeys: this.configKeys,
389
+ ...(Object.keys(this.secretKeys).length > 0 && { secretKeys: this.secretKeys }),
390
+ fields: this.fields,
391
+ fieldLabels: this.fieldLabels,
392
+ ...(Object.keys(this.fieldDescriptions).length > 0 && {
393
+ fieldDescriptions: this.fieldDescriptions,
394
+ }),
395
+ ...(this.requiredFields.length > 0 && { requiredFields: this.requiredFields }),
396
+ layout: { ...base.layout, sections: this.sections },
397
+ access,
398
+ };
399
+ }
400
+ claim(id, qn) {
401
+ const previous = this.configKeys[id] ?? this.secretKeys[id];
402
+ if (previous !== undefined) {
403
+ throw new Error(`[Settings-Hub] "${previous}" and "${qn}" both resolve to field id "${id}" on "${this.screenId}" — rename one key.`);
404
+ }
405
+ }
406
+ }
407
+ function gateFields(fields, visible) {
408
+ return fields.map((spec) => typeof spec === "string" ? { field: spec, visible } : { ...spec, visible });
291
409
  }
292
410
  // Everything generated for one audience scope: the audience-parent nav, the
293
411
  // per-feature configEdit screens+navs, and (tenant-scope only, when secrets
294
412
  // are enabled) the secrets screen. Null when nothing is visible at this scope.
413
+ // The secrets set handler also admits the machine role for server-side writes; like the
414
+ // config keys' write roles, that role must not reach a screen or nav access gate.
415
+ function withoutMachineRole(rule) {
416
+ if (rule === undefined || !("roles" in rule))
417
+ return rule;
418
+ return { ...rule, roles: rule.roles.filter((role) => role !== MACHINE_WRITE_ROLE) };
419
+ }
295
420
  function buildScopeResult(scope, masked, secretsEnabled, secretsWriteHandler, declaredSecrets, hub, registry) {
296
421
  const visible = scopedKeysAt(masked, scope);
297
422
  // Secrets attach to the tenant-audience nav regardless of whether any
@@ -300,7 +425,7 @@ function buildScopeResult(scope, masked, secretsEnabled, secretsWriteHandler, de
300
425
  if (visible.length === 0 && !includeSecrets)
301
426
  return null;
302
427
  const configAccess = rolesToAccess(visible.flatMap((v) => v.roles));
303
- const secretsAccess = secretsWriteHandler?.access;
428
+ const secretsAccess = withoutMachineRole(secretsWriteHandler?.access);
304
429
  const gating = scope === "tenant"
305
430
  ? planSelectorGating(registry, visible, declaredSecrets, secretsEnabled)
306
431
  : NO_SELECTOR_GATING;
@@ -346,6 +471,7 @@ export function buildConfigFeatureSchema(registry) {
346
471
  const hub = {
347
472
  declaredTranslationKeys: new Set(translationsByKey.keys()),
348
473
  selectorOptions: collectSelectorOptions(registry),
474
+ selectorOptionFeatures: collectSelectorOptionFeatures(registry),
349
475
  translationsByKey,
350
476
  };
351
477
  const screens = [];
@@ -440,7 +566,12 @@ function buildSecretsEditScreen(screenId, secrets, access, declaredTranslationKe
440
566
  if (s.def.writeRoles !== undefined)
441
567
  fieldAccess[fieldId] = { roles: [...s.def.writeRoles] };
442
568
  }
443
- const titleKey = `${feature}.settings`;
569
+ // Opt-in `${feature}.settings.secrets`: in a selector dashboard this section sits right
570
+ // under the plugin's own settings section, which already carries `${feature}.settings`.
571
+ const secretsTitleKey = `${feature}.settings.secrets`;
572
+ const titleKey = declaredTranslationKeys.has(secretsTitleKey)
573
+ ? secretsTitleKey
574
+ : `${feature}.settings`;
444
575
  sections.push({
445
576
  ...(declaredTranslationKeys.has(titleKey) && { title: titleKey }),
446
577
  fields: fieldIds,
@@ -514,7 +645,8 @@ function buildScreen(shortId, scope, feature, keys, access, hub, options = {}) {
514
645
  // feature's group (100% of today's apps — byte-identical output). Only a
515
646
  // cross-feature `group` (feature !== ownerFeature) needs the owner prefix,
516
647
  // to keep two features sharing a group from clobbering the same shortKey.
517
- const fieldId = (k) => k.feature === k.ownerFeature ? k.shortKey : `${k.ownerFeature}-${k.shortKey}`;
648
+ const fieldId = options.fieldIdOf ??
649
+ ((k) => (k.feature === k.ownerFeature ? k.shortKey : qualifiedFieldId(k)));
518
650
  const seenFieldIds = new Map();
519
651
  for (const k of keys) {
520
652
  const id = fieldId(k);
@@ -544,7 +676,6 @@ function buildScreen(shortId, scope, feature, keys, access, hub, options = {}) {
544
676
  ...(Object.keys(fieldDescriptions).length > 0 && { fieldDescriptions }),
545
677
  ...(requiredFields.length > 0 && { requiredFields }),
546
678
  ...(screenDescription !== undefined && { description: screenDescription }),
547
- ...(options.submitLabel !== undefined && { submitLabel: options.submitLabel }),
548
679
  layout: { sections: [section], width: "full", variant: "settings-list" },
549
680
  access,
550
681
  };
@@ -589,16 +720,17 @@ function buildSettingsSection(feature, scope, fieldIds, hub, options) {
589
720
  }
590
721
  // Option labels of the provider select: each plugin feature's `<feature>.settings`
591
722
  // copy, under the option-label key the configEdit renderer already derives.
592
- function providerOptionTranslations(selectionScreen, plugins, hub) {
723
+ function providerOptionTranslations(selectionScreen, hub) {
593
724
  const out = {};
594
725
  for (const [fieldId, qn] of Object.entries(selectionScreen.configKeys)) {
595
- if (!hub.selectorOptions.has(qn))
726
+ const featureByPluginId = hub.selectorOptionFeatures.get(qn);
727
+ if (featureByPluginId === undefined)
596
728
  continue;
597
- for (const plugin of plugins) {
598
- const label = hub.translationsByKey.get(`${plugin.feature}.settings`);
729
+ for (const [pluginId, feature] of featureByPluginId) {
730
+ const label = hub.translationsByKey.get(`${feature}.settings`);
599
731
  if (label === undefined)
600
732
  continue;
601
- const key = `${fieldLabelKey(SETTINGS_HUB_FEATURE, CONFIG_EDIT_ENTITY, fieldId)}:option:${plugin.pluginId}`;
733
+ const key = `${fieldLabelKey(SETTINGS_HUB_FEATURE, CONFIG_EDIT_ENTITY, fieldId)}:option:${pluginId}`;
602
734
  out[key] = label;
603
735
  }
604
736
  }
@@ -1,9 +1,10 @@
1
- import type { EventDef, HandlerContext } from "./types/handlers.js";
1
+ import type { AppendEventArgs, EventDef, HandlerContext } from "./types/handlers.js";
2
2
  export type EmitCtx = Pick<HandlerContext, "unsafeAppendEvent">;
3
3
  export declare function emitEvent<TPayload>(ctx: EmitCtx, eventDef: EventDef<TPayload>, args: {
4
4
  readonly aggregateId: string;
5
5
  readonly aggregateType: string;
6
6
  readonly payload: TPayload;
7
+ readonly headers?: AppendEventArgs["headers"];
7
8
  }): Promise<void>;
8
9
  export declare function typedPayload<TPayload>(event: {
9
10
  readonly type: string;
@@ -21,6 +21,7 @@ export async function emitEvent(ctx, eventDef, args) {
21
21
  aggregateType: args.aggregateType,
22
22
  type: eventDef.name,
23
23
  payload: args.payload,
24
+ ...(args.headers !== undefined && { headers: args.headers }),
24
25
  };
25
26
  await ctx.unsafeAppendEvent(appendArgs);
26
27
  }
@@ -1,5 +1,8 @@
1
1
  import type { Registry } from "./types/feature.js";
2
+ import type { RegistrarExtensionRegistration } from "./types/index.js";
2
3
  export declare const SELECTED_EXTENSIONS_QUERY = "config:query:config-value:selected-extensions";
3
4
  export declare const EXTENSION_SELECTOR_HINT_KEY = "config.settings.extensionSelectorHint";
4
5
  export declare function selectablePluginIds(registry: Pick<Registry, "getExtensionUsages">, extensionName: string): string[];
6
+ export declare function isSelectableInSettings(usage: RegistrarExtensionRegistration): boolean;
7
+ export declare function settingsSelectablePluginIds(registry: Pick<Registry, "getExtensionUsages">, extensionName: string): string[];
5
8
  export declare function extensionSelectorTargets(registry: Pick<Registry, "getAllExtensionSelectors">): ReadonlyMap<string, string>;
@@ -5,6 +5,20 @@ export const EXTENSION_SELECTOR_HINT_KEY = "config.settings.extensionSelectorHin
5
5
  export function selectablePluginIds(registry, extensionName) {
6
6
  return [...new Set(registry.getExtensionUsages(extensionName).map((u) => u.entityName))].sort();
7
7
  }
8
+ // A plugin registered with `selectableInSettings: false` (a test inbox) stays a valid
9
+ // stored value for seeds and dev setups, but the generated settings never offer it.
10
+ const SELECTABLE_IN_SETTINGS_OPTION = "selectableInSettings";
11
+ export function isSelectableInSettings(usage) {
12
+ return usage.options?.[SELECTABLE_IN_SETTINGS_OPTION] !== false;
13
+ }
14
+ export function settingsSelectablePluginIds(registry, extensionName) {
15
+ return [
16
+ ...new Set(registry
17
+ .getExtensionUsages(extensionName)
18
+ .filter(isSelectableInSettings)
19
+ .map((u) => u.entityName)),
20
+ ].sort();
21
+ }
8
22
  export function extensionSelectorTargets(registry) {
9
23
  const extensionByKey = new Map();
10
24
  for (const [extensionName, selectorKey] of registry.getAllExtensionSelectors()) {
@@ -28,6 +28,7 @@ export declare function buildConfigEventsJobsMethods<TName extends string>(state
28
28
  readonly renamedFrom?: readonly string[];
29
29
  readonly systemOnly?: boolean;
30
30
  readonly unsubscribe?: boolean;
31
+ readonly tenantTransport?: boolean;
31
32
  }), definition?: {
32
33
  readonly trigger?: {
33
34
  readonly on: NameOrRef;
@@ -43,6 +44,7 @@ export declare function buildConfigEventsJobsMethods<TName extends string>(state
43
44
  readonly renamedFrom?: readonly string[];
44
45
  readonly systemOnly?: boolean;
45
46
  readonly unsubscribe?: boolean;
47
+ readonly tenantTransport?: boolean;
46
48
  }): void;
47
49
  translations(def: TranslationsDef): void;
48
50
  defineEvent: <const TInner extends string, TPayload>(eventName: TInner, schema: ZodType<TPayload>, options: {
@@ -40,12 +40,12 @@ function assertOwnerFieldYieldsPresentString(args) {
40
40
  const { featureName, eventName, field, ownerField, owner } = args;
41
41
  if (neverYieldsString(owner)) {
42
42
  throw new Error(`[Feature ${featureName}] defineEvent("${eventName}"): piiFields."${field}" is owned by "${ownerField}", whose payload schema is not a string — the subject id must be a non-empty string, so every append would fail. ` +
43
- `Use a string owner field, or declare whenAbsent ("tenant" | "plaintext").`);
43
+ `Use a string owner field, or declare whenAbsent ("tenant" | "self" | "plaintext").`);
44
44
  }
45
45
  if (isAbsentable(owner)) {
46
46
  throw new Error(`[Feature ${featureName}] defineEvent("${eventName}"): piiFields."${field}" is owned by "${ownerField}", which the payload schema allows to be null/undefined. ` +
47
47
  `Declare what happens then: { personal: { of: "${ownerField}", whenAbsent: "tenant" } } encrypts under the envelope tenant key, ` +
48
- `whenAbsent: "plaintext" acknowledges that the value ships unencrypted and cannot be crypto-shredded (fw#2776).`);
48
+ `whenAbsent: "self" under the event's own record key, whenAbsent: "plaintext" acknowledges that the value ships unencrypted and cannot be crypto-shredded (fw#2776).`);
49
49
  }
50
50
  }
51
51
  // Builds config/secrets/claims/events/jobs/notifications registrar methods.
@@ -222,6 +222,7 @@ export function buildConfigEventsJobsMethods(state, name) {
222
222
  : {}),
223
223
  ...(resolvedDefinition.systemOnly === true ? { systemOnly: true } : {}),
224
224
  ...(resolvedDefinition.unsubscribe === true ? { unsubscribe: true } : {}),
225
+ ...(resolvedDefinition.tenantTransport === true ? { tenantTransport: true } : {}),
225
226
  };
226
227
  },
227
228
  translations(def) {
@@ -4,6 +4,7 @@ export { isPrincipalStatusPlugin, isTenantLifecycleStatusPlugin, type PrincipalP
4
4
  export { PII_DIRECT_NAME_HINTS, PII_USER_OWNED_NAME_HINTS, PII_USER_REFERENCE_NAME_HINTS, } from "./boot-validator/entity-handler.js";
5
5
  export { entityHasAnonymizableSubjectField } from "./boot-validator/pii-retention.js";
6
6
  export { type CardMetaOverflow, collectCardMetaOverflow, collectWriteHandlerQns, MAX_TRANSFER_DEPTH, SECURITY_BASELINE_FEATURE_NAMES, type ValidateBootOptions, validateAppCustomScreenWriteQns, validateBoot, } from "./boot-validator.js";
7
+ export { bridgeStub } from "./bridge-stub.js";
7
8
  export { type BuildAppSchemaOptions, buildAppSchema } from "./build-app-schema.js";
8
9
  export type { ConfigFeatureSchema } from "./build-config-feature-schema.js";
9
10
  export { buildConfigFeatureSchema, SETTINGS_HUB_FEATURE, SETTINGS_HUB_WORKSPACE, } from "./build-config-feature-schema.js";
@@ -5,6 +5,7 @@ export { isPrincipalStatusPlugin, isTenantLifecycleStatusPlugin, TENANT_TEARDOWN
5
5
  export { PII_DIRECT_NAME_HINTS, PII_USER_OWNED_NAME_HINTS, PII_USER_REFERENCE_NAME_HINTS, } from "./boot-validator/entity-handler.js";
6
6
  export { entityHasAnonymizableSubjectField } from "./boot-validator/pii-retention.js";
7
7
  export { collectCardMetaOverflow, collectWriteHandlerQns, MAX_TRANSFER_DEPTH, SECURITY_BASELINE_FEATURE_NAMES, validateAppCustomScreenWriteQns, validateBoot, } from "./boot-validator.js";
8
+ export { bridgeStub } from "./bridge-stub.js";
8
9
  export { buildAppSchema } from "./build-app-schema.js";
9
10
  export { buildConfigFeatureSchema, SETTINGS_HUB_FEATURE, SETTINGS_HUB_WORKSPACE, } from "./build-config-feature-schema.js";
10
11
  export { buildTarget } from "./build-target.js";
@@ -12,7 +12,7 @@
12
12
  import { index, integer, table as pgTable, sql, text, timestamp } from "../db/dialect.js";
13
13
  // sql now comes from native dialect
14
14
  import { tableExists } from "../db/index.js";
15
- import { unsafePushTables } from "../stack/index.js";
15
+ import { unsafePushTables } from "../db/table-push.js";
16
16
  export const esOperationsTable = pgTable("kumiko_es_operations", {
17
17
  // Filename without extension serves as ID. Chronologically sortable
18
18
  // (date-prefix convention), human-meaningful, no separate hash needed.
@@ -7,10 +7,10 @@
7
7
  // Guard-Script `scripts/guard-admin-api.ts` blockt Aufrufe aus App-Code —
8
8
  // Allowlist: samples/*/migration/, scripts/migrations/, die Definition
9
9
  // selbst, das Guard-Script selbst.
10
- import { instantToTimestamptzParam } from "../bun-db/query.js";
11
10
  import { constraintOf, isUniqueViolation } from "../db/pg-error.js";
12
11
  import { claimXactId, runAtomicEventWrite } from "../db/queries/event-store.js";
13
12
  import { eventPredecessorExists, findExistingEventVersion, insertRawEventBatch, insertRawFirstEvent, insertRawSubsequentEvent, } from "../db/queries/event-store-admin.js";
13
+ import { instantToTimestamptzParam } from "../db/query.js";
14
14
  import { IdempotentAppendConflictError, VersionConflictError } from "./errors.js";
15
15
  function mapEventUniqueViolation(e, event) {
16
16
  if (constraintOf(e) === "events_idempotency_uq" && event.metadata.idempotencyKey) {
@@ -3,7 +3,7 @@ import { instant, table as pgTable, sql, text, uniqueIndex, uuid } from "../db/d
3
3
  import { upsertArchivedStream } from "../db/queries/event-store.js";
4
4
  import { deleteMany, fetchOne } from "../db/query.js";
5
5
  import { tableExists } from "../db/schema-inspection.js";
6
- import { unsafePushTables } from "../stack/index.js";
6
+ import { unsafePushTables } from "../db/table-push.js";
7
7
  // Marten-aligned stream archival. Archived streams become read-only: fresh
8
8
  // appendEvent on an archived aggregate throws, and loadAggregate returns
9
9
  // an empty slice unless the caller passes { includeArchived: true }.
@@ -2,7 +2,8 @@
2
2
  export declare class VersionConflictError extends Error {
3
3
  readonly aggregateId: string;
4
4
  readonly expectedVersion: number;
5
- constructor(aggregateId: string, expectedVersion: number);
5
+ readonly currentVersion: number | undefined;
6
+ constructor(aggregateId: string, expectedVersion: number, currentVersion?: number);
6
7
  }
7
8
  export declare class IdempotentAppendConflictError extends Error {
8
9
  readonly tenantId: string;
@@ -9,8 +9,12 @@
9
9
  export class VersionConflictError extends Error {
10
10
  aggregateId;
11
11
  expectedVersion;
12
- constructor(aggregateId, expectedVersion) {
13
- super(`Version conflict on aggregate ${aggregateId}: expected predecessor version ${expectedVersion}`);
12
+ // Undefined when the conflict surfaced as a bare unique violation with no lookup possible.
13
+ currentVersion;
14
+ constructor(aggregateId, expectedVersion, currentVersion) {
15
+ super(`Version conflict on aggregate ${aggregateId}: expected predecessor version ${expectedVersion}` +
16
+ (currentVersion === undefined ? "" : `, stream is at version ${currentVersion}`));
17
+ this.currentVersion = currentVersion;
14
18
  this.name = "VersionConflictError";
15
19
  this.aggregateId = aggregateId;
16
20
  this.expectedVersion = expectedVersion;
@@ -2,11 +2,10 @@
2
2
  import { isPersonalDataGated, UNATTRIBUTED_ORIGIN, } from "@cosmicdrift/kumiko-types/event-store-types";
3
3
  import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
4
4
  import { requestContext } from "../api/request-context.js";
5
- import { instantToTimestamptzParam } from "../bun-db/query.js";
6
5
  import { encryptEventPayloadPii } from "../crypto/event-pii.js";
7
6
  import { constraintOf, isUniqueViolation } from "../db/pg-error.js";
8
- import { claimXactId, insertSubsequentEventRow, notifyPgChannel, runAtomicEventWrite, selectAggregateMaxVersion, selectEventsHighWaterMark, selectStreamMaxVersion, selectStreamMaxVersions, } from "../db/queries/event-store.js";
9
- import { insertOne, selectMany } from "../db/query.js";
7
+ import { claimXactId, insertFirstEventRow, insertSubsequentEventRow, notifyPgChannel, runAtomicEventWrite, selectAggregateMaxVersion, selectEventsHighWaterMark, selectStreamMaxVersion, selectStreamMaxVersions, } from "../db/queries/event-store.js";
8
+ import { instantToTimestamptzParam, selectMany } from "../db/query.js";
10
9
  import { isStreamArchived } from "./archive.js";
11
10
  import { IdempotentAppendConflictError, SeedTimeOrderError, VersionConflictError, } from "./errors.js";
12
11
  import { eventsTable } from "./events-schema.js";
@@ -112,21 +111,21 @@ async function assertSeedTimeNotBeforePredecessor(db, event, seedCreatedAt) {
112
111
  }
113
112
  }
114
113
  async function insertFirstEvent(db, event, newVersion, eventVersion, seedCreatedAt) {
115
- const row = await insertOne(db, eventsTable, {
114
+ const row = await insertFirstEventRow(db, {
116
115
  aggregateId: event.aggregateId,
117
116
  aggregateType: event.aggregateType,
118
117
  tenantId: event.tenantId,
119
- version: newVersion,
118
+ newVersion,
120
119
  type: event.type,
121
120
  eventVersion,
122
121
  payload: event.payload,
123
122
  metadata: event.metadata,
124
123
  createdBy: event.metadata.userId,
125
- ...(seedCreatedAt !== undefined && { createdAt: seedCreatedAt }),
124
+ createdAt: seedCreatedAt && instantToTimestamptzParam(seedCreatedAt),
126
125
  });
127
126
  if (!row)
128
- throw new Error("insertFirstEvent: INSERT RETURNING produced no row");
129
- return { id: row.id, createdAt: row.createdAt };
127
+ throw await versionConflictWithCurrentVersion(db, event);
128
+ return insertReturnOf(row);
130
129
  }
131
130
  // Subsequent event — predecessor must exist AND belong to the same tenant.
132
131
  // INSERT … SELECT … WHERE EXISTS is awkward in the typed builder, so this
@@ -147,7 +146,10 @@ async function insertSubsequentEvent(db, event, newVersion, eventVersion, seedCr
147
146
  createdAt: seedCreatedAt && instantToTimestamptzParam(seedCreatedAt),
148
147
  });
149
148
  if (!row)
150
- throw new VersionConflictError(event.aggregateId, event.expectedVersion);
149
+ throw await versionConflictWithCurrentVersion(db, event);
150
+ return insertReturnOf(row);
151
+ }
152
+ function insertReturnOf(row) {
151
153
  const createdAt = row.created_at instanceof Date
152
154
  ? Temporal.Instant.fromEpochMilliseconds(row.created_at.getTime())
153
155
  : Temporal.Instant.from(row.created_at);
@@ -156,6 +158,12 @@ async function insertSubsequentEvent(db, event, newVersion, eventVersion, seedCr
156
158
  createdAt,
157
159
  };
158
160
  }
161
+ // Runs in the still-healthy write transaction: ON CONFLICT DO NOTHING left it
162
+ // unaborted, so the stream version is readable here.
163
+ async function versionConflictWithCurrentVersion(db, event) {
164
+ const currentVersion = await selectStreamMaxVersion(db, event.aggregateId, event.tenantId);
165
+ return new VersionConflictError(event.aggregateId, event.expectedVersion, currentVersion);
166
+ }
159
167
  function buildStoredEvent(event, newVersion, eventVersion, row) {
160
168
  return {
161
169
  id: String(row.id),