@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
@@ -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 only records object identity, relationship columns, and which field groups require which permissions.
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
@@ -43,14 +43,14 @@ schemas validate `event.data`, so do not wrap payload schemas in another
43
43
 
44
44
  ### 1. Create a function collection
45
45
 
46
- Group related functions into a collection class that implements `InngestFunctionCollection`:
46
+ Group related functions into a collection class that implements `PerceptaInngestFunctionCollection`:
47
47
 
48
48
  ```typescript
49
+ import { type PerceptaInngestFunctionCollection } from "@percepta/inngest";
49
50
  import { type InngestFunction } from "inngest";
50
- import { type InngestFunctionCollection } from "../InngestFunctionCollection";
51
51
  import { InngestService } from "../InngestService";
52
52
 
53
- export class DocumentFunctions implements InngestFunctionCollection {
53
+ export class DocumentFunctions implements PerceptaInngestFunctionCollection {
54
54
  private inngestService = InngestService.create();
55
55
 
56
56
  public get functions(): InngestFunction.Like[] {
@@ -85,9 +85,9 @@ export class DocumentFunctions implements InngestFunctionCollection {
85
85
  Add your collection to the `functionCollections` array in the Inngest serve endpoint:
86
86
 
87
87
  ```typescript
88
- const functionCollections: InngestFunctionCollection[] = compact([
88
+ const functionCollections: PerceptaInngestFunctionCollection[] = [
89
89
  new DocumentFunctions(),
90
- ]);
90
+ ];
91
91
  ```
92
92
 
93
93
  ## Sending Events
@@ -21,14 +21,14 @@ Langfuse is an open-source LLM observability platform. It captures traces, spans
21
21
  The template uses Next.js's instrumentation hook (called on server startup) to bootstrap OTEL with both the environment collector and optional Langfuse:
22
22
 
23
23
  ```typescript
24
- import { LangfuseSpanProcessor } from "@langfuse/otel";
25
- import { NodeSDK, tracing } from "@opentelemetry/sdk-node";
26
24
  import { getNodeAutoInstrumentations } from "@opentelemetry/auto-instrumentations-node";
25
+ import { NodeSDK, tracing } from "@opentelemetry/sdk-node";
26
+ import { createLangfuseSpanProcessor } from "@percepta/ai";
27
27
 
28
28
  const sdk = new NodeSDK({
29
29
  spanProcessors: [
30
30
  new tracing.BatchSpanProcessor(otlpTraceExporter),
31
- new LangfuseSpanProcessor({ baseUrl, publicKey, secretKey }),
31
+ createLangfuseSpanProcessor(env, logger),
32
32
  ],
33
33
  instrumentations: [getNodeAutoInstrumentations()],
34
34
  });
@@ -37,7 +37,7 @@ sdk.start();
37
37
 
38
38
  - `getNodeAutoInstrumentations()` automatically instruments HTTP calls, database queries, and other standard Node.js operations.
39
39
  - The OTLP span processor forwards general server traces to the environment collector.
40
- - `LangfuseSpanProcessor` forwards only AI SDK spans to Langfuse when all `LANGFUSE_*` variables are set. General HTTP, DB, and server spans stay in the OTEL/LGTM pipeline and are not sent to Langfuse by default.
40
+ - The shared Langfuse span processor forwards only AI SDK spans to Langfuse when all `LANGFUSE_*` variables are set. General HTTP, DB, and server spans stay in the OTEL/LGTM pipeline and are not sent to Langfuse by default.
41
41
 
42
42
  **You do not need to manually instrument LLM calls.** Use `LLMService` from `src/services/llm/LLMService.ts`; it calls the Vercel AI SDK with telemetry enabled and provider/model metadata attached.
43
43
 
@@ -30,7 +30,7 @@ const stream = LLMService.create().streamText({
30
30
 
31
31
  ## Providers
32
32
 
33
- `LlmProviderService` chooses a provider at call time:
33
+ The shared `@percepta/ai` provider helper chooses a provider at call time:
34
34
 
35
35
  1. `LLM_PROVIDER`, when explicitly set.
36
36
  2. Anthropic when `ANTHROPIC_API_KEY` is available.
@@ -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": "tsx ./scripts/generate-migrations.ts",
21
- "db:migrate": "tsx ./scripts/migrate.ts",
22
- "db:setup": "tsx ./scripts/setup-database.ts",
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": "tsx ./scripts/setup-readonly-user.ts",
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__",
@@ -33,10 +33,7 @@
33
33
  "test:watch": "vitest"
34
34
  },
35
35
  "dependencies": {
36
- "@ai-sdk/anthropic": "^2.0.23",
37
- "@ai-sdk/openai": "^2.0.23",
38
36
  "@aws-sdk/client-s3": "^3.888.0",
39
- "@aws-sdk/client-secrets-manager": "^3.914.0",
40
37
  "@aws-sdk/client-sts": "^3.913.0",
41
38
  "@aws-sdk/credential-providers": "^3.913.0",
42
39
  "@aws-sdk/s3-request-presigner": "^3.891.0",
@@ -48,9 +45,6 @@
48
45
  "@grafana/faro-web-sdk": "^1.14.0",
49
46
  "@grafana/faro-web-tracing": "^1.14.0",
50
47
  "@hookform/resolvers": "^5.2.2",
51
- "@inngest/middleware-validation": "^0.0.5",
52
- "@langfuse/otel": "^4.0.0",
53
- "@langfuse/tracing": "^4.0.0",
54
48
  "@mantine/hooks": "^8.3.1",
55
49
  "@next/env": "^15.3.5",
56
50
  "@opentelemetry/api": "^1.9.0",
@@ -58,10 +52,13 @@
58
52
  "@opentelemetry/exporter-trace-otlp-proto": "^0.203.0",
59
53
  "@opentelemetry/sdk-node": "^0.203.0",
60
54
  "@__REPO_NAME__/auth": "workspace:*",
61
- "@percepta/access-control": "0.6.1",
55
+ "@percepta/access-control": "0.7.0",
56
+ "@percepta/ai": "0.1.0",
57
+ "@percepta/database": "0.1.0",
62
58
  "@percepta/design": "0.3.2",
63
- "@percepta/logger": "0.0.6",
64
- "@percepta/next-utils": "0.1.0",
59
+ "@percepta/inngest": "0.1.0",
60
+ "@percepta/logger": "0.1.0",
61
+ "@percepta/next-utils": "0.2.0",
65
62
  "@percepta/utils": "0.1.10",
66
63
  "@radix-ui/react-slot": "^1.2.3",
67
64
  "@tanstack/react-query": "^5.81.5",
@@ -69,7 +66,6 @@
69
66
  "@trpc/client": "^11.4.3",
70
67
  "@trpc/server": "^11.4.3",
71
68
  "@trpc/tanstack-react-query": "^11.4.3",
72
- "ai": "^5.0.24",
73
69
  "better-auth": "^1.6.4",
74
70
  "clsx": "^2.1.1",
75
71
  "date-fns": "^4.1.0",
@@ -80,15 +76,12 @@
80
76
  "import-in-the-middle": "^1.14.2",
81
77
  "inngest": "^3.44.3",
82
78
  "keyed-type-union": "^0.1.0",
83
- "langfuse": "^3.38.6",
84
79
  "lodash-es": "^4.17.21",
85
80
  "lucide-react": "^0.542.0",
86
81
  "mime-types": "^3.0.1",
87
82
  "next": "^15.3.5",
88
83
  "numeral": "^2.0.6",
89
84
  "pg": "^8.16.3",
90
- "pino": "^10.1.0",
91
- "pino-pretty": "^13.1.3",
92
85
  "pluralize": "^8.0.0",
93
86
  "react": "^19.0.0",
94
87
  "react-dom": "^19.0.0",
@@ -1,31 +1,21 @@
1
- import { serve } from "inngest/next";
2
- import { compact } from "lodash-es";
3
- import { type InngestFunctionCollection } from "../../../services/inngest/InngestFunctionCollection";
1
+ import {
2
+ createPerceptaInngestNextRouteHandlers,
3
+ type PerceptaInngestFunctionCollection,
4
+ } from "@percepta/inngest";
4
5
  import { InngestService } from "../../../services/inngest/InngestService";
5
6
 
6
7
  export const dynamic = "force-dynamic";
7
8
 
8
- const functionCollections: InngestFunctionCollection[] = compact([]);
9
+ const functionCollections: PerceptaInngestFunctionCollection[] = [];
9
10
 
10
11
  // InngestService.create() reads env vars and throws if INNGEST_BASE_URL is
11
12
  // unset. Defer initialization until request time so `next build` can compile
12
13
  // this route without those vars present.
13
- type Handlers = ReturnType<typeof serve>;
14
+ const inngestHandlers = createPerceptaInngestNextRouteHandlers({
15
+ getService: () => InngestService.create(),
16
+ getFunctions: () => functionCollections.flatMap(({ functions }) => functions),
17
+ });
14
18
 
15
- let cachedHandlers: Handlers | undefined;
16
-
17
- function getHandlers(): Handlers {
18
- if (cachedHandlers == null) {
19
- const inngestService = InngestService.create();
20
- cachedHandlers = serve({
21
- client: inngestService.client,
22
- functions: functionCollections.flatMap(({ functions }) => functions),
23
- signingKey: inngestService.signingKey,
24
- });
25
- }
26
- return cachedHandlers;
27
- }
28
-
29
- export const GET: Handlers["GET"] = (...args) => getHandlers().GET(...args);
30
- export const POST: Handlers["POST"] = (...args) => getHandlers().POST(...args);
31
- export const PUT: Handlers["PUT"] = (...args) => getHandlers().PUT(...args);
19
+ export const GET = inngestHandlers.GET;
20
+ export const POST = inngestHandlers.POST;
21
+ export const PUT = inngestHandlers.PUT;
@@ -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,52 +1,12 @@
1
- import { LangfuseSpanProcessor } from "@langfuse/otel";
2
1
  import { getNodeAutoInstrumentations } from "@opentelemetry/auto-instrumentations-node";
3
2
  import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-proto";
4
3
  import { NodeSDK, tracing } from "@opentelemetry/sdk-node";
4
+ import { createLangfuseSpanProcessor } from "@percepta/ai";
5
5
  import { compact } from "lodash-es";
6
6
  import { getEnvConfig } from "./config/getEnvConfig";
7
7
  import { getLogger } from "./services/logger/AppLogger";
8
8
 
9
9
  type SpanProcessor = tracing.SpanProcessor;
10
- type ReadableSpan = Parameters<SpanProcessor["onEnd"]>[0];
11
- type OnStartArgs = Parameters<SpanProcessor["onStart"]>;
12
-
13
- class FilteringSpanProcessor implements SpanProcessor {
14
- public constructor(
15
- private delegate: SpanProcessor,
16
- private shouldExport: (span: ReadableSpan) => boolean,
17
- ) {}
18
-
19
- public forceFlush(): Promise<void> {
20
- return this.delegate.forceFlush();
21
- }
22
-
23
- public onStart(...args: OnStartArgs): void {
24
- this.delegate.onStart(...args);
25
- }
26
-
27
- public onEnd(span: ReadableSpan): void {
28
- if (this.shouldExport(span)) {
29
- this.delegate.onEnd(span);
30
- }
31
- }
32
-
33
- public shutdown(): Promise<void> {
34
- return this.delegate.shutdown();
35
- }
36
- }
37
-
38
- function isAiSdkSpan(span: ReadableSpan): boolean {
39
- const operationId = span.attributes["ai.operationId"];
40
- if (typeof operationId === "string" && operationId.startsWith("ai.")) {
41
- return true;
42
- }
43
-
44
- if (span.name.startsWith("ai.")) {
45
- return true;
46
- }
47
-
48
- return Object.keys(span.attributes).some((key) => key.startsWith("gen_ai."));
49
- }
50
10
 
51
11
  function getOtlpTracesEndpoint(): string | undefined {
52
12
  const {
@@ -90,28 +50,7 @@ function getOtlpSpanProcessor(): tracing.BatchSpanProcessor | undefined {
90
50
  }
91
51
 
92
52
  function getLangfuseSpanProcessor(): SpanProcessor | undefined {
93
- const {
94
- LANGFUSE_BASE_URL: baseUrl,
95
- LANGFUSE_PUBLIC_KEY: publicKey,
96
- LANGFUSE_SECRET_KEY: secretKey,
97
- } = getEnvConfig();
98
- if (!baseUrl || !publicKey || !secretKey) {
99
- getLogger().debug(
100
- undefined,
101
- "Langfuse environment is incomplete. Skipping Langfuse OpenTelemetry.",
102
- );
103
- return undefined;
104
- }
105
-
106
- getLogger().debug(undefined, "Registering Langfuse OpenTelemetry.");
107
- return new FilteringSpanProcessor(
108
- new LangfuseSpanProcessor({
109
- baseUrl,
110
- publicKey,
111
- secretKey,
112
- }),
113
- isAiSdkSpan,
114
- );
53
+ return createLangfuseSpanProcessor(getEnvConfig(), getLogger());
115
54
  }
116
55
 
117
56
  const spanProcessors: tracing.SpanProcessor[] = compact([
@@ -1,8 +1,5 @@
1
- import type { PermissionClient, SubjectRef } from "@percepta/access-control";
2
- import {
3
- createRequireApplicationAccess,
4
- createRequirePermission,
5
- } from "@percepta/access-control/trpc";
1
+ import type { SubjectRef } from "@percepta/access-control";
2
+ import { createAppAccessControlMiddlewares } from "@percepta/access-control/trpc";
6
3
  import { TRPCError, initTRPC } from "@trpc/server";
7
4
  import superjson from "superjson";
8
5
  import { accessManifest } from "../access/access.manifest";
@@ -25,12 +22,6 @@ export interface ProtectedContext extends Context {
25
22
  session: BetterAuthSession;
26
23
  }
27
24
 
28
- const lazyPermissionClient = {
29
- can(check) {
30
- return getAccessControl().permissions.can(check);
31
- },
32
- } satisfies Pick<PermissionClient, "can">;
33
-
34
25
  function getCurrentSubject(ctx: Context): SubjectRef | null {
35
26
  const userId = ctx.session?.user.id;
36
27
  return userId == null ? null : toUserSubject(userId);
@@ -76,17 +67,14 @@ const requireAuthenticatedUser = procedure.use(
76
67
  },
77
68
  );
78
69
 
79
- const requireApplicationAccess =
80
- createRequireApplicationAccess<ProtectedContext>({
81
- accessControl: lazyPermissionClient,
70
+ const { requireApplicationAccess, requirePermission } =
71
+ createAppAccessControlMiddlewares<ProtectedContext>({
72
+ getAccessControl,
82
73
  getSubject: getCurrentSubject,
83
74
  manifest: accessManifest,
84
75
  });
85
76
 
86
- export const requirePermission = createRequirePermission<ProtectedContext>({
87
- accessControl: lazyPermissionClient,
88
- getSubject: getCurrentSubject,
89
- });
77
+ export { requirePermission };
90
78
 
91
79
  export const protectedProcedure = requireAuthenticatedUser;
92
80
 
@@ -1,63 +1,11 @@
1
- import { trace } from "@opentelemetry/api";
2
- import { TRPCError } from "@trpc/server";
1
+ import {
2
+ createAuthContextServiceFactory,
3
+ type AuthContextService as PerceptaAuthContextService,
4
+ } from "@percepta/next-utils";
3
5
  import type { BetterAuthSession } from "../lib/auth";
4
6
 
5
7
  export type AuthSession = BetterAuthSession;
6
8
 
7
- export class AuthContextService {
8
- private static SINGLETON: AuthContextService | undefined;
9
- public static create(): AuthContextService {
10
- if (AuthContextService.SINGLETON == null) {
11
- AuthContextService.SINGLETON = new AuthContextService();
12
- }
13
-
14
- return AuthContextService.SINGLETON;
15
- }
16
-
17
- private authAsyncLocalStorage = new AsyncLocalStorage<LocalStorage>();
18
-
19
- private constructor() {}
20
-
21
- public withSession<TReturn>(
22
- session: AuthSession,
23
- callback: () => TReturn,
24
- ): TReturn {
25
- // Set user email on the active OpenTelemetry span
26
- const activeSpan = trace.getActiveSpan();
27
- if (
28
- activeSpan != null &&
29
- session.user.email != null &&
30
- session.user.id != null
31
- ) {
32
- activeSpan.setAttribute("user.email", session.user.email);
33
- activeSpan.setAttribute("user.id", session.user.id);
34
- }
35
-
36
- return this.authAsyncLocalStorage.run<TReturn>({ session }, () =>
37
- callback(),
38
- );
39
- }
40
-
41
- public getSession(): AuthSession | undefined {
42
- const context = this.authAsyncLocalStorage.getStore();
43
- if (context == null) {
44
- return undefined;
45
- }
46
-
47
- const { session } = context;
48
- return session;
49
- }
50
-
51
- public getSessionOrThrow(): AuthSession {
52
- const session = this.getSession();
53
- if (session == null) {
54
- throw new TRPCError({ code: "UNAUTHORIZED" });
55
- }
56
-
57
- return session;
58
- }
59
- }
60
-
61
- interface LocalStorage {
62
- session: AuthSession;
63
- }
9
+ export const AuthContextService =
10
+ createAuthContextServiceFactory<AuthSession>();
11
+ export type AuthContextService = PerceptaAuthContextService<AuthSession>;
@@ -1,5 +1,9 @@
1
- import { validationMiddleware } from "@inngest/middleware-validation";
2
- import { EventSchemas, Inngest } from "inngest";
1
+ import {
2
+ createPerceptaInngestServiceFactory,
3
+ type PerceptaInngest,
4
+ type PerceptaInngestService,
5
+ } from "@percepta/inngest";
6
+ import { EventSchemas } from "inngest";
3
7
  import { getEnvConfig } from "../../config/getEnvConfig";
4
8
  import { AppEvents } from "./events/AppEvents";
5
9
 
@@ -8,64 +12,12 @@ export interface PollOpts {
8
12
  delayMs: number;
9
13
  }
10
14
 
11
- export interface AppInngest extends AppInngestInternal {
12
- // Externally, we should never call createFunction on the client interface.
13
- createFunction: never;
14
- }
15
- type AppInngestInternal = Inngest<{
16
- id: string;
17
- schemas: typeof EVENT_SCHEMAS;
18
- }>;
19
-
20
- export class InngestService {
21
- private static SINGLETON: InngestService | undefined;
22
- public static create(): InngestService {
23
- if (InngestService.SINGLETON == null) {
24
- InngestService.SINGLETON = new InngestService();
25
- }
26
- return InngestService.SINGLETON;
27
- }
28
-
29
- private _signingKey: string | undefined;
30
- private inngest: AppInngestInternal;
31
-
32
- private constructor() {
33
- const {
34
- INNGEST_BASE_URL: baseUrl,
35
- INNGEST_SIGNING_KEY: signingKey,
36
- INNGEST_EVENT_KEY: eventKey,
37
- } = getEnvConfig();
38
-
39
- if (baseUrl == null) {
40
- throw new Error("INNGEST_BASE_URL is not set.");
41
- }
42
-
43
- this._signingKey = signingKey;
44
- this.inngest = new Inngest({
45
- id: "__APP_NAME__",
46
- eventKey,
47
- baseUrl,
48
- schemas: EVENT_SCHEMAS,
49
- middleware: [validationMiddleware()],
50
- });
51
- }
52
-
53
- public get client(): AppInngest {
54
- return this.inngest as never;
55
- }
56
-
57
- public get signingKey(): string | undefined {
58
- return this._signingKey;
59
- }
60
-
61
- public createFunction: Inngest.CreateFunction<AppInngest> = (
62
- options,
63
- trigger,
64
- handler,
65
- ) => {
66
- // TODO: Add context plumbing here.
67
- return this.inngest.createFunction(options, trigger, handler) as never;
68
- };
69
- }
70
-
71
15
  const EVENT_SCHEMAS = new EventSchemas().fromSchema(AppEvents);
16
+
17
+ export type AppInngest = PerceptaInngest<typeof EVENT_SCHEMAS>;
18
+ export const InngestService = createPerceptaInngestServiceFactory({
19
+ appId: "__APP_NAME__",
20
+ getEnv: getEnvConfig,
21
+ schemas: EVENT_SCHEMAS,
22
+ });
23
+ export type InngestService = PerceptaInngestService<typeof EVENT_SCHEMAS>;
@@ -1,80 +1,12 @@
1
- import { Langfuse } from "langfuse";
1
+ import {
2
+ createPerceptaLangfuseServiceFactory,
3
+ type PerceptaLangfuseService,
4
+ } from "@percepta/ai";
2
5
  import { getEnvConfig } from "../../config/getEnvConfig";
3
6
  import { getLogger } from "../logger/AppLogger";
4
7
 
5
- export class LangfuseService {
6
- private static SINGLETON: LangfuseService | undefined;
7
-
8
- public static create(): LangfuseService {
9
- if (LangfuseService.SINGLETON == null) {
10
- LangfuseService.SINGLETON = new LangfuseService();
11
- }
12
- return LangfuseService.SINGLETON;
13
- }
14
-
15
- private readonly client: Langfuse | undefined;
16
-
17
- private constructor() {
18
- const config = getEnvConfig();
19
- const baseUrl = config.LANGFUSE_BASE_URL;
20
- const publicKey = config.LANGFUSE_PUBLIC_KEY;
21
- const secretKey = config.LANGFUSE_SECRET_KEY;
22
-
23
- if (baseUrl && publicKey && secretKey) {
24
- this.client = new Langfuse({
25
- baseUrl,
26
- publicKey,
27
- secretKey,
28
- });
29
- }
30
- }
31
-
32
- /**
33
- * Check if Langfuse is configured.
34
- */
35
- public isConfigured(): boolean {
36
- return this.client != null;
37
- }
38
-
39
- /**
40
- * Update a Langfuse trace with metadata.
41
- * Uses trace.update() which merges with existing metadata.
42
- *
43
- * @param traceId - The trace ID (same as documentPipelineRuns.id)
44
- * @param metadata - The metadata to save (must be JSON-serializable)
45
- */
46
- public async updateTraceMetadata<T extends object>(
47
- traceId: string,
48
- metadata: T,
49
- ): Promise<void> {
50
- if (!this.isConfigured()) {
51
- getLogger().warn(
52
- { safe: { traceId } },
53
- "[LangfuseService] Langfuse not configured, skipping trace update",
54
- );
55
- return;
56
- }
57
-
58
- // Capture client after isConfigured() guard for type narrowing
59
- const client = this.client!;
60
-
61
- try {
62
- // Attach to existing trace by ID and update metadata
63
- const trace = client.trace({ id: traceId });
64
- trace.update({ metadata });
65
-
66
- // Flush to ensure the update is sent
67
- await client.flushAsync();
68
-
69
- getLogger().info(
70
- { safe: { traceId } },
71
- "[LangfuseService] Updated trace with metadata",
72
- );
73
- } catch (error) {
74
- getLogger().error(
75
- { safe: { traceId }, unsafe: { error: String(error) } },
76
- "[LangfuseService] Failed to update trace metadata",
77
- );
78
- }
79
- }
80
- }
8
+ export const LangfuseService = createPerceptaLangfuseServiceFactory({
9
+ getConfig: getEnvConfig,
10
+ getLogger,
11
+ });
12
+ export type LangfuseService = PerceptaLangfuseService;