spindb 0.34.0 → 0.34.5

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.
@@ -0,0 +1,22 @@
1
+ /**
2
+ * DuckDB Scanner — thin wrapper around shared file-based-utils
3
+ */
4
+
5
+ import { Engine } from '../../types'
6
+ import {
7
+ scanForUnregisteredFiles,
8
+ deriveContainerName as sharedDeriveContainerName,
9
+ type UnregisteredFile,
10
+ } from '../file-based-utils'
11
+
12
+ export type { UnregisteredFile }
13
+
14
+ export async function scanForUnregisteredDuckDBFiles(
15
+ directory?: string,
16
+ ): Promise<UnregisteredFile[]> {
17
+ return scanForUnregisteredFiles(Engine.DuckDB, directory)
18
+ }
19
+
20
+ export function deriveContainerName(fileName: string): string {
21
+ return sharedDeriveContainerName(fileName, Engine.DuckDB)
22
+ }
@@ -0,0 +1,262 @@
1
+ /**
2
+ * Centralized utilities for file-based engines (SQLite, DuckDB)
3
+ *
4
+ * This module is the single source of truth for:
5
+ * - Extension → engine mapping
6
+ * - Engine → valid extensions
7
+ * - Engine → registry
8
+ * - Container name derivation from filenames
9
+ * - Scanning for unregistered files
10
+ *
11
+ * All file-based engine behavior should go through this module
12
+ * so that adding a new file-based engine only requires changes here.
13
+ */
14
+
15
+ import { readdir } from 'fs/promises'
16
+ import { existsSync } from 'fs'
17
+ import { resolve, extname } from 'path'
18
+ import { Engine, isFileBasedEngine } from '../types'
19
+ import { sqliteRegistry } from './sqlite/registry'
20
+ import { duckdbRegistry } from './duckdb/registry'
21
+
22
+ // ============================================================
23
+ // Extension Mapping
24
+ // ============================================================
25
+
26
+ /**
27
+ * Map of file extensions to their corresponding file-based engine.
28
+ * This is the single source of truth for extension → engine detection.
29
+ */
30
+ const EXTENSION_TO_ENGINE: Record<string, Engine> = {
31
+ '.sqlite': Engine.SQLite,
32
+ '.sqlite3': Engine.SQLite,
33
+ '.db': Engine.SQLite,
34
+ '.duckdb': Engine.DuckDB,
35
+ '.ddb': Engine.DuckDB,
36
+ }
37
+
38
+ /**
39
+ * Valid extensions per engine, derived from the extension map.
40
+ * Used for validation (e.g., ensuring a SQLite container only relocates to a SQLite extension).
41
+ */
42
+ const ENGINE_EXTENSIONS: Record<Engine.SQLite | Engine.DuckDB, string[]> = {
43
+ [Engine.SQLite]: ['.sqlite', '.sqlite3', '.db'],
44
+ [Engine.DuckDB]: ['.duckdb', '.ddb'],
45
+ }
46
+
47
+ /**
48
+ * Extension regex per engine (for stripping extensions from filenames).
49
+ */
50
+ const ENGINE_EXTENSION_REGEX: Record<Engine.SQLite | Engine.DuckDB, RegExp> = {
51
+ [Engine.SQLite]: /\.(sqlite3?|db)$/i,
52
+ [Engine.DuckDB]: /\.(duckdb|ddb)$/i,
53
+ }
54
+
55
+ /**
56
+ * Combined regex matching any file-based engine extension.
57
+ */
58
+ export const FILE_BASED_EXTENSION_REGEX = /\.(sqlite3?|db|duckdb|ddb)$/i
59
+
60
+ /**
61
+ * Detect which file-based engine a file belongs to based on its extension.
62
+ * Returns null if the extension is not recognized as a file-based database.
63
+ */
64
+ export function detectEngineFromPath(filePath: string): Engine | null {
65
+ const ext = extname(filePath).toLowerCase()
66
+ return EXTENSION_TO_ENGINE[ext] ?? null
67
+ }
68
+
69
+ /**
70
+ * Get the valid file extensions for a file-based engine.
71
+ */
72
+ export function getExtensionsForEngine(
73
+ engine: Engine.SQLite | Engine.DuckDB,
74
+ ): string[] {
75
+ return ENGINE_EXTENSIONS[engine]
76
+ }
77
+
78
+ /**
79
+ * Get all valid file-based database extensions.
80
+ */
81
+ export function getAllFileBasedExtensions(): string[] {
82
+ return Object.keys(EXTENSION_TO_ENGINE)
83
+ }
84
+
85
+ /**
86
+ * Check if a file path has a valid extension for the given engine.
87
+ */
88
+ export function isValidExtensionForEngine(
89
+ filePath: string,
90
+ engine: Engine.SQLite | Engine.DuckDB,
91
+ ): boolean {
92
+ const ext = extname(filePath).toLowerCase()
93
+ return ENGINE_EXTENSIONS[engine].includes(ext)
94
+ }
95
+
96
+ /**
97
+ * Human-readable string of valid extensions for a file-based engine.
98
+ */
99
+ export function formatExtensionsForEngine(
100
+ engine: Engine.SQLite | Engine.DuckDB,
101
+ ): string {
102
+ return ENGINE_EXTENSIONS[engine].join(', ')
103
+ }
104
+
105
+ /**
106
+ * Human-readable string of all valid file-based extensions.
107
+ */
108
+ export function formatAllExtensions(): string {
109
+ return Object.keys(EXTENSION_TO_ENGINE).join(', ')
110
+ }
111
+
112
+ // ============================================================
113
+ // Registry Access
114
+ // ============================================================
115
+
116
+ /**
117
+ * Common interface for file-based engine registries.
118
+ * Both SQLite and DuckDB registries implement this shape.
119
+ */
120
+ export type FileBasedRegistry = {
121
+ add(entry: { name: string; filePath: string; created: string }): Promise<void>
122
+ get(name: string): Promise<{ name: string; filePath: string } | null>
123
+ remove(name: string): Promise<boolean>
124
+ update(
125
+ name: string,
126
+ updates: { filePath?: string; lastVerified?: string },
127
+ ): Promise<boolean>
128
+ exists(name: string): Promise<boolean>
129
+ isPathRegistered(filePath: string): Promise<boolean>
130
+ getByPath(
131
+ filePath: string,
132
+ ): Promise<{ name: string; filePath: string } | null>
133
+ isFolderIgnored(folderPath: string): Promise<boolean>
134
+ addIgnoreFolder(folderPath: string): Promise<void>
135
+ removeIgnoreFolder(folderPath: string): Promise<boolean>
136
+ listIgnoredFolders(): Promise<string[]>
137
+ }
138
+
139
+ /**
140
+ * Get the registry for a file-based engine.
141
+ * Throws if the engine is not file-based.
142
+ */
143
+ export function getRegistryForEngine(engine: Engine): FileBasedRegistry {
144
+ switch (engine) {
145
+ case Engine.SQLite:
146
+ return sqliteRegistry
147
+ case Engine.DuckDB:
148
+ return duckdbRegistry
149
+ default:
150
+ if (isFileBasedEngine(engine)) {
151
+ throw new Error(
152
+ `File-based engine "${engine}" has no registry configured in getRegistryForEngine()`,
153
+ )
154
+ }
155
+ throw new Error(`"${engine}" is not a file-based engine`)
156
+ }
157
+ }
158
+
159
+ // ============================================================
160
+ // Container Name Derivation
161
+ // ============================================================
162
+
163
+ /**
164
+ * Derive a valid container name from a database filename.
165
+ * Removes the engine-specific extension and sanitizes for use as a container name.
166
+ *
167
+ * - Must start with a letter
168
+ * - Can contain letters, numbers, hyphens, underscores
169
+ * - Falls back to engine-specific default if result is empty
170
+ */
171
+ export function deriveContainerName(
172
+ fileName: string,
173
+ engine: Engine.SQLite | Engine.DuckDB,
174
+ ): string {
175
+ const extensionRegex = ENGINE_EXTENSION_REGEX[engine]
176
+ const fallback = engine === Engine.SQLite ? 'sqlite-db' : 'duckdb-db'
177
+
178
+ // Remove extension
179
+ const base = fileName.replace(extensionRegex, '')
180
+
181
+ // If nothing remains after extension removal, return engine-specific fallback
182
+ const sanitizedBase = base
183
+ .replace(/[^a-zA-Z0-9_-]/g, '-')
184
+ .replace(/^-+|-+$/g, '')
185
+ if (!sanitizedBase) {
186
+ return fallback
187
+ }
188
+
189
+ // Convert to valid container name (alphanumeric, hyphens, underscores)
190
+ let name = base.replace(/[^a-zA-Z0-9_-]/g, '-')
191
+
192
+ // Ensure starts with letter
193
+ if (!/^[a-zA-Z]/.test(name)) {
194
+ name = 'db-' + name
195
+ }
196
+
197
+ // Remove consecutive hyphens
198
+ name = name.replace(/-+/g, '-')
199
+
200
+ // Trim leading/trailing hyphens
201
+ name = name.replace(/^-+|-+$/g, '')
202
+
203
+ return name || fallback
204
+ }
205
+
206
+ // ============================================================
207
+ // File Scanning
208
+ // ============================================================
209
+
210
+ export type UnregisteredFile = {
211
+ fileName: string
212
+ absolutePath: string
213
+ }
214
+
215
+ /**
216
+ * Scan a directory for unregistered file-based database files.
217
+ *
218
+ * @param engine The file-based engine to scan for
219
+ * @param directory Directory to scan (defaults to CWD)
220
+ * @returns Array of unregistered files
221
+ */
222
+ export async function scanForUnregisteredFiles(
223
+ engine: Engine.SQLite | Engine.DuckDB,
224
+ directory: string = process.cwd(),
225
+ ): Promise<UnregisteredFile[]> {
226
+ const absoluteDir = resolve(directory)
227
+ const registry = getRegistryForEngine(engine)
228
+ const extensionRegex = ENGINE_EXTENSION_REGEX[engine]
229
+
230
+ // Check if folder is ignored
231
+ if (await registry.isFolderIgnored(absoluteDir)) {
232
+ return []
233
+ }
234
+
235
+ // Check if directory exists
236
+ if (!existsSync(absoluteDir)) {
237
+ return []
238
+ }
239
+
240
+ try {
241
+ const entries = await readdir(absoluteDir, { withFileTypes: true })
242
+
243
+ const matchingFiles = entries
244
+ .filter((e) => e.isFile())
245
+ .filter((e) => extensionRegex.test(e.name))
246
+ .map((e) => ({
247
+ fileName: e.name,
248
+ absolutePath: resolve(absoluteDir, e.name),
249
+ }))
250
+
251
+ const unregistered: UnregisteredFile[] = []
252
+ for (const file of matchingFiles) {
253
+ if (!(await registry.isPathRegistered(file.absolutePath))) {
254
+ unregistered.push(file)
255
+ }
256
+ }
257
+
258
+ return unregistered
259
+ } catch {
260
+ return []
261
+ }
262
+ }
@@ -1044,8 +1044,41 @@ export class InfluxDBEngine extends BaseEngine {
1044
1044
  const database = options.database || container.database
1045
1045
 
1046
1046
  if (options.file) {
1047
- // Read file content and execute as SQL
1048
1047
  const content = await readFile(options.file, 'utf-8')
1048
+
1049
+ // Ensure the database exists (InfluxDB creates DBs implicitly on write,
1050
+ // but SQL queries fail if the DB doesn't exist yet)
1051
+ const createDbResp = await influxdbApiRequest(
1052
+ port,
1053
+ 'POST',
1054
+ '/api/v3/configure/database',
1055
+ { db: database },
1056
+ )
1057
+ if (createDbResp.status >= 400) {
1058
+ throw new Error(
1059
+ `Failed to create database "${database}": HTTP ${createDbResp.status} — ${JSON.stringify(createDbResp.data)}`,
1060
+ )
1061
+ }
1062
+
1063
+ // Line protocol files (.lp) → write via /api/v3/write_lp
1064
+ if (options.file.endsWith('.lp')) {
1065
+ const lines = content
1066
+ .split('\n')
1067
+ .filter((line) => line.trim().length > 0 && !line.startsWith('#'))
1068
+ .join('\n')
1069
+ const response = await influxdbApiRequest(
1070
+ port,
1071
+ 'POST',
1072
+ `/api/v3/write_lp?db=${encodeURIComponent(database)}`,
1073
+ lines,
1074
+ )
1075
+ if (response.status >= 400) {
1076
+ throw new Error(`Write error: ${JSON.stringify(response.data)}`)
1077
+ }
1078
+ return
1079
+ }
1080
+
1081
+ // SQL files → execute via /api/v3/query_sql
1049
1082
  const statements = content
1050
1083
  .split('\n')
1051
1084
  .filter((line) => !line.startsWith('--') && line.trim().length > 0)
@@ -1076,6 +1109,18 @@ export class InfluxDBEngine extends BaseEngine {
1076
1109
  }
1077
1110
 
1078
1111
  if (options.sql) {
1112
+ // Ensure database exists for inline SQL too
1113
+ const createDbResp2 = await influxdbApiRequest(
1114
+ port,
1115
+ 'POST',
1116
+ '/api/v3/configure/database',
1117
+ { db: database },
1118
+ )
1119
+ if (createDbResp2.status >= 400) {
1120
+ throw new Error(
1121
+ `Failed to create database "${database}": HTTP ${createDbResp2.status} — ${JSON.stringify(createDbResp2.data)}`,
1122
+ )
1123
+ }
1079
1124
  const response = await influxdbApiRequest(
1080
1125
  port,
1081
1126
  'POST',
@@ -1,99 +1,22 @@
1
1
  /**
2
- * SQLite Scanner
3
- *
4
- * Scans directories for unregistered SQLite database files.
5
- * Used to detect SQLite databases in the current working directory
6
- * that are not yet registered with SpinDB.
2
+ * SQLite Scanner — thin wrapper around shared file-based-utils
7
3
  */
8
4
 
9
- import { readdir } from 'fs/promises'
10
- import { existsSync } from 'fs'
11
- import { resolve } from 'path'
12
- import { sqliteRegistry } from './registry'
5
+ import { Engine } from '../../types'
6
+ import {
7
+ scanForUnregisteredFiles,
8
+ deriveContainerName as sharedDeriveContainerName,
9
+ type UnregisteredFile,
10
+ } from '../file-based-utils'
13
11
 
14
- export type UnregisteredFile = {
15
- fileName: string
16
- absolutePath: string
17
- }
12
+ export type { UnregisteredFile }
18
13
 
19
- /**
20
- * Scan a directory for unregistered SQLite files
21
- * Returns files with .sqlite, .sqlite3, or .db extensions
22
- * that are not already in the registry
23
- *
24
- * @param directory Directory to scan (defaults to CWD)
25
- * @returns Array of unregistered SQLite files
26
- */
27
14
  export async function scanForUnregisteredSqliteFiles(
28
- directory: string = process.cwd(),
15
+ directory?: string,
29
16
  ): Promise<UnregisteredFile[]> {
30
- const absoluteDir = resolve(directory)
31
-
32
- // Check if folder is ignored
33
- if (await sqliteRegistry.isFolderIgnored(absoluteDir)) {
34
- return []
35
- }
36
-
37
- // Check if directory exists
38
- if (!existsSync(absoluteDir)) {
39
- return []
40
- }
41
-
42
- try {
43
- // Get all files in directory
44
- const entries = await readdir(absoluteDir, { withFileTypes: true })
45
-
46
- // Filter for SQLite files
47
- const sqliteFiles = entries
48
- .filter((e) => e.isFile())
49
- .filter((e) => /\.(sqlite3?|db)$/i.test(e.name))
50
- .map((e) => ({
51
- fileName: e.name,
52
- absolutePath: resolve(absoluteDir, e.name),
53
- }))
54
-
55
- // Filter out already registered files
56
- const unregistered: UnregisteredFile[] = []
57
- for (const file of sqliteFiles) {
58
- if (!(await sqliteRegistry.isPathRegistered(file.absolutePath))) {
59
- unregistered.push(file)
60
- }
61
- }
62
-
63
- return unregistered
64
- } catch {
65
- // If we can't read the directory, return empty
66
- return []
67
- }
17
+ return scanForUnregisteredFiles(Engine.SQLite, directory)
68
18
  }
69
19
 
70
- /**
71
- * Derive a valid container name from a filename
72
- * Removes extension and converts to valid container name format:
73
- * - Must start with a letter
74
- * - Can contain letters, numbers, hyphens, underscores
75
- *
76
- * @param fileName The SQLite filename (e.g., "my-database.sqlite")
77
- * @returns A valid container name (e.g., "my-database")
78
- */
79
20
  export function deriveContainerName(fileName: string): string {
80
- // Remove extension
81
- const base = fileName.replace(/\.(sqlite3?|db)$/i, '')
82
-
83
- // Convert to valid container name (alphanumeric, hyphens, underscores)
84
- // Replace invalid chars with hyphens
85
- let name = base.replace(/[^a-zA-Z0-9_-]/g, '-')
86
-
87
- // Ensure starts with letter
88
- if (!/^[a-zA-Z]/.test(name)) {
89
- name = 'db-' + name
90
- }
91
-
92
- // Remove consecutive hyphens
93
- name = name.replace(/-+/g, '-')
94
-
95
- // Trim leading/trailing hyphens
96
- name = name.replace(/^-+|-+$/g, '')
97
-
98
- return name || 'sqlite-db'
21
+ return sharedDeriveContainerName(fileName, Engine.SQLite)
99
22
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spindb",
3
- "version": "0.34.0",
3
+ "version": "0.34.5",
4
4
  "author": "Bob Bass <bob@bbass.co>",
5
5
  "license": "PolyForm-Noncommercial-1.0.0",
6
6
  "description": "Zero-config Docker-free local database containers. Create, backup, and clone a variety of popular databases.",