spindb 0.34.5 → 0.35.3

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 (51) hide show
  1. package/README.md +4 -4
  2. package/cli/commands/create.ts +22 -1
  3. package/cli/commands/engines.ts +56 -22
  4. package/cli/commands/menu/container-handlers.ts +17 -1
  5. package/cli/commands/menu/engine-handlers.ts +48 -29
  6. package/cli/ui/theme.ts +5 -2
  7. package/config/engines-registry.ts +56 -0
  8. package/config/engines.json +14 -3
  9. package/config/engines.schema.json +13 -0
  10. package/core/base-binary-manager.ts +6 -2
  11. package/core/base-document-binary-manager.ts +5 -2
  12. package/core/base-embedded-binary-manager.ts +5 -2
  13. package/core/base-server-binary-manager.ts +5 -2
  14. package/core/hostdb-client.ts +157 -22
  15. package/core/hostdb-metadata.ts +67 -43
  16. package/engines/clickhouse/binary-urls.ts +1 -1
  17. package/engines/cockroachdb/binary-urls.ts +9 -7
  18. package/engines/cockroachdb/hostdb-releases.ts +18 -106
  19. package/engines/cockroachdb/version-maps.ts +1 -1
  20. package/engines/couchdb/binary-urls.ts +1 -1
  21. package/engines/duckdb/binary-urls.ts +1 -1
  22. package/engines/duckdb/index.ts +4 -74
  23. package/engines/ferretdb/README.md +76 -38
  24. package/engines/ferretdb/backup.ts +18 -10
  25. package/engines/ferretdb/binary-manager.ts +233 -35
  26. package/engines/ferretdb/binary-urls.ts +69 -24
  27. package/engines/ferretdb/index.ts +424 -213
  28. package/engines/ferretdb/restore.ts +23 -16
  29. package/engines/ferretdb/version-maps.ts +36 -8
  30. package/engines/index.ts +3 -4
  31. package/engines/influxdb/binary-urls.ts +1 -1
  32. package/engines/mariadb/binary-urls.ts +2 -2
  33. package/engines/meilisearch/binary-urls.ts +1 -1
  34. package/engines/mysql/binary-urls.ts +2 -2
  35. package/engines/postgresql/binary-urls.ts +1 -1
  36. package/engines/qdrant/binary-urls.ts +1 -1
  37. package/engines/questdb/binary-manager.ts +16 -9
  38. package/engines/questdb/binary-urls.ts +9 -10
  39. package/engines/questdb/hostdb-releases.ts +19 -97
  40. package/engines/questdb/version-maps.ts +2 -2
  41. package/engines/redis/binary-urls.ts +1 -8
  42. package/engines/sqlite/binary-urls.ts +1 -1
  43. package/engines/sqlite/index.ts +4 -74
  44. package/engines/surrealdb/binary-urls.ts +9 -7
  45. package/engines/surrealdb/hostdb-releases.ts +18 -106
  46. package/engines/surrealdb/version-maps.ts +1 -1
  47. package/engines/typedb/binary-urls.ts +10 -8
  48. package/engines/typedb/hostdb-releases.ts +18 -113
  49. package/engines/typedb/version-maps.ts +1 -1
  50. package/engines/valkey/binary-urls.ts +1 -1
  51. package/package.json +4 -1
@@ -11,7 +11,12 @@
11
11
  * - Stop: Stop FerretDB → Stop PostgreSQL
12
12
  */
13
13
 
14
- import { spawn, exec, type SpawnOptions } from 'child_process'
14
+ import {
15
+ spawn,
16
+ exec,
17
+ type ChildProcess,
18
+ type SpawnOptions,
19
+ } from 'child_process'
15
20
  import { promisify } from 'util'
16
21
  import { existsSync } from 'fs'
17
22
  import net from 'net'
@@ -43,8 +48,10 @@ import {
43
48
  SUPPORTED_MAJOR_VERSIONS,
44
49
  FALLBACK_VERSION_MAP,
45
50
  DEFAULT_DOCUMENTDB_VERSION,
51
+ DEFAULT_V1_POSTGRESQL_VERSION,
46
52
  normalizeVersion,
47
53
  normalizeDocumentDBVersion,
54
+ isV1,
48
55
  } from './version-maps'
49
56
  import { getBinaryUrls, isPlatformSupported } from './binary-urls'
50
57
  import {
@@ -157,6 +164,76 @@ function waitForPort(port: number, timeoutMs = 30000): Promise<boolean> {
157
164
  })
158
165
  }
159
166
 
167
+ /**
168
+ * Spawn a process and pipe input to its stdin.
169
+ * Used for `postgres --single` which reads SQL from stdin.
170
+ */
171
+ function spawnWithInput(
172
+ command: string,
173
+ args: string[],
174
+ input: string,
175
+ options?: { env?: Record<string, string>; timeout?: number },
176
+ ): Promise<{ stdout: string; stderr: string }> {
177
+ return new Promise((resolve, reject) => {
178
+ let proc: ChildProcess
179
+ try {
180
+ proc = spawn(command, args, {
181
+ stdio: ['pipe', 'pipe', 'pipe'],
182
+ env: options?.env ? { ...process.env, ...options.env } : undefined,
183
+ })
184
+ } catch (error) {
185
+ reject(error)
186
+ return
187
+ }
188
+
189
+ let stdout = ''
190
+ let stderr = ''
191
+ let timedOut = false
192
+
193
+ const timer = options?.timeout
194
+ ? setTimeout(() => {
195
+ timedOut = true
196
+ proc.kill('SIGKILL')
197
+ reject(
198
+ new Error(
199
+ `Command "${command}" timed out after ${options.timeout}ms`,
200
+ ),
201
+ )
202
+ }, options.timeout)
203
+ : undefined
204
+
205
+ proc.stdout?.on('data', (data: Buffer) => {
206
+ stdout += data.toString()
207
+ })
208
+ proc.stderr?.on('data', (data: Buffer) => {
209
+ stderr += data.toString()
210
+ })
211
+
212
+ proc.on('close', (code) => {
213
+ if (timer) clearTimeout(timer)
214
+ if (timedOut) return
215
+ if (code === 0) {
216
+ resolve({ stdout, stderr })
217
+ } else {
218
+ reject(
219
+ new Error(
220
+ `Command "${command}" failed with code ${code}: ${stderr || stdout}`,
221
+ ),
222
+ )
223
+ }
224
+ })
225
+
226
+ proc.on('error', (err) => {
227
+ if (timer) clearTimeout(timer)
228
+ if (timedOut) return
229
+ reject(err)
230
+ })
231
+
232
+ proc.stdin?.write(input)
233
+ proc.stdin?.end()
234
+ })
235
+ }
236
+
160
237
  export class FerretDBEngine extends BaseEngine {
161
238
  name = ENGINE
162
239
  displayName = 'FerretDB'
@@ -170,10 +247,11 @@ export class FerretDBEngine extends BaseEngine {
170
247
 
171
248
  /**
172
249
  * Check if the current platform supports FerretDB
250
+ * @param version - Optional version to check (v1 supports Windows, v2 does not)
173
251
  */
174
- isPlatformSupported(): boolean {
252
+ isPlatformSupported(version?: string): boolean {
175
253
  const { platform, arch } = this.getPlatformInfo()
176
- return isPlatformSupported(platform, arch)
254
+ return isPlatformSupported(platform, arch, version)
177
255
  }
178
256
 
179
257
  /**
@@ -193,12 +271,10 @@ export class FerretDBEngine extends BaseEngine {
193
271
 
194
272
  // Get binary download URL from hostdb
195
273
  getBinaryUrl(version: string, platform: Platform, arch: Arch): string {
196
- const urls = getBinaryUrls(
197
- version,
198
- DEFAULT_DOCUMENTDB_VERSION,
199
- platform,
200
- arch,
201
- )
274
+ const backendVersion = isV1(version)
275
+ ? DEFAULT_V1_POSTGRESQL_VERSION
276
+ : DEFAULT_DOCUMENTDB_VERSION
277
+ const urls = getBinaryUrls(version, backendVersion, platform, arch)
202
278
  return urls.ferretdb
203
279
  }
204
280
 
@@ -224,12 +300,10 @@ export class FerretDBEngine extends BaseEngine {
224
300
  const { platform: p, arch: a } = this.getPlatformInfo()
225
301
 
226
302
  if (version) {
227
- return ferretdbBinaryManager.isInstalled(
228
- version,
229
- p,
230
- a,
231
- DEFAULT_DOCUMENTDB_VERSION,
232
- )
303
+ const backendVersion = isV1(version)
304
+ ? DEFAULT_V1_POSTGRESQL_VERSION
305
+ : DEFAULT_DOCUMENTDB_VERSION
306
+ return ferretdbBinaryManager.isInstalled(version, p, a, backendVersion)
233
307
  }
234
308
 
235
309
  // Fallback: extract version from directory name
@@ -237,11 +311,14 @@ export class FerretDBEngine extends BaseEngine {
237
311
  const match = dirName.match(/^ferretdb-([\d.]+)-/)
238
312
  if (match) {
239
313
  const extractedVersion = match[1]
314
+ const backendVersion = isV1(extractedVersion)
315
+ ? DEFAULT_V1_POSTGRESQL_VERSION
316
+ : DEFAULT_DOCUMENTDB_VERSION
240
317
  return ferretdbBinaryManager.isInstalled(
241
318
  extractedVersion,
242
319
  p,
243
320
  a,
244
- DEFAULT_DOCUMENTDB_VERSION,
321
+ backendVersion,
245
322
  )
246
323
  }
247
324
 
@@ -254,11 +331,14 @@ export class FerretDBEngine extends BaseEngine {
254
331
  // Check if a specific FerretDB version is installed
255
332
  async isBinaryInstalled(version: string): Promise<boolean> {
256
333
  const { platform, arch } = this.getPlatformInfo()
334
+ const backendVersion = isV1(version)
335
+ ? DEFAULT_V1_POSTGRESQL_VERSION
336
+ : DEFAULT_DOCUMENTDB_VERSION
257
337
  return ferretdbBinaryManager.isInstalled(
258
338
  version,
259
339
  platform,
260
340
  arch,
261
- DEFAULT_DOCUMENTDB_VERSION,
341
+ backendVersion,
262
342
  )
263
343
  }
264
344
 
@@ -270,14 +350,17 @@ export class FerretDBEngine extends BaseEngine {
270
350
  onProgress?: ProgressCallback,
271
351
  ): Promise<string> {
272
352
  const { platform, arch } = this.getPlatformInfo()
353
+ const backendVersion = isV1(version)
354
+ ? DEFAULT_V1_POSTGRESQL_VERSION
355
+ : DEFAULT_DOCUMENTDB_VERSION
273
356
 
274
- // Download both binaries
357
+ // Download binaries (proxy + backend)
275
358
  const { ferretdbPath } = await ferretdbBinaryManager.ensureInstalled(
276
359
  version,
277
360
  platform,
278
361
  arch,
279
362
  onProgress,
280
- DEFAULT_DOCUMENTDB_VERSION,
363
+ backendVersion,
281
364
  )
282
365
 
283
366
  // Register ferretdb binary in config
@@ -290,6 +373,41 @@ export class FerretDBEngine extends BaseEngine {
290
373
  return ferretdbPath
291
374
  }
292
375
 
376
+ /**
377
+ * Get backend binary paths and spawn environment for a container.
378
+ * Centralizes the v1/v2 branching logic for backend resolution.
379
+ */
380
+ private getBackendPaths(
381
+ version: string,
382
+ backendVersion: string,
383
+ platform: Platform,
384
+ arch: Arch,
385
+ ): { backendPath: string; pgSpawnEnv: Record<string, string> | undefined } {
386
+ const fullVersion = normalizeVersion(version)
387
+ const backendPath = ferretdbBinaryManager.getBackendBinaryPath(
388
+ fullVersion,
389
+ backendVersion,
390
+ platform,
391
+ arch,
392
+ )
393
+
394
+ const baseSpawnEnv = ferretdbBinaryManager.getBackendSpawnEnv(
395
+ fullVersion,
396
+ backendVersion,
397
+ platform,
398
+ arch,
399
+ )
400
+ const pgSpawnEnv =
401
+ platform === 'darwin'
402
+ ? {
403
+ ...baseSpawnEnv,
404
+ DYLD_FALLBACK_LIBRARY_PATH: join(backendPath, 'lib'),
405
+ }
406
+ : baseSpawnEnv
407
+
408
+ return { backendPath, pgSpawnEnv }
409
+ }
410
+
293
411
  /**
294
412
  * Initialize a new FerretDB container directory
295
413
  * Creates both the PostgreSQL data directory and FerretDB config
@@ -300,17 +418,16 @@ export class FerretDBEngine extends BaseEngine {
300
418
  options: Record<string, unknown> = {},
301
419
  ): Promise<string> {
302
420
  const { platform, arch } = this.getPlatformInfo()
421
+ const version = normalizeVersion(_version)
422
+ const v1 = isV1(version)
303
423
 
304
- // Get binary paths
305
- const backendVersion =
306
- (options.backendVersion as string) || DEFAULT_DOCUMENTDB_VERSION
307
- const fullBackendVersion = normalizeDocumentDBVersion(backendVersion)
424
+ // Get binary paths - resolve backend based on v1/v2
425
+ const backendVersion = v1
426
+ ? (options.backendVersion as string) || DEFAULT_V1_POSTGRESQL_VERSION
427
+ : (options.backendVersion as string) || DEFAULT_DOCUMENTDB_VERSION
308
428
 
309
- const documentdbPath = ferretdbBinaryManager.getDocumentDBBinaryPath(
310
- fullBackendVersion,
311
- platform,
312
- arch,
313
- )
429
+ const { backendPath: documentdbPath, pgSpawnEnv: initSpawnEnv } =
430
+ this.getBackendPaths(version, backendVersion, platform, arch)
314
431
 
315
432
  // Container directory structure
316
433
  const containerDir = paths.getContainerPath(containerName, {
@@ -335,13 +452,6 @@ export class FerretDBEngine extends BaseEngine {
335
452
  throw new Error(`initdb not found at ${initdb}`)
336
453
  }
337
454
 
338
- // Get spawn env for Linux (LD_LIBRARY_PATH)
339
- const spawnEnv = ferretdbBinaryManager.getDocumentDBSpawnEnv(
340
- fullBackendVersion,
341
- platform,
342
- arch,
343
- )
344
-
345
455
  // Homebrew-derived x64 binaries have compiled-in absolute paths for
346
456
  // sharedir, pkglibdir ($libdir), and libdir that don't exist when running
347
457
  // from ~/.spindb/bin/. We fix this by:
@@ -355,10 +465,9 @@ export class FerretDBEngine extends BaseEngine {
355
465
  ? join(shareDirBase, 'postgresql')
356
466
  : shareDirBase
357
467
 
358
- // Homebrew-derived binaries have compiled-in absolute paths that only
359
- // need fixup on macOS. On Linux the paths are relative or handled by
360
- // LD_LIBRARY_PATH, so skip the pg_config symlink fixups entirely.
361
- if (platform === 'darwin') {
468
+ // v2 only: Homebrew-derived DocumentDB binaries need compiled-in path fixups
469
+ // v1 uses plain PostgreSQL which has correct relative paths
470
+ if (!v1 && platform === 'darwin') {
362
471
  const pgConfigBin = join(documentdbPath, 'bin', `pg_config${ext}`)
363
472
  if (existsSync(pgConfigBin)) {
364
473
  // Query all relevant compiled-in paths and create symlinks where needed
@@ -420,17 +529,15 @@ export class FerretDBEngine extends BaseEngine {
420
529
  }
421
530
  }
422
531
  }
423
- } else {
532
+ } else if (!v1) {
424
533
  logDebug(
425
534
  'Skipping pg_config symlink fixups (not required on this platform)',
426
535
  )
427
536
  }
428
537
 
429
- // On macOS, fix hardcoded Homebrew dylib paths in extension libraries.
430
- // The x64 build may have extensions (e.g. pg_documentdb_core.dylib) that
431
- // reference Homebrew libraries (e.g. libbson2.2.dylib from mongo-c-driver)
432
- // via absolute paths that don't exist on the target machine.
433
- if (platform === 'darwin') {
538
+ // v2 only: Fix hardcoded Homebrew dylib paths in DocumentDB extension libraries
539
+ // v1 uses plain PostgreSQL which doesn't have DocumentDB extensions
540
+ if (!v1 && platform === 'darwin') {
434
541
  const dylibMarker = join(documentdbPath, '.dylib_fix_done')
435
542
  if (!existsSync(dylibMarker)) {
436
543
  await this.fixDylibDependencies(documentdbPath)
@@ -442,16 +549,6 @@ export class FerretDBEngine extends BaseEngine {
442
549
  }
443
550
  }
444
551
 
445
- // On macOS, set DYLD_FALLBACK_LIBRARY_PATH as additional library search path.
446
- // Unlike DYLD_LIBRARY_PATH, this is NOT stripped by SIP.
447
- const initdbEnv =
448
- platform === 'darwin'
449
- ? {
450
- ...spawnEnv,
451
- DYLD_FALLBACK_LIBRARY_PATH: join(documentdbPath, 'lib'),
452
- }
453
- : spawnEnv
454
-
455
552
  try {
456
553
  await spawnAsync(
457
554
  initdb,
@@ -465,7 +562,7 @@ export class FerretDBEngine extends BaseEngine {
465
562
  '-L',
466
563
  actualShareDir,
467
564
  ],
468
- { env: initdbEnv, timeout: 60000 },
565
+ { env: initSpawnEnv, timeout: 60000 },
469
566
  )
470
567
  logDebug(`Initialized PostgreSQL data directory: ${pgDataDir}`)
471
568
  } catch (error) {
@@ -473,37 +570,40 @@ export class FerretDBEngine extends BaseEngine {
473
570
  throw new Error(`Failed to initialize PostgreSQL: ${err.message}`)
474
571
  }
475
572
 
476
- // Copy the bundled postgresql.conf.sample to ensure shared_preload_libraries is set
573
+ // v2 only: Copy the bundled postgresql.conf.sample to ensure shared_preload_libraries is set
477
574
  // This is critical for DocumentDB extension to load properly
478
- const bundledConf = existsSync(
479
- join(shareDirBase, 'postgresql.conf.sample'),
480
- )
481
- ? join(shareDirBase, 'postgresql.conf.sample')
482
- : join(shareDirBase, 'postgresql', 'postgresql.conf.sample')
483
- const pgConf = join(pgDataDir, 'postgresql.conf')
484
-
485
- if (existsSync(bundledConf)) {
486
- try {
487
- // Read the bundled config
488
- let confContent = await readFile(bundledConf, 'utf8')
489
-
490
- // Update cron.database_name to 'ferretdb' (required for pg_cron to work with DocumentDB)
491
- confContent = confContent.replace(
492
- /cron\.database_name\s*=\s*'[^']*'/,
493
- "cron.database_name = 'ferretdb'",
494
- )
575
+ // v1 uses initdb defaults (no DocumentDB extensions to preload)
576
+ if (!v1) {
577
+ const bundledConf = existsSync(
578
+ join(shareDirBase, 'postgresql.conf.sample'),
579
+ )
580
+ ? join(shareDirBase, 'postgresql.conf.sample')
581
+ : join(shareDirBase, 'postgresql', 'postgresql.conf.sample')
582
+ const pgConf = join(pgDataDir, 'postgresql.conf')
495
583
 
496
- // Write the modified config
497
- await writeFile(pgConf, confContent)
498
- logDebug(`Copied and configured postgresql.conf to ${pgConf}`)
499
- } catch (copyError) {
500
- logDebug(
501
- `Warning: Could not copy postgresql.conf.sample: ${copyError}`,
502
- )
503
- // Continue anyway - initdb creates a default config
584
+ if (existsSync(bundledConf)) {
585
+ try {
586
+ // Read the bundled config
587
+ let confContent = await readFile(bundledConf, 'utf8')
588
+
589
+ // Update cron.database_name to 'ferretdb' (required for pg_cron to work with DocumentDB)
590
+ confContent = confContent.replace(
591
+ /cron\.database_name\s*=\s*'[^']*'/,
592
+ "cron.database_name = 'ferretdb'",
593
+ )
594
+
595
+ // Write the modified config
596
+ await writeFile(pgConf, confContent)
597
+ logDebug(`Copied and configured postgresql.conf to ${pgConf}`)
598
+ } catch (copyError) {
599
+ logDebug(
600
+ `Warning: Could not copy postgresql.conf.sample: ${copyError}`,
601
+ )
602
+ // Continue anyway - initdb creates a default config
603
+ }
604
+ } else {
605
+ logDebug(`Bundled postgresql.conf.sample not found at ${bundledConf}`)
504
606
  }
505
- } else {
506
- logDebug(`Bundled postgresql.conf.sample not found at ${bundledConf}`)
507
607
  }
508
608
  }
509
609
 
@@ -545,42 +645,32 @@ export class FerretDBEngine extends BaseEngine {
545
645
 
546
646
  const { platform, arch } = this.getPlatformInfo()
547
647
  const fullVersion = normalizeVersion(version)
548
- const fullBackendVersion = normalizeDocumentDBVersion(
549
- backendVersion || DEFAULT_DOCUMENTDB_VERSION,
550
- )
648
+ const v1 = isV1(version)
649
+ const effectiveBackendVersion = v1
650
+ ? backendVersion || DEFAULT_V1_POSTGRESQL_VERSION
651
+ : normalizeDocumentDBVersion(backendVersion || DEFAULT_DOCUMENTDB_VERSION)
551
652
 
552
- // Get binary paths
653
+ // Get binary paths using version-aware helper
553
654
  const ferretdbPath = ferretdbBinaryManager.getFerretDBBinaryPath(
554
655
  fullVersion,
555
656
  platform,
556
657
  arch,
557
658
  )
558
- const documentdbPath = ferretdbBinaryManager.getDocumentDBBinaryPath(
559
- fullBackendVersion,
659
+ const { backendPath: documentdbPath, pgSpawnEnv } = this.getBackendPaths(
660
+ version,
661
+ effectiveBackendVersion,
560
662
  platform,
561
663
  arch,
562
664
  )
563
665
 
564
- // Get spawn env for postgresql-documentdb binaries:
565
- // - Linux: LD_LIBRARY_PATH for shared libraries
566
- // - macOS: DYLD_FALLBACK_LIBRARY_PATH (not stripped by SIP)
567
- const baseSpawnEnv = ferretdbBinaryManager.getDocumentDBSpawnEnv(
568
- fullBackendVersion,
569
- platform,
570
- arch,
571
- )
572
- const pgSpawnEnv =
573
- platform === 'darwin'
574
- ? {
575
- ...baseSpawnEnv,
576
- DYLD_FALLBACK_LIBRARY_PATH: join(documentdbPath, 'lib'),
577
- }
578
- : baseSpawnEnv
579
-
580
666
  const ext = platformService.getExecutableExtension()
581
667
  const ferretdbBinary = join(ferretdbPath, 'bin', `ferretdb${ext}`)
582
668
  const pgCtl = join(documentdbPath, 'bin', `pg_ctl${ext}`)
583
- const psql = join(documentdbPath, 'bin', `psql${ext}`)
669
+ // v1 backend may be a minimal PostgreSQL install (shared with DocumentDB) that
670
+ // lacks client tools. Use postgres --single as fallback for database creation.
671
+ const psqlCandidate = join(documentdbPath, 'bin', `psql${ext}`)
672
+ const psql = existsSync(psqlCandidate) ? psqlCandidate : null
673
+ const postgresBinary = join(documentdbPath, 'bin', `postgres${ext}`)
584
674
 
585
675
  // Verify binaries exist
586
676
  if (!existsSync(ferretdbBinary)) {
@@ -604,9 +694,10 @@ export class FerretDBEngine extends BaseEngine {
604
694
  // Allocate backend port
605
695
  const backendPort = existingBackendPort || (await allocateBackendPort())
606
696
 
607
- // Fix hardcoded Homebrew dylib paths (darwin-x64 binaries)
697
+ // v2 only: Fix hardcoded Homebrew dylib paths (darwin-x64 binaries)
608
698
  // Skip if already completed (marker written by initDataDir or a previous start)
609
- if (platform === 'darwin') {
699
+ // v1 uses plain PostgreSQL which doesn't have DocumentDB extensions
700
+ if (!v1 && platform === 'darwin') {
610
701
  const dylibMarker = join(documentdbPath, '.dylib_fix_done')
611
702
  if (!existsSync(dylibMarker)) {
612
703
  await this.fixDylibDependencies(documentdbPath)
@@ -642,13 +733,46 @@ export class FerretDBEngine extends BaseEngine {
642
733
  // Exit code != 0 means not running — proceed to start
643
734
  }
644
735
 
736
+ // v1 pre-start: Create ferretdb database using postgres --single mode
737
+ // when psql is unavailable (minimal PG install may lack client tools).
738
+ // postgres --single requires exclusive data dir access, so this MUST
739
+ // happen before pg_ctl start.
740
+ if (v1 && !psql && !pgAlreadyRunning) {
741
+ logDebug(
742
+ 'psql not found in backend, using postgres --single to pre-create database',
743
+ )
744
+ try {
745
+ await spawnWithInput(
746
+ postgresBinary,
747
+ ['--single', '-D', pgDataDir, 'postgres'],
748
+ "CREATE DATABASE ferretdb ENCODING 'UTF8';\n",
749
+ { env: pgSpawnEnv, timeout: 30000 },
750
+ )
751
+ logDebug('Pre-created ferretdb database via postgres --single')
752
+ } catch {
753
+ // Database may already exist from a previous start — safe to ignore
754
+ logDebug(
755
+ 'postgres --single CREATE DATABASE failed (may already exist)',
756
+ )
757
+ }
758
+ }
759
+
645
760
  if (!pgAlreadyRunning) {
646
761
  // Use pg_ctl to start PostgreSQL
647
- // Add 60s timeout to prevent hanging if PostgreSQL fails to start (especially on Windows)
762
+ // On Windows, spawnAsync pipes stdout/stderr which get inherited by the
763
+ // PostgreSQL background process, preventing the 'close' event from firing
764
+ // until PG itself exits (causing a 60s timeout even though PG is ready).
765
+ // Use exec() on Windows (matches process-manager.ts approach) which runs
766
+ // through the shell and doesn't hold pipes open. On Unix, use -w (wait mode).
648
767
  try {
649
- await spawnAsync(
650
- pgCtl,
651
- [
768
+ if (isWindows()) {
769
+ const cmd = `"${pgCtl}" start -D "${pgDataDir}" -l "${pgLogFile}" -o "-p ${backendPort} -h 127.0.0.1"`
770
+ await execAsync(cmd, {
771
+ env: { ...process.env, ...pgSpawnEnv },
772
+ timeout: 30000,
773
+ })
774
+ } else {
775
+ const pgCtlArgs = [
652
776
  'start',
653
777
  '-D',
654
778
  pgDataDir,
@@ -656,10 +780,13 @@ export class FerretDBEngine extends BaseEngine {
656
780
  pgLogFile,
657
781
  '-o',
658
782
  `-p ${backendPort} -h 127.0.0.1`,
659
- '-w', // Wait for startup
660
- ],
661
- { env: pgSpawnEnv, timeout: 60000 },
662
- )
783
+ '-w',
784
+ ]
785
+ await spawnAsync(pgCtl, pgCtlArgs, {
786
+ env: pgSpawnEnv,
787
+ timeout: 60000,
788
+ })
789
+ }
663
790
  } catch (pgError) {
664
791
  // Read PostgreSQL log for debugging
665
792
  let pgLog = ''
@@ -686,56 +813,60 @@ export class FerretDBEngine extends BaseEngine {
686
813
  }
687
814
 
688
815
  // 3. Create ferretdb database and extension (first start)
689
- onProgress?.({
690
- stage: 'starting',
691
- message: 'Initializing FerretDB database...',
692
- })
693
- try {
694
- // Create ferretdb database if it doesn't exist
695
- // Add timeout to prevent hanging on Windows
696
- await spawnAsync(
697
- psql,
698
- [
699
- '-h',
700
- '127.0.0.1',
701
- '-p',
702
- String(backendPort),
703
- '-U',
704
- 'postgres',
705
- '-c',
706
- "CREATE DATABASE ferretdb WITH ENCODING 'UTF8';",
707
- ],
708
- { env: pgSpawnEnv, timeout: 30000 },
709
- ).catch(() => {
710
- // Ignore error if database already exists (error code 42P04)
816
+ // For v1 without psql, database was already created pre-start via postgres --single
817
+ if (psql) {
818
+ onProgress?.({
819
+ stage: 'starting',
820
+ message: 'Initializing FerretDB database...',
711
821
  })
822
+ try {
823
+ // Create ferretdb database if it doesn't exist
824
+ await spawnAsync(
825
+ psql,
826
+ [
827
+ '-h',
828
+ '127.0.0.1',
829
+ '-p',
830
+ String(backendPort),
831
+ '-U',
832
+ 'postgres',
833
+ '-c',
834
+ "CREATE DATABASE ferretdb WITH ENCODING 'UTF8';",
835
+ ],
836
+ { env: pgSpawnEnv, timeout: 30000 },
837
+ ).catch(() => {
838
+ // Ignore error if database already exists (error code 42P04)
839
+ })
712
840
 
713
- // Create DocumentDB extension
714
- // Add timeout to prevent hanging on Windows
715
- await spawnAsync(
716
- psql,
717
- [
718
- '-h',
719
- '127.0.0.1',
720
- '-p',
721
- String(backendPort),
722
- '-U',
723
- 'postgres',
724
- '-d',
725
- 'ferretdb',
726
- '-c',
727
- 'CREATE EXTENSION IF NOT EXISTS documentdb CASCADE;',
728
- ],
729
- { env: pgSpawnEnv, timeout: 30000 },
730
- ).catch((error) => {
731
- logWarning(`Failed to create documentdb extension: ${error}`)
732
- // Continue anyway - extension might already exist
733
- })
841
+ // v2 only: Create DocumentDB extension
842
+ // v1 uses plain PostgreSQL without DocumentDB
843
+ if (!v1) {
844
+ await spawnAsync(
845
+ psql,
846
+ [
847
+ '-h',
848
+ '127.0.0.1',
849
+ '-p',
850
+ String(backendPort),
851
+ '-U',
852
+ 'postgres',
853
+ '-d',
854
+ 'ferretdb',
855
+ '-c',
856
+ 'CREATE EXTENSION IF NOT EXISTS documentdb CASCADE;',
857
+ ],
858
+ { env: pgSpawnEnv, timeout: 30000 },
859
+ ).catch((error) => {
860
+ logWarning(`Failed to create documentdb extension: ${error}`)
861
+ // Continue anyway - extension might already exist
862
+ })
863
+ }
734
864
 
735
- logDebug('FerretDB database initialized')
736
- } catch (error) {
737
- logDebug(`Database initialization warning: ${error}`)
738
- // Continue - might already be initialized
865
+ logDebug('FerretDB database initialized')
866
+ } catch (error) {
867
+ logDebug(`Database initialization warning: ${error}`)
868
+ // Continue - might already be initialized
869
+ }
739
870
  }
740
871
 
741
872
  // 4. Start FerretDB proxy
@@ -773,14 +904,22 @@ export class FerretDBEngine extends BaseEngine {
773
904
 
774
905
  logDebug(`Using debug port ${debugPort} for FerretDB HTTP debug handler`)
775
906
 
907
+ // v1 uses plain PostgreSQL without TLS configured, so sslmode=disable is required
908
+ // v2 uses postgresql-documentdb which handles SSL negotiation internally
909
+ const pgUrl = isV1(version)
910
+ ? `postgres://postgres@127.0.0.1:${backendPort}/ferretdb?sslmode=disable`
911
+ : `postgres://postgres@127.0.0.1:${backendPort}/ferretdb`
912
+
776
913
  const ferretArgs = [
777
914
  '--listen-addr',
778
915
  `127.0.0.1:${port}`,
779
916
  '--postgresql-url',
780
- `postgres://postgres@127.0.0.1:${backendPort}/ferretdb`,
917
+ pgUrl,
781
918
  '--state-dir',
782
919
  containerDir,
783
- '--no-auth',
920
+ // v2 requires --no-auth to disable SCRAM authentication
921
+ // v1 has auth disabled by default (flag doesn't exist)
922
+ ...(isV1(version) ? [] : ['--no-auth']),
784
923
  '--debug-addr',
785
924
  `127.0.0.1:${debugPort}`,
786
925
  ]
@@ -853,32 +992,20 @@ export class FerretDBEngine extends BaseEngine {
853
992
  * Stop FerretDB (reverse order: FerretDB first, then PostgreSQL)
854
993
  */
855
994
  async stop(container: ContainerConfig): Promise<void> {
856
- const { name, backendVersion } = container
995
+ const { name, version, backendVersion } = container
857
996
  const { platform, arch } = this.getPlatformInfo()
997
+ const v1 = isV1(version)
858
998
 
859
- const fullBackendVersion = normalizeDocumentDBVersion(
860
- backendVersion || DEFAULT_DOCUMENTDB_VERSION,
861
- )
999
+ const effectiveBackendVersion = v1
1000
+ ? backendVersion || DEFAULT_V1_POSTGRESQL_VERSION
1001
+ : backendVersion || DEFAULT_DOCUMENTDB_VERSION
862
1002
 
863
- const documentdbPath = ferretdbBinaryManager.getDocumentDBBinaryPath(
864
- fullBackendVersion,
865
- platform,
866
- arch,
867
- )
868
-
869
- // Get spawn env for postgresql-documentdb binaries
870
- const baseStopEnv = ferretdbBinaryManager.getDocumentDBSpawnEnv(
871
- fullBackendVersion,
1003
+ const { backendPath: documentdbPath, pgSpawnEnv } = this.getBackendPaths(
1004
+ version,
1005
+ effectiveBackendVersion,
872
1006
  platform,
873
1007
  arch,
874
1008
  )
875
- const pgSpawnEnv =
876
- platform === 'darwin'
877
- ? {
878
- ...baseStopEnv,
879
- DYLD_FALLBACK_LIBRARY_PATH: join(documentdbPath, 'lib'),
880
- }
881
- : baseStopEnv
882
1009
 
883
1010
  const ext = platformService.getExecutableExtension()
884
1011
  const pgCtl = join(documentdbPath, 'bin', `pg_ctl${ext}`)
@@ -1002,13 +1129,32 @@ export class FerretDBEngine extends BaseEngine {
1002
1129
  const pidFile = join(containerDir, 'ferretdb.pid')
1003
1130
 
1004
1131
  if (existsSync(pidFile)) {
1132
+ let pid = NaN
1005
1133
  try {
1006
1134
  const pidContent = await readFile(pidFile, 'utf8')
1007
- const pid = parseInt(pidContent.trim(), 10)
1135
+ pid = parseInt(pidContent.trim(), 10)
1136
+ } catch {
1137
+ // PID file unreadable — clean it up below
1138
+ }
1139
+
1140
+ if (!isNaN(pid) && platformService.isProcessRunning(pid)) {
1141
+ logDebug(`Killing FerretDB process ${pid}`)
1008
1142
 
1009
- if (!isNaN(pid) && platformService.isProcessRunning(pid)) {
1010
- logDebug(`Killing FerretDB process ${pid}`)
1011
- await platformService.terminateProcess(pid, false)
1143
+ // On Windows, taskkill without /F sends WM_CLOSE which console/server
1144
+ // processes ignore, causing an error. Use force kill directly.
1145
+ if (isWindows()) {
1146
+ try {
1147
+ await platformService.terminateProcess(pid, true)
1148
+ } catch {
1149
+ logDebug(`Force kill of FerretDB process ${pid} failed`)
1150
+ }
1151
+ } else {
1152
+ // Unix: try graceful SIGTERM first, then SIGKILL
1153
+ try {
1154
+ await platformService.terminateProcess(pid, false)
1155
+ } catch {
1156
+ // Graceful termination failed — force kill below
1157
+ }
1012
1158
 
1013
1159
  // Poll until process exits or timeout (10 seconds)
1014
1160
  const maxWaitMs = 10000
@@ -1026,14 +1172,26 @@ export class FerretDBEngine extends BaseEngine {
1026
1172
  // Force kill if still running after timeout
1027
1173
  if (platformService.isProcessRunning(pid)) {
1028
1174
  logWarning(`Graceful termination timed out, force killing ${pid}`)
1029
- await platformService.terminateProcess(pid, true)
1175
+ try {
1176
+ await platformService.terminateProcess(pid, true)
1177
+ } catch {
1178
+ logDebug(`Force kill of FerretDB process ${pid} failed`)
1179
+ }
1030
1180
  }
1031
1181
  }
1032
1182
 
1033
- await unlink(pidFile).catch(() => {})
1034
- } catch (error) {
1035
- logDebug(`Error stopping FerretDB: ${error}`)
1183
+ // Wait briefly for process to fully exit after force kill
1184
+ const exitWaitMs = isWindows() ? 3000 : 1000
1185
+ const pollMs = 100
1186
+ const exitStart = Date.now()
1187
+ while (Date.now() - exitStart < exitWaitMs) {
1188
+ if (!platformService.isProcessRunning(pid)) break
1189
+ await new Promise((resolve) => setTimeout(resolve, pollMs))
1190
+ }
1036
1191
  }
1192
+
1193
+ // Always clean up PID file
1194
+ await unlink(pidFile).catch(() => {})
1037
1195
  }
1038
1196
  }
1039
1197
 
@@ -1045,24 +1203,45 @@ export class FerretDBEngine extends BaseEngine {
1045
1203
  pgDataDir: string,
1046
1204
  spawnEnv?: Record<string, string>,
1047
1205
  ): Promise<void> {
1048
- try {
1049
- // Add timeout to prevent hanging on Windows
1050
- await spawnAsync(pgCtl, ['stop', '-D', pgDataDir, '-m', 'fast', '-w'], {
1051
- env: spawnEnv,
1052
- timeout: 30000,
1053
- })
1054
- logDebug('PostgreSQL stopped')
1055
- } catch (error) {
1056
- logDebug(`pg_ctl stop error: ${error}`)
1057
- // Try immediate mode if fast fails
1206
+ if (isWindows()) {
1207
+ // On Windows, use exec() instead of spawnAsync() to avoid pipe-related
1208
+ // hangs (same issue as pg_ctl start -w). pg_ctl stop -w can block when
1209
+ // stdout/stderr pipes prevent the child process from exiting cleanly.
1058
1210
  try {
1059
- await spawnAsync(
1060
- pgCtl,
1061
- ['stop', '-D', pgDataDir, '-m', 'immediate', '-w'],
1062
- { env: spawnEnv, timeout: 15000 },
1063
- )
1064
- } catch {
1065
- logWarning('Failed to stop PostgreSQL gracefully')
1211
+ await execAsync(`"${pgCtl}" stop -D "${pgDataDir}" -m fast -w`, {
1212
+ timeout: 30000,
1213
+ env: spawnEnv ? { ...process.env, ...spawnEnv } : undefined,
1214
+ })
1215
+ logDebug('PostgreSQL stopped')
1216
+ } catch (error) {
1217
+ logDebug(`pg_ctl stop error: ${error}`)
1218
+ try {
1219
+ await execAsync(`"${pgCtl}" stop -D "${pgDataDir}" -m immediate -w`, {
1220
+ timeout: 15000,
1221
+ env: spawnEnv ? { ...process.env, ...spawnEnv } : undefined,
1222
+ })
1223
+ } catch {
1224
+ logWarning('Failed to stop PostgreSQL gracefully')
1225
+ }
1226
+ }
1227
+ } else {
1228
+ try {
1229
+ await spawnAsync(pgCtl, ['stop', '-D', pgDataDir, '-m', 'fast', '-w'], {
1230
+ env: spawnEnv,
1231
+ timeout: 30000,
1232
+ })
1233
+ logDebug('PostgreSQL stopped')
1234
+ } catch (error) {
1235
+ logDebug(`pg_ctl stop error: ${error}`)
1236
+ try {
1237
+ await spawnAsync(
1238
+ pgCtl,
1239
+ ['stop', '-D', pgDataDir, '-m', 'immediate', '-w'],
1240
+ { env: spawnEnv, timeout: 15000 },
1241
+ )
1242
+ } catch {
1243
+ logWarning('Failed to stop PostgreSQL gracefully')
1244
+ }
1066
1245
  }
1067
1246
  }
1068
1247
  }
@@ -1136,10 +1315,41 @@ export class FerretDBEngine extends BaseEngine {
1136
1315
  // Validate database name before restore (defense-in-depth)
1137
1316
  assertValidDatabaseName(database)
1138
1317
 
1139
- return restoreBackup(container, backupPath, {
1318
+ const result = await restoreBackup(container, backupPath, {
1140
1319
  database,
1141
1320
  drop: options.drop !== false,
1142
1321
  })
1322
+
1323
+ // Restart FerretDB proxy so it picks up the restored data.
1324
+ // pg_restore writes directly to PostgreSQL, but FerretDB's proxy
1325
+ // caches schema/collection metadata in memory and won't see
1326
+ // the restored collections until restarted.
1327
+ const containerDir = paths.getContainerPath(container.name, {
1328
+ engine: ENGINE,
1329
+ })
1330
+ try {
1331
+ await this.stopFerretDBProcess(containerDir)
1332
+ // start() detects PG is already running and only launches the proxy
1333
+ await this.start(container)
1334
+ } catch (error) {
1335
+ const err = error as Error
1336
+ logWarning(
1337
+ `Failed to restart FerretDB proxy after restore: ${err.message}`,
1338
+ )
1339
+ // Retry once — transient issues (port race, slow PG) can resolve on second attempt
1340
+ try {
1341
+ await this.stopFerretDBProcess(containerDir).catch(() => {})
1342
+ await this.start(container)
1343
+ } catch {
1344
+ throw new Error(
1345
+ `Restore succeeded but FerretDB proxy failed to restart. ` +
1346
+ `Data is safely in PostgreSQL. Run 'spindb start ${container.name}' to restart manually. ` +
1347
+ `Original error: ${err.message}`,
1348
+ )
1349
+ }
1350
+ }
1351
+
1352
+ return result
1143
1353
  }
1144
1354
 
1145
1355
  // Get connection string (MongoDB-compatible)
@@ -1280,7 +1490,8 @@ export class FerretDBEngine extends BaseEngine {
1280
1490
  const lastBrace = stdout.lastIndexOf('}')
1281
1491
  if (firstBrace !== -1 && lastBrace !== -1 && lastBrace > firstBrace) {
1282
1492
  const stats = JSON.parse(stdout.substring(firstBrace, lastBrace + 1))
1283
- return stats?.dataSize || null
1493
+ const dataSize = Number(stats?.dataSize)
1494
+ return Number.isFinite(dataSize) && dataSize > 0 ? dataSize : null
1284
1495
  }
1285
1496
  return null
1286
1497
  } catch {