@igstack/app-catalog-backend-core 0.4.0-alpha-20260814233321 → 0.4.0-alpha-20260817192135

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.
@@ -1,6 +1,59 @@
1
1
  import { PrismaClient } from "../generated/prisma/client.mjs";
2
+ import pg from "pg";
2
3
 
3
4
  //#region src/db/client.d.ts
5
+ type PrismaLogOption = NonNullable<ConstructorParameters<typeof PrismaClient>[0]>['log'];
6
+ /**
7
+ * The schema a deployment should be reading and writing.
8
+ *
9
+ * DB_SCHEMA is set per deployment, so it outranks a schema baked into the app
10
+ * config - otherwise a config that names `public` would pin a preview env back
11
+ * to the shared tables it was meant to be isolated from.
12
+ */
13
+ declare function resolveDbSchema(configuredSchema?: string): string | undefined;
14
+ /**
15
+ * `search_path` startup options for a schema-isolated deployment.
16
+ *
17
+ * node-postgres ignores Prisma's `?schema=` URL parameter, so a preview env's
18
+ * schema has to be injected as a connection option instead. This half covers
19
+ * raw SQL and the migrations; Prisma's own queries need the adapter half too,
20
+ * which is why every core connection goes through createCorePrismaClient.
21
+ */
22
+ declare function buildPgSchemaOptions(configuredSchema?: string): {
23
+ options?: string;
24
+ };
25
+ /**
26
+ * The one way to open a connection to the core database.
27
+ *
28
+ * Schema isolation takes two halves and both live here:
29
+ * - the pool's `search_path`, which raw SQL and the migrations follow;
30
+ * - the adapter's `schema`, which is what Prisma qualifies its table names
31
+ * with. A client built without it emits `"public"."DbResource"` however
32
+ * correct the pool's `current_schema()` is, so a preview env silently reads
33
+ * and overwrites the shared canonical tables.
34
+ *
35
+ * A pool built outside this factory gets neither half, so don't build one.
36
+ */
37
+ declare function createCorePrismaClient(params: {
38
+ connectionString: string;
39
+ configuredSchema?: string;
40
+ log?: PrismaLogOption;
41
+ }): {
42
+ client: PrismaClient;
43
+ pool: pg.Pool;
44
+ schema: string | undefined;
45
+ };
46
+ /**
47
+ * Fails a deployment that believes it is schema-isolated but is not.
48
+ *
49
+ * Speaks up whenever a schema was resolved at all - the same resolution feeds the
50
+ * pool's `search_path`, so the expectation and the connection can never disagree
51
+ * by construction, and a deployment that configures isolation without setting
52
+ * DB_SCHEMA is checked too. The case worth catching is a missing schema: Postgres
53
+ * silently skips a `search_path` entry that does not exist and falls through to
54
+ * `public`, which is the shared-catalog corruption this is here to prevent.
55
+ */
56
+ declare function verifyDbSchema(poolToCheck: pg.Pool, configuredSchema?: string): Promise<void>;
4
57
  /**
5
58
  * Gets the internal Prisma client instance.
6
59
  * Creates one if it doesn't exist.
@@ -22,5 +75,5 @@ declare function connectDb(): Promise<void>;
22
75
  */
23
76
  declare function disconnectDb(): Promise<void>;
24
77
  //#endregion
25
- export { connectDb, disconnectDb, getDbClient, setDbClient };
78
+ export { buildPgSchemaOptions, connectDb, createCorePrismaClient, disconnectDb, getDbClient, resolveDbSchema, setDbClient, verifyDbSchema };
26
79
  //# sourceMappingURL=client.d.mts.map
@@ -1 +1 @@
1
- {"version":3,"file":"client.d.mts","names":[],"sources":["../../src/db/client.ts"],"mappings":";;;;AAuEA;;;iBAzCgB,WAAA,CAAA,GAAe,YAAA;;AAkD/B;;;iBAjBgB,WAAA,CAAY,MAAA,EAAQ,YAAA;;;;;iBAQd,SAAA,CAAA,GAAa,OAAA;;;;;iBASb,YAAA,CAAA,GAAgB,OAAA"}
1
+ {"version":3,"file":"client.d.mts","names":[],"sources":["../../src/db/client.ts"],"mappings":";;;;KAQK,eAAA,GAAkB,WAAA,CACrB,qBAAA,QAA6B,YAAA;;AAPZ;;;;;;iBAiBH,eAAA,CAAgB,gBAAA;;;;;;;AAAhC;;iBAYgB,oBAAA,CAAqB,gBAAA;EACnC,OAAA;AAAA;AADF;;;;;AAmBA;;;;;;;AAnBA,iBAmBgB,sBAAA,CAAuB,MAAA;EACrC,gBAAA;EACA,gBAAA;EACA,GAAA,GAAM,eAAA;AAAA;EACF,MAAA,EAAQ,YAAA;EAAc,IAAA,EAAM,EAAA,CAAG,IAAA;EAAM,MAAA;AAAA;;;;;;AA8C3C;;;;;iBAAsB,cAAA,CACpB,WAAA,EAAa,EAAA,CAAG,IAAA,EAChB,gBAAA,YACC,OAAA;;;;;iBAuBa,WAAA,CAAA,GAAe,YAAA;AAA/B;;;;AAAA,iBAqBgB,WAAA,CAAY,MAAA,EAAQ,YAAA;AAApC;;;;AAAA,iBAQsB,SAAA,CAAA,GAAa,OAAA;AAAnC;;;;AAAA,iBAUsB,YAAA,CAAA,GAAgB,OAAA"}
@@ -7,18 +7,89 @@ import pg from "pg";
7
7
  let prismaClient = null;
8
8
  let pool = null;
9
9
  /**
10
+ * The schema a deployment should be reading and writing.
11
+ *
12
+ * DB_SCHEMA is set per deployment, so it outranks a schema baked into the app
13
+ * config - otherwise a config that names `public` would pin a preview env back
14
+ * to the shared tables it was meant to be isolated from.
15
+ */
16
+ function resolveDbSchema(configuredSchema) {
17
+ return process.env.DB_SCHEMA || configuredSchema || void 0;
18
+ }
19
+ /**
10
20
  * `search_path` startup options for a schema-isolated deployment.
11
21
  *
12
22
  * node-postgres ignores Prisma's `?schema=` URL parameter, so a preview env's
13
- * schema has to be injected as a connection option instead. Every pool that
14
- * talks to the core database must go through here - one that skips it silently
15
- * lands in `public` and reads the shared canonical tables.
23
+ * schema has to be injected as a connection option instead. This half covers
24
+ * raw SQL and the migrations; Prisma's own queries need the adapter half too,
25
+ * which is why every core connection goes through createCorePrismaClient.
16
26
  */
17
27
  function buildPgSchemaOptions(configuredSchema) {
18
- const schema = process.env.DB_SCHEMA || configuredSchema;
28
+ const schema = resolveDbSchema(configuredSchema);
19
29
  return schema ? { options: `-c search_path=${schema}` } : {};
20
30
  }
21
31
  /**
32
+ * The one way to open a connection to the core database.
33
+ *
34
+ * Schema isolation takes two halves and both live here:
35
+ * - the pool's `search_path`, which raw SQL and the migrations follow;
36
+ * - the adapter's `schema`, which is what Prisma qualifies its table names
37
+ * with. A client built without it emits `"public"."DbResource"` however
38
+ * correct the pool's `current_schema()` is, so a preview env silently reads
39
+ * and overwrites the shared canonical tables.
40
+ *
41
+ * A pool built outside this factory gets neither half, so don't build one.
42
+ */
43
+ function createCorePrismaClient(params) {
44
+ const schema = resolveDbSchema(params.configuredSchema);
45
+ const ssl = buildPgSslConfig();
46
+ const newPool = new pg.Pool({
47
+ connectionString: params.connectionString,
48
+ ...ssl === void 0 ? {} : { ssl },
49
+ ...buildPgSchemaOptions(params.configuredSchema)
50
+ });
51
+ return {
52
+ client: new PrismaClient({
53
+ adapter: new PrismaPg(newPool, { schema }),
54
+ ...params.log === void 0 ? {} : { log: params.log }
55
+ }),
56
+ pool: newPool,
57
+ schema
58
+ };
59
+ }
60
+ /** Postgres NAMEDATALEN - 1: identifiers are truncated to this many bytes. */
61
+ const PG_MAX_IDENTIFIER_BYTES = 63;
62
+ /**
63
+ * How Postgres will have stored an identifier this long.
64
+ *
65
+ * A schema name over the limit is truncated identically wherever it appears - at
66
+ * CREATE SCHEMA and in `search_path` alike - so the isolation still holds and
67
+ * current_schema() merely reports the shortened name. Comparing raw strings would
68
+ * fail a correctly isolated deployment, so compare what Postgres kept.
69
+ */
70
+ function truncateIdentifier(name) {
71
+ const bytes = Buffer.from(name, "utf8");
72
+ if (bytes.length <= PG_MAX_IDENTIFIER_BYTES) return name;
73
+ return bytes.subarray(0, PG_MAX_IDENTIFIER_BYTES).toString("utf8");
74
+ }
75
+ /**
76
+ * Fails a deployment that believes it is schema-isolated but is not.
77
+ *
78
+ * Speaks up whenever a schema was resolved at all - the same resolution feeds the
79
+ * pool's `search_path`, so the expectation and the connection can never disagree
80
+ * by construction, and a deployment that configures isolation without setting
81
+ * DB_SCHEMA is checked too. The case worth catching is a missing schema: Postgres
82
+ * silently skips a `search_path` entry that does not exist and falls through to
83
+ * `public`, which is the shared-catalog corruption this is here to prevent.
84
+ */
85
+ async function verifyDbSchema(poolToCheck, configuredSchema) {
86
+ const resolved = resolveDbSchema(configuredSchema);
87
+ const actual = (await poolToCheck.query("SELECT current_schema() AS schema")).rows[0]?.schema ?? null;
88
+ console.log(`[db] core schema: resolved=${resolved ?? "(none)"} current_schema=${actual ?? "(none)"}`);
89
+ if (!resolved) return;
90
+ if (actual !== truncateIdentifier(resolved)) throw new Error(`The configured schema is "${resolved}" but the database resolved current_schema() to "${actual}". The schema is most likely missing, so every query would fall through to the shared catalog tables. Migrate this schema before starting the app.`);
91
+ }
92
+ /**
22
93
  * Gets the internal Prisma client instance.
23
94
  * Creates one if it doesn't exist.
24
95
  */
@@ -26,13 +97,9 @@ function getDbClient() {
26
97
  if (!prismaClient) {
27
98
  const databaseUrl = process.env.AC_CORE_DATABASE_URL;
28
99
  if (!databaseUrl) throw new Error("PrismaClient not initialized. You must call createAcMiddleware() before using database functions, or set AC_CORE_DATABASE_URL environment variable for standalone usage.");
29
- const ssl = buildPgSslConfig();
30
- pool = new pg.Pool({
31
- connectionString: databaseUrl,
32
- ...ssl === void 0 ? {} : { ssl },
33
- ...buildPgSchemaOptions()
34
- });
35
- prismaClient = new PrismaClient({ adapter: new PrismaPg(pool) });
100
+ const created = createCorePrismaClient({ connectionString: databaseUrl });
101
+ pool = created.pool;
102
+ prismaClient = created.client;
36
103
  }
37
104
  return prismaClient;
38
105
  }
@@ -49,6 +116,7 @@ function setDbClient(client) {
49
116
  */
50
117
  async function connectDb() {
51
118
  await getDbClient().$connect();
119
+ if (pool) await verifyDbSchema(pool);
52
120
  }
53
121
  /**
54
122
  * Disconnects from the database.
@@ -66,5 +134,5 @@ async function disconnectDb() {
66
134
  }
67
135
 
68
136
  //#endregion
69
- export { buildPgSchemaOptions, connectDb, disconnectDb, getDbClient, setDbClient };
137
+ export { buildPgSchemaOptions, connectDb, createCorePrismaClient, disconnectDb, getDbClient, resolveDbSchema, setDbClient, verifyDbSchema };
70
138
  //# sourceMappingURL=client.mjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"client.mjs","names":[],"sources":["../../src/db/client.ts"],"sourcesContent":["import { PrismaClient } from '../generated/prisma/client'\nimport { PrismaPg } from '@prisma/adapter-pg'\nimport pg from 'pg'\nimport { buildPgSslConfig } from './sslConfig'\n\nlet prismaClient: PrismaClient | null = null\nlet pool: pg.Pool | null = null\n\n/**\n * `search_path` startup options for a schema-isolated deployment.\n *\n * node-postgres ignores Prisma's `?schema=` URL parameter, so a preview env's\n * schema has to be injected as a connection option instead. Every pool that\n * talks to the core database must go through here - one that skips it silently\n * lands in `public` and reads the shared canonical tables.\n */\nexport function buildPgSchemaOptions(configuredSchema?: string): {\n options?: string\n} {\n // DB_SCHEMA is set per deployment, so it outranks a schema baked into the app\n // config - otherwise a config that names `public` would pin a preview env back\n // to the shared tables it was meant to be isolated from.\n const schema = process.env.DB_SCHEMA || configuredSchema\n return schema ? { options: `-c search_path=${schema}` } : {}\n}\n\n/**\n * Gets the internal Prisma client instance.\n * Creates one if it doesn't exist.\n */\nexport function getDbClient(): PrismaClient {\n if (!prismaClient) {\n const databaseUrl = process.env.AC_CORE_DATABASE_URL\n if (!databaseUrl) {\n throw new Error(\n 'PrismaClient not initialized. You must call createAcMiddleware() before using database functions, ' +\n 'or set AC_CORE_DATABASE_URL environment variable for standalone usage.',\n )\n }\n\n // Prisma 7 with adapter: Create pg pool and wrap with adapter.\n // SSL comes from PGSSLMODE/PGSSLROOTCERT via our helper (node-postgres\n // doesn't honor those correctly for a connection-string pool — see\n // buildPgSslConfig).\n //\n // Schema isolation for preview envs (#44): see buildPgSchemaOptions.\n const ssl = buildPgSslConfig()\n pool = new pg.Pool({\n connectionString: databaseUrl,\n ...(ssl === undefined ? {} : { ssl }),\n ...buildPgSchemaOptions(),\n })\n const adapter = new PrismaPg(pool)\n\n prismaClient = new PrismaClient({ adapter })\n }\n return prismaClient\n}\n\n/**\n * Sets the internal Prisma client instance.\n * Used by middleware to bridge with existing getDbClient() usage.\n */\nexport function setDbClient(client: PrismaClient): void {\n prismaClient = client\n}\n\n/**\n * Connects to the database.\n * Call this before performing database operations.\n */\nexport async function connectDb(): Promise<void> {\n const client = getDbClient()\n await client.$connect()\n}\n\n/**\n * Disconnects from the database.\n * Call this when done with database operations (e.g., in scripts).\n */\nexport async function disconnectDb(): Promise<void> {\n if (prismaClient) {\n await prismaClient.$disconnect()\n prismaClient = null\n }\n if (pool) {\n await pool.end()\n pool = null\n }\n}\n"],"mappings":";;;;;;AAKA,IAAI,eAAoC;AACxC,IAAI,OAAuB;;;;;;;;;AAU3B,SAAgB,qBAAqB,kBAEnC;CAIA,MAAM,SAAS,QAAQ,IAAI,aAAa;AACxC,QAAO,SAAS,EAAE,SAAS,kBAAkB,UAAU,GAAG,EAAE;;;;;;AAO9D,SAAgB,cAA4B;AAC1C,KAAI,CAAC,cAAc;EACjB,MAAM,cAAc,QAAQ,IAAI;AAChC,MAAI,CAAC,YACH,OAAM,IAAI,MACR,2KAED;EASH,MAAM,MAAM,kBAAkB;AAC9B,SAAO,IAAI,GAAG,KAAK;GACjB,kBAAkB;GAClB,GAAI,QAAQ,SAAY,EAAE,GAAG,EAAE,KAAK;GACpC,GAAG,sBAAsB;GAC1B,CAAC;AAGF,iBAAe,IAAI,aAAa,EAAE,SAFlB,IAAI,SAAS,KAAK,EAES,CAAC;;AAE9C,QAAO;;;;;;AAOT,SAAgB,YAAY,QAA4B;AACtD,gBAAe;;;;;;AAOjB,eAAsB,YAA2B;AAE/C,OADe,aAAa,CACf,UAAU;;;;;;AAOzB,eAAsB,eAA8B;AAClD,KAAI,cAAc;AAChB,QAAM,aAAa,aAAa;AAChC,iBAAe;;AAEjB,KAAI,MAAM;AACR,QAAM,KAAK,KAAK;AAChB,SAAO"}
1
+ {"version":3,"file":"client.mjs","names":[],"sources":["../../src/db/client.ts"],"sourcesContent":["import { PrismaClient } from '../generated/prisma/client'\nimport { PrismaPg } from '@prisma/adapter-pg'\nimport pg from 'pg'\nimport { buildPgSslConfig } from './sslConfig'\n\nlet prismaClient: PrismaClient | null = null\nlet pool: pg.Pool | null = null\n\ntype PrismaLogOption = NonNullable<\n ConstructorParameters<typeof PrismaClient>[0]\n>['log']\n\n/**\n * The schema a deployment should be reading and writing.\n *\n * DB_SCHEMA is set per deployment, so it outranks a schema baked into the app\n * config - otherwise a config that names `public` would pin a preview env back\n * to the shared tables it was meant to be isolated from.\n */\nexport function resolveDbSchema(configuredSchema?: string): string | undefined {\n return process.env.DB_SCHEMA || configuredSchema || undefined\n}\n\n/**\n * `search_path` startup options for a schema-isolated deployment.\n *\n * node-postgres ignores Prisma's `?schema=` URL parameter, so a preview env's\n * schema has to be injected as a connection option instead. This half covers\n * raw SQL and the migrations; Prisma's own queries need the adapter half too,\n * which is why every core connection goes through createCorePrismaClient.\n */\nexport function buildPgSchemaOptions(configuredSchema?: string): {\n options?: string\n} {\n const schema = resolveDbSchema(configuredSchema)\n return schema ? { options: `-c search_path=${schema}` } : {}\n}\n\n/**\n * The one way to open a connection to the core database.\n *\n * Schema isolation takes two halves and both live here:\n * - the pool's `search_path`, which raw SQL and the migrations follow;\n * - the adapter's `schema`, which is what Prisma qualifies its table names\n * with. A client built without it emits `\"public\".\"DbResource\"` however\n * correct the pool's `current_schema()` is, so a preview env silently reads\n * and overwrites the shared canonical tables.\n *\n * A pool built outside this factory gets neither half, so don't build one.\n */\nexport function createCorePrismaClient(params: {\n connectionString: string\n configuredSchema?: string\n log?: PrismaLogOption\n}): { client: PrismaClient; pool: pg.Pool; schema: string | undefined } {\n const schema = resolveDbSchema(params.configuredSchema)\n // SSL comes from PGSSLMODE/PGSSLROOTCERT via our helper (node-postgres\n // doesn't honor those correctly for a connection-string pool - see\n // buildPgSslConfig).\n const ssl = buildPgSslConfig()\n const newPool = new pg.Pool({\n connectionString: params.connectionString,\n ...(ssl === undefined ? {} : { ssl }),\n ...buildPgSchemaOptions(params.configuredSchema),\n })\n const adapter = new PrismaPg(newPool, { schema })\n const client = new PrismaClient({\n adapter,\n ...(params.log === undefined ? {} : { log: params.log }),\n })\n return { client, pool: newPool, schema }\n}\n\n/** Postgres NAMEDATALEN - 1: identifiers are truncated to this many bytes. */\nconst PG_MAX_IDENTIFIER_BYTES = 63\n\n/**\n * How Postgres will have stored an identifier this long.\n *\n * A schema name over the limit is truncated identically wherever it appears - at\n * CREATE SCHEMA and in `search_path` alike - so the isolation still holds and\n * current_schema() merely reports the shortened name. Comparing raw strings would\n * fail a correctly isolated deployment, so compare what Postgres kept.\n */\nfunction truncateIdentifier(name: string): string {\n const bytes = Buffer.from(name, 'utf8')\n if (bytes.length <= PG_MAX_IDENTIFIER_BYTES) return name\n return bytes.subarray(0, PG_MAX_IDENTIFIER_BYTES).toString('utf8')\n}\n\n/**\n * Fails a deployment that believes it is schema-isolated but is not.\n *\n * Speaks up whenever a schema was resolved at all - the same resolution feeds the\n * pool's `search_path`, so the expectation and the connection can never disagree\n * by construction, and a deployment that configures isolation without setting\n * DB_SCHEMA is checked too. The case worth catching is a missing schema: Postgres\n * silently skips a `search_path` entry that does not exist and falls through to\n * `public`, which is the shared-catalog corruption this is here to prevent.\n */\nexport async function verifyDbSchema(\n poolToCheck: pg.Pool,\n configuredSchema?: string,\n): Promise<void> {\n const resolved = resolveDbSchema(configuredSchema)\n const result = await poolToCheck.query<{ schema: string | null }>(\n 'SELECT current_schema() AS schema',\n )\n const actual = result.rows[0]?.schema ?? null\n console.log(\n `[db] core schema: resolved=${resolved ?? '(none)'} current_schema=${actual ?? '(none)'}`,\n )\n if (!resolved) return\n if (actual !== truncateIdentifier(resolved)) {\n throw new Error(\n `The configured schema is \"${resolved}\" but the database resolved current_schema() to ` +\n `\"${actual}\". The schema is most likely missing, so every query would fall through to ` +\n `the shared catalog tables. Migrate this schema before starting the app.`,\n )\n }\n}\n\n/**\n * Gets the internal Prisma client instance.\n * Creates one if it doesn't exist.\n */\nexport function getDbClient(): PrismaClient {\n if (!prismaClient) {\n const databaseUrl = process.env.AC_CORE_DATABASE_URL\n if (!databaseUrl) {\n throw new Error(\n 'PrismaClient not initialized. You must call createAcMiddleware() before using database functions, ' +\n 'or set AC_CORE_DATABASE_URL environment variable for standalone usage.',\n )\n }\n\n const created = createCorePrismaClient({ connectionString: databaseUrl })\n pool = created.pool\n prismaClient = created.client\n }\n return prismaClient\n}\n\n/**\n * Sets the internal Prisma client instance.\n * Used by middleware to bridge with existing getDbClient() usage.\n */\nexport function setDbClient(client: PrismaClient): void {\n prismaClient = client\n}\n\n/**\n * Connects to the database.\n * Call this before performing database operations.\n */\nexport async function connectDb(): Promise<void> {\n const client = getDbClient()\n await client.$connect()\n if (pool) await verifyDbSchema(pool)\n}\n\n/**\n * Disconnects from the database.\n * Call this when done with database operations (e.g., in scripts).\n */\nexport async function disconnectDb(): Promise<void> {\n if (prismaClient) {\n await prismaClient.$disconnect()\n prismaClient = null\n }\n if (pool) {\n await pool.end()\n pool = null\n }\n}\n"],"mappings":";;;;;;AAKA,IAAI,eAAoC;AACxC,IAAI,OAAuB;;;;;;;;AAa3B,SAAgB,gBAAgB,kBAA+C;AAC7E,QAAO,QAAQ,IAAI,aAAa,oBAAoB;;;;;;;;;;AAWtD,SAAgB,qBAAqB,kBAEnC;CACA,MAAM,SAAS,gBAAgB,iBAAiB;AAChD,QAAO,SAAS,EAAE,SAAS,kBAAkB,UAAU,GAAG,EAAE;;;;;;;;;;;;;;AAe9D,SAAgB,uBAAuB,QAIiC;CACtE,MAAM,SAAS,gBAAgB,OAAO,iBAAiB;CAIvD,MAAM,MAAM,kBAAkB;CAC9B,MAAM,UAAU,IAAI,GAAG,KAAK;EAC1B,kBAAkB,OAAO;EACzB,GAAI,QAAQ,SAAY,EAAE,GAAG,EAAE,KAAK;EACpC,GAAG,qBAAqB,OAAO,iBAAiB;EACjD,CAAC;AAMF,QAAO;EAAE,QAJM,IAAI,aAAa;GAC9B,SAFc,IAAI,SAAS,SAAS,EAAE,QAAQ,CAAC;GAG/C,GAAI,OAAO,QAAQ,SAAY,EAAE,GAAG,EAAE,KAAK,OAAO,KAAK;GACxD,CAAC;EACe,MAAM;EAAS;EAAQ;;;AAI1C,MAAM,0BAA0B;;;;;;;;;AAUhC,SAAS,mBAAmB,MAAsB;CAChD,MAAM,QAAQ,OAAO,KAAK,MAAM,OAAO;AACvC,KAAI,MAAM,UAAU,wBAAyB,QAAO;AACpD,QAAO,MAAM,SAAS,GAAG,wBAAwB,CAAC,SAAS,OAAO;;;;;;;;;;;;AAapE,eAAsB,eACpB,aACA,kBACe;CACf,MAAM,WAAW,gBAAgB,iBAAiB;CAIlD,MAAM,UAHS,MAAM,YAAY,MAC/B,oCACD,EACqB,KAAK,IAAI,UAAU;AACzC,SAAQ,IACN,8BAA8B,YAAY,SAAS,kBAAkB,UAAU,WAChF;AACD,KAAI,CAAC,SAAU;AACf,KAAI,WAAW,mBAAmB,SAAS,CACzC,OAAM,IAAI,MACR,6BAA6B,SAAS,mDAChC,OAAO,oJAEd;;;;;;AAQL,SAAgB,cAA4B;AAC1C,KAAI,CAAC,cAAc;EACjB,MAAM,cAAc,QAAQ,IAAI;AAChC,MAAI,CAAC,YACH,OAAM,IAAI,MACR,2KAED;EAGH,MAAM,UAAU,uBAAuB,EAAE,kBAAkB,aAAa,CAAC;AACzE,SAAO,QAAQ;AACf,iBAAe,QAAQ;;AAEzB,QAAO;;;;;;AAOT,SAAgB,YAAY,QAA4B;AACtD,gBAAe;;;;;;AAOjB,eAAsB,YAA2B;AAE/C,OADe,aAAa,CACf,UAAU;AACvB,KAAI,KAAM,OAAM,eAAe,KAAK;;;;;;AAOtC,eAAsB,eAA8B;AAClD,KAAI,cAAc;AAChB,QAAM,aAAa,aAAa;AAChC,iBAAe;;AAEjB,KAAI,MAAM;AACR,QAAM,KAAK,KAAK;AAChB,SAAO"}
@@ -1,4 +1,4 @@
1
- import { connectDb, disconnectDb, getDbClient, setDbClient } from "./client.mjs";
1
+ import { buildPgSchemaOptions, connectDb, createCorePrismaClient, disconnectDb, getDbClient, resolveDbSchema, setDbClient, verifyDbSchema } from "./client.mjs";
2
2
  import { buildPgSslConfig } from "./sslConfig.mjs";
3
3
  import { MakeTFromPrismaModel, ObjectKeys, ScalarFilter, ScalarKeys, TableSyncParamsPrisma, tableSyncPrisma } from "./tableSyncPrismaAdapter.mjs";
4
4
  import { TABLE_SYNC_MAGAZINE, TableSyncMagazine, TableSyncMagazineModelNameKey } from "./tableSyncMagazine.mjs";
package/dist/index.d.mts CHANGED
@@ -24,7 +24,7 @@ import { AssetRestControllerConfig, registerAssetRestController } from "./module
24
24
  import { ScreenshotRestControllerConfig, registerScreenshotRestController } from "./modules/assets/screenshotRestController.mjs";
25
25
  import { SyncAssetsConfig, syncAssets } from "./modules/assets/syncAssets.mjs";
26
26
  import { checkAllLinks, printLinkCheckReport } from "./modules/appCatalog/checkLinks.mjs";
27
- import { connectDb, disconnectDb, getDbClient, setDbClient } from "./db/client.mjs";
27
+ import { buildPgSchemaOptions, connectDb, createCorePrismaClient, disconnectDb, getDbClient, resolveDbSchema, setDbClient, verifyDbSchema } from "./db/client.mjs";
28
28
  import { buildPgSslConfig } from "./db/sslConfig.mjs";
29
29
  import { MakeTFromPrismaModel, ObjectKeys, ScalarFilter, ScalarKeys, TableSyncParamsPrisma, tableSyncPrisma } from "./db/tableSyncPrismaAdapter.mjs";
30
30
  import { TABLE_SYNC_MAGAZINE, TableSyncMagazine, TableSyncMagazineModelNameKey } from "./db/tableSyncMagazine.mjs";
@@ -36,4 +36,4 @@ import { injectCustomScripts } from "./middleware/htmlInjection.mjs";
36
36
  import { runLighthouseKeeperDemo } from "./modules/lighthouseKeeper/demo.mjs";
37
37
  import { APP_CATALOG_AI_SYSTEM_PROMPT, createAppCatalogAITools } from "./modules/lighthouseKeeper/tools.mjs";
38
38
  import { getBuildPipelineId, getFrontendPackageVersion, getVersionInfo } from "./utils/versionUtils.mjs";
39
- export { APP_CATALOG_AI_SYSTEM_PROMPT, AcAppIndexed, AcAppPageIndexed, AcAppUiIndexed, AcAppsMeta, type AcAuthConfig, AcBackendAppDto, AcBackendAppInput, AcBackendAppUIBaseInput, AcBackendAppUIInput, AcBackendCredentialInput, AcBackendDataFreshness, AcBackendDataSourceInput, AcBackendDataSourceInputCommon, AcBackendDataSourceInputDb, AcBackendDataSourceInputKafka, AcBackendDataVersion, AcBackendDeployableInput, AcBackendDeployment, AcBackendDeploymentInput, AcBackendEnvironmentInput, AcBackendPageInput, type AcBackendProvider, AcBackendTagDescriptionDataIndexed, AcBackendTagFixedTagValue, AcBackendTagsDescriptionDataIndexed, AcBackendUiDefaultsInput, AcBackendVersionsRequestParams, AcBackendVersionsReturn, AcContextIndexed, type AcDatabaseConfig, AcDatabaseManager, AcEnvIndexed, type AcFeatureToggles, type AcLifecycleHooks, type AcLighthouseKeeperConfig, type AcMcpServerConfig, AcMetaDictionary, type AcMiddlewareOptions, type AcMiddlewareResult, AcResourceIndexed, type AcStaticControllerContract, type AcTrpcContext, type AcTrpcContextOptions, AccessRequest, AppAccessRequest, AppApprovalMethod, AppCatalogCompanySpecificBackend, AppCatalogData, AppCategory, AppRole, AppVersionInfo, ApprovalMethod, ApprovalMethodConfig, ApprovalMethodType, ApprovalUrl, type AssetRestControllerConfig, type AuthRouter, type BetterAuth, CustomConfig, Freshness, Group, GroupingTagDefinition, GroupingTagValue, type IconRestControllerConfig, type MakeTFromPrismaModel, type MiddlewareContext, type ObjectKeys, Person, PersonTeamConfig, type Resource, Role, type ScalarFilter, type ScalarKeys, type ScreenshotRestControllerConfig, ServiceConfig, SourceReference, type SyncAppCatalogResult, type SyncAssetsConfig, TABLE_SYNC_MAGAZINE, type TRPCRouter, type TableSyncMagazine, type TableSyncMagazineModelNameKey, type TableSyncParamsPrisma, Tag, TierVariant, type UpsertIconInput, VersionInfo, buildPgSslConfig, checkAllLinks, connectDb, createAcMiddleware, createAcTrpcContext, createAppCatalogAITools, createAuth, createAuthRouter, createTrpcRouter, disconnectDb, getAssetByName, getBuildPipelineId, getDbClient, getFrontendPackageVersion, getResourcesFromPrisma, getVersionInfo, injectCustomScripts, printLinkCheckReport, registerAssetRestController, registerAuthRoutes, registerIconRestController, registerScreenshotRestController, runLighthouseKeeperDemo, setDbClient, staticControllerContract, syncAppCatalog, syncAssets, tableSyncPrisma, upsertIcon, upsertIcons };
39
+ export { APP_CATALOG_AI_SYSTEM_PROMPT, AcAppIndexed, AcAppPageIndexed, AcAppUiIndexed, AcAppsMeta, type AcAuthConfig, AcBackendAppDto, AcBackendAppInput, AcBackendAppUIBaseInput, AcBackendAppUIInput, AcBackendCredentialInput, AcBackendDataFreshness, AcBackendDataSourceInput, AcBackendDataSourceInputCommon, AcBackendDataSourceInputDb, AcBackendDataSourceInputKafka, AcBackendDataVersion, AcBackendDeployableInput, AcBackendDeployment, AcBackendDeploymentInput, AcBackendEnvironmentInput, AcBackendPageInput, type AcBackendProvider, AcBackendTagDescriptionDataIndexed, AcBackendTagFixedTagValue, AcBackendTagsDescriptionDataIndexed, AcBackendUiDefaultsInput, AcBackendVersionsRequestParams, AcBackendVersionsReturn, AcContextIndexed, type AcDatabaseConfig, AcDatabaseManager, AcEnvIndexed, type AcFeatureToggles, type AcLifecycleHooks, type AcLighthouseKeeperConfig, type AcMcpServerConfig, AcMetaDictionary, type AcMiddlewareOptions, type AcMiddlewareResult, AcResourceIndexed, type AcStaticControllerContract, type AcTrpcContext, type AcTrpcContextOptions, AccessRequest, AppAccessRequest, AppApprovalMethod, AppCatalogCompanySpecificBackend, AppCatalogData, AppCategory, AppRole, AppVersionInfo, ApprovalMethod, ApprovalMethodConfig, ApprovalMethodType, ApprovalUrl, type AssetRestControllerConfig, type AuthRouter, type BetterAuth, CustomConfig, Freshness, Group, GroupingTagDefinition, GroupingTagValue, type IconRestControllerConfig, type MakeTFromPrismaModel, type MiddlewareContext, type ObjectKeys, Person, PersonTeamConfig, type Resource, Role, type ScalarFilter, type ScalarKeys, type ScreenshotRestControllerConfig, ServiceConfig, SourceReference, type SyncAppCatalogResult, type SyncAssetsConfig, TABLE_SYNC_MAGAZINE, type TRPCRouter, type TableSyncMagazine, type TableSyncMagazineModelNameKey, type TableSyncParamsPrisma, Tag, TierVariant, type UpsertIconInput, VersionInfo, buildPgSchemaOptions, buildPgSslConfig, checkAllLinks, connectDb, createAcMiddleware, createAcTrpcContext, createAppCatalogAITools, createAuth, createAuthRouter, createCorePrismaClient, createTrpcRouter, disconnectDb, getAssetByName, getBuildPipelineId, getDbClient, getFrontendPackageVersion, getResourcesFromPrisma, getVersionInfo, injectCustomScripts, printLinkCheckReport, registerAssetRestController, registerAuthRoutes, registerIconRestController, registerScreenshotRestController, resolveDbSchema, runLighthouseKeeperDemo, setDbClient, staticControllerContract, syncAppCatalog, syncAssets, tableSyncPrisma, upsertIcon, upsertIcons, verifyDbSchema };
package/dist/index.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  import { buildPgSslConfig } from "./db/sslConfig.mjs";
2
- import { connectDb, disconnectDb, getDbClient, setDbClient } from "./db/client.mjs";
2
+ import { buildPgSchemaOptions, connectDb, createCorePrismaClient, disconnectDb, getDbClient, resolveDbSchema, setDbClient, verifyDbSchema } from "./db/client.mjs";
3
3
  import { getResourcesFromPrisma } from "./modules/appCatalog/service.mjs";
4
4
  import { createAuthRouter } from "./modules/auth/authRouter.mjs";
5
5
  import { createTrpcRouter } from "./server/controller.mjs";
@@ -23,4 +23,4 @@ import { APP_CATALOG_AI_SYSTEM_PROMPT, createAppCatalogAITools } from "./modules
23
23
  import { runLighthouseKeeperDemo } from "./modules/lighthouseKeeper/demo.mjs";
24
24
  import { getBuildPipelineId, getFrontendPackageVersion, getVersionInfo } from "./utils/versionUtils.mjs";
25
25
 
26
- export { APP_CATALOG_AI_SYSTEM_PROMPT, AcDatabaseManager, TABLE_SYNC_MAGAZINE, buildPgSslConfig, checkAllLinks, connectDb, createAcMiddleware, createAcTrpcContext, createAppCatalogAITools, createAuth, createAuthRouter, createTrpcRouter, disconnectDb, getAssetByName, getBuildPipelineId, getDbClient, getFrontendPackageVersion, getResourcesFromPrisma, getVersionInfo, injectCustomScripts, printLinkCheckReport, registerAssetRestController, registerAuthRoutes, registerIconRestController, registerScreenshotRestController, runLighthouseKeeperDemo, setDbClient, staticControllerContract, syncAppCatalog, syncAssets, tableSyncPrisma, upsertIcon, upsertIcons };
26
+ export { APP_CATALOG_AI_SYSTEM_PROMPT, AcDatabaseManager, TABLE_SYNC_MAGAZINE, buildPgSchemaOptions, buildPgSslConfig, checkAllLinks, connectDb, createAcMiddleware, createAcTrpcContext, createAppCatalogAITools, createAuth, createAuthRouter, createCorePrismaClient, createTrpcRouter, disconnectDb, getAssetByName, getBuildPipelineId, getDbClient, getFrontendPackageVersion, getResourcesFromPrisma, getVersionInfo, injectCustomScripts, printLinkCheckReport, registerAssetRestController, registerAuthRoutes, registerIconRestController, registerScreenshotRestController, resolveDbSchema, runLighthouseKeeperDemo, setDbClient, staticControllerContract, syncAppCatalog, syncAssets, tableSyncPrisma, upsertIcon, upsertIcons, verifyDbSchema };
@@ -18,6 +18,7 @@ declare class AcDatabaseManager {
18
18
  getClient(): PrismaClient;
19
19
  connect(): Promise<void>;
20
20
  disconnect(): Promise<void>;
21
+ private configuredSchema;
21
22
  }
22
23
  //#endregion
23
24
  export { AcDatabaseManager };
@@ -1 +1 @@
1
- {"version":3,"file":"database.d.mts","names":[],"sources":["../../src/middleware/database.ts"],"mappings":";;;;;;AAuBA;;cAAa,iBAAA;EAAA,QACH,MAAA;EAAA,QACA,IAAA;EAAA,QACA,MAAA;cAEI,MAAA,EAAQ,gBAAA;EA+CO;;;;EAvC3B,SAAA,CAAA,GAAa,YAAA;EAkCP,OAAA,CAAA,GAAW,OAAA;EAKX,UAAA,CAAA,GAAc,OAAA;AAAA"}
1
+ {"version":3,"file":"database.d.mts","names":[],"sources":["../../src/middleware/database.ts"],"mappings":";;;;;;AAyBA;;cAAa,iBAAA;EAAA,QACH,MAAA;EAAA,QACA,IAAA;EAAA,QACA,MAAA;cAEI,MAAA,EAAQ,gBAAA;EAoCO;;;;EA5B3B,SAAA,CAAA,GAAa,YAAA;EAsBP,OAAA,CAAA,GAAW,OAAA;EAMX,UAAA,CAAA,GAAc,OAAA;EAAA,QAWZ,gBAAA;AAAA"}
@@ -1,8 +1,4 @@
1
- import { PrismaClient } from "../generated/prisma/client.mjs";
2
- import { buildPgSslConfig } from "../db/sslConfig.mjs";
3
- import { buildPgSchemaOptions, setDbClient } from "../db/client.mjs";
4
- import { PrismaPg } from "@prisma/adapter-pg";
5
- import pg from "pg";
1
+ import { createCorePrismaClient, setDbClient, verifyDbSchema } from "../db/client.mjs";
6
2
 
7
3
  //#region src/middleware/database.ts
8
4
  /**
@@ -29,23 +25,20 @@ var AcDatabaseManager = class {
29
25
  */
30
26
  getClient() {
31
27
  if (!this.client) {
32
- const datasourceUrl = formatConnectionUrl(this.config);
33
- const ssl = buildPgSslConfig();
34
- this.pool = new pg.Pool({
35
- connectionString: datasourceUrl,
36
- ...ssl === void 0 ? {} : { ssl },
37
- ...buildPgSchemaOptions("url" in this.config ? void 0 : this.config.schema)
38
- });
39
- this.client = new PrismaClient({
40
- adapter: new PrismaPg(this.pool),
28
+ const created = createCorePrismaClient({
29
+ connectionString: formatConnectionUrl(this.config),
30
+ configuredSchema: this.configuredSchema(),
41
31
  log: process.env.NODE_ENV === "development" ? ["warn", "error"] : ["warn", "error"]
42
32
  });
33
+ this.pool = created.pool;
34
+ this.client = created.client;
43
35
  setDbClient(this.client);
44
36
  }
45
37
  return this.client;
46
38
  }
47
39
  async connect() {
48
40
  await this.getClient().$connect();
41
+ if (this.pool) await verifyDbSchema(this.pool, this.configuredSchema());
49
42
  }
50
43
  async disconnect() {
51
44
  if (this.client) {
@@ -57,6 +50,9 @@ var AcDatabaseManager = class {
57
50
  this.pool = null;
58
51
  }
59
52
  }
53
+ configuredSchema() {
54
+ return "url" in this.config ? void 0 : this.config.schema;
55
+ }
60
56
  };
61
57
 
62
58
  //#endregion
@@ -1 +1 @@
1
- {"version":3,"file":"database.mjs","names":[],"sources":["../../src/middleware/database.ts"],"sourcesContent":["import { PrismaClient } from '../generated/prisma/client'\nimport { PrismaPg } from '@prisma/adapter-pg'\nimport pg from 'pg'\nimport type { AcDatabaseConfig } from './types'\nimport { buildPgSchemaOptions, setDbClient } from '../db/client'\nimport { buildPgSslConfig } from '../db/sslConfig'\n\n/**\n * Formats a database connection URL from structured config.\n */\nfunction formatConnectionUrl(config: AcDatabaseConfig): string {\n if ('url' in config) {\n return config.url\n }\n\n const { host, port, database, username, password, schema = 'public' } = config\n return `postgresql://${username}:${encodeURIComponent(password)}@${host}:${port}/${database}?schema=${schema}`\n}\n\n/**\n * Internal database manager used by the middleware.\n * Handles connection URL formatting and lifecycle.\n */\nexport class AcDatabaseManager {\n private client: PrismaClient | null = null\n private pool: pg.Pool | null = null\n private config: AcDatabaseConfig\n\n constructor(config: AcDatabaseConfig) {\n this.config = config\n }\n\n /**\n * Get or create the Prisma client instance.\n * Uses lazy initialization for flexibility.\n */\n getClient(): PrismaClient {\n if (!this.client) {\n const datasourceUrl = formatConnectionUrl(this.config)\n\n // Prisma 7 with adapter: Create pg pool and wrap with adapter.\n // SSL from PGSSLMODE/PGSSLROOTCERT via buildPgSslConfig (node-postgres\n // doesn't apply those correctly to a connection-string pool).\n // This pool - not getDbClient()'s - is the one the app ends up using, so\n // the preview-env schema has to be applied here as well. The `?schema=`\n // in datasourceUrl is inert: node-postgres drops unknown URL params.\n const ssl = buildPgSslConfig()\n this.pool = new pg.Pool({\n connectionString: datasourceUrl,\n ...(ssl === undefined ? {} : { ssl }),\n ...buildPgSchemaOptions(\n 'url' in this.config ? undefined : this.config.schema,\n ),\n })\n const adapter = new PrismaPg(this.pool)\n\n this.client = new PrismaClient({\n adapter,\n log:\n process.env.NODE_ENV === 'development'\n ? ['warn', 'error']\n : ['warn', 'error'],\n })\n\n // Bridge with existing backend-core getDbClient() usage\n setDbClient(this.client)\n }\n return this.client\n }\n\n async connect(): Promise<void> {\n const client = this.getClient()\n await client.$connect()\n }\n\n async disconnect(): Promise<void> {\n if (this.client) {\n await this.client.$disconnect()\n this.client = null\n }\n if (this.pool) {\n await this.pool.end()\n this.pool = null\n }\n }\n}\n"],"mappings":";;;;;;;;;;AAUA,SAAS,oBAAoB,QAAkC;AAC7D,KAAI,SAAS,OACX,QAAO,OAAO;CAGhB,MAAM,EAAE,MAAM,MAAM,UAAU,UAAU,UAAU,SAAS,aAAa;AACxE,QAAO,gBAAgB,SAAS,GAAG,mBAAmB,SAAS,CAAC,GAAG,KAAK,GAAG,KAAK,GAAG,SAAS,UAAU;;;;;;AAOxG,IAAa,oBAAb,MAA+B;CAK7B,YAAY,QAA0B;gBAJA;cACP;AAI7B,OAAK,SAAS;;;;;;CAOhB,YAA0B;AACxB,MAAI,CAAC,KAAK,QAAQ;GAChB,MAAM,gBAAgB,oBAAoB,KAAK,OAAO;GAQtD,MAAM,MAAM,kBAAkB;AAC9B,QAAK,OAAO,IAAI,GAAG,KAAK;IACtB,kBAAkB;IAClB,GAAI,QAAQ,SAAY,EAAE,GAAG,EAAE,KAAK;IACpC,GAAG,qBACD,SAAS,KAAK,SAAS,SAAY,KAAK,OAAO,OAChD;IACF,CAAC;AAGF,QAAK,SAAS,IAAI,aAAa;IAC7B,SAHc,IAAI,SAAS,KAAK,KAAK;IAIrC,KACE,QAAQ,IAAI,aAAa,gBACrB,CAAC,QAAQ,QAAQ,GACjB,CAAC,QAAQ,QAAQ;IACxB,CAAC;AAGF,eAAY,KAAK,OAAO;;AAE1B,SAAO,KAAK;;CAGd,MAAM,UAAyB;AAE7B,QADe,KAAK,WAAW,CAClB,UAAU;;CAGzB,MAAM,aAA4B;AAChC,MAAI,KAAK,QAAQ;AACf,SAAM,KAAK,OAAO,aAAa;AAC/B,QAAK,SAAS;;AAEhB,MAAI,KAAK,MAAM;AACb,SAAM,KAAK,KAAK,KAAK;AACrB,QAAK,OAAO"}
1
+ {"version":3,"file":"database.mjs","names":[],"sources":["../../src/middleware/database.ts"],"sourcesContent":["import type { PrismaClient } from '../generated/prisma/client'\nimport type pg from 'pg'\nimport type { AcDatabaseConfig } from './types'\nimport {\n createCorePrismaClient,\n setDbClient,\n verifyDbSchema,\n} from '../db/client'\n\n/**\n * Formats a database connection URL from structured config.\n */\nfunction formatConnectionUrl(config: AcDatabaseConfig): string {\n if ('url' in config) {\n return config.url\n }\n\n const { host, port, database, username, password, schema = 'public' } = config\n return `postgresql://${username}:${encodeURIComponent(password)}@${host}:${port}/${database}?schema=${schema}`\n}\n\n/**\n * Internal database manager used by the middleware.\n * Handles connection URL formatting and lifecycle.\n */\nexport class AcDatabaseManager {\n private client: PrismaClient | null = null\n private pool: pg.Pool | null = null\n private config: AcDatabaseConfig\n\n constructor(config: AcDatabaseConfig) {\n this.config = config\n }\n\n /**\n * Get or create the Prisma client instance.\n * Uses lazy initialization for flexibility.\n */\n getClient(): PrismaClient {\n if (!this.client) {\n // This client - not getDbClient()'s - is the one the app ends up using.\n // The `?schema=` in the url is inert (node-postgres drops unknown URL\n // params), so the schema has to travel via createCorePrismaClient.\n const created = createCorePrismaClient({\n connectionString: formatConnectionUrl(this.config),\n configuredSchema: this.configuredSchema(),\n log:\n process.env.NODE_ENV === 'development'\n ? ['warn', 'error']\n : ['warn', 'error'],\n })\n this.pool = created.pool\n this.client = created.client\n\n // Bridge with existing backend-core getDbClient() usage\n setDbClient(this.client)\n }\n return this.client\n }\n\n async connect(): Promise<void> {\n const client = this.getClient()\n await client.$connect()\n if (this.pool) await verifyDbSchema(this.pool, this.configuredSchema())\n }\n\n async disconnect(): Promise<void> {\n if (this.client) {\n await this.client.$disconnect()\n this.client = null\n }\n if (this.pool) {\n await this.pool.end()\n this.pool = null\n }\n }\n\n private configuredSchema(): string | undefined {\n return 'url' in this.config ? undefined : this.config.schema\n }\n}\n"],"mappings":";;;;;;AAYA,SAAS,oBAAoB,QAAkC;AAC7D,KAAI,SAAS,OACX,QAAO,OAAO;CAGhB,MAAM,EAAE,MAAM,MAAM,UAAU,UAAU,UAAU,SAAS,aAAa;AACxE,QAAO,gBAAgB,SAAS,GAAG,mBAAmB,SAAS,CAAC,GAAG,KAAK,GAAG,KAAK,GAAG,SAAS,UAAU;;;;;;AAOxG,IAAa,oBAAb,MAA+B;CAK7B,YAAY,QAA0B;gBAJA;cACP;AAI7B,OAAK,SAAS;;;;;;CAOhB,YAA0B;AACxB,MAAI,CAAC,KAAK,QAAQ;GAIhB,MAAM,UAAU,uBAAuB;IACrC,kBAAkB,oBAAoB,KAAK,OAAO;IAClD,kBAAkB,KAAK,kBAAkB;IACzC,KACE,QAAQ,IAAI,aAAa,gBACrB,CAAC,QAAQ,QAAQ,GACjB,CAAC,QAAQ,QAAQ;IACxB,CAAC;AACF,QAAK,OAAO,QAAQ;AACpB,QAAK,SAAS,QAAQ;AAGtB,eAAY,KAAK,OAAO;;AAE1B,SAAO,KAAK;;CAGd,MAAM,UAAyB;AAE7B,QADe,KAAK,WAAW,CAClB,UAAU;AACvB,MAAI,KAAK,KAAM,OAAM,eAAe,KAAK,MAAM,KAAK,kBAAkB,CAAC;;CAGzE,MAAM,aAA4B;AAChC,MAAI,KAAK,QAAQ;AACf,SAAM,KAAK,OAAO,aAAa;AAC/B,QAAK,SAAS;;AAEhB,MAAI,KAAK,MAAM;AACb,SAAM,KAAK,KAAK,KAAK;AACrB,QAAK,OAAO;;;CAIhB,AAAQ,mBAAuC;AAC7C,SAAO,SAAS,KAAK,SAAS,SAAY,KAAK,OAAO"}
@@ -1 +1 @@
1
- {"version":3,"file":"tools.d.mts","names":[],"sources":["../../../src/modules/lighthouseKeeper/tools.ts"],"mappings":";;;;;;AA0BA;;iBAAgB,uBAAA,CACd,cAAA,EAAgB,gBAAA,GACf,MAAA,SAAe,IAAA;;;;;cAuEL,4BAAA"}
1
+ {"version":3,"file":"tools.d.mts","names":[],"sources":["../../../src/modules/lighthouseKeeper/tools.ts"],"mappings":";;;;;;AAwBA;;iBAAgB,uBAAA,CACd,cAAA,EAAgB,gBAAA,GACf,MAAA,SAAe,IAAA;;;;;cAwEL,4BAAA"}
@@ -1,6 +1,4 @@
1
- import { PrismaClient } from "../../generated/prisma/client.mjs";
2
- import { PrismaPg } from "@prisma/adapter-pg";
3
- import pg from "pg";
1
+ import { createCorePrismaClient } from "../../db/client.mjs";
4
2
  import { z } from "zod";
5
3
 
6
4
  //#region src/modules/lighthouseKeeper/tools.ts
@@ -14,8 +12,10 @@ function getDatabaseUrl(config) {
14
12
  * These tools allow the AI to fetch and inspect app information.
15
13
  */
16
14
  function createAppCatalogAITools(databaseConfig) {
17
- const databaseUrl = getDatabaseUrl(databaseConfig);
18
- const prisma = new PrismaClient({ adapter: new PrismaPg(new pg.Pool({ connectionString: databaseUrl })) });
15
+ const { client: prisma } = createCorePrismaClient({
16
+ connectionString: getDatabaseUrl(databaseConfig),
17
+ configuredSchema: "url" in databaseConfig ? void 0 : databaseConfig.schema
18
+ });
19
19
  return { getAppCard: {
20
20
  description: "Fetch complete app catalog card information by ID. Returns title, description, tags, access request details, links, and all other app metadata.",
21
21
  inputSchema: z.object({ slug: z.string().describe("The app slug to fetch") }),
@@ -1 +1 @@
1
- {"version":3,"file":"tools.mjs","names":[],"sources":["../../../src/modules/lighthouseKeeper/tools.ts"],"sourcesContent":["import type { Tool } from 'ai'\nimport { z } from 'zod'\nimport { PrismaClient } from '../../generated/prisma/client'\nimport { PrismaPg } from '@prisma/adapter-pg'\nimport pg from 'pg'\nimport type { AcDatabaseConfig } from '../../middleware/types.js'\n\n// ============================================================================\n// Database URL Helper\n// ============================================================================\n\nfunction getDatabaseUrl(config: AcDatabaseConfig): string {\n if ('url' in config) return config.url\n const { host, port, database, username, password, schema } = config\n const schemaParam = schema ? `?schema=${schema}` : ''\n return `postgresql://${username}:${password}@${host}:${port}/${database}${schemaParam}`\n}\n\n// ============================================================================\n// AI Tools for App Catalog\n// ============================================================================\n\n/**\n * Create AI tools for working with app catalog cards.\n * These tools allow the AI to fetch and inspect app information.\n */\nexport function createAppCatalogAITools(\n databaseConfig: AcDatabaseConfig,\n): Record<string, Tool> {\n const databaseUrl = getDatabaseUrl(databaseConfig)\n const pool = new pg.Pool({ connectionString: databaseUrl })\n const adapter = new PrismaPg(pool)\n const prisma = new PrismaClient({ adapter })\n\n const getAppCardSchema = z.object({\n slug: z.string().describe('The app slug to fetch'),\n })\n\n type GetAppCardInput = z.infer<typeof getAppCardSchema>\n\n const getAppCard: Tool<GetAppCardInput, unknown> = {\n description:\n 'Fetch complete app catalog card information by ID. Returns title, description, tags, access request details, links, and all other app metadata.',\n inputSchema: getAppCardSchema,\n execute: async ({ slug }: { slug: string }) => {\n try {\n const app = await prisma.dbResource.findUnique({\n where: { slug },\n include: {\n sourceRefs: true,\n },\n })\n\n if (!app) {\n return { error: `App not found with slug: ${slug}` }\n }\n\n // Return structured card data\n return {\n success: true,\n card: {\n id: app.id,\n slug: app.slug,\n displayName: app.displayName,\n description: app.description,\n teams: app.teams,\n tags: app.tags,\n appUrl: app.appUrl,\n links: app.links,\n iconName: app.iconName,\n sources: app.sourceRefs.map((ref) => ref.url),\n notes: app.notes,\n accessRequest: app.accessRequest,\n deprecated: app.deprecated,\n createdAt: app.createdAt,\n updatedAt: app.updatedAt,\n },\n }\n } catch (error) {\n return {\n error: error instanceof Error ? error.message : 'Failed to fetch app',\n }\n }\n },\n }\n\n return {\n getAppCard,\n }\n}\n\n// ============================================================================\n// System Prompt\n// ============================================================================\n\n/**\n * Default system prompt for AI working with app catalog cards.\n * Use this when configuring lighthouseKeeper for card updates.\n */\nexport const APP_CATALOG_AI_SYSTEM_PROMPT = `You are an assistant that helps update app catalog cards based on information from provided links.\n\nWhen given a link and an app ID:\n1. Use getAppCard to fetch the current card information\n2. Analyze the provided link for relevant information\n3. Output a new card structure with updated information based on the link\n\nReturn the updated card as a structured JSON object matching the card format:\n- displayName: App title\n- description: Full description\n- tags: Array of tags\n- teams: Array of team names\n- appUrl: Main application URL\n- links: Array of {url, title} objects for related links\n- sources: Array of source URLs (documentation, Confluence pages, etc.)\n- accessRequest: Object with approvalMethodSlug, comments, requestPrompt, postApprovalInstructions, roles, approverPersonSlugs, urls\n- notes: Additional notes\n- deprecated: Optional deprecation info with type, replacementSlug, comment\n\nDo NOT save the card to the database. Only output the updated structure.`\n"],"mappings":";;;;;;AAWA,SAAS,eAAe,QAAkC;AACxD,KAAI,SAAS,OAAQ,QAAO,OAAO;CACnC,MAAM,EAAE,MAAM,MAAM,UAAU,UAAU,UAAU,WAAW;AAE7D,QAAO,gBAAgB,SAAS,GAAG,SAAS,GAAG,KAAK,GAAG,KAAK,GAAG,WAD3C,SAAS,WAAW,WAAW;;;;;;AAYrD,SAAgB,wBACd,gBACsB;CACtB,MAAM,cAAc,eAAe,eAAe;CAGlD,MAAM,SAAS,IAAI,aAAa,EAAE,SADlB,IAAI,SADP,IAAI,GAAG,KAAK,EAAE,kBAAkB,aAAa,CAAC,CACzB,EACS,CAAC;AAsD5C,QAAO,EACL,YA/CiD;EACjD,aACE;EACF,aATuB,EAAE,OAAO,EAChC,MAAM,EAAE,QAAQ,CAAC,SAAS,wBAAwB,EACnD,CAAC;EAQA,SAAS,OAAO,EAAE,WAA6B;AAC7C,OAAI;IACF,MAAM,MAAM,MAAM,OAAO,WAAW,WAAW;KAC7C,OAAO,EAAE,MAAM;KACf,SAAS,EACP,YAAY,MACb;KACF,CAAC;AAEF,QAAI,CAAC,IACH,QAAO,EAAE,OAAO,4BAA4B,QAAQ;AAItD,WAAO;KACL,SAAS;KACT,MAAM;MACJ,IAAI,IAAI;MACR,MAAM,IAAI;MACV,aAAa,IAAI;MACjB,aAAa,IAAI;MACjB,OAAO,IAAI;MACX,MAAM,IAAI;MACV,QAAQ,IAAI;MACZ,OAAO,IAAI;MACX,UAAU,IAAI;MACd,SAAS,IAAI,WAAW,KAAK,QAAQ,IAAI,IAAI;MAC7C,OAAO,IAAI;MACX,eAAe,IAAI;MACnB,YAAY,IAAI;MAChB,WAAW,IAAI;MACf,WAAW,IAAI;MAChB;KACF;YACM,OAAO;AACd,WAAO,EACL,OAAO,iBAAiB,QAAQ,MAAM,UAAU,uBACjD;;;EAGN,EAIA;;;;;;AAWH,MAAa,+BAA+B"}
1
+ {"version":3,"file":"tools.mjs","names":[],"sources":["../../../src/modules/lighthouseKeeper/tools.ts"],"sourcesContent":["import type { Tool } from 'ai'\nimport { z } from 'zod'\nimport { createCorePrismaClient } from '../../db/client'\nimport type { AcDatabaseConfig } from '../../middleware/types.js'\n\n// ============================================================================\n// Database URL Helper\n// ============================================================================\n\nfunction getDatabaseUrl(config: AcDatabaseConfig): string {\n if ('url' in config) return config.url\n const { host, port, database, username, password, schema } = config\n const schemaParam = schema ? `?schema=${schema}` : ''\n return `postgresql://${username}:${password}@${host}:${port}/${database}${schemaParam}`\n}\n\n// ============================================================================\n// AI Tools for App Catalog\n// ============================================================================\n\n/**\n * Create AI tools for working with app catalog cards.\n * These tools allow the AI to fetch and inspect app information.\n */\nexport function createAppCatalogAITools(\n databaseConfig: AcDatabaseConfig,\n): Record<string, Tool> {\n const { client: prisma } = createCorePrismaClient({\n connectionString: getDatabaseUrl(databaseConfig),\n configuredSchema:\n 'url' in databaseConfig ? undefined : databaseConfig.schema,\n })\n\n const getAppCardSchema = z.object({\n slug: z.string().describe('The app slug to fetch'),\n })\n\n type GetAppCardInput = z.infer<typeof getAppCardSchema>\n\n const getAppCard: Tool<GetAppCardInput, unknown> = {\n description:\n 'Fetch complete app catalog card information by ID. Returns title, description, tags, access request details, links, and all other app metadata.',\n inputSchema: getAppCardSchema,\n execute: async ({ slug }: { slug: string }) => {\n try {\n const app = await prisma.dbResource.findUnique({\n where: { slug },\n include: {\n sourceRefs: true,\n },\n })\n\n if (!app) {\n return { error: `App not found with slug: ${slug}` }\n }\n\n // Return structured card data\n return {\n success: true,\n card: {\n id: app.id,\n slug: app.slug,\n displayName: app.displayName,\n description: app.description,\n teams: app.teams,\n tags: app.tags,\n appUrl: app.appUrl,\n links: app.links,\n iconName: app.iconName,\n sources: app.sourceRefs.map((ref) => ref.url),\n notes: app.notes,\n accessRequest: app.accessRequest,\n deprecated: app.deprecated,\n createdAt: app.createdAt,\n updatedAt: app.updatedAt,\n },\n }\n } catch (error) {\n return {\n error: error instanceof Error ? error.message : 'Failed to fetch app',\n }\n }\n },\n }\n\n return {\n getAppCard,\n }\n}\n\n// ============================================================================\n// System Prompt\n// ============================================================================\n\n/**\n * Default system prompt for AI working with app catalog cards.\n * Use this when configuring lighthouseKeeper for card updates.\n */\nexport const APP_CATALOG_AI_SYSTEM_PROMPT = `You are an assistant that helps update app catalog cards based on information from provided links.\n\nWhen given a link and an app ID:\n1. Use getAppCard to fetch the current card information\n2. Analyze the provided link for relevant information\n3. Output a new card structure with updated information based on the link\n\nReturn the updated card as a structured JSON object matching the card format:\n- displayName: App title\n- description: Full description\n- tags: Array of tags\n- teams: Array of team names\n- appUrl: Main application URL\n- links: Array of {url, title} objects for related links\n- sources: Array of source URLs (documentation, Confluence pages, etc.)\n- accessRequest: Object with approvalMethodSlug, comments, requestPrompt, postApprovalInstructions, roles, approverPersonSlugs, urls\n- notes: Additional notes\n- deprecated: Optional deprecation info with type, replacementSlug, comment\n\nDo NOT save the card to the database. Only output the updated structure.`\n"],"mappings":";;;;AASA,SAAS,eAAe,QAAkC;AACxD,KAAI,SAAS,OAAQ,QAAO,OAAO;CACnC,MAAM,EAAE,MAAM,MAAM,UAAU,UAAU,UAAU,WAAW;AAE7D,QAAO,gBAAgB,SAAS,GAAG,SAAS,GAAG,KAAK,GAAG,KAAK,GAAG,WAD3C,SAAS,WAAW,WAAW;;;;;;AAYrD,SAAgB,wBACd,gBACsB;CACtB,MAAM,EAAE,QAAQ,WAAW,uBAAuB;EAChD,kBAAkB,eAAe,eAAe;EAChD,kBACE,SAAS,iBAAiB,SAAY,eAAe;EACxD,CAAC;AAsDF,QAAO,EACL,YA/CiD;EACjD,aACE;EACF,aATuB,EAAE,OAAO,EAChC,MAAM,EAAE,QAAQ,CAAC,SAAS,wBAAwB,EACnD,CAAC;EAQA,SAAS,OAAO,EAAE,WAA6B;AAC7C,OAAI;IACF,MAAM,MAAM,MAAM,OAAO,WAAW,WAAW;KAC7C,OAAO,EAAE,MAAM;KACf,SAAS,EACP,YAAY,MACb;KACF,CAAC;AAEF,QAAI,CAAC,IACH,QAAO,EAAE,OAAO,4BAA4B,QAAQ;AAItD,WAAO;KACL,SAAS;KACT,MAAM;MACJ,IAAI,IAAI;MACR,MAAM,IAAI;MACV,aAAa,IAAI;MACjB,aAAa,IAAI;MACjB,OAAO,IAAI;MACX,MAAM,IAAI;MACV,QAAQ,IAAI;MACZ,OAAO,IAAI;MACX,UAAU,IAAI;MACd,SAAS,IAAI,WAAW,KAAK,QAAQ,IAAI,IAAI;MAC7C,OAAO,IAAI;MACX,eAAe,IAAI;MACnB,YAAY,IAAI;MAChB,WAAW,IAAI;MACf,WAAW,IAAI;MAChB;KACF;YACM,OAAO;AACd,WAAO,EACL,OAAO,iBAAiB,QAAQ,MAAM,UAAU,uBACjD;;;EAGN,EAIA;;;;;;AAWH,MAAa,+BAA+B"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@igstack/app-catalog-backend-core",
3
- "version": "0.4.0-alpha-20260814233321",
3
+ "version": "0.4.0-alpha-20260817192135",
4
4
  "description": "Backend core library for App Catalog",
5
5
  "homepage": "https://github.com/lislon/app-catalog",
6
6
  "repository": {
@@ -45,8 +45,8 @@
45
45
  "tsyringe": "^4.10.0",
46
46
  "yaml": "^2.8.0",
47
47
  "zod": "^4.3.5",
48
- "@igstack/app-catalog-shared-core": "0.4.0-alpha-20260814233321",
49
- "@igstack/app-catalog-table-sync": "0.4.0-alpha-20260814233321"
48
+ "@igstack/app-catalog-shared-core": "0.4.0-alpha-20260817192135",
49
+ "@igstack/app-catalog-table-sync": "0.4.0-alpha-20260817192135"
50
50
  },
51
51
  "devDependencies": {
52
52
  "@tanstack/vite-config": "^0.4.3",
@@ -73,7 +73,7 @@
73
73
  "engines": {
74
74
  "node": ">=24"
75
75
  },
76
- "gitHead": "0b1eefe32dd6b9d0d6a995120c3f26ace4e28ebe",
76
+ "gitHead": "3f60ebcfbc1820e1bf80298a7956fe6c432ca274",
77
77
  "scripts": {
78
78
  "build": "tsdown",
79
79
  "build:lenient": "tsdown",
@@ -3,7 +3,11 @@
3
3
  * Tests the logic that maps DB_SCHEMA env → pg pool options.
4
4
  */
5
5
  import { afterEach, beforeEach, describe, expect, it } from 'vitest'
6
- import { buildPgSchemaOptions } from '../db/client'
6
+ import {
7
+ buildPgSchemaOptions,
8
+ resolveDbSchema,
9
+ verifyDbSchema,
10
+ } from '../db/client'
7
11
 
8
12
  const original = process.env.DB_SCHEMA
9
13
 
@@ -62,3 +66,64 @@ describe('DB_SCHEMA schema-per-branch isolation (#44)', () => {
62
66
  })
63
67
  })
64
68
  })
69
+
70
+ describe('resolveDbSchema', () => {
71
+ it('is undefined when nothing configures a schema', () => {
72
+ expect(resolveDbSchema()).toBeUndefined()
73
+ expect(resolveDbSchema('')).toBeUndefined()
74
+ })
75
+
76
+ it('prefers DB_SCHEMA over the configured schema', () => {
77
+ process.env.DB_SCHEMA = 'preview_from-env'
78
+ expect(resolveDbSchema('public')).toBe('preview_from-env')
79
+ })
80
+
81
+ it('falls back to the configured schema', () => {
82
+ expect(resolveDbSchema('public')).toBe('public')
83
+ })
84
+ })
85
+
86
+ describe('verifyDbSchema fail-fast (#82)', () => {
87
+ const poolReturning = (schema: string | null) =>
88
+ ({
89
+ query: async () => ({ rows: [{ schema }] }),
90
+ }) as unknown as Parameters<typeof verifyDbSchema>[0]
91
+
92
+ it('stays quiet for a deployment with no isolation to verify', async () => {
93
+ await expect(
94
+ verifyDbSchema(poolReturning('public')),
95
+ ).resolves.toBeUndefined()
96
+ })
97
+
98
+ it('passes when the database resolves the expected schema', async () => {
99
+ process.env.DB_SCHEMA = 'preview_feat-my-branch'
100
+ await expect(
101
+ verifyDbSchema(poolReturning('preview_feat-my-branch')),
102
+ ).resolves.toBeUndefined()
103
+ })
104
+
105
+ it('checks a deployment isolated by config alone, without DB_SCHEMA', async () => {
106
+ await expect(
107
+ verifyDbSchema(poolReturning('public'), 'preview_feat-my-branch'),
108
+ ).rejects.toThrow(/current_schema\(\) to "public"/)
109
+ })
110
+
111
+ it('accepts the name Postgres kept when the schema is over 63 bytes', async () => {
112
+ // CREATE SCHEMA and search_path truncate identically, so a long branch slug
113
+ // is still isolated - failing it here would crash-loop a healthy preview env.
114
+ const long = 'preview_' + 'b'.repeat(70)
115
+ process.env.DB_SCHEMA = long
116
+ await expect(
117
+ verifyDbSchema(poolReturning(long.slice(0, 63))),
118
+ ).resolves.toBeUndefined()
119
+ })
120
+
121
+ it('throws when a missing schema falls the connection through to public', async () => {
122
+ // Postgres skips a search_path entry that does not exist, which would let a
123
+ // preview env quietly read and overwrite the shared catalog.
124
+ process.env.DB_SCHEMA = 'preview_feat-my-branch'
125
+ await expect(verifyDbSchema(poolReturning('public'))).rejects.toThrow(
126
+ /current_schema\(\) to "public"/,
127
+ )
128
+ })
129
+ })
@@ -0,0 +1,118 @@
1
+ /**
2
+ * Regression tests for preview-env schema isolation (#82).
3
+ *
4
+ * A `search_path` on the pool is not enough: Prisma 7 driver adapters qualify
5
+ * every table name with the schema the adapter reports, so a preview env whose
6
+ * adapter reports nothing reads and writes `public` no matter what the pool's
7
+ * `current_schema()` says. These tests pin the schema all the way to the
8
+ * `PrismaPg` constructor for every core call site.
9
+ */
10
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
11
+
12
+ const poolCtor = vi.fn()
13
+ const adapterCtor = vi.fn()
14
+
15
+ vi.mock('pg', () => ({
16
+ default: {
17
+ Pool: class {
18
+ constructor(config: unknown) {
19
+ poolCtor(config)
20
+ }
21
+ },
22
+ },
23
+ }))
24
+
25
+ vi.mock('@prisma/adapter-pg', () => ({
26
+ PrismaPg: class {
27
+ constructor(pool: unknown, options: unknown) {
28
+ adapterCtor(pool, options)
29
+ }
30
+ },
31
+ }))
32
+
33
+ vi.mock('../generated/prisma/client', () => ({
34
+ PrismaClient: class {
35
+ $connect = vi.fn()
36
+ $disconnect = vi.fn()
37
+ },
38
+ }))
39
+
40
+ const original = process.env.DB_SCHEMA
41
+ const originalUrl = process.env.AC_CORE_DATABASE_URL
42
+
43
+ beforeEach(() => {
44
+ vi.resetModules()
45
+ poolCtor.mockClear()
46
+ adapterCtor.mockClear()
47
+ process.env.DB_SCHEMA = 'preview_feat-my-branch'
48
+ })
49
+
50
+ afterEach(() => {
51
+ if (original === undefined) delete process.env.DB_SCHEMA
52
+ else process.env.DB_SCHEMA = original
53
+ if (originalUrl === undefined) delete process.env.AC_CORE_DATABASE_URL
54
+ else process.env.AC_CORE_DATABASE_URL = originalUrl
55
+ })
56
+
57
+ const adapterSchema = () => adapterCtor.mock.calls[0]?.[1]
58
+ const poolOptions = () =>
59
+ (poolCtor.mock.calls[0]?.[0] as { options?: string } | undefined)?.options
60
+
61
+ describe('preview-env schema reaches the Prisma adapter (#82)', () => {
62
+ it('getDbClient() passes DB_SCHEMA to the adapter, not just the pool', async () => {
63
+ process.env.AC_CORE_DATABASE_URL = 'postgresql://u:p@h:5432/db'
64
+ const { getDbClient } = await import('../db/client')
65
+
66
+ getDbClient()
67
+
68
+ expect(poolOptions()).toBe('-c search_path=preview_feat-my-branch')
69
+ expect(adapterSchema()).toEqual({ schema: 'preview_feat-my-branch' })
70
+ })
71
+
72
+ it('the middleware pool - the one the app uses - carries the schema too', async () => {
73
+ const { AcDatabaseManager } = await import('../middleware/database')
74
+
75
+ new AcDatabaseManager({
76
+ host: 'h',
77
+ port: 5432,
78
+ database: 'db',
79
+ username: 'u',
80
+ password: 'p',
81
+ schema: 'public',
82
+ }).getClient()
83
+
84
+ expect(poolOptions()).toBe('-c search_path=preview_feat-my-branch')
85
+ expect(adapterSchema()).toEqual({ schema: 'preview_feat-my-branch' })
86
+ })
87
+
88
+ it('the AI tools client reads the preview schema, not the shared catalog', async () => {
89
+ const { createAppCatalogAITools } =
90
+ await import('../modules/lighthouseKeeper/tools')
91
+
92
+ createAppCatalogAITools({
93
+ host: 'h',
94
+ port: 5432,
95
+ database: 'db',
96
+ username: 'u',
97
+ password: 'p',
98
+ schema: 'public',
99
+ })
100
+
101
+ expect(poolOptions()).toBe('-c search_path=preview_feat-my-branch')
102
+ expect(adapterSchema()).toEqual({ schema: 'preview_feat-my-branch' })
103
+ })
104
+
105
+ it('leaves the adapter schema unset when DB_SCHEMA is not configured', async () => {
106
+ delete process.env.DB_SCHEMA
107
+ process.env.AC_CORE_DATABASE_URL = 'postgresql://u:p@h:5432/db'
108
+ const { getDbClient } = await import('../db/client')
109
+
110
+ getDbClient()
111
+
112
+ expect(poolOptions()).toBeUndefined()
113
+ // Not toEqual({ schema: undefined }) - that also passes for {}, and an
114
+ // explicit undefined is what keeps the adapter on its own default.
115
+ expect(adapterSchema()).toHaveProperty('schema', undefined)
116
+ expect(adapterSchema()?.schema).toBeUndefined()
117
+ })
118
+ })
package/src/db/client.ts CHANGED
@@ -6,24 +6,120 @@ import { buildPgSslConfig } from './sslConfig'
6
6
  let prismaClient: PrismaClient | null = null
7
7
  let pool: pg.Pool | null = null
8
8
 
9
+ type PrismaLogOption = NonNullable<
10
+ ConstructorParameters<typeof PrismaClient>[0]
11
+ >['log']
12
+
13
+ /**
14
+ * The schema a deployment should be reading and writing.
15
+ *
16
+ * DB_SCHEMA is set per deployment, so it outranks a schema baked into the app
17
+ * config - otherwise a config that names `public` would pin a preview env back
18
+ * to the shared tables it was meant to be isolated from.
19
+ */
20
+ export function resolveDbSchema(configuredSchema?: string): string | undefined {
21
+ return process.env.DB_SCHEMA || configuredSchema || undefined
22
+ }
23
+
9
24
  /**
10
25
  * `search_path` startup options for a schema-isolated deployment.
11
26
  *
12
27
  * node-postgres ignores Prisma's `?schema=` URL parameter, so a preview env's
13
- * schema has to be injected as a connection option instead. Every pool that
14
- * talks to the core database must go through here - one that skips it silently
15
- * lands in `public` and reads the shared canonical tables.
28
+ * schema has to be injected as a connection option instead. This half covers
29
+ * raw SQL and the migrations; Prisma's own queries need the adapter half too,
30
+ * which is why every core connection goes through createCorePrismaClient.
16
31
  */
17
32
  export function buildPgSchemaOptions(configuredSchema?: string): {
18
33
  options?: string
19
34
  } {
20
- // DB_SCHEMA is set per deployment, so it outranks a schema baked into the app
21
- // config - otherwise a config that names `public` would pin a preview env back
22
- // to the shared tables it was meant to be isolated from.
23
- const schema = process.env.DB_SCHEMA || configuredSchema
35
+ const schema = resolveDbSchema(configuredSchema)
24
36
  return schema ? { options: `-c search_path=${schema}` } : {}
25
37
  }
26
38
 
39
+ /**
40
+ * The one way to open a connection to the core database.
41
+ *
42
+ * Schema isolation takes two halves and both live here:
43
+ * - the pool's `search_path`, which raw SQL and the migrations follow;
44
+ * - the adapter's `schema`, which is what Prisma qualifies its table names
45
+ * with. A client built without it emits `"public"."DbResource"` however
46
+ * correct the pool's `current_schema()` is, so a preview env silently reads
47
+ * and overwrites the shared canonical tables.
48
+ *
49
+ * A pool built outside this factory gets neither half, so don't build one.
50
+ */
51
+ export function createCorePrismaClient(params: {
52
+ connectionString: string
53
+ configuredSchema?: string
54
+ log?: PrismaLogOption
55
+ }): { client: PrismaClient; pool: pg.Pool; schema: string | undefined } {
56
+ const schema = resolveDbSchema(params.configuredSchema)
57
+ // SSL comes from PGSSLMODE/PGSSLROOTCERT via our helper (node-postgres
58
+ // doesn't honor those correctly for a connection-string pool - see
59
+ // buildPgSslConfig).
60
+ const ssl = buildPgSslConfig()
61
+ const newPool = new pg.Pool({
62
+ connectionString: params.connectionString,
63
+ ...(ssl === undefined ? {} : { ssl }),
64
+ ...buildPgSchemaOptions(params.configuredSchema),
65
+ })
66
+ const adapter = new PrismaPg(newPool, { schema })
67
+ const client = new PrismaClient({
68
+ adapter,
69
+ ...(params.log === undefined ? {} : { log: params.log }),
70
+ })
71
+ return { client, pool: newPool, schema }
72
+ }
73
+
74
+ /** Postgres NAMEDATALEN - 1: identifiers are truncated to this many bytes. */
75
+ const PG_MAX_IDENTIFIER_BYTES = 63
76
+
77
+ /**
78
+ * How Postgres will have stored an identifier this long.
79
+ *
80
+ * A schema name over the limit is truncated identically wherever it appears - at
81
+ * CREATE SCHEMA and in `search_path` alike - so the isolation still holds and
82
+ * current_schema() merely reports the shortened name. Comparing raw strings would
83
+ * fail a correctly isolated deployment, so compare what Postgres kept.
84
+ */
85
+ function truncateIdentifier(name: string): string {
86
+ const bytes = Buffer.from(name, 'utf8')
87
+ if (bytes.length <= PG_MAX_IDENTIFIER_BYTES) return name
88
+ return bytes.subarray(0, PG_MAX_IDENTIFIER_BYTES).toString('utf8')
89
+ }
90
+
91
+ /**
92
+ * Fails a deployment that believes it is schema-isolated but is not.
93
+ *
94
+ * Speaks up whenever a schema was resolved at all - the same resolution feeds the
95
+ * pool's `search_path`, so the expectation and the connection can never disagree
96
+ * by construction, and a deployment that configures isolation without setting
97
+ * DB_SCHEMA is checked too. The case worth catching is a missing schema: Postgres
98
+ * silently skips a `search_path` entry that does not exist and falls through to
99
+ * `public`, which is the shared-catalog corruption this is here to prevent.
100
+ */
101
+ export async function verifyDbSchema(
102
+ poolToCheck: pg.Pool,
103
+ configuredSchema?: string,
104
+ ): Promise<void> {
105
+ const resolved = resolveDbSchema(configuredSchema)
106
+ const result = await poolToCheck.query<{ schema: string | null }>(
107
+ 'SELECT current_schema() AS schema',
108
+ )
109
+ const actual = result.rows[0]?.schema ?? null
110
+ console.log(
111
+ `[db] core schema: resolved=${resolved ?? '(none)'} current_schema=${actual ?? '(none)'}`,
112
+ )
113
+ if (!resolved) return
114
+ if (actual !== truncateIdentifier(resolved)) {
115
+ throw new Error(
116
+ `The configured schema is "${resolved}" but the database resolved current_schema() to ` +
117
+ `"${actual}". The schema is most likely missing, so every query would fall through to ` +
118
+ `the shared catalog tables. Migrate this schema before starting the app.`,
119
+ )
120
+ }
121
+ }
122
+
27
123
  /**
28
124
  * Gets the internal Prisma client instance.
29
125
  * Creates one if it doesn't exist.
@@ -38,21 +134,9 @@ export function getDbClient(): PrismaClient {
38
134
  )
39
135
  }
40
136
 
41
- // Prisma 7 with adapter: Create pg pool and wrap with adapter.
42
- // SSL comes from PGSSLMODE/PGSSLROOTCERT via our helper (node-postgres
43
- // doesn't honor those correctly for a connection-string pool — see
44
- // buildPgSslConfig).
45
- //
46
- // Schema isolation for preview envs (#44): see buildPgSchemaOptions.
47
- const ssl = buildPgSslConfig()
48
- pool = new pg.Pool({
49
- connectionString: databaseUrl,
50
- ...(ssl === undefined ? {} : { ssl }),
51
- ...buildPgSchemaOptions(),
52
- })
53
- const adapter = new PrismaPg(pool)
54
-
55
- prismaClient = new PrismaClient({ adapter })
137
+ const created = createCorePrismaClient({ connectionString: databaseUrl })
138
+ pool = created.pool
139
+ prismaClient = created.client
56
140
  }
57
141
  return prismaClient
58
142
  }
@@ -72,6 +156,7 @@ export function setDbClient(client: PrismaClient): void {
72
156
  export async function connectDb(): Promise<void> {
73
157
  const client = getDbClient()
74
158
  await client.$connect()
159
+ if (pool) await verifyDbSchema(pool)
75
160
  }
76
161
 
77
162
  /**
package/src/db/index.ts CHANGED
@@ -1,5 +1,14 @@
1
1
  // Database connection
2
- export { connectDb, disconnectDb, getDbClient, setDbClient } from './client'
2
+ export {
3
+ buildPgSchemaOptions,
4
+ connectDb,
5
+ createCorePrismaClient,
6
+ disconnectDb,
7
+ getDbClient,
8
+ resolveDbSchema,
9
+ setDbClient,
10
+ verifyDbSchema,
11
+ } from './client'
3
12
  export { buildPgSslConfig } from './sslConfig'
4
13
 
5
14
  // Table sync utilities
package/src/index.ts CHANGED
@@ -60,14 +60,18 @@ export {
60
60
 
61
61
  // Database utilities
62
62
  export {
63
+ buildPgSchemaOptions,
63
64
  buildPgSslConfig,
64
65
  connectDb,
66
+ createCorePrismaClient,
65
67
  disconnectDb,
66
68
  getDbClient,
69
+ resolveDbSchema,
67
70
  setDbClient,
68
71
  syncAppCatalog,
69
72
  TABLE_SYNC_MAGAZINE,
70
73
  tableSyncPrisma,
74
+ verifyDbSchema,
71
75
  type MakeTFromPrismaModel,
72
76
  type ObjectKeys,
73
77
  type ScalarFilter,
@@ -1,9 +1,11 @@
1
- import { PrismaClient } from '../generated/prisma/client'
2
- import { PrismaPg } from '@prisma/adapter-pg'
3
- import pg from 'pg'
1
+ import type { PrismaClient } from '../generated/prisma/client'
2
+ import type pg from 'pg'
4
3
  import type { AcDatabaseConfig } from './types'
5
- import { buildPgSchemaOptions, setDbClient } from '../db/client'
6
- import { buildPgSslConfig } from '../db/sslConfig'
4
+ import {
5
+ createCorePrismaClient,
6
+ setDbClient,
7
+ verifyDbSchema,
8
+ } from '../db/client'
7
9
 
8
10
  /**
9
11
  * Formats a database connection URL from structured config.
@@ -36,31 +38,19 @@ export class AcDatabaseManager {
36
38
  */
37
39
  getClient(): PrismaClient {
38
40
  if (!this.client) {
39
- const datasourceUrl = formatConnectionUrl(this.config)
40
-
41
- // Prisma 7 with adapter: Create pg pool and wrap with adapter.
42
- // SSL from PGSSLMODE/PGSSLROOTCERT via buildPgSslConfig (node-postgres
43
- // doesn't apply those correctly to a connection-string pool).
44
- // This pool - not getDbClient()'s - is the one the app ends up using, so
45
- // the preview-env schema has to be applied here as well. The `?schema=`
46
- // in datasourceUrl is inert: node-postgres drops unknown URL params.
47
- const ssl = buildPgSslConfig()
48
- this.pool = new pg.Pool({
49
- connectionString: datasourceUrl,
50
- ...(ssl === undefined ? {} : { ssl }),
51
- ...buildPgSchemaOptions(
52
- 'url' in this.config ? undefined : this.config.schema,
53
- ),
54
- })
55
- const adapter = new PrismaPg(this.pool)
56
-
57
- this.client = new PrismaClient({
58
- adapter,
41
+ // This client - not getDbClient()'s - is the one the app ends up using.
42
+ // The `?schema=` in the url is inert (node-postgres drops unknown URL
43
+ // params), so the schema has to travel via createCorePrismaClient.
44
+ const created = createCorePrismaClient({
45
+ connectionString: formatConnectionUrl(this.config),
46
+ configuredSchema: this.configuredSchema(),
59
47
  log:
60
48
  process.env.NODE_ENV === 'development'
61
49
  ? ['warn', 'error']
62
50
  : ['warn', 'error'],
63
51
  })
52
+ this.pool = created.pool
53
+ this.client = created.client
64
54
 
65
55
  // Bridge with existing backend-core getDbClient() usage
66
56
  setDbClient(this.client)
@@ -71,6 +61,7 @@ export class AcDatabaseManager {
71
61
  async connect(): Promise<void> {
72
62
  const client = this.getClient()
73
63
  await client.$connect()
64
+ if (this.pool) await verifyDbSchema(this.pool, this.configuredSchema())
74
65
  }
75
66
 
76
67
  async disconnect(): Promise<void> {
@@ -83,4 +74,8 @@ export class AcDatabaseManager {
83
74
  this.pool = null
84
75
  }
85
76
  }
77
+
78
+ private configuredSchema(): string | undefined {
79
+ return 'url' in this.config ? undefined : this.config.schema
80
+ }
86
81
  }
@@ -37,7 +37,11 @@ export function createPrismaDatabaseClient(prisma: {
37
37
  },
38
38
  getTables: async () => {
39
39
  const tables = await prisma.$queryRawUnsafe<{ tablename: string }[]>(
40
- `SELECT tablename FROM pg_tables WHERE schemaname = 'public'`,
40
+ // current_schema() - not a hardcoded 'public' - because the DML these
41
+ // tools go on to run is unqualified and therefore resolves through
42
+ // search_path. A schema-isolated deployment would otherwise be told
43
+ // about tables it cannot see.
44
+ `SELECT tablename FROM pg_tables WHERE schemaname = current_schema()`,
41
45
  )
42
46
  return tables.map((t) => t.tablename)
43
47
  },
@@ -51,7 +55,7 @@ export function createPrismaDatabaseClient(prisma: {
51
55
  >(
52
56
  `SELECT column_name, data_type, is_nullable
53
57
  FROM information_schema.columns
54
- WHERE table_name = '${tableName}' AND table_schema = 'public'`,
58
+ WHERE table_name = '${tableName}' AND table_schema = current_schema()`,
55
59
  )
56
60
  return columns.map((c) => ({
57
61
  name: c.column_name,
@@ -1,8 +1,6 @@
1
1
  import type { Tool } from 'ai'
2
2
  import { z } from 'zod'
3
- import { PrismaClient } from '../../generated/prisma/client'
4
- import { PrismaPg } from '@prisma/adapter-pg'
5
- import pg from 'pg'
3
+ import { createCorePrismaClient } from '../../db/client'
6
4
  import type { AcDatabaseConfig } from '../../middleware/types.js'
7
5
 
8
6
  // ============================================================================
@@ -27,10 +25,11 @@ function getDatabaseUrl(config: AcDatabaseConfig): string {
27
25
  export function createAppCatalogAITools(
28
26
  databaseConfig: AcDatabaseConfig,
29
27
  ): Record<string, Tool> {
30
- const databaseUrl = getDatabaseUrl(databaseConfig)
31
- const pool = new pg.Pool({ connectionString: databaseUrl })
32
- const adapter = new PrismaPg(pool)
33
- const prisma = new PrismaClient({ adapter })
28
+ const { client: prisma } = createCorePrismaClient({
29
+ connectionString: getDatabaseUrl(databaseConfig),
30
+ configuredSchema:
31
+ 'url' in databaseConfig ? undefined : databaseConfig.schema,
32
+ })
34
33
 
35
34
  const getAppCardSchema = z.object({
36
35
  slug: z.string().describe('The app slug to fetch'),