@cosmicdrift/kumiko-dev-server 0.125.2 → 0.127.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-dev-server",
3
- "version": "0.125.2",
3
+ "version": "0.127.0",
4
4
  "description": "Development server bootstrap for Kumiko apps. Bundles the client, mints dev-JWTs, injects the resolved AppSchema, and seeds an admin. Not for production.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -50,8 +50,8 @@
50
50
  "kumiko-schema-check": "./bin/kumiko-schema-check.ts"
51
51
  },
52
52
  "dependencies": {
53
- "@cosmicdrift/kumiko-bundled-features": "0.125.2",
54
- "@cosmicdrift/kumiko-framework": "0.125.2",
53
+ "@cosmicdrift/kumiko-bundled-features": "0.127.0",
54
+ "@cosmicdrift/kumiko-framework": "0.127.0",
55
55
  "ts-morph": "^28.0.0"
56
56
  },
57
57
  "publishConfig": {
@@ -0,0 +1,26 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { createJobsFeature } from "@cosmicdrift/kumiko-bundled-features/jobs";
3
+ import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
4
+ import { createRegistry, defineFeature } from "@cosmicdrift/kumiko-framework/engine";
5
+ import { jobRunLoggerCallbacks } from "../job-run-logger";
6
+
7
+ // ponytail: callbacks shape only — no DB I/O; createTestDb needs TEST_DATABASE_URL (CI unit job has none).
8
+ const mockDb = {} as DbConnection;
9
+
10
+ describe("jobRunLoggerCallbacks", () => {
11
+ test("returns undefined when jobs feature is not registered", () => {
12
+ const registry = createRegistry([
13
+ defineFeature("empty", () => ({ handlers: {}, queries: {} })),
14
+ ]);
15
+ expect(jobRunLoggerCallbacks(registry, mockDb)).toBeUndefined();
16
+ });
17
+
18
+ test("returns logger callbacks when jobs feature is registered", () => {
19
+ const registry = createRegistry([createJobsFeature()]);
20
+ const callbacks = jobRunLoggerCallbacks(registry, mockDb);
21
+ expect(callbacks).toBeDefined();
22
+ expect(typeof callbacks?.onJobStart).toBe("function");
23
+ expect(typeof callbacks?.onJobComplete).toBe("function");
24
+ expect(typeof callbacks?.onJobFailed).toBe("function");
25
+ });
26
+ });
@@ -0,0 +1,51 @@
1
+ import { createJobRunLogger } from "@cosmicdrift/kumiko-bundled-features/jobs";
2
+ import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
3
+ import type { Registry } from "@cosmicdrift/kumiko-framework/engine";
4
+ import type { JobRunIn } from "@cosmicdrift/kumiko-framework/engine/types";
5
+ import { createJobRunner, type JobRunner } from "@cosmicdrift/kumiko-framework/jobs";
6
+
7
+ export function jobRunLoggerCallbacks(
8
+ registry: Registry,
9
+ db: DbConnection,
10
+ ): ReturnType<typeof createJobRunLogger> | undefined {
11
+ if (registry.getFeature("jobs") === undefined) return undefined;
12
+ return createJobRunLogger({ db, registry });
13
+ }
14
+
15
+ /** Dev-server parity: consume api + worker lanes when jobs are registered. */
16
+ export async function startDevJobRunners(opts: {
17
+ readonly registry: Registry;
18
+ readonly db: DbConnection;
19
+ readonly context: Record<string, unknown>;
20
+ readonly redisUrl: string;
21
+ }): Promise<{ readonly runners: readonly JobRunner[]; readonly stop: () => Promise<void> }> {
22
+ const jobs = [...opts.registry.getAllJobs().values()];
23
+ if (opts.registry.getFeature("jobs") === undefined || jobs.length === 0) {
24
+ return { runners: [], stop: async () => {} };
25
+ }
26
+
27
+ const logger = createJobRunLogger({ db: opts.db, registry: opts.registry });
28
+ const runners: JobRunner[] = [];
29
+ const lanes = new Set(
30
+ jobs.map((j) => j.runIn).filter((lane): lane is JobRunIn => lane !== undefined),
31
+ );
32
+
33
+ for (const lane of lanes) {
34
+ const jr = createJobRunner({
35
+ registry: opts.registry,
36
+ context: { ...opts.context, db: opts.db },
37
+ redisUrl: opts.redisUrl,
38
+ consumerLane: lane,
39
+ ...logger,
40
+ });
41
+ await jr.start();
42
+ runners.push(jr);
43
+ }
44
+
45
+ return {
46
+ runners,
47
+ stop: async () => {
48
+ for (const runner of runners) await runner.stop();
49
+ },
50
+ };
51
+ }
@@ -29,6 +29,7 @@ import {
29
29
  type TestStackOptions,
30
30
  TestUsers,
31
31
  } from "@cosmicdrift/kumiko-framework/stack";
32
+ import { startDevJobRunners } from "./boot/job-run-logger";
32
33
  import { type ExtraRoutesSystemDeps, makeDispatchSystemWrite } from "./extra-routes-deps";
33
34
  import { injectSchema } from "./inject-schema";
34
35
  import { canResolveTailwindStylesheet, resolveTailwindCli } from "./resolve-tailwind-cli";
@@ -707,6 +708,14 @@ export async function createKumikoServer(
707
708
  await stack.eventDispatcher.start();
708
709
  }
709
710
 
711
+ const redisUrl = `redis://${stack.redis.redis.options.host}:${stack.redis.redis.options.port}/${stack.redis.redis.options.db}`;
712
+ const devJobRunners = await startDevJobRunners({
713
+ registry: stack.registry,
714
+ db: stack.db,
715
+ context: { db: stack.db, registry: stack.registry },
716
+ redisUrl,
717
+ });
718
+
710
719
  // Dev user = TestUsers.admin. Demo features are openToAll but the
711
720
  // auth-middleware still needs a valid JWT to let the request past.
712
721
  // Nicht genutzt wenn `options.auth` gesetzt ist — dann macht der Client
@@ -977,6 +986,7 @@ export async function createKumikoServer(
977
986
  if (stack.eventDispatcher) {
978
987
  await stack.eventDispatcher.stop();
979
988
  }
989
+ await devJobRunners.stop();
980
990
  await stack.cleanup();
981
991
  };
982
992
 
@@ -35,6 +35,10 @@ import {
35
35
  type SessionCallbacks,
36
36
  } from "@cosmicdrift/kumiko-bundled-features/sessions";
37
37
  import { TenantQueries } from "@cosmicdrift/kumiko-bundled-features/tenant";
38
+ import {
39
+ resolveTenantLifecycleGate,
40
+ TENANT_LIFECYCLE_FEATURE,
41
+ } from "@cosmicdrift/kumiko-bundled-features/tenant-lifecycle";
38
42
  import type { PatResolver, SessionMetadata } from "@cosmicdrift/kumiko-framework/api";
39
43
  import { createInMemoryLoginRateLimiter } from "@cosmicdrift/kumiko-framework/api";
40
44
  import {
@@ -42,6 +46,7 @@ import {
42
46
  configurePiiSubjectKms,
43
47
  type KmsAdapter,
44
48
  } from "@cosmicdrift/kumiko-framework/crypto";
49
+ import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
45
50
  import { configureEntityFieldEncryption } from "@cosmicdrift/kumiko-framework/db";
46
51
  import {
47
52
  collectWriteHandlerQns,
@@ -396,7 +401,9 @@ export async function runDevApp(options: RunDevAppOptions): Promise<KumikoServer
396
401
  // db (only concrete after setupTestStack). Wired when the feature is mounted;
397
402
  // scopes come from the feature's exports (single source with its handlers).
398
403
  let patResolver: PatResolver | undefined;
404
+ let lifecycleDb: DbConnection | undefined;
399
405
  const patFeature = features.find((f) => f.name === PAT_FEATURE);
406
+ const tenantLifecycleFeature = features.find((f) => f.name === TENANT_LIFECYCLE_FEATURE);
400
407
  const patAuthFragment = patFeature
401
408
  ? {
402
409
  patResolver: (rawToken: string) => {
@@ -412,6 +419,18 @@ export async function runDevApp(options: RunDevAppOptions): Promise<KumikoServer
412
419
  }
413
420
  : {};
414
421
 
422
+ const tenantLifecycleAuthFragment = tenantLifecycleFeature
423
+ ? {
424
+ resolveTenantLifecycleStatus: async (tenantId: string) => {
425
+ if (!lifecycleDb) {
426
+ throw new Error("[runDevApp] tenant-lifecycle gate accessed before onAfterSetup");
427
+ }
428
+ const gate = await resolveTenantLifecycleGate(lifecycleDb, tenantId);
429
+ return gate ? { status: gate.status } : null;
430
+ },
431
+ }
432
+ : {};
433
+
415
434
  const sessionAuthFragment =
416
435
  effectiveAuth?.sessions !== undefined
417
436
  ? {
@@ -466,6 +485,7 @@ export async function runDevApp(options: RunDevAppOptions): Promise<KumikoServer
466
485
  }),
467
486
  ...sessionAuthFragment,
468
487
  ...patAuthFragment,
488
+ ...tenantLifecycleAuthFragment,
469
489
  ...(effectiveAuth.passwordReset && {
470
490
  passwordReset: {
471
491
  requestHandler: AuthHandlers.requestPasswordReset,
@@ -494,6 +514,7 @@ export async function runDevApp(options: RunDevAppOptions): Promise<KumikoServer
494
514
  },
495
515
  }),
496
516
  onAfterSetup: async (stack) => {
517
+ lifecycleDb = stack.db;
497
518
  // Sprint-8a: build tier-resolver BEFORE any seeds so seeds can rely
498
519
  // on the resolver being live (e.g. seed that writes a SystemAdmin's
499
520
  // tier-assignment can immediately read tier-cuts).
@@ -66,6 +66,10 @@ import {
66
66
  SESSIONS_FEATURE,
67
67
  } from "@cosmicdrift/kumiko-bundled-features/sessions";
68
68
  import { TenantQueries } from "@cosmicdrift/kumiko-bundled-features/tenant";
69
+ import {
70
+ resolveTenantLifecycleGate,
71
+ TENANT_LIFECYCLE_FEATURE,
72
+ } from "@cosmicdrift/kumiko-bundled-features/tenant-lifecycle";
69
73
  import { createTextContentApi } from "@cosmicdrift/kumiko-bundled-features/text-content";
70
74
  import { UserQueries } from "@cosmicdrift/kumiko-bundled-features/user";
71
75
  import {
@@ -134,6 +138,7 @@ import { warnIfNonUtcServerTimeZone } from "@cosmicdrift/kumiko-framework/time";
134
138
  import Redis from "ioredis";
135
139
  import { applyBootSeeds } from "./boot/apply-boot-seeds";
136
140
  import { type BootCrypto, resolveBootCrypto } from "./boot/boot-crypto";
141
+ import { jobRunLoggerCallbacks } from "./boot/job-run-logger";
137
142
  import { ASSETS_DIR } from "./build-prod-bundle";
138
143
  import { buildComposeAuthOptions, composeFeatures } from "./compose-features";
139
144
  import { type ExtraRoutesSystemDeps, makeDispatchSystemWrite } from "./extra-routes-deps";
@@ -1036,6 +1041,16 @@ export async function runProdApp(options: RunProdAppOptions): Promise<ProdAppHan
1036
1041
  };
1037
1042
  }
1038
1043
 
1044
+ const tenantLifecycleFeature = features.find((f) => f.name === TENANT_LIFECYCLE_FEATURE);
1045
+ const tenantLifecycleAuthFragment = tenantLifecycleFeature
1046
+ ? {
1047
+ resolveTenantLifecycleStatus: async (tenantId: string) => {
1048
+ const gate = await resolveTenantLifecycleGate(db, tenantId);
1049
+ return gate ? { status: gate.status } : null;
1050
+ },
1051
+ }
1052
+ : undefined;
1053
+
1039
1054
  const baseEntrypointOptions = {
1040
1055
  registry,
1041
1056
  context: {
@@ -1074,6 +1089,7 @@ export async function runProdApp(options: RunProdAppOptions): Promise<ProdAppHan
1074
1089
  }),
1075
1090
  ...sessionAuthFragment,
1076
1091
  ...patAuthFragment,
1092
+ ...tenantLifecycleAuthFragment,
1077
1093
  ...(effectiveAuth.passwordReset && {
1078
1094
  passwordReset: {
1079
1095
  requestHandler: AuthHandlers.requestPasswordReset,
@@ -1118,6 +1134,7 @@ export async function runProdApp(options: RunProdAppOptions): Promise<ProdAppHan
1118
1134
  const runSingleInstance = options.runSingleInstance ?? options.eventDispatcher?.disabled !== true;
1119
1135
  const hasJobs = registry.getAllJobs().size > 0;
1120
1136
  const queueNamePrefix = options.jobs?.queueNamePrefix;
1137
+ const jobLogger = jobRunLoggerCallbacks(registry, db);
1121
1138
  const dispatcherTunables =
1122
1139
  options.eventDispatcher?.pollIntervalMs !== undefined
1123
1140
  ? { pollIntervalMs: options.eventDispatcher.pollIntervalMs }
@@ -1126,20 +1143,18 @@ export async function runProdApp(options: RunProdAppOptions): Promise<ProdAppHan
1126
1143
  const entrypoint: ApiEntrypoint | AllInOneEntrypoint = runSingleInstance
1127
1144
  ? createAllInOneEntrypoint({
1128
1145
  ...baseEntrypointOptions,
1129
- // Worker-Seite liest die JobsBlock TOP-LEVEL (nicht nested `jobs` wie
1130
- // die api-Seite); beide Lane-Runner ziehen redisUrl/prefix von hier.
1131
1146
  redisUrl,
1147
+ ...jobLogger,
1132
1148
  ...(queueNamePrefix !== undefined && { queueNamePrefix }),
1133
1149
  ...(!options.eventDispatcher?.disabled && { eventDispatcher: dispatcherTunables }),
1134
1150
  })
1135
1151
  : createApiEntrypoint({
1136
1152
  ...baseEntrypointOptions,
1137
- // API-only: api-Lane-Jobs laufen lokal, ein dezidierter Worker fährt
1138
- // worker-Lane + MSPs. createApiEntrypoint liest den nested `jobs`-Block.
1139
1153
  ...(hasJobs && {
1140
1154
  jobs: {
1141
1155
  redisUrl,
1142
1156
  runLocalJobs: true,
1157
+ ...jobLogger,
1143
1158
  ...(queueNamePrefix !== undefined && { queueNamePrefix }),
1144
1159
  },
1145
1160
  }),