@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@percepta/create",
3
- "version": "3.1.3",
3
+ "version": "3.1.4",
4
4
  "description": "Scaffold a new Mosaic package",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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. **Review the generated SQL** — Drizzle generates it automatically but you should verify it's correct.
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; patched into Ryvn by the deploy helper.
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. Patches `BETTER_AUTH_SECRET` and `ENCRYPTION_SECRET_KEY` from `deploy/ryvn/percepta-test.secrets.env`.
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 deploy helper patched `BETTER_AUTH_SECRET` and `ENCRYPTION_SECRET_KEY`.
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": z.object({
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
- const inngest = InngestService.create();
97
- await inngest.client.send({
98
- name: "app/document.processed",
99
- data: { documentId: "abc", userId: "user-1", pageCount: 5 },
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> --private --source=. --push
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 the web installation, patches generated secrets, waits for health, and verifies the health and app routes.
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 patches app-specific auth/encryption secrets into the Ryvn installation; shared Langfuse and LLM demo keys are inherited from a Ryvn variable group.
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 patches BETTER_AUTH_SECRET and ENCRYPTION_SECRET_KEY
98
- # from deploy/ryvn/percepta-test.secrets.env after the installation exists.
99
- # Secret values are intentionally not declared in GitOps IaC.
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": "drizzle-kit 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
- "--private",
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
- if (installationExists(name, monorepoRoot)) {
434
- run("ryvn", ["replace", "-f", manifestPath], monorepoRoot);
435
- } else {
436
- run("ryvn", ["create", "-f", manifestPath], monorepoRoot);
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
- run(
505
- "gh",
506
- [
507
- "run",
508
- "watch",
509
- String(runInfo.databaseId),
510
- "--repo",
511
- repoSlug,
512
- "--exit-status",
513
- ],
514
- monorepoRoot,
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 patchInstallationSecrets(
856
+ async function readInstallationSecrets(
623
857
  packageDir: string,
624
- monorepoRoot: string,
625
858
  options: Options,
626
- ): Promise<void> {
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 secrets = parseEnvFile(await readFile(secretsPath, "utf8"));
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 secretEnv = secrets.map(({ name, value }) => ({
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 triggerWorkflow(
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 triggerWorkflow(
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 event data
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
- * data: z.object({
16
- * userId: z.string(),
17
- * email: z.string(),
18
- * }),
15
+ * userId: z.string(),
16
+ * email: z.string(),
19
17
  * }),
20
18
  * "app/order.completed": z.object({
21
- * data: z.object({
22
- * orderId: z.string(),
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": z.object({
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
- // Add your event properties here
11
- // exampleId: z.string(),
12
- // data: z.record(z.unknown()),
10
+ exampleId: z.string(),
13
11
  });
14
12
  }