@cosmicdrift/kumiko-framework 0.153.0 → 0.154.1

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 (31) hide show
  1. package/package.json +2 -2
  2. package/src/__tests__/anonymous-access.integration.test.ts +25 -0
  3. package/src/engine/__tests__/boot-validator-action-wiring.test.ts +242 -0
  4. package/src/engine/__tests__/boot-validator.test.ts +7 -4
  5. package/src/engine/__tests__/event-migration-declarative.test.ts +9 -2
  6. package/src/engine/boot-validator/action-wiring.ts +99 -0
  7. package/src/engine/boot-validator/index.ts +7 -0
  8. package/src/engine/feature-ast/__tests__/canonical-form.test.ts +7 -21
  9. package/src/engine/feature-ast/__tests__/parse.test.ts +33 -6
  10. package/src/engine/feature-ast/__tests__/patch.test.ts +8 -13
  11. package/src/engine/feature-ast/__tests__/patcher.test.ts +4 -14
  12. package/src/engine/feature-ast/__tests__/render-roundtrip.test.ts +37 -1
  13. package/src/engine/feature-ast/extractors/index.ts +0 -1
  14. package/src/engine/feature-ast/extractors/round4.ts +92 -136
  15. package/src/engine/feature-ast/index.ts +0 -2
  16. package/src/engine/feature-ast/parse.ts +0 -3
  17. package/src/engine/feature-ast/patch.ts +0 -41
  18. package/src/engine/feature-ast/patcher.ts +14 -20
  19. package/src/engine/feature-ast/patterns.ts +10 -19
  20. package/src/engine/feature-ast/render.ts +10 -13
  21. package/src/engine/feature-config-events-jobs.ts +77 -51
  22. package/src/engine/pattern-library/__tests__/library.test.ts +0 -10
  23. package/src/engine/pattern-library/library.ts +0 -2
  24. package/src/engine/pattern-library/mixed-schemas.ts +8 -35
  25. package/src/engine/registry-validate.ts +6 -6
  26. package/src/engine/types/feature.ts +18 -17
  27. package/src/engine/types/handlers.ts +6 -3
  28. package/src/event-store/__tests__/upcaster.integration.test.ts +77 -45
  29. package/src/pipeline/__tests__/load-aggregate-query.integration.test.ts +16 -8
  30. package/src/stack/redis.ts +8 -0
  31. package/src/stack/test-stack.ts +156 -136
@@ -237,167 +237,187 @@ export async function setupTestStack(options: TestStackOptions): Promise<TestSta
237
237
  // test-stack's own ephemeral redis — no separate `redisUrl` seam needed.
238
238
  let jobRunner: JobRunner | undefined;
239
239
  if (options.jobs && registry.getAllJobs().size > 0) {
240
- const redisOpts = testRedis.redis.options;
241
240
  jobRunner = createJobRunner({
242
241
  registry,
243
242
  context: { db: testDb.db, registry },
244
- redisUrl: `redis://${redisOpts.host}:${redisOpts.port}/${redisOpts.db}`,
243
+ // The real REDIS_URL, not one rebuilt from `.options` — that lost
244
+ // password/username/tls/path (pr-review kumiko-framework #1036/2).
245
+ redisUrl: testRedis.redisUrl,
245
246
  ...(options.jobs.consumerLane !== undefined && { consumerLane: options.jobs.consumerLane }),
246
247
  ...(options.jobs.queueNamePrefix !== undefined && {
247
248
  queueNamePrefix: options.jobs.queueNamePrefix,
248
249
  }),
249
250
  });
250
- await jobRunner.start();
251
251
  }
252
252
 
253
- // Auto-configure search for tenant 1 based on registry
254
- if (enabledHooks.includes("search")) {
255
- const searchableFields: string[] = [];
256
- for (const feature of options.features) {
257
- for (const [, entity] of Object.entries(feature.entities ?? {})) {
258
- for (const [fieldName, field] of Object.entries(entity.fields)) {
259
- if (field.type === "text" && field.searchable) {
260
- searchableFields.push(fieldName);
261
- }
262
- if (field.type === "embedded") {
263
- for (const [subName, subField] of Object.entries(field.schema)) {
264
- if (subField.searchable) {
265
- searchableFields.push(`${fieldName}_${subName}`);
253
+ // From here on, any throw must stop a jobRunner that was already created
254
+ // above (createJobRunner() itself opens live BullMQ Queue/lock Redis
255
+ // connections, .start() adds a live worker) — otherwise a failing
256
+ // buildServer()/search-config/etc. leaks that connection and the test
257
+ // process hangs on exit (pr-review kumiko-framework #1036/1).
258
+ try {
259
+ if (jobRunner) await jobRunner.start();
260
+
261
+ // Auto-configure search for tenant 1 based on registry
262
+ if (enabledHooks.includes("search")) {
263
+ const searchableFields: string[] = [];
264
+ for (const feature of options.features) {
265
+ for (const [, entity] of Object.entries(feature.entities ?? {})) {
266
+ for (const [fieldName, field] of Object.entries(entity.fields)) {
267
+ if (field.type === "text" && field.searchable) {
268
+ searchableFields.push(fieldName);
269
+ }
270
+ if (field.type === "embedded") {
271
+ for (const [subName, subField] of Object.entries(field.schema)) {
272
+ if (subField.searchable) {
273
+ searchableFields.push(`${fieldName}_${subName}`);
274
+ }
266
275
  }
267
276
  }
268
277
  }
269
278
  }
270
279
  }
271
- }
272
280
 
273
- if (options.searchConfig) {
274
- await searchAdapter.configure(options.searchConfig.tenantId, {
275
- searchableFields: options.searchConfig.searchableFields,
276
- rankingFields: options.searchConfig.rankingFields,
277
- });
278
- } else if (searchableFields.length > 0) {
279
- await searchAdapter.configure("00000000-0000-4000-8000-000000000001", {
280
- searchableFields,
281
- rankingFields: searchableFields,
282
- });
281
+ if (options.searchConfig) {
282
+ await searchAdapter.configure(options.searchConfig.tenantId, {
283
+ searchableFields: options.searchConfig.searchableFields,
284
+ rankingFields: options.searchConfig.rankingFields,
285
+ });
286
+ } else if (searchableFields.length > 0) {
287
+ await searchAdapter.configure("00000000-0000-4000-8000-000000000001", {
288
+ searchableFields,
289
+ rankingFields: searchableFields,
290
+ });
291
+ }
283
292
  }
284
- }
285
293
 
286
- // Wire SSE broker with event collector
287
- const sseBroker = createSseBroker();
288
- sseBroker.addClient(
289
- "tenant:00000000-0000-4000-8000-000000000001",
290
- (event) => events.sse.push(event),
291
- () => {},
292
- );
294
+ // Wire SSE broker with event collector
295
+ const sseBroker = createSseBroker();
296
+ sseBroker.addClient(
297
+ "tenant:00000000-0000-4000-8000-000000000001",
298
+ (event) => events.sse.push(event),
299
+ () => {},
300
+ );
293
301
 
294
- const idempotency = createIdempotencyGuard(testRedis.redis, { ttlSeconds: 60 });
295
- const eventDedup = createEventDedup(testRedis.redis, { ttlSeconds: 60 });
296
- const entityCache = createEntityCache(testRedis.redis, { ttlSeconds: 60 });
302
+ const idempotency = createIdempotencyGuard(testRedis.redis, { ttlSeconds: 60 });
303
+ const eventDedup = createEventDedup(testRedis.redis, { ttlSeconds: 60 });
304
+ const entityCache = createEntityCache(testRedis.redis, { ttlSeconds: 60 });
297
305
 
298
- // A static `files.storageProvider` is wired as the per-tenant resolver — the
299
- // framework test seam that doesn't require mounting config + file-foundation.
300
- // (Bundled GDPR tests mount the real provider features instead.)
301
- let fileProviderResolver: import("../files").FileProviderResolver | undefined;
302
- if (options.files) {
303
- const provider = options.files.storageProvider;
304
- fileProviderResolver = () => Promise.resolve(provider);
305
- }
306
+ // A static `files.storageProvider` is wired as the per-tenant resolver — the
307
+ // framework test seam that doesn't require mounting config + file-foundation.
308
+ // (Bundled GDPR tests mount the real provider features instead.)
309
+ let fileProviderResolver: import("../files").FileProviderResolver | undefined;
310
+ if (options.files) {
311
+ const provider = options.files.storageProvider;
312
+ fileProviderResolver = () => Promise.resolve(provider);
313
+ }
306
314
 
307
- const server = buildServer({
308
- registry,
309
- context: {
310
- db: testDb.db,
311
- redis: testRedis.redis,
312
- searchAdapter,
313
- entityCache,
315
+ const server = buildServer({
314
316
  registry,
315
- ...(options.masterKeyProvider ? { masterKeyProvider: options.masterKeyProvider } : {}),
316
- ...(fileProviderResolver ? { _fileProviderResolver: fileProviderResolver } : {}),
317
- ...(typeof options.extraContext === "function"
318
- ? options.extraContext({ registry, db: testDb.db, sseBroker, redis: testRedis.redis })
319
- : options.extraContext),
320
- },
321
- jwtSecret,
322
- dispatcherOptions: {
323
- idempotency,
324
- ...(options.effectiveFeatures && { effectiveFeatures: options.effectiveFeatures }),
325
- ...(jobRunner && { jobRunner }),
326
- },
327
- eventDedup,
328
- sseBroker,
329
- // Tests drive the dispatcher via stack.eventDispatcher.runOnce() for
330
- // deterministic drains — no timer-induced flakiness. pollIntervalMs
331
- // stays short anyway in case a test opts into `.start()`. pgClient
332
- // plumbs through the LISTEN wake-up for tests that want to measure
333
- // post-commit latency (Sprint E.4).
334
- eventDispatcher: {
335
- pollIntervalMs: 50,
336
- pgClient: testDb.client as PgClient | undefined,
337
- systemConsumers: {
338
- sse: enabledHooks.includes("sse"),
339
- search: enabledHooks.includes("search"),
317
+ context: {
318
+ db: testDb.db,
319
+ redis: testRedis.redis,
320
+ searchAdapter,
321
+ entityCache,
322
+ registry,
323
+ ...(options.masterKeyProvider ? { masterKeyProvider: options.masterKeyProvider } : {}),
324
+ ...(fileProviderResolver ? { _fileProviderResolver: fileProviderResolver } : {}),
325
+ ...(typeof options.extraContext === "function"
326
+ ? options.extraContext({ registry, db: testDb.db, sseBroker, redis: testRedis.redis })
327
+ : options.extraContext),
340
328
  },
341
- },
342
- // Default tests to no login rate-limiter so existing suites that loop
343
- // over logins don't hit a 429 after 10 attempts. Suites specifically
344
- // testing the limiter can override via authConfig.loginRateLimit.
345
- ...(options.authConfig
346
- ? {
347
- auth: {
348
- ...options.authConfig,
349
- ...(options.authConfig.loginRateLimit === undefined ? { loginRateLimit: null } : {}),
350
- },
351
- }
352
- : {}),
353
- ...(options.observability ? { observability: options.observability } : {}),
354
- ...(options.lifecycle ? { lifecycle: options.lifecycle } : {}),
355
- ...(options.rateLimit ? { rateLimit: options.rateLimit } : {}),
356
- ...(options.anonymousAccess
357
- ? {
358
- anonymousAccess:
359
- typeof options.anonymousAccess === "function"
360
- ? options.anonymousAccess({
361
- registry,
362
- db: testDb.db,
363
- sseBroker,
364
- redis: testRedis.redis,
365
- })
366
- : options.anonymousAccess,
367
- }
368
- : {}),
369
- });
329
+ jwtSecret,
330
+ dispatcherOptions: {
331
+ idempotency,
332
+ ...(options.effectiveFeatures && { effectiveFeatures: options.effectiveFeatures }),
333
+ ...(jobRunner && { jobRunner }),
334
+ },
335
+ eventDedup,
336
+ sseBroker,
337
+ // Tests drive the dispatcher via stack.eventDispatcher.runOnce() for
338
+ // deterministic drains — no timer-induced flakiness. pollIntervalMs
339
+ // stays short anyway in case a test opts into `.start()`. pgClient
340
+ // plumbs through the LISTEN wake-up for tests that want to measure
341
+ // post-commit latency (Sprint E.4).
342
+ eventDispatcher: {
343
+ pollIntervalMs: 50,
344
+ pgClient: testDb.client as PgClient | undefined,
345
+ systemConsumers: {
346
+ sse: enabledHooks.includes("sse"),
347
+ search: enabledHooks.includes("search"),
348
+ },
349
+ },
350
+ // Default tests to no login rate-limiter so existing suites that loop
351
+ // over logins don't hit a 429 after 10 attempts. Suites specifically
352
+ // testing the limiter can override via authConfig.loginRateLimit.
353
+ ...(options.authConfig
354
+ ? {
355
+ auth: {
356
+ ...options.authConfig,
357
+ ...(options.authConfig.loginRateLimit === undefined ? { loginRateLimit: null } : {}),
358
+ },
359
+ }
360
+ : {}),
361
+ ...(options.observability ? { observability: options.observability } : {}),
362
+ ...(options.lifecycle ? { lifecycle: options.lifecycle } : {}),
363
+ ...(options.rateLimit ? { rateLimit: options.rateLimit } : {}),
364
+ ...(options.anonymousAccess
365
+ ? {
366
+ anonymousAccess:
367
+ typeof options.anonymousAccess === "function"
368
+ ? options.anonymousAccess({
369
+ registry,
370
+ db: testDb.db,
371
+ sseBroker,
372
+ redis: testRedis.redis,
373
+ })
374
+ : options.anonymousAccess,
375
+ }
376
+ : {}),
377
+ });
370
378
 
371
- const eventDispatcher: EventDispatcher | undefined = server.eventDispatcher;
379
+ const eventDispatcher: EventDispatcher | undefined = server.eventDispatcher;
372
380
 
373
- // Pre-register consumer state rows so tests can call runOnce() directly
374
- // without a preceding explicit start(). Timer fires at pollIntervalMs=50
375
- // but passInFlight serialises concurrent passes — tests that drain via
376
- // runOnce() remain deterministic. Tests that specifically exercise the
377
- // timer loop call start() again (idempotent) after setup.
378
- if (eventDispatcher) await eventDispatcher.ensureRegistered();
381
+ // Pre-register consumer state rows so tests can call runOnce() directly
382
+ // without a preceding explicit start(). Timer fires at pollIntervalMs=50
383
+ // but passInFlight serialises concurrent passes — tests that drain via
384
+ // runOnce() remain deterministic. Tests that specifically exercise the
385
+ // timer loop call start() again (idempotent) after setup.
386
+ if (eventDispatcher) await eventDispatcher.ensureRegistered();
379
387
 
380
- const http = createRequestHelper(server.app, server.jwt);
388
+ const http = createRequestHelper(server.app, server.jwt);
381
389
 
382
- return {
383
- app: server.app,
384
- jwt: server.jwt,
385
- registry,
386
- db: testDb.db,
387
- redis: testRedis,
388
- search: searchAdapter,
389
- events,
390
- http,
391
- observability: server.observability,
392
- dispatcher: server.dispatcher,
393
- ...(eventDispatcher ? { eventDispatcher } : {}),
394
- ...(server.lifecycle ? { lifecycle: server.lifecycle } : {}),
395
- ...(jobRunner ? { jobRunner } : {}),
396
- cleanup: async () => {
397
- if (jobRunner) await jobRunner.stop();
398
- if (eventDispatcher) await eventDispatcher.stop();
399
- await server.observability.shutdown();
400
- await Promise.all([testDb.cleanup(), testRedis.cleanup()]);
401
- },
402
- };
390
+ return {
391
+ app: server.app,
392
+ jwt: server.jwt,
393
+ registry,
394
+ db: testDb.db,
395
+ redis: testRedis,
396
+ search: searchAdapter,
397
+ events,
398
+ http,
399
+ observability: server.observability,
400
+ dispatcher: server.dispatcher,
401
+ ...(eventDispatcher ? { eventDispatcher } : {}),
402
+ ...(server.lifecycle ? { lifecycle: server.lifecycle } : {}),
403
+ ...(jobRunner ? { jobRunner } : {}),
404
+ cleanup: async () => {
405
+ if (jobRunner) await jobRunner.stop();
406
+ if (eventDispatcher) await eventDispatcher.stop();
407
+ await server.observability.shutdown();
408
+ await Promise.all([testDb.cleanup(), testRedis.cleanup()]);
409
+ },
410
+ };
411
+ } catch (error) {
412
+ // Best-effort — a broken jobRunner.stop() must never mask the real
413
+ // setup failure below.
414
+ if (jobRunner) {
415
+ try {
416
+ await jobRunner.stop();
417
+ } catch {
418
+ // ignore — `error` is the one that matters
419
+ }
420
+ }
421
+ throw error;
422
+ }
403
423
  }