@percepta/create 3.4.3 → 3.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -0
- package/dist/index.js +135 -106
- package/dist/index.js.map +1 -1
- package/dist/{init-CtCp7Tv2.js → init-sI9aIrkU.js} +2 -2
- package/dist/init-sI9aIrkU.js.map +1 -0
- package/dist/{upstream-D-LH_1z4.js → upstream-gUHLWSR1.js} +2 -2
- package/dist/upstream-gUHLWSR1.js.map +1 -0
- package/package.json +1 -1
- package/template-versions.json +1 -0
- package/templates/monorepo/README.md +8 -5
- package/templates/monorepo/package.json.template +1 -0
- package/templates/webapp/.claude/commands/upstream.md +1 -1
- package/templates/webapp/agent-skills/access-control.md +24 -1
- package/templates/webapp/drizzle.config.ts +1 -1
- package/templates/webapp/package.json.template +5 -5
- package/templates/webapp/src/drizzle/db.ts +1 -2
- package/dist/init-CtCp7Tv2.js.map +0 -1
- package/dist/upstream-D-LH_1z4.js.map +0 -1
- package/templates/webapp/scripts/generate-migrations.ts +0 -28
- package/templates/webapp/scripts/migrate.ts +0 -21
- package/templates/webapp/scripts/setup-database.ts +0 -78
- package/templates/webapp/scripts/setup-readonly-user.ts +0 -193
- package/templates/webapp/src/drizzle/__tests__/migrationSql.test.ts +0 -24
- package/templates/webapp/src/drizzle/migrationSql.ts +0 -8
- package/templates/webapp/src/drizzle/searchPath.test.ts +0 -21
- package/templates/webapp/src/drizzle/searchPath.ts +0 -16
- package/templates/webapp/src/drizzle/ssl.ts +0 -5
|
@@ -140,7 +140,7 @@ If `access:reconcile` should repair missing system links for a resource type, pr
|
|
|
140
140
|
|
|
141
141
|
## Permissioned Postgres Tables
|
|
142
142
|
|
|
143
|
-
When a Drizzle table backs a SpiceDB resource, bind the table to the resource manifest once. Keep Zed as the permission source of truth; the table binding
|
|
143
|
+
When a Drizzle table backs a SpiceDB resource, bind the table to the resource manifest once. Keep Zed as the permission source of truth; the table binding records object identity, relationship columns, and which field groups require which permissions. It does not automatically write to SpiceDB.
|
|
144
144
|
|
|
145
145
|
```ts
|
|
146
146
|
import {
|
|
@@ -192,6 +192,29 @@ const checks = createColumnPermissionChecks(employeeAccess, {
|
|
|
192
192
|
const [canReadSalary] = await getAccessControl().permissions.canMany(checks);
|
|
193
193
|
```
|
|
194
194
|
|
|
195
|
+
Use a lifecycle sync wrapper in repositories/services that create, update, or delete rows with SpiceDB-backed relationships. Configure it once with either direct SpiceDB writes for authorization changes that must be visible immediately, or outbox enqueueing for transactional retry.
|
|
196
|
+
|
|
197
|
+
```ts
|
|
198
|
+
import {
|
|
199
|
+
createPermissionedResourceLifecycleSync,
|
|
200
|
+
} from "@percepta/access-control/drizzle";
|
|
201
|
+
|
|
202
|
+
const employeeLifecycle = createPermissionedResourceLifecycleSync({
|
|
203
|
+
apply: {
|
|
204
|
+
client: getAccessControl().client,
|
|
205
|
+
mode: "direct",
|
|
206
|
+
},
|
|
207
|
+
binding: employeeAccess,
|
|
208
|
+
system: accessManifest.system.ref(),
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
await employeeLifecycle.afterInsert(createdEmployee);
|
|
212
|
+
await employeeLifecycle.afterUpdate(existingEmployee, updatedEmployee);
|
|
213
|
+
await employeeLifecycle.afterDelete(deletedEmployee);
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
For transactional outbox sync, pass `apply: { mode: "outbox", enqueue }`; the queued event contains either relationship mutations or a resource delete filter. Hard deletes use resource-filter cleanup by default so relationships not represented by local columns are removed too. Pass `{ cleanup: "derived-mutations" }` to `afterDelete()` only when the resource should keep out-of-band relationships.
|
|
217
|
+
|
|
195
218
|
Do not model ordinary Postgres columns as SpiceDB objects. Model business permissions in Zed, group columns under those permissions in the table binding, then enforce the binding at the API/data boundary.
|
|
196
219
|
|
|
197
220
|
## App Code
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { loadEnvConfig } from "@next/env";
|
|
2
|
+
import { getPgSearchPathOption } from "@percepta/database";
|
|
2
3
|
import type { Config } from "drizzle-kit";
|
|
3
4
|
import { getEnvConfig } from "./src/config/getEnvConfig";
|
|
4
|
-
import { getPgSearchPathOption } from "./src/drizzle/searchPath";
|
|
5
5
|
|
|
6
6
|
loadEnvConfig(process.cwd());
|
|
7
7
|
|
|
@@ -17,11 +17,11 @@
|
|
|
17
17
|
"access:apply-local": "pnpm --dir ../.. run access:apply-local",
|
|
18
18
|
"auth:db:setup-and-migrate": "pnpm --dir ../.. run auth:db:setup-and-migrate",
|
|
19
19
|
"inngest:dev": "pnpm dlx inngest-cli@latest dev -u http://localhost:3000/api/inngest",
|
|
20
|
-
"db:generate": "
|
|
21
|
-
"db:migrate": "
|
|
22
|
-
"db:setup": "
|
|
20
|
+
"db:generate": "percepta-db generate-migrations",
|
|
21
|
+
"db:migrate": "percepta-db migrate --database __DB_NAME__",
|
|
22
|
+
"db:setup": "percepta-db setup --database __DB_NAME__",
|
|
23
23
|
"db:setup-and-migrate": "pnpm db:setup && pnpm db:migrate",
|
|
24
|
-
"db:setup-readonly": "
|
|
24
|
+
"db:setup-readonly": "percepta-db setup-readonly --database __DB_NAME__",
|
|
25
25
|
"db:studio": "pnpm db:setup-and-migrate && drizzle-kit studio",
|
|
26
26
|
"db:seed": "tsx ./scripts/seed.ts",
|
|
27
27
|
"deploy:percepta-test": "percepta-deploy percepta-test --app __APP_NAME__ --repo __REPO_NAME__",
|
|
@@ -34,7 +34,6 @@
|
|
|
34
34
|
},
|
|
35
35
|
"dependencies": {
|
|
36
36
|
"@aws-sdk/client-s3": "^3.888.0",
|
|
37
|
-
"@aws-sdk/client-secrets-manager": "^3.914.0",
|
|
38
37
|
"@aws-sdk/client-sts": "^3.913.0",
|
|
39
38
|
"@aws-sdk/credential-providers": "^3.913.0",
|
|
40
39
|
"@aws-sdk/s3-request-presigner": "^3.891.0",
|
|
@@ -55,6 +54,7 @@
|
|
|
55
54
|
"@__REPO_NAME__/auth": "workspace:*",
|
|
56
55
|
"@percepta/access-control": "0.7.0",
|
|
57
56
|
"@percepta/ai": "0.1.0",
|
|
57
|
+
"@percepta/database": "0.1.0",
|
|
58
58
|
"@percepta/design": "0.3.2",
|
|
59
59
|
"@percepta/inngest": "0.1.0",
|
|
60
60
|
"@percepta/logger": "0.1.0",
|
|
@@ -1,8 +1,7 @@
|
|
|
1
|
+
import { getPgSearchPathOption, getPgSslConfig } from "@percepta/database";
|
|
1
2
|
import { type NodePgDatabase, drizzle } from "drizzle-orm/node-postgres";
|
|
2
3
|
import { Pool } from "pg";
|
|
3
4
|
import { getEnvConfig } from "../config/getEnvConfig";
|
|
4
|
-
import { getPgSearchPathOption } from "./searchPath";
|
|
5
|
-
import { getPgSslConfig } from "./ssl";
|
|
6
5
|
|
|
7
6
|
export const { client, db } = createDb();
|
|
8
7
|
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"init-CtCp7Tv2.js","names":[],"sources":["../src/commands/init.ts"],"sourcesContent":["import path from \"node:path\";\nimport chalk from \"chalk\";\nimport fs from \"fs-extra\";\nimport inquirer from \"inquirer\";\nimport {\n writeManifest,\n manifestExists,\n derivePlaceholders,\n type MosaicManifest,\n} from \"../utils/manifest.js\";\nimport { isValidProjectType, VALID_PROJECT_TYPES } from \"../utils/prompts.js\";\nimport { getTemplateVersion } from \"../utils/template-versions.js\";\n\nexport interface InitOptions {\n type?: string;\n templateVersion?: string;\n}\n\nexport async function initCommand(options: InitOptions): Promise<void> {\n const cwd = process.cwd();\n\n if (await manifestExists(cwd)) {\n console.error(\n chalk.red(\".mosaic-template.json already exists in this directory.\"),\n );\n process.exit(1);\n }\n\n // Auto-detect app name from package.json\n const pkgPath = path.join(cwd, \"package.json\");\n let appName = path.basename(cwd);\n if (await fs.pathExists(pkgPath)) {\n const pkg = JSON.parse(await fs.readFile(pkgPath, \"utf-8\"));\n appName = pkg.name?.replace(/^@[^/]+\\//, \"\") || appName;\n }\n\n // Determine template type\n let templateType = options.type;\n if (templateType && !isValidProjectType(templateType)) {\n console.error(\n chalk.red(\n `Invalid template type \"${templateType}\". Valid types: ${VALID_PROJECT_TYPES.join(\", \")}`,\n ),\n );\n process.exit(1);\n }\n if (!templateType) {\n const answer = await inquirer.prompt([\n {\n type: \"list\",\n name: \"type\",\n message: \"Template type:\",\n choices: [\"webapp\", \"library\"],\n },\n ]);\n templateType = answer.type;\n }\n\n const templateVersion =\n options.templateVersion || getTemplateVersion(templateType!);\n\n // Derive placeholders from app name\n const appTitle = appName\n .split(\"-\")\n .map((w: string) => w.charAt(0).toUpperCase() + w.slice(1))\n .join(\" \");\n\n const manifest: MosaicManifest = {\n templateType: templateType!,\n templateVersion,\n templateCommit: \"unknown\",\n createdAt: new Date().toISOString(),\n placeholders: derivePlaceholders(appName, appTitle),\n source: {\n templatePath: `packages/create-mosaic-module/templates/${templateType}`,\n },\n };\n\n await writeManifest(cwd, manifest);\n\n // Create mosaic-template-notes.md if it doesn't exist\n const notesPath = path.join(cwd, \"mosaic-template-notes.md\");\n if (!(await fs.pathExists(notesPath))) {\n await fs.writeFile(\n notesPath,\n `# Mosaic Divergence Notes\\n\\nDocument intentional differences from the ${templateType} template here.\\nClaude reads this file during sync to preserve your customizations.\\n\\n## Intentional Divergences\\n\\n`,\n );\n }\n\n console.log();\n console.log(\n chalk.green(\"\\u2714\"),\n chalk.bold(\"Initialized .mosaic-template.json\"),\n );\n console.log();\n console.log(chalk.dim(\" Template:\"), templateType);\n console.log(chalk.dim(\" Version:\"), templateVersion);\n console.log(chalk.dim(\" App name:\"), appName);\n console.log();\n console.log(\n chalk.dim(\n \"Review .mosaic-template.json and mosaic-template-notes.md, then commit them.\",\n ),\n );\n console.log();\n}\n"],"mappings":";;;;;;AAkBA,eAAsB,YAAY,SAAqC;CACrE,MAAM,MAAM,QAAQ,KAAK;AAEzB,KAAI,MAAM,eAAe,IAAI,EAAE;AAC7B,UAAQ,MACN,MAAM,IAAI,0DAA0D,CACrE;AACD,UAAQ,KAAK,EAAE;;CAIjB,MAAM,UAAU,KAAK,KAAK,KAAK,eAAe;CAC9C,IAAI,UAAU,KAAK,SAAS,IAAI;AAChC,KAAI,MAAM,GAAG,WAAW,QAAQ,CAE9B,WADY,KAAK,MAAM,MAAM,GAAG,SAAS,SAAS,QAAQ,CAC7C,CAAC,MAAM,QAAQ,aAAa,GAAG,IAAI;CAIlD,IAAI,eAAe,QAAQ;AAC3B,KAAI,gBAAgB,CAAC,mBAAmB,aAAa,EAAE;AACrD,UAAQ,MACN,MAAM,IACJ,0BAA0B,aAAa,kBAAkB,oBAAoB,KAAK,KAAK,GACxF,CACF;AACD,UAAQ,KAAK,EAAE;;AAEjB,KAAI,CAAC,aASH,iBAAe,MARM,SAAS,OAAO,CACnC;EACE,MAAM;EACN,MAAM;EACN,SAAS;EACT,SAAS,CAAC,UAAU,UAAU;EAC/B,CACF,CAAC,EACoB;CAGxB,MAAM,kBACJ,QAAQ,mBAAmB,mBAAmB,aAAc;CAG9D,MAAM,WAAW,QACd,MAAM,IAAI,CACV,KAAK,MAAc,EAAE,OAAO,EAAE,CAAC,aAAa,GAAG,EAAE,MAAM,EAAE,CAAC,CAC1D,KAAK,IAAI;AAaZ,OAAM,cAAc,KAAK;EAVT;EACd;EACA,gBAAgB;EAChB,4BAAW,IAAI,MAAM,EAAC,aAAa;EACnC,cAAc,mBAAmB,SAAS,SAAS;EACnD,QAAQ,EACN,cAAc,2CAA2C,gBAC1D;EAG8B,CAAC;CAGlC,MAAM,YAAY,KAAK,KAAK,KAAK,2BAA2B;AAC5D,KAAI,CAAE,MAAM,GAAG,WAAW,UAAU,CAClC,OAAM,GAAG,UACP,WACA,0EAA0E,aAAa,wHACxF;AAGH,SAAQ,KAAK;AACb,SAAQ,IACN,MAAM,MAAM,IAAS,EACrB,MAAM,KAAK,oCAAoC,CAChD;AACD,SAAQ,KAAK;AACb,SAAQ,IAAI,MAAM,IAAI,cAAc,EAAE,aAAa;AACnD,SAAQ,IAAI,MAAM,IAAI,aAAa,EAAE,gBAAgB;AACrD,SAAQ,IAAI,MAAM,IAAI,cAAc,EAAE,QAAQ;AAC9C,SAAQ,KAAK;AACb,SAAQ,IACN,MAAM,IACJ,+EACD,CACF;AACD,SAAQ,KAAK"}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"upstream-D-LH_1z4.js","names":[],"sources":["../src/commands/upstream.ts"],"sourcesContent":["import path from \"node:path\";\nimport chalk from \"chalk\";\nimport fs from \"fs-extra\";\nimport { getFileAtTag } from \"../utils/git-ops.js\";\nimport {\n readManifest,\n resolveMosaicTemplatePath,\n type MosaicManifest,\n} from \"../utils/manifest.js\";\n\nexport interface UpstreamOptions {\n mosaicTemplatePath?: string;\n files?: string[];\n}\n\nasync function generateUpstreamContext(\n manifest: MosaicManifest,\n mosaicTemplatePath: string,\n tag: string,\n appDir: string,\n files: string[],\n): Promise<string> {\n let content = `# Mosaic Upstream Context\n\n## App Info\n- **App name:** ${manifest.placeholders.__APP_NAME__ || \"unknown\"}\n- **Template:** ${manifest.templateType}\n- **Template version:** ${manifest.templateVersion}\n\n## Placeholder Mappings\n\nWhen generalizing app code back to template, replace these values with placeholder tokens:\n\n| Value | Placeholder |\n|-------|------------|\n${Object.entries(manifest.placeholders)\n .sort((a, b) => b[1].length - a[1].length) // longest first to avoid partial matches\n .map(([k, v]) => `| \\`${v}\\` | \\`${k}\\` |`)\n .join(\"\\n\")}\n\n## Files to Review\n\n`;\n\n for (const file of files) {\n const appFilePath = path.resolve(appDir, file);\n const templateRelPath = `${manifest.source.templatePath}/${file}`;\n\n const appContent = (await fs.pathExists(appFilePath))\n ? await fs.readFile(appFilePath, \"utf-8\")\n : null;\n const templateContent = getFileAtTag(\n mosaicTemplatePath,\n tag,\n templateRelPath,\n );\n\n content += `### ${file}\\n\\n`;\n\n if (!templateContent && appContent) {\n content += `**New file** (not in template at ${manifest.templateVersion})\\n\\n`;\n content += `\\`\\`\\`\\n${appContent}\\n\\`\\`\\`\\n\\n`;\n } else if (templateContent && !appContent) {\n content += `**Deleted** (exists in template but not in app)\\n\\n`;\n } else if (appContent && templateContent) {\n content += `**App version:**\\n\\`\\`\\`\\n${appContent}\\n\\`\\`\\`\\n\\n`;\n content += `**Template version (at ${manifest.templateVersion}):**\\n\\`\\`\\`\\n${templateContent}\\n\\`\\`\\`\\n\\n`;\n } else {\n content += `**Not found** (file does not exist in app or template)\\n\\n`;\n }\n }\n\n content += `## Instructions\n\n1. Review each file above\n2. Determine which changes are generalizable (useful for all apps) vs app-specific\n3. For generalizable changes: apply them to the template at \\`${manifest.source.templatePath}/\\`\n4. When applying, replace app-specific values with placeholders using the mapping table above (replace longest values first)\n5. After applying, bump the version in \\`packages/create-mosaic-module/template-versions.json\\`\n6. Run \\`pnpm template:tag\\` to create the new version tag\n7. Delete this file (\\`.mosaic-upstream-context.md\\`) when done\n`;\n\n return content;\n}\n\nexport async function upstreamCommand(options: UpstreamOptions): Promise<void> {\n const cwd = process.cwd();\n\n try {\n const manifest = await readManifest(cwd);\n const mosaicTemplatePath = resolveMosaicTemplatePath(options);\n\n if (!options.files || options.files.length === 0) {\n console.error(\n chalk.red(\"Specify files with --files <file1> <file2> ...\"),\n );\n console.log(\n chalk.dim(\n \" Example: create upstream --files src/config/getEnvConfig.ts\",\n ),\n );\n process.exit(1);\n }\n\n const tag = `template/${manifest.templateType}/${manifest.templateVersion}`;\n\n const context = await generateUpstreamContext(\n manifest,\n mosaicTemplatePath,\n tag,\n cwd,\n options.files,\n );\n const contextPath = path.join(cwd, \".mosaic-upstream-context.md\");\n await fs.writeFile(contextPath, context);\n\n console.log();\n console.log(chalk.bold(\"Upstream Context Generated\"));\n console.log();\n console.log(chalk.dim(\" Files:\"), options.files.join(\", \"));\n console.log(chalk.dim(\" Context file:\"), \".mosaic-upstream-context.md\");\n console.log();\n console.log(\"Next steps:\");\n console.log(chalk.dim(\" 1.\"), \"Open Claude Code in the mosaic repo\");\n console.log(\n chalk.dim(\" 2.\"),\n `Tell Claude: \"Read ${path.resolve(cwd, \".mosaic-upstream-context.md\")} and apply generalizable changes to the template\"`,\n );\n console.log(chalk.dim(\" 3.\"), \"Review Claude's changes to the template\");\n console.log();\n } catch (error) {\n console.error(chalk.red((error as Error).message));\n process.exit(1);\n }\n}\n"],"mappings":";;;;;;AAeA,eAAe,wBACb,UACA,oBACA,KACA,QACA,OACiB;CACjB,IAAI,UAAU;;;kBAGE,SAAS,aAAa,gBAAgB,UAAU;kBAChD,SAAS,aAAa;0BACd,SAAS,gBAAgB;;;;;;;;EAQjD,OAAO,QAAQ,SAAS,aAAa,CACpC,MAAM,GAAG,MAAM,EAAE,GAAG,SAAS,EAAE,GAAG,OAAO,CACzC,KAAK,CAAC,GAAG,OAAO,OAAO,EAAE,SAAS,EAAE,MAAM,CAC1C,KAAK,KAAK,CAAC;;;;;AAMZ,MAAK,MAAM,QAAQ,OAAO;EACxB,MAAM,cAAc,KAAK,QAAQ,QAAQ,KAAK;EAC9C,MAAM,kBAAkB,GAAG,SAAS,OAAO,aAAa,GAAG;EAE3D,MAAM,aAAc,MAAM,GAAG,WAAW,YAAY,GAChD,MAAM,GAAG,SAAS,aAAa,QAAQ,GACvC;EACJ,MAAM,kBAAkB,aACtB,oBACA,KACA,gBACD;AAED,aAAW,OAAO,KAAK;AAEvB,MAAI,CAAC,mBAAmB,YAAY;AAClC,cAAW,oCAAoC,SAAS,gBAAgB;AACxE,cAAW,WAAW,WAAW;aACxB,mBAAmB,CAAC,WAC7B,YAAW;WACF,cAAc,iBAAiB;AACxC,cAAW,6BAA6B,WAAW;AACnD,cAAW,0BAA0B,SAAS,gBAAgB,gBAAgB,gBAAgB;QAE9F,YAAW;;AAIf,YAAW;;;;gEAImD,SAAS,OAAO,aAAa;;;;;;AAO3F,QAAO;;AAGT,eAAsB,gBAAgB,SAAyC;CAC7E,MAAM,MAAM,QAAQ,KAAK;AAEzB,KAAI;EACF,MAAM,WAAW,MAAM,aAAa,IAAI;EACxC,MAAM,qBAAqB,0BAA0B,QAAQ;AAE7D,MAAI,CAAC,QAAQ,SAAS,QAAQ,MAAM,WAAW,GAAG;AAChD,WAAQ,MACN,MAAM,IAAI,iDAAiD,CAC5D;AACD,WAAQ,IACN,MAAM,IACJ,gEACD,CACF;AACD,WAAQ,KAAK,EAAE;;EAKjB,MAAM,UAAU,MAAM,wBACpB,UACA,oBACA,YALsB,SAAS,aAAa,GAAG,SAAS,mBAMxD,KACA,QAAQ,MACT;EACD,MAAM,cAAc,KAAK,KAAK,KAAK,8BAA8B;AACjE,QAAM,GAAG,UAAU,aAAa,QAAQ;AAExC,UAAQ,KAAK;AACb,UAAQ,IAAI,MAAM,KAAK,6BAA6B,CAAC;AACrD,UAAQ,KAAK;AACb,UAAQ,IAAI,MAAM,IAAI,WAAW,EAAE,QAAQ,MAAM,KAAK,KAAK,CAAC;AAC5D,UAAQ,IAAI,MAAM,IAAI,kBAAkB,EAAE,8BAA8B;AACxE,UAAQ,KAAK;AACb,UAAQ,IAAI,cAAc;AAC1B,UAAQ,IAAI,MAAM,IAAI,OAAO,EAAE,sCAAsC;AACrE,UAAQ,IACN,MAAM,IAAI,OAAO,EACjB,sBAAsB,KAAK,QAAQ,KAAK,8BAA8B,CAAC,mDACxE;AACD,UAAQ,IAAI,MAAM,IAAI,OAAO,EAAE,0CAA0C;AACzE,UAAQ,KAAK;UACN,OAAO;AACd,UAAQ,MAAM,MAAM,IAAK,MAAgB,QAAQ,CAAC;AAClD,UAAQ,KAAK,EAAE"}
|
|
@@ -1,28 +0,0 @@
|
|
|
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();
|
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
import { loadEnvConfig } from "@next/env";
|
|
2
|
-
import { drizzle } from "drizzle-orm/node-postgres";
|
|
3
|
-
import { migrate } from "drizzle-orm/node-postgres/migrator";
|
|
4
|
-
|
|
5
|
-
async function main(): Promise<void> {
|
|
6
|
-
loadEnvConfig(process.cwd());
|
|
7
|
-
|
|
8
|
-
// Dynamically load because we need to load the environment variables before importing modules that depend on them.
|
|
9
|
-
const { getEnvConfig } = await import("../src/config/getEnvConfig");
|
|
10
|
-
const { client } = await import("../src/drizzle/db");
|
|
11
|
-
const { DATABASE_SCHEMA: databaseSchema } = getEnvConfig();
|
|
12
|
-
|
|
13
|
-
await migrate(drizzle(client), {
|
|
14
|
-
migrationsFolder: "./src/drizzle/migrations",
|
|
15
|
-
...(databaseSchema ? { migrationsSchema: databaseSchema } : {}),
|
|
16
|
-
});
|
|
17
|
-
|
|
18
|
-
await client.end();
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
void main();
|
|
@@ -1,78 +0,0 @@
|
|
|
1
|
-
import { loadEnvConfig } from "@next/env";
|
|
2
|
-
import { Pool } from "pg";
|
|
3
|
-
import { getEnvConfig } from "../src/config/getEnvConfig";
|
|
4
|
-
import { getPgSslConfig } from "../src/drizzle/ssl";
|
|
5
|
-
|
|
6
|
-
const SHARED_DATABASES = new Set(["demos", "internal_apps"]);
|
|
7
|
-
|
|
8
|
-
async function main(): Promise<void> {
|
|
9
|
-
loadEnvConfig(process.cwd());
|
|
10
|
-
|
|
11
|
-
const {
|
|
12
|
-
DATABASE_HOST: host,
|
|
13
|
-
DATABASE_PORT: port,
|
|
14
|
-
DATABASE_USERNAME: user,
|
|
15
|
-
DATABASE_PASSWORD: password,
|
|
16
|
-
DATABASE_NAME: database,
|
|
17
|
-
DATABASE_SCHEMA: databaseSchema,
|
|
18
|
-
DATABASE_USE_SSL: useSSL,
|
|
19
|
-
} = getEnvConfig();
|
|
20
|
-
|
|
21
|
-
console.log(`🔧 Setting up database: ${database}`);
|
|
22
|
-
console.log(`📍 Host: ${host}:${port}`);
|
|
23
|
-
console.log(`👤 User: ${user}`);
|
|
24
|
-
|
|
25
|
-
if (SHARED_DATABASES.has(database)) {
|
|
26
|
-
console.log(
|
|
27
|
-
`✅ ${database} is a shared database; skipping CREATE DATABASE. ` +
|
|
28
|
-
"The database is managed by infra.",
|
|
29
|
-
);
|
|
30
|
-
if (databaseSchema) {
|
|
31
|
-
console.log(`📦 Using schema: ${databaseSchema}`);
|
|
32
|
-
}
|
|
33
|
-
return;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
// First, connect to the default 'postgres' database to create our target database
|
|
37
|
-
const adminClient = new Pool({
|
|
38
|
-
host,
|
|
39
|
-
port,
|
|
40
|
-
user,
|
|
41
|
-
password,
|
|
42
|
-
database: "postgres", // Connect to default postgres database
|
|
43
|
-
ssl: getPgSslConfig(useSSL),
|
|
44
|
-
});
|
|
45
|
-
|
|
46
|
-
try {
|
|
47
|
-
// Check if the target database exists
|
|
48
|
-
const result = await adminClient.query(
|
|
49
|
-
"SELECT 1 FROM pg_database WHERE datname = $1",
|
|
50
|
-
[database],
|
|
51
|
-
);
|
|
52
|
-
|
|
53
|
-
if (result.rows.length === 0) {
|
|
54
|
-
console.log(`📦 Creating database: ${database}`);
|
|
55
|
-
// Create the database (note: database names cannot be parameterized)
|
|
56
|
-
await adminClient.query(
|
|
57
|
-
`CREATE DATABASE ${escapePgIdentifier(database)}`,
|
|
58
|
-
);
|
|
59
|
-
console.log(`✅ Database ${database} created successfully`);
|
|
60
|
-
} else {
|
|
61
|
-
console.log(`✅ Database ${database} already exists`);
|
|
62
|
-
}
|
|
63
|
-
} catch (error) {
|
|
64
|
-
console.error("❌ Error creating database:", error);
|
|
65
|
-
throw error;
|
|
66
|
-
} finally {
|
|
67
|
-
await adminClient.end();
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
function escapePgIdentifier(identifier: string): string {
|
|
72
|
-
return `"${identifier.replaceAll('"', '""')}"`;
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
void main().catch((error) => {
|
|
76
|
-
console.error("💥 Database setup failed:", error);
|
|
77
|
-
process.exit(1);
|
|
78
|
-
});
|
|
@@ -1,193 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env tsx
|
|
2
|
-
|
|
3
|
-
import {
|
|
4
|
-
GetSecretValueCommand,
|
|
5
|
-
SecretsManagerClient,
|
|
6
|
-
} from "@aws-sdk/client-secrets-manager";
|
|
7
|
-
import { loadEnvConfig } from "@next/env";
|
|
8
|
-
import { Pool } from "pg";
|
|
9
|
-
import { getEnvConfig } from "../src/config/getEnvConfig";
|
|
10
|
-
|
|
11
|
-
interface ReadonlyCredentials {
|
|
12
|
-
username: string;
|
|
13
|
-
password: string;
|
|
14
|
-
host: string;
|
|
15
|
-
port: number;
|
|
16
|
-
database: string;
|
|
17
|
-
engine: string;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
async function getReadonlyCredentials(): Promise<ReadonlyCredentials> {
|
|
21
|
-
const client = new SecretsManagerClient({});
|
|
22
|
-
|
|
23
|
-
// Get the secret name from environment
|
|
24
|
-
// Example format: rds-{app}-readonly-{name}-{environment}-*
|
|
25
|
-
const { READONLY_SECRET_NAME: secretName } = getEnvConfig();
|
|
26
|
-
if (secretName == null) {
|
|
27
|
-
throw new Error("READONLY_SECRET_NAME environment variable not set");
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
const command = new GetSecretValueCommand({ SecretId: secretName });
|
|
31
|
-
const response = await client.send(command);
|
|
32
|
-
|
|
33
|
-
if (!response.SecretString) {
|
|
34
|
-
throw new Error("Secret value is empty");
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
return JSON.parse(response.SecretString) as ReadonlyCredentials;
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
async function setupReadonlyUser(): Promise<number> {
|
|
41
|
-
console.log("🔐 Fetching readonly user credentials from Secrets Manager...");
|
|
42
|
-
|
|
43
|
-
let credentials: ReadonlyCredentials;
|
|
44
|
-
try {
|
|
45
|
-
credentials = await getReadonlyCredentials();
|
|
46
|
-
console.log(`✅ Retrieved credentials for user: ${credentials.username}`);
|
|
47
|
-
} catch (error) {
|
|
48
|
-
console.error(
|
|
49
|
-
"❌ Failed to retrieve credentials from Secrets Manager:",
|
|
50
|
-
error,
|
|
51
|
-
);
|
|
52
|
-
return 1;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
// Connect as master user
|
|
56
|
-
const {
|
|
57
|
-
DATABASE_HOST: host,
|
|
58
|
-
DATABASE_PORT: port,
|
|
59
|
-
DATABASE_USERNAME: user,
|
|
60
|
-
DATABASE_PASSWORD: password,
|
|
61
|
-
DATABASE_NAME: database,
|
|
62
|
-
DATABASE_USE_SSL: useSSL,
|
|
63
|
-
} = getEnvConfig();
|
|
64
|
-
const masterPool = new Pool({
|
|
65
|
-
host,
|
|
66
|
-
port,
|
|
67
|
-
user,
|
|
68
|
-
password,
|
|
69
|
-
database,
|
|
70
|
-
ssl: useSSL ? { rejectUnauthorized: false } : false,
|
|
71
|
-
});
|
|
72
|
-
|
|
73
|
-
let client;
|
|
74
|
-
try {
|
|
75
|
-
client = await masterPool.connect();
|
|
76
|
-
console.log("✅ Connected to database as master user");
|
|
77
|
-
|
|
78
|
-
// Check if readonly user already exists
|
|
79
|
-
const userExistsResult = await client.query(
|
|
80
|
-
"SELECT 1 FROM pg_roles WHERE rolname = $1",
|
|
81
|
-
[credentials.username],
|
|
82
|
-
);
|
|
83
|
-
|
|
84
|
-
let userCreated = false;
|
|
85
|
-
if (userExistsResult.rows.length === 0) {
|
|
86
|
-
// Create the readonly user
|
|
87
|
-
console.log(`📝 Creating user: ${credentials.username}`);
|
|
88
|
-
await client.query(
|
|
89
|
-
`
|
|
90
|
-
CREATE USER ${credentials.username} WITH LOGIN PASSWORD $1
|
|
91
|
-
`,
|
|
92
|
-
[credentials.password],
|
|
93
|
-
);
|
|
94
|
-
userCreated = true;
|
|
95
|
-
console.log(`✅ User ${credentials.username} created`);
|
|
96
|
-
} else {
|
|
97
|
-
// Update password to ensure it matches Secrets Manager
|
|
98
|
-
console.log(
|
|
99
|
-
`📝 Updating password for existing user: ${credentials.username}`,
|
|
100
|
-
);
|
|
101
|
-
await client.query(
|
|
102
|
-
`
|
|
103
|
-
ALTER USER ${credentials.username} WITH PASSWORD $1
|
|
104
|
-
`,
|
|
105
|
-
[credentials.password],
|
|
106
|
-
);
|
|
107
|
-
console.log(`✅ Password updated for ${credentials.username}`);
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
// Grant CONNECT privilege on database
|
|
111
|
-
await client.query(`
|
|
112
|
-
GRANT CONNECT ON DATABASE ${database} TO ${credentials.username}
|
|
113
|
-
`);
|
|
114
|
-
console.log("✅ Granted CONNECT on database");
|
|
115
|
-
|
|
116
|
-
// Get all schemas (excluding system schemas)
|
|
117
|
-
const schemasResult = await client.query<{ schema_name: string }>(`
|
|
118
|
-
SELECT schema_name
|
|
119
|
-
FROM information_schema.schemata
|
|
120
|
-
WHERE schema_name NOT IN ('pg_catalog', 'information_schema', 'pg_toast')
|
|
121
|
-
`);
|
|
122
|
-
|
|
123
|
-
for (const row of schemasResult.rows) {
|
|
124
|
-
const schemaName = row.schema_name;
|
|
125
|
-
console.log(`📝 Configuring access for schema: ${schemaName}`);
|
|
126
|
-
|
|
127
|
-
// Grant USAGE on schema
|
|
128
|
-
await client.query(`
|
|
129
|
-
GRANT USAGE ON SCHEMA ${schemaName} TO ${credentials.username}
|
|
130
|
-
`);
|
|
131
|
-
|
|
132
|
-
// Grant SELECT on all existing tables in schema
|
|
133
|
-
await client.query(`
|
|
134
|
-
GRANT SELECT ON ALL TABLES IN SCHEMA ${schemaName} TO ${credentials.username}
|
|
135
|
-
`);
|
|
136
|
-
|
|
137
|
-
// Set default privileges for future tables in schema
|
|
138
|
-
// Grant on tables created by the master user
|
|
139
|
-
await client.query(`
|
|
140
|
-
ALTER DEFAULT PRIVILEGES IN SCHEMA ${schemaName}
|
|
141
|
-
GRANT SELECT ON TABLES TO ${credentials.username}
|
|
142
|
-
`);
|
|
143
|
-
|
|
144
|
-
console.log(`✅ Configured access for schema: ${schemaName}`);
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
// Test the readonly user connection
|
|
148
|
-
console.log("🧪 Testing readonly user connection...");
|
|
149
|
-
const readonlyPool = new Pool({
|
|
150
|
-
host,
|
|
151
|
-
port,
|
|
152
|
-
user: credentials.username,
|
|
153
|
-
password: credentials.password,
|
|
154
|
-
database,
|
|
155
|
-
ssl: useSSL ? { rejectUnauthorized: false } : false,
|
|
156
|
-
});
|
|
157
|
-
|
|
158
|
-
try {
|
|
159
|
-
const readonlyClient = await readonlyPool.connect();
|
|
160
|
-
await readonlyClient.query("SELECT 1");
|
|
161
|
-
readonlyClient.release();
|
|
162
|
-
console.log("✅ Readonly user connection test successful");
|
|
163
|
-
} catch (error) {
|
|
164
|
-
console.error("⚠️ Readonly user connection test failed:", error);
|
|
165
|
-
return 1;
|
|
166
|
-
} finally {
|
|
167
|
-
await readonlyPool.end();
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
console.log("✅ Readonly user setup completed successfully");
|
|
171
|
-
|
|
172
|
-
// Return exit code 2 if user already existed (idempotent case)
|
|
173
|
-
// Return exit code 0 if user was created
|
|
174
|
-
return userCreated ? 0 : 2;
|
|
175
|
-
} catch (error) {
|
|
176
|
-
console.error("❌ Error setting up readonly user:", error);
|
|
177
|
-
return 1;
|
|
178
|
-
} finally {
|
|
179
|
-
if (client) {
|
|
180
|
-
client.release();
|
|
181
|
-
}
|
|
182
|
-
await masterPool.end();
|
|
183
|
-
}
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
async function main(): Promise<void> {
|
|
187
|
-
loadEnvConfig(process.cwd());
|
|
188
|
-
|
|
189
|
-
const exitCode = await setupReadonlyUser();
|
|
190
|
-
process.exit(exitCode);
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
void main();
|
|
@@ -1,24 +0,0 @@
|
|
|
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
|
-
});
|
|
@@ -1,8 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from "vitest";
|
|
2
|
-
import { getPgSearchPathOption } from "./searchPath";
|
|
3
|
-
|
|
4
|
-
describe("getPgSearchPathOption", () => {
|
|
5
|
-
it("includes public so extension functions remain resolvable", () => {
|
|
6
|
-
expect(getPgSearchPathOption("my_app")).toBe(
|
|
7
|
-
"-c search_path=my_app,public",
|
|
8
|
-
);
|
|
9
|
-
});
|
|
10
|
-
|
|
11
|
-
it("returns undefined for blank schema names", () => {
|
|
12
|
-
expect(getPgSearchPathOption(undefined)).toBeUndefined();
|
|
13
|
-
expect(getPgSearchPathOption(" ")).toBeUndefined();
|
|
14
|
-
});
|
|
15
|
-
|
|
16
|
-
it("rejects unsafe unquoted identifiers", () => {
|
|
17
|
-
expect(() => getPgSearchPathOption("my-app")).toThrow(
|
|
18
|
-
"DATABASE_SCHEMA must be a valid unquoted Postgres identifier",
|
|
19
|
-
);
|
|
20
|
-
});
|
|
21
|
-
});
|
|
@@ -1,16 +0,0 @@
|
|
|
1
|
-
const POSTGRES_IDENTIFIER_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
2
|
-
|
|
3
|
-
export function getPgSearchPathOption(
|
|
4
|
-
schemaName: string | undefined,
|
|
5
|
-
): string | undefined {
|
|
6
|
-
const searchPath = schemaName?.trim();
|
|
7
|
-
if (!searchPath) return undefined;
|
|
8
|
-
|
|
9
|
-
if (!POSTGRES_IDENTIFIER_PATTERN.test(searchPath)) {
|
|
10
|
-
throw new Error(
|
|
11
|
-
`DATABASE_SCHEMA must be a valid unquoted Postgres identifier. Received: ${searchPath}`,
|
|
12
|
-
);
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
return `-c search_path=${searchPath},public`;
|
|
16
|
-
}
|