@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.
- package/dist/db/client.d.mts +54 -1
- package/dist/db/client.d.mts.map +1 -1
- package/dist/db/client.mjs +80 -12
- package/dist/db/client.mjs.map +1 -1
- package/dist/db/index.d.mts +1 -1
- package/dist/index.d.mts +2 -2
- package/dist/index.mjs +2 -2
- package/dist/middleware/database.d.mts +1 -0
- package/dist/middleware/database.d.mts.map +1 -1
- package/dist/middleware/database.mjs +10 -14
- package/dist/middleware/database.mjs.map +1 -1
- package/dist/modules/lighthouseKeeper/tools.d.mts.map +1 -1
- package/dist/modules/lighthouseKeeper/tools.mjs +5 -5
- package/dist/modules/lighthouseKeeper/tools.mjs.map +1 -1
- package/package.json +4 -4
- package/src/__tests__/dbSchema.test.ts +66 -1
- package/src/__tests__/dbSchemaAdapter.test.ts +118 -0
- package/src/db/client.ts +107 -22
- package/src/db/index.ts +10 -1
- package/src/index.ts +4 -0
- package/src/middleware/database.ts +20 -25
- package/src/modules/admin/chat/createDatabaseTools.ts +6 -2
- package/src/modules/lighthouseKeeper/tools.ts +6 -7
package/dist/db/client.d.mts
CHANGED
|
@@ -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
|
package/dist/db/client.d.mts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"client.d.mts","names":[],"sources":["../../src/db/client.ts"],"mappings":";;;;
|
|
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"}
|
package/dist/db/client.mjs
CHANGED
|
@@ -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.
|
|
14
|
-
*
|
|
15
|
-
*
|
|
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 =
|
|
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
|
|
30
|
-
pool =
|
|
31
|
-
|
|
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
|
package/dist/db/client.mjs.map
CHANGED
|
@@ -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.
|
|
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"}
|
package/dist/db/index.d.mts
CHANGED
|
@@ -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 };
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"database.d.mts","names":[],"sources":["../../src/middleware/database.ts"],"mappings":";;;;;;
|
|
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 {
|
|
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
|
|
33
|
-
|
|
34
|
-
|
|
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
|
|
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":";;;;;;
|
|
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 {
|
|
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
|
|
18
|
-
|
|
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 {
|
|
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-
|
|
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-
|
|
49
|
-
"@igstack/app-catalog-table-sync": "0.4.0-alpha-
|
|
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": "
|
|
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 {
|
|
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.
|
|
14
|
-
*
|
|
15
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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 {
|
|
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
|
|
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 {
|
|
6
|
-
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
//
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
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 =
|
|
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 {
|
|
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
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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'),
|