@emseepea/create-multi-instance-postgres-server 0.0.1 → 0.0.3

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/README.md CHANGED
@@ -1,51 +1,54 @@
1
1
  # `@emseepea/create-multi-instance-postgres-server`
2
2
 
3
- This directory is the maintained example and the candidate source for its
3
+ This directory is both the maintained example and the template source for its
4
4
  public npm initializer.
5
5
 
6
6
  ## Use This Template
7
7
 
8
- Use this template when independently deployed server instances must share one
9
- PostgreSQL-backed store and repeated request IDs must resolve to one stored
10
- result. Choose the [tool server](../tool-server/README.md) when one process and
11
- in-memory data are enough. [Compare all eight templates](https://emseepea.github.io/emseepea/examples/).
8
+ Use this template when independently deployed server processes must present one
9
+ coherent PostgreSQL-backed application state. It demonstrates interchangeable,
10
+ stateless MCP processes without exposing server identity or routing to users.
12
11
 
13
- ## Create a Project
12
+ Choose the [tool server](../tool-server/README.md) when one process and in-memory
13
+ data are enough. [Compare all eight templates](https://emseepea.github.io/emseepea/examples/).
14
14
 
15
- Publication is pending exact Quality checks. After publication, use:
15
+ ## Create a Project
16
16
 
17
17
  ```sh
18
18
  npm init @emseepea/multi-instance-postgres-server -- my-server
19
19
  ```
20
20
 
21
+ The command creates a standalone app in a new `my-server` directory and sets
22
+ `private: true` in its `package.json` so the app cannot be published by
23
+ accident. It includes its PostgreSQL schema, Docker Compose setup, lint checks,
24
+ ordinary tests, and semantic tests.
25
+
21
26
  <!-- generated-project-readme -->
22
27
 
23
28
  ## Multi-Instance PostgreSQL Server
24
29
 
25
- Choose this project when separate server instances may receive the same retry
26
- and must avoid creating duplicate stored work.
30
+ Each server process owns its own Em See Pea app and bounded PostgreSQL
31
+ connection pool. Every process reads and writes the same application state, so
32
+ a client does not need sticky sessions or knowledge of which process served a
33
+ request.
27
34
 
28
- Each server has its own Em See Pea app and PostgreSQL connection pool. The
29
- servers coordinate through one shared database without sharing a local
30
- filesystem.
31
-
32
- The same request ID creates one stored pea harvest report. A retry through any
33
- instance returns the original report, including the name of the instance that
34
- created it.
35
+ The example stores one complete pea harvest report per garden bed and harvest
36
+ date. PostgreSQL enforces that meaningful business key. Saving the same report
37
+ again is safe, and saving changed counts replaces the complete desired state.
35
38
 
36
39
  ## Run Locally
37
40
 
38
41
  You need Node.js 22 or 24 and Docker Compose. Install dependencies, then start
39
- PostgreSQL and both server instances with one run command:
42
+ PostgreSQL and two independent server processes:
40
43
 
41
44
  ```sh
42
45
  npm install
43
46
  npm run dev
44
47
  ```
45
48
 
46
- The run command applies the database schema, starts two independent server
47
- processes, and prints both MCP addresses. Stop them with Control-C. Remove the
48
- database container and local volume when you no longer need them:
49
+ The command applies the database schema and prints both MCP addresses. Stop it
50
+ with Control-C. Remove the database container and local volume when you no
51
+ longer need them:
49
52
 
50
53
  ```sh
51
54
  docker compose down --volumes
@@ -54,59 +57,56 @@ docker compose down --volumes
54
57
  ## Use Managed PostgreSQL
55
58
 
56
59
  Set `DATABASE_URL` to a PostgreSQL connection string that every deployed
57
- instance can reach. Apply the included schema once, then start each instance
58
- with its own name:
60
+ process can reach. Apply the included schema once, then start each process:
59
61
 
60
62
  ```sh
61
63
  npm run build
62
64
  npm run db:setup
63
- EMSEEPEA_INSTANCE=instance-a PORT=3000 npm run start:instance
65
+ PORT=3000 npm run start:instance
64
66
  ```
65
67
 
66
- Run the final command for each deployed instance, changing
67
- `EMSEEPEA_INSTANCE` for each instance. Protect `DATABASE_URL` as a secret. Do
68
- not put it in source control or send it to an MCP client.
68
+ Use a different port or deployment endpoint for each process. You may set
69
+ `EMSEEPEA_INSTANCE` to add an operator-friendly label to logs. That label never
70
+ enters the MCP tool contract. Protect `DATABASE_URL` as a secret. Do not put it
71
+ in source control or send it to an MCP client.
69
72
 
70
73
  ## Tools
71
74
 
72
- - `create-shared-harvest-report` atomically creates or returns one stored report
73
- for a request ID. The result identifies its original server instance.
74
- - `describe-instance` returns the instance handling the current request and
75
- does not query PostgreSQL.
76
-
77
- PostgreSQL enforces one report per request ID with a unique constraint. The
78
- tool uses one atomic upsert, without an application-side existence check or a
79
- distributed lock.
75
+ - `save-harvest-report` saves the complete report for one garden bed and
76
+ harvest date.
77
+ - `get-harvest-report` retrieves that report, or returns `null` when it does not
78
+ exist.
80
79
 
81
- If PostgreSQL is unavailable, `/readyz` returns 503 and the report tool returns
82
- a generic failure. Independent endpoints and `describe-instance` remain
83
- available for diagnosis.
80
+ If PostgreSQL is unavailable, `/readyz` returns 503 and both tools return a
81
+ generic failure without connection details. Another healthy process connected
82
+ to the shared database continues to serve the same state.
84
83
 
85
84
  ## Exact Scope
86
85
 
87
- - Independently deployable server instances connected to one PostgreSQL database.
86
+ - Independently deployable server processes connected to one PostgreSQL database.
88
87
  - One bounded connection pool per process.
89
- - One atomic database statement per report request.
90
- - PostgreSQL bounds database statements to 1.5 seconds. The driver cannot
91
- cancel an in-flight query from an AbortSignal, so the database timeout is the
88
+ - One database statement per tool call.
89
+ - PostgreSQL bounds statements to 1.5 seconds. The driver cannot cancel an
90
+ in-flight query from an AbortSignal, so the database timeout is the
92
91
  cancellation boundary.
93
- - No claim that an external service change happens exactly once.
94
- - No latency, throughput, or unlimited-scale claim.
92
+ - No sticky-session requirement.
93
+ - No claim of exactly-once external effects, latency, throughput, or unlimited
94
+ scale.
95
95
 
96
96
  ## Check This Project
97
97
 
98
- [Ordinary tests](test/) live in `test/`. The
99
- [AI tool-choice and understanding test](eval/meaning.test.mjs) lives separately
100
- in `eval/`. Docker Compose is required because both suites exercise real
101
- PostgreSQL behavior.
98
+ [Ordinary tests](test/) prove cross-process state, complete-state replacement,
99
+ missing reports, provider failure, readiness, and bounded queries. The
100
+ [semantic test](eval/meaning.test.mjs) checks that an AI naturally selects the
101
+ save and get tools with the right arguments.
102
102
 
103
- Run the build and two-server MCP checks:
103
+ Run the build and two-process MCP checks:
104
104
 
105
105
  ```sh
106
106
  npm test
107
107
  ```
108
108
 
109
- Check that Claude understands report replay correctly:
109
+ Run the more expensive AI test separately:
110
110
 
111
111
  ```sh
112
112
  npm run test:llm
@@ -1,29 +1,27 @@
1
1
  # Multi-Instance PostgreSQL Server
2
2
 
3
- Choose this project when separate server instances may receive the same retry
4
- and must avoid creating duplicate stored work.
3
+ Each server process owns its own Em See Pea app and bounded PostgreSQL
4
+ connection pool. Every process reads and writes the same application state, so
5
+ a client does not need sticky sessions or knowledge of which process served a
6
+ request.
5
7
 
6
- Each server has its own Em See Pea app and PostgreSQL connection pool. The
7
- servers coordinate through one shared database without sharing a local
8
- filesystem.
9
-
10
- The same request ID creates one stored pea harvest report. A retry through any
11
- instance returns the original report, including the name of the instance that
12
- created it.
8
+ The example stores one complete pea harvest report per garden bed and harvest
9
+ date. PostgreSQL enforces that meaningful business key. Saving the same report
10
+ again is safe, and saving changed counts replaces the complete desired state.
13
11
 
14
12
  ## Run Locally
15
13
 
16
14
  You need Node.js 22 or 24 and Docker Compose. Install dependencies, then start
17
- PostgreSQL and both server instances with one run command:
15
+ PostgreSQL and two independent server processes:
18
16
 
19
17
  ```sh
20
18
  npm install
21
19
  npm run dev
22
20
  ```
23
21
 
24
- The run command applies the database schema, starts two independent server
25
- processes, and prints both MCP addresses. Stop them with Control-C. Remove the
26
- database container and local volume when you no longer need them:
22
+ The command applies the database schema and prints both MCP addresses. Stop it
23
+ with Control-C. Remove the database container and local volume when you no
24
+ longer need them:
27
25
 
28
26
  ```sh
29
27
  docker compose down --volumes
@@ -32,59 +30,56 @@ docker compose down --volumes
32
30
  ## Use Managed PostgreSQL
33
31
 
34
32
  Set `DATABASE_URL` to a PostgreSQL connection string that every deployed
35
- instance can reach. Apply the included schema once, then start each instance
36
- with its own name:
33
+ process can reach. Apply the included schema once, then start each process:
37
34
 
38
35
  ```sh
39
36
  npm run build
40
37
  npm run db:setup
41
- EMSEEPEA_INSTANCE=instance-a PORT=3000 npm run start:instance
38
+ PORT=3000 npm run start:instance
42
39
  ```
43
40
 
44
- Run the final command for each deployed instance, changing
45
- `EMSEEPEA_INSTANCE` for each instance. Protect `DATABASE_URL` as a secret. Do
46
- not put it in source control or send it to an MCP client.
41
+ Use a different port or deployment endpoint for each process. You may set
42
+ `EMSEEPEA_INSTANCE` to add an operator-friendly label to logs. That label never
43
+ enters the MCP tool contract. Protect `DATABASE_URL` as a secret. Do not put it
44
+ in source control or send it to an MCP client.
47
45
 
48
46
  ## Tools
49
47
 
50
- - `create-shared-harvest-report` atomically creates or returns one stored report
51
- for a request ID. The result identifies its original server instance.
52
- - `describe-instance` returns the instance handling the current request and
53
- does not query PostgreSQL.
54
-
55
- PostgreSQL enforces one report per request ID with a unique constraint. The
56
- tool uses one atomic upsert, without an application-side existence check or a
57
- distributed lock.
48
+ - `save-harvest-report` saves the complete report for one garden bed and
49
+ harvest date.
50
+ - `get-harvest-report` retrieves that report, or returns `null` when it does not
51
+ exist.
58
52
 
59
- If PostgreSQL is unavailable, `/readyz` returns 503 and the report tool returns
60
- a generic failure. Independent endpoints and `describe-instance` remain
61
- available for diagnosis.
53
+ If PostgreSQL is unavailable, `/readyz` returns 503 and both tools return a
54
+ generic failure without connection details. Another healthy process connected
55
+ to the shared database continues to serve the same state.
62
56
 
63
57
  ## Exact Scope
64
58
 
65
- - Independently deployable server instances connected to one PostgreSQL database.
59
+ - Independently deployable server processes connected to one PostgreSQL database.
66
60
  - One bounded connection pool per process.
67
- - One atomic database statement per report request.
68
- - PostgreSQL bounds database statements to 1.5 seconds. The driver cannot
69
- cancel an in-flight query from an AbortSignal, so the database timeout is the
61
+ - One database statement per tool call.
62
+ - PostgreSQL bounds statements to 1.5 seconds. The driver cannot cancel an
63
+ in-flight query from an AbortSignal, so the database timeout is the
70
64
  cancellation boundary.
71
- - No claim that an external service change happens exactly once.
72
- - No latency, throughput, or unlimited-scale claim.
65
+ - No sticky-session requirement.
66
+ - No claim of exactly-once external effects, latency, throughput, or unlimited
67
+ scale.
73
68
 
74
69
  ## Check This Project
75
70
 
76
- [Ordinary tests](test/) live in `test/`. The
77
- [AI tool-choice and understanding test](eval/meaning.test.mjs) lives separately
78
- in `eval/`. Docker Compose is required because both suites exercise real
79
- PostgreSQL behavior.
71
+ [Ordinary tests](test/) prove cross-process state, complete-state replacement,
72
+ missing reports, provider failure, readiness, and bounded queries. The
73
+ [semantic test](eval/meaning.test.mjs) checks that an AI naturally selects the
74
+ save and get tools with the right arguments.
80
75
 
81
- Run the build and two-server MCP checks:
76
+ Run the build and two-process MCP checks:
82
77
 
83
78
  ```sh
84
79
  npm test
85
80
  ```
86
81
 
87
- Check that Claude understands report replay correctly:
82
+ Run the more expensive AI test separately:
88
83
 
89
84
  ```sh
90
85
  npm run test:llm
@@ -1,6 +1,5 @@
1
1
  import test from "node:test";
2
2
  import {
3
- assertNoToolCalls,
4
3
  assertResponseContains,
5
4
  assertResponseMeaning,
6
5
  assertToolCalls,
@@ -10,35 +9,44 @@ import {
10
9
  const databaseUrl = process.env.DATABASE_URL;
11
10
  if (!databaseUrl) throw new Error("DATABASE_URL is required for the PostgreSQL semantic test");
12
11
 
13
- test("reuses the original shared report across server instances", async (t) => {
12
+ test("saves and retrieves a harvest report without exposing server instances", async (t) => {
14
13
  const chat = await createConversation(t, {
15
14
  server: new URL("../dist/server.js", import.meta.url),
16
- environment: { DATABASE_URL: databaseUrl, EMSEEPEA_INSTANCE: "eval-instance" },
15
+ environment: { DATABASE_URL: databaseUrl },
17
16
  });
18
17
 
19
- // Cross-process concurrency stays in ordinary tests because asking the model
20
- // to simulate routing would not exercise it. Only the comparison turn needs
21
- // a semantic judge; the other turns use exact tool and literal assertions.
22
- const created = await chat.send(
23
- "Create a shared harvest report with request ID daily-harvest-report.",
18
+ // Two natural turns cover write and read tool selection at low model cost.
19
+ // Cross-process behavior stays in ordinary tests because a model cannot
20
+ // prove which process served a request.
21
+ const saved = await chat.send(
22
+ "Save a harvest report for North Bed on 2026-09-08 with 12 shelling pea " +
23
+ "plants and 8 snap pea plants.",
24
24
  );
25
- assertToolCalls(created, [
26
- { name: "create-shared-harvest-report", arguments: { requestId: "daily-harvest-report" } },
25
+ assertToolCalls(saved, [
26
+ {
27
+ name: "save-harvest-report",
28
+ arguments: {
29
+ gardenBed: "North Bed",
30
+ harvestDate: "2026-09-08",
31
+ shellingCount: 12,
32
+ snapCount: 8,
33
+ },
34
+ },
27
35
  ]);
28
- const repeated = await chat.send(
29
- "Create that report again with the same request ID. Is its report ID the " +
30
- "same as before?",
36
+ assertResponseContains(saved, ["North Bed", "12", "8", "20"]);
37
+
38
+ const retrieved = await chat.send(
39
+ "What harvest report do we have for that garden bed and date?",
31
40
  );
32
- assertToolCalls(repeated, [
33
- { name: "create-shared-harvest-report", arguments: { requestId: "daily-harvest-report" } },
41
+ assertToolCalls(retrieved, [
42
+ {
43
+ name: "get-harvest-report",
44
+ arguments: { gardenBed: "North Bed", harvestDate: "2026-09-08" },
45
+ },
34
46
  ]);
35
- await assertResponseMeaning(repeated, {
36
- expected: "The repeated request returned the same report ID.",
47
+ await assertResponseMeaning(retrieved, {
48
+ expected:
49
+ "The saved report for North Bed on 2026-09-08 has 12 shelling pea plants, " +
50
+ "8 snap pea plants, and 20 plants in total.",
37
51
  });
38
-
39
- const creator = await chat.send(
40
- "What exact createdByInstance value did those tool results return?",
41
- );
42
- assertNoToolCalls(creator);
43
- assertResponseContains(creator, "eval-instance");
44
52
  });
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "emseepea-starter",
3
3
  "version": "0.0.0",
4
- "description": "Create multiple Em See Pea server instances backed by PostgreSQL.",
4
+ "description": "Create interchangeable Em See Pea server instances sharing PostgreSQL state.",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
7
  "scripts": {
@@ -1,20 +1,7 @@
1
- CREATE TABLE IF NOT EXISTS pea_plants (
2
- name text PRIMARY KEY,
3
- pea_type text NOT NULL CHECK (pea_type IN ('shelling', 'snap'))
4
- );
5
-
6
- INSERT INTO pea_plants (name, pea_type) VALUES
7
- ('Harbour Gem', 'shelling'),
8
- ('Highland Snap', 'snap'),
9
- ('Meadow Sweet', 'snap'),
10
- ('Garden Pearl', 'shelling')
11
- ON CONFLICT (name) DO NOTHING;
12
-
13
- CREATE TABLE IF NOT EXISTS reports (
14
- report_id integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
15
- idempotency_key text NOT NULL UNIQUE,
16
- created_by_instance text NOT NULL,
17
- total_plants integer NOT NULL,
18
- shelling_count integer NOT NULL,
19
- snap_count integer NOT NULL
1
+ CREATE TABLE IF NOT EXISTS harvest_reports (
2
+ garden_bed text NOT NULL,
3
+ harvest_date date NOT NULL,
4
+ shelling_count integer NOT NULL CHECK (shelling_count >= 0),
5
+ snap_count integer NOT NULL CHECK (snap_count >= 0),
6
+ PRIMARY KEY (garden_bed, harvest_date)
20
7
  );
@@ -4,11 +4,9 @@ import { z } from "zod";
4
4
 
5
5
  export interface MultiInstanceExampleOptions {
6
6
  readonly databaseUrl: string;
7
- readonly instanceName: string;
8
7
  }
9
8
 
10
9
  export async function createMultiInstanceExample(options: MultiInstanceExampleOptions) {
11
- const instanceName = z.string().min(1).max(64).parse(options.instanceName);
12
10
  const databaseUrl = z.string().url().refine(
13
11
  (value) => ["postgres:", "postgresql:"].includes(new URL(value).protocol),
14
12
  "databaseUrl must use PostgreSQL",
@@ -28,12 +26,12 @@ export async function createMultiInstanceExample(options: MultiInstanceExampleOp
28
26
  const app = createEmseepea({
29
27
  name: "emseepea-multi-instance-postgres-server",
30
28
  version: "0.0.0",
31
- instructions: "Use create-shared-harvest-report for a stored pea harvest report. Reusing a request ID returns the original report.",
29
+ instructions: "Save and retrieve pea harvest reports by garden bed and harvest date.",
32
30
  readiness: async ({ signal }) => {
33
31
  if (!database) return false;
34
32
  try {
35
33
  signal.throwIfAborted();
36
- await database.query("SELECT 1 FROM reports LIMIT 1");
34
+ await database.query("SELECT 1 FROM harvest_reports LIMIT 1");
37
35
  signal.throwIfAborted();
38
36
  return true;
39
37
  } catch {
@@ -43,7 +41,6 @@ export async function createMultiInstanceExample(options: MultiInstanceExampleOp
43
41
  readinessTimeoutMs: 2_500,
44
42
  ...await discoverCapabilities(new URL("./capabilities/", import.meta.url), {
45
43
  database: () => database,
46
- instanceName,
47
44
  }),
48
45
  });
49
46
  const closeProvider = async () => {
@@ -2,5 +2,4 @@ import type { Pool } from "pg";
2
2
 
3
3
  export interface MultiInstanceContext {
4
4
  readonly database: () => Pool | undefined;
5
- readonly instanceName: string;
6
5
  }
@@ -0,0 +1,39 @@
1
+ import { defineTool, type CapabilityModuleFactory } from "@emseepea/server";
2
+ import { z } from "zod";
3
+ import { reportKeySchema, reportSchema } from "../harvest-report.js";
4
+ import type { MultiInstanceContext } from "./context.js";
5
+
6
+ const inputSchema = reportKeySchema;
7
+ const outputSchema = z.object({
8
+ report: reportSchema.nullable().describe(
9
+ "The saved harvest report, or null when no report exists for that garden bed and date.",
10
+ ),
11
+ });
12
+
13
+ export default ((context) => defineTool({
14
+ name: "get-harvest-report",
15
+ access: "public",
16
+ description: "Get the pea harvest report for a garden bed and date.",
17
+ inputSchema,
18
+ outputSchema,
19
+ async handler({ gardenBed, harvestDate }, { signal }) {
20
+ const database = context.database();
21
+ if (!database) throw new Error("Report provider unavailable");
22
+ signal.throwIfAborted();
23
+ const result = await database.query({
24
+ text: `
25
+ SELECT
26
+ garden_bed AS "gardenBed",
27
+ harvest_date::text AS "harvestDate",
28
+ shelling_count AS "shellingCount",
29
+ snap_count AS "snapCount",
30
+ (shelling_count + snap_count)::integer AS "totalPlants"
31
+ FROM harvest_reports
32
+ WHERE garden_bed = $1 AND harvest_date = $2
33
+ `,
34
+ values: [gardenBed, harvestDate],
35
+ });
36
+ signal.throwIfAborted();
37
+ return { data: { report: result.rows[0] ?? null } };
38
+ },
39
+ })) satisfies CapabilityModuleFactory<MultiInstanceContext>;
@@ -0,0 +1,39 @@
1
+ import { defineTool, type CapabilityModuleFactory } from "@emseepea/server";
2
+ import { z } from "zod";
3
+ import { editableReportSchema, reportSchema } from "../harvest-report.js";
4
+ import type { MultiInstanceContext } from "./context.js";
5
+
6
+ const inputSchema = editableReportSchema;
7
+ const outputSchema = reportSchema;
8
+
9
+ export default ((context) => defineTool({
10
+ name: "save-harvest-report",
11
+ access: "public",
12
+ description: "Save the complete pea harvest report for a garden bed and date.",
13
+ inputSchema,
14
+ outputSchema,
15
+ async handler({ gardenBed, harvestDate, shellingCount, snapCount }, { signal }) {
16
+ const database = context.database();
17
+ if (!database) throw new Error("Report provider unavailable");
18
+ signal.throwIfAborted();
19
+ const result = await database.query({
20
+ text: `
21
+ INSERT INTO harvest_reports (
22
+ garden_bed, harvest_date, shelling_count, snap_count
23
+ ) VALUES ($1, $2, $3, $4)
24
+ ON CONFLICT (garden_bed, harvest_date) DO UPDATE SET
25
+ shelling_count = EXCLUDED.shelling_count,
26
+ snap_count = EXCLUDED.snap_count
27
+ RETURNING
28
+ garden_bed AS "gardenBed",
29
+ harvest_date::text AS "harvestDate",
30
+ shelling_count AS "shellingCount",
31
+ snap_count AS "snapCount",
32
+ (shelling_count + snap_count)::integer AS "totalPlants"
33
+ `,
34
+ values: [gardenBed, harvestDate, shellingCount, snapCount],
35
+ });
36
+ signal.throwIfAborted();
37
+ return { data: result.rows[0] as z.input<typeof outputSchema> };
38
+ },
39
+ })) satisfies CapabilityModuleFactory<MultiInstanceContext>;
@@ -0,0 +1,17 @@
1
+ import { z } from "zod";
2
+
3
+ export const editableReportSchema = z.object({
4
+ gardenBed: z.string().min(1).max(80).describe("Garden bed that this harvest report describes."),
5
+ harvestDate: z.iso.date().describe("Harvest date in YYYY-MM-DD format."),
6
+ shellingCount: z.number().int().nonnegative().describe("Shelling pea plants harvested."),
7
+ snapCount: z.number().int().nonnegative().describe("Snap pea plants harvested."),
8
+ });
9
+
10
+ export const reportKeySchema = editableReportSchema.pick({
11
+ gardenBed: true,
12
+ harvestDate: true,
13
+ });
14
+
15
+ export const reportSchema = editableReportSchema.extend({
16
+ totalPlants: z.number().int().nonnegative().describe("Total pea plants harvested."),
17
+ });
@@ -3,7 +3,7 @@ import { createMultiInstanceExample } from "./app.js";
3
3
 
4
4
  const instanceName = process.env.EMSEEPEA_INSTANCE ?? `instance-${process.pid}`;
5
5
  const databaseUrl = process.env.DATABASE_URL ?? "postgres://emseepea:emseepea@127.0.0.1:5432/emseepea";
6
- const { app, closeProvider } = await createMultiInstanceExample({ databaseUrl, instanceName });
6
+ const { app, closeProvider } = await createMultiInstanceExample({ databaseUrl });
7
7
  const running = await serveEmseepea(app, {
8
8
  port: Number.parseInt(process.env.PORT ?? "3000", 10),
9
9
  });
@@ -16,9 +16,11 @@ const requestMeta = {
16
16
  "io.modelcontextprotocol/clientCapabilities": {},
17
17
  };
18
18
 
19
- test("two server processes share one atomic report store", async (t) => {
19
+ test("two interchangeable server processes share one coherent report store", async (t) => {
20
20
  const first = await startInstance("instance-a", databaseUrl);
21
21
  const second = await startInstance("instance-b", databaseUrl);
22
+ assert.notEqual(first.child.pid, second.child.pid);
23
+ assert.notEqual(first.url.href, second.url.href);
22
24
  const firstClient = await connect(first.url);
23
25
  const secondClient = await connect(second.url);
24
26
  t.after(async () => {
@@ -26,56 +28,46 @@ test("two server processes share one atomic report store", async (t) => {
26
28
  await Promise.all([stopInstance(first.child), stopInstance(second.child)]);
27
29
  });
28
30
 
29
- const localRace = await Promise.all(Array.from({ length: 6 }, () => (
30
- createReport(firstClient, "single-instance-race")
31
- )));
32
- assert.equal(new Set(localRace.map(({ reportId }) => reportId)).size, 1);
33
- assert.equal(await reportCount("single-instance-race"), 1);
31
+ const original = {
32
+ gardenBed: "North Bed",
33
+ harvestDate: "2026-09-08",
34
+ shellingCount: 12,
35
+ snapCount: 8,
36
+ totalPlants: 20,
37
+ };
38
+ assert.deepEqual(await saveReport(firstClient, original), original);
39
+ assert.deepEqual(await getReport(secondClient, original), { report: original });
34
40
 
35
- const [fromFirst, fromSecond] = await Promise.all([
36
- createReport(firstClient, "shared-instance-race"),
37
- createReport(secondClient, "shared-instance-race"),
38
- ]);
39
- assert.deepEqual(fromFirst, fromSecond);
40
- assert.match(fromFirst.createdByInstance, /^instance-[ab]$/);
41
- assert.deepEqual(fromFirst.peaTypeCounts, { shelling: 2, snap: 2 });
42
- assert.equal(fromFirst.totalPlants, 4);
43
- assert.equal(await reportCount("shared-instance-race"), 1);
44
-
45
- const replay = await createReport(
46
- fromFirst.createdByInstance === "instance-a" ? secondClient : firstClient,
47
- "shared-instance-race",
48
- );
49
- assert.deepEqual(replay, fromFirst);
41
+ assert.deepEqual(await saveReport(secondClient, original), original);
42
+ assert.equal(await reportCount(original), 1);
43
+
44
+ const updated = { ...original, shellingCount: 14, totalPlants: 22 };
45
+ assert.deepEqual(await saveReport(secondClient, updated), updated);
46
+ assert.deepEqual(await getReport(firstClient, updated), { report: updated });
47
+ assert.equal(await reportCount(updated), 1);
48
+
49
+ assert.deepEqual(await getReport(secondClient, {
50
+ gardenBed: "Missing Bed",
51
+ harvestDate: original.harvestDate,
52
+ }), { report: null });
50
53
 
51
- const raw = await rawCreateReport(first.url, "raw-http-report");
54
+ const raw = await rawCall(first.url, "get-harvest-report", reportKey(updated));
52
55
  assert.equal(raw.response.status, 200);
53
56
  assert.equal(raw.body.result.isError, false);
54
- assert.equal(raw.body.result.structuredContent.requestId, "raw-http-report");
55
- assert.equal(raw.body.result.content[0].text, JSON.stringify(raw.body.result.structuredContent));
56
- assert.equal(await reportCount("raw-http-report"), 1);
57
+ assert.deepEqual(raw.body.result.structuredContent, { report: updated });
58
+ assert.equal(raw.body.result.content[0].text, JSON.stringify({ report: updated }));
57
59
 
58
60
  await closeProvider(first.child);
59
- const unavailable = await rawCreateReport(first.url, "must-not-be-created");
60
- assert.equal(unavailable.response.status, 200);
61
- assert.equal(unavailable.body.result.content[0].text, "Tool execution failed");
62
- assert.doesNotMatch(
63
- JSON.stringify({ ...unavailable.body.result, _meta: undefined }),
64
- /postgres|database|connection|provider/i,
65
- );
66
- assert.equal(await reportCount("must-not-be-created"), 0);
61
+ const unavailable = await rawCall(first.url, "get-harvest-report", reportKey(updated));
62
+ assertGenericToolFailure(unavailable);
63
+ assert.deepEqual(await getReport(secondClient, updated), { report: updated });
67
64
 
68
- const independent = await firstClient.callTool({ name: "describe-instance", arguments: {} });
69
- assert.deepEqual(independent.structuredContent, { instanceName: "instance-a" });
70
65
  const readiness = await fetch(new URL("/readyz", first.url));
71
66
  assert.equal(readiness.status, 503);
72
67
  assert.equal(await readiness.text(), "not ready\n");
73
-
74
- const secondStillWorks = await createReport(secondClient, "provider-b-still-works");
75
- assert.equal(secondStillWorks.requestId, "provider-b-still-works");
76
68
  });
77
69
 
78
- test("describes every multi-instance tool property", async (t) => {
70
+ test("describes every public harvest report property", async (t) => {
79
71
  const instance = await startInstance("schema-instance", databaseUrl);
80
72
  const client = await connect(instance.url);
81
73
  t.after(async () => {
@@ -85,43 +77,52 @@ test("describes every multi-instance tool property", async (t) => {
85
77
 
86
78
  const listed = await client.listTools();
87
79
  assert.deepEqual(listed.tools.map(({ name }) => name), [
88
- "create-shared-harvest-report",
89
- "describe-instance",
80
+ "get-harvest-report",
81
+ "save-harvest-report",
90
82
  ]);
91
- const reportInput = listed.tools[0].inputSchema.properties;
92
- const reportOutput = listed.tools[0].outputSchema.properties;
93
- assert.equal(reportInput.requestId.description, "Idempotency key. Reusing it returns the existing report instead of creating another.");
94
- assert.equal(reportOutput.reportId.description, "Stored report identifier.");
95
- assert.equal(reportOutput.requestId.description, reportInput.requestId.description);
96
- assert.equal(reportOutput.createdByInstance.description, "Server instance that originally created the report.");
97
- assert.equal(reportOutput.totalPlants.description, "Total pea plants counted in the report.");
98
- assert.equal(reportOutput.peaTypeCounts.description, "Plant counts grouped by pea type.");
99
- assert.equal(reportOutput.peaTypeCounts.properties.shelling.description, "Shelling pea plants counted in the report.");
100
- assert.equal(reportOutput.peaTypeCounts.properties.snap.description, "Snap pea plants counted in the report.");
101
- assert.equal(listed.tools[1].outputSchema.properties.instanceName.description, "Server instance that handled this request.");
83
+
84
+ const get = listed.tools[0];
85
+ const save = listed.tools[1];
86
+ assert.equal(get.inputSchema.properties.gardenBed.description, "Garden bed that this harvest report describes.");
87
+ assert.equal(get.inputSchema.properties.harvestDate.description, "Harvest date in YYYY-MM-DD format.");
88
+ assert.equal(
89
+ get.outputSchema.properties.report.description,
90
+ "The saved harvest report, or null when no report exists for that garden bed and date.",
91
+ );
92
+ const retrievedReport = get.outputSchema.properties.report.anyOf[0].properties;
93
+ assert.equal(retrievedReport.gardenBed.description, "Garden bed that this harvest report describes.");
94
+ assert.equal(retrievedReport.harvestDate.description, "Harvest date in YYYY-MM-DD format.");
95
+ assert.equal(retrievedReport.shellingCount.description, "Shelling pea plants harvested.");
96
+ assert.equal(retrievedReport.snapCount.description, "Snap pea plants harvested.");
97
+ assert.equal(retrievedReport.totalPlants.description, "Total pea plants harvested.");
98
+ assert.equal(save.inputSchema.properties.gardenBed.description, "Garden bed that this harvest report describes.");
99
+ assert.equal(save.inputSchema.properties.harvestDate.description, "Harvest date in YYYY-MM-DD format.");
100
+ assert.equal(save.inputSchema.properties.shellingCount.description, "Shelling pea plants harvested.");
101
+ assert.equal(save.inputSchema.properties.snapCount.description, "Snap pea plants harvested.");
102
+ assert.equal(save.outputSchema.properties.gardenBed.description, "Garden bed that this harvest report describes.");
103
+ assert.equal(save.outputSchema.properties.harvestDate.description, "Harvest date in YYYY-MM-DD format.");
104
+ assert.equal(save.outputSchema.properties.shellingCount.description, "Shelling pea plants harvested.");
105
+ assert.equal(save.outputSchema.properties.snapCount.description, "Snap pea plants harvested.");
106
+ assert.equal(save.outputSchema.properties.totalPlants.description, "Total pea plants harvested.");
102
107
  });
103
108
 
104
- test("an unavailable PostgreSQL provider fails readiness but not independent tools", async (t) => {
109
+ test("an unavailable PostgreSQL provider fails safely", async (t) => {
105
110
  const instance = await startInstance(
106
111
  "unavailable-before-start",
107
112
  "postgres://emseepea:emseepea@127.0.0.1:1/emseepea",
108
113
  );
109
- const client = await connect(instance.url);
110
- t.after(async () => {
111
- await client.close();
112
- await stopInstance(instance.child);
113
- });
114
+ t.after(() => stopInstance(instance.child));
114
115
 
115
- assert.deepEqual(
116
- (await client.callTool({ name: "describe-instance", arguments: {} })).structuredContent,
117
- { instanceName: "unavailable-before-start" },
118
- );
119
- const unavailable = await rawCreateReport(instance.url, "provider-never-connected");
120
- assert.equal(unavailable.body.result.content[0].text, "Tool execution failed");
121
- assert.doesNotMatch(
122
- JSON.stringify({ ...unavailable.body.result, _meta: undefined }),
123
- /postgres|database|connection|ECONNREFUSED|provider/i,
124
- );
116
+ assertGenericToolFailure(await rawCall(instance.url, "get-harvest-report", {
117
+ gardenBed: "Unavailable Bed",
118
+ harvestDate: "2026-09-08",
119
+ }));
120
+ assertGenericToolFailure(await rawCall(instance.url, "save-harvest-report", {
121
+ gardenBed: "Unavailable Bed",
122
+ harvestDate: "2026-09-08",
123
+ shellingCount: 1,
124
+ snapCount: 1,
125
+ }));
125
126
  const readiness = await fetch(new URL("/readyz", instance.url));
126
127
  assert.equal(readiness.status, 503);
127
128
  assert.equal(await readiness.text(), "not ready\n");
@@ -136,19 +137,27 @@ test("blocked PostgreSQL work finishes at the database timeout", async (t) => {
136
137
  await stopInstance(instance.child);
137
138
  });
138
139
  await blocker.query("BEGIN");
139
- await blocker.query("LOCK TABLE reports IN ACCESS EXCLUSIVE MODE");
140
+ await blocker.query("LOCK TABLE harvest_reports IN ACCESS EXCLUSIVE MODE");
140
141
 
142
+ const blockedReport = {
143
+ gardenBed: "Blocked Bed",
144
+ harvestDate: "2026-09-08",
145
+ shellingCount: 2,
146
+ snapCount: 3,
147
+ };
141
148
  const started = Date.now();
142
- const [readiness, report] = await Promise.all([
149
+ const [readiness, save, get] = await Promise.all([
143
150
  fetch(new URL("/readyz", instance.url)),
144
- rawCreateReport(instance.url, "blocked-report"),
151
+ rawCall(instance.url, "save-harvest-report", blockedReport),
152
+ rawCall(instance.url, "get-harvest-report", reportKey(blockedReport)),
145
153
  ]);
146
154
 
147
155
  assert.ok(Date.now() - started < 3_000, "blocked database work exceeded its bounded timeout");
148
156
  assert.equal(readiness.status, 503);
149
- assert.equal(report.body.result.content[0].text, "Tool execution failed");
157
+ assertGenericToolFailure(save);
158
+ assertGenericToolFailure(get);
150
159
  await blocker.query("ROLLBACK");
151
- assert.equal(await reportCount("blocked-report"), 0);
160
+ assert.equal(await reportCount(blockedReport), 0);
152
161
  });
153
162
 
154
163
  async function startInstance(instanceName, connectionString) {
@@ -172,13 +181,26 @@ async function connect(url) {
172
181
  return client;
173
182
  }
174
183
 
175
- async function createReport(client, requestId) {
176
- const result = await client.callTool({ name: "create-shared-harvest-report", arguments: { requestId } });
184
+ async function saveReport(client, report) {
185
+ const result = await client.callTool({
186
+ name: "save-harvest-report",
187
+ arguments: { ...reportKey(report), shellingCount: report.shellingCount, snapCount: report.snapCount },
188
+ });
189
+ assert.equal(result.isError, false);
190
+ return result.structuredContent;
191
+ }
192
+
193
+ async function getReport(client, report) {
194
+ const result = await client.callTool({ name: "get-harvest-report", arguments: reportKey(report) });
177
195
  assert.equal(result.isError, false);
178
196
  return result.structuredContent;
179
197
  }
180
198
 
181
- async function rawCreateReport(url, requestId) {
199
+ function reportKey({ gardenBed, harvestDate }) {
200
+ return { gardenBed, harvestDate };
201
+ }
202
+
203
+ async function rawCall(url, name, arguments_) {
182
204
  const response = await fetch(url, {
183
205
  method: "POST",
184
206
  headers: {
@@ -186,22 +208,31 @@ async function rawCreateReport(url, requestId) {
186
208
  "Content-Type": "application/json",
187
209
  "MCP-Protocol-Version": "2026-07-28",
188
210
  "Mcp-Method": "tools/call",
189
- "Mcp-Name": "create-shared-harvest-report",
211
+ "Mcp-Name": name,
190
212
  },
191
213
  body: JSON.stringify({
192
214
  jsonrpc: "2.0",
193
215
  id: crypto.randomUUID(),
194
216
  method: "tools/call",
195
- params: { name: "create-shared-harvest-report", arguments: { requestId }, _meta: requestMeta },
217
+ params: { name, arguments: arguments_, _meta: requestMeta },
196
218
  }),
197
219
  });
198
220
  return { response, body: await response.json() };
199
221
  }
200
222
 
201
- async function reportCount(requestId) {
223
+ function assertGenericToolFailure(result) {
224
+ assert.equal(result.response.status, 200);
225
+ assert.equal(result.body.result.content[0].text, "Tool execution failed");
226
+ assert.doesNotMatch(
227
+ JSON.stringify({ ...result.body.result, _meta: undefined }),
228
+ /postgres|database|connection|ECONNREFUSED|provider/i,
229
+ );
230
+ }
231
+
232
+ async function reportCount({ gardenBed, harvestDate }) {
202
233
  const result = await database.query(
203
- "SELECT COUNT(*)::integer AS count FROM reports WHERE idempotency_key = $1",
204
- [requestId],
234
+ "SELECT COUNT(*)::integer AS count FROM harvest_reports WHERE garden_bed = $1 AND harvest_date = $2",
235
+ [gardenBed, harvestDate],
205
236
  );
206
237
  return result.rows[0].count;
207
238
  }
package/package.json CHANGED
@@ -1,14 +1,10 @@
1
1
  {
2
2
  "name": "@emseepea/create-multi-instance-postgres-server",
3
- "version": "0.0.1",
4
- "description": "Create multiple Em See Pea server instances backed by PostgreSQL.",
3
+ "version": "0.0.3",
4
+ "description": "Create interchangeable Em See Pea server instances sharing PostgreSQL state.",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
- "starterDependencies": [
8
- "@emseepea/server",
9
- "pg",
10
- "zod"
11
- ],
7
+ "starterDependencies": ["@emseepea/server", "pg", "zod"],
12
8
  "scripts": {
13
9
  "build": "npm run build:example && npm run build:initializer",
14
10
  "build:example": "tsc -p tsconfig.json",
@@ -26,33 +22,22 @@
26
22
  },
27
23
  "devDependencies": {
28
24
  "@emseepea/server": "0.3.3",
25
+ "pg": "8.23.0",
26
+ "zod": "4.4.3",
29
27
  "@emseepea/testing": "0.5.2",
30
28
  "@modelcontextprotocol/client": "2.0.0",
31
29
  "@types/node": "24.13.3",
32
30
  "@types/pg": "8.23.1",
33
- "oxlint": "1.80.0",
34
- "pg": "8.23.0",
35
31
  "typescript": "6.0.3",
36
- "zod": "4.4.3"
32
+ "oxlint": "1.80.0"
37
33
  },
38
34
  "engines": {
39
35
  "node": ">=22.13.0"
40
36
  },
41
- "repository": {
42
- "type": "git",
43
- "url": "git+https://github.com/emseepea/emseepea.git",
44
- "directory": "examples/multi-instance-postgres-server"
45
- },
37
+ "repository": { "type": "git", "url": "git+https://github.com/emseepea/emseepea.git", "directory": "examples/multi-instance-postgres-server" },
46
38
  "homepage": "https://emseepea.github.io/emseepea/examples/",
47
39
  "bugs": "https://github.com/emseepea/emseepea/issues",
48
- "publishConfig": {
49
- "access": "public",
50
- "provenance": true
51
- },
52
- "bin": {
53
- "create-multi-instance-postgres-server": "./initializer-dist/create.mjs"
54
- },
55
- "files": [
56
- "initializer-dist"
57
- ]
40
+ "publishConfig": { "access": "public", "provenance": true },
41
+ "bin": { "create-multi-instance-postgres-server": "./initializer-dist/create.mjs" },
42
+ "files": ["initializer-dist"]
58
43
  }
@@ -1,81 +0,0 @@
1
- import { defineMappedTool, type CapabilityModuleFactory } from "@emseepea/server";
2
- import { z } from "zod";
3
- import type { MultiInstanceContext } from "./context.js";
4
-
5
- const requestIdSchema = z.string().min(3).max(64).regex(/^[a-z0-9][a-z0-9-]*$/)
6
- .describe("Idempotency key. Reusing it returns the existing report instead of creating another.");
7
- const inputSchema = z.object({ requestId: requestIdSchema });
8
- const outputSchema = z.object({
9
- reportId: z.number().int().positive().describe("Stored report identifier."),
10
- requestId: requestIdSchema,
11
- createdByInstance: z.string().min(1).max(64).describe("Server instance that originally created the report."),
12
- totalPlants: z.number().int().nonnegative().describe("Total pea plants counted in the report."),
13
- peaTypeCounts: z.object({
14
- shelling: z.number().int().nonnegative().describe("Shelling pea plants counted in the report."),
15
- snap: z.number().int().nonnegative().describe("Snap pea plants counted in the report."),
16
- }).describe("Plant counts grouped by pea type."),
17
- });
18
- const backendInputSchema = z.object({ idempotency_key: requestIdSchema });
19
- const backendOutputSchema = z.object({
20
- report_id: z.number().int().positive(),
21
- idempotency_key: requestIdSchema,
22
- created_by_instance: z.string().min(1).max(64),
23
- total_plants: z.number().int().nonnegative(),
24
- shelling_count: z.number().int().nonnegative(),
25
- snap_count: z.number().int().nonnegative(),
26
- });
27
-
28
- export default ((context) => defineMappedTool({
29
- name: "create-shared-harvest-report",
30
- access: "public",
31
- description: "Create or return one stored pea harvest report per request ID. The result identifies its original server instance.",
32
- inputSchema,
33
- outputSchema,
34
- backendInputSchema,
35
- backendOutputSchema,
36
- isAvailable: () => {
37
- return context.database() !== undefined;
38
- },
39
- mapInput: ({ requestId }) => ({ idempotency_key: requestId }),
40
- async adapter({ idempotency_key }, { signal }) {
41
- const database = context.database();
42
- if (!database) throw new Error("Report provider unavailable");
43
- signal.throwIfAborted();
44
- const result = await database.query({
45
- text: `
46
- INSERT INTO reports (
47
- idempotency_key, created_by_instance, total_plants,
48
- shelling_count, snap_count
49
- )
50
- SELECT
51
- $1,
52
- $2,
53
- COUNT(*)::integer,
54
- COUNT(*) FILTER (WHERE pea_type = 'shelling')::integer,
55
- COUNT(*) FILTER (WHERE pea_type = 'snap')::integer
56
- FROM pea_plants
57
- ON CONFLICT (idempotency_key) DO UPDATE
58
- SET idempotency_key = EXCLUDED.idempotency_key
59
- RETURNING
60
- report_id, idempotency_key, created_by_instance, total_plants,
61
- shelling_count, snap_count
62
- `,
63
- values: [idempotency_key, context.instanceName],
64
- });
65
- signal.throwIfAborted();
66
- return result.rows[0] as z.input<typeof backendOutputSchema>;
67
- },
68
- mapOutput: (report) => {
69
- const data = {
70
- reportId: report.report_id,
71
- requestId: report.idempotency_key,
72
- createdByInstance: report.created_by_instance,
73
- totalPlants: report.total_plants,
74
- peaTypeCounts: {
75
- shelling: report.shelling_count,
76
- snap: report.snap_count,
77
- },
78
- };
79
- return { data };
80
- },
81
- })) satisfies CapabilityModuleFactory<MultiInstanceContext>;
@@ -1,14 +0,0 @@
1
- import { defineTool, type CapabilityModuleFactory } from "@emseepea/server";
2
- import { z } from "zod";
3
- import type { MultiInstanceContext } from "./context.js";
4
-
5
- export default (({ instanceName }) => defineTool({
6
- name: "describe-instance",
7
- access: "public",
8
- description: "Return the server instance handling this request, not the instance that created a stored report.",
9
- inputSchema: z.object({}),
10
- outputSchema: z.object({
11
- instanceName: z.string().describe("Server instance that handled this request."),
12
- }),
13
- handler: () => ({ data: { instanceName } }),
14
- })) satisfies CapabilityModuleFactory<MultiInstanceContext>;