spindb 0.4.1 → 0.5.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.
- package/README.md +207 -101
- package/cli/commands/clone.ts +3 -1
- package/cli/commands/connect.ts +54 -24
- package/cli/commands/create.ts +309 -189
- package/cli/commands/delete.ts +3 -1
- package/cli/commands/deps.ts +19 -4
- package/cli/commands/edit.ts +245 -0
- package/cli/commands/engines.ts +434 -0
- package/cli/commands/info.ts +279 -0
- package/cli/commands/list.ts +14 -3
- package/cli/commands/menu.ts +510 -198
- package/cli/commands/restore.ts +66 -43
- package/cli/commands/start.ts +50 -19
- package/cli/commands/stop.ts +3 -1
- package/cli/commands/url.ts +79 -0
- package/cli/index.ts +9 -3
- package/cli/ui/prompts.ts +99 -34
- package/config/defaults.ts +40 -15
- package/config/engine-defaults.ts +107 -0
- package/config/os-dependencies.ts +119 -124
- package/config/paths.ts +82 -56
- package/core/binary-manager.ts +44 -6
- package/core/config-manager.ts +17 -5
- package/core/container-manager.ts +124 -60
- package/core/dependency-manager.ts +9 -15
- package/core/error-handler.ts +336 -0
- package/core/platform-service.ts +634 -0
- package/core/port-manager.ts +51 -32
- package/core/process-manager.ts +26 -8
- package/core/start-with-retry.ts +167 -0
- package/core/transaction-manager.ts +170 -0
- package/engines/index.ts +7 -2
- package/engines/mysql/binary-detection.ts +325 -0
- package/engines/mysql/index.ts +808 -0
- package/engines/mysql/restore.ts +257 -0
- package/engines/mysql/version-validator.ts +373 -0
- package/{core/postgres-binary-manager.ts → engines/postgresql/binary-manager.ts} +63 -23
- package/engines/postgresql/binary-urls.ts +5 -3
- package/engines/postgresql/index.ts +17 -9
- package/engines/postgresql/restore.ts +54 -5
- package/engines/postgresql/version-validator.ts +262 -0
- package/package.json +9 -3
- package/types/index.ts +29 -5
- package/cli/commands/postgres-tools.ts +0 -216
package/core/port-manager.ts
CHANGED
|
@@ -3,12 +3,21 @@ import { exec } from 'child_process'
|
|
|
3
3
|
import { promisify } from 'util'
|
|
4
4
|
import { existsSync } from 'fs'
|
|
5
5
|
import { readdir, readFile } from 'fs/promises'
|
|
6
|
-
import { defaults } from '../config/defaults'
|
|
6
|
+
import { defaults, getSupportedEngines } from '../config/defaults'
|
|
7
7
|
import { paths } from '../config/paths'
|
|
8
|
+
import { logDebug } from './error-handler'
|
|
8
9
|
import type { ContainerConfig, PortResult } from '../types'
|
|
9
10
|
|
|
10
11
|
const execAsync = promisify(exec)
|
|
11
12
|
|
|
13
|
+
/**
|
|
14
|
+
* Options for finding an available port
|
|
15
|
+
*/
|
|
16
|
+
type FindPortOptions = {
|
|
17
|
+
preferredPort?: number
|
|
18
|
+
portRange?: { start: number; end: number }
|
|
19
|
+
}
|
|
20
|
+
|
|
12
21
|
export class PortManager {
|
|
13
22
|
/**
|
|
14
23
|
* Check if a specific port is available
|
|
@@ -36,12 +45,13 @@ export class PortManager {
|
|
|
36
45
|
}
|
|
37
46
|
|
|
38
47
|
/**
|
|
39
|
-
* Find the next available port starting from the
|
|
48
|
+
* Find the next available port starting from the preferred port
|
|
40
49
|
* Returns the port number and whether it's the default port
|
|
41
50
|
*/
|
|
42
|
-
async findAvailablePort(
|
|
43
|
-
preferredPort
|
|
44
|
-
|
|
51
|
+
async findAvailablePort(options: FindPortOptions = {}): Promise<PortResult> {
|
|
52
|
+
const preferredPort = options.preferredPort ?? defaults.port
|
|
53
|
+
const portRange = options.portRange ?? defaults.portRange
|
|
54
|
+
|
|
45
55
|
// First try the preferred port
|
|
46
56
|
if (await this.isPortAvailable(preferredPort)) {
|
|
47
57
|
return {
|
|
@@ -51,11 +61,7 @@ export class PortManager {
|
|
|
51
61
|
}
|
|
52
62
|
|
|
53
63
|
// Scan for available ports in the range
|
|
54
|
-
for (
|
|
55
|
-
let port = defaults.portRange.start;
|
|
56
|
-
port <= defaults.portRange.end;
|
|
57
|
-
port++
|
|
58
|
-
) {
|
|
64
|
+
for (let port = portRange.start; port <= portRange.end; port++) {
|
|
59
65
|
if (port === preferredPort) continue // Already tried this one
|
|
60
66
|
|
|
61
67
|
if (await this.isPortAvailable(port)) {
|
|
@@ -67,7 +73,7 @@ export class PortManager {
|
|
|
67
73
|
}
|
|
68
74
|
|
|
69
75
|
throw new Error(
|
|
70
|
-
`No available ports found in range ${
|
|
76
|
+
`No available ports found in range ${portRange.start}-${portRange.end}`,
|
|
71
77
|
)
|
|
72
78
|
}
|
|
73
79
|
|
|
@@ -78,13 +84,17 @@ export class PortManager {
|
|
|
78
84
|
try {
|
|
79
85
|
const { stdout } = await execAsync(`lsof -i :${port} -P -n | head -5`)
|
|
80
86
|
return stdout.trim()
|
|
81
|
-
} catch {
|
|
87
|
+
} catch (error) {
|
|
88
|
+
logDebug('Could not determine port user', {
|
|
89
|
+
port,
|
|
90
|
+
error: error instanceof Error ? error.message : String(error),
|
|
91
|
+
})
|
|
82
92
|
return null
|
|
83
93
|
}
|
|
84
94
|
}
|
|
85
95
|
|
|
86
96
|
/**
|
|
87
|
-
* Get all ports currently assigned to containers
|
|
97
|
+
* Get all ports currently assigned to containers across all engines
|
|
88
98
|
*/
|
|
89
99
|
async getContainerPorts(): Promise<number[]> {
|
|
90
100
|
const containersDir = paths.containers
|
|
@@ -94,18 +104,29 @@ export class PortManager {
|
|
|
94
104
|
}
|
|
95
105
|
|
|
96
106
|
const ports: number[] = []
|
|
97
|
-
const
|
|
98
|
-
|
|
99
|
-
for (const
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
107
|
+
const engines = getSupportedEngines()
|
|
108
|
+
|
|
109
|
+
for (const engine of engines) {
|
|
110
|
+
const engineDir = paths.getEngineContainersPath(engine)
|
|
111
|
+
if (!existsSync(engineDir)) continue
|
|
112
|
+
|
|
113
|
+
const entries = await readdir(engineDir, { withFileTypes: true })
|
|
114
|
+
for (const entry of entries) {
|
|
115
|
+
if (entry.isDirectory()) {
|
|
116
|
+
const configPath = paths.getContainerConfigPath(entry.name, {
|
|
117
|
+
engine,
|
|
118
|
+
})
|
|
119
|
+
if (existsSync(configPath)) {
|
|
120
|
+
try {
|
|
121
|
+
const content = await readFile(configPath, 'utf8')
|
|
122
|
+
const config = JSON.parse(content) as ContainerConfig
|
|
123
|
+
ports.push(config.port)
|
|
124
|
+
} catch (error) {
|
|
125
|
+
logDebug('Skipping invalid container config', {
|
|
126
|
+
configPath,
|
|
127
|
+
error: error instanceof Error ? error.message : String(error),
|
|
128
|
+
})
|
|
129
|
+
}
|
|
109
130
|
}
|
|
110
131
|
}
|
|
111
132
|
}
|
|
@@ -118,8 +139,10 @@ export class PortManager {
|
|
|
118
139
|
* Find an available port that's not in use by any process AND not assigned to any container
|
|
119
140
|
*/
|
|
120
141
|
async findAvailablePortExcludingContainers(
|
|
121
|
-
|
|
142
|
+
options: FindPortOptions = {},
|
|
122
143
|
): Promise<PortResult> {
|
|
144
|
+
const preferredPort = options.preferredPort ?? defaults.port
|
|
145
|
+
const portRange = options.portRange ?? defaults.portRange
|
|
123
146
|
const containerPorts = await this.getContainerPorts()
|
|
124
147
|
|
|
125
148
|
// First try the preferred port
|
|
@@ -134,11 +157,7 @@ export class PortManager {
|
|
|
134
157
|
}
|
|
135
158
|
|
|
136
159
|
// Scan for available ports in the range
|
|
137
|
-
for (
|
|
138
|
-
let port = defaults.portRange.start;
|
|
139
|
-
port <= defaults.portRange.end;
|
|
140
|
-
port++
|
|
141
|
-
) {
|
|
160
|
+
for (let port = portRange.start; port <= portRange.end; port++) {
|
|
142
161
|
if (containerPorts.includes(port)) continue // Skip ports used by containers
|
|
143
162
|
if (port === preferredPort) continue // Already tried this one
|
|
144
163
|
|
|
@@ -151,7 +170,7 @@ export class PortManager {
|
|
|
151
170
|
}
|
|
152
171
|
|
|
153
172
|
throw new Error(
|
|
154
|
-
`No available ports found in range ${
|
|
173
|
+
`No available ports found in range ${portRange.start}-${portRange.end}`,
|
|
155
174
|
)
|
|
156
175
|
}
|
|
157
176
|
}
|
package/core/process-manager.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { promisify } from 'util'
|
|
|
3
3
|
import { existsSync } from 'fs'
|
|
4
4
|
import { readFile } from 'fs/promises'
|
|
5
5
|
import { paths } from '../config/paths'
|
|
6
|
+
import { logDebug } from './error-handler'
|
|
6
7
|
import type { ProcessResult, StatusResult } from '../types'
|
|
7
8
|
|
|
8
9
|
const execAsync = promisify(exec)
|
|
@@ -202,10 +203,14 @@ export class ProcessManager {
|
|
|
202
203
|
}
|
|
203
204
|
|
|
204
205
|
/**
|
|
205
|
-
* Check if
|
|
206
|
+
* Check if a database server is running by looking for PID file
|
|
206
207
|
*/
|
|
207
|
-
async isRunning(
|
|
208
|
-
|
|
208
|
+
async isRunning(
|
|
209
|
+
containerName: string,
|
|
210
|
+
options: { engine: string },
|
|
211
|
+
): Promise<boolean> {
|
|
212
|
+
const { engine } = options
|
|
213
|
+
const pidFile = paths.getContainerPidPath(containerName, { engine })
|
|
209
214
|
if (!existsSync(pidFile)) {
|
|
210
215
|
return false
|
|
211
216
|
}
|
|
@@ -217,16 +222,25 @@ export class ProcessManager {
|
|
|
217
222
|
// Check if process is still running
|
|
218
223
|
process.kill(pid, 0)
|
|
219
224
|
return true
|
|
220
|
-
} catch {
|
|
225
|
+
} catch (error) {
|
|
226
|
+
logDebug('PID file check failed', {
|
|
227
|
+
containerName,
|
|
228
|
+
engine: options.engine,
|
|
229
|
+
error: error instanceof Error ? error.message : String(error),
|
|
230
|
+
})
|
|
221
231
|
return false
|
|
222
232
|
}
|
|
223
233
|
}
|
|
224
234
|
|
|
225
235
|
/**
|
|
226
|
-
* Get the PID of a running
|
|
236
|
+
* Get the PID of a running database server
|
|
227
237
|
*/
|
|
228
|
-
async getPid(
|
|
229
|
-
|
|
238
|
+
async getPid(
|
|
239
|
+
containerName: string,
|
|
240
|
+
options: { engine: string },
|
|
241
|
+
): Promise<number | null> {
|
|
242
|
+
const { engine } = options
|
|
243
|
+
const pidFile = paths.getContainerPidPath(containerName, { engine })
|
|
230
244
|
if (!existsSync(pidFile)) {
|
|
231
245
|
return null
|
|
232
246
|
}
|
|
@@ -234,7 +248,11 @@ export class ProcessManager {
|
|
|
234
248
|
try {
|
|
235
249
|
const content = await readFile(pidFile, 'utf8')
|
|
236
250
|
return parseInt(content.split('\n')[0], 10)
|
|
237
|
-
} catch {
|
|
251
|
+
} catch (error) {
|
|
252
|
+
logDebug('Failed to read PID file', {
|
|
253
|
+
pidFile,
|
|
254
|
+
error: error instanceof Error ? error.message : String(error),
|
|
255
|
+
})
|
|
238
256
|
return null
|
|
239
257
|
}
|
|
240
258
|
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Start with Retry
|
|
3
|
+
*
|
|
4
|
+
* Handles port race conditions by automatically retrying with a new port
|
|
5
|
+
* when the original port becomes unavailable between check and bind.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { portManager } from './port-manager'
|
|
9
|
+
import { containerManager } from './container-manager'
|
|
10
|
+
import { logWarning, logDebug } from './error-handler'
|
|
11
|
+
import type { BaseEngine } from '../engines/base-engine'
|
|
12
|
+
import type { ContainerConfig } from '../types'
|
|
13
|
+
|
|
14
|
+
// =============================================================================
|
|
15
|
+
// Types
|
|
16
|
+
// =============================================================================
|
|
17
|
+
|
|
18
|
+
export type StartWithRetryOptions = {
|
|
19
|
+
engine: BaseEngine
|
|
20
|
+
config: ContainerConfig
|
|
21
|
+
maxRetries?: number // Default: 3
|
|
22
|
+
onPortChange?: (oldPort: number, newPort: number) => void
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type StartWithRetryResult = {
|
|
26
|
+
success: boolean
|
|
27
|
+
finalPort: number
|
|
28
|
+
retriesUsed: number
|
|
29
|
+
error?: Error
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// =============================================================================
|
|
33
|
+
// Port Error Detection
|
|
34
|
+
// =============================================================================
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Check if an error is a port-in-use error
|
|
38
|
+
*/
|
|
39
|
+
function isPortInUseError(err: unknown): boolean {
|
|
40
|
+
const message = (err as Error)?.message?.toLowerCase() || ''
|
|
41
|
+
return (
|
|
42
|
+
message.includes('address already in use') ||
|
|
43
|
+
message.includes('eaddrinuse') ||
|
|
44
|
+
(message.includes('port') && message.includes('in use')) ||
|
|
45
|
+
message.includes('could not bind') ||
|
|
46
|
+
message.includes('socket already in use')
|
|
47
|
+
)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// =============================================================================
|
|
51
|
+
// Start with Retry Implementation
|
|
52
|
+
// =============================================================================
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Start a database container with automatic port retry on conflict
|
|
56
|
+
*
|
|
57
|
+
* This handles the race condition where a port is available when checked
|
|
58
|
+
* but taken by the time the database server tries to bind to it.
|
|
59
|
+
*/
|
|
60
|
+
export async function startWithRetry(
|
|
61
|
+
options: StartWithRetryOptions,
|
|
62
|
+
): Promise<StartWithRetryResult> {
|
|
63
|
+
const { engine, config, maxRetries = 3, onPortChange } = options
|
|
64
|
+
|
|
65
|
+
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
|
66
|
+
try {
|
|
67
|
+
logDebug(`Starting ${engine.name} (attempt ${attempt}/${maxRetries})`, {
|
|
68
|
+
containerName: config.name,
|
|
69
|
+
port: config.port,
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
await engine.start(config)
|
|
73
|
+
|
|
74
|
+
return {
|
|
75
|
+
success: true,
|
|
76
|
+
finalPort: config.port,
|
|
77
|
+
retriesUsed: attempt - 1,
|
|
78
|
+
}
|
|
79
|
+
} catch (err) {
|
|
80
|
+
const isPortError = isPortInUseError(err)
|
|
81
|
+
|
|
82
|
+
logDebug(`Start attempt ${attempt} failed`, {
|
|
83
|
+
containerName: config.name,
|
|
84
|
+
port: config.port,
|
|
85
|
+
isPortError,
|
|
86
|
+
error: err instanceof Error ? err.message : String(err),
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
if (isPortError && attempt < maxRetries) {
|
|
90
|
+
const oldPort = config.port
|
|
91
|
+
|
|
92
|
+
// Find a new available port, excluding the one that just failed
|
|
93
|
+
const { port: newPort } = await portManager.findAvailablePort({
|
|
94
|
+
portRange: getEnginePortRange(config.engine),
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
// Update config with new port
|
|
98
|
+
config.port = newPort
|
|
99
|
+
await containerManager.updateConfig(config.name, { port: newPort })
|
|
100
|
+
|
|
101
|
+
// Notify caller of port change
|
|
102
|
+
if (onPortChange) {
|
|
103
|
+
onPortChange(oldPort, newPort)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Log and retry
|
|
107
|
+
logWarning(
|
|
108
|
+
`Port ${oldPort} is in use, retrying with port ${newPort}...`,
|
|
109
|
+
)
|
|
110
|
+
continue
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Not a port error or max retries exceeded
|
|
114
|
+
return {
|
|
115
|
+
success: false,
|
|
116
|
+
finalPort: config.port,
|
|
117
|
+
retriesUsed: attempt - 1,
|
|
118
|
+
error: err instanceof Error ? err : new Error(String(err)),
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Should never reach here, but TypeScript needs a return
|
|
124
|
+
return {
|
|
125
|
+
success: false,
|
|
126
|
+
finalPort: config.port,
|
|
127
|
+
retriesUsed: maxRetries,
|
|
128
|
+
error: new Error('Max retries exceeded'),
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Get the port range for an engine
|
|
134
|
+
*/
|
|
135
|
+
function getEnginePortRange(engine: string): { start: number; end: number } {
|
|
136
|
+
// Import defaults dynamically to avoid circular dependency
|
|
137
|
+
// These are the standard ranges from config/defaults.ts
|
|
138
|
+
if (engine === 'postgresql') {
|
|
139
|
+
return { start: 5432, end: 5500 }
|
|
140
|
+
}
|
|
141
|
+
if (engine === 'mysql') {
|
|
142
|
+
return { start: 3306, end: 3400 }
|
|
143
|
+
}
|
|
144
|
+
// Default fallback range
|
|
145
|
+
return { start: 5432, end: 6000 }
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Wrapper that simplifies the common use case
|
|
150
|
+
*/
|
|
151
|
+
export async function startContainerWithRetry(
|
|
152
|
+
engine: BaseEngine,
|
|
153
|
+
config: ContainerConfig,
|
|
154
|
+
options?: {
|
|
155
|
+
onPortChange?: (oldPort: number, newPort: number) => void
|
|
156
|
+
},
|
|
157
|
+
): Promise<void> {
|
|
158
|
+
const result = await startWithRetry({
|
|
159
|
+
engine,
|
|
160
|
+
config,
|
|
161
|
+
onPortChange: options?.onPortChange,
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
if (!result.success && result.error) {
|
|
165
|
+
throw result.error
|
|
166
|
+
}
|
|
167
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Transaction Manager
|
|
3
|
+
*
|
|
4
|
+
* Provides rollback support for multi-step operations like container creation.
|
|
5
|
+
* If any step fails, all previously completed steps are rolled back in reverse order.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { logError, logDebug, ErrorCodes } from './error-handler'
|
|
9
|
+
|
|
10
|
+
// =============================================================================
|
|
11
|
+
// Types
|
|
12
|
+
// =============================================================================
|
|
13
|
+
|
|
14
|
+
export type RollbackAction = {
|
|
15
|
+
description: string
|
|
16
|
+
execute: () => Promise<void>
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// =============================================================================
|
|
20
|
+
// Transaction Manager Implementation
|
|
21
|
+
// =============================================================================
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Manages a stack of rollback actions for transactional operations.
|
|
25
|
+
*
|
|
26
|
+
* Usage:
|
|
27
|
+
* ```typescript
|
|
28
|
+
* const tx = new TransactionManager()
|
|
29
|
+
*
|
|
30
|
+
* try {
|
|
31
|
+
* await createDirectory()
|
|
32
|
+
* tx.addRollback({
|
|
33
|
+
* description: 'Delete directory',
|
|
34
|
+
* execute: () => deleteDirectory()
|
|
35
|
+
* })
|
|
36
|
+
*
|
|
37
|
+
* await initDatabase()
|
|
38
|
+
* // Directory rollback covers this too
|
|
39
|
+
*
|
|
40
|
+
* await startServer()
|
|
41
|
+
* tx.addRollback({
|
|
42
|
+
* description: 'Stop server',
|
|
43
|
+
* execute: () => stopServer()
|
|
44
|
+
* })
|
|
45
|
+
*
|
|
46
|
+
* tx.commit() // Success - clear rollback stack
|
|
47
|
+
* } catch (err) {
|
|
48
|
+
* await tx.rollback() // Error - undo everything
|
|
49
|
+
* throw err
|
|
50
|
+
* }
|
|
51
|
+
* ```
|
|
52
|
+
*/
|
|
53
|
+
export class TransactionManager {
|
|
54
|
+
private rollbackStack: RollbackAction[] = []
|
|
55
|
+
private committed = false
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Add a rollback action to the stack.
|
|
59
|
+
* Actions are executed in reverse order during rollback.
|
|
60
|
+
*/
|
|
61
|
+
addRollback(action: RollbackAction): void {
|
|
62
|
+
if (this.committed) {
|
|
63
|
+
throw new Error('Cannot add rollback action after commit')
|
|
64
|
+
}
|
|
65
|
+
this.rollbackStack.push(action)
|
|
66
|
+
logDebug(`Added rollback action: ${action.description}`, {
|
|
67
|
+
totalActions: this.rollbackStack.length,
|
|
68
|
+
})
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Execute all rollbacks in reverse order.
|
|
73
|
+
* Continues even if individual rollback actions fail.
|
|
74
|
+
*/
|
|
75
|
+
async rollback(): Promise<void> {
|
|
76
|
+
if (this.committed) {
|
|
77
|
+
logDebug('Skipping rollback - transaction was committed')
|
|
78
|
+
return
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (this.rollbackStack.length === 0) {
|
|
82
|
+
logDebug('No rollback actions to execute')
|
|
83
|
+
return
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
logDebug(`Starting rollback of ${this.rollbackStack.length} actions`)
|
|
87
|
+
|
|
88
|
+
// Execute in reverse order (LIFO)
|
|
89
|
+
while (this.rollbackStack.length > 0) {
|
|
90
|
+
const action = this.rollbackStack.pop()!
|
|
91
|
+
|
|
92
|
+
try {
|
|
93
|
+
logDebug(`Executing rollback: ${action.description}`)
|
|
94
|
+
await action.execute()
|
|
95
|
+
logDebug(`Rollback successful: ${action.description}`)
|
|
96
|
+
} catch (err) {
|
|
97
|
+
// Log error but continue with other rollbacks
|
|
98
|
+
logError({
|
|
99
|
+
code: ErrorCodes.ROLLBACK_FAILED,
|
|
100
|
+
message: `Failed to rollback: ${action.description}`,
|
|
101
|
+
severity: 'warning',
|
|
102
|
+
context: {
|
|
103
|
+
error: err instanceof Error ? err.message : String(err),
|
|
104
|
+
},
|
|
105
|
+
})
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
logDebug('Rollback complete')
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Mark the transaction as committed.
|
|
114
|
+
* Clears the rollback stack since we don't need to undo anything.
|
|
115
|
+
*/
|
|
116
|
+
commit(): void {
|
|
117
|
+
if (this.committed) {
|
|
118
|
+
return // Already committed
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
logDebug(`Committing transaction with ${this.rollbackStack.length} actions`)
|
|
122
|
+
this.rollbackStack = []
|
|
123
|
+
this.committed = true
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Check if the transaction has been committed.
|
|
128
|
+
*/
|
|
129
|
+
isCommitted(): boolean {
|
|
130
|
+
return this.committed
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Get the number of pending rollback actions.
|
|
135
|
+
*/
|
|
136
|
+
getPendingCount(): number {
|
|
137
|
+
return this.rollbackStack.length
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Helper function to execute an operation with automatic rollback on failure.
|
|
143
|
+
*
|
|
144
|
+
* Usage:
|
|
145
|
+
* ```typescript
|
|
146
|
+
* await withTransaction(async (tx) => {
|
|
147
|
+
* await step1()
|
|
148
|
+
* tx.addRollback({ description: 'Undo step1', execute: undoStep1 })
|
|
149
|
+
*
|
|
150
|
+
* await step2()
|
|
151
|
+
* tx.addRollback({ description: 'Undo step2', execute: undoStep2 })
|
|
152
|
+
*
|
|
153
|
+
* // If we get here without throwing, transaction commits automatically
|
|
154
|
+
* })
|
|
155
|
+
* ```
|
|
156
|
+
*/
|
|
157
|
+
export async function withTransaction<T>(
|
|
158
|
+
operation: (tx: TransactionManager) => Promise<T>,
|
|
159
|
+
): Promise<T> {
|
|
160
|
+
const tx = new TransactionManager()
|
|
161
|
+
|
|
162
|
+
try {
|
|
163
|
+
const result = await operation(tx)
|
|
164
|
+
tx.commit()
|
|
165
|
+
return result
|
|
166
|
+
} catch (err) {
|
|
167
|
+
await tx.rollback()
|
|
168
|
+
throw err
|
|
169
|
+
}
|
|
170
|
+
}
|
package/engines/index.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { postgresqlEngine } from './postgresql'
|
|
2
|
+
import { mysqlEngine } from './mysql'
|
|
2
3
|
import type { BaseEngine } from './base-engine'
|
|
3
4
|
import type { EngineInfo } from '../types'
|
|
4
5
|
|
|
@@ -6,9 +7,13 @@ import type { EngineInfo } from '../types'
|
|
|
6
7
|
* Registry of available database engines
|
|
7
8
|
*/
|
|
8
9
|
export const engines: Record<string, BaseEngine> = {
|
|
10
|
+
// PostgreSQL and aliases
|
|
9
11
|
postgresql: postgresqlEngine,
|
|
10
|
-
postgres: postgresqlEngine,
|
|
11
|
-
pg: postgresqlEngine,
|
|
12
|
+
postgres: postgresqlEngine,
|
|
13
|
+
pg: postgresqlEngine,
|
|
14
|
+
// MySQL and aliases
|
|
15
|
+
mysql: mysqlEngine,
|
|
16
|
+
mariadb: mysqlEngine, // MariaDB is MySQL-compatible
|
|
12
17
|
}
|
|
13
18
|
|
|
14
19
|
/**
|