spindb 0.34.3 → 0.35.2
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/README.md +4 -4
- package/cli/commands/attach.ts +38 -9
- package/cli/commands/backups.ts +5 -0
- package/cli/commands/connect.ts +6 -6
- package/cli/commands/create.ts +22 -1
- package/cli/commands/detach.ts +16 -9
- package/cli/commands/doctor.ts +2 -2
- package/cli/commands/duckdb.ts +273 -0
- package/cli/commands/edit.ts +31 -21
- package/cli/commands/engines.ts +51 -21
- package/cli/commands/info.ts +26 -16
- package/cli/commands/list.ts +44 -26
- package/cli/commands/menu/container-handlers.ts +17 -1
- package/cli/commands/menu/engine-handlers.ts +48 -29
- package/cli/commands/menu/update-handlers.ts +2 -2
- package/cli/commands/sqlite.ts +21 -0
- package/cli/index.ts +2 -0
- package/cli/ui/theme.ts +5 -2
- package/config/engines.json +2 -2
- package/core/base-binary-manager.ts +6 -2
- package/core/base-document-binary-manager.ts +5 -2
- package/core/base-embedded-binary-manager.ts +5 -2
- package/core/base-server-binary-manager.ts +5 -2
- package/core/hostdb-client.ts +157 -22
- package/core/hostdb-metadata.ts +67 -43
- package/engines/clickhouse/binary-urls.ts +1 -1
- package/engines/cockroachdb/binary-urls.ts +9 -7
- package/engines/cockroachdb/hostdb-releases.ts +18 -106
- package/engines/cockroachdb/version-maps.ts +1 -1
- package/engines/couchdb/binary-urls.ts +1 -1
- package/engines/duckdb/binary-urls.ts +1 -1
- package/engines/duckdb/index.ts +4 -74
- package/engines/duckdb/scanner.ts +22 -0
- package/engines/ferretdb/README.md +76 -38
- package/engines/ferretdb/backup.ts +18 -10
- package/engines/ferretdb/binary-manager.ts +233 -35
- package/engines/ferretdb/binary-urls.ts +69 -24
- package/engines/ferretdb/index.ts +424 -213
- package/engines/ferretdb/restore.ts +23 -16
- package/engines/ferretdb/version-maps.ts +36 -8
- package/engines/file-based-utils.ts +262 -0
- package/engines/index.ts +3 -4
- package/engines/influxdb/binary-urls.ts +1 -1
- package/engines/mariadb/binary-urls.ts +2 -2
- package/engines/meilisearch/binary-urls.ts +1 -1
- package/engines/mysql/binary-urls.ts +2 -2
- package/engines/postgresql/binary-urls.ts +1 -1
- package/engines/qdrant/binary-urls.ts +1 -1
- package/engines/questdb/binary-manager.ts +16 -9
- package/engines/questdb/binary-urls.ts +9 -10
- package/engines/questdb/hostdb-releases.ts +19 -97
- package/engines/questdb/version-maps.ts +2 -2
- package/engines/redis/binary-urls.ts +1 -8
- package/engines/sqlite/binary-urls.ts +1 -1
- package/engines/sqlite/index.ts +4 -74
- package/engines/sqlite/scanner.ts +11 -88
- package/engines/surrealdb/binary-urls.ts +9 -7
- package/engines/surrealdb/hostdb-releases.ts +18 -106
- package/engines/surrealdb/version-maps.ts +1 -1
- package/engines/typedb/binary-urls.ts +10 -8
- package/engines/typedb/hostdb-releases.ts +18 -113
- package/engines/typedb/version-maps.ts +1 -1
- package/engines/valkey/binary-urls.ts +1 -1
- package/package.json +4 -1
package/cli/commands/sqlite.ts
CHANGED
|
@@ -7,8 +7,13 @@ import {
|
|
|
7
7
|
scanForUnregisteredSqliteFiles,
|
|
8
8
|
deriveContainerName,
|
|
9
9
|
} from '../../engines/sqlite/scanner'
|
|
10
|
+
import {
|
|
11
|
+
isValidExtensionForEngine,
|
|
12
|
+
formatExtensionsForEngine,
|
|
13
|
+
} from '../../engines/file-based-utils'
|
|
10
14
|
import { containerManager } from '../../core/container-manager'
|
|
11
15
|
import { uiSuccess, uiError, uiInfo } from '../ui/theme'
|
|
16
|
+
import { Engine } from '../../types'
|
|
12
17
|
import { detachCommand } from './detach'
|
|
13
18
|
|
|
14
19
|
export const sqliteCommand = new Command('sqlite').description(
|
|
@@ -146,6 +151,22 @@ sqliteCommand
|
|
|
146
151
|
try {
|
|
147
152
|
const absolutePath = resolve(path)
|
|
148
153
|
|
|
154
|
+
// Validate extension matches SQLite
|
|
155
|
+
if (!isValidExtensionForEngine(absolutePath, Engine.SQLite)) {
|
|
156
|
+
const msg = `File extension must be one of: ${formatExtensionsForEngine(Engine.SQLite)}`
|
|
157
|
+
if (options.json) {
|
|
158
|
+
console.log(JSON.stringify({ success: false, error: msg }))
|
|
159
|
+
} else {
|
|
160
|
+
console.error(uiError(msg))
|
|
161
|
+
console.log(
|
|
162
|
+
chalk.gray(
|
|
163
|
+
' For DuckDB files, use: spindb duckdb attach <path>',
|
|
164
|
+
),
|
|
165
|
+
)
|
|
166
|
+
}
|
|
167
|
+
process.exit(1)
|
|
168
|
+
}
|
|
169
|
+
|
|
149
170
|
if (!existsSync(absolutePath)) {
|
|
150
171
|
if (options.json) {
|
|
151
172
|
console.log(
|
package/cli/index.ts
CHANGED
|
@@ -27,6 +27,7 @@ import { doctorCommand } from './commands/doctor'
|
|
|
27
27
|
import { attachCommand } from './commands/attach'
|
|
28
28
|
import { detachCommand } from './commands/detach'
|
|
29
29
|
import { sqliteCommand } from './commands/sqlite'
|
|
30
|
+
import { duckdbCommand } from './commands/duckdb'
|
|
30
31
|
import { databasesCommand } from './commands/databases'
|
|
31
32
|
import { pullCommand } from './commands/pull'
|
|
32
33
|
import { whichCommand } from './commands/which'
|
|
@@ -142,6 +143,7 @@ export async function run(): Promise<void> {
|
|
|
142
143
|
program.addCommand(attachCommand)
|
|
143
144
|
program.addCommand(detachCommand)
|
|
144
145
|
program.addCommand(sqliteCommand)
|
|
146
|
+
program.addCommand(duckdbCommand)
|
|
145
147
|
program.addCommand(databasesCommand)
|
|
146
148
|
program.addCommand(pullCommand)
|
|
147
149
|
program.addCommand(whichCommand)
|
package/cli/ui/theme.ts
CHANGED
|
@@ -117,9 +117,12 @@ export function connectionBox(
|
|
|
117
117
|
}
|
|
118
118
|
|
|
119
119
|
export function formatBytes(bytes: number): string {
|
|
120
|
-
if (bytes
|
|
120
|
+
if (!Number.isFinite(bytes) || bytes <= 0) return '0 B'
|
|
121
121
|
const units = ['B', 'KB', 'MB', 'GB', 'TB']
|
|
122
|
-
const i = Math.
|
|
122
|
+
const i = Math.max(
|
|
123
|
+
0,
|
|
124
|
+
Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1),
|
|
125
|
+
)
|
|
123
126
|
const value = bytes / Math.pow(1024, i)
|
|
124
127
|
return `${value.toFixed(1)} ${units[i]}`
|
|
125
128
|
}
|
package/config/engines.json
CHANGED
|
@@ -185,7 +185,7 @@
|
|
|
185
185
|
"icon": "🦔",
|
|
186
186
|
"status": "integrated",
|
|
187
187
|
"binarySource": "hostdb",
|
|
188
|
-
"supportedVersions": ["2.7.0"],
|
|
188
|
+
"supportedVersions": ["1.24.2", "2.7.0"],
|
|
189
189
|
"defaultVersion": "2.7.0",
|
|
190
190
|
"defaultPort": 27017,
|
|
191
191
|
"runtime": "server",
|
|
@@ -195,7 +195,7 @@
|
|
|
195
195
|
"superuser": null,
|
|
196
196
|
"clientTools": ["ferretdb", "mongosh", "mongodump", "mongorestore"],
|
|
197
197
|
"licensing": "Apache-2.0",
|
|
198
|
-
"notes": "MongoDB-compatible proxy
|
|
198
|
+
"notes": "MongoDB-compatible proxy. v2 uses postgresql-documentdb (macOS/Linux). v1 uses plain PostgreSQL (all platforms incl. Windows)."
|
|
199
199
|
},
|
|
200
200
|
"couchdb": {
|
|
201
201
|
"displayName": "CouchDB",
|
|
@@ -17,6 +17,7 @@ import { paths } from '../config/paths'
|
|
|
17
17
|
import { spawnAsync } from './spawn-utils'
|
|
18
18
|
import { moveEntry } from './fs-error-utils'
|
|
19
19
|
import { logDebug } from './error-handler'
|
|
20
|
+
import { fetchWithRegistryFallback } from './hostdb-client'
|
|
20
21
|
import {
|
|
21
22
|
type Engine,
|
|
22
23
|
Platform,
|
|
@@ -186,7 +187,9 @@ export abstract class BaseBinaryManager {
|
|
|
186
187
|
|
|
187
188
|
let response: Response
|
|
188
189
|
try {
|
|
189
|
-
response = await
|
|
190
|
+
response = await fetchWithRegistryFallback(url, {
|
|
191
|
+
signal: controller.signal,
|
|
192
|
+
})
|
|
190
193
|
} catch (error) {
|
|
191
194
|
const err = error as Error
|
|
192
195
|
if (err.name === 'AbortError') {
|
|
@@ -202,7 +205,7 @@ export abstract class BaseBinaryManager {
|
|
|
202
205
|
throw new Error(
|
|
203
206
|
`${this.config.displayName} ${fullVersion} binaries not found (404). ` +
|
|
204
207
|
`This version may have been removed from hostdb. ` +
|
|
205
|
-
`Try a different version or check https://
|
|
208
|
+
`Try a different version or check https://registry.layerbase.host`,
|
|
206
209
|
)
|
|
207
210
|
}
|
|
208
211
|
throw new Error(
|
|
@@ -451,6 +454,7 @@ export abstract class BaseBinaryManager {
|
|
|
451
454
|
try {
|
|
452
455
|
const { stdout, stderr } = await spawnAsync(serverPath, ['--version'], {
|
|
453
456
|
timeout: this.verifyTimeoutMs,
|
|
457
|
+
cwd: binPath,
|
|
454
458
|
})
|
|
455
459
|
// Log stderr if present (may contain warnings)
|
|
456
460
|
if (stderr && stderr.trim()) {
|
|
@@ -25,6 +25,7 @@ import { paths } from '../config/paths'
|
|
|
25
25
|
import { spawnAsync, extractWindowsArchive } from './spawn-utils'
|
|
26
26
|
import { isRenameFallbackError } from './fs-error-utils'
|
|
27
27
|
import { logDebug } from './error-handler'
|
|
28
|
+
import { fetchWithRegistryFallback } from './hostdb-client'
|
|
28
29
|
import {
|
|
29
30
|
type Engine,
|
|
30
31
|
Platform,
|
|
@@ -203,14 +204,16 @@ export abstract class BaseDocumentBinaryManager {
|
|
|
203
204
|
let response: Response
|
|
204
205
|
let fileStream: ReturnType<typeof createWriteStream> | null = null
|
|
205
206
|
try {
|
|
206
|
-
response = await
|
|
207
|
+
response = await fetchWithRegistryFallback(url, {
|
|
208
|
+
signal: controller.signal,
|
|
209
|
+
})
|
|
207
210
|
|
|
208
211
|
if (!response.ok) {
|
|
209
212
|
if (response.status === 404) {
|
|
210
213
|
throw new Error(
|
|
211
214
|
`${this.config.displayName} ${fullVersion} binaries not found (404). ` +
|
|
212
215
|
`This version may have been removed from hostdb. ` +
|
|
213
|
-
`Try a different version or check https://
|
|
216
|
+
`Try a different version or check https://registry.layerbase.host`,
|
|
214
217
|
)
|
|
215
218
|
}
|
|
216
219
|
throw new Error(
|
|
@@ -23,6 +23,7 @@ import { paths } from '../config/paths'
|
|
|
23
23
|
import { spawnAsync } from './spawn-utils'
|
|
24
24
|
import { moveEntry } from './fs-error-utils'
|
|
25
25
|
import { compareVersions } from './version-utils'
|
|
26
|
+
import { fetchWithRegistryFallback } from './hostdb-client'
|
|
26
27
|
import {
|
|
27
28
|
type Engine,
|
|
28
29
|
Platform,
|
|
@@ -201,7 +202,9 @@ export abstract class BaseEmbeddedBinaryManager {
|
|
|
201
202
|
|
|
202
203
|
let response: Response
|
|
203
204
|
try {
|
|
204
|
-
response = await
|
|
205
|
+
response = await fetchWithRegistryFallback(url, {
|
|
206
|
+
signal: controller.signal,
|
|
207
|
+
})
|
|
205
208
|
} catch (error) {
|
|
206
209
|
const err = error as Error
|
|
207
210
|
if (err.name === 'AbortError') {
|
|
@@ -220,7 +223,7 @@ export abstract class BaseEmbeddedBinaryManager {
|
|
|
220
223
|
throw new Error(
|
|
221
224
|
`${this.config.displayName} ${fullVersion} binaries not found (404). ` +
|
|
222
225
|
`This version may have been removed from hostdb. ` +
|
|
223
|
-
`Try a different version or check https://
|
|
226
|
+
`Try a different version or check https://registry.layerbase.host`,
|
|
224
227
|
)
|
|
225
228
|
}
|
|
226
229
|
throw new Error(
|
|
@@ -23,6 +23,7 @@ import { pipeline } from 'stream/promises'
|
|
|
23
23
|
import { paths } from '../config/paths'
|
|
24
24
|
import { spawnAsync, extractWindowsArchive } from './spawn-utils'
|
|
25
25
|
import { isRenameFallbackError } from './fs-error-utils'
|
|
26
|
+
import { fetchWithRegistryFallback } from './hostdb-client'
|
|
26
27
|
import {
|
|
27
28
|
type Engine,
|
|
28
29
|
Platform,
|
|
@@ -205,7 +206,9 @@ export abstract class BaseServerBinaryManager {
|
|
|
205
206
|
|
|
206
207
|
let response: Response
|
|
207
208
|
try {
|
|
208
|
-
response = await
|
|
209
|
+
response = await fetchWithRegistryFallback(url, {
|
|
210
|
+
signal: controller.signal,
|
|
211
|
+
})
|
|
209
212
|
} catch (error) {
|
|
210
213
|
const err = error as Error
|
|
211
214
|
if (err.name === 'AbortError') {
|
|
@@ -224,7 +227,7 @@ export abstract class BaseServerBinaryManager {
|
|
|
224
227
|
throw new Error(
|
|
225
228
|
`${this.config.displayName} ${fullVersion} binaries not found (404). ` +
|
|
226
229
|
`This version may have been removed from hostdb. ` +
|
|
227
|
-
`Try a different version or check https://
|
|
230
|
+
`Try a different version or check https://registry.layerbase.host`,
|
|
228
231
|
)
|
|
229
232
|
}
|
|
230
233
|
throw new Error(
|
package/core/hostdb-client.ts
CHANGED
|
@@ -1,15 +1,28 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Shared hostdb Client Module
|
|
3
3
|
*
|
|
4
|
-
* Provides centralized access to
|
|
5
|
-
*
|
|
4
|
+
* Provides centralized access to pre-built database binaries.
|
|
5
|
+
* Primary registry: registry.layerbase.host
|
|
6
|
+
* Fallback registry: GitHub releases (robertjbass/hostdb)
|
|
6
7
|
*
|
|
7
|
-
* hostdb provides pre-built database binaries for multiple platforms.
|
|
8
8
|
* This module handles fetching releases.json with caching to avoid
|
|
9
9
|
* repeated network requests.
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
import { Platform, type Arch, type Engine } from '../types'
|
|
13
|
+
import { logDebug } from './error-handler'
|
|
14
|
+
|
|
15
|
+
// Registry base URLs
|
|
16
|
+
export const LAYERBASE_REGISTRY_BASE = 'https://registry.layerbase.host'
|
|
17
|
+
export const GITHUB_REGISTRY_BASE =
|
|
18
|
+
'https://github.com/robertjbass/hostdb/releases/download'
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Toggle GitHub fallback for binary downloads and releases.json fetches.
|
|
22
|
+
* Set to `false` to exclusively test the Layerbase registry.
|
|
23
|
+
* Must be re-enabled before release (see PRE_RELEASE_TASKS.md).
|
|
24
|
+
*/
|
|
25
|
+
export const ENABLE_GITHUB_FALLBACK = false
|
|
13
26
|
|
|
14
27
|
// Platform definition in hostdb releases.json
|
|
15
28
|
export type HostdbPlatform = {
|
|
@@ -59,9 +72,21 @@ let cachedReleases: HostdbReleasesData | null = null
|
|
|
59
72
|
let cacheTimestamp = 0
|
|
60
73
|
const CACHE_TTL_MS = 5 * 60 * 1000 // 5 minutes
|
|
61
74
|
|
|
62
|
-
const
|
|
75
|
+
export const LAYERBASE_RELEASES_URL =
|
|
76
|
+
'https://registry.layerbase.host/releases.json'
|
|
77
|
+
export const GITHUB_RELEASES_URL =
|
|
63
78
|
'https://raw.githubusercontent.com/robertjbass/hostdb/main/releases.json'
|
|
64
79
|
|
|
80
|
+
/**
|
|
81
|
+
* Get the list of releases.json URLs to try, respecting ENABLE_GITHUB_FALLBACK.
|
|
82
|
+
* When fallback is disabled, only the Layerbase URL is returned.
|
|
83
|
+
*/
|
|
84
|
+
export function getReleasesUrls(): string[] {
|
|
85
|
+
return ENABLE_GITHUB_FALLBACK
|
|
86
|
+
? [LAYERBASE_RELEASES_URL, GITHUB_RELEASES_URL]
|
|
87
|
+
: [LAYERBASE_RELEASES_URL]
|
|
88
|
+
}
|
|
89
|
+
|
|
65
90
|
/**
|
|
66
91
|
* Fetch releases.json from hostdb repository with caching.
|
|
67
92
|
*
|
|
@@ -74,27 +99,39 @@ export async function fetchHostdbReleases(): Promise<HostdbReleasesData> {
|
|
|
74
99
|
return cachedReleases
|
|
75
100
|
}
|
|
76
101
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
102
|
+
// Try layerbase registry first, fall back to GitHub (if enabled)
|
|
103
|
+
const urls = getReleasesUrls()
|
|
104
|
+
for (let i = 0; i < urls.length; i++) {
|
|
105
|
+
const url = urls[i]
|
|
106
|
+
const isLast = i === urls.length - 1
|
|
107
|
+
try {
|
|
108
|
+
const response = await fetch(url, {
|
|
109
|
+
signal: AbortSignal.timeout(5000),
|
|
110
|
+
})
|
|
111
|
+
if (!response.ok) {
|
|
112
|
+
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
|
|
113
|
+
}
|
|
84
114
|
|
|
85
|
-
|
|
115
|
+
const data = (await response.json()) as HostdbReleasesData
|
|
86
116
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
117
|
+
// Cache the results
|
|
118
|
+
cachedReleases = data
|
|
119
|
+
cacheTimestamp = Date.now()
|
|
90
120
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
121
|
+
return data
|
|
122
|
+
} catch (error) {
|
|
123
|
+
const err = error as Error
|
|
124
|
+
logDebug(`Failed to fetch releases from ${url}: ${err.message}`)
|
|
125
|
+
// If this was the last URL, rethrow
|
|
126
|
+
if (isLast) {
|
|
127
|
+
throw error
|
|
128
|
+
}
|
|
129
|
+
// Otherwise try the next URL
|
|
130
|
+
}
|
|
97
131
|
}
|
|
132
|
+
|
|
133
|
+
// Should be unreachable (loop always throws on last iteration)
|
|
134
|
+
throw new Error('Failed to fetch hostdb releases from all registries')
|
|
98
135
|
}
|
|
99
136
|
|
|
100
137
|
/**
|
|
@@ -175,7 +212,105 @@ export function buildHostdbUrl(
|
|
|
175
212
|
const tag = `${engine}-${version}`
|
|
176
213
|
const filename = `${engine}-${version}-${hostdbPlatform}.${extension}`
|
|
177
214
|
|
|
178
|
-
return
|
|
215
|
+
return `${LAYERBASE_REGISTRY_BASE}/${tag}/${filename}`
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Build a GitHub fallback URL for a hostdb release (same path scheme as layerbase).
|
|
220
|
+
*/
|
|
221
|
+
export function buildGithubFallbackUrl(
|
|
222
|
+
engine: Engine | string,
|
|
223
|
+
options: BuildHostdbUrlOptions,
|
|
224
|
+
): string {
|
|
225
|
+
const { version, hostdbPlatform, extension = 'tar.gz' } = options
|
|
226
|
+
const tag = `${engine}-${version}`
|
|
227
|
+
const filename = `${engine}-${version}-${hostdbPlatform}.${extension}`
|
|
228
|
+
|
|
229
|
+
return `${GITHUB_REGISTRY_BASE}/${tag}/${filename}`
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Convert a layerbase registry URL to its GitHub fallback equivalent.
|
|
234
|
+
* Returns null if the URL is not a layerbase URL or if GitHub fallback is disabled.
|
|
235
|
+
*/
|
|
236
|
+
export function getRegistryFallbackUrl(url: string): string | null {
|
|
237
|
+
if (!ENABLE_GITHUB_FALLBACK) return null
|
|
238
|
+
if (url.startsWith(LAYERBASE_REGISTRY_BASE)) {
|
|
239
|
+
return url.replace(LAYERBASE_REGISTRY_BASE, GITHUB_REGISTRY_BASE)
|
|
240
|
+
}
|
|
241
|
+
return null
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Fetch wrapper that tries the primary URL first, then falls back to the
|
|
246
|
+
* GitHub registry if the primary is a layerbase URL and the request fails
|
|
247
|
+
* with a 404, 5xx, or network error.
|
|
248
|
+
*
|
|
249
|
+
* AbortError (timeout) is never retried — it propagates immediately.
|
|
250
|
+
*/
|
|
251
|
+
export async function fetchWithRegistryFallback(
|
|
252
|
+
url: string,
|
|
253
|
+
options?: RequestInit,
|
|
254
|
+
): Promise<Response> {
|
|
255
|
+
try {
|
|
256
|
+
const response = await fetch(url, options)
|
|
257
|
+
if (response.status === 404 || response.status >= 500) {
|
|
258
|
+
const fallbackUrl = getRegistryFallbackUrl(url)
|
|
259
|
+
if (fallbackUrl) {
|
|
260
|
+
logDebug(
|
|
261
|
+
`Primary registry returned ${response.status}, trying GitHub fallback`,
|
|
262
|
+
)
|
|
263
|
+
return await fetch(fallbackUrl, options)
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
return response
|
|
267
|
+
} catch (error) {
|
|
268
|
+
const err = error as Error
|
|
269
|
+
// Never retry on timeout (AbortError)
|
|
270
|
+
if (err.name === 'AbortError') {
|
|
271
|
+
throw error
|
|
272
|
+
}
|
|
273
|
+
const fallbackUrl = getRegistryFallbackUrl(url)
|
|
274
|
+
if (fallbackUrl) {
|
|
275
|
+
logDebug(
|
|
276
|
+
`Primary registry fetch failed (${err.message}), trying GitHub fallback`,
|
|
277
|
+
)
|
|
278
|
+
return await fetch(fallbackUrl, options)
|
|
279
|
+
}
|
|
280
|
+
throw error
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Try fetching from multiple registry URLs in order, returning the first
|
|
286
|
+
* successful (response.ok) Response. Logs per-URL failures via the
|
|
287
|
+
* supplied logger callback.
|
|
288
|
+
*
|
|
289
|
+
* @param urls - URLs to try in order (e.g., layerbase then GitHub)
|
|
290
|
+
* @param logger - Callback for logging per-URL failures
|
|
291
|
+
* @param timeoutMs - Per-request timeout in milliseconds (default: 5000)
|
|
292
|
+
* @returns The first successful Response
|
|
293
|
+
* @throws Error if all URLs fail
|
|
294
|
+
*/
|
|
295
|
+
export async function fetchFromRegistryUrls(
|
|
296
|
+
urls: string[],
|
|
297
|
+
logger: (message: string) => void,
|
|
298
|
+
timeoutMs: number = 5000,
|
|
299
|
+
): Promise<Response> {
|
|
300
|
+
let lastError: Error | null = null
|
|
301
|
+
for (const url of urls) {
|
|
302
|
+
try {
|
|
303
|
+
const response = await fetch(url, {
|
|
304
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
305
|
+
})
|
|
306
|
+
if (response.ok) return response
|
|
307
|
+
logger(`Registry fetch from ${url}: HTTP ${response.status}`)
|
|
308
|
+
} catch (error) {
|
|
309
|
+
logger(`Registry fetch from ${url} failed: ${error}`)
|
|
310
|
+
lastError = error as Error
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
throw lastError ?? new Error('All release registries failed')
|
|
179
314
|
}
|
|
180
315
|
|
|
181
316
|
export type BuildDownloadUrlOptions = {
|
package/core/hostdb-metadata.ts
CHANGED
|
@@ -2,15 +2,19 @@
|
|
|
2
2
|
* Fetches metadata from hostdb (databases.json and downloads.json)
|
|
3
3
|
* to understand what tools each engine needs and how to install them.
|
|
4
4
|
*
|
|
5
|
+
* Primary registry: registry.layerbase.host
|
|
6
|
+
* Fallback registry: GitHub raw (robertjbass/hostdb)
|
|
7
|
+
*
|
|
5
8
|
* Architecture:
|
|
6
9
|
* - databases.json: Lists server, client, utilities, and enhanced CLI tools for each engine
|
|
7
10
|
* - downloads.json: Provides package manager commands for installing tools
|
|
8
11
|
*/
|
|
9
12
|
|
|
10
13
|
import { logDebug } from './error-handler'
|
|
14
|
+
import { LAYERBASE_REGISTRY_BASE } from './hostdb-client'
|
|
11
15
|
import type { Engine } from '../types'
|
|
12
16
|
|
|
13
|
-
const
|
|
17
|
+
const GITHUB_RAW_BASE =
|
|
14
18
|
'https://raw.githubusercontent.com/robertjbass/hostdb/main'
|
|
15
19
|
|
|
16
20
|
const CACHE_TTL_MS = 5 * 60 * 1000 // 5 minutes
|
|
@@ -22,12 +26,29 @@ type CliTools = {
|
|
|
22
26
|
enhanced: string[]
|
|
23
27
|
}
|
|
24
28
|
|
|
29
|
+
export type VersionEntryObject = {
|
|
30
|
+
enabled?: boolean
|
|
31
|
+
platforms?: string[]
|
|
32
|
+
dependencies?: Array<{
|
|
33
|
+
database: string
|
|
34
|
+
cascadeDelete: boolean
|
|
35
|
+
note?: string
|
|
36
|
+
}>
|
|
37
|
+
cliTools?: CliTools
|
|
38
|
+
}
|
|
39
|
+
|
|
25
40
|
type DatabaseEntry = {
|
|
26
41
|
displayName: string
|
|
27
42
|
cliTools: CliTools
|
|
28
|
-
versions: Record<string, boolean
|
|
29
|
-
|
|
30
|
-
|
|
43
|
+
versions: Record<string, boolean | VersionEntryObject>
|
|
44
|
+
platforms?: string[]
|
|
45
|
+
dependencies?: Array<{
|
|
46
|
+
database: string
|
|
47
|
+
cascadeDelete: boolean
|
|
48
|
+
note?: string
|
|
49
|
+
}>
|
|
50
|
+
spindbStatus?: string
|
|
51
|
+
hostedServiceAllowed?: boolean
|
|
31
52
|
}
|
|
32
53
|
|
|
33
54
|
type PackageManagerDef = {
|
|
@@ -69,7 +90,7 @@ let downloadsCache: { data: DownloadsJson; timestamp: number } | null = null
|
|
|
69
90
|
const inFlightRequests = new Map<string, Promise<unknown>>()
|
|
70
91
|
|
|
71
92
|
async function fetchWithCache<T>(
|
|
72
|
-
|
|
93
|
+
urls: string[],
|
|
73
94
|
getCache: () => { data: T; timestamp: number } | null,
|
|
74
95
|
setCache: (cache: { data: T; timestamp: number }) => void,
|
|
75
96
|
): Promise<T> {
|
|
@@ -80,35 +101,48 @@ async function fetchWithCache<T>(
|
|
|
80
101
|
return cache.data
|
|
81
102
|
}
|
|
82
103
|
|
|
83
|
-
// Check for in-flight request
|
|
84
|
-
const
|
|
104
|
+
// Check for in-flight request using the primary URL as key
|
|
105
|
+
const cacheKey = urls[0]
|
|
106
|
+
const inFlight = inFlightRequests.get(cacheKey)
|
|
85
107
|
if (inFlight) {
|
|
86
108
|
return inFlight as Promise<T>
|
|
87
109
|
}
|
|
88
110
|
|
|
89
|
-
// Create the fetch promise
|
|
111
|
+
// Create the fetch promise — try each URL in order
|
|
90
112
|
const fetchPromise = (async () => {
|
|
91
113
|
try {
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
114
|
+
let lastError: Error | null = null
|
|
115
|
+
for (const url of urls) {
|
|
116
|
+
try {
|
|
117
|
+
const response = await fetch(url)
|
|
118
|
+
if (!response.ok) {
|
|
119
|
+
throw new Error(`Failed to fetch ${url}: ${response.status}`)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const data = (await response.json()) as T
|
|
123
|
+
setCache({ data, timestamp: Date.now() })
|
|
124
|
+
return data
|
|
125
|
+
} catch (error) {
|
|
126
|
+
lastError = error as Error
|
|
127
|
+
logDebug(`Metadata fetch from ${url} failed: ${lastError.message}`)
|
|
128
|
+
}
|
|
95
129
|
}
|
|
96
|
-
|
|
97
|
-
const data = (await response.json()) as T
|
|
98
|
-
setCache({ data, timestamp: Date.now() })
|
|
99
|
-
return data
|
|
130
|
+
throw lastError ?? new Error('All metadata URLs failed')
|
|
100
131
|
} finally {
|
|
101
|
-
inFlightRequests.delete(
|
|
132
|
+
inFlightRequests.delete(cacheKey)
|
|
102
133
|
}
|
|
103
134
|
})()
|
|
104
135
|
|
|
105
|
-
inFlightRequests.set(
|
|
136
|
+
inFlightRequests.set(cacheKey, fetchPromise)
|
|
106
137
|
return fetchPromise
|
|
107
138
|
}
|
|
108
139
|
|
|
109
140
|
export async function fetchDatabasesJson(): Promise<DatabasesJson> {
|
|
110
141
|
return fetchWithCache(
|
|
111
|
-
|
|
142
|
+
[
|
|
143
|
+
`${LAYERBASE_REGISTRY_BASE}/databases.json`,
|
|
144
|
+
`${GITHUB_RAW_BASE}/databases.json`,
|
|
145
|
+
],
|
|
112
146
|
() => databasesCache,
|
|
113
147
|
(c) => {
|
|
114
148
|
databasesCache = c
|
|
@@ -118,7 +152,10 @@ export async function fetchDatabasesJson(): Promise<DatabasesJson> {
|
|
|
118
152
|
|
|
119
153
|
export async function fetchDownloadsJson(): Promise<DownloadsJson> {
|
|
120
154
|
return fetchWithCache(
|
|
121
|
-
|
|
155
|
+
[
|
|
156
|
+
`${LAYERBASE_REGISTRY_BASE}/downloads.json`,
|
|
157
|
+
`${GITHUB_RAW_BASE}/downloads.json`,
|
|
158
|
+
],
|
|
122
159
|
() => downloadsCache,
|
|
123
160
|
(c) => {
|
|
124
161
|
downloadsCache = c
|
|
@@ -275,6 +312,16 @@ export async function getPackagesForTools(
|
|
|
275
312
|
}
|
|
276
313
|
}
|
|
277
314
|
|
|
315
|
+
/**
|
|
316
|
+
* Check if a version entry is enabled.
|
|
317
|
+
* Handles both old schema (boolean) and new schema (object with optional enabled field).
|
|
318
|
+
* Objects are enabled by default unless explicitly `{ enabled: false }`.
|
|
319
|
+
*/
|
|
320
|
+
export function isVersionEnabled(value: boolean | VersionEntryObject): boolean {
|
|
321
|
+
if (typeof value === 'boolean') return value
|
|
322
|
+
return value.enabled !== false
|
|
323
|
+
}
|
|
324
|
+
|
|
278
325
|
/**
|
|
279
326
|
* Get available versions for a database engine from databases.json
|
|
280
327
|
* This is the authoritative source for what versions are actually available in hostdb.
|
|
@@ -290,9 +337,8 @@ export async function getAvailableVersions(
|
|
|
290
337
|
const entry = data[key]
|
|
291
338
|
if (!entry?.versions) return null
|
|
292
339
|
|
|
293
|
-
// Return only versions marked as available (true)
|
|
294
340
|
return Object.entries(entry.versions)
|
|
295
|
-
.filter(([,
|
|
341
|
+
.filter(([, value]) => isVersionEnabled(value))
|
|
296
342
|
.map(([version]) => version)
|
|
297
343
|
} catch (error) {
|
|
298
344
|
logDebug('Failed to fetch available versions from hostdb', {
|
|
@@ -302,25 +348,3 @@ export async function getAvailableVersions(
|
|
|
302
348
|
return null
|
|
303
349
|
}
|
|
304
350
|
}
|
|
305
|
-
|
|
306
|
-
/**
|
|
307
|
-
* Get the latest LTS version for a database engine
|
|
308
|
-
* @param engine Engine (e.g., Engine.PostgreSQL or 'postgresql')
|
|
309
|
-
* @returns Latest LTS version string, or null if not found
|
|
310
|
-
*/
|
|
311
|
-
export async function getLatestLtsVersion(
|
|
312
|
-
engine: Engine | string,
|
|
313
|
-
): Promise<string | null> {
|
|
314
|
-
try {
|
|
315
|
-
const data = await fetchDatabasesJson()
|
|
316
|
-
const key = engine.toLowerCase()
|
|
317
|
-
const entry = data[key]
|
|
318
|
-
return entry?.latestLts || null
|
|
319
|
-
} catch (error) {
|
|
320
|
-
logDebug('Failed to fetch latest LTS version from hostdb', {
|
|
321
|
-
engine,
|
|
322
|
-
error: error instanceof Error ? error.message : String(error),
|
|
323
|
-
})
|
|
324
|
-
return null
|
|
325
|
-
}
|
|
326
|
-
}
|
|
@@ -35,7 +35,7 @@ export function getHostdbPlatform(
|
|
|
35
35
|
/**
|
|
36
36
|
* Build the download URL for ClickHouse binaries from hostdb
|
|
37
37
|
*
|
|
38
|
-
* Format: https://
|
|
38
|
+
* Format: https://registry.layerbase.host/clickhouse-{version}/clickhouse-{version}-{platform}-{arch}.tar.gz
|
|
39
39
|
*
|
|
40
40
|
* @param version - ClickHouse version (e.g., '25.12', '25.12.3.21')
|
|
41
41
|
* @param platform - Platform identifier (e.g., 'darwin', 'linux')
|
|
@@ -1,19 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* CockroachDB binary URL generation
|
|
3
3
|
*
|
|
4
|
-
* Generates download URLs for CockroachDB binaries from
|
|
4
|
+
* Generates download URLs for CockroachDB binaries from the layerbase registry.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import type
|
|
7
|
+
import { type Platform, type Arch, Engine } from '../../types'
|
|
8
8
|
import { normalizeVersion } from './version-maps'
|
|
9
|
-
|
|
10
|
-
const HOSTDB_BASE_URL =
|
|
11
|
-
'https://github.com/robertjbass/hostdb/releases/download'
|
|
9
|
+
import { buildHostdbUrl } from '../../core/hostdb-client'
|
|
12
10
|
|
|
13
11
|
/**
|
|
14
12
|
* Get the binary download URL for a specific version and platform
|
|
15
13
|
*
|
|
16
|
-
* URL format: https://
|
|
14
|
+
* URL format: https://registry.layerbase.host/cockroachdb-{version}/cockroachdb-{version}-{platform}-{arch}.{ext}
|
|
17
15
|
*
|
|
18
16
|
* @param version - CockroachDB version (e.g., '25.4.2' or '25')
|
|
19
17
|
* @param platform - Target platform (darwin, linux, win32)
|
|
@@ -27,7 +25,11 @@ export function getBinaryUrl(
|
|
|
27
25
|
const fullVersion = normalizeVersion(version)
|
|
28
26
|
const ext = platform === 'win32' ? 'zip' : 'tar.gz'
|
|
29
27
|
|
|
30
|
-
return
|
|
28
|
+
return buildHostdbUrl(Engine.CockroachDB, {
|
|
29
|
+
version: fullVersion,
|
|
30
|
+
hostdbPlatform: `${platform}-${arch}`,
|
|
31
|
+
extension: ext,
|
|
32
|
+
})
|
|
31
33
|
}
|
|
32
34
|
|
|
33
35
|
/**
|