@postgres.ai/shared 4.1.3 → 4.2.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 (137) hide show
  1. package/.gitlab-ci.yml +7 -3
  2. package/components/UpgradeCloneModal/index.tsx +145 -0
  3. package/dist/components/UpgradeCloneModal/index.d.ts +11 -0
  4. package/dist/components/UpgradeCloneModal/index.js +75 -0
  5. package/dist/package.json +2 -2
  6. package/dist/pages/Branches/Branch/index.js +27 -2
  7. package/dist/pages/Branches/Branch/stores/Main.d.ts +5 -0
  8. package/dist/pages/Branches/Branch/stores/Main.js +29 -0
  9. package/dist/pages/Branches/components/BranchesTable/index.js +2 -2
  10. package/dist/pages/Branches/index.js +10 -1
  11. package/dist/pages/Clone/Status/index.js +3 -0
  12. package/dist/pages/Clone/index.js +12 -4
  13. package/dist/pages/Clone/stores/Main.d.ts +9 -1
  14. package/dist/pages/Clone/stores/Main.js +40 -3
  15. package/dist/pages/CreateBranch/stores/Main.d.ts +3 -0
  16. package/dist/pages/CreateClone/index.js +2 -0
  17. package/dist/pages/CreateClone/stores/Main.d.ts +5 -1
  18. package/dist/pages/CreateClone/stores/Main.js +8 -3
  19. package/dist/pages/Instance/Configuration/PhysicalMode/EnvsEditor/index.d.ts +11 -0
  20. package/dist/pages/Instance/Configuration/PhysicalMode/EnvsEditor/index.js +24 -0
  21. package/dist/pages/Instance/Configuration/PhysicalMode/PgBackRest/index.d.ts +10 -0
  22. package/dist/pages/Instance/Configuration/PhysicalMode/PgBackRest/index.js +29 -0
  23. package/dist/pages/Instance/Configuration/PhysicalMode/Sync/index.d.ts +9 -0
  24. package/dist/pages/Instance/Configuration/PhysicalMode/Sync/index.js +14 -0
  25. package/dist/pages/Instance/Configuration/PhysicalMode/Walg/index.d.ts +10 -0
  26. package/dist/pages/Instance/Configuration/PhysicalMode/Walg/index.js +21 -0
  27. package/dist/pages/Instance/Configuration/PhysicalMode/index.d.ts +10 -0
  28. package/dist/pages/Instance/Configuration/PhysicalMode/index.js +17 -0
  29. package/dist/pages/Instance/Configuration/SimpleMode/PreviewCard.d.ts +13 -0
  30. package/dist/pages/Instance/Configuration/SimpleMode/PreviewCard.js +14 -0
  31. package/dist/pages/Instance/Configuration/SimpleMode/index.d.ts +19 -0
  32. package/dist/pages/Instance/Configuration/SimpleMode/index.js +166 -0
  33. package/dist/pages/Instance/Configuration/configMode.d.ts +2 -0
  34. package/dist/pages/Instance/Configuration/configMode.js +7 -0
  35. package/dist/pages/Instance/Configuration/configOptions.d.ts +16 -0
  36. package/dist/pages/Instance/Configuration/configOptions.js +71 -0
  37. package/dist/pages/Instance/Configuration/connectionString.d.ts +20 -0
  38. package/dist/pages/Instance/Configuration/connectionString.js +129 -0
  39. package/dist/pages/Instance/Configuration/dockerCatalog.d.ts +2 -0
  40. package/dist/pages/Instance/Configuration/dockerCatalog.js +19 -0
  41. package/dist/pages/Instance/Configuration/index.d.ts +1 -2
  42. package/dist/pages/Instance/Configuration/index.js +115 -86
  43. package/dist/pages/Instance/Configuration/useForm.d.ts +20 -0
  44. package/dist/pages/Instance/Configuration/useForm.js +126 -5
  45. package/dist/pages/Instance/Configuration/utils/index.js +1 -17
  46. package/dist/pages/Instance/Info/Snapshots/Calendar/Day/index.js +3 -3
  47. package/dist/pages/Instance/Info/Snapshots/Calendar/utils.d.ts +3 -0
  48. package/dist/pages/Instance/Info/Snapshots/utils.d.ts +6 -0
  49. package/dist/pages/Instance/Snapshots/components/SnapshotsList/index.js +11 -1
  50. package/dist/pages/Instance/Snapshots/index.js +11 -3
  51. package/dist/pages/Instance/Snapshots/utils/index.d.ts +3 -0
  52. package/dist/pages/Instance/Tabs/styles.js +6 -6
  53. package/dist/pages/Instance/index.js +1 -3
  54. package/dist/pages/Instance/stores/Main.d.ts +22 -2
  55. package/dist/pages/Instance/stores/Main.js +26 -9
  56. package/dist/pages/Snapshots/Snapshot/index.js +28 -2
  57. package/dist/pages/Snapshots/Snapshot/stores/Main.d.ts +5 -0
  58. package/dist/pages/Snapshots/Snapshot/stores/Main.js +29 -0
  59. package/dist/stores/Snapshots.d.ts +1 -1
  60. package/dist/stores/Snapshots.js +12 -6
  61. package/dist/types/api/endpoints/getBranches.d.ts +6 -0
  62. package/dist/types/api/endpoints/probeSource.d.ts +32 -0
  63. package/dist/types/api/endpoints/probeSource.js +1 -0
  64. package/dist/types/api/endpoints/testDbSource.d.ts +3 -1
  65. package/dist/types/api/endpoints/testDbSource.js +6 -0
  66. package/dist/types/api/endpoints/updateBranch.d.ts +11 -0
  67. package/dist/types/api/endpoints/updateBranch.js +1 -0
  68. package/dist/types/api/endpoints/updateSnapshot.d.ts +11 -0
  69. package/dist/types/api/endpoints/updateSnapshot.js +1 -0
  70. package/dist/types/api/endpoints/upgradeClone.d.ts +8 -0
  71. package/dist/types/api/endpoints/upgradeClone.js +1 -0
  72. package/dist/types/api/entities/branchSnapshots.d.ts +3 -0
  73. package/dist/types/api/entities/clone.d.ts +9 -2
  74. package/dist/types/api/entities/config.d.ts +32 -0
  75. package/dist/types/api/entities/config.js +45 -18
  76. package/dist/types/api/entities/instance.d.ts +11 -1
  77. package/dist/types/api/entities/instanceState.d.ts +16 -1
  78. package/dist/types/api/entities/snapshot.d.ts +6 -0
  79. package/dist/utils/api.d.ts +1 -1
  80. package/dist/utils/api.js +6 -0
  81. package/dist/utils/clone.d.ts +3 -1
  82. package/dist/utils/clone.js +7 -1
  83. package/dist/utils/clonePoller.d.ts +12 -0
  84. package/dist/utils/clonePoller.js +41 -0
  85. package/dist/utils/date.js +12 -12
  86. package/dist/utils/protection.d.ts +5 -0
  87. package/dist/utils/protection.js +23 -0
  88. package/package.json +2 -2
  89. package/pages/Branches/Branch/index.tsx +70 -0
  90. package/pages/Branches/Branch/stores/Main.ts +40 -0
  91. package/pages/Branches/components/BranchesTable/index.tsx +2 -0
  92. package/pages/Branches/index.tsx +13 -1
  93. package/pages/Clone/Status/index.tsx +3 -0
  94. package/pages/Clone/index.tsx +58 -0
  95. package/pages/Clone/stores/Main.ts +51 -8
  96. package/pages/CreateClone/index.tsx +3 -0
  97. package/pages/CreateClone/stores/Main.ts +10 -8
  98. package/pages/Instance/Configuration/PhysicalMode/EnvsEditor/index.tsx +129 -0
  99. package/pages/Instance/Configuration/PhysicalMode/PgBackRest/index.tsx +89 -0
  100. package/pages/Instance/Configuration/PhysicalMode/Sync/index.tsx +64 -0
  101. package/pages/Instance/Configuration/PhysicalMode/Walg/index.tsx +63 -0
  102. package/pages/Instance/Configuration/PhysicalMode/index.tsx +93 -0
  103. package/pages/Instance/Configuration/SimpleMode/PreviewCard.tsx +176 -0
  104. package/pages/Instance/Configuration/SimpleMode/index.tsx +303 -0
  105. package/pages/Instance/Configuration/configMode.ts +12 -0
  106. package/pages/Instance/Configuration/configOptions.ts +111 -0
  107. package/pages/Instance/Configuration/connectionString.ts +157 -0
  108. package/pages/Instance/Configuration/dockerCatalog.ts +21 -0
  109. package/pages/Instance/Configuration/index.tsx +151 -49
  110. package/pages/Instance/Configuration/useForm.ts +155 -5
  111. package/pages/Instance/Configuration/utils/index.ts +1 -17
  112. package/pages/Instance/Info/Snapshots/Calendar/Day/index.tsx +3 -3
  113. package/pages/Instance/Snapshots/components/SnapshotsList/index.tsx +21 -1
  114. package/pages/Instance/Snapshots/index.tsx +15 -5
  115. package/pages/Instance/Tabs/styles.ts +7 -7
  116. package/pages/Instance/index.tsx +0 -4
  117. package/pages/Instance/stores/Main.ts +29 -7
  118. package/pages/Snapshots/Snapshot/index.tsx +71 -0
  119. package/pages/Snapshots/Snapshot/stores/Main.ts +40 -0
  120. package/postgres.ai-shared-4.2.0.tgz +0 -0
  121. package/stores/Snapshots.ts +16 -7
  122. package/types/api/endpoints/getBranches.ts +3 -0
  123. package/types/api/endpoints/probeSource.ts +34 -0
  124. package/types/api/endpoints/testDbSource.ts +10 -1
  125. package/types/api/endpoints/updateBranch.ts +11 -0
  126. package/types/api/endpoints/updateSnapshot.ts +11 -0
  127. package/types/api/endpoints/upgradeClone.ts +8 -0
  128. package/types/api/entities/clone.ts +13 -1
  129. package/types/api/entities/config.ts +46 -0
  130. package/types/api/entities/instanceState.ts +6 -0
  131. package/types/api/entities/snapshot.ts +3 -0
  132. package/utils/api.ts +8 -0
  133. package/utils/clone.ts +7 -1
  134. package/utils/clonePoller.ts +47 -0
  135. package/utils/date.ts +19 -14
  136. package/utils/protection.ts +25 -0
  137. package/postgres.ai-shared-4.1.3.tgz +0 -0
@@ -0,0 +1,303 @@
1
+ import { useState } from 'react'
2
+ import { observer } from 'mobx-react-lite'
3
+ import { Button, TextField, Typography } from '@material-ui/core'
4
+ import Box from '@mui/material/Box'
5
+
6
+ import { Spinner } from '@postgres.ai/shared/components/Spinner'
7
+ import { useStores } from '@postgres.ai/shared/pages/Instance/context'
8
+ import { SeImages } from '@postgres.ai/shared/types/api/endpoints/getSeImages'
9
+ import { ProposedConfig } from '@postgres.ai/shared/types/api/endpoints/probeSource'
10
+
11
+ import {
12
+ ResolvedImage,
13
+ providerKeyToImage,
14
+ resolveDockerImagePath,
15
+ selectSeImage,
16
+ } from '../configOptions'
17
+ import { FormValues } from '../useForm'
18
+ import { PreviewCard } from './PreviewCard'
19
+
20
+ type Props = {
21
+ instanceId: string
22
+ disabled?: boolean
23
+ onApplied?: () => void
24
+ onEdit?: (proposed: ProposedConfig, password: string) => void
25
+ }
26
+
27
+ // genericFallbackImage resolves the generic CE image for a probed source. Used
28
+ // when no SE image is available (CE, or a managed provider with no SE image for
29
+ // the detected major version).
30
+ const genericFallbackImage = (proposed: ProposedConfig): ResolvedImage => {
31
+ const mapping = providerKeyToImage(
32
+ proposed.dockerImage,
33
+ proposed.pgMajorVersion,
34
+ )
35
+ const isGeneric = mapping.imageType === 'Generic Postgres'
36
+
37
+ return {
38
+ dockerPath: resolveDockerImagePath(
39
+ proposed.dockerImage,
40
+ proposed.pgMajorVersion,
41
+ proposed.dockerTag,
42
+ ),
43
+ dockerTag: isGeneric ? proposed.dockerTag || mapping.defaultTag || '' : '',
44
+ sharedPreloadLibraries: proposed.sharedPreloadLibraries,
45
+ isSe: false,
46
+ }
47
+ }
48
+
49
+ // Translates a ProposedConfig from POST /admin/probe-source into the
50
+ // FormValues shape the Expert form uses. Both Apply (→ updateConfig) and
51
+ // Edit (→ formik.setValues) consume it, so the engine receives the same
52
+ // projection regardless of which flow the user picks. The resolved image (SE
53
+ // or generic) is passed in so the preview and the applied config never diverge.
54
+ export const buildProjectionFromProposed = (
55
+ proposed: ProposedConfig,
56
+ password: string,
57
+ resolved?: ResolvedImage,
58
+ ): FormValues => {
59
+ const mapping = providerKeyToImage(
60
+ proposed.dockerImage,
61
+ proposed.pgMajorVersion,
62
+ )
63
+ const isGeneric = mapping.imageType === 'Generic Postgres'
64
+ const image = resolved ?? genericFallbackImage(proposed)
65
+
66
+ return {
67
+ debug: false,
68
+ dockerImage: isGeneric
69
+ ? String(proposed.pgMajorVersion)
70
+ : mapping.imageType,
71
+ dockerImageType: mapping.imageType,
72
+ dockerPath: image.dockerPath,
73
+ dockerTag: image.dockerTag,
74
+ sharedBuffers: proposed.sharedBuffers,
75
+ sharedPreloadLibraries:
76
+ image.sharedPreloadLibraries ?? proposed.sharedPreloadLibraries,
77
+ // tuningParams is typed as string on FormValues but updateConfig.ts
78
+ // spreads it as a key-value object; cast matches the Expert form's
79
+ // formatTuningParamsToObj(...) as unknown as string pattern.
80
+ tuningParams: { ...proposed.queryTuning } as unknown as string,
81
+ timetable: '0 0 * * *',
82
+ dbname: proposed.source.dbname,
83
+ host: proposed.source.host,
84
+ port: String(proposed.source.port),
85
+ username: proposed.source.username,
86
+ password,
87
+ databases: proposed.databases.join(' '),
88
+ dumpParallelJobs: '',
89
+ dumpIgnoreErrors: false,
90
+ restoreParallelJobs: '',
91
+ // managed sources reference cloud-only extensions/objects (e.g. supabase_vault)
92
+ // that are absent from the clone image; best-effort restore skips them.
93
+ restoreIgnoreErrors: true,
94
+ restoreConfigs: '',
95
+ pgDumpCustomOptions: '',
96
+ // skip ownership and privileges on restore: managed sources (Supabase, RDS)
97
+ // reference roles that do not exist in the clone, which would abort pg_restore.
98
+ pgRestoreCustomOptions: '--no-owner --no-privileges --no-tablespaces',
99
+ retrievalMode: 'logical',
100
+ physicalTool: '',
101
+ physicalDockerImage: '',
102
+ physicalSyncEnabled: false,
103
+ physicalWalgBackupName: '',
104
+ physicalPgbackrestStanza: '',
105
+ physicalPgbackrestDelta: false,
106
+ physicalEnvs: [],
107
+ }
108
+ }
109
+
110
+ // resolveProbeImage selects the platform SE image for a managed provider when
111
+ // the SE catalog is reachable — SE/Enterprise instances expose it (platformUrl
112
+ // set), CE returns undefined. Falls back to the generic image for CE and for
113
+ // managed providers with no SE image at the detected major version.
114
+ export const resolveProbeImage = async (
115
+ proposed: ProposedConfig,
116
+ getSeImages: (args: {
117
+ packageGroup: string
118
+ }) => Promise<SeImages[] | null | undefined>,
119
+ ): Promise<ResolvedImage> => {
120
+ const mapping = providerKeyToImage(
121
+ proposed.dockerImage,
122
+ proposed.pgMajorVersion,
123
+ )
124
+
125
+ if (mapping.imageType !== 'Generic Postgres') {
126
+ const seImages = await getSeImages({ packageGroup: mapping.imageType })
127
+ const se = selectSeImage(seImages, proposed.pgMajorVersion)
128
+
129
+ if (se) {
130
+ return {
131
+ dockerPath: se.location,
132
+ dockerTag: se.tag,
133
+ sharedPreloadLibraries: se.pg_config_presets?.shared_preload_libraries,
134
+ isSe: true,
135
+ }
136
+ }
137
+ }
138
+
139
+ return genericFallbackImage(proposed)
140
+ }
141
+
142
+ export const SimpleMode = observer(
143
+ ({ instanceId, disabled, onApplied, onEdit }: Props) => {
144
+ const stores = useStores()
145
+ const main = stores.main
146
+
147
+ const [url, setUrl] = useState('')
148
+ const [password, setPassword] = useState('')
149
+ const [probing, setProbing] = useState(false)
150
+ const [probeError, setProbeError] = useState<string | null>(null)
151
+ const [proposed, setProposed] = useState<ProposedConfig | null>(null)
152
+ const [resolved, setResolved] = useState<ResolvedImage | null>(null)
153
+ const [applying, setApplying] = useState(false)
154
+ const [applyError, setApplyError] = useState<string | null>(null)
155
+
156
+ const onDetect = async () => {
157
+ setProbing(true)
158
+ setProbeError(null)
159
+ setProposed(null)
160
+ setResolved(null)
161
+ setApplyError(null)
162
+
163
+ const result = await main.probeSource({ url, password })
164
+
165
+ if (!result) {
166
+ setProbing(false)
167
+ setProbeError('Probe is not available on this instance.')
168
+ return
169
+ }
170
+ if (result.error) {
171
+ setProbing(false)
172
+ setProbeError(result.error.message)
173
+ return
174
+ }
175
+ if (result.response) {
176
+ const image = await resolveProbeImage(result.response, main.getSeImages)
177
+ setResolved(image)
178
+ setProposed(result.response)
179
+ }
180
+
181
+ setProbing(false)
182
+ }
183
+
184
+ const onApply = async () => {
185
+ if (!proposed || !resolved) return
186
+
187
+ setApplying(true)
188
+ setApplyError(null)
189
+
190
+ try {
191
+ const projection = buildProjectionFromProposed(proposed, password, resolved)
192
+ const response = await main.updateConfig(projection, instanceId)
193
+
194
+ if (!response) {
195
+ setApplyError(
196
+ main.configError ?? 'Could not apply the proposed configuration.',
197
+ )
198
+ return
199
+ }
200
+
201
+ const refresh = await main.fullRefresh(instanceId)
202
+
203
+ if (refresh?.error) {
204
+ setApplyError(
205
+ `Configuration applied, but starting data retrieval failed: ${refresh.error.message}`,
206
+ )
207
+ return
208
+ }
209
+
210
+ onApplied?.()
211
+ } catch (err) {
212
+ setApplyError(
213
+ err instanceof Error
214
+ ? err.message
215
+ : 'Could not apply the proposed configuration.',
216
+ )
217
+ } finally {
218
+ setApplying(false)
219
+ }
220
+ }
221
+
222
+ const handleEdit = () => {
223
+ if (proposed) onEdit?.(proposed, password)
224
+ }
225
+
226
+ const canDetect =
227
+ !probing && url.trim().length > 0 && password.length > 0 && !disabled
228
+
229
+ return (
230
+ <Box mt={2} mb={2} data-testid="simple-mode">
231
+ {!proposed && (
232
+ <Box>
233
+ <Typography variant="h6">Simple configuration</Typography>
234
+ <Typography variant="body2">
235
+ Paste your source connection string and password. We'll probe
236
+ the source, propose a configuration, and let you review before
237
+ starting retrieval.
238
+ </Typography>
239
+
240
+ <Box mt={2}>
241
+ <TextField
242
+ label="Connection string"
243
+ placeholder="postgres://user@host:5432/dbname"
244
+ value={url}
245
+ onChange={(e) => setUrl(e.target.value)}
246
+ fullWidth
247
+ disabled={probing || disabled}
248
+ inputProps={{ 'data-testid': 'simple-url' }}
249
+ />
250
+ </Box>
251
+
252
+ <Box mt={2}>
253
+ <TextField
254
+ label="Password"
255
+ type="password"
256
+ value={password}
257
+ onChange={(e) => setPassword(e.target.value)}
258
+ fullWidth
259
+ disabled={probing || disabled}
260
+ inputProps={{ 'data-testid': 'simple-password' }}
261
+ />
262
+ </Box>
263
+
264
+ <Box mt={2}>
265
+ <Button
266
+ variant="contained"
267
+ color="secondary"
268
+ onClick={onDetect}
269
+ disabled={!canDetect}
270
+ data-testid="simple-detect"
271
+ >
272
+ Detect &amp; preview
273
+ {probing && <Spinner size="sm" />}
274
+ </Button>
275
+ </Box>
276
+
277
+ {probeError && (
278
+ <Box
279
+ mt={1}
280
+ color="#d32f2f"
281
+ fontSize={13}
282
+ data-testid="probe-error"
283
+ >
284
+ {probeError}
285
+ </Box>
286
+ )}
287
+ </Box>
288
+ )}
289
+
290
+ {proposed && resolved && (
291
+ <PreviewCard
292
+ proposed={proposed}
293
+ resolved={resolved}
294
+ applying={applying}
295
+ applyError={applyError}
296
+ onApply={onApply}
297
+ onEdit={handleEdit}
298
+ />
299
+ )}
300
+ </Box>
301
+ )
302
+ },
303
+ )
@@ -0,0 +1,12 @@
1
+ export type ConfigMode = 'simple' | 'expert'
2
+
3
+ // Picks the default Configuration tab when the page first finishes loading
4
+ // the server config. An unconfigured instance lands on Simple; an instance
5
+ // that already has a source host filled in OR a non-logical retrieval mode
6
+ // configured lands on Expert so the user sees the form they previously
7
+ // interacted with. Physical-mode users do not have a host field but still
8
+ // need to land on Expert because Simple-mode targets logical retrieval.
9
+ export const getInitialConfigMode = (
10
+ host: string | undefined,
11
+ retrievalMode?: string,
12
+ ): ConfigMode => (host || retrievalMode === 'physical' ? 'expert' : 'simple')
@@ -1,3 +1,114 @@
1
+ import { SeImages } from '@postgres.ai/shared/types/api/endpoints/getSeImages'
2
+
3
+ import { dockerImagesConfig, genericImagePrefix } from './dockerCatalog'
4
+
5
+ // Mapping from the engine probe's provider key (probe.Provider) to a
6
+ // dockerImageOptions.type value the form already understands. Keys must
7
+ // match the values defined in engine/internal/retrieval/probe/provider.go
8
+ // (ProviderRDS, ProviderAurora, etc.) — see Task 18 in the plan.
9
+ const providerKeyToImageType: Record<string, string> = {
10
+ generic: 'Generic Postgres',
11
+ rds: 'rds',
12
+ aurora: 'aurora',
13
+ cloudsql: 'google-cloud-sql',
14
+ supabase: 'supabase',
15
+ heroku: 'heroku',
16
+ timescale: 'timescale-cloud',
17
+ }
18
+
19
+ export type ProviderImageMapping = {
20
+ imageType: string
21
+ defaultTag?: string
22
+ fallback: boolean
23
+ }
24
+
25
+ // Reads the most recent tag for a given PG major from the shared catalog.
26
+ // SE images (rds, aurora, etc.) require the platform-only getSeImages call
27
+ // and have no entry here — they return defaultTag: undefined.
28
+ const genericDefaultTag = (pgMajorVersion: number): string | undefined => {
29
+ if (!pgMajorVersion) return undefined
30
+ const version = String(pgMajorVersion)
31
+ const tags = dockerImagesConfig[version]
32
+ if (!tags || tags.length === 0) return undefined
33
+ return `${version}-${tags[0]}`
34
+ }
35
+
36
+ // genericImagePathForVersion returns a pullable generic extended-postgres
37
+ // reference for a PG major version, or '' when the version is unknown. Simple
38
+ // mode uses it as the CE fallback for managed providers, whose SE images are
39
+ // only resolvable through the platform-only getSeImages call.
40
+ export const genericImagePathForVersion = (pgMajorVersion: number): string => {
41
+ const tag = genericDefaultTag(pgMajorVersion)
42
+ return tag ? `${genericImagePrefix}:${tag}` : ''
43
+ }
44
+
45
+ // ResolvedImage is the concrete image Simple mode ships for a probed source:
46
+ // either a platform SE image (carrying its curated preload-library preset) or
47
+ // the generic CE fallback. Apply and the preview both consume it, so they show
48
+ // and post the same thing.
49
+ export type ResolvedImage = {
50
+ dockerPath: string
51
+ dockerTag: string
52
+ sharedPreloadLibraries?: string
53
+ isSe: boolean
54
+ }
55
+
56
+ // selectSeImage picks the SE catalog entry matching the detected major version.
57
+ // Returns undefined when the catalog is empty (CE) or has no matching version,
58
+ // which signals the caller to fall back to the generic image.
59
+ export const selectSeImage = (
60
+ seImages: SeImages[] | null | undefined,
61
+ pgMajorVersion: number,
62
+ ): SeImages | undefined =>
63
+ seImages?.find((image) => image.pg_major_version === String(pgMajorVersion))
64
+
65
+ // resolveDockerImagePath returns the concrete image reference Simple mode ships
66
+ // for a probed source. Managed providers fall back to the generic image in CE,
67
+ // so both the Apply payload and the preview show the same value.
68
+ export const resolveDockerImagePath = (
69
+ providerKey: string,
70
+ pgMajorVersion: number,
71
+ dockerTag?: string,
72
+ ): string => {
73
+ const mapping = providerKeyToImage(providerKey, pgMajorVersion)
74
+ const tag = dockerTag || mapping.defaultTag || ''
75
+
76
+ if (mapping.imageType === 'Generic Postgres') {
77
+ return tag ? `${genericImagePrefix}:${tag}` : ''
78
+ }
79
+
80
+ return genericImagePathForVersion(pgMajorVersion)
81
+ }
82
+
83
+ // Resolves a probe provider key to a concrete docker image type the
84
+ // Configuration form can write into the projection. Unknown keys (including
85
+ // "azure", which has no matching SE image today) fall back to the generic
86
+ // Postgres image and set fallback=true so the UI can warn.
87
+ export const providerKeyToImage = (
88
+ providerKey: string,
89
+ pgMajorVersion: number,
90
+ ): ProviderImageMapping => {
91
+ const known = providerKeyToImageType[providerKey]
92
+
93
+ if (!known) {
94
+ return {
95
+ imageType: 'Generic Postgres',
96
+ defaultTag: genericDefaultTag(pgMajorVersion),
97
+ fallback: true,
98
+ }
99
+ }
100
+
101
+ if (known === 'Generic Postgres') {
102
+ return {
103
+ imageType: 'Generic Postgres',
104
+ defaultTag: genericDefaultTag(pgMajorVersion),
105
+ fallback: false,
106
+ }
107
+ }
108
+
109
+ return { imageType: known, defaultTag: undefined, fallback: false }
110
+ }
111
+
1
112
  export const dockerImageOptions = [
2
113
  {
3
114
  name: 'Generic PostgreSQL (postgresai/extended-postgres)',
@@ -0,0 +1,157 @@
1
+ // connectionString.ts — Expert-mode helper that converts between the
2
+ // (host, port, username, dbname) shape stored in `server.yml` and the
3
+ // single URL field the form exposes. The engine remains the source of
4
+ // truth for actual probing (see engine/internal/retrieval/probe/parser.go);
5
+ // this parser only powers the form's load/save round-trip.
6
+ //
7
+ // Accepts both URI form (postgres://user@host:5432/dbname) and DSN form
8
+ // (host=db port=5432 user=app dbname=shop). Rejects any input that carries
9
+ // a password — passwords belong in the masked field, never the URL field.
10
+
11
+ export type ConnectionFields = {
12
+ host: string
13
+ port: string
14
+ username: string
15
+ dbname: string
16
+ }
17
+
18
+ export type ParseResult = {
19
+ fields: ConnectionFields
20
+ portWasExplicit: boolean
21
+ }
22
+
23
+ export class ConnectionStringError extends Error {}
24
+
25
+ export const ErrPasswordInConnectionString = 'Password must be entered in the Password field, not the connection string.'
26
+ export const ErrMultiHost = 'Multi-host connection strings are not supported.'
27
+ export const ErrInvalidConnectionString = 'Could not parse the connection string.'
28
+
29
+ const URI_SCHEMES = ['postgres:', 'postgresql:']
30
+
31
+ const isUri = (s: string) => URI_SCHEMES.some((p) => s.toLowerCase().startsWith(p))
32
+
33
+ const stripBrackets = (h: string) => (h.startsWith('[') && h.endsWith(']') ? h.slice(1, -1) : h)
34
+
35
+ const parseUri = (s: string): ParseResult => {
36
+ let url: URL
37
+ try {
38
+ url = new URL(s)
39
+ } catch {
40
+ throw new ConnectionStringError(ErrInvalidConnectionString)
41
+ }
42
+
43
+ if (url.password) throw new ConnectionStringError(ErrPasswordInConnectionString)
44
+
45
+ if (!url.hostname) throw new ConnectionStringError(ErrInvalidConnectionString)
46
+ if (url.hostname.includes(',')) throw new ConnectionStringError(ErrMultiHost)
47
+
48
+ const portWasExplicit = url.port !== ''
49
+ const path = url.pathname.startsWith('/') ? url.pathname.slice(1) : url.pathname
50
+ const dbname = path.includes(',') ? '' : decodeURIComponent(path)
51
+
52
+ return {
53
+ fields: {
54
+ host: stripBrackets(url.hostname),
55
+ port: portWasExplicit ? url.port : '5432',
56
+ username: url.username ? decodeURIComponent(url.username) : '',
57
+ dbname,
58
+ },
59
+ portWasExplicit,
60
+ }
61
+ }
62
+
63
+ // Tokenizer for libpq DSN form: key=value pairs, values may be single-quoted
64
+ // when they contain spaces (libpq spec). Throws on malformed input.
65
+ const tokenizeDsn = (s: string): Record<string, string> => {
66
+ const out: Record<string, string> = {}
67
+ let i = 0
68
+
69
+ while (i < s.length) {
70
+ // skip leading whitespace
71
+ while (i < s.length && /\s/.test(s[i])) i++
72
+ if (i >= s.length) break
73
+
74
+ // read key
75
+ const keyStart = i
76
+ while (i < s.length && s[i] !== '=' && !/\s/.test(s[i])) i++
77
+ if (i >= s.length || s[i] !== '=') throw new ConnectionStringError(ErrInvalidConnectionString)
78
+ const key = s.slice(keyStart, i)
79
+ i++ // skip '='
80
+
81
+ // read value (possibly quoted)
82
+ let value = ''
83
+ if (s[i] === "'") {
84
+ i++
85
+ while (i < s.length && s[i] !== "'") {
86
+ if (s[i] === '\\' && i + 1 < s.length) {
87
+ value += s[i + 1]
88
+ i += 2
89
+ continue
90
+ }
91
+ value += s[i]
92
+ i++
93
+ }
94
+ if (i >= s.length) throw new ConnectionStringError(ErrInvalidConnectionString)
95
+ i++ // skip closing quote
96
+ } else {
97
+ while (i < s.length && !/\s/.test(s[i])) {
98
+ value += s[i]
99
+ i++
100
+ }
101
+ }
102
+
103
+ out[key.toLowerCase()] = value
104
+ }
105
+
106
+ return out
107
+ }
108
+
109
+ const parseDsn = (s: string): ParseResult => {
110
+ const tokens = tokenizeDsn(s)
111
+ if (tokens.password) throw new ConnectionStringError(ErrPasswordInConnectionString)
112
+
113
+ const host = tokens.host ?? tokens.hostaddr ?? ''
114
+ if (host.includes(',')) throw new ConnectionStringError(ErrMultiHost)
115
+
116
+ const portRaw = tokens.port ?? ''
117
+ const portWasExplicit = portRaw !== ''
118
+
119
+ return {
120
+ fields: {
121
+ host,
122
+ port: portWasExplicit ? portRaw : '5432',
123
+ username: tokens.user ?? tokens.username ?? '',
124
+ dbname: tokens.dbname ?? tokens.database ?? '',
125
+ },
126
+ portWasExplicit,
127
+ }
128
+ }
129
+
130
+ export const connectionStringToFields = (s: string): ParseResult => {
131
+ const trimmed = s.trim()
132
+ if (!trimmed) throw new ConnectionStringError(ErrInvalidConnectionString)
133
+
134
+ if (isUri(trimmed)) return parseUri(trimmed)
135
+ if (trimmed.includes('=')) return parseDsn(trimmed)
136
+ throw new ConnectionStringError(ErrInvalidConnectionString)
137
+ }
138
+
139
+ // formatHost wraps IPv6 literals in brackets so the resulting URI parses cleanly.
140
+ const formatHost = (host: string) => (host.includes(':') ? `[${host}]` : host)
141
+
142
+ export type SerializeOptions = {
143
+ // When true, omit `:5432` from the URI even if `fields.port === '5432'`.
144
+ // Used to preserve configs that originally had no `port:` key.
145
+ omitDefaultPort?: boolean
146
+ }
147
+
148
+ export const connectionStringFromFields = (
149
+ fields: ConnectionFields,
150
+ opts: SerializeOptions = {},
151
+ ): string => {
152
+ const userPart = fields.username ? `${encodeURIComponent(fields.username)}@` : ''
153
+ const showPort = fields.port && !(opts.omitDefaultPort && fields.port === '5432')
154
+ const portPart = showPort ? `:${fields.port}` : ''
155
+ const dbPart = fields.dbname ? `/${encodeURIComponent(fields.dbname)}` : ''
156
+ return `postgres://${userPart}${formatHost(fields.host)}${portPart}${dbPart}`
157
+ }
@@ -0,0 +1,21 @@
1
+ // Shared image-catalog constants. Kept in its own module so both
2
+ // `configOptions.ts` and `utils/index.ts` can read them without creating
3
+ // an import cycle (utils → configOptions today).
4
+
5
+ export const genericImagePrefix = 'postgresai/extended-postgres'
6
+
7
+ // Predefined Docker image catalog for the Generic Postgres image. If a
8
+ // user's config references a tag not listed here, createEnhancedDockerImages
9
+ // in utils/index.ts appends it at runtime.
10
+ export const dockerImagesConfig: Record<string, string[]> = {
11
+ '9.6': ['0.5.3', '0.5.2', '0.5.1'],
12
+ '10': ['0.5.3', '0.5.2', '0.5.1'],
13
+ '11': ['0.5.3', '0.5.2', '0.5.1'],
14
+ '12': ['0.5.3', '0.5.2', '0.5.1'],
15
+ '13': ['0.5.3', '0.5.2', '0.5.1'],
16
+ '14': ['0.5.3', '0.5.2', '0.5.1'],
17
+ '15': ['0.5.3', '0.5.2', '0.5.1'],
18
+ '16': ['0.5.3', '0.5.2', '0.5.1'],
19
+ '17': ['0.5.3', '0.5.2', '0.5.1'],
20
+ '18': ['0.6.1'],
21
+ }