@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
@@ -1,429 +0,0 @@
1
- import { propertyOf } from "@cosmicdrift/kumiko-types/type-guards";
2
- import { buildServer } from "../api/server.js";
3
- import { createSseBroker } from "../api/sse-broker.js";
4
- import { validateBoot, validateNavBootSubset } from "../engine/boot-validator/index.js";
5
- import { validateOwnershipBoot } from "../engine/boot-validator/ownership.js";
6
- import { createRegistry } from "../engine/registry.js";
7
- import { createArchivedStreamsTable, createEventsTable } from "../event-store/index.js";
8
- import { createJobRunner } from "../jobs/index.js";
9
- import { createNoopProvider } from "../observability/index.js";
10
- import { createEntityCache, createEventDedup, createIdempotencyGuard, dispatcherToWriteRef, } from "../pipeline/index.js";
11
- import { createRateLimitResolver } from "../rate-limit/index.js";
12
- import { createLocalCacheSyncBus, createRedisCacheSyncBus, } from "../redis/cache-sync-bus.js";
13
- import { createInMemorySearchAdapter } from "../search/index.js";
14
- import { createTestDb } from "./db.js";
15
- import { createJobFailureTracker, drainJobs } from "./drain-jobs.js";
16
- import { createEventCollector } from "./event-collector.js";
17
- import { createTestRedis, queueNamePrefixForTestRedis } from "./redis.js";
18
- import { createRequestHelper } from "./request-helper.js";
19
- import { unsafePushTables } from "./table-helpers.js";
20
- const DEFAULT_JWT_SECRET = "test-stack-secret-minimum-32-characters!!";
21
- export async function setupTestStack(options) {
22
- const jwtSecret = options.jwtSecret ?? DEFAULT_JWT_SECRET;
23
- const enabledHooks = options.systemHooks ?? ["sse", "search"];
24
- // Temporal-Polyfill installieren bevor Feature-Code läuft. Idempotent —
25
- // Production-Server-Boot ruft das gleich. Auf Runtimes mit nativem
26
- // Temporal ein No-Op.
27
- const { ensureTemporalPolyfill } = await import("../time/polyfill.js");
28
- await ensureTemporalPolyfill();
29
- // Ownership boot-guards (fw#2639 unqualified where-fragments, fw#2626
30
- // where-rules on the write path) otherwise run only in the prod boot via
31
- // validateBoot, leaving every test stack blind to a broken access map.
32
- // Runs after the polyfill (buildEntityTable needs it to derive the probe
33
- // columns) and before the ephemeral DB/Redis exist, so a throw can't leak
34
- // a database that nothing will clean up.
35
- validateOwnershipBoot(options.features);
36
- // Nav/workspace refs fail the prod boot too; the subset check tolerates refs into unmounted features.
37
- validateNavBootSubset(options.features);
38
- if (options.validateBoot === "full")
39
- validateBoot(options.features);
40
- // Forward db-name/persistent-flag through to createTestDb. The
41
- // defaults (undefined dbName, persistent:false) keep the legacy
42
- // test contract: fresh kumiko_test_<random> DB per setup, dropped
43
- // on cleanup.
44
- const [testDb, testRedis] = await Promise.all([
45
- createTestDb({
46
- ...(options.dbName !== undefined && { dbName: options.dbName }),
47
- ...(options.persistentDb !== undefined && { persistent: options.persistentDb }),
48
- ...(options.dbPoolMax !== undefined && { poolMax: options.dbPoolMax }),
49
- ...(options.outsideTransactionPoolMax !== undefined && {
50
- outsideTransactionPoolMax: options.outsideTransactionPoolMax,
51
- }),
52
- }),
53
- createTestRedis(options.sharedRedisWith && { borrowKeyPrefix: options.sharedRedisWith.redis.keyPrefix }),
54
- ]);
55
- // Any throw in this DDL region must still drop the just-created ephemeral
56
- // DB/Redis — the caller never gets the stack object back to call
57
- // cleanup() itself (same "leaked ephemeral DB" failure class as the later
58
- // try/catch below, but for setup that runs before that try starts).
59
- try {
60
- // Every ES-entity writes events via createEventStoreExecutor in the
61
- // feature's write handlers. Auto-create the events table so every
62
- // setupTestStack call is ready for writes without needing a manual
63
- // createEventsTable().
64
- await createEventsTable(testDb.db);
65
- // Archive-stream metadata — needed by ctx.appendEvent's archive guard and
66
- // loadAggregate's default-skip. Idempotent, so production boot running
67
- // the same call is fine.
68
- await createArchivedStreamsTable(testDb.db);
69
- // Framework state for projection rebuild/status + event-consumer cursors.
70
- // Idempotent — production boot flows run the same calls.
71
- const { createProjectionStateTable, createEventConsumerStateTable } = await import("../pipeline/index.js");
72
- await createProjectionStateTable(testDb.db);
73
- await createEventConsumerStateTable(testDb.db);
74
- // Files support: when a provider is registered, the fileRefs table must
75
- // exist before the first upload. Skipped when no provider — the table
76
- // stays off tenant test DBs that never touch files.
77
- if (options.files) {
78
- const { fileRefsTable } = await import("../files/index.js");
79
- await unsafePushTables(testDb.db, { fileRefsTable });
80
- }
81
- // Same table list as `kumiko schema generate` (collectTableMetas) — divergence
82
- // was the #255 prod-crash and the #3102 missing r.entity() tables.
83
- const { collectTableMetas } = await import("../db/collect-table-metas.js");
84
- const { tableExists } = await import("../db/schema-inspection.js");
85
- let metas = collectTableMetas(options.features);
86
- if (options.entityTables === false) {
87
- const { enumerateFeatureTableSources } = await import("../db/feature-table-sources.js");
88
- const { extractTableInfo } = await import("../db/query.js");
89
- const nonEntityNames = new Set(options.features.flatMap((f) => enumerateFeatureTableSources(f).map((s) => extractTableInfo(s.table).name)));
90
- metas = metas.filter((m) => nonEntityNames.has(m.tableName));
91
- }
92
- const missing = {};
93
- for (const meta of metas) {
94
- if (await tableExists(testDb.db, `public.${meta.tableName}`))
95
- continue;
96
- missing[meta.tableName] = meta;
97
- }
98
- if (Object.keys(missing).length > 0) {
99
- await unsafePushTables(testDb.db, missing);
100
- }
101
- }
102
- catch (error) {
103
- try {
104
- await testDb.cleanup();
105
- }
106
- catch {
107
- // ignore — `error` is the one that matters
108
- }
109
- try {
110
- await testRedis.cleanup();
111
- }
112
- catch {
113
- // ignore — `error` is the one that matters
114
- }
115
- throw error;
116
- }
117
- // Redis only where two stacks can actually disagree (avoids two extra
118
- // Pub/Sub connections); otherwise a local bus so invalidation still works
119
- // inside this stack. Passing it explicitly keeps buildServer from using its
120
- // REDIS_URL default (an unnamespaced channel).
121
- const cacheSync = options.cacheSync || options.sharedRedisWith
122
- ? createRedisCacheSyncBus({
123
- redisUrl: testRedis.redisUrl,
124
- channelPrefix: testRedis.keyPrefix,
125
- })
126
- : createLocalCacheSyncBus();
127
- const searchAdapter = createInMemorySearchAdapter();
128
- const events = createEventCollector();
129
- const registry = createRegistry([...options.features]);
130
- // Wire SSE broker with event collector — built early (not just before
131
- // buildServer) because extraContext + the job context below both need it.
132
- const sseBroker = createSseBroker();
133
- sseBroker.addClient("tenant:00000000-0000-4000-8000-000000000001", (event) => events.sse.push(event), () => { });
134
- const entityCache = createEntityCache(testRedis.redis, { ttlSeconds: 60 });
135
- // A static `files.storageProvider` is wired as the per-tenant resolver — the
136
- // framework test seam that doesn't require mounting config + file-foundation.
137
- // (Bundled GDPR tests mount the real provider features instead.)
138
- let fileProviderResolver;
139
- if (options.files) {
140
- const provider = options.files.storageProvider;
141
- fileProviderResolver = () => Promise.resolve(provider);
142
- }
143
- const observability = options.observability ?? createNoopProvider();
144
- // Same AppContext buildServer() gets below (db/redis/searchAdapter/
145
- // entityCache/masterKeyProvider/fileProviderResolver/extraContext) —
146
- // shared so the job context can't drift from the request-path context
147
- // (kumiko-framework#1232: a reduced `{ db, registry }` literal let jobs
148
- // pass in tests while reaching for fields only prod's context has).
149
- //
150
- // effectiveFeatures deliberately stays OUT of appContext — prod never puts
151
- // it there either (only dispatcherOptions.effectiveFeatures, consumed by
152
- // the command-dispatcher — see buildJobRunnerWithHook in entrypoint/
153
- // index.ts). Putting it here would reintroduce the exact test-vs-prod
154
- // drift #1232 fixed: a job/handler reading `ctx.effectiveFeatures` would
155
- // pass in tests and break in prod (kumiko-framework#1255).
156
- const appContext = {
157
- db: testDb.db,
158
- redis: testRedis.redis,
159
- searchAdapter,
160
- entityCache,
161
- registry,
162
- ...(options.masterKeyProvider ? { masterKeyProvider: options.masterKeyProvider } : {}),
163
- ...(fileProviderResolver ? { _fileProviderResolver: fileProviderResolver } : {}),
164
- ...(options.defaultLocale !== undefined && { defaultLocale: options.defaultLocale }),
165
- ...(typeof options.extraContext === "function"
166
- ? options.extraContext({ registry, db: testDb.db, sseBroker, redis: testRedis.redis })
167
- : options.extraContext),
168
- };
169
- // Built before buildServer() so it can be merged into dispatcherOptions
170
- // (write handlers' `ctx.jobRunner.dispatch(...)` and the afterCommit
171
- // event-trigger hook both need it present at dispatcher-construction
172
- // time, same as the prod entrypoint's buildJobRunnerWithHook). Uses the
173
- // test-stack's own ephemeral redis — no separate `redisUrl` seam needed.
174
- //
175
- // context = appContext + tracer/meter, mirroring the prod entrypoint's
176
- // `contextWithObservability(options.context, observability)`.
177
- let jobRunner;
178
- // Fed to createJobRunner's onJobComplete/onJobFailed below so drainJobs()
179
- // can tell a job's final outcome apart from an attempt that will retry —
180
- // built before createJobRunner regardless of whether a runner ends up
181
- // existing, so the returned drainJobs() closure always has one to read.
182
- const jobFailureTracker = createJobFailureTracker();
183
- if (options.jobs && registry.getAllJobs().size > 0) {
184
- const runLogger = options.jobs.runLogger?.({ registry, db: testDb.db });
185
- jobRunner = createJobRunner({
186
- registry,
187
- context: { ...appContext, tracer: observability.tracer, meter: observability.meter },
188
- // The real REDIS_URL, not one rebuilt from `.options` — that lost
189
- // password/username/tls/path (pr-review kumiko-framework #1036/2).
190
- redisUrl: testRedis.redisUrl,
191
- ...(options.jobs.consumerLane !== undefined && { consumerLane: options.jobs.consumerLane }),
192
- queueNamePrefix: options.jobs.queueNamePrefix ?? queueNamePrefixForTestRedis(testRedis.keyPrefix),
193
- ...(options.jobs.getActiveTenantIds !== undefined && {
194
- getActiveTenantIds: options.jobs.getActiveTenantIds,
195
- }),
196
- ...(runLogger?.onJobStart !== undefined && { onJobStart: runLogger.onJobStart }),
197
- ...(runLogger?.onJobQueued !== undefined && { onJobQueued: runLogger.onJobQueued }),
198
- ...(runLogger?.onJobDropped !== undefined && { onJobDropped: runLogger.onJobDropped }),
199
- // Tracker fires in finally: drainJobs wakes on it, so the log row must
200
- // be written first, and a throwing logger must not swallow the signal.
201
- onJobComplete: async (jobName, jobId, ...rest) => {
202
- try {
203
- await runLogger?.onJobComplete?.(jobName, jobId, ...rest);
204
- }
205
- finally {
206
- jobFailureTracker.onJobComplete(jobName, jobId);
207
- }
208
- },
209
- onJobFailed: async (jobName, jobId, error, ...rest) => {
210
- try {
211
- await runLogger?.onJobFailed?.(jobName, jobId, error, ...rest);
212
- }
213
- finally {
214
- jobFailureTracker.onJobFailed(jobName, jobId, error);
215
- }
216
- },
217
- });
218
- }
219
- // From here on, any throw must stop a jobRunner that was already created
220
- // above (createJobRunner() itself opens live BullMQ Queue/lock Redis
221
- // connections, .start() adds a live worker) — otherwise a failing
222
- // buildServer()/search-config/etc. leaks that connection and the test
223
- // process hangs on exit (pr-review kumiko-framework #1036/1).
224
- try {
225
- if (jobRunner)
226
- await jobRunner.start();
227
- // Auto-configure search for tenant 1 based on registry
228
- if (enabledHooks.includes("search")) {
229
- const searchableFields = [];
230
- for (const feature of options.features) {
231
- for (const [, entity] of Object.entries(feature.entities ?? {})) {
232
- for (const [fieldName, field] of Object.entries(entity.fields)) {
233
- if (field.type === "text" && field.searchable) {
234
- searchableFields.push(fieldName);
235
- }
236
- if (field.type === "embedded") {
237
- for (const [subName, subField] of Object.entries(field.schema)) {
238
- if (subField.searchable) {
239
- searchableFields.push(`${fieldName}_${subName}`);
240
- }
241
- }
242
- }
243
- }
244
- }
245
- }
246
- if (options.searchConfig) {
247
- await searchAdapter.configure(options.searchConfig.tenantId, {
248
- searchableFields: options.searchConfig.searchableFields,
249
- rankingFields: options.searchConfig.rankingFields,
250
- });
251
- }
252
- else if (searchableFields.length > 0) {
253
- await searchAdapter.configure("00000000-0000-4000-8000-000000000001", {
254
- searchableFields,
255
- rankingFields: searchableFields,
256
- });
257
- }
258
- }
259
- const idempotency = createIdempotencyGuard(testRedis.redis, { ttlSeconds: 60 });
260
- const eventDedup = createEventDedup(testRedis.redis, { ttlSeconds: 60 });
261
- // buildServer only auto-wires an L3 RateLimitResolver from
262
- // `context.redis` — a degradation test that nulls `redis` via
263
- // extraContext (to exercise a handler's own ctx.redis-down guard) also
264
- // silently strips rate-limiting, even though every handler with a
265
- // `rateLimit` declaration still requires a resolver at dispatch time.
266
- // Since e7bf543ad, an IP-keyed bucket key is built unconditionally
267
- // (falls back to a shared "unknown" IP rather than skipping), so the
268
- // missing resolver now throws instead of merely skipping enforcement.
269
- // Mirrors idempotency/eventDedup above: built from the test-stack's own
270
- // always-real `testRedis.redis`, independent of whatever extraContext
271
- // did to `appContext.redis` — an explicit `context.rateLimit` from
272
- // extraContext still wins (same precedence buildServer documents).
273
- const explicitRateLimit = propertyOf(appContext, "rateLimit");
274
- const needsOwnRateLimitResolver = explicitRateLimit === undefined &&
275
- (appContext.redis === undefined || options.rateLimitNowMs !== undefined);
276
- const rateLimitFallback = needsOwnRateLimitResolver
277
- ? createRateLimitResolver({
278
- // An app-supplied redis keeps buckets and app redis on the same instance.
279
- redis: appContext.redis ?? testRedis.redis,
280
- ...(options.rateLimitNowMs ? { nowMs: options.rateLimitNowMs } : {}),
281
- })
282
- : undefined;
283
- const server = buildServer({
284
- registry,
285
- context: rateLimitFallback ? { ...appContext, rateLimit: rateLimitFallback } : appContext,
286
- jwtSecret,
287
- ...(options.observability ? { observability: options.observability } : {}),
288
- ...(options.lifecycleSystemHooks && { systemHooks: options.lifecycleSystemHooks }),
289
- dispatcherOptions: {
290
- idempotency,
291
- cacheSync,
292
- outsideTransactionDb: testDb.outsideTransactionDb,
293
- ...(options.effectiveFeatures && { effectiveFeatures: options.effectiveFeatures }),
294
- ...(jobRunner && { jobRunner }),
295
- },
296
- eventDedup,
297
- sseBroker,
298
- ...(options.trustedProxyHops !== undefined && { trustedProxyHops: options.trustedProxyHops }),
299
- ...(options.errorDocs && { errorDocs: options.errorDocs }),
300
- ...(options.extraRoutes && { extraRoutes: options.extraRoutes }),
301
- ...(options.metrics && { metrics: options.metrics }),
302
- // Tests drive the dispatcher via stack.eventDispatcher.runOnce() for
303
- // deterministic drains — no timer-induced flakiness. pollIntervalMs
304
- // stays short anyway in case a test opts into `.start()`. pgClient
305
- // plumbs through the LISTEN wake-up for tests that want to measure
306
- // post-commit latency (Sprint E.4).
307
- eventDispatcher: {
308
- pollIntervalMs: options.eventDispatcherPollIntervalMs ?? 50,
309
- pgClient: testDb.client,
310
- systemConsumers: {
311
- sse: enabledHooks.includes("sse"),
312
- search: enabledHooks.includes("search"),
313
- accessInvalidation: enabledHooks.includes("sse"),
314
- },
315
- },
316
- // Default tests to no login rate-limiter so existing suites that loop
317
- // over logins don't hit a 429 after 10 attempts. Suites specifically
318
- // testing the limiter can override via authConfig.loginRateLimit.
319
- ...(options.authConfig
320
- ? {
321
- auth: {
322
- ...options.authConfig,
323
- ...(options.authConfig.loginRateLimit === undefined ? { loginRateLimit: null } : {}),
324
- },
325
- }
326
- : {}),
327
- ...(options.lifecycle ? { lifecycle: options.lifecycle } : {}),
328
- ...(options.rateLimit ? { rateLimit: options.rateLimit } : {}),
329
- ...(await (async () => {
330
- const baseAnon = typeof options.anonymousAccess === "function"
331
- ? options.anonymousAccess({
332
- registry,
333
- db: testDb.db,
334
- sseBroker,
335
- redis: testRedis.redis,
336
- })
337
- : options.anonymousAccess;
338
- const resolvedAnon = options.enrichAnonymousAccess
339
- ? await options.enrichAnonymousAccess(baseAnon, {
340
- registry,
341
- db: testDb.db,
342
- })
343
- : baseAnon;
344
- return resolvedAnon ? { anonymousAccess: resolvedAnon } : {};
345
- })()),
346
- });
347
- // Mirrors the prod entrypoints — without this, JobContext.write/writeAs/
348
- // queryAs/queryAsMember throw on first use inside a test-stack job.
349
- if (jobRunner)
350
- jobRunner.attachDispatcher(dispatcherToWriteRef(server.dispatcher));
351
- const eventDispatcher = server.eventDispatcher;
352
- // Pre-register consumer state rows so tests can call runOnce() directly
353
- // without a preceding explicit start(). Timer fires at pollIntervalMs=50
354
- // but each consumer runs at most one turn at a time — tests that drain via
355
- // runOnce() remain deterministic. Tests that specifically exercise the
356
- // timer loop call start() again (idempotent) after setup.
357
- if (eventDispatcher)
358
- await eventDispatcher.ensureRegistered();
359
- const http = createRequestHelper(server.app, server.jwt, {
360
- ...(options.authConfig?.sessionCreator !== undefined && {
361
- sessionCreator: options.authConfig.sessionCreator,
362
- }),
363
- });
364
- return {
365
- app: server.app,
366
- jwt: server.jwt,
367
- registry,
368
- db: testDb.db,
369
- redis: testRedis,
370
- cacheSync,
371
- search: searchAdapter,
372
- events,
373
- http,
374
- observability: server.observability,
375
- sseBroker,
376
- dispatcher: server.dispatcher,
377
- context: server.context,
378
- ...(eventDispatcher ? { eventDispatcher } : {}),
379
- ...(server.lifecycle ? { lifecycle: server.lifecycle } : {}),
380
- ...(jobRunner ? { jobRunner } : {}),
381
- drainJobs: () => drainJobs({ db: testDb.db, eventDispatcher, jobRunner }, jobFailureTracker),
382
- cleanup: async () => {
383
- if (jobRunner)
384
- await jobRunner.stop();
385
- if (eventDispatcher)
386
- await eventDispatcher.stop();
387
- await server.observability.shutdown();
388
- await cacheSync.close();
389
- await Promise.all([testDb.cleanup(), testRedis.cleanup()]);
390
- },
391
- };
392
- }
393
- catch (error) {
394
- // Best-effort — a broken jobRunner.stop()/cleanup() must never mask the
395
- // real setup failure below. testDb/testRedis are created before this
396
- // try even starts, so a throw anywhere in setup (buildServer,
397
- // search-config, ...) would otherwise leak their open pg/ioredis
398
- // sockets — the caller never gets the stack object back to call
399
- // cleanup() itself, which is the same "hangs on exit" failure class
400
- // this function exists to prevent.
401
- if (jobRunner) {
402
- try {
403
- await jobRunner.stop();
404
- }
405
- catch {
406
- // ignore — `error` is the one that matters
407
- }
408
- }
409
- try {
410
- await cacheSync.close();
411
- }
412
- catch {
413
- // ignore — `error` is the one that matters
414
- }
415
- try {
416
- await testDb.cleanup();
417
- }
418
- catch {
419
- // ignore — `error` is the one that matters
420
- }
421
- try {
422
- await testRedis.cleanup();
423
- }
424
- catch {
425
- // ignore — `error` is the one that matters
426
- }
427
- throw error;
428
- }
429
- }
@@ -1,2 +0,0 @@
1
- import type { AccessRule } from "../engine/types/index.js";
2
- export declare function rolesOf(access: AccessRule | undefined): readonly string[];
@@ -1,13 +0,0 @@
1
- // Test-only helper: extracts the role list from a role-based AccessRule,
2
- // narrowing the union safely. Throws when the rule is openToAll or missing —
3
- // the tests that call this always expect roles, and a clear error beats a
4
- // cryptic undefined assertion downstream.
5
- export function rolesOf(access) {
6
- if (!access) {
7
- throw new Error("expected role-based access rule, got undefined");
8
- }
9
- if (!("roles" in access)) {
10
- throw new Error("expected role-based access rule, got openToAll");
11
- }
12
- return access.roles;
13
- }
@@ -1,10 +0,0 @@
1
- import type { WriteResult } from "../engine/types/index.js";
2
- import type { WriteErrorInfo } from "../errors/index.js";
3
- export declare function expectSuccess<T>(result: WriteResult<T>): asserts result is {
4
- isSuccess: true;
5
- data: T;
6
- };
7
- export declare function expectError(result: WriteResult, matcher?: string): asserts result is {
8
- isSuccess: false;
9
- error: WriteErrorInfo;
10
- };
@@ -1,22 +0,0 @@
1
- export function expectSuccess(result) {
2
- if (!result.isSuccess) {
3
- throw new Error(`Expected success but got error: ${result.error.code} (${result.error.message})`);
4
- }
5
- }
6
- // `matcher` checks either the error code (e.g. "not_found") or looks for a
7
- // substring in the message. Both are useful: code matches are stable across
8
- // i18n changes, substrings catch feature-specific detail text.
9
- export function expectError(result, matcher) {
10
- if (result.isSuccess) {
11
- throw new Error("Expected error but got success");
12
- }
13
- if (matcher === undefined) {
14
- // skip: caller only asked for the type narrowing; nothing else to check.
15
- return;
16
- }
17
- const err = result.error;
18
- const hit = err.code === matcher || err.message.includes(matcher);
19
- if (!hit) {
20
- throw new Error(`Expected error code or message to match "${matcher}" but got code="${err.code}", message="${err.message}"`);
21
- }
22
- }
@@ -1,3 +0,0 @@
1
- import type { FeatureDefinition } from "../engine/types/index.js";
2
- /** Test-fixture helper: satisfy entityList + i18n boot rules without bloating every makeFeature. */
3
- export declare function withBootValidatorFixture(features: readonly FeatureDefinition[]): FeatureDefinition[];
@@ -1,101 +0,0 @@
1
- import { SEARCHABLE_FALSE_WHITELIST } from "../engine/boot-validator/entity-list-screens.js";
2
- import { normalizeListColumn } from "../engine/screen-helpers.js";
3
- import { featureHasI18nSurface, requiredKeysFromFeature } from "../i18n/required-surface-keys.js";
4
- function ensureEntityListSortable(feature) {
5
- if (!feature.entities)
6
- return feature;
7
- const entities = { ...feature.entities };
8
- for (const screen of Object.values(feature.screens)) {
9
- if (screen.type !== "entityList")
10
- continue;
11
- const entity = entities[screen.entity];
12
- if (!entity)
13
- continue;
14
- const hasSortable = Object.values(entity.fields).some((field) => "sortable" in field && field.sortable === true);
15
- if (hasSortable)
16
- continue;
17
- const firstCol = screen.columns[0];
18
- if (firstCol === undefined)
19
- continue;
20
- const fieldName = normalizeListColumn(firstCol).field;
21
- const fieldDef = entity.fields[fieldName];
22
- if (fieldDef === undefined)
23
- continue;
24
- if (fieldDef.type !== "text" && fieldDef.type !== "number")
25
- continue;
26
- entities[screen.entity] = {
27
- ...entity,
28
- fields: {
29
- ...entity.fields,
30
- [fieldName]: { ...fieldDef, sortable: true },
31
- },
32
- };
33
- }
34
- return { ...feature, entities };
35
- }
36
- function ensureEntityListRowNavigation(feature, screen) {
37
- const hasEdit = Object.values(feature.screens).some((s) => s.type === "entityEdit" && s.entity === screen.entity);
38
- if (!hasEdit)
39
- return screen;
40
- const hasNavigate = (screen.rowActions ?? []).some((a) => a.kind === "navigate" && (a.id === "view" || a.id === "edit"));
41
- if (hasNavigate)
42
- return screen;
43
- const editScreen = Object.values(feature.screens).find((s) => s.type === "entityEdit" && s.entity === screen.entity);
44
- if (editScreen === undefined)
45
- return screen;
46
- return {
47
- ...screen,
48
- rowActions: [
49
- ...(screen.rowActions ?? []),
50
- // No `params` here: this same-entity target always resolves to
51
- // UPDATE mode (the renderer auto-fills entityId from row["id"]),
52
- // and entityEdit-update never reads search-params as initial
53
- // values — the boot-validator now rejects that combination (#1680).
54
- {
55
- kind: "navigate",
56
- id: "edit",
57
- label: "stub:edit",
58
- screen: editScreen.id,
59
- },
60
- ],
61
- };
62
- }
63
- function defaultSortForEntityList(feature, screen) {
64
- if (screen.defaultSort !== undefined || screen.searchable === false)
65
- return screen;
66
- if (SEARCHABLE_FALSE_WHITELIST.has(screen.id))
67
- return screen;
68
- const entity = feature.entities?.[screen.entity];
69
- const sortableColumn = screen.columns.map(normalizeListColumn).find((col) => {
70
- const fieldDef = entity?.fields[col.field];
71
- return fieldDef !== undefined && "sortable" in fieldDef && fieldDef.sortable === true;
72
- });
73
- if (sortableColumn === undefined)
74
- return screen;
75
- return { ...screen, defaultSort: { field: sortableColumn.field, dir: "asc" } };
76
- }
77
- function stubTranslations(feature) {
78
- if (!featureHasI18nSurface(feature))
79
- return feature;
80
- const keys = {
81
- ...Object.fromEntries(Object.entries(feature.translations ?? {}).map(([key, value]) => [key, { ...value }])),
82
- };
83
- for (const key of requiredKeysFromFeature(feature)) {
84
- if (keys[key] === undefined)
85
- keys[key] = { de: "stub", en: "stub" };
86
- }
87
- return { ...feature, translations: keys };
88
- }
89
- /** Test-fixture helper: satisfy entityList + i18n boot rules without bloating every makeFeature. */
90
- export function withBootValidatorFixture(features) {
91
- return features.map((feature) => {
92
- const sortable = ensureEntityListSortable(feature);
93
- const screens = Object.fromEntries(Object.entries(sortable.screens).map(([id, screen]) => {
94
- if (screen.type !== "entityList")
95
- return [id, screen];
96
- const withDefaults = defaultSortForEntityList(sortable, screen);
97
- return [id, ensureEntityListRowNavigation(sortable, withDefaults)];
98
- }));
99
- return stubTranslations({ ...sortable, screens });
100
- });
101
- }
@@ -1,7 +0,0 @@
1
- export declare function testDatabaseUrl(): string;
2
- type AdminClient = {
3
- unsafe(sql: string, params?: readonly unknown[]): Promise<unknown>;
4
- };
5
- export declare function terminateBackendsByApplicationName(admin: AdminClient, applicationName: string): Promise<void>;
6
- export declare function captureClosedConnectionError(url?: string): Promise<unknown>;
7
- export {};
@@ -1,46 +0,0 @@
1
- // Captures a real closed-connection error instead of a hand-built fake —
2
- // the production matcher checks driver-specific codes a fake can't reproduce.
3
- import postgres from "postgres";
4
- import { isClosedConnectionError } from "../bun-db/query.js";
5
- import { requireEnv } from "../stack/db.js";
6
- export function testDatabaseUrl() {
7
- return requireEnv("TEST_DATABASE_URL");
8
- }
9
- const MAX_WARMUP_ROUNDS = 10;
10
- const READS_PER_ROUND = 5;
11
- // A per-round admin client hits a postgres-js reconnect timing bug (the first
12
- // retry after terminate hangs); a single long-lived admin client avoids it.
13
- export async function terminateBackendsByApplicationName(admin, applicationName) {
14
- await admin.unsafe("select pg_terminate_backend(pid) from pg_stat_activity where application_name = $1", [applicationName]);
15
- }
16
- // Terminates a throwaway pool's backends and races reads right after — the
17
- // dead-connection window is only a few ms wide, so this retries rounds until one lands.
18
- export async function captureClosedConnectionError(url = testDatabaseUrl()) {
19
- const admin = postgres(url, { max: 1 });
20
- try {
21
- for (let round = 0; round < MAX_WARMUP_ROUNDS; round++) {
22
- const applicationName = `kumiko-closed-conn-test-${crypto.randomUUID()}`;
23
- const pool = postgres(url, { max: 1, connection: { application_name: applicationName } });
24
- try {
25
- await pool.unsafe("select 1");
26
- await terminateBackendsByApplicationName(admin, applicationName);
27
- for (let read = 0; read < READS_PER_ROUND; read++) {
28
- try {
29
- await pool.unsafe("select 1");
30
- }
31
- catch (err) {
32
- if (isClosedConnectionError(err))
33
- return err;
34
- }
35
- }
36
- }
37
- finally {
38
- await pool.end({ timeout: 0 });
39
- }
40
- }
41
- throw new Error(`captureClosedConnectionError: no closed-connection error observed after ${MAX_WARMUP_ROUNDS} rounds.`);
42
- }
43
- finally {
44
- await admin.end({ timeout: 0 });
45
- }
46
- }
@@ -1,9 +0,0 @@
1
- import { type AnyDb } from "../db/query.js";
2
- /** EntityTableMeta, a built table, or a plain table name string. */
3
- export type ClearableTable = string | {
4
- readonly tableName?: string;
5
- } | unknown;
6
- /** Delete all rows from each table (order preserved — FK-sensitive callers order explicitly). */
7
- export declare function clearTables(db: AnyDb, tables: readonly ClearableTable[]): Promise<void>;
8
- /** Alias — same as clearTables, reads better in beforeEach. */
9
- export declare function resetTestTables(db: AnyDb, tables: readonly ClearableTable[]): Promise<void>;