@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 +49 -49
- package/initializer-dist/template/README.md +37 -42
- package/initializer-dist/template/eval/meaning.test.mjs +31 -23
- package/initializer-dist/template/package.json +1 -1
- package/initializer-dist/template/schema.sql +6 -19
- package/initializer-dist/template/src/app.ts +2 -5
- package/initializer-dist/template/src/capabilities/context.ts +0 -1
- package/initializer-dist/template/src/capabilities/tool.get-harvest-report.ts +39 -0
- package/initializer-dist/template/src/capabilities/tool.save-harvest-report.ts +39 -0
- package/initializer-dist/template/src/harvest-report.ts +17 -0
- package/initializer-dist/template/src/server.ts +1 -1
- package/initializer-dist/template/test/server.test.mjs +112 -81
- package/package.json +10 -25
- package/initializer-dist/template/src/capabilities/tool.create-shared-harvest-report.ts +0 -81
- package/initializer-dist/template/src/capabilities/tool.describe-instance.ts +0 -14
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
|
|
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
|
|
9
|
-
PostgreSQL-backed
|
|
10
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
26
|
-
and
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
|
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
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
|
|
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
|
-
|
|
65
|
+
PORT=3000 npm run start:instance
|
|
64
66
|
```
|
|
65
67
|
|
|
66
|
-
|
|
67
|
-
`EMSEEPEA_INSTANCE`
|
|
68
|
-
|
|
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
|
-
- `
|
|
73
|
-
|
|
74
|
-
- `
|
|
75
|
-
|
|
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
|
|
82
|
-
|
|
83
|
-
|
|
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
|
|
86
|
+
- Independently deployable server processes connected to one PostgreSQL database.
|
|
88
87
|
- One bounded connection pool per process.
|
|
89
|
-
- One
|
|
90
|
-
- PostgreSQL bounds
|
|
91
|
-
|
|
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
|
|
94
|
-
- No latency, throughput, or unlimited
|
|
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/)
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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-
|
|
103
|
+
Run the build and two-process MCP checks:
|
|
104
104
|
|
|
105
105
|
```sh
|
|
106
106
|
npm test
|
|
107
107
|
```
|
|
108
108
|
|
|
109
|
-
|
|
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
|
-
|
|
4
|
-
and
|
|
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
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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
|
|
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
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
|
|
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
|
-
|
|
38
|
+
PORT=3000 npm run start:instance
|
|
42
39
|
```
|
|
43
40
|
|
|
44
|
-
|
|
45
|
-
`EMSEEPEA_INSTANCE`
|
|
46
|
-
|
|
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
|
-
- `
|
|
51
|
-
|
|
52
|
-
- `
|
|
53
|
-
|
|
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
|
|
60
|
-
|
|
61
|
-
|
|
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
|
|
59
|
+
- Independently deployable server processes connected to one PostgreSQL database.
|
|
66
60
|
- One bounded connection pool per process.
|
|
67
|
-
- One
|
|
68
|
-
- PostgreSQL bounds
|
|
69
|
-
|
|
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
|
|
72
|
-
- No latency, throughput, or unlimited
|
|
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/)
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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-
|
|
76
|
+
Run the build and two-process MCP checks:
|
|
82
77
|
|
|
83
78
|
```sh
|
|
84
79
|
npm test
|
|
85
80
|
```
|
|
86
81
|
|
|
87
|
-
|
|
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("
|
|
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
|
|
15
|
+
environment: { DATABASE_URL: databaseUrl },
|
|
17
16
|
});
|
|
18
17
|
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
const
|
|
23
|
-
"
|
|
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(
|
|
26
|
-
{
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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(
|
|
33
|
-
{
|
|
41
|
+
assertToolCalls(retrieved, [
|
|
42
|
+
{
|
|
43
|
+
name: "get-harvest-report",
|
|
44
|
+
arguments: { gardenBed: "North Bed", harvestDate: "2026-09-08" },
|
|
45
|
+
},
|
|
34
46
|
]);
|
|
35
|
-
await assertResponseMeaning(
|
|
36
|
-
expected:
|
|
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
|
|
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
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
)
|
|
5
|
-
|
|
6
|
-
|
|
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: "
|
|
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
|
|
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 () => {
|
|
@@ -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
|
|
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
|
|
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
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
assert.deepEqual(
|
|
40
|
-
assert.
|
|
41
|
-
assert.
|
|
42
|
-
|
|
43
|
-
assert.
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
|
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.
|
|
55
|
-
assert.equal(raw.body.result.content[0].text, JSON.stringify(
|
|
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
|
|
60
|
-
|
|
61
|
-
assert.
|
|
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
|
|
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
|
-
"
|
|
89
|
-
"
|
|
80
|
+
"get-harvest-report",
|
|
81
|
+
"save-harvest-report",
|
|
90
82
|
]);
|
|
91
|
-
|
|
92
|
-
const
|
|
93
|
-
|
|
94
|
-
assert.equal(
|
|
95
|
-
assert.equal(
|
|
96
|
-
assert.equal(
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
assert.equal(
|
|
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
|
|
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
|
-
|
|
110
|
-
t.after(async () => {
|
|
111
|
-
await client.close();
|
|
112
|
-
await stopInstance(instance.child);
|
|
113
|
-
});
|
|
114
|
+
t.after(() => stopInstance(instance.child));
|
|
114
115
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
);
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
|
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,
|
|
149
|
+
const [readiness, save, get] = await Promise.all([
|
|
143
150
|
fetch(new URL("/readyz", instance.url)),
|
|
144
|
-
|
|
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
|
-
|
|
157
|
+
assertGenericToolFailure(save);
|
|
158
|
+
assertGenericToolFailure(get);
|
|
150
159
|
await blocker.query("ROLLBACK");
|
|
151
|
-
assert.equal(await reportCount(
|
|
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
|
|
176
|
-
const result = await client.callTool({
|
|
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
|
-
|
|
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":
|
|
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
|
|
217
|
+
params: { name, arguments: arguments_, _meta: requestMeta },
|
|
196
218
|
}),
|
|
197
219
|
});
|
|
198
220
|
return { response, body: await response.json() };
|
|
199
221
|
}
|
|
200
222
|
|
|
201
|
-
|
|
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
|
|
204
|
-
[
|
|
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.
|
|
4
|
-
"description": "Create
|
|
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
|
-
"
|
|
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
|
-
|
|
50
|
-
|
|
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>;
|