@percepta/create 4.1.15 → 4.1.16

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": "4.1.15",
3
+ "version": "4.1.16",
4
4
  "description": "Scaffold a new Mosaic package",
5
5
  "keywords": [
6
6
  "cli",
@@ -50,7 +50,10 @@
50
50
  "typecheck": "tsc --noEmit",
51
51
  "test": "vitest run",
52
52
  "test:watch": "vitest",
53
- "test:template": "bash scripts/test-template.sh",
53
+ "lint:templates:logging": "oxlint -c oxlint.template-logging.config.json templates --no-error-on-unmatched-pattern",
54
+ "test:template": "pnpm lint:templates:logging && pnpm test:template:contract && pnpm build && pnpm test:template:build",
55
+ "test:template:contract": "vitest run src/commands/create-output.test.ts",
56
+ "test:template:build": "bash scripts/test-template.sh",
54
57
  "create:local": "pnpm build && node dist/index.js",
55
58
  "sync-template": "tsx scripts/sync-template.ts",
56
59
  "template:tag": "tsx scripts/template-tag.ts"
@@ -16,7 +16,7 @@
16
16
  "db:migrate": "drizzle-kit migrate"
17
17
  },
18
18
  "dependencies": {
19
- "@percepta/auth": "^0.1.4",
19
+ "@percepta/auth": "^0.1.7",
20
20
  "better-auth": "^1.6.4",
21
21
  "drizzle-orm": "^0.45.2"
22
22
  },
@@ -1,120 +1,26 @@
1
- import { createLazyAuth, createPerceptaAuth } from "@percepta/auth/better-auth";
2
- import type { BetterAuthOptions } from "better-auth";
3
- import { admin } from "better-auth/plugins";
4
- import { genericOAuth, okta } from "better-auth/plugins/generic-oauth";
1
+ import {
2
+ createLazyAuth,
3
+ createPerceptaAuthFromEnv,
4
+ type PerceptaAuthMode,
5
+ } from "@percepta/auth/better-auth";
5
6
  import { db } from "./drizzle/db";
6
7
  import { accounts } from "./drizzle/schema/auth/accounts";
7
8
  import { sessions } from "./drizzle/schema/auth/sessions";
8
9
  import { verifications } from "./drizzle/schema/auth/verifications";
9
10
  import { users } from "./drizzle/schema/users";
10
11
 
11
- type AuthMode = "username-password" | "google" | "okta";
12
-
13
- const DEFAULT_AUTH_MODE: AuthMode = "__AUTH_MODE__" as AuthMode;
14
- const isBuildPhase = process.env.NEXT_PHASE === "phase-production-build";
15
-
16
- function isAuthMode(value: string | undefined): value is AuthMode {
17
- return (
18
- value === "username-password" || value === "google" || value === "okta"
19
- );
20
- }
21
-
22
- function getAuthMode(): AuthMode {
23
- return isAuthMode(process.env.AUTH_MODE)
24
- ? process.env.AUTH_MODE
25
- : DEFAULT_AUTH_MODE;
26
- }
27
-
28
- function requiredEnv(name: string): string {
29
- const value = process.env[name];
30
- if (value == null || value.length === 0) {
31
- throw new Error(`${name} is required.`);
32
- }
33
- return value;
34
- }
35
-
36
- function optionalEnv(name: string): string | undefined {
37
- const value = process.env[name];
38
- return value == null || value.length === 0 ? undefined : value;
39
- }
40
-
41
- function getSecret(): string {
42
- if (isBuildPhase) {
43
- return "build-placeholder-not-used-at-runtime";
44
- }
45
-
46
- return requiredEnv("BETTER_AUTH_SECRET");
47
- }
48
-
49
- function getBaseUrl(): string {
50
- return (
51
- process.env.BETTER_AUTH_URL ??
52
- process.env.APP_BASE_URL ??
53
- "http://localhost:3000"
54
- );
55
- }
56
-
57
- function getSocialProviders(
58
- authMode: AuthMode,
59
- ): BetterAuthOptions["socialProviders"] {
60
- if (authMode !== "google") return undefined;
61
-
62
- const clientId = optionalEnv("GOOGLE_CLIENT_ID");
63
- const clientSecret = optionalEnv("GOOGLE_CLIENT_SECRET");
64
- if (clientId == null || clientSecret == null) return undefined;
65
-
66
- const hostedDomain = optionalEnv("GOOGLE_HOSTED_DOMAIN");
67
- return {
68
- google: {
69
- clientId,
70
- clientSecret,
71
- ...(hostedDomain == null ? {} : { hd: hostedDomain }),
72
- },
73
- };
74
- }
75
-
76
- function getPlugins(authMode: AuthMode): BetterAuthOptions["plugins"] {
77
- if (authMode !== "okta") return undefined;
78
-
79
- const clientId = optionalEnv("OKTA_CLIENT_ID");
80
- const clientSecret = optionalEnv("OKTA_CLIENT_SECRET");
81
- const issuer = optionalEnv("OKTA_ISSUER");
82
- if (clientId == null || clientSecret == null || issuer == null) {
83
- return undefined;
84
- }
85
-
86
- return [
87
- admin(),
88
- genericOAuth({
89
- config: [
90
- okta({
91
- clientId,
92
- clientSecret,
93
- issuer,
94
- }),
95
- ],
96
- }),
97
- ];
98
- }
12
+ const DEFAULT_AUTH_MODE = "__AUTH_MODE__" satisfies PerceptaAuthMode;
99
13
 
100
14
  function createAuth() {
101
- const authMode = getAuthMode();
102
-
103
- return createPerceptaAuth({
104
- baseURL: getBaseUrl(),
15
+ return createPerceptaAuthFromEnv({
105
16
  database: db,
106
- emailAndPassword: {
107
- enabled: authMode === "username-password",
108
- },
109
- plugins: getPlugins(authMode),
17
+ defaultAuthMode: DEFAULT_AUTH_MODE,
110
18
  schema: {
111
19
  user: users,
112
20
  session: sessions,
113
21
  account: accounts,
114
22
  verification: verifications,
115
23
  },
116
- secret: getSecret(),
117
- socialProviders: getSocialProviders(authMode),
118
24
  });
119
25
  }
120
26
 
@@ -1,3 +1,7 @@
1
1
  import { defineOxlintMonorepoConfig } from "@percepta/build/oxlint";
2
2
 
3
- export default defineOxlintMonorepoConfig();
3
+ export default defineOxlintMonorepoConfig({
4
+ rules: {
5
+ "no-console": "error",
6
+ },
7
+ });
@@ -10,7 +10,7 @@
10
10
  "setup": "pnpm run docker:up && pnpm run db:setup-local && pnpm run auth:db:migrate && pnpm run access:apply-local && pnpm -r --filter './packages/*' --if-present run db:migrate && pnpm -r --filter './packages/*' --if-present run db:seed",
11
11
  "docker:up": "docker compose up -d --wait",
12
12
  "docker:down": "docker compose down",
13
- "db:setup-local": "node scripts/setup-local-databases.mjs",
13
+ "db:setup-local": "percepta-db setup-local",
14
14
  "dev": "pnpm -r --parallel --if-present run dev",
15
15
  "build": "turbo run build",
16
16
  "clean": "turbo run clean",
@@ -32,6 +32,7 @@
32
32
  "devDependencies": {
33
33
  "@percepta/access-control": "^1.0.0",
34
34
  "@percepta/build": "^1.0.0",
35
+ "@percepta/database": "0.1.4",
35
36
  "@types/node": "^24.1.0",
36
37
  "oxfmt": "^0.47.0",
37
38
  "oxlint": "^1.61.0",
@@ -1,6 +1,7 @@
1
1
  name: Build & Release __APP_NAME__
2
2
 
3
3
  on:
4
+ workflow_dispatch:
4
5
  push:
5
6
  branches:
6
7
  - "main"
@@ -13,97 +14,29 @@ on:
13
14
  - "pnpm-lock.yaml"
14
15
  - "pnpm-workspace.yaml"
15
16
  - ".github/workflows/__APP_NAME__-ryvn-release.yaml"
16
- workflow_dispatch:
17
-
18
- env:
19
- SERVICE_NAME: __APP_NAME__
17
+ pull_request:
18
+ paths:
19
+ - "packages/__APP_NAME__/src/**"
20
+ - "packages/__APP_NAME__/scripts/**"
21
+ - "packages/__APP_NAME__/Dockerfile"
22
+ - "packages/__APP_NAME__/package.json"
23
+ - "package.json"
24
+ - "pnpm-lock.yaml"
25
+ - "pnpm-workspace.yaml"
26
+ - ".github/workflows/__APP_NAME__-ryvn-release.yaml"
20
27
 
21
28
  jobs:
22
- build-and-release:
23
- name: Build and Release
24
- runs-on: ubuntu-latest
29
+ release:
30
+ uses: ryvn-technologies/ryvn-build-action/.github/workflows/release.yml@v2
31
+ with:
32
+ service_name: __APP_NAME__
25
33
  permissions:
26
34
  contents: write
27
35
  id-token: write
28
-
29
- steps:
30
- - name: Checkout code
31
- uses: actions/checkout@v4
32
- with:
33
- fetch-depth: 0
34
-
35
- - name: Install Ryvn CLI
36
- uses: ryvn-technologies/install-ryvn-cli@v1.0.0
37
-
38
- - name: Generate Release Tag
39
- id: generate-tag
40
- env:
41
- RYVN_CLIENT_ID: ${{ secrets.RYVN_CLIENT_ID }}
42
- RYVN_CLIENT_SECRET: ${{ secrets.RYVN_CLIENT_SECRET }}
43
- run: |
44
- tag_info=$(ryvn generate-release-tag "$SERVICE_NAME" --prefix="${SERVICE_NAME}@" -o json --default-bump-minor)
45
-
46
- version=$(echo "$tag_info" | jq -r '.version')
47
- new_tag=$(echo "$tag_info" | jq -r '.tag')
48
- channel=$(echo "$tag_info" | jq -r '.channel')
49
- isPreview=$(echo "$tag_info" | jq -r '.isPreview')
50
-
51
- echo "version=$version" >> $GITHUB_OUTPUT
52
- echo "new_tag=$new_tag" >> $GITHUB_OUTPUT
53
- echo "channel=$channel" >> $GITHUB_OUTPUT
54
- echo "isPreview=$isPreview" >> $GITHUB_OUTPUT
55
-
56
- - name: Build and Push
57
- uses: ryvn-technologies/ryvn-build-action@v2
58
- with:
59
- service_name: ${{ env.SERVICE_NAME }}
60
- version: ${{ steps.generate-tag.outputs.version }}
61
- build_only: ${{ !(github.ref == format('refs/heads/{0}', github.event.repository.default_branch) || steps.generate-tag.outputs.isPreview == 'true') }}
62
- build_secrets: |
63
- NPM_TOKEN=${{ secrets.NPM_TOKEN }}
64
- ryvn_client_id: ${{ secrets.RYVN_CLIENT_ID }}
65
- ryvn_client_secret: ${{ secrets.RYVN_CLIENT_SECRET }}
66
-
67
- - name: Create Ryvn Release
68
- if: |
69
- !contains(github.event.head_commit.message, '[skip-release]') &&
70
- !contains(github.event.pull_request.title, '[skip-release]') &&
71
- (steps.generate-tag.outputs.isPreview == 'true' || github.ref == format('refs/heads/{0}', github.event.repository.default_branch))
72
- env:
73
- RYVN_CLIENT_ID: ${{ secrets.RYVN_CLIENT_ID }}
74
- RYVN_CLIENT_SECRET: ${{ secrets.RYVN_CLIENT_SECRET }}
75
- run: |
76
- version="${{ steps.generate-tag.outputs.new_tag }}"
77
- version="${version#"${SERVICE_NAME}@"}"
78
- version="${version#@}"
79
- channel="${{ steps.generate-tag.outputs.channel }}"
80
-
81
- if [ -n "$channel" ] && [ "$channel" != "null" ]; then
82
- ryvn create release "$SERVICE_NAME" "$version" --channel "$channel"
83
- else
84
- ryvn create release "$SERVICE_NAME" "$version"
85
- fi
86
-
87
- - name: Create GitHub Tag
88
- if: |
89
- github.ref == format('refs/heads/{0}', github.event.repository.default_branch) &&
90
- !contains(github.event.head_commit.message, '[skip-release]') &&
91
- !contains(github.event.pull_request.title, '[skip-release]')
92
- run: |
93
- git config --global user.email "github-actions[bot]@users.noreply.github.com"
94
- git config --global user.name "github-actions[bot]"
95
- git tag "${{ steps.generate-tag.outputs.new_tag }}"
96
- git push origin "${{ steps.generate-tag.outputs.new_tag }}"
97
-
98
- - name: Create GitHub Release
99
- if: |
100
- github.ref == format('refs/heads/{0}', github.event.repository.default_branch) &&
101
- !contains(github.event.head_commit.message, '[skip-release]') &&
102
- !contains(github.event.pull_request.title, '[skip-release]')
103
- uses: softprops/action-gh-release@v1
104
- with:
105
- tag_name: ${{ steps.generate-tag.outputs.new_tag }}
106
- name: ${{ steps.generate-tag.outputs.new_tag }}
107
- generate_release_notes: true
108
- draft: false
109
- prerelease: false
36
+ secrets:
37
+ RYVN_CLIENT_ID: ${{ secrets.RYVN_CLIENT_ID }}
38
+ RYVN_CLIENT_SECRET: ${{ secrets.RYVN_CLIENT_SECRET }}
39
+ BUILD_SECRETS: |
40
+ NPM_TOKEN=${{ secrets.NPM_TOKEN }}
41
+ TURBO_TOKEN=${{ secrets.TURBO_TOKEN }}
42
+ TURBO_TEAM=${{ vars.TURBO_TEAM }}
@@ -54,7 +54,7 @@ src/ # Application source
54
54
  │ ├── langfuse/ # LLM observability
55
55
  │ ├── llm/ # LLM provider selection and call helpers
56
56
  │ ├── logger/ # App logger setup (wraps @percepta/logger)
57
- │ └── observability/ # OpenTelemetry setup
57
+ │ └── observability/ # Frontend observability setup
58
58
  └── utils/ # Helpers (cn, pathEncryption, etc.)
59
59
 
60
60
  deploy/ # Optional release metadata
@@ -22,18 +22,15 @@ Langfuse is an open-source LLM observability platform. It captures traces, spans
22
22
  The template uses Next.js's instrumentation hook (called on server startup) to bootstrap OTEL with both the environment collector and optional Langfuse:
23
23
 
24
24
  ```typescript
25
- import { getNodeAutoInstrumentations } from "@opentelemetry/auto-instrumentations-node";
26
- import { NodeSDK, tracing } from "@opentelemetry/sdk-node";
27
- import { createLangfuseSpanProcessor } from "@percepta/ai";
28
-
29
- const sdk = new NodeSDK({
30
- spanProcessors: [
31
- new tracing.BatchSpanProcessor(otlpTraceExporter),
32
- createLangfuseSpanProcessor(env, logger),
33
- ],
34
- instrumentations: [getNodeAutoInstrumentations()],
25
+ import { startPerceptaNodeTelemetry } from "@percepta/ai";
26
+ import { getEnvConfig } from "./config/getEnvConfig";
27
+ import { getLogger } from "./services/logger/AppLogger";
28
+
29
+ startPerceptaNodeTelemetry({
30
+ appName: "__APP_NAME__",
31
+ getEnv: getEnvConfig,
32
+ getLogger,
35
33
  });
36
- sdk.start();
37
34
  ```
38
35
 
39
36
  - `getNodeAutoInstrumentations()` automatically instruments HTTP calls, database queries, and other standard Node.js operations.
@@ -40,17 +40,16 @@
40
40
  "@mantine/hooks": "^8.3.1",
41
41
  "@next/env": "^16.2.6",
42
42
  "@opentelemetry/api": "^1.9.0",
43
- "@opentelemetry/auto-instrumentations-node": "^0.75.0",
44
- "@opentelemetry/exporter-trace-otlp-proto": "^0.217.0",
45
43
  "@opentelemetry/sdk-node": "^0.217.0",
46
44
  "@__REPO_NAME__/auth": "workspace:*",
47
45
  "@percepta/access-control": "^1.0.0",
48
- "@percepta/ai": "^0.1.0",
49
- "@percepta/database": "0.1.3",
46
+ "@percepta/ai": "^0.1.1",
47
+ "@percepta/auth": "^0.1.7",
48
+ "@percepta/database": "0.1.4",
50
49
  "@percepta/design": "^0.4.1",
51
50
  "@percepta/inngest": "^0.1.0",
52
51
  "@percepta/logger": "^0.1.0",
53
- "@percepta/next-utils": "^0.2.2",
52
+ "@percepta/next-utils": "^0.2.3",
54
53
  "@percepta/utils": "^0.1.11",
55
54
  "@radix-ui/react-slot": "^1.2.3",
56
55
  "@tanstack/react-query": "^5.81.5",
@@ -11,6 +11,10 @@ import { AsyncLocalStorage } from "node:async_hooks";
11
11
  import { execFileSync } from "node:child_process";
12
12
  import * as nextEnvModule from "@next/env";
13
13
  import type { SubjectRef } from "@percepta/access-control";
14
+ import {
15
+ ensurePerceptaAuthModeEnv,
16
+ type PerceptaAuthMode,
17
+ } from "@percepta/auth/better-auth";
14
18
 
15
19
  const nextEnv =
16
20
  (nextEnvModule as { default?: typeof nextEnvModule }).default ??
@@ -47,7 +51,6 @@ const SEEDED_USERS = [
47
51
  },
48
52
  ] as const;
49
53
 
50
- type AuthMode = "username-password" | "google" | "okta";
51
54
  interface AdminCreateUserApi {
52
55
  createUser(input: {
53
56
  body: {
@@ -58,24 +61,13 @@ interface AdminCreateUserApi {
58
61
  }): Promise<{ user: { id: string } }>;
59
62
  }
60
63
 
61
- const DEFAULT_AUTH_MODE: AuthMode = "__AUTH_MODE__" as AuthMode;
62
-
63
- function isAuthMode(value: string | undefined): value is AuthMode {
64
- return (
65
- value === "username-password" || value === "google" || value === "okta"
66
- );
67
- }
68
-
69
- function getAuthMode(): AuthMode {
70
- return isAuthMode(process.env.AUTH_MODE)
71
- ? process.env.AUTH_MODE
72
- : DEFAULT_AUTH_MODE;
73
- }
64
+ const DEFAULT_AUTH_MODE = "__AUTH_MODE__" satisfies PerceptaAuthMode;
74
65
 
75
66
  async function main(): Promise<void> {
76
67
  nextEnv.loadEnvConfig(process.cwd());
77
- const authMode = getAuthMode();
78
- process.env.AUTH_MODE = authMode;
68
+ const authMode = ensurePerceptaAuthModeEnv({
69
+ defaultAuthMode: DEFAULT_AUTH_MODE,
70
+ });
79
71
  // oxlint-disable-next-line typescript/no-explicit-any
80
72
  (globalThis as any).AsyncLocalStorage = AsyncLocalStorage;
81
73
 
@@ -85,10 +77,12 @@ async function main(): Promise<void> {
85
77
  const { getAccessControl, toUserSubject } =
86
78
  await import("../src/services/access/AppAccessControl");
87
79
  const { getEnvConfig } = await import("../src/config/getEnvConfig");
80
+ const { getLogger } = await import("../src/services/logger/AppLogger");
88
81
  const { createCustomerAccessControl } =
89
82
  await import("@percepta/access-control");
90
83
  const { eq, sql } = await import("drizzle-orm");
91
84
 
85
+ const logger = getLogger().child({ safe: { component: "db-seed" } });
92
86
  const envConfig = getEnvConfig();
93
87
  const access = getAccessControl();
94
88
  const appNamespace = access.manifest.appNamespace;
@@ -108,8 +102,9 @@ async function main(): Promise<void> {
108
102
  let userId: string;
109
103
  if (existing != null) {
110
104
  userId = existing.id;
111
- console.log(
112
- `Seed user "${seededUser.email}" already exists (id: ${existing.id}).`,
105
+ logger.info(
106
+ { safe: { email: seededUser.email, userId: existing.id } },
107
+ "Seed user already exists.",
113
108
  );
114
109
  } else {
115
110
  if (authMode === "username-password") {
@@ -135,9 +130,18 @@ async function main(): Promise<void> {
135
130
  userId = res.user.id;
136
131
  }
137
132
 
138
- console.log(`Seed user created: ${seededUser.email} (id: ${userId})`);
133
+ logger.info(
134
+ { safe: { email: seededUser.email, userId } },
135
+ "Seed user created.",
136
+ );
139
137
  if (authMode === "username-password") {
140
- console.log(` Password: ${seededUser.password}`);
138
+ logger.info(
139
+ {
140
+ safe: { email: seededUser.email },
141
+ unsafe: { password: seededUser.password },
142
+ },
143
+ "Seed user password configured.",
144
+ );
141
145
  }
142
146
  }
143
147
 
@@ -145,7 +149,10 @@ async function main(): Promise<void> {
145
149
  .update(users)
146
150
  .set({ role: seededUser.role })
147
151
  .where(eq(users.id, userId));
148
- console.log(` Ensured role: ${seededUser.role}`);
152
+ logger.info(
153
+ { safe: { email: seededUser.email, role: seededUser.role } },
154
+ "Seed user role ensured.",
155
+ );
149
156
 
150
157
  const subject = toUserSubject(userId);
151
158
  switch (seededUser.access) {
@@ -169,7 +176,7 @@ async function main(): Promise<void> {
169
176
  }
170
177
  }
171
178
 
172
- console.log("Ensured local customer and app access grants.");
179
+ logger.info(undefined, "Ensured local customer and app access grants.");
173
180
  process.exit(0);
174
181
  }
175
182
 
@@ -1,75 +1,23 @@
1
1
  #!/usr/bin/env tsx
2
2
 
3
- import { spawn } from "node:child_process";
4
- import { existsSync, readFileSync } from "node:fs";
5
3
  import { homedir } from "node:os";
6
4
  import path from "node:path";
5
+ import { runCommandWithEnvFile } from "@percepta/database";
7
6
 
8
- type EnvMap = Record<string, string>;
9
-
10
- const LOCAL_ENV_PATH = path.join(
11
- homedir(),
12
- ".config",
13
- "percepta",
14
- "create.env",
15
- );
16
-
17
- function parseEnvFile(filePath: string): EnvMap {
18
- if (!existsSync(filePath)) return {};
19
-
20
- const env: EnvMap = {};
21
- const content = readFileSync(filePath, "utf8");
22
- for (const rawLine of content.split(/\r?\n/)) {
23
- const line = rawLine.trim();
24
- if (!line || line.startsWith("#")) continue;
25
-
26
- const normalized = line.startsWith("export ") ? line.slice(7).trim() : line;
27
- const separatorIndex = normalized.indexOf("=");
28
- if (separatorIndex === -1) continue;
29
-
30
- const key = normalized.slice(0, separatorIndex).trim();
31
- const rawValue = normalized.slice(separatorIndex + 1).trim();
32
- if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue;
33
-
34
- env[key] = unquote(rawValue);
35
- }
36
-
37
- return env;
38
- }
39
-
40
- function unquote(value: string): string {
41
- if (
42
- (value.startsWith('"') && value.endsWith('"')) ||
43
- (value.startsWith("'") && value.endsWith("'"))
44
- ) {
45
- return value.slice(1, -1);
46
- }
47
-
48
- return value;
49
- }
7
+ const LOCAL_ENV_DIR = path.join(homedir(), ".config", "percepta");
8
+ const LOCAL_ENV_FILE = "create.env";
50
9
 
51
10
  const [command, ...args] = process.argv.slice(2);
52
11
  if (!command) {
53
12
  throw new Error("Usage: tsx scripts/with-local-env.ts <command> [...args]");
54
13
  }
55
14
 
56
- const child = spawn(command, args, {
57
- env: {
58
- ...parseEnvFile(LOCAL_ENV_PATH),
59
- ...process.env,
60
- },
61
- stdio: "inherit",
62
- });
63
-
64
- child.on("error", (error) => {
65
- throw error;
66
- });
67
-
68
- child.on("exit", (code, signal) => {
69
- if (signal) {
70
- process.kill(process.pid, signal);
71
- return;
72
- }
73
-
74
- process.exit(code ?? 1);
75
- });
15
+ process.exit(
16
+ await runCommandWithEnvFile({
17
+ args,
18
+ allowedEnvFileNames: [LOCAL_ENV_FILE],
19
+ command,
20
+ envFileBaseDir: LOCAL_ENV_DIR,
21
+ envFilePath: LOCAL_ENV_FILE,
22
+ }),
23
+ );
@@ -1,5 +1,5 @@
1
- import { createPgPool, readDatabaseConfig } from "@percepta/database";
2
- import { type NodePgDatabase, drizzle } from "drizzle-orm/node-postgres";
1
+ import { createDrizzlePgDatabase } from "@percepta/database";
2
+ import type { NodePgDatabase } from "drizzle-orm/node-postgres";
3
3
  import type { Pool } from "pg";
4
4
  import { getEnvConfig } from "../config/getEnvConfig";
5
5
 
@@ -7,11 +7,7 @@ export const { client, db } = createDb();
7
7
 
8
8
  function createDb(): { client: Pool; db: NodePgDatabase } {
9
9
  const { DATABASE_URL: databaseUrl, NODE_ENV: nodeEnv } = getEnvConfig();
10
- const pool = createPgPool(
11
- readDatabaseConfig({
12
- env: { DATABASE_URL: databaseUrl, NODE_ENV: nodeEnv },
13
- }),
14
- );
15
-
16
- return { client: pool, db: drizzle(pool) };
10
+ return createDrizzlePgDatabase({
11
+ env: { DATABASE_URL: databaseUrl, NODE_ENV: nodeEnv },
12
+ });
17
13
  }
@@ -1,76 +1,9 @@
1
- import { getNodeAutoInstrumentations } from "@opentelemetry/auto-instrumentations-node";
2
- import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-proto";
3
- import { NodeSDK, tracing } from "@opentelemetry/sdk-node";
4
- import { createLangfuseSpanProcessor } from "@percepta/ai";
5
- import { compact } from "lodash-es";
1
+ import { startPerceptaNodeTelemetry } from "@percepta/ai";
6
2
  import { getEnvConfig } from "./config/getEnvConfig";
7
3
  import { getLogger } from "./services/logger/AppLogger";
8
4
 
9
- type SpanProcessor = tracing.SpanProcessor;
10
-
11
- function setDefaultOpenTelemetryEnv(): void {
12
- const { DEPLOYMENT_ENVIRONMENT: deploymentEnvironment, NODE_ENV: nodeEnv } =
13
- getEnvConfig();
14
-
15
- process.env.OTEL_SERVICE_NAME ??= "__APP_NAME__";
16
- process.env.OTEL_RESOURCE_ATTRIBUTES ??= `deployment.environment=${deploymentEnvironment ?? nodeEnv}`;
17
- }
18
-
19
- function getOtlpTracesEndpoint(): string | undefined {
20
- const {
21
- OTEL_EXPORTER_OTLP_TRACES_ENDPOINT: tracesEndpoint,
22
- OTEL_EXPORTER_OTLP_ENDPOINT: baseEndpoint,
23
- } = getEnvConfig();
24
-
25
- if (tracesEndpoint) return tracesEndpoint;
26
-
27
- if (!baseEndpoint) return undefined;
28
-
29
- return `${baseEndpoint.replace(/\/$/, "")}/v1/traces`;
30
- }
31
-
32
- function getOtlpSpanProcessor(): tracing.BatchSpanProcessor | undefined {
33
- const { OTEL_TRACES_EXPORTER: tracesExporter } = getEnvConfig();
34
- if (tracesExporter === "none") {
35
- getLogger().debug(
36
- undefined,
37
- "OTEL_TRACES_EXPORTER=none. Skipping OTLP trace export.",
38
- );
39
- return undefined;
40
- }
41
-
42
- const tracesEndpoint = getOtlpTracesEndpoint();
43
- if (!tracesEndpoint) {
44
- getLogger().debug(
45
- undefined,
46
- "No OTLP trace endpoint found. Skipping OTLP trace export.",
47
- );
48
- return undefined;
49
- }
50
-
51
- getLogger().debug(
52
- { safe: { tracesEndpoint } },
53
- "Registering OTLP trace exporter.",
54
- );
55
- return new tracing.BatchSpanProcessor(
56
- new OTLPTraceExporter({ url: tracesEndpoint }),
57
- );
58
- }
59
-
60
- function getLangfuseSpanProcessor(): SpanProcessor | undefined {
61
- return createLangfuseSpanProcessor(getEnvConfig(), getLogger());
62
- }
63
-
64
- setDefaultOpenTelemetryEnv();
65
-
66
- const spanProcessors: tracing.SpanProcessor[] = compact([
67
- getOtlpSpanProcessor(),
68
- getLangfuseSpanProcessor(),
69
- ]);
70
-
71
- const sdk = new NodeSDK({
72
- spanProcessors,
73
- instrumentations: [getNodeAutoInstrumentations()],
5
+ startPerceptaNodeTelemetry({
6
+ appName: "__APP_NAME__",
7
+ getEnv: getEnvConfig,
8
+ getLogger,
74
9
  });
75
-
76
- sdk.start();
@@ -1,20 +1,12 @@
1
- export type AuthMode = "username-password" | "google" | "okta";
1
+ import {
2
+ ensurePerceptaAuthModeEnv,
3
+ type PerceptaAuthMode,
4
+ } from "@percepta/auth/better-auth";
2
5
 
3
- const APP_AUTH_MODE: AuthMode = "__AUTH_MODE__" as AuthMode;
4
-
5
- function isAuthMode(value: string | undefined): value is AuthMode {
6
- return (
7
- value === "username-password" || value === "google" || value === "okta"
8
- );
9
- }
6
+ export type AuthMode = PerceptaAuthMode;
10
7
 
11
- export function ensureAppAuthModeEnv(): AuthMode {
12
- if (isAuthMode(process.env.AUTH_MODE)) {
13
- return process.env.AUTH_MODE;
14
- }
15
-
16
- process.env.AUTH_MODE = APP_AUTH_MODE;
17
- return APP_AUTH_MODE;
18
- }
8
+ const APP_AUTH_MODE: AuthMode = "__AUTH_MODE__" as AuthMode;
19
9
 
20
- export const AUTH_MODE = ensureAppAuthModeEnv();
10
+ export const AUTH_MODE = ensurePerceptaAuthModeEnv({
11
+ defaultAuthMode: APP_AUTH_MODE,
12
+ });
@@ -1,53 +1,5 @@
1
- import { AsyncLocalStorage } from "node:async_hooks";
2
- import type { NodePgDatabase } from "drizzle-orm/node-postgres";
1
+ import { createTransactionalDatabaseServiceFactory } from "@percepta/database";
3
2
  import { db } from "../drizzle/db";
4
3
 
5
- export class DatabaseService {
6
- private static SINGLETON: DatabaseService | undefined;
7
- public static create(): DatabaseService {
8
- if (DatabaseService.SINGLETON == null) {
9
- DatabaseService.SINGLETON = new DatabaseService(db);
10
- }
11
-
12
- return DatabaseService.SINGLETON;
13
- }
14
-
15
- private transactionAsyncLocalStorage = new AsyncLocalStorage<LocalStorage>();
16
-
17
- private constructor(private database: NodePgDatabase) {}
18
-
19
- public async createTransaction<TReturn>(
20
- callback: (txn: NodePgDatabase) => Promise<TReturn>,
21
- ): Promise<TReturn> {
22
- const currentContext = this.transactionAsyncLocalStorage.getStore();
23
- if (currentContext != null) {
24
- const { txn } = currentContext;
25
-
26
- // Already in a transaction.
27
- return callback(txn);
28
- }
29
-
30
- return this.database.transaction((txn) => {
31
- return this.transactionAsyncLocalStorage.run<Promise<TReturn>>(
32
- { txn },
33
- () => callback(txn),
34
- );
35
- });
36
- }
37
-
38
- public getDatabase(): Database {
39
- const context = this.transactionAsyncLocalStorage.getStore();
40
- if (context == null) {
41
- return this.database;
42
- }
43
-
44
- const { txn } = context;
45
- return txn;
46
- }
47
- }
48
-
49
- type Database = Omit<NodePgDatabase, "transaction">;
50
-
51
- interface LocalStorage {
52
- txn: NodePgDatabase;
53
- }
4
+ export const DatabaseService = createTransactionalDatabaseServiceFactory(db);
5
+ export type DatabaseService = ReturnType<typeof DatabaseService.create>;
@@ -1,22 +1,10 @@
1
1
  "use client";
2
2
 
3
- import { TracingInstrumentation } from "@grafana/faro-web-tracing";
4
- import { createFaroInstance } from "@percepta/next-utils/faro";
3
+ import { createPerceptaFaroInstance } from "@percepta/next-utils/faro";
5
4
  import { getClientEnvConfig } from "../../config/clientEnvConfig";
6
5
 
7
- const {
8
- FARO_COLLECTOR_URL,
9
- FARO_APP_NAME,
10
- FARO_APP_VERSION,
11
- FARO_APP_ENVIRONMENT,
12
- } = getClientEnvConfig();
13
-
14
- export const faroInstance = createFaroInstance({
15
- collectorUrl: FARO_COLLECTOR_URL,
16
- app: {
17
- name: FARO_APP_NAME,
18
- version: FARO_APP_VERSION,
19
- environment: FARO_APP_ENVIRONMENT,
20
- },
21
- extraInstrumentations: [new TracingInstrumentation()],
6
+ export const faroInstance = createPerceptaFaroInstance({
7
+ defaultAppName: "__APP_NAME__",
8
+ defaultEnvironment: process.env.NODE_ENV,
9
+ env: getClientEnvConfig(),
22
10
  });
@@ -1,183 +0,0 @@
1
- #!/usr/bin/env node
2
-
3
- import { execFileSync } from "node:child_process";
4
- import { existsSync, readFileSync, readdirSync } from "node:fs";
5
- import path from "node:path";
6
- import { fileURLToPath } from "node:url";
7
-
8
- const LOCAL_POSTGRES_HOSTS = new Set(["localhost", "127.0.0.1", "::1"]);
9
- const LOCAL_POSTGRES_PORT = "5434";
10
- const POSTGRES_SERVICE = "postgres";
11
- const POSTGRES_USER = "postgres";
12
- const ROOT_DIR = path.resolve(
13
- path.dirname(fileURLToPath(import.meta.url)),
14
- "..",
15
- );
16
- const PACKAGES_DIR = path.join(ROOT_DIR, "packages");
17
-
18
- const databases = new Set(["auth"]);
19
-
20
- for (const packageDir of listPackageDirs()) {
21
- const database = readPackageDatabaseName(packageDir);
22
- if (database != null) {
23
- databases.add(database);
24
- }
25
- }
26
-
27
- for (const database of [...databases].sort()) {
28
- ensureDatabase(database);
29
- }
30
-
31
- function listPackageDirs() {
32
- if (!existsSync(PACKAGES_DIR)) return [];
33
-
34
- return readdirSync(PACKAGES_DIR, { withFileTypes: true })
35
- .filter((entry) => entry.isDirectory())
36
- .map((entry) => packageDirFor(entry.name));
37
- }
38
-
39
- function packageDirFor(packageName) {
40
- if (
41
- packageName === "." ||
42
- packageName === ".." ||
43
- packageName.includes("/") ||
44
- packageName.includes("\\")
45
- ) {
46
- throw new Error(`Unexpected package directory name: ${packageName}`);
47
- }
48
-
49
- return path.join(PACKAGES_DIR, packageName);
50
- }
51
-
52
- function readPackageDatabaseName(packageDir) {
53
- const env = readPackageEnvFile(packageDir);
54
- const databaseUrl = env.DATABASE_URL;
55
- if (databaseUrl == null || databaseUrl.length === 0) return null;
56
-
57
- let url;
58
- try {
59
- url = new URL(databaseUrl);
60
- } catch {
61
- throw new Error(
62
- `Invalid DATABASE_URL in ${path.relative(ROOT_DIR, packageDir)}/.env.local`,
63
- );
64
- }
65
-
66
- if (url.protocol !== "postgres:" && url.protocol !== "postgresql:") {
67
- throw new Error(
68
- `DATABASE_URL in ${path.relative(ROOT_DIR, packageDir)}/.env.local must use postgres or postgresql.`,
69
- );
70
- }
71
-
72
- const port = url.port || "5432";
73
- if (!LOCAL_POSTGRES_HOSTS.has(url.hostname) || port !== LOCAL_POSTGRES_PORT) {
74
- console.log(
75
- `Skipping non-local app database for ${path.relative(ROOT_DIR, packageDir)}.`,
76
- );
77
- return null;
78
- }
79
-
80
- const database = decodeURIComponent(url.pathname.replace(/^\/+/, ""));
81
- if (database.length === 0) {
82
- throw new Error(
83
- `DATABASE_URL in ${path.relative(ROOT_DIR, packageDir)}/.env.local must include a database name.`,
84
- );
85
- }
86
-
87
- return database;
88
- }
89
-
90
- function readPackageEnvFile(packageDir) {
91
- const safePackageDir = assertPackageDir(packageDir);
92
- const envPath = path.join(safePackageDir, ".env.local");
93
- if (!existsSync(envPath)) return {};
94
-
95
- const env = {};
96
- const content = readFileSync(envPath, "utf8");
97
- for (const rawLine of content.split(/\r?\n/)) {
98
- const line = rawLine.trim();
99
- if (!line || line.startsWith("#")) continue;
100
-
101
- const normalized = line.startsWith("export ") ? line.slice(7).trim() : line;
102
- const separatorIndex = normalized.indexOf("=");
103
- if (separatorIndex === -1) continue;
104
-
105
- const key = normalized.slice(0, separatorIndex).trim();
106
- const rawValue = normalized.slice(separatorIndex + 1).trim();
107
- if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue;
108
-
109
- env[key] = unquote(rawValue);
110
- }
111
-
112
- return env;
113
- }
114
-
115
- function assertPackageDir(packageDir) {
116
- const resolvedPackageDir = path.resolve(packageDir);
117
- const relative = path.relative(PACKAGES_DIR, resolvedPackageDir);
118
-
119
- if (
120
- relative.length === 0 ||
121
- relative.startsWith("..") ||
122
- path.isAbsolute(relative) ||
123
- relative.includes(path.sep)
124
- ) {
125
- throw new Error(`Unexpected package directory: ${packageDir}`);
126
- }
127
-
128
- return resolvedPackageDir;
129
- }
130
-
131
- function unquote(value) {
132
- if (
133
- (value.startsWith('"') && value.endsWith('"')) ||
134
- (value.startsWith("'") && value.endsWith("'"))
135
- ) {
136
- return value.slice(1, -1);
137
- }
138
-
139
- return value;
140
- }
141
-
142
- function ensureDatabase(database) {
143
- const exists = execDockerCompose([
144
- "exec",
145
- "-T",
146
- POSTGRES_SERVICE,
147
- "psql",
148
- "-U",
149
- POSTGRES_USER,
150
- "-d",
151
- "postgres",
152
- "-tAc",
153
- `SELECT 1 FROM pg_database WHERE datname = ${quotePgLiteral(database)}`,
154
- ]).trim();
155
-
156
- if (exists === "1") {
157
- console.log(`Database ${database} already exists.`);
158
- return;
159
- }
160
-
161
- execDockerCompose([
162
- "exec",
163
- "-T",
164
- POSTGRES_SERVICE,
165
- "createdb",
166
- "-U",
167
- POSTGRES_USER,
168
- database,
169
- ]);
170
- console.log(`Database ${database} created.`);
171
- }
172
-
173
- function execDockerCompose(args) {
174
- return execFileSync("docker", ["compose", ...args], {
175
- cwd: ROOT_DIR,
176
- encoding: "utf8",
177
- stdio: ["ignore", "pipe", "inherit"],
178
- });
179
- }
180
-
181
- function quotePgLiteral(value) {
182
- return `'${value.replaceAll("'", "''")}'`;
183
- }
@@ -1,25 +0,0 @@
1
- import { customType } from "drizzle-orm/pg-core";
2
- import type { z } from "zod";
3
-
4
- export function jsonbFromZod<TValue>(schema: z.Schema<TValue>): ReturnType<
5
- typeof customType<{
6
- data: TValue;
7
- driverData: string;
8
- }>
9
- > {
10
- return customType<{
11
- data: TValue;
12
- driverData: string;
13
- }>({
14
- dataType() {
15
- return "jsonb";
16
- },
17
- toDriver(value) {
18
- return JSON.stringify(schema.parse(value));
19
- },
20
- fromDriver(raw) {
21
- const parsed = schema.parse(raw);
22
- return parsed;
23
- },
24
- });
25
- }