@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.
Files changed (2) hide show
  1. package/package.json +22 -0
  2. 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
+ }