@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.
- package/package.json +2 -2
- package/src/__tests__/anonymous-access.integration.test.ts +25 -0
- package/src/engine/__tests__/boot-validator-action-wiring.test.ts +242 -0
- package/src/engine/__tests__/boot-validator.test.ts +7 -4
- package/src/engine/__tests__/event-migration-declarative.test.ts +9 -2
- package/src/engine/boot-validator/action-wiring.ts +99 -0
- package/src/engine/boot-validator/index.ts +7 -0
- package/src/engine/feature-ast/__tests__/canonical-form.test.ts +7 -21
- package/src/engine/feature-ast/__tests__/parse.test.ts +33 -6
- package/src/engine/feature-ast/__tests__/patch.test.ts +8 -13
- package/src/engine/feature-ast/__tests__/patcher.test.ts +4 -14
- package/src/engine/feature-ast/__tests__/render-roundtrip.test.ts +37 -1
- package/src/engine/feature-ast/extractors/index.ts +0 -1
- package/src/engine/feature-ast/extractors/round4.ts +92 -136
- package/src/engine/feature-ast/index.ts +0 -2
- package/src/engine/feature-ast/parse.ts +0 -3
- package/src/engine/feature-ast/patch.ts +0 -41
- package/src/engine/feature-ast/patcher.ts +14 -20
- package/src/engine/feature-ast/patterns.ts +10 -19
- package/src/engine/feature-ast/render.ts +10 -13
- package/src/engine/feature-config-events-jobs.ts +77 -51
- package/src/engine/pattern-library/__tests__/library.test.ts +0 -10
- package/src/engine/pattern-library/library.ts +0 -2
- package/src/engine/pattern-library/mixed-schemas.ts +8 -35
- package/src/engine/registry-validate.ts +6 -6
- package/src/engine/types/feature.ts +18 -17
- package/src/engine/types/handlers.ts +6 -3
- package/src/event-store/__tests__/upcaster.integration.test.ts +77 -45
- package/src/pipeline/__tests__/load-aggregate-query.integration.test.ts +16 -8
- package/src/stack/redis.ts +8 -0
- package/src/stack/test-stack.ts +156 -136
package/src/stack/test-stack.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
//
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
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
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
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
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
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
|
-
|
|
295
|
-
|
|
296
|
-
|
|
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
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
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
|
-
|
|
308
|
-
registry,
|
|
309
|
-
context: {
|
|
310
|
-
db: testDb.db,
|
|
311
|
-
redis: testRedis.redis,
|
|
312
|
-
searchAdapter,
|
|
313
|
-
entityCache,
|
|
315
|
+
const server = buildServer({
|
|
314
316
|
registry,
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
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
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
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
|
-
|
|
379
|
+
const eventDispatcher: EventDispatcher | undefined = server.eventDispatcher;
|
|
372
380
|
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
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
|
-
|
|
388
|
+
const http = createRequestHelper(server.app, server.jwt);
|
|
381
389
|
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
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
|
}
|