@percepta/create 3.4.2 → 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.
Files changed (42) hide show
  1. package/README.md +13 -0
  2. package/dist/index.js +135 -106
  3. package/dist/index.js.map +1 -1
  4. package/dist/{init-CtCp7Tv2.js → init-sI9aIrkU.js} +2 -2
  5. package/dist/init-sI9aIrkU.js.map +1 -0
  6. package/dist/{upstream-D-LH_1z4.js → upstream-gUHLWSR1.js} +2 -2
  7. package/dist/upstream-gUHLWSR1.js.map +1 -0
  8. package/package.json +1 -1
  9. package/template-versions.json +1 -0
  10. package/templates/monorepo/README.md +8 -5
  11. package/templates/monorepo/package.json.template +1 -0
  12. package/templates/webapp/.claude/commands/upstream.md +1 -1
  13. package/templates/webapp/AGENTS.md +1 -1
  14. package/templates/webapp/agent-skills/access-control.md +24 -1
  15. package/templates/webapp/agent-skills/inngest.md +5 -5
  16. package/templates/webapp/agent-skills/langfuse.md +4 -4
  17. package/templates/webapp/agent-skills/llm.md +1 -1
  18. package/templates/webapp/drizzle.config.ts +1 -1
  19. package/templates/webapp/package.json.template +10 -17
  20. package/templates/webapp/src/app/api/inngest/route.ts +12 -22
  21. package/templates/webapp/src/drizzle/db.ts +1 -2
  22. package/templates/webapp/src/instrumentation.ts +2 -63
  23. package/templates/webapp/src/server/trpc.ts +6 -18
  24. package/templates/webapp/src/services/AuthContextService.ts +7 -59
  25. package/templates/webapp/src/services/inngest/InngestService.ts +14 -62
  26. package/templates/webapp/src/services/langfuse/LangfuseService.ts +9 -77
  27. package/templates/webapp/src/services/llm/LLMService.ts +10 -88
  28. package/templates/webapp/src/services/logger/AppLogger.ts +3 -48
  29. package/templates/webapp/src/utils/syncInngestApp.ts +4 -56
  30. package/dist/init-CtCp7Tv2.js.map +0 -1
  31. package/dist/upstream-D-LH_1z4.js.map +0 -1
  32. package/templates/webapp/scripts/generate-migrations.ts +0 -28
  33. package/templates/webapp/scripts/migrate.ts +0 -21
  34. package/templates/webapp/scripts/setup-database.ts +0 -78
  35. package/templates/webapp/scripts/setup-readonly-user.ts +0 -193
  36. package/templates/webapp/src/drizzle/__tests__/migrationSql.test.ts +0 -24
  37. package/templates/webapp/src/drizzle/migrationSql.ts +0 -8
  38. package/templates/webapp/src/drizzle/searchPath.test.ts +0 -21
  39. package/templates/webapp/src/drizzle/searchPath.ts +0 -16
  40. package/templates/webapp/src/drizzle/ssl.ts +0 -5
  41. package/templates/webapp/src/services/inngest/InngestFunctionCollection.ts +0 -5
  42. package/templates/webapp/src/services/llm/LlmProviderService.ts +0 -85
@@ -1,88 +1,10 @@
1
- import { generateText, streamText } from "ai";
2
- import { type LlmProviderName, LlmProviderService } from "./LlmProviderService";
3
-
4
- type GenerateTextOptions = Omit<Parameters<typeof generateText>[0], "model"> & {
5
- modelId?: string;
6
- provider?: LlmProviderName;
7
- telemetryFunctionId?: string;
8
- };
9
-
10
- type StreamTextOptions = Omit<Parameters<typeof streamText>[0], "model"> & {
11
- modelId?: string;
12
- provider?: LlmProviderName;
13
- telemetryFunctionId?: string;
14
- };
15
-
16
- export class LLMService {
17
- private static SINGLETON: LLMService | undefined;
18
-
19
- public static create(): LLMService {
20
- if (LLMService.SINGLETON == null) {
21
- LLMService.SINGLETON = new LLMService(LlmProviderService.create());
22
- }
23
-
24
- return LLMService.SINGLETON;
25
- }
26
-
27
- private constructor(private llmProviderService: LlmProviderService) {}
28
-
29
- public generateText(
30
- options: GenerateTextOptions,
31
- ): ReturnType<typeof generateText> {
32
- const { modelId, provider, telemetryFunctionId, ...generateOptions } =
33
- options;
34
- const selection = this.llmProviderService.getLanguageModel({
35
- modelId,
36
- provider,
37
- });
38
-
39
- const aiOptions = {
40
- ...generateOptions,
41
- model: selection.model,
42
- experimental_telemetry: {
43
- ...generateOptions.experimental_telemetry,
44
- isEnabled: generateOptions.experimental_telemetry?.isEnabled ?? true,
45
- functionId:
46
- telemetryFunctionId ??
47
- generateOptions.experimental_telemetry?.functionId ??
48
- "llm.generateText",
49
- metadata: {
50
- ...generateOptions.experimental_telemetry?.metadata,
51
- llmProvider: selection.provider,
52
- llmModel: selection.modelId,
53
- },
54
- },
55
- } as Parameters<typeof generateText>[0];
56
-
57
- return generateText(aiOptions);
58
- }
59
-
60
- public streamText(options: StreamTextOptions): ReturnType<typeof streamText> {
61
- const { modelId, provider, telemetryFunctionId, ...streamOptions } =
62
- options;
63
- const selection = this.llmProviderService.getLanguageModel({
64
- modelId,
65
- provider,
66
- });
67
-
68
- const aiOptions = {
69
- ...streamOptions,
70
- model: selection.model,
71
- experimental_telemetry: {
72
- ...streamOptions.experimental_telemetry,
73
- isEnabled: streamOptions.experimental_telemetry?.isEnabled ?? true,
74
- functionId:
75
- telemetryFunctionId ??
76
- streamOptions.experimental_telemetry?.functionId ??
77
- "llm.streamText",
78
- metadata: {
79
- ...streamOptions.experimental_telemetry?.metadata,
80
- llmProvider: selection.provider,
81
- llmModel: selection.modelId,
82
- },
83
- },
84
- } as Parameters<typeof streamText>[0];
85
-
86
- return streamText(aiOptions);
87
- }
88
- }
1
+ import {
2
+ createPerceptaLlmServiceFactory,
3
+ type PerceptaLlmService,
4
+ } from "@percepta/ai";
5
+ import { getEnvConfig } from "../../config/getEnvConfig";
6
+
7
+ export const LLMService = createPerceptaLlmServiceFactory({
8
+ getConfig: getEnvConfig,
9
+ });
10
+ export type LLMService = PerceptaLlmService;
@@ -1,11 +1,4 @@
1
- import {
2
- type MosaicLogger,
3
- createLogFactory,
4
- createTracerFactory,
5
- } from "@percepta/logger";
6
- import { assertNever } from "@percepta/utils";
7
- import pino, { type Logger } from "pino";
8
- import pretty from "pino-pretty";
1
+ import { createLoggerRuntime } from "@percepta/logger";
9
2
  import { getEnvConfig } from "../../config/getEnvConfig";
10
3
 
11
4
  /**
@@ -19,43 +12,5 @@ import { getEnvConfig } from "../../config/getEnvConfig";
19
12
  * All logs automatically include request context (reqId, method, path, host)
20
13
  * when called within a request scope established by withLogContext().
21
14
  */
22
- export const { getLogger, withLogContext } = createLogFactory(
23
- createBasePinoInstance(),
24
- () => new AsyncLocalStorage<MosaicLogger>(),
25
- );
26
-
27
- /**
28
- * Tracer for request context propagation.
29
- * Uses AsyncLocalStorage to pass trace IDs through async boundaries.
30
- */
31
- export const { getTracer, withTracer } = createTracerFactory(
32
- () => new AsyncLocalStorage(),
33
- );
34
-
35
- // TODO(@dzhao): Generalize this and move to createPinoInstance.ts.
36
- function createBasePinoInstance(): Logger {
37
- const { NODE_ENV: nodeEnv, LOG_LEVEL: level } = getEnvConfig();
38
-
39
- switch (nodeEnv) {
40
- case "production":
41
- return pino({
42
- level,
43
- formatters: {
44
- level: (label) => ({ level: label }),
45
- },
46
- });
47
- case "development":
48
- case "test":
49
- // Development: use pino-pretty synchronously (avoids worker thread issues with Next.js/Turbopack)
50
- return pino(
51
- { level },
52
- pretty({
53
- colorize: true,
54
- translateTime: "SYS:standard",
55
- ignore: "pid,hostname",
56
- }),
57
- );
58
- default:
59
- return assertNever(nodeEnv);
60
- }
61
- }
15
+ export const { getLogger, getTracer, withLogContext, withTracer } =
16
+ createLoggerRuntime({ env: getEnvConfig() });
@@ -1,62 +1,10 @@
1
+ import { syncPerceptaInngestApp } from "@percepta/inngest";
1
2
  import { getEnvConfig } from "../config/getEnvConfig";
2
3
  import { getLogger } from "../services/logger/AppLogger";
3
4
 
4
5
  export async function syncInngestApp(): Promise<void> {
5
- const {
6
- SKIP_INNGEST_SYNC: skipSync,
7
- INNGEST_BASE_URL: inngestServerUrl,
8
- APP_BASE_URL: appBaseUrl,
9
- INNGEST_APP_URL,
10
- } = getEnvConfig();
11
-
12
- // Cascade: explicit INNGEST_APP_URL > derived from APP_BASE_URL > localhost fallback
13
- const appUrl =
14
- INNGEST_APP_URL ??
15
- (appBaseUrl
16
- ? `${appBaseUrl}/api/inngest`
17
- : "http://localhost:3000/api/inngest");
18
-
19
- if (skipSync) {
20
- getLogger().info(undefined, "SKIP_INNGEST_SYNC=true, skipping auto-sync");
21
- return;
22
- }
23
-
24
- // INNGEST_BASE_URL is required
25
- if (!inngestServerUrl) {
26
- getLogger().error(
27
- undefined,
28
- "INNGEST_BASE_URL environment variable is not set, skipping sync",
29
- );
30
- return;
31
- }
32
-
33
- const gqlEndpoint = `${inngestServerUrl}/v0/gql`;
34
-
35
- getLogger().info(
36
- { safe: { gqlEndpoint, appUrl } },
37
- "Syncing with Inngest Server",
38
- );
39
-
40
- const payload = {
41
- operationName: "CreateApp",
42
- query:
43
- "mutation CreateApp($input: CreateAppInput!) {\n createApp(input: $input) {\n url\n }\n}\n",
44
- variables: {
45
- input: {
46
- url: appUrl,
47
- },
48
- },
49
- };
50
-
51
- const response = await fetch(gqlEndpoint, {
52
- method: "POST",
53
- headers: {
54
- "Content-Type": "application/json",
55
- },
56
- body: JSON.stringify(payload),
6
+ await syncPerceptaInngestApp({
7
+ getEnv: getEnvConfig,
8
+ logger: getLogger(),
57
9
  });
58
-
59
- if (response.ok) {
60
- getLogger().info(undefined, "Successfully synced with Inngest Server");
61
- }
62
10
  }
@@ -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
- }
@@ -1,5 +0,0 @@
1
- export function getPgSslConfig(
2
- useSSL: boolean,
3
- ): false | { rejectUnauthorized: false } {
4
- return useSSL ? { rejectUnauthorized: false } : false;
5
- }
@@ -1,5 +0,0 @@
1
- import { type InngestFunction } from "inngest";
2
-
3
- export interface InngestFunctionCollection {
4
- functions: InngestFunction.Like[];
5
- }