@wopr-network/platform-core 1.71.0 → 1.72.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.
@@ -20,8 +20,18 @@ export interface RoutePlugin {
20
20
  export interface BootConfig {
21
21
  /** Short product identifier (e.g. "paperclip", "wopr", "holyship"). */
22
22
  slug: string;
23
- /** PostgreSQL connection string. */
23
+ /** PostgreSQL connection string. Required unless `pool` is provided. */
24
24
  databaseUrl: string;
25
+ /**
26
+ * Pre-created PostgreSQL connection pool. When provided, buildContainer
27
+ * reuses this pool and skips pool creation + Drizzle migrations. The
28
+ * caller is responsible for running their own migrations before calling
29
+ * buildContainer.
30
+ *
31
+ * Use this when the product has its own migration set (e.g. wopr-platform
32
+ * generates migrations locally from the shared schema).
33
+ */
34
+ pool?: import("pg").Pool;
25
35
  /** Bind host (default "0.0.0.0"). */
26
36
  host?: string;
27
37
  /** Bind port (default 3001). */
@@ -20,20 +20,29 @@
20
20
  * `bootConfig.features`. Disabled features yield `null`.
21
21
  */
22
22
  export async function buildContainer(bootConfig) {
23
- if (!bootConfig.databaseUrl) {
24
- throw new Error("buildContainer: databaseUrl is required");
23
+ // 1. Database pool — reuse existing or create new
24
+ let pool;
25
+ if (bootConfig.pool) {
26
+ pool = bootConfig.pool;
27
+ }
28
+ else {
29
+ if (!bootConfig.databaseUrl) {
30
+ throw new Error("buildContainer: databaseUrl is required when pool is not provided");
31
+ }
32
+ const { Pool: PgPool } = await import("pg");
33
+ pool = new PgPool({ connectionString: bootConfig.databaseUrl });
25
34
  }
26
- // 1. Database pool
27
- const { Pool: PgPool } = await import("pg");
28
- const pool = new PgPool({ connectionString: bootConfig.databaseUrl });
29
35
  // 2. Drizzle ORM instance
30
36
  const { createDb } = await import("../db/index.js");
31
37
  const db = createDb(pool);
32
- // 3. Run Drizzle migrations
33
- const { migrate } = await import("drizzle-orm/node-postgres/migrator");
34
- const path = await import("node:path");
35
- const migrationsFolder = path.resolve(path.dirname(new URL(import.meta.url).pathname), "../../drizzle");
36
- await migrate(db, { migrationsFolder });
38
+ // 3. Run Drizzle migrations (skip when caller provided their own pool —
39
+ // they are responsible for running product-specific migrations first)
40
+ if (!bootConfig.pool) {
41
+ const { migrate } = await import("drizzle-orm/node-postgres/migrator");
42
+ const path = await import("node:path");
43
+ const migrationsFolder = path.resolve(path.dirname(new URL(import.meta.url).pathname), "../../drizzle");
44
+ await migrate(db, { migrationsFolder });
45
+ }
37
46
  // 4. Bootstrap product config from DB (auto-seeds from presets if needed)
38
47
  const { platformBoot } = await import("../product-config/boot.js");
39
48
  const { config: productConfig, service: productConfigService } = await platformBoot({ slug: bootConfig.slug, db });
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Tests for hot pool service functions (getPoolSize, setPoolSize).
3
+ *
4
+ * Uses InMemoryPoolRepository to test service logic without Docker or DB.
5
+ */
6
+ export {};
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Tests for hot pool service functions (getPoolSize, setPoolSize).
3
+ *
4
+ * Uses InMemoryPoolRepository to test service logic without Docker or DB.
5
+ */
6
+ import { describe, expect, it } from "vitest";
7
+ import { getPoolSize, setPoolSize } from "../hot-pool.js";
8
+ import { InMemoryPoolRepository } from "./in-memory-pool-repository.js";
9
+ describe("hot pool service", () => {
10
+ describe("getPoolSize / setPoolSize", () => {
11
+ it("returns default pool size", async () => {
12
+ const repo = new InMemoryPoolRepository();
13
+ expect(await getPoolSize(repo)).toBe(2);
14
+ });
15
+ it("sets and reads pool size", async () => {
16
+ const repo = new InMemoryPoolRepository();
17
+ await setPoolSize(repo, 10);
18
+ expect(await getPoolSize(repo)).toBe(10);
19
+ });
20
+ it("pool size of 0 is valid", async () => {
21
+ const repo = new InMemoryPoolRepository();
22
+ await setPoolSize(repo, 0);
23
+ expect(await getPoolSize(repo)).toBe(0);
24
+ });
25
+ });
26
+ });
@@ -0,0 +1,21 @@
1
+ /**
2
+ * In-memory IPoolRepository for testing.
3
+ * FIFO claiming, dead instance handling — no DB required.
4
+ */
5
+ import type { IPoolRepository, PoolInstance } from "../pool-repository.js";
6
+ export declare class InMemoryPoolRepository implements IPoolRepository {
7
+ private poolSize;
8
+ private instances;
9
+ getPoolSize(): Promise<number>;
10
+ setPoolSize(size: number): Promise<void>;
11
+ warmCount(): Promise<number>;
12
+ insertWarm(id: string, containerId: string): Promise<void>;
13
+ listWarm(): Promise<PoolInstance[]>;
14
+ markDead(id: string): Promise<void>;
15
+ deleteDead(): Promise<void>;
16
+ claimWarm(tenantId: string, name: string): Promise<{
17
+ id: string;
18
+ containerId: string;
19
+ } | null>;
20
+ updateInstanceStatus(id: string, status: string): Promise<void>;
21
+ }
@@ -0,0 +1,57 @@
1
+ /**
2
+ * In-memory IPoolRepository for testing.
3
+ * FIFO claiming, dead instance handling — no DB required.
4
+ */
5
+ export class InMemoryPoolRepository {
6
+ poolSize = 2;
7
+ instances = [];
8
+ async getPoolSize() {
9
+ return this.poolSize;
10
+ }
11
+ async setPoolSize(size) {
12
+ this.poolSize = size;
13
+ }
14
+ async warmCount() {
15
+ return this.instances.filter((i) => i.status === "warm").length;
16
+ }
17
+ async insertWarm(id, containerId) {
18
+ this.instances.push({
19
+ id,
20
+ containerId,
21
+ status: "warm",
22
+ tenantId: null,
23
+ name: null,
24
+ createdAt: new Date(),
25
+ claimedAt: null,
26
+ });
27
+ }
28
+ async listWarm() {
29
+ return this.instances.filter((i) => i.status === "warm").map(({ createdAt, claimedAt, ...rest }) => rest);
30
+ }
31
+ async markDead(id) {
32
+ const inst = this.instances.find((i) => i.id === id);
33
+ if (inst)
34
+ inst.status = "dead";
35
+ }
36
+ async deleteDead() {
37
+ this.instances = this.instances.filter((i) => i.status !== "dead");
38
+ }
39
+ async claimWarm(tenantId, name) {
40
+ const warm = this.instances
41
+ .filter((i) => i.status === "warm")
42
+ .sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime());
43
+ if (warm.length === 0)
44
+ return null;
45
+ const target = warm[0];
46
+ target.status = "claimed";
47
+ target.tenantId = tenantId;
48
+ target.name = name;
49
+ target.claimedAt = new Date();
50
+ return { id: target.id, containerId: target.containerId };
51
+ }
52
+ async updateInstanceStatus(id, status) {
53
+ const inst = this.instances.find((i) => i.id === id);
54
+ if (inst)
55
+ inst.status = status;
56
+ }
57
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Tests for IPoolRepository interface contract.
3
+ *
4
+ * Uses an in-memory implementation to verify the interface
5
+ * behavior without requiring a real database.
6
+ */
7
+ export {};
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Tests for IPoolRepository interface contract.
3
+ *
4
+ * Uses an in-memory implementation to verify the interface
5
+ * behavior without requiring a real database.
6
+ */
7
+ import { describe, expect, it } from "vitest";
8
+ import { InMemoryPoolRepository } from "./in-memory-pool-repository.js";
9
+ describe("IPoolRepository (InMemory)", () => {
10
+ it("returns default pool size of 2", async () => {
11
+ const repo = new InMemoryPoolRepository();
12
+ expect(await repo.getPoolSize()).toBe(2);
13
+ });
14
+ it("sets and gets pool size", async () => {
15
+ const repo = new InMemoryPoolRepository();
16
+ await repo.setPoolSize(5);
17
+ expect(await repo.getPoolSize()).toBe(5);
18
+ });
19
+ it("inserts and counts warm instances", async () => {
20
+ const repo = new InMemoryPoolRepository();
21
+ expect(await repo.warmCount()).toBe(0);
22
+ await repo.insertWarm("a", "container-a");
23
+ await repo.insertWarm("b", "container-b");
24
+ expect(await repo.warmCount()).toBe(2);
25
+ });
26
+ it("lists warm instances", async () => {
27
+ const repo = new InMemoryPoolRepository();
28
+ await repo.insertWarm("a", "container-a");
29
+ await repo.insertWarm("b", "container-b");
30
+ const warm = await repo.listWarm();
31
+ expect(warm).toHaveLength(2);
32
+ expect(warm[0].id).toBe("a");
33
+ expect(warm[0].containerId).toBe("container-a");
34
+ expect(warm[0].status).toBe("warm");
35
+ });
36
+ it("claims warm instance FIFO", async () => {
37
+ const repo = new InMemoryPoolRepository();
38
+ await repo.insertWarm("first", "c-first");
39
+ await repo.insertWarm("second", "c-second");
40
+ const claimed = await repo.claimWarm("tenant-1", "my-bot");
41
+ expect(claimed).not.toBeNull();
42
+ expect(claimed?.id).toBe("first");
43
+ expect(claimed?.containerId).toBe("c-first");
44
+ // Warm count drops by 1
45
+ expect(await repo.warmCount()).toBe(1);
46
+ });
47
+ it("returns null when claiming from empty pool", async () => {
48
+ const repo = new InMemoryPoolRepository();
49
+ const result = await repo.claimWarm("tenant-1", "bot");
50
+ expect(result).toBeNull();
51
+ });
52
+ it("does not re-claim already claimed instances", async () => {
53
+ const repo = new InMemoryPoolRepository();
54
+ await repo.insertWarm("only", "c-only");
55
+ const first = await repo.claimWarm("t1", "bot1");
56
+ expect(first).not.toBeNull();
57
+ const second = await repo.claimWarm("t2", "bot2");
58
+ expect(second).toBeNull();
59
+ });
60
+ it("marks instances dead", async () => {
61
+ const repo = new InMemoryPoolRepository();
62
+ await repo.insertWarm("a", "c-a");
63
+ await repo.markDead("a");
64
+ expect(await repo.warmCount()).toBe(0);
65
+ const warm = await repo.listWarm();
66
+ expect(warm).toHaveLength(0);
67
+ });
68
+ it("deletes dead instances", async () => {
69
+ const repo = new InMemoryPoolRepository();
70
+ await repo.insertWarm("a", "c-a");
71
+ await repo.insertWarm("b", "c-b");
72
+ await repo.markDead("a");
73
+ await repo.deleteDead();
74
+ // Only 'b' remains (warm)
75
+ expect(await repo.warmCount()).toBe(1);
76
+ const warm = await repo.listWarm();
77
+ expect(warm[0].id).toBe("b");
78
+ });
79
+ it("updates instance status", async () => {
80
+ const repo = new InMemoryPoolRepository();
81
+ await repo.insertWarm("a", "c-a");
82
+ await repo.updateInstanceStatus("a", "provisioning");
83
+ // No longer warm
84
+ expect(await repo.warmCount()).toBe(0);
85
+ });
86
+ it("handles multiple claims in order", async () => {
87
+ const repo = new InMemoryPoolRepository();
88
+ await repo.insertWarm("1", "c-1");
89
+ await repo.insertWarm("2", "c-2");
90
+ await repo.insertWarm("3", "c-3");
91
+ const c1 = await repo.claimWarm("t-a", "bot-a");
92
+ const c2 = await repo.claimWarm("t-b", "bot-b");
93
+ const c3 = await repo.claimWarm("t-c", "bot-c");
94
+ const c4 = await repo.claimWarm("t-d", "bot-d");
95
+ expect(c1?.id).toBe("1");
96
+ expect(c2?.id).toBe("2");
97
+ expect(c3?.id).toBe("3");
98
+ expect(c4).toBeNull();
99
+ expect(await repo.warmCount()).toBe(0);
100
+ });
101
+ it("dead instances are not claimable", async () => {
102
+ const repo = new InMemoryPoolRepository();
103
+ await repo.insertWarm("a", "c-a");
104
+ await repo.markDead("a");
105
+ const result = await repo.claimWarm("t1", "bot");
106
+ expect(result).toBeNull();
107
+ });
108
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wopr-network/platform-core",
3
- "version": "1.71.0",
3
+ "version": "1.72.0",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -37,9 +37,20 @@ export interface BootConfig {
37
37
  /** Short product identifier (e.g. "paperclip", "wopr", "holyship"). */
38
38
  slug: string;
39
39
 
40
- /** PostgreSQL connection string. */
40
+ /** PostgreSQL connection string. Required unless `pool` is provided. */
41
41
  databaseUrl: string;
42
42
 
43
+ /**
44
+ * Pre-created PostgreSQL connection pool. When provided, buildContainer
45
+ * reuses this pool and skips pool creation + Drizzle migrations. The
46
+ * caller is responsible for running their own migrations before calling
47
+ * buildContainer.
48
+ *
49
+ * Use this when the product has its own migration set (e.g. wopr-platform
50
+ * generates migrations locally from the shared schema).
51
+ */
52
+ pool?: import("pg").Pool;
53
+
43
54
  /** Bind host (default "0.0.0.0"). */
44
55
  host?: string;
45
56
 
@@ -110,23 +110,30 @@ export interface PlatformContainer {
110
110
  * `bootConfig.features`. Disabled features yield `null`.
111
111
  */
112
112
  export async function buildContainer(bootConfig: BootConfig): Promise<PlatformContainer> {
113
- if (!bootConfig.databaseUrl) {
114
- throw new Error("buildContainer: databaseUrl is required");
113
+ // 1. Database pool — reuse existing or create new
114
+ let pool: Pool;
115
+ if (bootConfig.pool) {
116
+ pool = bootConfig.pool;
117
+ } else {
118
+ if (!bootConfig.databaseUrl) {
119
+ throw new Error("buildContainer: databaseUrl is required when pool is not provided");
120
+ }
121
+ const { Pool: PgPool } = await import("pg");
122
+ pool = new PgPool({ connectionString: bootConfig.databaseUrl });
115
123
  }
116
124
 
117
- // 1. Database pool
118
- const { Pool: PgPool } = await import("pg");
119
- const pool: Pool = new PgPool({ connectionString: bootConfig.databaseUrl });
120
-
121
125
  // 2. Drizzle ORM instance
122
126
  const { createDb } = await import("../db/index.js");
123
127
  const db = createDb(pool);
124
128
 
125
- // 3. Run Drizzle migrations
126
- const { migrate } = await import("drizzle-orm/node-postgres/migrator");
127
- const path = await import("node:path");
128
- const migrationsFolder = path.resolve(path.dirname(new URL(import.meta.url).pathname), "../../drizzle");
129
- await migrate(db as never, { migrationsFolder });
129
+ // 3. Run Drizzle migrations (skip when caller provided their own pool —
130
+ // they are responsible for running product-specific migrations first)
131
+ if (!bootConfig.pool) {
132
+ const { migrate } = await import("drizzle-orm/node-postgres/migrator");
133
+ const path = await import("node:path");
134
+ const migrationsFolder = path.resolve(path.dirname(new URL(import.meta.url).pathname), "../../drizzle");
135
+ await migrate(db as never, { migrationsFolder });
136
+ }
130
137
 
131
138
  // 4. Bootstrap product config from DB (auto-seeds from presets if needed)
132
139
  const { platformBoot } = await import("../product-config/boot.js");
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Tests for hot pool service functions (getPoolSize, setPoolSize).
3
+ *
4
+ * Uses InMemoryPoolRepository to test service logic without Docker or DB.
5
+ */
6
+
7
+ import { describe, expect, it } from "vitest";
8
+ import { getPoolSize, setPoolSize } from "../hot-pool.js";
9
+ import { InMemoryPoolRepository } from "./in-memory-pool-repository.js";
10
+
11
+ describe("hot pool service", () => {
12
+ describe("getPoolSize / setPoolSize", () => {
13
+ it("returns default pool size", async () => {
14
+ const repo = new InMemoryPoolRepository();
15
+ expect(await getPoolSize(repo)).toBe(2);
16
+ });
17
+
18
+ it("sets and reads pool size", async () => {
19
+ const repo = new InMemoryPoolRepository();
20
+ await setPoolSize(repo, 10);
21
+ expect(await getPoolSize(repo)).toBe(10);
22
+ });
23
+
24
+ it("pool size of 0 is valid", async () => {
25
+ const repo = new InMemoryPoolRepository();
26
+ await setPoolSize(repo, 0);
27
+ expect(await getPoolSize(repo)).toBe(0);
28
+ });
29
+ });
30
+ });
@@ -0,0 +1,66 @@
1
+ /**
2
+ * In-memory IPoolRepository for testing.
3
+ * FIFO claiming, dead instance handling — no DB required.
4
+ */
5
+
6
+ import type { IPoolRepository, PoolInstance } from "../pool-repository.js";
7
+
8
+ export class InMemoryPoolRepository implements IPoolRepository {
9
+ private poolSize = 2;
10
+ private instances: Array<PoolInstance & { createdAt: Date; claimedAt: Date | null }> = [];
11
+
12
+ async getPoolSize(): Promise<number> {
13
+ return this.poolSize;
14
+ }
15
+
16
+ async setPoolSize(size: number): Promise<void> {
17
+ this.poolSize = size;
18
+ }
19
+
20
+ async warmCount(): Promise<number> {
21
+ return this.instances.filter((i) => i.status === "warm").length;
22
+ }
23
+
24
+ async insertWarm(id: string, containerId: string): Promise<void> {
25
+ this.instances.push({
26
+ id,
27
+ containerId,
28
+ status: "warm",
29
+ tenantId: null,
30
+ name: null,
31
+ createdAt: new Date(),
32
+ claimedAt: null,
33
+ });
34
+ }
35
+
36
+ async listWarm(): Promise<PoolInstance[]> {
37
+ return this.instances.filter((i) => i.status === "warm").map(({ createdAt, claimedAt, ...rest }) => rest);
38
+ }
39
+
40
+ async markDead(id: string): Promise<void> {
41
+ const inst = this.instances.find((i) => i.id === id);
42
+ if (inst) inst.status = "dead";
43
+ }
44
+
45
+ async deleteDead(): Promise<void> {
46
+ this.instances = this.instances.filter((i) => i.status !== "dead");
47
+ }
48
+
49
+ async claimWarm(tenantId: string, name: string): Promise<{ id: string; containerId: string } | null> {
50
+ const warm = this.instances
51
+ .filter((i) => i.status === "warm")
52
+ .sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime());
53
+ if (warm.length === 0) return null;
54
+ const target = warm[0];
55
+ target.status = "claimed";
56
+ target.tenantId = tenantId;
57
+ target.name = name;
58
+ target.claimedAt = new Date();
59
+ return { id: target.id, containerId: target.containerId };
60
+ }
61
+
62
+ async updateInstanceStatus(id: string, status: string): Promise<void> {
63
+ const inst = this.instances.find((i) => i.id === id);
64
+ if (inst) inst.status = status;
65
+ }
66
+ }
@@ -0,0 +1,135 @@
1
+ /**
2
+ * Tests for IPoolRepository interface contract.
3
+ *
4
+ * Uses an in-memory implementation to verify the interface
5
+ * behavior without requiring a real database.
6
+ */
7
+
8
+ import { describe, expect, it } from "vitest";
9
+ import { InMemoryPoolRepository } from "./in-memory-pool-repository.js";
10
+
11
+ describe("IPoolRepository (InMemory)", () => {
12
+ it("returns default pool size of 2", async () => {
13
+ const repo = new InMemoryPoolRepository();
14
+ expect(await repo.getPoolSize()).toBe(2);
15
+ });
16
+
17
+ it("sets and gets pool size", async () => {
18
+ const repo = new InMemoryPoolRepository();
19
+ await repo.setPoolSize(5);
20
+ expect(await repo.getPoolSize()).toBe(5);
21
+ });
22
+
23
+ it("inserts and counts warm instances", async () => {
24
+ const repo = new InMemoryPoolRepository();
25
+ expect(await repo.warmCount()).toBe(0);
26
+
27
+ await repo.insertWarm("a", "container-a");
28
+ await repo.insertWarm("b", "container-b");
29
+
30
+ expect(await repo.warmCount()).toBe(2);
31
+ });
32
+
33
+ it("lists warm instances", async () => {
34
+ const repo = new InMemoryPoolRepository();
35
+ await repo.insertWarm("a", "container-a");
36
+ await repo.insertWarm("b", "container-b");
37
+
38
+ const warm = await repo.listWarm();
39
+ expect(warm).toHaveLength(2);
40
+ expect(warm[0].id).toBe("a");
41
+ expect(warm[0].containerId).toBe("container-a");
42
+ expect(warm[0].status).toBe("warm");
43
+ });
44
+
45
+ it("claims warm instance FIFO", async () => {
46
+ const repo = new InMemoryPoolRepository();
47
+ await repo.insertWarm("first", "c-first");
48
+ await repo.insertWarm("second", "c-second");
49
+
50
+ const claimed = await repo.claimWarm("tenant-1", "my-bot");
51
+ expect(claimed).not.toBeNull();
52
+ expect(claimed?.id).toBe("first");
53
+ expect(claimed?.containerId).toBe("c-first");
54
+
55
+ // Warm count drops by 1
56
+ expect(await repo.warmCount()).toBe(1);
57
+ });
58
+
59
+ it("returns null when claiming from empty pool", async () => {
60
+ const repo = new InMemoryPoolRepository();
61
+ const result = await repo.claimWarm("tenant-1", "bot");
62
+ expect(result).toBeNull();
63
+ });
64
+
65
+ it("does not re-claim already claimed instances", async () => {
66
+ const repo = new InMemoryPoolRepository();
67
+ await repo.insertWarm("only", "c-only");
68
+
69
+ const first = await repo.claimWarm("t1", "bot1");
70
+ expect(first).not.toBeNull();
71
+
72
+ const second = await repo.claimWarm("t2", "bot2");
73
+ expect(second).toBeNull();
74
+ });
75
+
76
+ it("marks instances dead", async () => {
77
+ const repo = new InMemoryPoolRepository();
78
+ await repo.insertWarm("a", "c-a");
79
+ await repo.markDead("a");
80
+
81
+ expect(await repo.warmCount()).toBe(0);
82
+ const warm = await repo.listWarm();
83
+ expect(warm).toHaveLength(0);
84
+ });
85
+
86
+ it("deletes dead instances", async () => {
87
+ const repo = new InMemoryPoolRepository();
88
+ await repo.insertWarm("a", "c-a");
89
+ await repo.insertWarm("b", "c-b");
90
+ await repo.markDead("a");
91
+
92
+ await repo.deleteDead();
93
+
94
+ // Only 'b' remains (warm)
95
+ expect(await repo.warmCount()).toBe(1);
96
+ const warm = await repo.listWarm();
97
+ expect(warm[0].id).toBe("b");
98
+ });
99
+
100
+ it("updates instance status", async () => {
101
+ const repo = new InMemoryPoolRepository();
102
+ await repo.insertWarm("a", "c-a");
103
+ await repo.updateInstanceStatus("a", "provisioning");
104
+
105
+ // No longer warm
106
+ expect(await repo.warmCount()).toBe(0);
107
+ });
108
+
109
+ it("handles multiple claims in order", async () => {
110
+ const repo = new InMemoryPoolRepository();
111
+ await repo.insertWarm("1", "c-1");
112
+ await repo.insertWarm("2", "c-2");
113
+ await repo.insertWarm("3", "c-3");
114
+
115
+ const c1 = await repo.claimWarm("t-a", "bot-a");
116
+ const c2 = await repo.claimWarm("t-b", "bot-b");
117
+ const c3 = await repo.claimWarm("t-c", "bot-c");
118
+ const c4 = await repo.claimWarm("t-d", "bot-d");
119
+
120
+ expect(c1?.id).toBe("1");
121
+ expect(c2?.id).toBe("2");
122
+ expect(c3?.id).toBe("3");
123
+ expect(c4).toBeNull();
124
+ expect(await repo.warmCount()).toBe(0);
125
+ });
126
+
127
+ it("dead instances are not claimable", async () => {
128
+ const repo = new InMemoryPoolRepository();
129
+ await repo.insertWarm("a", "c-a");
130
+ await repo.markDead("a");
131
+
132
+ const result = await repo.claimWarm("t1", "bot");
133
+ expect(result).toBeNull();
134
+ });
135
+ });