@yamf/services-sqlite 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +22 -0
- package/service.js +156 -0
package/package.json
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@yamf/services-sqlite",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "SQLite database service for YAMF",
|
|
5
|
+
"license": "ISC",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "service.js",
|
|
8
|
+
"files": [
|
|
9
|
+
"service.js"
|
|
10
|
+
],
|
|
11
|
+
"peerDependencies": {
|
|
12
|
+
"@yamf/core": "0.4.0",
|
|
13
|
+
"@yamf/shared": "0.1.2"
|
|
14
|
+
},
|
|
15
|
+
"devDependencies": {
|
|
16
|
+
"@yamf/cli": "0.2.0",
|
|
17
|
+
"@yamf/test": "0.1.4"
|
|
18
|
+
},
|
|
19
|
+
"scripts": {
|
|
20
|
+
"test": "yamf test -d ."
|
|
21
|
+
}
|
|
22
|
+
}
|
package/service.js
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createService,
|
|
3
|
+
HttpError
|
|
4
|
+
} from '@yamf/core'
|
|
5
|
+
|
|
6
|
+
import { mkdir } from 'node:fs/promises'
|
|
7
|
+
import { dirname } from 'node:path'
|
|
8
|
+
import { backup as sqliteBackup, DatabaseSync } from 'node:sqlite'
|
|
9
|
+
import { toCamelCase } from '@yamf/shared'
|
|
10
|
+
|
|
11
|
+
const defaultSqliteConfig = ':memory:'
|
|
12
|
+
|
|
13
|
+
// Strict pattern for placeholder names - only valid identifiers allowed
|
|
14
|
+
const VALID_PLACEHOLDER_PATTERN = /^[a-zA-Z_][a-zA-Z0-9_]*$/
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Validates that a placeholder name is safe (alphanumeric + underscore, starts with letter/underscore)
|
|
18
|
+
* This prevents code injection through malicious placeholder names.
|
|
19
|
+
*/
|
|
20
|
+
function validatePlaceholderName(name) {
|
|
21
|
+
if (!VALID_PLACEHOLDER_PATTERN.test(name)) {
|
|
22
|
+
throw new HttpError(400, `Invalid placeholder name: "${name}". Must be a valid identifier.`)
|
|
23
|
+
}
|
|
24
|
+
return true
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Ensures the parent directory exists for a file-backed database path.
|
|
29
|
+
* No-op for :memory: or file: URLs.
|
|
30
|
+
*
|
|
31
|
+
* @param {string} path - Database path (e.g. './data/app.sqlite')
|
|
32
|
+
* @returns {Promise<string>} The path, for chaining
|
|
33
|
+
*/
|
|
34
|
+
export async function ensureDbPath(path) {
|
|
35
|
+
if (path === ':memory:' || path.startsWith('file:')) {
|
|
36
|
+
return path
|
|
37
|
+
}
|
|
38
|
+
const dir = dirname(path)
|
|
39
|
+
if (dir && dir !== '.') {
|
|
40
|
+
await mkdir(dir, { recursive: true })
|
|
41
|
+
}
|
|
42
|
+
return path
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function runSchema(db, schema) {
|
|
46
|
+
if (!schema) return
|
|
47
|
+
const statements = Array.isArray(schema) ? schema : [schema]
|
|
48
|
+
for (const sql of statements) {
|
|
49
|
+
if (typeof sql === 'string' && sql.trim()) {
|
|
50
|
+
db.exec(sql)
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function runSeed(db, seed) {
|
|
56
|
+
if (!seed) return
|
|
57
|
+
if (typeof seed === 'function') {
|
|
58
|
+
seed(db)
|
|
59
|
+
} else {
|
|
60
|
+
const statements = Array.isArray(seed) ? seed : [seed]
|
|
61
|
+
for (const sql of statements) {
|
|
62
|
+
if (typeof sql === 'string' && sql.trim()) {
|
|
63
|
+
db.exec(sql)
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export default async function createSqliteService({
|
|
70
|
+
serviceName = 'sqlite-service',
|
|
71
|
+
sqliteConfig = defaultSqliteConfig,
|
|
72
|
+
schema = null,
|
|
73
|
+
seed = null
|
|
74
|
+
}) {
|
|
75
|
+
const db = new DatabaseSync(sqliteConfig)
|
|
76
|
+
runSchema(db, schema)
|
|
77
|
+
runSeed(db, seed)
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Processes a template query string with provided data using safe parameterized queries.
|
|
81
|
+
*
|
|
82
|
+
* Uses the same :placeholder syntax and snake_case→camelCase mapping as the postgres service.
|
|
83
|
+
* Converts :param to SQLite's ? positional placeholders and uses prepared statements.
|
|
84
|
+
*
|
|
85
|
+
* @param {string} template The string containing `:<name>` placeholders (camelCase).
|
|
86
|
+
* @param {object} data An object with camelCase keys corresponding to the placeholders.
|
|
87
|
+
* @param {object} options Query options.
|
|
88
|
+
* @param {boolean} options.mapCase Whether to convert output from snake_case to camelCase (default: true)
|
|
89
|
+
* @returns {Promise<Array>} The query result with camelCase keys.
|
|
90
|
+
*/
|
|
91
|
+
async function processQueryTemplate(template, data, options = {}) {
|
|
92
|
+
const { mapCase = true } = options
|
|
93
|
+
|
|
94
|
+
// Extract all placeholder names from template (placeholders are camelCase)
|
|
95
|
+
const placeholderMatches = [...template.matchAll(/(?<!:):([a-zA-Z_][a-zA-Z0-9_]*)/g)]
|
|
96
|
+
const placeholders = placeholderMatches.map(m => m[1])
|
|
97
|
+
|
|
98
|
+
// Validate all placeholder names are safe identifiers
|
|
99
|
+
for (const name of placeholders) {
|
|
100
|
+
validatePlaceholderName(name)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Check all placeholders have corresponding data
|
|
104
|
+
for (const name of placeholders) {
|
|
105
|
+
if (!(name in data)) {
|
|
106
|
+
throw new HttpError(400, `Missing data for placeholder: "${name}"`)
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Build the values array in placeholder order
|
|
111
|
+
const values = placeholders.map(name => data[name])
|
|
112
|
+
|
|
113
|
+
// Replace :name placeholders with ? (SQLite uses positional ? not $1, $2)
|
|
114
|
+
let parameterizedQuery = template.replace(/(?<!:):([a-zA-Z_][a-zA-Z0-9_]*)/g, () => '?')
|
|
115
|
+
// Convert Postgres-style ::type casts to SQLite CAST(? AS type) for template portability
|
|
116
|
+
parameterizedQuery = parameterizedQuery.replace(/\?\s*::\s*([a-zA-Z]\w*)/g, (_, type) => `CAST(? AS ${type})`)
|
|
117
|
+
|
|
118
|
+
try {
|
|
119
|
+
const stmt = db.prepare(parameterizedQuery)
|
|
120
|
+
const isSelect = /^\s*(SELECT|WITH)\s/i.test(template.trim()) || /RETURNING\s/i.test(template)
|
|
121
|
+
const result = isSelect ? stmt.all(...values) : stmt.run(...values)
|
|
122
|
+
|
|
123
|
+
// For SELECT/WITH/RETURNING: result is array of row objects
|
|
124
|
+
// For INSERT/UPDATE/DELETE: result is { changes, lastInsertRowid }; return [] for consistency with postgres
|
|
125
|
+
const rows = Array.isArray(result) ? result : []
|
|
126
|
+
return mapCase ? toCamelCase(rows) : rows
|
|
127
|
+
} catch (err) {
|
|
128
|
+
const httpError = new HttpError(500, `Query error: ${err.message}`)
|
|
129
|
+
httpError.stack = err.stack
|
|
130
|
+
httpError.parameterizedQuery = parameterizedQuery
|
|
131
|
+
throw httpError
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const service = await createService(serviceName, async ({ template, data, options }) => {
|
|
136
|
+
if (!template) throw new HttpError(400, 'Expected "template" query string in payload')
|
|
137
|
+
if (!data) throw new HttpError(400, 'Expected "data" map in payload')
|
|
138
|
+
return await processQueryTemplate(template, data, options)
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Export/backup the database to a file.
|
|
143
|
+
*
|
|
144
|
+
* @param {string} targetPath - Path for the backup file
|
|
145
|
+
* @param {object} options - Optional { rate, progress } for sqlite backup API
|
|
146
|
+
* @returns {Promise<number>} Total pages transferred
|
|
147
|
+
*/
|
|
148
|
+
async function backup(targetPath, options = {}) {
|
|
149
|
+
return sqliteBackup(db, targetPath, options)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
service.database = db
|
|
153
|
+
service.backup = backup
|
|
154
|
+
|
|
155
|
+
return service
|
|
156
|
+
}
|