@igstack/app-catalog-backend-core 0.15.0 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/dist/db/client.d.mts +54 -1
  2. package/dist/db/client.d.mts.map +1 -1
  3. package/dist/db/client.mjs +88 -7
  4. package/dist/db/client.mjs.map +1 -1
  5. package/dist/db/index.d.mts +1 -1
  6. package/dist/index.d.mts +2 -2
  7. package/dist/index.mjs +2 -2
  8. package/dist/middleware/database.d.mts +1 -0
  9. package/dist/middleware/database.d.mts.map +1 -1
  10. package/dist/middleware/database.mjs +10 -13
  11. package/dist/middleware/database.mjs.map +1 -1
  12. package/dist/modules/assets/assetCache.mjs +25 -0
  13. package/dist/modules/assets/assetCache.mjs.map +1 -0
  14. package/dist/modules/assets/assetRestController.d.mts.map +1 -1
  15. package/dist/modules/assets/assetRestController.mjs +17 -6
  16. package/dist/modules/assets/assetRestController.mjs.map +1 -1
  17. package/dist/modules/assets/assetUtils.mjs +1 -0
  18. package/dist/modules/assets/assetUtils.mjs.map +1 -1
  19. package/dist/modules/assets/screenshotRestController.d.mts.map +1 -1
  20. package/dist/modules/assets/screenshotRestController.mjs +7 -2
  21. package/dist/modules/assets/screenshotRestController.mjs.map +1 -1
  22. package/dist/modules/assets/upsertAsset.mjs +7 -1
  23. package/dist/modules/assets/upsertAsset.mjs.map +1 -1
  24. package/dist/modules/icons/iconRestController.d.mts.map +1 -1
  25. package/dist/modules/icons/iconRestController.mjs +7 -2
  26. package/dist/modules/icons/iconRestController.mjs.map +1 -1
  27. package/dist/modules/lighthouseKeeper/tools.d.mts.map +1 -1
  28. package/dist/modules/lighthouseKeeper/tools.mjs +5 -5
  29. package/dist/modules/lighthouseKeeper/tools.mjs.map +1 -1
  30. package/package.json +4 -4
  31. package/src/__tests__/assetMime.test.ts +74 -0
  32. package/src/__tests__/dbSchema.test.ts +129 -0
  33. package/src/__tests__/dbSchemaAdapter.test.ts +118 -0
  34. package/src/__tests__/iconCache.test.ts +81 -0
  35. package/src/db/client.ts +118 -12
  36. package/src/db/index.ts +10 -1
  37. package/src/index.ts +4 -0
  38. package/src/middleware/database.ts +20 -19
  39. package/src/modules/admin/chat/createDatabaseTools.ts +6 -2
  40. package/src/modules/assets/assetCache.ts +30 -0
  41. package/src/modules/assets/assetRestController.ts +25 -2
  42. package/src/modules/assets/assetUtils.ts +1 -0
  43. package/src/modules/assets/screenshotRestController.ts +13 -1
  44. package/src/modules/assets/upsertAsset.ts +8 -0
  45. package/src/modules/icons/iconRestController.ts +11 -1
  46. package/src/modules/lighthouseKeeper/tools.ts +6 -7
package/src/db/client.ts CHANGED
@@ -6,6 +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
+
24
+ /**
25
+ * `search_path` startup options for a schema-isolated deployment.
26
+ *
27
+ * node-postgres ignores Prisma's `?schema=` URL parameter, so a preview env's
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.
31
+ */
32
+ export function buildPgSchemaOptions(configuredSchema?: string): {
33
+ options?: string
34
+ } {
35
+ const schema = resolveDbSchema(configuredSchema)
36
+ return schema ? { options: `-c search_path=${schema}` } : {}
37
+ }
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
+
9
123
  /**
10
124
  * Gets the internal Prisma client instance.
11
125
  * Creates one if it doesn't exist.
@@ -20,18 +134,9 @@ export function getDbClient(): PrismaClient {
20
134
  )
21
135
  }
22
136
 
23
- // Prisma 7 with adapter: Create pg pool and wrap with adapter.
24
- // SSL comes from PGSSLMODE/PGSSLROOTCERT via our helper (node-postgres
25
- // doesn't honor those correctly for a connection-string pool — see
26
- // buildPgSslConfig).
27
- const ssl = buildPgSslConfig()
28
- pool = new pg.Pool({
29
- connectionString: databaseUrl,
30
- ...(ssl === undefined ? {} : { ssl }),
31
- })
32
- const adapter = new PrismaPg(pool)
33
-
34
- prismaClient = new PrismaClient({ adapter })
137
+ const created = createCorePrismaClient({ connectionString: databaseUrl })
138
+ pool = created.pool
139
+ prismaClient = created.client
35
140
  }
36
141
  return prismaClient
37
142
  }
@@ -51,6 +156,7 @@ export function setDbClient(client: PrismaClient): void {
51
156
  export async function connectDb(): Promise<void> {
52
157
  const client = getDbClient()
53
158
  await client.$connect()
159
+ if (pool) await verifyDbSchema(pool)
54
160
  }
55
161
 
56
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
@@ -59,14 +59,18 @@ export {
59
59
 
60
60
  // Database utilities
61
61
  export {
62
+ buildPgSchemaOptions,
62
63
  buildPgSslConfig,
63
64
  connectDb,
65
+ createCorePrismaClient,
64
66
  disconnectDb,
65
67
  getDbClient,
68
+ resolveDbSchema,
66
69
  setDbClient,
67
70
  syncAppCatalog,
68
71
  TABLE_SYNC_MAGAZINE,
69
72
  tableSyncPrisma,
73
+ verifyDbSchema,
70
74
  type MakeTFromPrismaModel,
71
75
  type ObjectKeys,
72
76
  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 { 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,25 +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
- const ssl = buildPgSslConfig()
45
- this.pool = new pg.Pool({
46
- connectionString: datasourceUrl,
47
- ...(ssl === undefined ? {} : { ssl }),
48
- })
49
- const adapter = new PrismaPg(this.pool)
50
-
51
- this.client = new PrismaClient({
52
- 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(),
53
47
  log:
54
48
  process.env.NODE_ENV === 'development'
55
49
  ? ['warn', 'error']
56
50
  : ['warn', 'error'],
57
51
  })
52
+ this.pool = created.pool
53
+ this.client = created.client
58
54
 
59
55
  // Bridge with existing backend-core getDbClient() usage
60
56
  setDbClient(this.client)
@@ -65,6 +61,7 @@ export class AcDatabaseManager {
65
61
  async connect(): Promise<void> {
66
62
  const client = this.getClient()
67
63
  await client.$connect()
64
+ if (this.pool) await verifyDbSchema(this.pool, this.configuredSchema())
68
65
  }
69
66
 
70
67
  async disconnect(): Promise<void> {
@@ -77,4 +74,8 @@ export class AcDatabaseManager {
77
74
  this.pool = null
78
75
  }
79
76
  }
77
+
78
+ private configuredSchema(): string | undefined {
79
+ return 'url' in this.config ? undefined : this.config.schema
80
+ }
80
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,
@@ -0,0 +1,30 @@
1
+ import type { Request, Response } from 'express'
2
+
3
+ /**
4
+ * Asset URLs are keyed by row id or by icon name, and `upsertAsset` replaces
5
+ * content in place rather than inserting a new row — so the bytes behind a
6
+ * given URL can change. A long `max-age` would then serve the old artwork for
7
+ * the whole window (an icon replacement stayed invisible for a day). Revalidate
8
+ * against the stored checksum instead: unchanged content still costs only a
9
+ * conditional request answered with 304, and a replacement shows up at once.
10
+ *
11
+ * `variant` distinguishes derived renditions of the same row (a resize width,
12
+ * for example) so they do not share one entity tag.
13
+ */
14
+ export function setRevalidatingCacheHeaders(
15
+ res: Response,
16
+ checksum: string,
17
+ variant?: string | number,
18
+ ): string {
19
+ const etag =
20
+ variant === undefined ? `"${checksum}"` : `"${checksum}-${variant}"`
21
+
22
+ res.setHeader('ETag', etag)
23
+ res.setHeader('Cache-Control', 'public, max-age=0, must-revalidate')
24
+
25
+ return etag
26
+ }
27
+
28
+ export function isNotModified(req: Request, etag: string): boolean {
29
+ return req.headers['if-none-match'] === etag
30
+ }
@@ -4,6 +4,7 @@ import sharp from 'sharp'
4
4
  import { getDbClient } from '../../db'
5
5
  import { getImageFormat, isRasterImage, resizeImage } from './assetUtils'
6
6
  import { upsertAsset } from './upsertAsset'
7
+ import { isNotModified, setRevalidatingCacheHeaders } from './assetCache'
7
8
 
8
9
  // Configure multer for memory storage
9
10
  const upload = multer({
@@ -106,6 +107,7 @@ export function registerAssetRestController(
106
107
  name: true,
107
108
  width: true,
108
109
  height: true,
110
+ checksum: true,
109
111
  },
110
112
  })
111
113
 
@@ -129,6 +131,17 @@ export function registerAssetRestController(
129
131
  Number.isFinite(width) &&
130
132
  width > 0
131
133
 
134
+ // Answered before resizing, so an unchanged asset costs no image work.
135
+ const etag = setRevalidatingCacheHeaders(
136
+ res,
137
+ asset.checksum,
138
+ shouldResize ? `w${width}` : undefined,
139
+ )
140
+ if (isNotModified(req, etag)) {
141
+ res.status(304).end()
142
+ return
143
+ }
144
+
132
145
  if (shouldResize) {
133
146
  const fmt = getImageFormat(asset.mimeType) || 'jpeg'
134
147
  const buf = await resizeImage(
@@ -144,7 +157,6 @@ export function registerAssetRestController(
144
157
  // Set appropriate headers
145
158
  res.setHeader('Content-Type', outMime)
146
159
  res.setHeader('Content-Disposition', `inline; filename="${asset.name}"`)
147
- res.setHeader('Cache-Control', 'public, max-age=86400') // Cache for 1 day
148
160
 
149
161
  // Send binary content (resized if requested)
150
162
  res.send(outBuffer)
@@ -204,6 +216,7 @@ export function registerAssetRestController(
204
216
  name: true,
205
217
  width: true,
206
218
  height: true,
219
+ checksum: true,
207
220
  },
208
221
  })
209
222
 
@@ -229,6 +242,17 @@ export function registerAssetRestController(
229
242
  Number.isFinite(width) &&
230
243
  width > 0
231
244
 
245
+ // Answered before resizing, so an unchanged asset costs no image work.
246
+ const etag = setRevalidatingCacheHeaders(
247
+ res,
248
+ asset.checksum,
249
+ shouldResize ? `w${width}` : undefined,
250
+ )
251
+ if (isNotModified(req, etag)) {
252
+ res.status(304).end()
253
+ return
254
+ }
255
+
232
256
  if (shouldResize) {
233
257
  const fmt = asset.mimeType.includes('png')
234
258
  ? 'png'
@@ -258,7 +282,6 @@ export function registerAssetRestController(
258
282
  // Set appropriate headers
259
283
  res.setHeader('Content-Type', outMime)
260
284
  res.setHeader('Content-Disposition', `inline; filename="${asset.name}"`)
261
- res.setHeader('Cache-Control', 'public, max-age=86400') // Cache for 1 day
262
285
 
263
286
  // Send binary content (resized if requested)
264
287
  res.send(outBuffer)
@@ -99,6 +99,7 @@ export async function parseAssetMeta(
99
99
  tiff: 'image/tiff',
100
100
  gif: 'image/gif',
101
101
  heif: 'image/heif',
102
+ svg: 'image/svg+xml',
102
103
  raw: 'application/octet-stream',
103
104
  }
104
105
 
@@ -1,6 +1,7 @@
1
1
  import type { Request, Response, Router } from 'express'
2
2
  import sharp from 'sharp'
3
3
  import { getDbClient } from '../../db'
4
+ import { isNotModified, setRevalidatingCacheHeaders } from './assetCache'
4
5
 
5
6
  export interface ScreenshotRestControllerConfig {
6
7
  /**
@@ -133,6 +134,7 @@ export function registerScreenshotRestController(
133
134
  content: true,
134
135
  mimeType: true,
135
136
  name: true,
137
+ checksum: true,
136
138
  },
137
139
  })
138
140
 
@@ -141,6 +143,17 @@ export function registerScreenshotRestController(
141
143
  return
142
144
  }
143
145
 
146
+ // Answered before resizing, so an unchanged screenshot costs no image work.
147
+ const etag = setRevalidatingCacheHeaders(
148
+ res,
149
+ screenshot.checksum,
150
+ targetSize && targetSize > 0 ? `s${targetSize}` : undefined,
151
+ )
152
+ if (isNotModified(req, etag)) {
153
+ res.status(304).end()
154
+ return
155
+ }
156
+
144
157
  let content: Uint8Array | Buffer = screenshot.content
145
158
 
146
159
  // Resize if size parameter provided
@@ -164,7 +177,6 @@ export function registerScreenshotRestController(
164
177
  'Content-Disposition',
165
178
  `inline; filename="${screenshot.name}"`,
166
179
  )
167
- res.setHeader('Cache-Control', 'public, max-age=86400') // Cache for 1 day
168
180
 
169
181
  // Send binary content
170
182
  res.send(content)
@@ -27,6 +27,14 @@ export async function upsertAsset({
27
27
  })
28
28
 
29
29
  if (existing) {
30
+ // Reusing the stored binary must not keep a mimeType we no longer derive:
31
+ // rows written by an older, wrong derivation would never be corrected.
32
+ if (existing.mimeType !== mimeType) {
33
+ await prisma.dbAsset.update({
34
+ where: { id: existing.id },
35
+ data: { mimeType },
36
+ })
37
+ }
30
38
  return existing.id
31
39
  }
32
40
 
@@ -3,6 +3,10 @@ import multer from 'multer'
3
3
  import { createHash } from 'node:crypto'
4
4
  import { getDbClient } from '../../db'
5
5
  import { getExtensionFromFilename, getExtensionFromMimeType } from './iconUtils'
6
+ import {
7
+ isNotModified,
8
+ setRevalidatingCacheHeaders,
9
+ } from '../assets/assetCache'
6
10
 
7
11
  // Configure multer for memory storage
8
12
  const upload = multer({
@@ -112,6 +116,7 @@ export function registerIconRestController(
112
116
  content: true,
113
117
  mimeType: true,
114
118
  name: true,
119
+ checksum: true,
115
120
  },
116
121
  })
117
122
 
@@ -120,10 +125,15 @@ export function registerIconRestController(
120
125
  return
121
126
  }
122
127
 
128
+ const etag = setRevalidatingCacheHeaders(res, icon.checksum)
129
+ if (isNotModified(req, etag)) {
130
+ res.status(304).end()
131
+ return
132
+ }
133
+
123
134
  // Set appropriate headers
124
135
  res.setHeader('Content-Type', icon.mimeType)
125
136
  res.setHeader('Content-Disposition', `inline; filename="${icon.name}"`)
126
- res.setHeader('Cache-Control', 'public, max-age=86400') // Cache for 1 day
127
137
 
128
138
  // Send binary content
129
139
  res.send(icon.content)
@@ -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'),