@percepta/create 3.1.3 → 3.1.4
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 +1 -1
- package/templates/webapp/AGENTS.md +1 -1
- package/templates/webapp/README.md +1 -1
- package/templates/webapp/agent-skills/database.md +5 -1
- package/templates/webapp/agent-skills/deploy.md +5 -3
- package/templates/webapp/agent-skills/inngest.md +13 -8
- package/templates/webapp/agent-skills/oneshot.md +1 -1
- package/templates/webapp/deploy/README.md +2 -2
- package/templates/webapp/deploy/ryvn/environments/percepta-test/installations/__APP_NAME__.env.percepta-test.serviceinstallation.yaml +3 -3
- package/templates/webapp/package.json.template +2 -2
- package/templates/webapp/scripts/deploy-percepta-test.ts +311 -36
- package/templates/webapp/scripts/generate-migrations.ts +28 -0
- package/templates/webapp/src/drizzle/__tests__/migrationSql.test.ts +24 -0
- package/templates/webapp/src/drizzle/migrationSql.ts +8 -0
- package/templates/webapp/src/services/inngest/AppWorkflowService.ts +19 -0
- package/templates/webapp/src/services/inngest/__tests__/AppWorkflowService.test.ts +19 -0
- package/templates/webapp/src/services/inngest/events/AppEvents.ts +7 -13
- package/templates/webapp/src/services/inngest/events/payloads/ExampleEventPayload.ts +1 -3
package/package.json
CHANGED
|
@@ -8,7 +8,7 @@ Next.js 15 full-stack application scaffolded from the Mosaic webapp template via
|
|
|
8
8
|
- `pnpm build` — production build
|
|
9
9
|
- `pnpm lint` — run ESLint
|
|
10
10
|
- `pnpm test` — run Vitest tests
|
|
11
|
-
- `pnpm docker:up` / `pnpm docker:down` — start/stop PostgreSQL
|
|
11
|
+
- `pnpm docker:up` / `pnpm docker:down` — start PostgreSQL and wait for health / stop PostgreSQL
|
|
12
12
|
- `pnpm inngest:dev` — start local Inngest dev server when working on background jobs
|
|
13
13
|
- `pnpm db:generate` — generate Drizzle migrations
|
|
14
14
|
- `pnpm db:migrate` — apply migrations
|
|
@@ -85,7 +85,7 @@ src/
|
|
|
85
85
|
| `pnpm build` | Build for production |
|
|
86
86
|
| `pnpm start` | Start production server |
|
|
87
87
|
| `pnpm lint` | Run ESLint |
|
|
88
|
-
| `pnpm docker:up` | Start PostgreSQL container |
|
|
88
|
+
| `pnpm docker:up` | Start PostgreSQL container and wait until it is healthy |
|
|
89
89
|
| `pnpm docker:down` | Stop PostgreSQL container |
|
|
90
90
|
| `pnpm inngest:dev` | Start the local Inngest dev server for this app |
|
|
91
91
|
| `pnpm db:generate` | Generate Drizzle migrations |
|
|
@@ -38,7 +38,11 @@ export * from "./documents";
|
|
|
38
38
|
pnpm db:generate
|
|
39
39
|
```
|
|
40
40
|
|
|
41
|
-
This creates a new SQL migration file
|
|
41
|
+
This creates a new SQL migration file and normalizes generated foreign key
|
|
42
|
+
references so they stay schema-relative when `DATABASE_SCHEMA` is set in
|
|
43
|
+
`percepta-test`. **Review the generated SQL** — Drizzle generates it
|
|
44
|
+
automatically but you should verify it's correct, especially for new foreign
|
|
45
|
+
keys and destructive changes.
|
|
42
46
|
|
|
43
47
|
### 4. Apply the migration
|
|
44
48
|
|
|
@@ -12,7 +12,7 @@ This is the existing-environment deploy motion: `percepta-test` already owns the
|
|
|
12
12
|
- `deploy/ryvn/environments/percepta-test/installations/__APP_NAME__-terraform.env.percepta-test.serviceinstallation.yaml` — schema installation.
|
|
13
13
|
- `.github/workflows/__APP_NAME__-ryvn-release.yaml` — builds the Docker image and creates the web Ryvn release.
|
|
14
14
|
- `.github/workflows/__APP_NAME__-terraform-ryvn-release.yaml` — creates the schema Terraform Ryvn release.
|
|
15
|
-
- `deploy/ryvn/percepta-test.secrets.env` — generated locally and ignored by git;
|
|
15
|
+
- `deploy/ryvn/percepta-test.secrets.env` — generated locally and ignored by git; injected into the app installation as Ryvn secrets by the deploy helper.
|
|
16
16
|
|
|
17
17
|
See [`deploy/README.md`](../deploy/README.md) for the file-by-file breakdown.
|
|
18
18
|
|
|
@@ -42,7 +42,7 @@ The helper:
|
|
|
42
42
|
6. Creates or replaces the schema installation and approves the Terraform plan.
|
|
43
43
|
7. Runs the web release workflow.
|
|
44
44
|
8. Creates or replaces the web installation.
|
|
45
|
-
9.
|
|
45
|
+
9. Creates or updates app-scoped Ryvn installation secrets for `BETTER_AUTH_SECRET` and `ENCRYPTION_SECRET_KEY` from `deploy/ryvn/percepta-test.secrets.env`. On first install, the helper injects them into the create manifest so the first pod starts with auth configured.
|
|
46
46
|
10. Waits for Ryvn health and checks `/api/healthz`, `/api/readyz`, and the protected app route.
|
|
47
47
|
|
|
48
48
|
The app will be available at **https://__APP_NAME__.percepta-test.aitco.dev**.
|
|
@@ -74,12 +74,14 @@ curl -s https://__APP_NAME__.percepta-test.aitco.dev/api/readyz
|
|
|
74
74
|
curl -I https://__APP_NAME__.percepta-test.aitco.dev/
|
|
75
75
|
```
|
|
76
76
|
|
|
77
|
+
For apps with tRPC routes, also verify at least one endpoint that initializes Better Auth or app services. `healthz` can be green even when app-specific secrets or workflow wiring are wrong.
|
|
78
|
+
|
|
77
79
|
## Troubleshooting
|
|
78
80
|
|
|
79
81
|
- **Image build fails fetching @percepta packages** → check the Percepta-Core org-level `NPM_TOKEN` secret. Do not add a repo-level token unless the org secret is unavailable.
|
|
80
82
|
- **Ryvn release already exists** → commit a new change or re-run with `--skip-workflows` if the current releases are already present.
|
|
81
83
|
- **Terraform plan needs approval** → the helper approves it when run with `--yes`; without `--yes`, approve the prompt.
|
|
82
|
-
- **Auth/sign-in routes fail after install** → verify the
|
|
84
|
+
- **Auth/sign-in or tRPC routes fail after install** → verify the `__APP_NAME__` installation has `BETTER_AUTH_SECRET` and `ENCRYPTION_SECRET_KEY` secrets from `deploy/ryvn/percepta-test.secrets.env`, then redeploy `__APP_NAME__` so the pod reloads them.
|
|
83
85
|
- **Pod crash-looping** → check `ryvn logs`; migration or database connectivity failures are the most common fresh-deploy causes.
|
|
84
86
|
- **Database schema missing** → check `ryvn get installation __APP_NAME__-terraform -e percepta-test`.
|
|
85
87
|
- **Inngest can't reach the app** → `INNGEST_APP_URL` must use the k8s service name `__APP_NAME__-web-server`.
|
|
@@ -31,13 +31,13 @@ Add the event to the central `AppEvents` registry:
|
|
|
31
31
|
import { DocumentProcessedPayload } from "./payloads/DocumentProcessedPayload";
|
|
32
32
|
|
|
33
33
|
export const AppEvents = {
|
|
34
|
-
"app/document.processed":
|
|
35
|
-
data: DocumentProcessedPayload.SCHEMA,
|
|
36
|
-
}),
|
|
34
|
+
"app/document.processed": DocumentProcessedPayload.SCHEMA,
|
|
37
35
|
};
|
|
38
36
|
```
|
|
39
37
|
|
|
40
|
-
Event names follow the convention `"app/<entity>.<action>"`.
|
|
38
|
+
Event names follow the convention `"app/<entity>.<action>"`. `AppEvents`
|
|
39
|
+
schemas validate `event.data`, so do not wrap payload schemas in another
|
|
40
|
+
`{ data: ... }` object.
|
|
41
41
|
|
|
42
42
|
## Adding a New Function
|
|
43
43
|
|
|
@@ -93,13 +93,18 @@ const functionCollections: InngestFunctionCollection[] = compact([
|
|
|
93
93
|
## Sending Events
|
|
94
94
|
|
|
95
95
|
```typescript
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
96
|
+
await AppWorkflowService.create().sendDocumentProcessed({
|
|
97
|
+
documentId: "abc",
|
|
98
|
+
userId: "user-1",
|
|
99
|
+
pageCount: 5,
|
|
100
100
|
});
|
|
101
101
|
```
|
|
102
102
|
|
|
103
|
+
Prefer adding typed methods to `AppWorkflowService` instead of calling
|
|
104
|
+
`InngestService.create().client.send(...)` directly from routers or route
|
|
105
|
+
handlers. Keep a unit test that asserts the method sends the same payload shape
|
|
106
|
+
that `AppEvents` validates.
|
|
107
|
+
|
|
103
108
|
## Running Inngest Locally
|
|
104
109
|
|
|
105
110
|
### 1. Start the Inngest Dev Server
|
|
@@ -193,7 +193,7 @@ git add -A
|
|
|
193
193
|
git commit -m "Initial implementation of <app-name>"
|
|
194
194
|
|
|
195
195
|
# Create the repo under the Percepta-Core org
|
|
196
|
-
gh repo create Percepta-Core/<app-name> --
|
|
196
|
+
gh repo create Percepta-Core/<app-name> --internal --source=. --push
|
|
197
197
|
```
|
|
198
198
|
|
|
199
199
|
If `gh` is not authenticated, tell the user to run `gh auth login` and then continue.
|
|
@@ -18,7 +18,7 @@ These files deploy to `https://__APP_NAME__.percepta-test.aitco.dev`.
|
|
|
18
18
|
|
|
19
19
|
The default deploy helper performs the existing-environment deploy motion: it assumes the target Ryvn environment already has the shared platform services installed, then wires this app into them. For `percepta-test`, that means shared Postgres, Inngest, the OTEL collector, the LGTM stack, and Langfuse must already exist before app deploy starts. Fresh-environment platform bootstrap is a separate motion and should be handled by a Ryvn blueprint or environment-specific platform rollout.
|
|
20
20
|
|
|
21
|
-
The helper talks directly to Ryvn: it preflights the existing platform services, creates/updates the services, runs the GitHub Actions release workflows, creates the schema installation, approves the schema Terraform plan, creates
|
|
21
|
+
The helper talks directly to Ryvn: it preflights the existing platform services, creates/updates the services, runs the GitHub Actions release workflows, creates the schema installation, approves the schema Terraform plan, creates or updates app-scoped Ryvn secrets, creates the web installation, waits for health, and verifies the health and app routes.
|
|
22
22
|
|
|
23
23
|
## Deploying
|
|
24
24
|
|
|
@@ -40,7 +40,7 @@ The helper expects a clean, committed git worktree because GitHub Actions builds
|
|
|
40
40
|
|
|
41
41
|
**`__APP_NAME__-terraform.env.percepta-test.serviceinstallation.yaml`** — schema installation for `percepta-test`.
|
|
42
42
|
|
|
43
|
-
**`percepta-test.secrets.env`** — generated locally and ignored by git. The deploy helper
|
|
43
|
+
**`percepta-test.secrets.env`** — generated locally and ignored by git. The deploy helper injects app-specific auth/encryption secrets into the Ryvn installation create manifest so the first pod starts with auth configured; shared Langfuse and LLM demo keys are inherited from a Ryvn variable group.
|
|
44
44
|
|
|
45
45
|
## Platform Wiring
|
|
46
46
|
|
|
@@ -94,9 +94,9 @@ spec:
|
|
|
94
94
|
value: https://__APP_NAME__.percepta-test.aitco.dev
|
|
95
95
|
- key: BETTER_AUTH_URL
|
|
96
96
|
value: https://__APP_NAME__.percepta-test.aitco.dev
|
|
97
|
-
# deploy:percepta-test
|
|
98
|
-
# from deploy/ryvn/percepta-test.secrets.env
|
|
99
|
-
# Secret values are intentionally not declared
|
|
97
|
+
# deploy:percepta-test injects BETTER_AUTH_SECRET and ENCRYPTION_SECRET_KEY
|
|
98
|
+
# from deploy/ryvn/percepta-test.secrets.env into the create request.
|
|
99
|
+
# Secret values are intentionally not declared here.
|
|
100
100
|
|
|
101
101
|
# Inngest (shared percepta-test platform service)
|
|
102
102
|
- key: INNGEST_BASE_URL
|
|
@@ -11,10 +11,10 @@
|
|
|
11
11
|
"start": "next start",
|
|
12
12
|
"lint": "eslint .",
|
|
13
13
|
"setup": "pnpm docker:up && pnpm db:setup-and-migrate && pnpm db:seed",
|
|
14
|
-
"docker:up": "docker compose up -d",
|
|
14
|
+
"docker:up": "docker compose up -d --wait",
|
|
15
15
|
"docker:down": "docker compose down",
|
|
16
16
|
"inngest:dev": "pnpm dlx inngest-cli@latest dev -u http://localhost:3000/api/inngest",
|
|
17
|
-
"db:generate": "
|
|
17
|
+
"db:generate": "tsx ./scripts/generate-migrations.ts",
|
|
18
18
|
"db:migrate": "tsx ./scripts/migrate.ts",
|
|
19
19
|
"db:setup": "tsx ./scripts/setup-database.ts",
|
|
20
20
|
"db:setup-and-migrate": "pnpm db:setup && pnpm db:migrate",
|
|
@@ -81,6 +81,11 @@ interface Installation {
|
|
|
81
81
|
name?: string;
|
|
82
82
|
status?: string;
|
|
83
83
|
health?: string;
|
|
84
|
+
release?: {
|
|
85
|
+
commitSha?: string;
|
|
86
|
+
currentVersion?: string;
|
|
87
|
+
targetVersion?: string;
|
|
88
|
+
};
|
|
84
89
|
}
|
|
85
90
|
|
|
86
91
|
interface VariableGroup {
|
|
@@ -91,6 +96,17 @@ interface VariableGroup {
|
|
|
91
96
|
}>;
|
|
92
97
|
}
|
|
93
98
|
|
|
99
|
+
interface Release {
|
|
100
|
+
version?: string;
|
|
101
|
+
commit?: {
|
|
102
|
+
sha?: string;
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
interface ReleaseList {
|
|
107
|
+
releases?: Release[];
|
|
108
|
+
}
|
|
109
|
+
|
|
94
110
|
function parseArgs(argv: string[]): Options {
|
|
95
111
|
const options: Options = {
|
|
96
112
|
yes: false,
|
|
@@ -151,6 +167,22 @@ function run(
|
|
|
151
167
|
return typeof result === "string" ? result.trim() : "";
|
|
152
168
|
}
|
|
153
169
|
|
|
170
|
+
function runWithInput(
|
|
171
|
+
command: string,
|
|
172
|
+
args: string[],
|
|
173
|
+
cwd: string,
|
|
174
|
+
inputText: string,
|
|
175
|
+
): string {
|
|
176
|
+
const result = execFileSync(command, args, {
|
|
177
|
+
cwd,
|
|
178
|
+
encoding: "utf8",
|
|
179
|
+
input: inputText,
|
|
180
|
+
stdio: ["pipe", "inherit", "inherit"],
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
return typeof result === "string" ? result.trim() : "";
|
|
184
|
+
}
|
|
185
|
+
|
|
154
186
|
function runJson<T>(command: string, args: string[], cwd: string): T {
|
|
155
187
|
const outputText = run(command, args, cwd, "pipe");
|
|
156
188
|
return JSON.parse(outputText) as T;
|
|
@@ -248,7 +280,7 @@ async function ensureGitHubRepo(
|
|
|
248
280
|
"repo",
|
|
249
281
|
"create",
|
|
250
282
|
repoSlug,
|
|
251
|
-
"--
|
|
283
|
+
"--internal",
|
|
252
284
|
"--source",
|
|
253
285
|
monorepoRoot,
|
|
254
286
|
"--remote",
|
|
@@ -301,6 +333,10 @@ function upsertService(manifestPath: string, monorepoRoot: string): void {
|
|
|
301
333
|
run("ryvn", ["replace", "-f", manifestPath], monorepoRoot);
|
|
302
334
|
}
|
|
303
335
|
|
|
336
|
+
function getCurrentCommitSha(monorepoRoot: string): string {
|
|
337
|
+
return run("git", ["rev-parse", "HEAD"], monorepoRoot, "pipe");
|
|
338
|
+
}
|
|
339
|
+
|
|
304
340
|
function getInstallation(
|
|
305
341
|
name: string,
|
|
306
342
|
monorepoRoot: string,
|
|
@@ -335,6 +371,39 @@ function installationExists(name: string, monorepoRoot: string): boolean {
|
|
|
335
371
|
return getInstallation(name, monorepoRoot) !== null;
|
|
336
372
|
}
|
|
337
373
|
|
|
374
|
+
function isHealthyAtCommit(
|
|
375
|
+
installation: Installation | null,
|
|
376
|
+
commitSha: string,
|
|
377
|
+
): boolean {
|
|
378
|
+
return (
|
|
379
|
+
installation?.status === "UP_TO_DATE" &&
|
|
380
|
+
installation.health === "HEALTHY" &&
|
|
381
|
+
installation.release?.commitSha === commitSha
|
|
382
|
+
);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function getReleaseForCommit(
|
|
386
|
+
serviceName: string,
|
|
387
|
+
commitSha: string,
|
|
388
|
+
monorepoRoot: string,
|
|
389
|
+
): Release | null {
|
|
390
|
+
try {
|
|
391
|
+
const releaseList = runJson<ReleaseList>(
|
|
392
|
+
"ryvn",
|
|
393
|
+
["get", "release", "--service", serviceName, "-o", "json"],
|
|
394
|
+
monorepoRoot,
|
|
395
|
+
);
|
|
396
|
+
|
|
397
|
+
return (
|
|
398
|
+
releaseList.releases?.find(
|
|
399
|
+
(release) => release.commit?.sha === commitSha,
|
|
400
|
+
) ?? null
|
|
401
|
+
);
|
|
402
|
+
} catch {
|
|
403
|
+
return null;
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
338
407
|
function assertHealthyPlatformInstallation(
|
|
339
408
|
installation: Installation | null,
|
|
340
409
|
name: string,
|
|
@@ -429,19 +498,170 @@ function upsertInstallation(
|
|
|
429
498
|
name: string,
|
|
430
499
|
manifestPath: string,
|
|
431
500
|
monorepoRoot: string,
|
|
501
|
+
expectedCommitSha: string,
|
|
502
|
+
manifestContents?: string,
|
|
503
|
+
): void {
|
|
504
|
+
const existingInstallation = getInstallation(name, monorepoRoot);
|
|
505
|
+
if (isHealthyAtCommit(existingInstallation, expectedCommitSha)) {
|
|
506
|
+
console.log(
|
|
507
|
+
`${name} is already healthy at ${expectedCommitSha.slice(0, 7)}; skipping installation replace.`,
|
|
508
|
+
);
|
|
509
|
+
return;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
const action = existingInstallation == null ? "create" : "replace";
|
|
513
|
+
try {
|
|
514
|
+
if (existingInstallation == null) {
|
|
515
|
+
if (manifestContents == null) {
|
|
516
|
+
run("ryvn", ["create", "-f", manifestPath], monorepoRoot);
|
|
517
|
+
} else {
|
|
518
|
+
runWithInput(
|
|
519
|
+
"ryvn",
|
|
520
|
+
["create", "-f", "-"],
|
|
521
|
+
monorepoRoot,
|
|
522
|
+
manifestContents,
|
|
523
|
+
);
|
|
524
|
+
}
|
|
525
|
+
} else if (manifestContents == null) {
|
|
526
|
+
run("ryvn", ["replace", "-f", manifestPath], monorepoRoot);
|
|
527
|
+
} else {
|
|
528
|
+
runWithInput(
|
|
529
|
+
"ryvn",
|
|
530
|
+
["replace", "-f", "-"],
|
|
531
|
+
monorepoRoot,
|
|
532
|
+
manifestContents,
|
|
533
|
+
);
|
|
534
|
+
}
|
|
535
|
+
} catch (error) {
|
|
536
|
+
const currentInstallation = getInstallation(name, monorepoRoot);
|
|
537
|
+
if (isHealthyAtCommit(currentInstallation, expectedCommitSha)) {
|
|
538
|
+
console.log(
|
|
539
|
+
`${name} ${action} returned an error, but Ryvn now reports it healthy at ${expectedCommitSha.slice(0, 7)}; continuing.`,
|
|
540
|
+
);
|
|
541
|
+
return;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
throw error;
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
function printFailedWorkflowLog(
|
|
549
|
+
repoSlug: string,
|
|
550
|
+
runId: number,
|
|
551
|
+
monorepoRoot: string,
|
|
432
552
|
): void {
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
run(
|
|
553
|
+
try {
|
|
554
|
+
console.log("");
|
|
555
|
+
console.log(`Failed workflow log for ${runId}:`);
|
|
556
|
+
run(
|
|
557
|
+
"gh",
|
|
558
|
+
["run", "view", String(runId), "--repo", repoSlug, "--log-failed"],
|
|
559
|
+
monorepoRoot,
|
|
560
|
+
);
|
|
561
|
+
} catch {
|
|
562
|
+
console.log(`Could not fetch failed workflow logs for ${runId}.`);
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
async function ensureReleaseForCurrentCommit(
|
|
567
|
+
serviceName: string,
|
|
568
|
+
repoSlug: string,
|
|
569
|
+
workflowFile: string,
|
|
570
|
+
monorepoRoot: string,
|
|
571
|
+
options: Options,
|
|
572
|
+
commitSha: string,
|
|
573
|
+
): Promise<Release> {
|
|
574
|
+
const existingRelease = getReleaseForCommit(
|
|
575
|
+
serviceName,
|
|
576
|
+
commitSha,
|
|
577
|
+
monorepoRoot,
|
|
578
|
+
);
|
|
579
|
+
if (existingRelease != null) {
|
|
580
|
+
console.log(
|
|
581
|
+
`${serviceName} release ${existingRelease.version ?? "unknown"} already exists for ${commitSha.slice(0, 7)}.`,
|
|
582
|
+
);
|
|
583
|
+
return existingRelease;
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
await triggerWorkflow(
|
|
587
|
+
serviceName,
|
|
588
|
+
repoSlug,
|
|
589
|
+
workflowFile,
|
|
590
|
+
monorepoRoot,
|
|
591
|
+
options,
|
|
592
|
+
commitSha,
|
|
593
|
+
);
|
|
594
|
+
|
|
595
|
+
const release = getReleaseForCommit(serviceName, commitSha, monorepoRoot);
|
|
596
|
+
if (release == null) {
|
|
597
|
+
throw new Error(
|
|
598
|
+
`No Ryvn release for ${serviceName} at ${commitSha} after ${workflowFile}.`,
|
|
599
|
+
);
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
return release;
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
function assertReleaseExistsForSkippedWorkflow(
|
|
606
|
+
serviceName: string,
|
|
607
|
+
commitSha: string,
|
|
608
|
+
monorepoRoot: string,
|
|
609
|
+
): Release {
|
|
610
|
+
const release = getReleaseForCommit(serviceName, commitSha, monorepoRoot);
|
|
611
|
+
if (release == null) {
|
|
612
|
+
throw new Error(
|
|
613
|
+
`--skip-workflows was set, but no ${serviceName} Ryvn release exists for commit ${commitSha}. Rerun without --skip-workflows or confirm the release was created.`,
|
|
614
|
+
);
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
return release;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
function logReleaseSummary(serviceName: string, release: Release): void {
|
|
621
|
+
console.log(
|
|
622
|
+
`${serviceName} release ready: ${release.version ?? "unknown version"}.`,
|
|
623
|
+
);
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
function requireReleaseForCurrentCommit(
|
|
627
|
+
serviceName: string,
|
|
628
|
+
repoSlug: string,
|
|
629
|
+
workflowFile: string,
|
|
630
|
+
monorepoRoot: string,
|
|
631
|
+
options: Options,
|
|
632
|
+
commitSha: string,
|
|
633
|
+
): Promise<Release> {
|
|
634
|
+
if (options.skipWorkflows) {
|
|
635
|
+
return Promise.resolve(
|
|
636
|
+
assertReleaseExistsForSkippedWorkflow(
|
|
637
|
+
serviceName,
|
|
638
|
+
commitSha,
|
|
639
|
+
monorepoRoot,
|
|
640
|
+
),
|
|
641
|
+
);
|
|
437
642
|
}
|
|
643
|
+
|
|
644
|
+
return ensureReleaseForCurrentCommit(
|
|
645
|
+
serviceName,
|
|
646
|
+
repoSlug,
|
|
647
|
+
workflowFile,
|
|
648
|
+
monorepoRoot,
|
|
649
|
+
options,
|
|
650
|
+
commitSha,
|
|
651
|
+
);
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
function isRetryableReleaseFailure(error: unknown): boolean {
|
|
655
|
+
return error instanceof Error;
|
|
438
656
|
}
|
|
439
657
|
|
|
440
658
|
async function triggerWorkflow(
|
|
659
|
+
serviceName: string,
|
|
441
660
|
repoSlug: string,
|
|
442
661
|
workflowFile: string,
|
|
443
662
|
monorepoRoot: string,
|
|
444
663
|
options: Options,
|
|
664
|
+
commitSha: string,
|
|
445
665
|
): Promise<void> {
|
|
446
666
|
if (options.skipWorkflows) return;
|
|
447
667
|
|
|
@@ -501,18 +721,32 @@ async function triggerWorkflow(
|
|
|
501
721
|
}
|
|
502
722
|
|
|
503
723
|
console.log(`Watching ${runInfo.url}`);
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
724
|
+
try {
|
|
725
|
+
run(
|
|
726
|
+
"gh",
|
|
727
|
+
[
|
|
728
|
+
"run",
|
|
729
|
+
"watch",
|
|
730
|
+
String(runInfo.databaseId),
|
|
731
|
+
"--repo",
|
|
732
|
+
repoSlug,
|
|
733
|
+
"--exit-status",
|
|
734
|
+
],
|
|
735
|
+
monorepoRoot,
|
|
736
|
+
);
|
|
737
|
+
} catch (error) {
|
|
738
|
+
printFailedWorkflowLog(repoSlug, runInfo.databaseId, monorepoRoot);
|
|
739
|
+
|
|
740
|
+
const release = getReleaseForCommit(serviceName, commitSha, monorepoRoot);
|
|
741
|
+
if (release != null && isRetryableReleaseFailure(error)) {
|
|
742
|
+
console.log(
|
|
743
|
+
`${workflowFile} reported a failure, but ${serviceName} release ${release.version ?? "unknown"} exists for ${commitSha.slice(0, 7)}; continuing.`,
|
|
744
|
+
);
|
|
745
|
+
return;
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
throw error;
|
|
749
|
+
}
|
|
516
750
|
}
|
|
517
751
|
|
|
518
752
|
function latestTask(tasks: InstallationTask[]): InstallationTask | null {
|
|
@@ -619,12 +853,11 @@ function parseEnvFile(
|
|
|
619
853
|
);
|
|
620
854
|
}
|
|
621
855
|
|
|
622
|
-
async function
|
|
856
|
+
async function readInstallationSecrets(
|
|
623
857
|
packageDir: string,
|
|
624
|
-
monorepoRoot: string,
|
|
625
858
|
options: Options,
|
|
626
|
-
): Promise<
|
|
627
|
-
if (options.skipSecrets) return;
|
|
859
|
+
): Promise<Array<{ name: string; value: string }>> {
|
|
860
|
+
if (options.skipSecrets) return [];
|
|
628
861
|
|
|
629
862
|
const secretsPath = path.join(
|
|
630
863
|
packageDir,
|
|
@@ -636,20 +869,42 @@ async function patchInstallationSecrets(
|
|
|
636
869
|
console.log(
|
|
637
870
|
`No secrets file found at ${secretsPath}; skipping secret patch.`,
|
|
638
871
|
);
|
|
639
|
-
return;
|
|
872
|
+
return [];
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
return parseEnvFile(await readFile(secretsPath, "utf8"));
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
function appendInstallationSecrets(
|
|
879
|
+
manifestContents: string,
|
|
880
|
+
secrets: Array<{ name: string; value: string }>,
|
|
881
|
+
): string {
|
|
882
|
+
if (secrets.length === 0) return manifestContents;
|
|
883
|
+
|
|
884
|
+
const marker = " variableGroups:\n";
|
|
885
|
+
if (!manifestContents.includes(marker)) {
|
|
886
|
+
throw new Error("Installation manifest is missing variableGroups marker.");
|
|
640
887
|
}
|
|
641
888
|
|
|
642
|
-
const
|
|
889
|
+
const secretYaml = [
|
|
890
|
+
" secrets:",
|
|
891
|
+
...secrets.flatMap(({ name, value }) => [
|
|
892
|
+
` - name: ${name}`,
|
|
893
|
+
` value: ${JSON.stringify(value)}`,
|
|
894
|
+
]),
|
|
895
|
+
"",
|
|
896
|
+
].join("\n");
|
|
897
|
+
|
|
898
|
+
return manifestContents.replace(marker, `${secretYaml}${marker}`);
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
function patchExistingInstallationSecrets(
|
|
902
|
+
secrets: Array<{ name: string; value: string }>,
|
|
903
|
+
monorepoRoot: string,
|
|
904
|
+
): void {
|
|
643
905
|
if (secrets.length === 0) return;
|
|
644
906
|
|
|
645
|
-
const
|
|
646
|
-
key: name,
|
|
647
|
-
value,
|
|
648
|
-
isSecret: true,
|
|
649
|
-
}));
|
|
650
|
-
// Strategic merge preserves existing non-secret env entries while upserting
|
|
651
|
-
// these generated secrets by key.
|
|
652
|
-
const patch = JSON.stringify({ spec: { env: secretEnv, secrets } });
|
|
907
|
+
const patch = JSON.stringify({ spec: { secrets } });
|
|
653
908
|
const result = spawnSync(
|
|
654
909
|
"ryvn",
|
|
655
910
|
[
|
|
@@ -672,7 +927,7 @@ async function patchInstallationSecrets(
|
|
|
672
927
|
);
|
|
673
928
|
|
|
674
929
|
if (result.status !== 0) {
|
|
675
|
-
throw new Error(`Failed to patch secrets for ${APP_NAME}.`);
|
|
930
|
+
throw new Error(`Failed to patch generated app secrets for ${APP_NAME}.`);
|
|
676
931
|
}
|
|
677
932
|
}
|
|
678
933
|
|
|
@@ -776,6 +1031,7 @@ async function main(): Promise<void> {
|
|
|
776
1031
|
|
|
777
1032
|
const repoSlug = await ensureGitHubRepo(monorepoRoot, options);
|
|
778
1033
|
ensureCleanAndPushed(monorepoRoot, options);
|
|
1034
|
+
const commitSha = getCurrentCommitSha(monorepoRoot);
|
|
779
1035
|
|
|
780
1036
|
const ryvnDir = path.join(packageDir, "deploy", "ryvn");
|
|
781
1037
|
const serviceManifest = path.join(ryvnDir, `${APP_NAME}.service.yaml`);
|
|
@@ -802,27 +1058,46 @@ async function main(): Promise<void> {
|
|
|
802
1058
|
upsertService(terraformServiceManifest, monorepoRoot);
|
|
803
1059
|
upsertService(serviceManifest, monorepoRoot);
|
|
804
1060
|
|
|
805
|
-
await
|
|
1061
|
+
const terraformRelease = await requireReleaseForCurrentCommit(
|
|
1062
|
+
TERRAFORM_SERVICE_NAME,
|
|
806
1063
|
repoSlug,
|
|
807
1064
|
`${TERRAFORM_SERVICE_NAME}-ryvn-release.yaml`,
|
|
808
1065
|
monorepoRoot,
|
|
809
1066
|
options,
|
|
1067
|
+
commitSha,
|
|
810
1068
|
);
|
|
1069
|
+
logReleaseSummary(TERRAFORM_SERVICE_NAME, terraformRelease);
|
|
811
1070
|
upsertInstallation(
|
|
812
1071
|
TERRAFORM_SERVICE_NAME,
|
|
813
1072
|
terraformInstallationManifest,
|
|
814
1073
|
monorepoRoot,
|
|
1074
|
+
commitSha,
|
|
815
1075
|
);
|
|
816
1076
|
await waitForTerraformApply(monorepoRoot, options);
|
|
817
1077
|
|
|
818
|
-
await
|
|
1078
|
+
const appRelease = await requireReleaseForCurrentCommit(
|
|
1079
|
+
APP_NAME,
|
|
819
1080
|
repoSlug,
|
|
820
1081
|
`${APP_NAME}-ryvn-release.yaml`,
|
|
821
1082
|
monorepoRoot,
|
|
822
1083
|
options,
|
|
1084
|
+
commitSha,
|
|
1085
|
+
);
|
|
1086
|
+
logReleaseSummary(APP_NAME, appRelease);
|
|
1087
|
+
const secrets = await readInstallationSecrets(packageDir, options);
|
|
1088
|
+
if (installationExists(APP_NAME, monorepoRoot)) {
|
|
1089
|
+
patchExistingInstallationSecrets(secrets, monorepoRoot);
|
|
1090
|
+
}
|
|
1091
|
+
upsertInstallation(
|
|
1092
|
+
APP_NAME,
|
|
1093
|
+
installationManifest,
|
|
1094
|
+
monorepoRoot,
|
|
1095
|
+
commitSha,
|
|
1096
|
+
appendInstallationSecrets(
|
|
1097
|
+
await readFile(installationManifest, "utf8"),
|
|
1098
|
+
secrets,
|
|
1099
|
+
),
|
|
823
1100
|
);
|
|
824
|
-
upsertInstallation(APP_NAME, installationManifest, monorepoRoot);
|
|
825
|
-
await patchInstallationSecrets(packageDir, monorepoRoot, options);
|
|
826
1101
|
await waitForHealthy(monorepoRoot, options);
|
|
827
1102
|
|
|
828
1103
|
await verifyDeployment();
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
#!/usr/bin/env tsx
|
|
2
|
+
|
|
3
|
+
import { execFileSync } from "node:child_process";
|
|
4
|
+
import { readFileSync, readdirSync, writeFileSync } from "node:fs";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { normalizeSchemaRelativeReferences } from "../src/drizzle/migrationSql";
|
|
7
|
+
|
|
8
|
+
const migrationsDir = path.resolve("src", "drizzle", "migrations");
|
|
9
|
+
|
|
10
|
+
function main(): void {
|
|
11
|
+
execFileSync("drizzle-kit", ["generate"], {
|
|
12
|
+
cwd: process.cwd(),
|
|
13
|
+
stdio: "inherit",
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
for (const fileName of readdirSync(migrationsDir)) {
|
|
17
|
+
if (!fileName.endsWith(".sql")) continue;
|
|
18
|
+
|
|
19
|
+
const filePath = path.join(migrationsDir, fileName);
|
|
20
|
+
const original = readFileSync(filePath, "utf8");
|
|
21
|
+
const normalized = normalizeSchemaRelativeReferences(original);
|
|
22
|
+
if (normalized !== original) {
|
|
23
|
+
writeFileSync(filePath, normalized);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
main();
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { normalizeSchemaRelativeReferences } from "../migrationSql";
|
|
3
|
+
|
|
4
|
+
describe("normalizeSchemaRelativeReferences", () => {
|
|
5
|
+
it("keeps generated foreign keys schema-relative for DATABASE_SCHEMA deploys", () => {
|
|
6
|
+
expect(
|
|
7
|
+
normalizeSchemaRelativeReferences(
|
|
8
|
+
'ALTER TABLE "child" ADD CONSTRAINT "child_parent_fk" FOREIGN KEY ("parent_id") REFERENCES "public"."parent"("id") ON DELETE cascade;',
|
|
9
|
+
),
|
|
10
|
+
).toBe(
|
|
11
|
+
'ALTER TABLE "child" ADD CONSTRAINT "child_parent_fk" FOREIGN KEY ("parent_id") REFERENCES "parent"("id") ON DELETE cascade;',
|
|
12
|
+
);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it("leaves already schema-relative references unchanged", () => {
|
|
16
|
+
expect(
|
|
17
|
+
normalizeSchemaRelativeReferences(
|
|
18
|
+
'ALTER TABLE "child" ADD CONSTRAINT "child_parent_fk" FOREIGN KEY ("parent_id") REFERENCES "parent"("id");',
|
|
19
|
+
),
|
|
20
|
+
).toBe(
|
|
21
|
+
'ALTER TABLE "child" ADD CONSTRAINT "child_parent_fk" FOREIGN KEY ("parent_id") REFERENCES "parent"("id");',
|
|
22
|
+
);
|
|
23
|
+
});
|
|
24
|
+
});
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
const PUBLIC_SCHEMA_REFERENCE_PATTERN = /REFERENCES\s+"public"\."([^"]+)"/g;
|
|
2
|
+
|
|
3
|
+
export function normalizeSchemaRelativeReferences(sql: string): string {
|
|
4
|
+
return sql.replace(
|
|
5
|
+
PUBLIC_SCHEMA_REFERENCE_PATTERN,
|
|
6
|
+
(_match, tableName: string) => `REFERENCES "${tableName}"`,
|
|
7
|
+
);
|
|
8
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { type AppInngest, InngestService } from "./InngestService";
|
|
2
|
+
import { type ExampleEventPayload } from "./events/payloads/ExampleEventPayload";
|
|
3
|
+
|
|
4
|
+
type AppWorkflowClient = Pick<AppInngest, "send">;
|
|
5
|
+
|
|
6
|
+
export class AppWorkflowService {
|
|
7
|
+
public static create(): AppWorkflowService {
|
|
8
|
+
return new AppWorkflowService(InngestService.create().client);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
public constructor(private inngestClient: AppWorkflowClient) {}
|
|
12
|
+
|
|
13
|
+
public async sendExampleEvent(payload: ExampleEventPayload): Promise<void> {
|
|
14
|
+
await this.inngestClient.send({
|
|
15
|
+
name: "app/example.event",
|
|
16
|
+
data: payload,
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { AppWorkflowService } from "../AppWorkflowService";
|
|
3
|
+
import { AppEvents } from "../events/AppEvents";
|
|
4
|
+
|
|
5
|
+
describe("AppWorkflowService", () => {
|
|
6
|
+
it("sends event data in the shape validated by AppEvents", async () => {
|
|
7
|
+
const payload = { exampleId: "example-1" };
|
|
8
|
+
const send = vi.fn().mockResolvedValue(undefined);
|
|
9
|
+
const service = new AppWorkflowService({ send } as never);
|
|
10
|
+
|
|
11
|
+
await service.sendExampleEvent(payload);
|
|
12
|
+
|
|
13
|
+
expect(AppEvents["app/example.event"].parse(payload)).toEqual(payload);
|
|
14
|
+
expect(send).toHaveBeenCalledWith({
|
|
15
|
+
name: "app/example.event",
|
|
16
|
+
data: payload,
|
|
17
|
+
});
|
|
18
|
+
});
|
|
19
|
+
});
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import z from "zod";
|
|
2
1
|
import { ExampleEventPayload } from "./payloads/ExampleEventPayload";
|
|
3
2
|
|
|
4
3
|
/**
|
|
@@ -6,29 +5,24 @@ import { ExampleEventPayload } from "./payloads/ExampleEventPayload";
|
|
|
6
5
|
*
|
|
7
6
|
* Each event should have:
|
|
8
7
|
* - A unique name (conventionally "app/event.name")
|
|
9
|
-
* - A Zod schema for the
|
|
8
|
+
* - A Zod schema for event.data. Do not wrap the payload in another
|
|
9
|
+
* `{ data: ... }` object here.
|
|
10
10
|
*
|
|
11
11
|
* @example
|
|
12
12
|
* ```ts
|
|
13
13
|
* export const AppEvents = {
|
|
14
14
|
* "app/user.created": z.object({
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
* email: z.string(),
|
|
18
|
-
* }),
|
|
15
|
+
* userId: z.string(),
|
|
16
|
+
* email: z.string(),
|
|
19
17
|
* }),
|
|
20
18
|
* "app/order.completed": z.object({
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
* total: z.number(),
|
|
24
|
-
* }),
|
|
19
|
+
* orderId: z.string(),
|
|
20
|
+
* total: z.number(),
|
|
25
21
|
* }),
|
|
26
22
|
* };
|
|
27
23
|
* ```
|
|
28
24
|
*/
|
|
29
25
|
export const AppEvents = {
|
|
30
26
|
// Example event - replace with your actual events
|
|
31
|
-
"app/example.event":
|
|
32
|
-
data: ExampleEventPayload.SCHEMA,
|
|
33
|
-
}),
|
|
27
|
+
"app/example.event": ExampleEventPayload.SCHEMA,
|
|
34
28
|
};
|
|
@@ -7,8 +7,6 @@ import z from "zod";
|
|
|
7
7
|
export type ExampleEventPayload = z.infer<typeof ExampleEventPayload.SCHEMA>;
|
|
8
8
|
export namespace ExampleEventPayload {
|
|
9
9
|
export const SCHEMA = z.object({
|
|
10
|
-
|
|
11
|
-
// exampleId: z.string(),
|
|
12
|
-
// data: z.record(z.unknown()),
|
|
10
|
+
exampleId: z.string(),
|
|
13
11
|
});
|
|
14
12
|
}
|