@cloudgrid-io/mcp 0.8.2 → 0.9.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +4 -1
- package/src/corpus/examples/app-with-data/index.md +251 -0
- package/src/corpus/templates/app-with-data/README.md +70 -0
- package/src/corpus/templates/app-with-data/cloudgrid.yaml +15 -0
- package/src/corpus/templates/app-with-data/index.md +261 -0
- package/src/corpus/templates/app-with-data/services/web/app/api/todos/route.js +48 -0
- package/src/corpus/templates/app-with-data/services/web/app/layout.js +32 -0
- package/src/corpus/templates/app-with-data/services/web/app/page.js +24 -0
- package/src/corpus/templates/app-with-data/services/web/app/todo-form.js +58 -0
- package/src/corpus/templates/app-with-data/services/web/lib/db.js +31 -0
- package/src/corpus/templates/app-with-data/services/web/package.json +16 -0
- package/src/corpus/troubleshooting/persistent-apps.md +22 -0
- package/src/corpus/workflows/app-with-data.md +115 -0
- package/src/tools.js +321 -20
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cloudgrid-io/mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.1",
|
|
4
4
|
"description": "MCP server for CloudGrid. Three editions: a local stdio server (full toolset), a hosted web server (light, CLI-free toolset), and a public docs server (search_cloudgrid_documentation) over MCP Streamable HTTP.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -18,6 +18,9 @@
|
|
|
18
18
|
"smoke": "node test/smoke.mjs",
|
|
19
19
|
"test:plug-wire": "node test/plug-wire.test.mjs",
|
|
20
20
|
"test:error-reporting": "node test/error-reporting.test.mjs",
|
|
21
|
+
"test:source": "node test/source-fetch.test.mjs",
|
|
22
|
+
"test:app-with-data": "node test/app-with-data.test.mjs",
|
|
23
|
+
"test:content": "node test/content-handling.test.mjs",
|
|
21
24
|
"test:self-healing": "node test/self-healing.test.mjs",
|
|
22
25
|
"test:grid-picker": "node test/grid-picker.test.mjs",
|
|
23
26
|
"test:drift-guard": "node test/drift-guard.mjs",
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
# Example: app-with-data — "Team Task Board"
|
|
2
|
+
|
|
3
|
+
A filled reference imitating the `app-with-data` template: a persistent team
|
|
4
|
+
task board. Same stack (Next.js App Router + the `mongodb` driver, grid-shared
|
|
5
|
+
Mongo via `process.env.MONGODB_URL`), slightly richer than the bare todo
|
|
6
|
+
template — tasks have an `assignee` and a `status` (todo / doing / done), and
|
|
7
|
+
the API supports updating status via PATCH.
|
|
8
|
+
|
|
9
|
+
Data persists in the grid-shared Mongo, so the whole team sees the same board
|
|
10
|
+
across sessions and refresh.
|
|
11
|
+
|
|
12
|
+
**Same three proven rules as the template:** (1) app code lives under
|
|
13
|
+
**`services/web/`** (the service name, NOT the repo root — `path:` is the URL
|
|
14
|
+
mount); (2) `MONGODB_URL` is read **lazily inside the `getDb` getter**, never at
|
|
15
|
+
module top level (a top-level read fails `next build`); (3) the datastore is
|
|
16
|
+
declared with **`requires: [mongodb]`, not `needs:`** — the deployer only
|
|
17
|
+
injects `MONGODB_URL` from `requires:`.
|
|
18
|
+
|
|
19
|
+
## cloudgrid.yaml
|
|
20
|
+
|
|
21
|
+
```yaml
|
|
22
|
+
# NOTE: use `requires:` (NOT `needs:`). The deployer only injects MONGODB_URL
|
|
23
|
+
# from `requires:`; `needs:` builds fine but injects NO DB connection (500s).
|
|
24
|
+
name: team-task-board
|
|
25
|
+
services:
|
|
26
|
+
web:
|
|
27
|
+
type: nextjs
|
|
28
|
+
path: /
|
|
29
|
+
requires:
|
|
30
|
+
- mongodb
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## services/web/package.json
|
|
34
|
+
|
|
35
|
+
```json
|
|
36
|
+
{
|
|
37
|
+
"name": "team-task-board",
|
|
38
|
+
"version": "0.1.0",
|
|
39
|
+
"private": true,
|
|
40
|
+
"scripts": { "dev": "next dev", "build": "next build", "start": "next start" },
|
|
41
|
+
"dependencies": {
|
|
42
|
+
"next": "^15.1.0",
|
|
43
|
+
"react": "^19.0.0",
|
|
44
|
+
"react-dom": "^19.0.0",
|
|
45
|
+
"mongodb": "^6.12.0"
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## services/web/lib/db.js
|
|
51
|
+
|
|
52
|
+
```js
|
|
53
|
+
// Cached Mongo client. MONGODB_URL is injected by the grid (dev + runtime).
|
|
54
|
+
// Resolve it LAZILY inside the getter — a top-level read fails `next build`.
|
|
55
|
+
import { MongoClient } from "mongodb";
|
|
56
|
+
|
|
57
|
+
function clientPromise() {
|
|
58
|
+
const uri = process.env.MONGODB_URL;
|
|
59
|
+
if (!uri) {
|
|
60
|
+
throw new Error(
|
|
61
|
+
"MONGODB_URL is not set. The grid injects it — run `grid dev` locally or " +
|
|
62
|
+
"deploy with `grid plug`. Do not set it by hand.",
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
if (!globalThis.__mongoClientPromise) {
|
|
66
|
+
globalThis.__mongoClientPromise = new MongoClient(uri).connect();
|
|
67
|
+
}
|
|
68
|
+
return globalThis.__mongoClientPromise;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export async function getDb() {
|
|
72
|
+
return (await clientPromise()).db();
|
|
73
|
+
}
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## services/web/app/api/tasks/route.js
|
|
77
|
+
|
|
78
|
+
```js
|
|
79
|
+
import { NextResponse } from "next/server";
|
|
80
|
+
import { ObjectId } from "mongodb";
|
|
81
|
+
import { getDb } from "../../../lib/db.js";
|
|
82
|
+
|
|
83
|
+
export const dynamic = "force-dynamic";
|
|
84
|
+
|
|
85
|
+
const STATUSES = ["todo", "doing", "done"];
|
|
86
|
+
|
|
87
|
+
async function tasks() {
|
|
88
|
+
return (await getDb()).collection("tasks");
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// GET /api/tasks — list the board.
|
|
92
|
+
export async function GET() {
|
|
93
|
+
const col = await tasks();
|
|
94
|
+
const items = await col.find({}).sort({ createdAt: -1 }).toArray();
|
|
95
|
+
return NextResponse.json(
|
|
96
|
+
items.map((t) => ({
|
|
97
|
+
id: t._id.toString(),
|
|
98
|
+
title: t.title,
|
|
99
|
+
assignee: t.assignee || "",
|
|
100
|
+
status: t.status || "todo",
|
|
101
|
+
})),
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// POST /api/tasks — add a task. Body: { title, assignee }.
|
|
106
|
+
export async function POST(request) {
|
|
107
|
+
const body = await request.json().catch(() => ({}));
|
|
108
|
+
const title = typeof body.title === "string" ? body.title.trim() : "";
|
|
109
|
+
if (!title) return NextResponse.json({ error: "title is required" }, { status: 400 });
|
|
110
|
+
const doc = {
|
|
111
|
+
title,
|
|
112
|
+
assignee: typeof body.assignee === "string" ? body.assignee.trim() : "",
|
|
113
|
+
status: "todo",
|
|
114
|
+
createdAt: new Date(),
|
|
115
|
+
};
|
|
116
|
+
const col = await tasks();
|
|
117
|
+
const res = await col.insertOne(doc);
|
|
118
|
+
return NextResponse.json({ id: res.insertedId.toString(), ...doc }, { status: 201 });
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// PATCH /api/tasks — move a task. Body: { id, status }.
|
|
122
|
+
export async function PATCH(request) {
|
|
123
|
+
const body = await request.json().catch(() => ({}));
|
|
124
|
+
const { id, status } = body;
|
|
125
|
+
if (!id || !ObjectId.isValid(id) || !STATUSES.includes(status)) {
|
|
126
|
+
return NextResponse.json({ error: "valid id and status required" }, { status: 400 });
|
|
127
|
+
}
|
|
128
|
+
const col = await tasks();
|
|
129
|
+
await col.updateOne({ _id: new ObjectId(id) }, { $set: { status } });
|
|
130
|
+
return NextResponse.json({ ok: true });
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// DELETE /api/tasks?id=<id>
|
|
134
|
+
export async function DELETE(request) {
|
|
135
|
+
const id = new URL(request.url).searchParams.get("id");
|
|
136
|
+
if (!id || !ObjectId.isValid(id)) {
|
|
137
|
+
return NextResponse.json({ error: "valid id is required" }, { status: 400 });
|
|
138
|
+
}
|
|
139
|
+
const col = await tasks();
|
|
140
|
+
await col.deleteOne({ _id: new ObjectId(id) });
|
|
141
|
+
return NextResponse.json({ ok: true });
|
|
142
|
+
}
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
## services/web/app/page.js
|
|
146
|
+
|
|
147
|
+
```js
|
|
148
|
+
import { getDb } from "../lib/db.js";
|
|
149
|
+
import Board from "./board.js";
|
|
150
|
+
|
|
151
|
+
export const dynamic = "force-dynamic";
|
|
152
|
+
|
|
153
|
+
async function listTasks() {
|
|
154
|
+
const items = await (await getDb())
|
|
155
|
+
.collection("tasks")
|
|
156
|
+
.find({})
|
|
157
|
+
.sort({ createdAt: -1 })
|
|
158
|
+
.toArray();
|
|
159
|
+
return items.map((t) => ({
|
|
160
|
+
id: t._id.toString(),
|
|
161
|
+
title: t.title,
|
|
162
|
+
assignee: t.assignee || "",
|
|
163
|
+
status: t.status || "todo",
|
|
164
|
+
}));
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export default async function Page() {
|
|
168
|
+
const tasks = await listTasks();
|
|
169
|
+
return (
|
|
170
|
+
<main>
|
|
171
|
+
<h1>Team Task Board</h1>
|
|
172
|
+
<p className="hint">Shared across the team — persisted in grid Mongo.</p>
|
|
173
|
+
<Board initialTasks={tasks} />
|
|
174
|
+
</main>
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
## services/web/app/board.js
|
|
180
|
+
|
|
181
|
+
```js
|
|
182
|
+
"use client";
|
|
183
|
+
import { useState } from "react";
|
|
184
|
+
|
|
185
|
+
const COLUMNS = ["todo", "doing", "done"];
|
|
186
|
+
const LABELS = { todo: "To do", doing: "Doing", done: "Done" };
|
|
187
|
+
|
|
188
|
+
export default function Board({ initialTasks }) {
|
|
189
|
+
const [tasks, setTasks] = useState(initialTasks);
|
|
190
|
+
const [title, setTitle] = useState("");
|
|
191
|
+
const [assignee, setAssignee] = useState("");
|
|
192
|
+
|
|
193
|
+
async function add(e) {
|
|
194
|
+
e.preventDefault();
|
|
195
|
+
if (!title.trim()) return;
|
|
196
|
+
const res = await fetch("/api/tasks", {
|
|
197
|
+
method: "POST",
|
|
198
|
+
headers: { "content-type": "application/json" },
|
|
199
|
+
body: JSON.stringify({ title, assignee }),
|
|
200
|
+
});
|
|
201
|
+
if (res.ok) { setTasks((p) => [await res.json(), ...p]); setTitle(""); setAssignee(""); }
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
async function move(id, status) {
|
|
205
|
+
const res = await fetch("/api/tasks", {
|
|
206
|
+
method: "PATCH",
|
|
207
|
+
headers: { "content-type": "application/json" },
|
|
208
|
+
body: JSON.stringify({ id, status }),
|
|
209
|
+
});
|
|
210
|
+
if (res.ok) setTasks((p) => p.map((t) => (t.id === id ? { ...t, status } : t)));
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return (
|
|
214
|
+
<div>
|
|
215
|
+
<form onSubmit={add} className="row">
|
|
216
|
+
<input value={title} onChange={(e) => setTitle(e.target.value)} placeholder="Task title…" />
|
|
217
|
+
<input value={assignee} onChange={(e) => setAssignee(e.target.value)} placeholder="Assignee" />
|
|
218
|
+
<button type="submit">Add</button>
|
|
219
|
+
</form>
|
|
220
|
+
<div className="board">
|
|
221
|
+
{COLUMNS.map((col) => (
|
|
222
|
+
<section key={col}>
|
|
223
|
+
<h2>{LABELS[col]}</h2>
|
|
224
|
+
{tasks.filter((t) => t.status === col).map((t) => (
|
|
225
|
+
<article key={t.id}>
|
|
226
|
+
<strong>{t.title}</strong>
|
|
227
|
+
{t.assignee && <em> · {t.assignee}</em>}
|
|
228
|
+
<div className="moves">
|
|
229
|
+
{COLUMNS.filter((c) => c !== col).map((c) => (
|
|
230
|
+
<button key={c} onClick={() => move(t.id, c)}>→ {LABELS[c]}</button>
|
|
231
|
+
))}
|
|
232
|
+
</div>
|
|
233
|
+
</article>
|
|
234
|
+
))}
|
|
235
|
+
</section>
|
|
236
|
+
))}
|
|
237
|
+
</div>
|
|
238
|
+
</div>
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
## Notes
|
|
244
|
+
|
|
245
|
+
- Deploy the same way as the template: `grid dev` locally, `grid plug` to deploy
|
|
246
|
+
(async — poll status to a live URL). Re-plug the same entity to keep one URL.
|
|
247
|
+
- The only store this needs is Mongo (`requires: [mongodb]` — not `needs:`; the
|
|
248
|
+
deployer only injects `MONGODB_URL` from `requires:`). Add `redis: private`
|
|
249
|
+
only for caching/sessions/pubsub you actually use.
|
|
250
|
+
- All app code lives under `services/web/`; the connection is read lazily inside
|
|
251
|
+
`getDb`, never at module top level.
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# app-with-data template — persistent Next.js + Mongo app
|
|
2
|
+
|
|
3
|
+
A minimal but real, deployable to-do app. Data lives in the grid-shared MongoDB,
|
|
4
|
+
so it survives refresh and is shared across users/sessions — unlike a static
|
|
5
|
+
page whose state is in memory only.
|
|
6
|
+
|
|
7
|
+
## How the grid gives you a database
|
|
8
|
+
|
|
9
|
+
You do **not** provision a database or set a connection string. In
|
|
10
|
+
`cloudgrid.yaml` you declare `requires: [mongodb]`, and the grid:
|
|
11
|
+
|
|
12
|
+
- provisions shared Mongo for the app, and
|
|
13
|
+
- injects the connection string as the **`MONGODB_URL`** environment variable —
|
|
14
|
+
at dev-time (`grid dev`) and at runtime (after `grid plug`).
|
|
15
|
+
|
|
16
|
+
The app reads it via `process.env.MONGODB_URL` in `services/web/lib/db.js` —
|
|
17
|
+
**lazily, inside the `getDb` getter, never at module top level** (a top-level
|
|
18
|
+
read fails `next build`, which imports the module for route analysis before the
|
|
19
|
+
grid injects the var). Never hardcode a connection string; never commit a
|
|
20
|
+
secret. (If you also declare `redis: private`, the grid injects `REDIS_URL` the
|
|
21
|
+
same way.)
|
|
22
|
+
|
|
23
|
+
> **Use `requires:` (NOT `needs:`).** The CLI warns `requires:` is deprecated,
|
|
24
|
+
> but the deployer currently only injects `MONGODB_URL` from `requires:` —
|
|
25
|
+
> migrating to `needs:` builds fine but injects NO DB connection (every request
|
|
26
|
+
> 500s). Keep `requires:` until CloudGrid's deployer honors `needs:`.
|
|
27
|
+
|
|
28
|
+
## Service layout
|
|
29
|
+
|
|
30
|
+
App code lives under **`services/web/`**, not the template root. `path:` in
|
|
31
|
+
`cloudgrid.yaml` is the URL mount, not the filesystem path — the service named
|
|
32
|
+
`web` means the CLI looks for `services/web/`. Files at the root fail with
|
|
33
|
+
`Error: Service directory not found: …/services/web`.
|
|
34
|
+
|
|
35
|
+
## Run locally
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
npm install
|
|
39
|
+
grid dev # runs Next.js with MONGODB_URL injected against dev Mongo
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Deploy
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
grid plug # builds + deploys the folder (async — poll status until live)
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
A runtime deploy is asynchronous: `plug` returns `status: building`; poll status
|
|
49
|
+
until the app is live, then use the returned live URL. Re-plug the same entity
|
|
50
|
+
to update the same URL.
|
|
51
|
+
|
|
52
|
+
## File tree
|
|
53
|
+
|
|
54
|
+
```
|
|
55
|
+
cloudgrid.yaml # name + services.web (nextjs) + requires: [mongodb]
|
|
56
|
+
services/web/package.json # next, react, react-dom, mongodb driver only
|
|
57
|
+
services/web/lib/db.js # lazy Mongo client from process.env.MONGODB_URL
|
|
58
|
+
services/web/app/layout.js # root layout + inline CSS
|
|
59
|
+
services/web/app/page.js # server component: reads todos from Mongo
|
|
60
|
+
services/web/app/todo-form.js # client form: POST/DELETE via the API
|
|
61
|
+
services/web/app/api/todos/route.js # GET (list) / POST (add) / DELETE (remove)
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## Adapt it
|
|
65
|
+
|
|
66
|
+
- Rename the `todos` collection in `services/web/app/api/todos/route.js` and
|
|
67
|
+
`services/web/app/page.js` (e.g. `submissions`, `tasks`, `entries`).
|
|
68
|
+
- Change the document fields (add owners, timestamps, statuses).
|
|
69
|
+
- Add more routes/collections as the app grows.
|
|
70
|
+
- Add `redis: private` to `requires` only if you actually need Redis.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# Rename this app. The grid injects MONGODB_URL (and REDIS_URL if you add a cache)
|
|
2
|
+
# as environment variables at runtime — do NOT set them yourself, and never commit
|
|
3
|
+
# a connection string or secret.
|
|
4
|
+
#
|
|
5
|
+
# NOTE: use `requires:` (NOT `needs:`). The CLI warns that `requires:` is deprecated,
|
|
6
|
+
# but the deployer currently only injects MONGODB_URL from `requires:` — migrating to
|
|
7
|
+
# `needs:` builds fine but injects NO database connection (every request 500s).
|
|
8
|
+
# Keep `requires:` until CloudGrid's deployer honors `needs:`.
|
|
9
|
+
name: my-app
|
|
10
|
+
services:
|
|
11
|
+
web:
|
|
12
|
+
type: nextjs
|
|
13
|
+
path: /
|
|
14
|
+
requires:
|
|
15
|
+
- mongodb
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
# Template: app-with-data (persistent Next.js + Mongo)
|
|
2
|
+
|
|
3
|
+
A minimal but real, deployable to-do app. Data lives in the grid-shared MongoDB,
|
|
4
|
+
so it survives refresh and is shared across sessions — unlike a static page.
|
|
5
|
+
|
|
6
|
+
**Key rules (all proven by a real end-to-end deploy):**
|
|
7
|
+
|
|
8
|
+
1. **Service code MUST live under `services/<name>/`, not the repo/template
|
|
9
|
+
root.** `path:` in `cloudgrid.yaml` is the URL mount, NOT the filesystem path.
|
|
10
|
+
The service named `web` → the CLI looks for `services/web/`. App files at the
|
|
11
|
+
root fail with `Error: Service directory not found: …/services/web`.
|
|
12
|
+
2. **Read `MONGODB_URL` LAZILY (inside the getter), never at module top level.**
|
|
13
|
+
The grid injects the DB connection string as the `MONGODB_URL` environment
|
|
14
|
+
variable at dev-time and runtime; the app reads `process.env.MONGODB_URL`
|
|
15
|
+
inside `getDb`. A top-level `const uri = process.env.MONGODB_URL; if (!uri)
|
|
16
|
+
throw` fails `next build` (the module is imported for route analysis before
|
|
17
|
+
the grid injects the var). Never hardcode a connection string; never commit a
|
|
18
|
+
secret.
|
|
19
|
+
3. **Declare the datastore with `requires: [mongodb]` — NOT `needs:`.** The CLI
|
|
20
|
+
warns `requires:` is deprecated, but the deployer only injects `MONGODB_URL`
|
|
21
|
+
from `requires:`; `needs:` builds fine but injects NO DB connection (every
|
|
22
|
+
request 500s). Keep `requires:` until the deployer honors `needs:`.
|
|
23
|
+
|
|
24
|
+
Write these files into the scaffolded app folder — the app code goes under
|
|
25
|
+
`services/web/` — adapt the collection/fields to the user's app, then `grid dev`
|
|
26
|
+
(local) / `grid plug` (deploy, async — poll to a live URL).
|
|
27
|
+
|
|
28
|
+
## File tree
|
|
29
|
+
|
|
30
|
+
```
|
|
31
|
+
cloudgrid.yaml # name + services.web (nextjs) + requires: [mongodb]
|
|
32
|
+
services/web/package.json # next, react, react-dom, mongodb driver only
|
|
33
|
+
services/web/lib/db.js # lazy Mongo client from process.env.MONGODB_URL
|
|
34
|
+
services/web/app/layout.js # root layout + inline CSS
|
|
35
|
+
services/web/app/page.js # server component: reads todos from Mongo
|
|
36
|
+
services/web/app/todo-form.js # client form: POST/DELETE via the API
|
|
37
|
+
services/web/app/api/todos/route.js # GET (list) / POST (add) / DELETE (remove)
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## cloudgrid.yaml
|
|
41
|
+
|
|
42
|
+
```yaml
|
|
43
|
+
# Rename this app. The grid injects MONGODB_URL (and REDIS_URL if you add a cache)
|
|
44
|
+
# as environment variables at runtime — do NOT set them yourself, and never commit
|
|
45
|
+
# a connection string or secret.
|
|
46
|
+
#
|
|
47
|
+
# NOTE: use `requires:` (NOT `needs:`). The CLI warns that `requires:` is deprecated,
|
|
48
|
+
# but the deployer currently only injects MONGODB_URL from `requires:` — migrating to
|
|
49
|
+
# `needs:` builds fine but injects NO database connection (every request 500s).
|
|
50
|
+
# Keep `requires:` until CloudGrid's deployer honors `needs:`.
|
|
51
|
+
name: my-app
|
|
52
|
+
services:
|
|
53
|
+
web:
|
|
54
|
+
type: nextjs
|
|
55
|
+
path: /
|
|
56
|
+
requires:
|
|
57
|
+
- mongodb
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## services/web/package.json
|
|
61
|
+
|
|
62
|
+
```json
|
|
63
|
+
{
|
|
64
|
+
"name": "my-app",
|
|
65
|
+
"version": "0.1.0",
|
|
66
|
+
"private": true,
|
|
67
|
+
"scripts": {
|
|
68
|
+
"dev": "next dev",
|
|
69
|
+
"build": "next build",
|
|
70
|
+
"start": "next start"
|
|
71
|
+
},
|
|
72
|
+
"dependencies": {
|
|
73
|
+
"next": "^15.1.0",
|
|
74
|
+
"react": "^19.0.0",
|
|
75
|
+
"react-dom": "^19.0.0",
|
|
76
|
+
"mongodb": "^6.12.0"
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
## services/web/lib/db.js
|
|
82
|
+
|
|
83
|
+
```js
|
|
84
|
+
// Cached MongoDB client for the App Router.
|
|
85
|
+
//
|
|
86
|
+
// The grid injects the connection string as the MONGODB_URL environment variable
|
|
87
|
+
// at runtime (after `grid plug`) and under `grid dev` locally. Never hardcode a
|
|
88
|
+
// connection string here and never commit a secret.
|
|
89
|
+
//
|
|
90
|
+
// The env var and client are resolved LAZILY (inside getDb), never at module top
|
|
91
|
+
// level — otherwise `next build` throws when it imports this module for route
|
|
92
|
+
// analysis, before the grid injects MONGODB_URL.
|
|
93
|
+
import { MongoClient } from "mongodb";
|
|
94
|
+
|
|
95
|
+
function clientPromise() {
|
|
96
|
+
const uri = process.env.MONGODB_URL;
|
|
97
|
+
if (!uri) {
|
|
98
|
+
throw new Error(
|
|
99
|
+
"MONGODB_URL is not set. The grid injects it automatically — run this app " +
|
|
100
|
+
"with `grid dev` locally, or deploy it with `grid plug` (the grid injects " +
|
|
101
|
+
"MONGODB_URL at runtime). Do not set it by hand.",
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
if (!globalThis.__mongoClientPromise) {
|
|
105
|
+
globalThis.__mongoClientPromise = new MongoClient(uri).connect();
|
|
106
|
+
}
|
|
107
|
+
return globalThis.__mongoClientPromise;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export async function getDb() {
|
|
111
|
+
const client = await clientPromise();
|
|
112
|
+
// The default DB comes from the MONGODB_URL path segment the grid injects.
|
|
113
|
+
return client.db();
|
|
114
|
+
}
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
## services/web/app/layout.js
|
|
118
|
+
|
|
119
|
+
```js
|
|
120
|
+
export const metadata = {
|
|
121
|
+
title: "Todos",
|
|
122
|
+
description: "A persistent todo app on CloudGrid, backed by grid-shared Mongo.",
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
const css = `
|
|
126
|
+
:root { color-scheme: light dark; }
|
|
127
|
+
body { margin: 0; font-family: system-ui, sans-serif; }
|
|
128
|
+
main { max-width: 40rem; margin: 3rem auto; padding: 0 1.25rem; }
|
|
129
|
+
.row { display: flex; gap: .5rem; margin-bottom: 1rem; }
|
|
130
|
+
input { flex: 1; padding: .5rem .75rem; border: 1px solid #8886; border-radius: .5rem; }
|
|
131
|
+
button { padding: .5rem .9rem; border: 1px solid #8886; border-radius: .5rem; cursor: pointer; }
|
|
132
|
+
ul { list-style: none; padding: 0; margin: 0; }
|
|
133
|
+
li { display: flex; justify-content: space-between; padding: .6rem 0; border-bottom: 1px solid #8883; }
|
|
134
|
+
`;
|
|
135
|
+
|
|
136
|
+
export default function RootLayout({ children }) {
|
|
137
|
+
return (
|
|
138
|
+
<html lang="en">
|
|
139
|
+
<head><style dangerouslySetInnerHTML={{ __html: css }} /></head>
|
|
140
|
+
<body>{children}</body>
|
|
141
|
+
</html>
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
## services/web/app/page.js
|
|
147
|
+
|
|
148
|
+
```js
|
|
149
|
+
import { getDb } from "../lib/db.js";
|
|
150
|
+
import TodoForm from "./todo-form.js";
|
|
151
|
+
|
|
152
|
+
export const dynamic = "force-dynamic";
|
|
153
|
+
|
|
154
|
+
async function listTodos() {
|
|
155
|
+
const db = await getDb();
|
|
156
|
+
const items = await db.collection("todos").find({}).sort({ createdAt: -1 }).toArray();
|
|
157
|
+
return items.map((t) => ({ id: t._id.toString(), text: t.text, done: !!t.done }));
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export default async function Page() {
|
|
161
|
+
const todos = await listTodos();
|
|
162
|
+
return (
|
|
163
|
+
<main>
|
|
164
|
+
<h1>Todos</h1>
|
|
165
|
+
<p className="hint">Persisted in the grid-shared Mongo — survives refresh.</p>
|
|
166
|
+
<TodoForm initialTodos={todos} />
|
|
167
|
+
</main>
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
## services/web/app/todo-form.js
|
|
173
|
+
|
|
174
|
+
```js
|
|
175
|
+
"use client";
|
|
176
|
+
import { useState } from "react";
|
|
177
|
+
|
|
178
|
+
export default function TodoForm({ initialTodos }) {
|
|
179
|
+
const [todos, setTodos] = useState(initialTodos);
|
|
180
|
+
const [text, setText] = useState("");
|
|
181
|
+
|
|
182
|
+
async function add(e) {
|
|
183
|
+
e.preventDefault();
|
|
184
|
+
const value = text.trim();
|
|
185
|
+
if (!value) return;
|
|
186
|
+
const res = await fetch("/api/todos", {
|
|
187
|
+
method: "POST",
|
|
188
|
+
headers: { "content-type": "application/json" },
|
|
189
|
+
body: JSON.stringify({ text: value }),
|
|
190
|
+
});
|
|
191
|
+
if (res.ok) { setTodos((p) => [await res.json(), ...p]); setText(""); }
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
async function remove(id) {
|
|
195
|
+
const res = await fetch(`/api/todos?id=${encodeURIComponent(id)}`, { method: "DELETE" });
|
|
196
|
+
if (res.ok) setTodos((p) => p.filter((t) => t.id !== id));
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return (
|
|
200
|
+
<div>
|
|
201
|
+
<form onSubmit={add} className="row">
|
|
202
|
+
<input value={text} onChange={(e) => setText(e.target.value)} placeholder="Add a todo…" />
|
|
203
|
+
<button type="submit">Add</button>
|
|
204
|
+
</form>
|
|
205
|
+
<ul>
|
|
206
|
+
{todos.map((t) => (
|
|
207
|
+
<li key={t.id}>
|
|
208
|
+
<span>{t.text}</span>
|
|
209
|
+
<button type="button" onClick={() => remove(t.id)}>Delete</button>
|
|
210
|
+
</li>
|
|
211
|
+
))}
|
|
212
|
+
</ul>
|
|
213
|
+
</div>
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
## services/web/app/api/todos/route.js
|
|
219
|
+
|
|
220
|
+
```js
|
|
221
|
+
import { NextResponse } from "next/server";
|
|
222
|
+
import { ObjectId } from "mongodb";
|
|
223
|
+
import { getDb } from "../../../lib/db.js";
|
|
224
|
+
|
|
225
|
+
export const dynamic = "force-dynamic";
|
|
226
|
+
|
|
227
|
+
async function todos() {
|
|
228
|
+
const db = await getDb();
|
|
229
|
+
return db.collection("todos");
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
export async function GET() {
|
|
233
|
+
const col = await todos();
|
|
234
|
+
const items = await col.find({}).sort({ createdAt: -1 }).toArray();
|
|
235
|
+
return NextResponse.json(items.map((t) => ({ id: t._id.toString(), text: t.text, done: !!t.done })));
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export async function POST(request) {
|
|
239
|
+
const body = await request.json().catch(() => ({}));
|
|
240
|
+
const text = typeof body.text === "string" ? body.text.trim() : "";
|
|
241
|
+
if (!text) return NextResponse.json({ error: "text is required" }, { status: 400 });
|
|
242
|
+
const col = await todos();
|
|
243
|
+
const res = await col.insertOne({ text, done: false, createdAt: new Date() });
|
|
244
|
+
return NextResponse.json({ id: res.insertedId.toString(), text, done: false }, { status: 201 });
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export async function DELETE(request) {
|
|
248
|
+
const id = new URL(request.url).searchParams.get("id");
|
|
249
|
+
if (!id || !ObjectId.isValid(id)) return NextResponse.json({ error: "valid id is required" }, { status: 400 });
|
|
250
|
+
const col = await todos();
|
|
251
|
+
await col.deleteOne({ _id: new ObjectId(id) });
|
|
252
|
+
return NextResponse.json({ ok: true });
|
|
253
|
+
}
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
## Adapt it
|
|
257
|
+
|
|
258
|
+
- Rename the `todos` collection to your data (`submissions`, `tasks`, `entries`).
|
|
259
|
+
- Change the document fields; add owners/timestamps/statuses.
|
|
260
|
+
- Add `redis: private` to `requires` only if you actually need Redis.
|
|
261
|
+
- Run `grid dev` to test locally, `grid plug` to deploy (async — poll to live).
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// App Router route handler for the `todos` collection.
|
|
2
|
+
// GET -> list todos, POST -> add a todo, DELETE -> remove a todo by id.
|
|
3
|
+
//
|
|
4
|
+
// To adapt this app, rename the collection and change the fields to match your
|
|
5
|
+
// own data (e.g. a `submissions` or `tasks` collection).
|
|
6
|
+
import { NextResponse } from "next/server";
|
|
7
|
+
import { ObjectId } from "mongodb";
|
|
8
|
+
import { getDb } from "../../../lib/db.js";
|
|
9
|
+
|
|
10
|
+
// Never cache — always read/write live data.
|
|
11
|
+
export const dynamic = "force-dynamic";
|
|
12
|
+
|
|
13
|
+
async function todos() {
|
|
14
|
+
const db = await getDb();
|
|
15
|
+
return db.collection("todos");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// GET /api/todos — list all todos, newest first.
|
|
19
|
+
export async function GET() {
|
|
20
|
+
const col = await todos();
|
|
21
|
+
const items = await col.find({}).sort({ createdAt: -1 }).toArray();
|
|
22
|
+
return NextResponse.json(
|
|
23
|
+
items.map((t) => ({ id: t._id.toString(), text: t.text, done: !!t.done })),
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// POST /api/todos — add a todo. Body: { text: string }.
|
|
28
|
+
export async function POST(request) {
|
|
29
|
+
const body = await request.json().catch(() => ({}));
|
|
30
|
+
const text = typeof body.text === "string" ? body.text.trim() : "";
|
|
31
|
+
if (!text) {
|
|
32
|
+
return NextResponse.json({ error: "text is required" }, { status: 400 });
|
|
33
|
+
}
|
|
34
|
+
const col = await todos();
|
|
35
|
+
const res = await col.insertOne({ text, done: false, createdAt: new Date() });
|
|
36
|
+
return NextResponse.json({ id: res.insertedId.toString(), text, done: false }, { status: 201 });
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// DELETE /api/todos?id=<id> — remove a todo.
|
|
40
|
+
export async function DELETE(request) {
|
|
41
|
+
const id = new URL(request.url).searchParams.get("id");
|
|
42
|
+
if (!id || !ObjectId.isValid(id)) {
|
|
43
|
+
return NextResponse.json({ error: "valid id is required" }, { status: 400 });
|
|
44
|
+
}
|
|
45
|
+
const col = await todos();
|
|
46
|
+
await col.deleteOne({ _id: new ObjectId(id) });
|
|
47
|
+
return NextResponse.json({ ok: true });
|
|
48
|
+
}
|