@rtrentjones/greenlight 0.2.25 → 0.2.27
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/assets/skills/provider-neon/SKILL.md +20 -4
- package/dist/bin.js +55 -17
- package/dist/{chunk-GPPUZ6Z5.js → chunk-HMU7D7R2.js} +42 -1
- package/dist/index.js +1 -1
- package/package.json +5 -5
- package/templates/_template-next/README.md +25 -3
- package/templates/_template-next/app/layout.tsx +14 -0
- package/templates/_template-next/app/page.tsx +27 -0
- package/templates/_template-next/lib/db.ts +7 -0
- package/templates/_template-next/migrations/0001_init.sql +10 -0
- package/templates/_template-next/next.config.mjs +4 -0
- package/templates/_template-next/package.json +24 -0
- package/templates/_template-next/scripts/migrate.mjs +43 -0
- package/templates/_template-next/tsconfig.json +17 -0
- package/templates/_template-next/verify.config.ts +8 -0
|
@@ -34,11 +34,27 @@ prod and beta hit **different branches**. Pin the provider `kislerdm/neon ~> 0.1
|
|
|
34
34
|
Do **not** add a Neon tool to `module.keepalive.targets_json`. Neon resumes on connect — a request
|
|
35
35
|
just wakes it. (`doctor` does not flag `data: neon` for keepalive; that exemption is intentional.)
|
|
36
36
|
|
|
37
|
-
##
|
|
37
|
+
## Schema as code / migrations
|
|
38
38
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
39
|
+
**Greenlight does NOT run migrations — by design.** The split:
|
|
40
|
+
- **Schema** lives in the tool (an ORM — Drizzle/Prisma — or plain `.sql` migrations).
|
|
41
|
+
- **Branch-per-env**: the TF module owns stable `prod`/`beta`; the **native Neon↔Vercel integration**
|
|
42
|
+
owns ephemeral per-PR preview branches (+ auto-injects `DATABASE_URL`). Don't put ephemeral branches
|
|
43
|
+
in Terraform.
|
|
44
|
+
- **Execution**: the app's own build runs its migrate (`drizzle-kit migrate` / `prisma migrate deploy`)
|
|
45
|
+
against the wired **`DIRECT_URL`** — prod build → prod branch, preview build → preview branch. A
|
|
46
|
+
failed migrate fails the build = a natural gate.
|
|
47
|
+
- **Greenlight's role**: the **dangerous-SQL gate**. Run `greenlight migrations scan` (no `<dir>` →
|
|
48
|
+
it auto-detects `supabase/migrations | migrations | drizzle/migrations | …`) in CI before the migrate.
|
|
49
|
+
|
|
50
|
+
See [docs/migrations.md](../../../docs/migrations.md).
|
|
51
|
+
|
|
52
|
+
## Sharing one DB + multi-account
|
|
53
|
+
|
|
54
|
+
- **One DB, many services**: a second tool sets `dataShareWith: '<owner>'` (or `add … --share <owner>`)
|
|
55
|
+
— it creates no project and wires the owner's connection strings.
|
|
56
|
+
- **A second Neon account**: `tokenOverrides: { NEON_API_KEY: 'NEON_API_KEY_X' }` → an aliased `neon`
|
|
57
|
+
provider authenticates that account. (A sharer can't also override — it uses the owner's account.)
|
|
42
58
|
|
|
43
59
|
## MCP
|
|
44
60
|
|
package/dist/bin.js
CHANGED
|
@@ -6,7 +6,7 @@ import {
|
|
|
6
6
|
resolveUrl,
|
|
7
7
|
scanSqlFiles,
|
|
8
8
|
verifyAll
|
|
9
|
-
} from "./chunk-
|
|
9
|
+
} from "./chunk-HMU7D7R2.js";
|
|
10
10
|
import "./chunk-HX7VA25D.js";
|
|
11
11
|
import "./chunk-N3IKUCSF.js";
|
|
12
12
|
import "./chunk-KP3Y6WRU.js";
|
|
@@ -64,6 +64,7 @@ function serializeTool(t) {
|
|
|
64
64
|
const ov = Object.entries(t.tokenOverrides).map(([k, v]) => `${k}: ${q(v)}`).join(", ");
|
|
65
65
|
parts.push(`tokenOverrides: { ${ov} }`);
|
|
66
66
|
}
|
|
67
|
+
if (t.dataShareWith !== void 0) parts.push(`dataShareWith: ${q(t.dataShareWith)}`);
|
|
67
68
|
return ` { ${parts.join(", ")} },`;
|
|
68
69
|
}
|
|
69
70
|
function serializeConfig(c) {
|
|
@@ -111,7 +112,8 @@ function addTool(config, t) {
|
|
|
111
112
|
...t.port !== void 0 ? { port: t.port } : {},
|
|
112
113
|
...t.preview ? { preview: t.preview } : {},
|
|
113
114
|
...t.tokens?.length ? { tokens: t.tokens } : {},
|
|
114
|
-
...t.tokenOverrides && Object.keys(t.tokenOverrides).length ? { tokenOverrides: t.tokenOverrides } : {}
|
|
115
|
+
...t.tokenOverrides && Object.keys(t.tokenOverrides).length ? { tokenOverrides: t.tokenOverrides } : {},
|
|
116
|
+
...t.dataShareWith ? { dataShareWith: t.dataShareWith } : {}
|
|
115
117
|
}
|
|
116
118
|
]
|
|
117
119
|
};
|
|
@@ -139,7 +141,8 @@ function upsertTool(config, t) {
|
|
|
139
141
|
...t.port !== void 0 ? { port: t.port } : {},
|
|
140
142
|
...t.preview ? { preview: t.preview } : {},
|
|
141
143
|
...t.tokens?.length ? { tokens: t.tokens } : {},
|
|
142
|
-
...t.tokenOverrides && Object.keys(t.tokenOverrides).length ? { tokenOverrides: t.tokenOverrides } : {}
|
|
144
|
+
...t.tokenOverrides && Object.keys(t.tokenOverrides).length ? { tokenOverrides: t.tokenOverrides } : {},
|
|
145
|
+
...t.dataShareWith ? { dataShareWith: t.dataShareWith } : {}
|
|
143
146
|
};
|
|
144
147
|
const tools = config.tools.some((x) => x.name === t.name) ? config.tools.map((x) => x.name === t.name ? entry : x) : [...config.tools, entry];
|
|
145
148
|
const result = ConfigSchema.safeParse({ ...config, tools });
|
|
@@ -475,7 +478,7 @@ function tokensForTool(tool) {
|
|
|
475
478
|
}
|
|
476
479
|
|
|
477
480
|
// src/version.ts
|
|
478
|
-
var MODULE_REF = "v0.2.
|
|
481
|
+
var MODULE_REF = "v0.2.27";
|
|
479
482
|
var MODULE_SOURCE_BASE = "git::https://github.com/RTrentJones/greenlight.git//infra/modules";
|
|
480
483
|
function moduleSource(module, ref = MODULE_REF) {
|
|
481
484
|
return `${MODULE_SOURCE_BASE}/${module}?ref=${ref}`;
|
|
@@ -492,6 +495,8 @@ function emitToolTf(opts) {
|
|
|
492
495
|
const useVercel = target === "vercel";
|
|
493
496
|
const useOci = target === "oci";
|
|
494
497
|
const supabaseOverride = opts.tokenOverrides?.SUPABASE_ACCESS_TOKEN;
|
|
498
|
+
const neonOverride = opts.tokenOverrides?.NEON_API_KEY;
|
|
499
|
+
const neonOwner = opts.dataShareWith ?? name;
|
|
495
500
|
const envList = envs.map((e) => `"${e}"`).join(", ");
|
|
496
501
|
const blocks = [];
|
|
497
502
|
const assumes = ["var.cloudflare_zone_id"];
|
|
@@ -542,18 +547,37 @@ variable "${name}_supabase_database_password" {
|
|
|
542
547
|
default = "import-placeholder" # ignored when importing an existing project
|
|
543
548
|
}${overrideBlock}`);
|
|
544
549
|
}
|
|
545
|
-
if (useNeon) {
|
|
550
|
+
if (useNeon && opts.dataShareWith) {
|
|
551
|
+
blocks.push(`# Shares the Neon project owned by "${opts.dataShareWith}" (one DB, many services).
|
|
552
|
+
# No neon module here \u2014 the env wiring below reads module.${neonOwner}_neon.* (its prod/beta branches).`);
|
|
553
|
+
} else if (useNeon) {
|
|
554
|
+
const providersLine = neonOverride ? `
|
|
555
|
+
providers = { neon = neon.${name} }` : "";
|
|
556
|
+
const overrideBlock = neonOverride ? `
|
|
557
|
+
|
|
558
|
+
# Multi-account: ${name}'s Neon lives in a SECOND account \u2014 an aliased provider authenticates with
|
|
559
|
+
# its own token. In infra.yml: TF_VAR_${name}_neon_api_key: \${{ secrets.${neonOverride} }}
|
|
560
|
+
provider "neon" {
|
|
561
|
+
alias = "${name}"
|
|
562
|
+
api_key = var.${name}_neon_api_key
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
variable "${name}_neon_api_key" {
|
|
566
|
+
type = string
|
|
567
|
+
sensitive = true
|
|
568
|
+
description = "Neon API key for ${name}'s account (scoped secret ${neonOverride})."
|
|
569
|
+
}` : "";
|
|
546
570
|
blocks.push(`# One Neon project, a branch per env (prod = the project's default branch; beta = a child
|
|
547
571
|
# branch \u2014 copy-on-write, instant). Compute scales to zero and auto-resumes on the next connection,
|
|
548
572
|
# so a Neon tool needs NO keepalive (the reason Neon is the default Postgres). NEON_API_KEY configures
|
|
549
573
|
# the provider in main.tf; the connection strings are module OUTPUTS \u2014 no per-tool secret to gather.
|
|
550
574
|
module "${name}_neon" {
|
|
551
|
-
source = "${moduleSource("neon", ref)}"
|
|
575
|
+
source = "${moduleSource("neon", ref)}"${providersLine}
|
|
552
576
|
|
|
553
577
|
name = "${name}"
|
|
554
578
|
region = "aws-us-east-1" # Neon region id, e.g. aws-us-east-1 / aws-us-west-2
|
|
555
579
|
envs = [${envList}]
|
|
556
|
-
}`);
|
|
580
|
+
}${overrideBlock}`);
|
|
557
581
|
}
|
|
558
582
|
if (useVercel) {
|
|
559
583
|
const env = useSupabase ? `
|
|
@@ -590,10 +614,10 @@ module "${name}_neon" {
|
|
|
590
614
|
environment_values = {
|
|
591
615
|
site_url_prod = "https://${name}.${domain}"
|
|
592
616
|
site_url_beta = "https://beta.${name}.${domain}"
|
|
593
|
-
db_url_prod = module.${
|
|
594
|
-
db_direct_prod = module.${
|
|
595
|
-
db_url_beta = module.${
|
|
596
|
-
db_direct_beta = module.${
|
|
617
|
+
db_url_prod = module.${neonOwner}_neon.database_url["prod"]
|
|
618
|
+
db_direct_prod = module.${neonOwner}_neon.direct_url["prod"]
|
|
619
|
+
db_url_beta = module.${neonOwner}_neon.database_url["beta"]
|
|
620
|
+
db_direct_beta = module.${neonOwner}_neon.direct_url["beta"]
|
|
597
621
|
}` : `
|
|
598
622
|
# No managed data store \u2014 add environment/environment_values if the app needs vars.
|
|
599
623
|
environment = {}
|
|
@@ -1127,7 +1151,7 @@ async function addCommand(args) {
|
|
|
1127
1151
|
const name = args[0];
|
|
1128
1152
|
if (!name || name.startsWith("-")) {
|
|
1129
1153
|
throw new Error(
|
|
1130
|
-
"usage: greenlight add <name> --lane <lane> --target <target> [--data <d>] [--auth <a>] [--envs beta,prod] [--port 8000]"
|
|
1154
|
+
"usage: greenlight add <name> --lane <lane> --target <target> [--data <d>] [--auth <a>] [--envs beta,prod] [--port 8000] [--share <owner>]"
|
|
1131
1155
|
);
|
|
1132
1156
|
}
|
|
1133
1157
|
const lane = flag2(args, "--lane");
|
|
@@ -1145,7 +1169,9 @@ async function addCommand(args) {
|
|
|
1145
1169
|
data: flag2(args, "--data"),
|
|
1146
1170
|
auth: flag2(args, "--auth"),
|
|
1147
1171
|
envs: flag2(args, "--envs")?.split(","),
|
|
1148
|
-
port: portFlag ? Number(portFlag) : void 0
|
|
1172
|
+
port: portFlag ? Number(portFlag) : void 0,
|
|
1173
|
+
// --share <owner>: this tool reads the owner's Neon DB instead of creating its own (one DB, many services).
|
|
1174
|
+
dataShareWith: flag2(args, "--share")
|
|
1149
1175
|
});
|
|
1150
1176
|
const entry = next.tools.find((t) => t.name === name);
|
|
1151
1177
|
const data = entry?.data ?? "none";
|
|
@@ -1194,7 +1220,8 @@ async function addCommand(args) {
|
|
|
1194
1220
|
data,
|
|
1195
1221
|
envs,
|
|
1196
1222
|
port: entry?.port,
|
|
1197
|
-
tokenOverrides: entry?.tokenOverrides
|
|
1223
|
+
tokenOverrides: entry?.tokenOverrides,
|
|
1224
|
+
dataShareWith: entry?.dataShareWith
|
|
1198
1225
|
})
|
|
1199
1226
|
);
|
|
1200
1227
|
console.log(`\u2714 wrote infra/${name}.tf (modules: ${providers.join(", ")})`);
|
|
@@ -2527,19 +2554,30 @@ Next:
|
|
|
2527
2554
|
}
|
|
2528
2555
|
|
|
2529
2556
|
// src/commands/migrations.ts
|
|
2530
|
-
import { readFileSync as readFileSync7, readdirSync as readdirSync3 } from "fs";
|
|
2557
|
+
import { existsSync as existsSync10, readFileSync as readFileSync7, readdirSync as readdirSync3 } from "fs";
|
|
2531
2558
|
import { join as join5 } from "path";
|
|
2532
2559
|
var DEFAULT_DIR = "supabase/migrations";
|
|
2560
|
+
var CANDIDATE_DIRS = [
|
|
2561
|
+
DEFAULT_DIR,
|
|
2562
|
+
"migrations",
|
|
2563
|
+
"drizzle/migrations",
|
|
2564
|
+
"drizzle",
|
|
2565
|
+
"db/migrations"
|
|
2566
|
+
];
|
|
2567
|
+
function resolveMigrationsDir(explicit, root = process.cwd()) {
|
|
2568
|
+
if (explicit) return explicit;
|
|
2569
|
+
return CANDIDATE_DIRS.find((d) => existsSync10(join5(root, d))) ?? DEFAULT_DIR;
|
|
2570
|
+
}
|
|
2533
2571
|
async function migrationsCommand(args) {
|
|
2534
2572
|
if (args[0] !== "scan") {
|
|
2535
2573
|
console.log(
|
|
2536
2574
|
`usage: greenlight migrations scan [<dir>] [--strict]
|
|
2537
2575
|
scan SQL migrations for data-destroying / lock-heavy statements (the pre-apply gate).
|
|
2538
|
-
|
|
2576
|
+
no <dir> \u2192 auto-detects ${CANDIDATE_DIRS.join(" | ")}. Acknowledge an intentional op with \`-- greenlight:allow\`.`
|
|
2539
2577
|
);
|
|
2540
2578
|
process.exit(args[0] ? 1 : 0);
|
|
2541
2579
|
}
|
|
2542
|
-
const dir = args.slice(1).find((a) => !a.startsWith("-"))
|
|
2580
|
+
const dir = resolveMigrationsDir(args.slice(1).find((a) => !a.startsWith("-")));
|
|
2543
2581
|
const strict = args.includes("--strict");
|
|
2544
2582
|
let names;
|
|
2545
2583
|
try {
|
|
@@ -65,7 +65,12 @@ var ToolSchema = z.object({
|
|
|
65
65
|
// var to an alternate secret name, so this tool authenticates that provider with a SECOND account
|
|
66
66
|
// — e.g. { SUPABASE_ACCESS_TOKEN: 'SUPABASE_ACCESS_TOKEN_HEISTMIND' }. Absent ⇒ unchanged (the
|
|
67
67
|
// default token). `add`/`adopt` emit an aliased provider + scoped var/secret for an overridden token.
|
|
68
|
-
tokenOverrides: z.record(z.string(), z.string()).optional()
|
|
68
|
+
tokenOverrides: z.record(z.string(), z.string()).optional(),
|
|
69
|
+
// Share another tool's data store instead of creating one (multiple services on one Neon DB).
|
|
70
|
+
// The value is the OWNER tool's name; this tool emits no data module and wires the owner's
|
|
71
|
+
// connection strings. Cross-tool validity (owner exists, same data, no chains) is checked on
|
|
72
|
+
// the whole config below.
|
|
73
|
+
dataShareWith: z.string().optional()
|
|
69
74
|
}).superRefine((tool, ctx) => {
|
|
70
75
|
const rule = MATRIX[tool.lane];
|
|
71
76
|
if (!rule.targets.includes(tool.target)) {
|
|
@@ -89,6 +94,13 @@ var ToolSchema = z.object({
|
|
|
89
94
|
message: 'private tools must set auth to "bearer" or "oauth", never "none"'
|
|
90
95
|
});
|
|
91
96
|
}
|
|
97
|
+
if (tool.dataShareWith && tool.data !== "neon") {
|
|
98
|
+
ctx.addIssue({
|
|
99
|
+
code: z.ZodIssueCode.custom,
|
|
100
|
+
path: ["dataShareWith"],
|
|
101
|
+
message: 'dataShareWith currently supports data: "neon" only'
|
|
102
|
+
});
|
|
103
|
+
}
|
|
92
104
|
});
|
|
93
105
|
var BlogSchema = z.object({
|
|
94
106
|
lane: z.literal("astro"),
|
|
@@ -104,6 +116,35 @@ var ConfigSchema = z.object({
|
|
|
104
116
|
// Optional: a tool-only repo (a poly-repo consumer) has no blog.
|
|
105
117
|
blog: BlogSchema.optional(),
|
|
106
118
|
tools: z.array(ToolSchema).default([])
|
|
119
|
+
}).superRefine((config, ctx) => {
|
|
120
|
+
for (const [i, tool] of config.tools.entries()) {
|
|
121
|
+
if (!tool.dataShareWith) continue;
|
|
122
|
+
const issue = (message) => ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["tools", i, "dataShareWith"], message });
|
|
123
|
+
if (tool.dataShareWith === tool.name) {
|
|
124
|
+
issue(`"${tool.name}" cannot share a data store with itself`);
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
const owner = config.tools.find((t) => t.name === tool.dataShareWith);
|
|
128
|
+
if (!owner) {
|
|
129
|
+
issue(`dataShareWith "${tool.dataShareWith}" is not a tool in this manifest`);
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
if (owner.data !== tool.data) {
|
|
133
|
+
issue(
|
|
134
|
+
`"${tool.name}" (data: ${tool.data}) must share a tool with the same data \u2014 "${owner.name}" is ${owner.data}`
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
if (owner.dataShareWith) {
|
|
138
|
+
issue(
|
|
139
|
+
`cannot share with "${owner.name}" \u2014 it is itself a sharer (no chains); point at the owner`
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
if (tool.tokenOverrides?.NEON_API_KEY) {
|
|
143
|
+
issue(
|
|
144
|
+
`a sharer uses the owner's Neon account \u2014 remove the NEON_API_KEY override from "${tool.name}"`
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
107
148
|
});
|
|
108
149
|
|
|
109
150
|
// ../packages/shared/src/load.ts
|
package/dist/index.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rtrentjones/greenlight",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.27",
|
|
4
4
|
"description": "Greenlight CLI — setup and lifecycle for the harness.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -31,10 +31,10 @@
|
|
|
31
31
|
"@anthropic-ai/sdk": "^0.69.0"
|
|
32
32
|
},
|
|
33
33
|
"devDependencies": {
|
|
34
|
-
"@rtrentjones/greenlight-adapters": "0.2.
|
|
35
|
-
"@rtrentjones/greenlight-
|
|
36
|
-
"@rtrentjones/greenlight-
|
|
37
|
-
"@rtrentjones/greenlight-
|
|
34
|
+
"@rtrentjones/greenlight-adapters": "0.2.27",
|
|
35
|
+
"@rtrentjones/greenlight-loop": "0.2.27",
|
|
36
|
+
"@rtrentjones/greenlight-shared": "0.2.27",
|
|
37
|
+
"@rtrentjones/greenlight-verify": "0.2.27"
|
|
38
38
|
},
|
|
39
39
|
"scripts": {
|
|
40
40
|
"build": "node scripts/copy-assets.mjs && tsup",
|
|
@@ -1,5 +1,27 @@
|
|
|
1
|
-
# `_template-next`
|
|
1
|
+
# `_template-next` — Next.js on Vercel + Neon
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The lane template `greenlight add <name> --lane next --target vercel --data neon` scaffolds. A minimal
|
|
4
|
+
real app that **reads Neon at request time** and **migrates its schema on deploy** — the Greenlight
|
|
5
|
+
Neon convention end to end. Verify mode: `api` (the page runs a live query, so a broken DB 500s).
|
|
4
6
|
|
|
5
|
-
|
|
7
|
+
## How it maps to Greenlight
|
|
8
|
+
|
|
9
|
+
- **Connection strings** come from the env, wired by the `neon` Terraform module per Vercel target:
|
|
10
|
+
`DATABASE_URL` (pooled) for runtime reads ([lib/db.ts](lib/db.ts)), `DIRECT_URL` (direct) for
|
|
11
|
+
migrations. Prod build → prod branch, preview build → its own branch — same code, different data.
|
|
12
|
+
- **Schema as code** lives in [migrations/](migrations) (plain `.sql`). The app's build runs
|
|
13
|
+
[scripts/migrate.mjs](scripts/migrate.mjs) (`package.json` `build` = `node scripts/migrate.mjs &&
|
|
14
|
+
next build`), so a deploy creates/edits tables. A failed migration fails the build → never goes live.
|
|
15
|
+
- **Greenlight's gate**: run `greenlight migrations scan` in CI before the migrate (the dangerous-SQL
|
|
16
|
+
check). See [docs/migrations.md](../../docs/migrations.md). Greenlight does **not** run migrations.
|
|
17
|
+
|
|
18
|
+
## Make it yours
|
|
19
|
+
|
|
20
|
+
- Prefer an ORM? Swap the plain-SQL migrations for Drizzle/Prisma — keep the build running *your*
|
|
21
|
+
migrate against `DIRECT_URL`, and keep `migrations scan` pointed at the generated SQL.
|
|
22
|
+
- Ephemeral per-PR preview branches: connect the native **Neon↔Vercel** integration (it creates a
|
|
23
|
+
branch + injects `DATABASE_URL` per preview); the stable prod/beta branches stay in Terraform.
|
|
24
|
+
|
|
25
|
+
## Local dev
|
|
26
|
+
|
|
27
|
+
Set `DATABASE_URL` + `DIRECT_URL` to a Neon branch, then `pnpm migrate && pnpm dev`.
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
|
|
3
|
+
export const metadata = {
|
|
4
|
+
title: 'Neon probe',
|
|
5
|
+
description: 'A minimal Next-on-Neon Greenlight tool.',
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
export default function RootLayout({ children }: { children: ReactNode }) {
|
|
9
|
+
return (
|
|
10
|
+
<html lang="en">
|
|
11
|
+
<body>{children}</body>
|
|
12
|
+
</html>
|
|
13
|
+
);
|
|
14
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { sql } from '../lib/db';
|
|
2
|
+
|
|
3
|
+
// Read fresh each request so the page reflects the DB (and so the verify gate exercises a live query —
|
|
4
|
+
// a broken connection / missing table would 500, not 200).
|
|
5
|
+
export const dynamic = 'force-dynamic';
|
|
6
|
+
|
|
7
|
+
export default async function Page() {
|
|
8
|
+
const rows = await sql`SELECT id, body FROM notes ORDER BY id DESC LIMIT 20`;
|
|
9
|
+
return (
|
|
10
|
+
<main
|
|
11
|
+
style={{
|
|
12
|
+
fontFamily: 'system-ui, sans-serif',
|
|
13
|
+
maxWidth: 640,
|
|
14
|
+
margin: '3rem auto',
|
|
15
|
+
padding: '0 1rem',
|
|
16
|
+
}}
|
|
17
|
+
>
|
|
18
|
+
<h1>Notes</h1>
|
|
19
|
+
<p>{rows.length} note(s) from Neon.</p>
|
|
20
|
+
<ul>
|
|
21
|
+
{rows.map((r) => (
|
|
22
|
+
<li key={String(r.id)}>{String(r.body)}</li>
|
|
23
|
+
))}
|
|
24
|
+
</ul>
|
|
25
|
+
</main>
|
|
26
|
+
);
|
|
27
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { neon } from '@neondatabase/serverless';
|
|
2
|
+
|
|
3
|
+
// Runtime reads use the POOLED connection (DATABASE_URL) over Neon's serverless HTTP driver — ideal
|
|
4
|
+
// for Vercel's per-request functions. Migrations use the DIRECT connection instead (scripts/migrate.mjs).
|
|
5
|
+
// Both are wired into the Vercel env per target by the Greenlight `neon` module, so prod hits the
|
|
6
|
+
// prod branch and preview hits its own branch.
|
|
7
|
+
export const sql = neon(process.env.DATABASE_URL ?? '');
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
-- Plain SQL migrations are the app's source of truth for the schema, applied by scripts/migrate.mjs
|
|
2
|
+
-- on each build (against DIRECT_URL → the env's branch). They compose with `greenlight migrations
|
|
3
|
+
-- scan` (the dangerous-SQL gate). Swap in Drizzle/Prisma migrations if you prefer — same convention.
|
|
4
|
+
CREATE TABLE IF NOT EXISTS notes (
|
|
5
|
+
id serial PRIMARY KEY,
|
|
6
|
+
body text NOT NULL,
|
|
7
|
+
created_at timestamptz NOT NULL DEFAULT now()
|
|
8
|
+
);
|
|
9
|
+
|
|
10
|
+
INSERT INTO notes (body) VALUES ('hello from Neon');
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "_template-next",
|
|
3
|
+
"private": true,
|
|
4
|
+
"type": "module",
|
|
5
|
+
"scripts": {
|
|
6
|
+
"dev": "next dev",
|
|
7
|
+
"build": "node scripts/migrate.mjs && next build",
|
|
8
|
+
"start": "next start",
|
|
9
|
+
"migrate": "node scripts/migrate.mjs"
|
|
10
|
+
},
|
|
11
|
+
"dependencies": {
|
|
12
|
+
"@neondatabase/serverless": "^0.10.0",
|
|
13
|
+
"next": "^15.0.0",
|
|
14
|
+
"pg": "^8.13.0",
|
|
15
|
+
"react": "^19.0.0",
|
|
16
|
+
"react-dom": "^19.0.0"
|
|
17
|
+
},
|
|
18
|
+
"devDependencies": {
|
|
19
|
+
"@types/node": "^22.0.0",
|
|
20
|
+
"@types/pg": "^8.11.0",
|
|
21
|
+
"@types/react": "^19.0.0",
|
|
22
|
+
"typescript": "^5.6.0"
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// The app's OWN migrate — Greenlight does not run migrations. The build runs this (see package.json
|
|
2
|
+
// `build`) against DIRECT_URL, so a deploy creates/edits tables on the env's Neon branch; a failed
|
|
3
|
+
// migration fails the build, so a broken schema never goes live. Gate it in CI with `greenlight
|
|
4
|
+
// migrations scan` first. Plain `pg` (TCP, transactional) for DDL; the app reads via lib/db.ts.
|
|
5
|
+
import { readFileSync, readdirSync } from 'node:fs';
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
import pg from 'pg';
|
|
8
|
+
|
|
9
|
+
const url = process.env.DIRECT_URL ?? process.env.DATABASE_URL;
|
|
10
|
+
if (!url) {
|
|
11
|
+
console.error('migrate: set DIRECT_URL (the direct Neon connection string)');
|
|
12
|
+
process.exit(1);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const client = new pg.Client({ connectionString: url });
|
|
16
|
+
await client.connect();
|
|
17
|
+
try {
|
|
18
|
+
await client.query(
|
|
19
|
+
'CREATE TABLE IF NOT EXISTS _migrations (name text PRIMARY KEY, applied_at timestamptz DEFAULT now())',
|
|
20
|
+
);
|
|
21
|
+
const { rows } = await client.query('SELECT name FROM _migrations');
|
|
22
|
+
const applied = new Set(rows.map((r) => r.name));
|
|
23
|
+
const files = readdirSync('migrations')
|
|
24
|
+
.filter((f) => f.endsWith('.sql'))
|
|
25
|
+
.sort();
|
|
26
|
+
|
|
27
|
+
for (const file of files) {
|
|
28
|
+
if (applied.has(file)) continue;
|
|
29
|
+
console.log(`applying ${file}`);
|
|
30
|
+
await client.query('BEGIN');
|
|
31
|
+
try {
|
|
32
|
+
await client.query(readFileSync(join('migrations', file), 'utf8'));
|
|
33
|
+
await client.query('INSERT INTO _migrations (name) VALUES ($1)', [file]);
|
|
34
|
+
await client.query('COMMIT');
|
|
35
|
+
} catch (err) {
|
|
36
|
+
await client.query('ROLLBACK');
|
|
37
|
+
throw err;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
console.log('migrations up to date');
|
|
41
|
+
} finally {
|
|
42
|
+
await client.end();
|
|
43
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"lib": ["dom", "dom.iterable", "esnext"],
|
|
5
|
+
"module": "esnext",
|
|
6
|
+
"moduleResolution": "bundler",
|
|
7
|
+
"jsx": "preserve",
|
|
8
|
+
"strict": true,
|
|
9
|
+
"noEmit": true,
|
|
10
|
+
"esModuleInterop": true,
|
|
11
|
+
"skipLibCheck": true,
|
|
12
|
+
"incremental": true,
|
|
13
|
+
"plugins": [{ "name": "next" }]
|
|
14
|
+
},
|
|
15
|
+
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
|
|
16
|
+
"exclude": ["node_modules"]
|
|
17
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// Verify the deployed app renders AND reads Neon: the page runs a live SELECT, so a missing table or
|
|
2
|
+
// a bad connection 500s instead of returning the marker text. The settle absorbs Vercel propagation.
|
|
3
|
+
export default {
|
|
4
|
+
mode: 'api',
|
|
5
|
+
checks: [{ path: '/', status: 200, contains: 'note(s) from Neon' }],
|
|
6
|
+
settleRetries: 6,
|
|
7
|
+
settleMs: 5000,
|
|
8
|
+
};
|