keryx 0.23.2 → 0.24.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/classes/API.ts CHANGED
@@ -8,7 +8,7 @@ import {
8
8
  formatLoadedMessage,
9
9
  } from "../util/config";
10
10
  import { globLoader } from "../util/glob";
11
- import type { Initializer, InitializerSortKeys } from "./Initializer";
11
+ import type { Initializer } from "./Initializer";
12
12
  import { Logger } from "./Logger";
13
13
  import { ErrorType, TypedError } from "./TypedError";
14
14
 
@@ -42,7 +42,7 @@ export class API {
42
42
  logger: Logger;
43
43
  /** The current run mode (SERVER or CLI), set during `start()`. */
44
44
  runMode!: RUN_MODE;
45
- /** All discovered initializer instances, sorted by the most-recently-used priority key. */
45
+ /** All discovered initializer instances, topologically sorted by `dependsOn` after discovery. */
46
46
  initializers: Initializer[];
47
47
 
48
48
  // allow arbitrary properties to be set on the API, to be added and typed later
@@ -63,7 +63,7 @@ export class API {
63
63
 
64
64
  /**
65
65
  * Load configuration overrides and discover + run all initializers.
66
- * Calls each initializer's `initialize()` method in `loadPriority` order.
66
+ * Calls each initializer's `initialize()` method in dependency order (topological sort of `dependsOn`).
67
67
  * The return value of each initializer is attached to `api[initializer.name]`.
68
68
  *
69
69
  * @throws {TypedError} With `ErrorType.SERVER_INITIALIZATION` if any initializer fails.
@@ -75,7 +75,8 @@ export class API {
75
75
  await this.loadLocalConfig();
76
76
  this.loadPluginConfig();
77
77
  await this.findInitializers();
78
- this.sortInitializers("loadPriority");
78
+ this.topologicallySortInitializers();
79
+ this.logInitializerDag();
79
80
 
80
81
  for (const initializer of this.initializers) {
81
82
  try {
@@ -101,7 +102,7 @@ export class API {
101
102
  /**
102
103
  * Start the framework: connect to external services, bind server ports, start workers.
103
104
  * Calls `initialize()` first if it hasn't been run yet, then calls each initializer's
104
- * `start()` method in `startPriority` order. Initializers whose `runModes` do not include
105
+ * `start()` method in dependency order. Initializers whose `runModes` do not include
105
106
  * the current `runMode` are skipped.
106
107
  *
107
108
  * @param runMode - Whether to start in SERVER mode (HTTP/WebSocket) or CLI mode.
@@ -117,8 +118,6 @@ export class API {
117
118
 
118
119
  this.logger.warn("--- 🔼 Starting process ---");
119
120
 
120
- this.sortInitializers("startPriority");
121
-
122
121
  for (const initializer of this.initializers) {
123
122
  if (!initializer.runModes.includes(runMode)) {
124
123
  this.logger.debug(
@@ -148,7 +147,8 @@ export class API {
148
147
 
149
148
  /**
150
149
  * Gracefully shut down the framework: disconnect from services, close server ports, stop workers.
151
- * Calls each initializer's `stop()` method in `stopPriority` order. No-ops if already stopped.
150
+ * Calls each initializer's `stop()` method in reverse dependency order (dependents stop before
151
+ * their dependencies). No-ops if already stopped.
152
152
  *
153
153
  * @throws {TypedError} With `ErrorType.SERVER_STOP` if any initializer fails to stop.
154
154
  */
@@ -160,9 +160,7 @@ export class API {
160
160
 
161
161
  this.logger.warn("--- 🔽 Stopping process ---");
162
162
 
163
- this.sortInitializers("stopPriority");
164
-
165
- for (const initializer of this.initializers) {
163
+ for (const initializer of [...this.initializers].reverse()) {
166
164
  try {
167
165
  this.logger.debug(`Stopping initializer ${initializer.name}`);
168
166
  await initializer.stop?.();
@@ -228,6 +226,11 @@ export class API {
228
226
  }
229
227
 
230
228
  private async findInitializers() {
229
+ // Reset so that re-running `initialize()` (e.g. between test files that share
230
+ // the `globalThis.api` singleton) produces a deterministic graph rather than
231
+ // accumulating duplicates from previous runs.
232
+ this.initializers = [];
233
+
231
234
  // Load framework initializers from the package directory
232
235
  const frameworkInitializers = await globLoader<Initializer>(
233
236
  path.join(this.packageDir, "initializers"),
@@ -273,8 +276,108 @@ export class API {
273
276
  );
274
277
  }
275
278
 
276
- private sortInitializers(key: InitializerSortKeys) {
277
- this.initializers.sort((a, b) => a[key] - b[key]);
279
+ /**
280
+ * Reorder `this.initializers` into a topological execution order derived from each
281
+ * initializer's `dependsOn` list. Uses Kahn's algorithm with insertion-order tie-breaking
282
+ * so framework initializers keep their relative load order when they are mutually
283
+ * independent. Throws on missing dependencies or cycles — both indicate misconfiguration
284
+ * that would otherwise surface as confusing runtime errors.
285
+ *
286
+ * @throws {TypedError} With `ErrorType.INITIALIZER_VALIDATION` on unknown dependency
287
+ * names or circular dependency chains.
288
+ */
289
+ private topologicallySortInitializers() {
290
+ const byName = new Map<string, Initializer>();
291
+ for (const i of this.initializers) byName.set(i.name, i);
292
+
293
+ // Validate dependency names exist before we try to sort.
294
+ for (const i of this.initializers) {
295
+ for (const dep of i.dependsOn) {
296
+ if (!byName.has(dep)) {
297
+ throw new TypedError({
298
+ type: ErrorType.INITIALIZER_VALIDATION,
299
+ message: `Initializer "${i.name}" depends on unknown initializer "${dep}". Available: ${[...byName.keys()].join(", ")}.`,
300
+ });
301
+ }
302
+ }
303
+ }
304
+
305
+ // Kahn's algorithm. `indegree[name]` = number of unresolved deps.
306
+ const indegree = new Map<string, number>();
307
+ for (const i of this.initializers) indegree.set(i.name, i.dependsOn.length);
308
+
309
+ // Reverse adjacency: for each dep, who depends on it?
310
+ const dependents = new Map<string, string[]>();
311
+ for (const i of this.initializers) {
312
+ for (const dep of i.dependsOn) {
313
+ const list = dependents.get(dep) ?? [];
314
+ list.push(i.name);
315
+ dependents.set(dep, list);
316
+ }
317
+ }
318
+
319
+ const sorted: Initializer[] = [];
320
+ // Seed the queue with zero-indegree initializers, preserving original insertion order.
321
+ const queue: Initializer[] = this.initializers.filter(
322
+ (i) => indegree.get(i.name) === 0,
323
+ );
324
+
325
+ while (queue.length > 0) {
326
+ const next = queue.shift()!;
327
+ sorted.push(next);
328
+ for (const dependentName of dependents.get(next.name) ?? []) {
329
+ const remaining = indegree.get(dependentName)! - 1;
330
+ indegree.set(dependentName, remaining);
331
+ if (remaining === 0) {
332
+ // Insert in original position to keep deterministic ordering.
333
+ const dependent = byName.get(dependentName)!;
334
+ // Keep queue ordered by original index among ready items.
335
+ const dependentIndex = this.initializers.indexOf(dependent);
336
+ let insertAt = queue.length;
337
+ for (let i = 0; i < queue.length; i++) {
338
+ if (this.initializers.indexOf(queue[i]) > dependentIndex) {
339
+ insertAt = i;
340
+ break;
341
+ }
342
+ }
343
+ queue.splice(insertAt, 0, dependent);
344
+ }
345
+ }
346
+ }
347
+
348
+ if (sorted.length !== this.initializers.length) {
349
+ const unresolved = this.initializers
350
+ .filter((i) => (indegree.get(i.name) ?? 0) > 0)
351
+ .map((i) => i.name);
352
+ throw new TypedError({
353
+ type: ErrorType.INITIALIZER_VALIDATION,
354
+ message: `Circular dependency detected among initializers: ${unresolved.join(" → ")}. Check each initializer's \`dependsOn\` list for a cycle.`,
355
+ });
356
+ }
357
+
358
+ this.initializers = sorted;
359
+ }
360
+
361
+ /**
362
+ * Render the resolved initializer dependency graph to the logs as a numbered list,
363
+ * one line per initializer, with each dependency shown to the right. Leaf initializers
364
+ * (no deps) render without an arrow.
365
+ */
366
+ private logInitializerDag() {
367
+ const longestName = this.initializers.reduce(
368
+ (max, i) => Math.max(max, i.name.length),
369
+ 0,
370
+ );
371
+ const digits = String(this.initializers.length).length;
372
+
373
+ this.logger.debug("--- 🔗 Initializer dependency graph ---");
374
+ this.initializers.forEach((i, idx) => {
375
+ const num = String(idx + 1).padStart(digits, "0");
376
+ const name = i.name.padEnd(longestName);
377
+ const deps =
378
+ i.dependsOn.length > 0 ? ` ← ${i.dependsOn.join(", ")}` : "";
379
+ this.logger.debug(` ${num} ${name}${deps}`);
380
+ });
278
381
  }
279
382
 
280
383
  /**
@@ -2,19 +2,19 @@ import { RUN_MODE } from "./../api";
2
2
 
3
3
  /**
4
4
  * Abstract base class for lifecycle components. Initializers are discovered automatically
5
- * and run in priority order during the framework's initialize → start → stop phases.
6
- * Each initializer typically extends the `API` interface via module augmentation and
7
- * returns its namespace object from `initialize()`.
5
+ * and run in topological order derived from `dependsOn` during the framework's
6
+ * `initialize → start → stop` phases. Each initializer typically extends the `API`
7
+ * interface via module augmentation and returns its namespace object from `initialize()`.
8
8
  */
9
9
  export abstract class Initializer {
10
10
  /** The unique name of this initializer (also used as the key on the `api` object). */
11
11
  name: string;
12
- /** Priority order for `initialize()`. Lower values run first. Default: 1000; core initializers use < 1000. */
13
- loadPriority: number;
14
- /** Priority order for `start()`. Lower values run first. Default: 1000; core initializers use < 1000. */
15
- startPriority: number;
16
- /** Priority order for `stop()`. Lower values run first. Default: 1000; core initializers use < 1000. */
17
- stopPriority: number;
12
+ /**
13
+ * Names of other initializers that must complete their `initialize()` and `start()`
14
+ * phases before this one runs. Also reverses for `stop()` dependents shut down
15
+ * before their dependencies. Unknown names or cycles cause a startup error.
16
+ */
17
+ dependsOn: string[];
18
18
  /** Which run modes this initializer participates in. Defaults to both SERVER and CLI. */
19
19
  runModes: RUN_MODE[];
20
20
  /**
@@ -27,9 +27,7 @@ export abstract class Initializer {
27
27
 
28
28
  constructor(name: string) {
29
29
  this.name = name;
30
- this.loadPriority = 1000;
31
- this.startPriority = 1000;
32
- this.stopPriority = 1000;
30
+ this.dependsOn = [];
33
31
  this.runModes = [RUN_MODE.SERVER, RUN_MODE.CLI];
34
32
  this.declaresAPIProperty = true;
35
33
  }
@@ -50,8 +48,3 @@ export abstract class Initializer {
50
48
  */
51
49
  async stop?(): Promise<any>;
52
50
  }
53
-
54
- export type InitializerSortKeys =
55
- | "loadPriority"
56
- | "startPriority"
57
- | "stopPriority";
@@ -71,7 +71,6 @@ export type TaskInputs = Record<string, any>;
71
71
  export class Actions extends Initializer {
72
72
  constructor() {
73
73
  super(namespace);
74
- this.loadPriority = 100;
75
74
  }
76
75
 
77
76
  /**
@@ -26,9 +26,7 @@ export class Channels extends Initializer {
26
26
 
27
27
  constructor() {
28
28
  super(namespace);
29
- this.loadPriority = 100;
30
- this.startPriority = 600;
31
- this.stopPriority = 50;
29
+ this.dependsOn = ["redis", "pubsub"];
32
30
  }
33
31
 
34
32
  /**
@@ -12,7 +12,6 @@ declare module "../classes/API" {
12
12
  export class Connections extends Initializer {
13
13
  constructor() {
14
14
  super(namespace);
15
- this.loadPriority = 1;
16
15
  }
17
16
 
18
17
  async initialize() {
@@ -24,9 +24,6 @@ declare module "../classes/API" {
24
24
  export class DB extends Initializer {
25
25
  constructor() {
26
26
  super(namespace);
27
- this.loadPriority = 100;
28
- this.startPriority = 100;
29
- this.stopPriority = 910;
30
27
  }
31
28
 
32
29
  async initialize() {
@@ -31,9 +31,7 @@ declare module "../classes/API" {
31
31
  export class McpInitializer extends Initializer {
32
32
  constructor() {
33
33
  super(namespace);
34
- this.loadPriority = 200;
35
- this.startPriority = 560;
36
- this.stopPriority = 90;
34
+ this.dependsOn = ["actions", "oauth", "connections", "pubsub"];
37
35
  }
38
36
 
39
37
  async initialize() {
@@ -36,8 +36,7 @@ declare module "../classes/API" {
36
36
  export class OAuthInitializer extends Initializer {
37
37
  constructor() {
38
38
  super(namespace);
39
- this.loadPriority = 175;
40
- this.startPriority = 175;
39
+ this.dependsOn = ["redis", "actions"];
41
40
  }
42
41
 
43
42
  async initialize() {
@@ -28,9 +28,7 @@ declare module "../classes/API" {
28
28
  export class Observability extends Initializer {
29
29
  constructor() {
30
30
  super(namespace);
31
- this.loadPriority = 50;
32
- this.startPriority = 50;
33
- this.stopPriority = 50;
31
+ this.dependsOn = ["actions", "connections"];
34
32
  }
35
33
 
36
34
  async initialize() {
@@ -13,7 +13,6 @@ declare module "../classes/API" {
13
13
  export class Process extends Initializer {
14
14
  constructor() {
15
15
  super(namespace);
16
- this.loadPriority = 2;
17
16
  }
18
17
 
19
18
  async initialize() {
@@ -33,8 +33,7 @@ declare module "../classes/API" {
33
33
  export class PubSub extends Initializer {
34
34
  constructor() {
35
35
  super(namespace);
36
- this.startPriority = 150;
37
- this.stopPriority = 950;
36
+ this.dependsOn = ["redis", "connections"];
38
37
  }
39
38
 
40
39
  async initialize() {
@@ -22,9 +22,6 @@ declare module "../classes/API" {
22
22
  export class Redis extends Initializer {
23
23
  constructor() {
24
24
  super(namespace);
25
- this.loadPriority = 200;
26
- this.startPriority = 110;
27
- this.stopPriority = 990;
28
25
  }
29
26
 
30
27
  async initialize() {
@@ -49,10 +49,7 @@ let SERVER_JOB_COUNTER = 1;
49
49
  export class Resque extends Initializer {
50
50
  constructor() {
51
51
  super(namespace);
52
-
53
- this.loadPriority = 250;
54
- this.startPriority = 10000;
55
- this.stopPriority = 900;
52
+ this.dependsOn = ["redis", "actions", "process"];
56
53
  }
57
54
 
58
55
  /** Create and connect the resque `Queue` instance (used for enqueuing jobs). */
@@ -17,9 +17,7 @@ declare module "../classes/API" {
17
17
  export class Servers extends Initializer {
18
18
  constructor() {
19
19
  super(namespace);
20
- this.loadPriority = 800;
21
- this.startPriority = 550;
22
- this.stopPriority = 100;
20
+ this.dependsOn = ["actions"];
23
21
  this.runModes = [RUN_MODE.SERVER];
24
22
  }
25
23
 
@@ -96,7 +96,7 @@ declare module "../classes/API" {
96
96
  export class Session extends Initializer {
97
97
  constructor() {
98
98
  super(namespace);
99
- this.startPriority = 600;
99
+ this.dependsOn = ["redis"];
100
100
  }
101
101
 
102
102
  async initialize() {
@@ -14,7 +14,6 @@ declare module "../classes/API" {
14
14
  export class Signals extends Initializer {
15
15
  constructor() {
16
16
  super(namespace);
17
- this.loadPriority = 1;
18
17
  }
19
18
 
20
19
  async initialize() {
@@ -19,7 +19,7 @@ declare module "../classes/API" {
19
19
  export class SwaggerInitializer extends Initializer {
20
20
  constructor() {
21
21
  super(namespace);
22
- this.loadPriority = 150; // After actions (100)
22
+ this.dependsOn = ["actions"];
23
23
  }
24
24
 
25
25
  async initialize() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "keryx",
3
- "version": "0.23.2",
3
+ "version": "0.24.0",
4
4
  "module": "index.ts",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Default test user credentials. Matches the inline values used in the example
3
+ * backend tests prior to this helper.
4
+ */
5
+ export const DEFAULT_TEST_USER = {
6
+ name: "Mario Mario",
7
+ email: "mario@example.com",
8
+ password: "mushroom1",
9
+ } as const;
10
+
11
+ /**
12
+ * Create a test user via HTTP PUT `/api/user`. Defaults to the Mario credentials
13
+ * used across example backend tests; override any field per call.
14
+ *
15
+ * Returns the raw `Response` so callers can assert on status and parse the body
16
+ * into whatever response type they need.
17
+ *
18
+ * @param url - Base server URL (from `useTestServer()`'s getter).
19
+ * @param overrides - Override any of `name`, `email`, `password`.
20
+ */
21
+ export async function createTestUser(
22
+ url: string,
23
+ overrides?: Partial<{ name: string; email: string; password: string }>,
24
+ ): Promise<Response> {
25
+ return fetch(url + "/api/user", {
26
+ method: "PUT",
27
+ headers: { "Content-Type": "application/json" },
28
+ body: JSON.stringify({ ...DEFAULT_TEST_USER, ...overrides }),
29
+ });
30
+ }
31
+
32
+ /**
33
+ * Log in as a test user via HTTP PUT `/api/session`. Pair with `createTestUser`.
34
+ * Returns the raw `Response`.
35
+ */
36
+ export async function createTestSession(
37
+ url: string,
38
+ overrides?: Partial<{ email: string; password: string }>,
39
+ ): Promise<Response> {
40
+ return fetch(url + "/api/session", {
41
+ method: "PUT",
42
+ headers: { "Content-Type": "application/json" },
43
+ body: JSON.stringify({
44
+ email: DEFAULT_TEST_USER.email,
45
+ password: DEFAULT_TEST_USER.password,
46
+ ...overrides,
47
+ }),
48
+ });
49
+ }
package/testing/index.ts CHANGED
@@ -2,6 +2,11 @@ import { afterAll, beforeAll } from "bun:test";
2
2
  import { api } from "../api";
3
3
  import type { WebServer } from "../servers/web";
4
4
 
5
+ export {
6
+ createTestSession,
7
+ createTestUser,
8
+ DEFAULT_TEST_USER,
9
+ } from "./http";
5
10
  export {
6
11
  buildWebSocket,
7
12
  createSession,