@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.
- package/dist/db/client.d.mts +54 -1
- package/dist/db/client.d.mts.map +1 -1
- package/dist/db/client.mjs +88 -7
- package/dist/db/client.mjs.map +1 -1
- package/dist/db/index.d.mts +1 -1
- package/dist/db/syncAppCatalog.mjs +2 -1
- package/dist/db/syncAppCatalog.mjs.map +1 -1
- package/dist/generated/prisma/internal/class.mjs +4 -4
- package/dist/generated/prisma/internal/class.mjs.map +1 -1
- package/dist/generated/prisma/internal/prismaNamespace.d.mts +1 -0
- package/dist/generated/prisma/internal/prismaNamespace.d.mts.map +1 -1
- package/dist/generated/prisma/models/DbResource.d.mts +43 -1
- package/dist/generated/prisma/models/DbResource.d.mts.map +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 -13
- package/dist/middleware/database.mjs.map +1 -1
- package/dist/modules/appCatalog/freshness.mjs +5 -3
- package/dist/modules/appCatalog/freshness.mjs.map +1 -1
- package/dist/modules/appCatalog/service.mjs +5 -1
- package/dist/modules/appCatalog/service.mjs.map +1 -1
- package/dist/modules/assets/assetCache.mjs +25 -0
- package/dist/modules/assets/assetCache.mjs.map +1 -0
- package/dist/modules/assets/assetRestController.d.mts.map +1 -1
- package/dist/modules/assets/assetRestController.mjs +17 -6
- package/dist/modules/assets/assetRestController.mjs.map +1 -1
- package/dist/modules/assets/assetUtils.mjs +1 -0
- package/dist/modules/assets/assetUtils.mjs.map +1 -1
- package/dist/modules/assets/screenshotRestController.d.mts.map +1 -1
- package/dist/modules/assets/screenshotRestController.mjs +7 -2
- package/dist/modules/assets/screenshotRestController.mjs.map +1 -1
- package/dist/modules/assets/upsertAsset.mjs +7 -1
- package/dist/modules/assets/upsertAsset.mjs.map +1 -1
- package/dist/modules/icons/iconRestController.d.mts.map +1 -1
- package/dist/modules/icons/iconRestController.mjs +7 -2
- package/dist/modules/icons/iconRestController.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/dist/types/common/appCatalogTypes.d.mts +8 -0
- package/dist/types/common/appCatalogTypes.d.mts.map +1 -1
- package/package.json +4 -4
- package/prisma/schema.prisma +5 -2
- package/src/__tests__/assetMime.test.ts +74 -0
- package/src/__tests__/dbSchema.test.ts +129 -0
- package/src/__tests__/dbSchemaAdapter.test.ts +118 -0
- package/src/__tests__/freshness.test.ts +56 -17
- package/src/__tests__/iconCache.test.ts +81 -0
- package/src/db/client.ts +118 -12
- package/src/db/index.ts +10 -1
- package/src/db/syncAppCatalog.ts +3 -0
- package/src/generated/prisma/internal/class.ts +4 -4
- package/src/generated/prisma/internal/prismaNamespace.ts +1 -0
- package/src/generated/prisma/internal/prismaNamespaceBrowser.ts +1 -0
- package/src/generated/prisma/models/DbResource.ts +44 -1
- package/src/index.ts +4 -0
- package/src/middleware/database.ts +20 -19
- package/src/modules/admin/chat/createDatabaseTools.ts +6 -2
- package/src/modules/appCatalog/freshness.ts +29 -11
- package/src/modules/appCatalog/service.ts +9 -4
- package/src/modules/assets/assetCache.ts +30 -0
- package/src/modules/assets/assetRestController.ts +25 -2
- package/src/modules/assets/assetUtils.ts +1 -0
- package/src/modules/assets/screenshotRestController.ts +13 -1
- package/src/modules/assets/upsertAsset.ts +8 -0
- package/src/modules/icons/iconRestController.ts +11 -1
- package/src/modules/lighthouseKeeper/tools.ts +6 -7
- 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
|
|
13
|
-
expect(computeFreshness(
|
|
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(
|
|
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(
|
|
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(
|
|
33
|
-
|
|
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(
|
|
40
|
-
|
|
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
|
-
|
|
49
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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 {
|
|
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/db/syncAppCatalog.ts
CHANGED
|
@@ -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
|
|