@igstack/app-catalog-backend-core 0.15.0 → 0.17.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 (70) 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/db/syncAppCatalog.mjs +2 -1
  7. package/dist/db/syncAppCatalog.mjs.map +1 -1
  8. package/dist/generated/prisma/internal/class.mjs +4 -4
  9. package/dist/generated/prisma/internal/class.mjs.map +1 -1
  10. package/dist/generated/prisma/internal/prismaNamespace.d.mts +1 -0
  11. package/dist/generated/prisma/internal/prismaNamespace.d.mts.map +1 -1
  12. package/dist/generated/prisma/models/DbResource.d.mts +43 -1
  13. package/dist/generated/prisma/models/DbResource.d.mts.map +1 -1
  14. package/dist/index.d.mts +2 -2
  15. package/dist/index.mjs +2 -2
  16. package/dist/middleware/database.d.mts +1 -0
  17. package/dist/middleware/database.d.mts.map +1 -1
  18. package/dist/middleware/database.mjs +10 -13
  19. package/dist/middleware/database.mjs.map +1 -1
  20. package/dist/modules/appCatalog/freshness.mjs +5 -3
  21. package/dist/modules/appCatalog/freshness.mjs.map +1 -1
  22. package/dist/modules/appCatalog/service.mjs +5 -1
  23. package/dist/modules/appCatalog/service.mjs.map +1 -1
  24. package/dist/modules/assets/assetCache.mjs +25 -0
  25. package/dist/modules/assets/assetCache.mjs.map +1 -0
  26. package/dist/modules/assets/assetRestController.d.mts.map +1 -1
  27. package/dist/modules/assets/assetRestController.mjs +17 -6
  28. package/dist/modules/assets/assetRestController.mjs.map +1 -1
  29. package/dist/modules/assets/assetUtils.mjs +1 -0
  30. package/dist/modules/assets/assetUtils.mjs.map +1 -1
  31. package/dist/modules/assets/screenshotRestController.d.mts.map +1 -1
  32. package/dist/modules/assets/screenshotRestController.mjs +7 -2
  33. package/dist/modules/assets/screenshotRestController.mjs.map +1 -1
  34. package/dist/modules/assets/upsertAsset.mjs +7 -1
  35. package/dist/modules/assets/upsertAsset.mjs.map +1 -1
  36. package/dist/modules/icons/iconRestController.d.mts.map +1 -1
  37. package/dist/modules/icons/iconRestController.mjs +7 -2
  38. package/dist/modules/icons/iconRestController.mjs.map +1 -1
  39. package/dist/modules/lighthouseKeeper/tools.d.mts.map +1 -1
  40. package/dist/modules/lighthouseKeeper/tools.mjs +5 -5
  41. package/dist/modules/lighthouseKeeper/tools.mjs.map +1 -1
  42. package/dist/types/common/appCatalogTypes.d.mts +8 -0
  43. package/dist/types/common/appCatalogTypes.d.mts.map +1 -1
  44. package/package.json +4 -4
  45. package/prisma/schema.prisma +5 -2
  46. package/src/__tests__/assetMime.test.ts +74 -0
  47. package/src/__tests__/dbSchema.test.ts +129 -0
  48. package/src/__tests__/dbSchemaAdapter.test.ts +118 -0
  49. package/src/__tests__/freshness.test.ts +56 -17
  50. package/src/__tests__/iconCache.test.ts +81 -0
  51. package/src/db/client.ts +118 -12
  52. package/src/db/index.ts +10 -1
  53. package/src/db/syncAppCatalog.ts +3 -0
  54. package/src/generated/prisma/internal/class.ts +4 -4
  55. package/src/generated/prisma/internal/prismaNamespace.ts +1 -0
  56. package/src/generated/prisma/internal/prismaNamespaceBrowser.ts +1 -0
  57. package/src/generated/prisma/models/DbResource.ts +44 -1
  58. package/src/index.ts +4 -0
  59. package/src/middleware/database.ts +20 -19
  60. package/src/modules/admin/chat/createDatabaseTools.ts +6 -2
  61. package/src/modules/appCatalog/freshness.ts +29 -11
  62. package/src/modules/appCatalog/service.ts +9 -4
  63. package/src/modules/assets/assetCache.ts +30 -0
  64. package/src/modules/assets/assetRestController.ts +25 -2
  65. package/src/modules/assets/assetUtils.ts +1 -0
  66. package/src/modules/assets/screenshotRestController.ts +13 -1
  67. package/src/modules/assets/upsertAsset.ts +8 -0
  68. package/src/modules/icons/iconRestController.ts +11 -1
  69. package/src/modules/lighthouseKeeper/tools.ts +6 -7
  70. package/src/types/common/appCatalogTypes.ts +8 -0
@@ -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
+ })
@@ -9,55 +9,94 @@ const iso = (msFromNow: number) => new Date(NOW + msFromNow).toISOString()
9
9
  const DAY = 24 * 60 * 60 * 1000
10
10
 
11
11
  describe('computeFreshness', () => {
12
- it('returns null date and not-stale when never checked', () => {
13
- expect(computeFreshness(null, null, NOW)).toEqual({
12
+ it('returns null dates and not-stale when never checked', () => {
13
+ expect(computeFreshness({}, NOW)).toEqual({
14
14
  lastCheckedAt: null,
15
+ lastContentChangeAt: null,
15
16
  isStale: false,
16
17
  })
17
18
  // nextCheckAfter present but never checked → still nothing to show
18
- expect(computeFreshness(null, iso(-30 * DAY), NOW)).toEqual({
19
+ expect(computeFreshness({ nextCheckAfter: iso(-30 * DAY) }, NOW)).toEqual({
19
20
  lastCheckedAt: null,
21
+ lastContentChangeAt: null,
20
22
  isStale: false,
21
23
  })
22
24
  })
23
25
 
24
26
  it('is fresh when the next check is still in the future', () => {
25
- const r = computeFreshness(iso(-DAY), iso(DAY), NOW)
27
+ const r = computeFreshness(
28
+ { lastCheckedAt: iso(-DAY), nextCheckAfter: iso(DAY) },
29
+ NOW,
30
+ )
26
31
  expect(r.isStale).toBe(false)
27
32
  expect(r.lastCheckedAt).toBe(iso(-DAY))
28
33
  })
29
34
 
30
35
  it('is fresh when overdue but within the grace period', () => {
31
36
  // due 3 days ago, grace is 7 days → not stale yet
32
- expect(computeFreshness(iso(-10 * DAY), iso(-3 * DAY), NOW).isStale).toBe(
33
- false,
34
- )
37
+ expect(
38
+ computeFreshness(
39
+ { lastCheckedAt: iso(-10 * DAY), nextCheckAfter: iso(-3 * DAY) },
40
+ NOW,
41
+ ).isStale,
42
+ ).toBe(false)
35
43
  })
36
44
 
37
45
  it('is stale once past due by more than the grace period', () => {
38
46
  // due 8 days ago > 7-day grace → stale
39
- expect(computeFreshness(iso(-15 * DAY), iso(-8 * DAY), NOW).isStale).toBe(
40
- true,
41
- )
47
+ expect(
48
+ computeFreshness(
49
+ { lastCheckedAt: iso(-15 * DAY), nextCheckAfter: iso(-8 * DAY) },
50
+ NOW,
51
+ ).isStale,
52
+ ).toBe(true)
42
53
  })
43
54
 
44
55
  it('treats exactly-grace as not yet stale (strictly greater than)', () => {
45
- const dueAt = iso(-0) // due now
46
- // now - due === 0; push due back exactly grace → boundary is not stale
47
56
  const atBoundary = computeFreshness(
48
- iso(-8 * DAY),
49
- new Date(NOW - STALE_GRACE_MS).toISOString(),
57
+ {
58
+ lastCheckedAt: iso(-8 * DAY),
59
+ nextCheckAfter: new Date(NOW - STALE_GRACE_MS).toISOString(),
60
+ },
50
61
  NOW,
51
62
  )
52
63
  expect(atBoundary.isStale).toBe(false)
53
- void dueAt
54
64
  })
55
65
 
56
66
  it('is not stale when nextCheckAfter is missing (cannot judge overdue)', () => {
57
- expect(computeFreshness(iso(-100 * DAY), null, NOW).isStale).toBe(false)
67
+ expect(
68
+ computeFreshness({ lastCheckedAt: iso(-100 * DAY) }, NOW).isStale,
69
+ ).toBe(false)
58
70
  })
59
71
 
60
72
  it('ignores an unparseable nextCheckAfter', () => {
61
- expect(computeFreshness(iso(-DAY), 'not-a-date', NOW).isStale).toBe(false)
73
+ expect(
74
+ computeFreshness(
75
+ { lastCheckedAt: iso(-DAY), nextCheckAfter: 'not-a-date' },
76
+ NOW,
77
+ ).isStale,
78
+ ).toBe(false)
79
+ })
80
+
81
+ it('passes through when the content last actually changed', () => {
82
+ // The UI shows this instead of lastCheckedAt: a source re-read 20 times
83
+ // without changing was still "Updated" 90 days ago.
84
+ const r = computeFreshness(
85
+ {
86
+ lastCheckedAt: iso(-DAY),
87
+ nextCheckAfter: iso(DAY),
88
+ lastContentChangeAt: iso(-90 * DAY),
89
+ },
90
+ NOW,
91
+ )
92
+ expect(r.lastContentChangeAt).toBe(iso(-90 * DAY))
93
+ expect(r.lastCheckedAt).toBe(iso(-DAY))
94
+ })
95
+
96
+ it('leaves lastContentChangeAt null for entries predating the field', () => {
97
+ // Older rows have no content-change timestamp; the frontend falls back to
98
+ // lastCheckedAt rather than showing nothing.
99
+ const r = computeFreshness({ lastCheckedAt: iso(-DAY) }, NOW)
100
+ expect(r.lastContentChangeAt).toBeNull()
62
101
  })
63
102
  })
@@ -0,0 +1,81 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest'
2
+ import { registerIconRestController } from '../modules/icons/iconRestController'
3
+ import * as dbClient from '../db/client'
4
+ import type { PrismaClient } from '../generated/prisma/client'
5
+ import type { Request, Response, Router } from 'express'
6
+
7
+ const ICON = {
8
+ content: Buffer.from('<svg xmlns="http://www.w3.org/2000/svg"/>'),
9
+ mimeType: 'image/svg+xml',
10
+ name: 'example-icon',
11
+ checksum: 'abc123',
12
+ }
13
+
14
+ /**
15
+ * Captures the `GET {basePath}/:name` handler the controller registers, so the
16
+ * test can drive it directly instead of standing up an HTTP server.
17
+ */
18
+ const captureBinaryHandler = () => {
19
+ let handler!: (req: Request, res: Response) => Promise<void>
20
+ const router = {
21
+ get: (path: string, fn: (req: Request, res: Response) => Promise<void>) => {
22
+ if (path === '/api/icons/:name') handler = fn
23
+ },
24
+ post: () => {},
25
+ } as unknown as Router
26
+
27
+ registerIconRestController(router, { basePath: '/api/icons' })
28
+ return handler
29
+ }
30
+
31
+ const fakeRes = () => {
32
+ const headers: Record<string, string> = {}
33
+ const res = {
34
+ setHeader: (k: string, v: string) => {
35
+ headers[k] = v
36
+ },
37
+ status: vi.fn().mockReturnThis(),
38
+ json: vi.fn().mockReturnThis(),
39
+ send: vi.fn().mockReturnThis(),
40
+ end: vi.fn().mockReturnThis(),
41
+ sendStatus: vi.fn().mockReturnThis(),
42
+ }
43
+ return { res: res as unknown as Response, headers, spy: res }
44
+ }
45
+
46
+ describe('icon binary caching', () => {
47
+ beforeEach(() => {
48
+ vi.spyOn(dbClient, 'getDbClient').mockReturnValue({
49
+ dbAsset: { findFirst: vi.fn().mockResolvedValue(ICON) },
50
+ } as unknown as PrismaClient)
51
+ })
52
+
53
+ it('requires revalidation so a replaced icon is not masked by a stale cache', async () => {
54
+ const handler = captureBinaryHandler()
55
+ const { res, headers } = fakeRes()
56
+
57
+ await handler(
58
+ { params: { name: 'example-icon' }, headers: {} } as unknown as Request,
59
+ res,
60
+ )
61
+
62
+ expect(headers['Cache-Control']).toBe('public, max-age=0, must-revalidate')
63
+ expect(headers['ETag']).toBe(`"${ICON.checksum}"`)
64
+ })
65
+
66
+ it('answers a matching If-None-Match with 304 and no body', async () => {
67
+ const handler = captureBinaryHandler()
68
+ const { res, spy } = fakeRes()
69
+
70
+ await handler(
71
+ {
72
+ params: { name: 'example-icon' },
73
+ headers: { 'if-none-match': `"${ICON.checksum}"` },
74
+ } as unknown as Request,
75
+ res,
76
+ )
77
+
78
+ expect(spy.status).toHaveBeenCalledWith(304)
79
+ expect(spy.send).not.toHaveBeenCalled()
80
+ })
81
+ })
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
@@ -313,6 +313,9 @@ export async function syncAppCatalog(
313
313
  nextCheckAfter: resource.nextCheckAfter
314
314
  ? new Date(resource.nextCheckAfter)
315
315
  : null,
316
+ lastContentChangeAt: resource.lastContentChangeAt
317
+ ? new Date(resource.lastContentChangeAt)
318
+ : null,
316
319
  }
317
320
  })
318
321