@autumnsgrove/groveengine 0.5.0 → 0.6.1

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,450 @@
1
+ /**
2
+ * Database Service - D1 SQLite Abstraction
3
+ *
4
+ * Provides typed utilities for D1 database operations:
5
+ * - Type-safe query helpers
6
+ * - Transaction/batch support
7
+ * - Specific error types for debugging
8
+ * - Common utility functions
9
+ *
10
+ * Note: Domain-specific operations (users, sessions, etc.) remain in
11
+ * their respective packages. This module provides shared primitives.
12
+ */
13
+ // ============================================================================
14
+ // Errors
15
+ // ============================================================================
16
+ export class DatabaseError extends Error {
17
+ code;
18
+ cause;
19
+ constructor(message, code, cause) {
20
+ super(message);
21
+ this.code = code;
22
+ this.cause = cause;
23
+ this.name = 'DatabaseError';
24
+ }
25
+ }
26
+ // ============================================================================
27
+ // Utility Functions
28
+ // ============================================================================
29
+ /**
30
+ * Generate a UUID v4 identifier
31
+ */
32
+ export function generateId() {
33
+ return crypto.randomUUID();
34
+ }
35
+ /**
36
+ * Get current timestamp in ISO format
37
+ */
38
+ export function now() {
39
+ return new Date().toISOString();
40
+ }
41
+ /**
42
+ * Get a timestamp for a future time
43
+ * @param ms - Milliseconds from now
44
+ */
45
+ export function futureTimestamp(ms) {
46
+ return new Date(Date.now() + ms).toISOString();
47
+ }
48
+ /**
49
+ * Check if a timestamp has expired
50
+ */
51
+ export function isExpired(timestamp) {
52
+ return new Date(timestamp) < new Date();
53
+ }
54
+ // ============================================================================
55
+ // Query Helpers
56
+ // ============================================================================
57
+ /**
58
+ * Execute a query expecting a single row result
59
+ *
60
+ * @example
61
+ * ```ts
62
+ * const user = await db.queryOne<User>(
63
+ * db,
64
+ * 'SELECT * FROM users WHERE id = ?',
65
+ * [userId]
66
+ * );
67
+ * if (!user) throw new Error('User not found');
68
+ * ```
69
+ */
70
+ export async function queryOne(db, sql, params = []) {
71
+ try {
72
+ const result = await db
73
+ .prepare(sql)
74
+ .bind(...params)
75
+ .first();
76
+ return result ?? null;
77
+ }
78
+ catch (err) {
79
+ throw new DatabaseError(`Query failed: ${sql}`, 'QUERY_FAILED', err);
80
+ }
81
+ }
82
+ /**
83
+ * Execute a query expecting a single row, throw if not found
84
+ *
85
+ * @example
86
+ * ```ts
87
+ * const user = await db.queryOneOrThrow<User>(
88
+ * db,
89
+ * 'SELECT * FROM users WHERE id = ?',
90
+ * [userId]
91
+ * );
92
+ * // user is guaranteed to exist here
93
+ * ```
94
+ */
95
+ export async function queryOneOrThrow(db, sql, params = [], errorMessage = 'Record not found') {
96
+ const result = await queryOne(db, sql, params);
97
+ if (result === null) {
98
+ throw new DatabaseError(errorMessage, 'NOT_FOUND');
99
+ }
100
+ return result;
101
+ }
102
+ /**
103
+ * Execute a query expecting multiple rows
104
+ *
105
+ * @example
106
+ * ```ts
107
+ * const users = await db.queryMany<User>(
108
+ * db,
109
+ * 'SELECT * FROM users WHERE is_admin = ?',
110
+ * [1]
111
+ * );
112
+ * ```
113
+ */
114
+ export async function queryMany(db, sql, params = []) {
115
+ try {
116
+ const result = await db
117
+ .prepare(sql)
118
+ .bind(...params)
119
+ .all();
120
+ return result.results ?? [];
121
+ }
122
+ catch (err) {
123
+ throw new DatabaseError(`Query failed: ${sql}`, 'QUERY_FAILED', err);
124
+ }
125
+ }
126
+ /**
127
+ * Execute a non-SELECT statement (INSERT, UPDATE, DELETE)
128
+ *
129
+ * @example
130
+ * ```ts
131
+ * const result = await db.execute(
132
+ * db,
133
+ * 'UPDATE users SET name = ? WHERE id = ?',
134
+ * [newName, userId]
135
+ * );
136
+ * console.log(`Updated ${result.meta.changes} rows`);
137
+ * ```
138
+ */
139
+ export async function execute(db, sql, params = []) {
140
+ try {
141
+ const result = await db
142
+ .prepare(sql)
143
+ .bind(...params)
144
+ .run();
145
+ return {
146
+ success: result.success,
147
+ meta: {
148
+ changes: result.meta.changes ?? 0,
149
+ duration: result.meta.duration,
150
+ lastRowId: result.meta.last_row_id ?? 0,
151
+ rowsRead: result.meta.rows_read ?? 0,
152
+ rowsWritten: result.meta.rows_written ?? 0
153
+ }
154
+ };
155
+ }
156
+ catch (err) {
157
+ throw new DatabaseError(`Execute failed: ${sql}`, 'QUERY_FAILED', err);
158
+ }
159
+ }
160
+ /**
161
+ * Execute a statement and throw if no rows were affected
162
+ *
163
+ * @example
164
+ * ```ts
165
+ * await db.executeOrThrow(
166
+ * db,
167
+ * 'DELETE FROM sessions WHERE id = ?',
168
+ * [sessionId],
169
+ * 'Session not found'
170
+ * );
171
+ * ```
172
+ */
173
+ export async function executeOrThrow(db, sql, params = [], errorMessage = 'No rows affected') {
174
+ const result = await execute(db, sql, params);
175
+ if (result.meta.changes === 0) {
176
+ throw new DatabaseError(errorMessage, 'NOT_FOUND');
177
+ }
178
+ return result;
179
+ }
180
+ // ============================================================================
181
+ // Batch / Transaction Helpers
182
+ // ============================================================================
183
+ /**
184
+ * Execute multiple statements atomically
185
+ * All statements succeed or all fail (D1 batch semantics)
186
+ *
187
+ * @example
188
+ * ```ts
189
+ * await db.batch(db, [
190
+ * { sql: 'INSERT INTO users (id, email) VALUES (?, ?)', params: [id, email] },
191
+ * { sql: 'INSERT INTO profiles (user_id) VALUES (?)', params: [id] }
192
+ * ]);
193
+ * ```
194
+ */
195
+ export async function batch(db, statements) {
196
+ try {
197
+ const prepared = statements.map((stmt, index) => {
198
+ try {
199
+ return db.prepare(stmt.sql).bind(...(stmt.params ?? []));
200
+ }
201
+ catch (prepareErr) {
202
+ throw new DatabaseError(`Batch statement ${index + 1}/${statements.length} failed to prepare: ${stmt.sql.slice(0, 100)}`, 'INVALID_QUERY', prepareErr);
203
+ }
204
+ });
205
+ const results = await db.batch(prepared);
206
+ return results.map((result) => ({
207
+ success: result.success,
208
+ meta: {
209
+ changes: result.meta.changes ?? 0,
210
+ duration: result.meta.duration,
211
+ lastRowId: result.meta.last_row_id ?? 0,
212
+ rowsRead: result.meta.rows_read ?? 0,
213
+ rowsWritten: result.meta.rows_written ?? 0
214
+ }
215
+ }));
216
+ }
217
+ catch (err) {
218
+ if (err instanceof DatabaseError) {
219
+ throw err;
220
+ }
221
+ // Include statement count for context
222
+ const stmtSummary = statements.map((s, i) => `${i + 1}: ${s.sql.slice(0, 50)}...`).join('; ');
223
+ throw new DatabaseError(`Batch operation failed (${statements.length} statements: ${stmtSummary})`, 'TRANSACTION_FAILED', err);
224
+ }
225
+ }
226
+ /**
227
+ * Execute a function within a database session for read consistency
228
+ *
229
+ * @example
230
+ * ```ts
231
+ * const result = await db.withSession(db, async (session) => {
232
+ * const user = await queryOne<User>(session, 'SELECT * FROM users WHERE id = ?', [id]);
233
+ * const posts = await queryMany<Post>(session, 'SELECT * FROM posts WHERE user_id = ?', [id]);
234
+ * return { user, posts };
235
+ * });
236
+ * ```
237
+ */
238
+ export async function withSession(db, fn) {
239
+ const session = db.withSession();
240
+ try {
241
+ return await fn(session);
242
+ }
243
+ catch (err) {
244
+ throw new DatabaseError('Session operation failed', 'TRANSACTION_FAILED', err);
245
+ }
246
+ }
247
+ // ============================================================================
248
+ // Table Name Validation
249
+ // ============================================================================
250
+ /**
251
+ * Valid identifier pattern - alphanumeric and underscores only
252
+ * This prevents SQL injection in functions that accept table/column names
253
+ */
254
+ const VALID_IDENTIFIER = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
255
+ function validateTableName(table) {
256
+ if (!VALID_IDENTIFIER.test(table)) {
257
+ throw new DatabaseError(`Invalid table name: ${table}. Table names must be alphanumeric with underscores only.`, 'INVALID_QUERY');
258
+ }
259
+ }
260
+ function validateColumnName(column) {
261
+ if (!VALID_IDENTIFIER.test(column)) {
262
+ throw new DatabaseError(`Invalid column name: ${column}. Column names must be alphanumeric with underscores only.`, 'INVALID_QUERY');
263
+ }
264
+ }
265
+ function validateColumnNames(columns) {
266
+ for (const column of columns) {
267
+ validateColumnName(column);
268
+ }
269
+ }
270
+ // ============================================================================
271
+ // Insert Helpers
272
+ // ============================================================================
273
+ /**
274
+ * Insert a row and return the generated ID
275
+ *
276
+ * SECURITY: Table and column names are validated to prevent SQL injection.
277
+ * Only use with hardcoded names, never user input.
278
+ *
279
+ * @example
280
+ * ```ts
281
+ * const id = await db.insert(db, 'users', {
282
+ * email: 'user@example.com',
283
+ * name: 'John'
284
+ * });
285
+ * ```
286
+ */
287
+ export async function insert(db, table, data, options) {
288
+ validateTableName(table);
289
+ validateColumnNames(Object.keys(data));
290
+ const id = options?.id ?? generateId();
291
+ const timestamp = now();
292
+ const dataWithMeta = {
293
+ id,
294
+ ...data,
295
+ created_at: timestamp,
296
+ updated_at: timestamp
297
+ };
298
+ const columns = Object.keys(dataWithMeta);
299
+ const placeholders = columns.map(() => '?').join(', ');
300
+ const values = Object.values(dataWithMeta);
301
+ const sql = `INSERT INTO ${table} (${columns.join(', ')}) VALUES (${placeholders})`;
302
+ try {
303
+ await db
304
+ .prepare(sql)
305
+ .bind(...values)
306
+ .run();
307
+ return id;
308
+ }
309
+ catch (err) {
310
+ if (err instanceof Error && err.message.includes('UNIQUE constraint')) {
311
+ throw new DatabaseError(`Duplicate entry in ${table} (columns: ${columns.join(', ')})`, 'CONSTRAINT_VIOLATION', err);
312
+ }
313
+ throw new DatabaseError(`Insert into ${table} failed (columns: ${columns.join(', ')})`, 'QUERY_FAILED', err);
314
+ }
315
+ }
316
+ // ============================================================================
317
+ // Update Helpers
318
+ // ============================================================================
319
+ /**
320
+ * Update rows matching a condition
321
+ *
322
+ * SECURITY: Table and column names are validated to prevent SQL injection.
323
+ * Only use with hardcoded names, never user input.
324
+ *
325
+ * @example
326
+ * ```ts
327
+ * const changes = await db.update(db, 'users', { name: 'Jane' }, 'id = ?', [userId]);
328
+ * ```
329
+ */
330
+ export async function update(db, table, data, where, whereParams = []) {
331
+ validateTableName(table);
332
+ validateColumnNames(Object.keys(data));
333
+ const dataWithTimestamp = {
334
+ ...data,
335
+ updated_at: now()
336
+ };
337
+ const setClauses = Object.keys(dataWithTimestamp)
338
+ .map((key) => `${key} = ?`)
339
+ .join(', ');
340
+ const values = [...Object.values(dataWithTimestamp), ...whereParams];
341
+ const sql = `UPDATE ${table} SET ${setClauses} WHERE ${where}`;
342
+ try {
343
+ const result = await db
344
+ .prepare(sql)
345
+ .bind(...values)
346
+ .run();
347
+ return result.meta.changes ?? 0;
348
+ }
349
+ catch (err) {
350
+ const fields = Object.keys(dataWithTimestamp).join(', ');
351
+ throw new DatabaseError(`Update ${table} failed (fields: ${fields}, where: ${where})`, 'QUERY_FAILED', err);
352
+ }
353
+ }
354
+ // ============================================================================
355
+ // Delete Helpers
356
+ // ============================================================================
357
+ /**
358
+ * Delete rows matching a condition
359
+ *
360
+ * SECURITY: Table name is validated to prevent SQL injection.
361
+ * Only use with hardcoded table names, never user input.
362
+ *
363
+ * @example
364
+ * ```ts
365
+ * const deleted = await db.deleteWhere(db, 'sessions', 'expires_at < datetime("now")');
366
+ * ```
367
+ */
368
+ export async function deleteWhere(db, table, where, whereParams = []) {
369
+ validateTableName(table);
370
+ const sql = `DELETE FROM ${table} WHERE ${where}`;
371
+ try {
372
+ const result = await db
373
+ .prepare(sql)
374
+ .bind(...whereParams)
375
+ .run();
376
+ return result.meta.changes ?? 0;
377
+ }
378
+ catch (err) {
379
+ throw new DatabaseError(`Delete from ${table} failed (where: ${where})`, 'QUERY_FAILED', err);
380
+ }
381
+ }
382
+ /**
383
+ * Delete a single row by ID
384
+ *
385
+ * @example
386
+ * ```ts
387
+ * await db.deleteById(db, 'users', userId);
388
+ * ```
389
+ */
390
+ export async function deleteById(db, table, id) {
391
+ const changes = await deleteWhere(db, table, 'id = ?', [id]);
392
+ return changes > 0;
393
+ }
394
+ // ============================================================================
395
+ // Existence Checks
396
+ // ============================================================================
397
+ /**
398
+ * Check if a row exists
399
+ *
400
+ * SECURITY: Table name is validated to prevent SQL injection.
401
+ * Only use with hardcoded table names, never user input.
402
+ *
403
+ * @example
404
+ * ```ts
405
+ * if (await db.exists(db, 'users', 'email = ?', [email])) {
406
+ * throw new Error('Email already registered');
407
+ * }
408
+ * ```
409
+ */
410
+ export async function exists(db, table, where, whereParams = []) {
411
+ validateTableName(table);
412
+ const sql = `SELECT 1 FROM ${table} WHERE ${where} LIMIT 1`;
413
+ try {
414
+ const result = await db
415
+ .prepare(sql)
416
+ .bind(...whereParams)
417
+ .first();
418
+ return result !== null;
419
+ }
420
+ catch (err) {
421
+ throw new DatabaseError(`Existence check on ${table} failed`, 'QUERY_FAILED', err);
422
+ }
423
+ }
424
+ /**
425
+ * Count rows matching a condition
426
+ *
427
+ * SECURITY: Table name is validated to prevent SQL injection.
428
+ * Only use with hardcoded table names, never user input.
429
+ *
430
+ * @example
431
+ * ```ts
432
+ * const activeUsers = await db.count(db, 'users', 'is_active = ?', [1]);
433
+ * ```
434
+ */
435
+ export async function count(db, table, where, whereParams = []) {
436
+ validateTableName(table);
437
+ const sql = where
438
+ ? `SELECT COUNT(*) as count FROM ${table} WHERE ${where}`
439
+ : `SELECT COUNT(*) as count FROM ${table}`;
440
+ try {
441
+ const result = await db
442
+ .prepare(sql)
443
+ .bind(...whereParams)
444
+ .first();
445
+ return result?.count ?? 0;
446
+ }
447
+ catch (err) {
448
+ throw new DatabaseError(`Count on ${table} failed`, 'QUERY_FAILED', err);
449
+ }
450
+ }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Grove Engine - Service Modules
3
+ *
4
+ * Cloudflare infrastructure abstraction layer providing clean interfaces
5
+ * for storage (R2), database (D1), and caching (KV) operations.
6
+ *
7
+ * @example
8
+ * ```ts
9
+ * import { storage, db, cache } from '@autumnsgrove/groveengine/services';
10
+ *
11
+ * // Storage operations
12
+ * const file = await storage.uploadFile(bucket, database, {
13
+ * data: arrayBuffer,
14
+ * filename: 'photo.jpg',
15
+ * contentType: 'image/jpeg',
16
+ * uploadedBy: userId
17
+ * });
18
+ *
19
+ * // Database operations
20
+ * const user = await db.queryOne<User>(database, 'SELECT * FROM users WHERE id = ?', [id]);
21
+ *
22
+ * // Cache operations
23
+ * const data = await cache.getOrSet(kv, 'user:123', {
24
+ * ttl: 3600,
25
+ * compute: () => fetchUser(id)
26
+ * });
27
+ * ```
28
+ */
29
+ export * as storage from './storage.js';
30
+ export { type StorageFile, type UploadOptions, type GetFileResult, type FileMetadata, StorageError, type StorageErrorCode, uploadFile, getFile, getFileMetadata, fileExists, deleteFile, deleteFileByKey, getFileRecord, getFileRecordByKey, listFiles, listAllFiles, listFolders, updateAltText, validateFile, isAllowedContentType, shouldReturn304, buildFileHeaders } from './storage.js';
31
+ export * as db from './database.js';
32
+ export { type D1DatabaseOrSession, type QueryMeta, type ExecuteResult, DatabaseError, type DatabaseErrorCode, generateId, now, futureTimestamp, isExpired, queryOne, queryOneOrThrow, queryMany, execute, executeOrThrow, batch, withSession, insert, update, deleteWhere, deleteById, exists, count } from './database.js';
33
+ export * as cache from './cache.js';
34
+ export { type CacheOptions, type GetOrSetOptions, CacheError, type CacheErrorCode, get as cacheGet, set as cacheSet, del as cacheDel, getOrSet, getOrSetSync, delMany, delByPrefix, has as cacheHas, touch, rateLimit, CACHE_DEFAULTS } from './cache.js';
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Grove Engine - Service Modules
3
+ *
4
+ * Cloudflare infrastructure abstraction layer providing clean interfaces
5
+ * for storage (R2), database (D1), and caching (KV) operations.
6
+ *
7
+ * @example
8
+ * ```ts
9
+ * import { storage, db, cache } from '@autumnsgrove/groveengine/services';
10
+ *
11
+ * // Storage operations
12
+ * const file = await storage.uploadFile(bucket, database, {
13
+ * data: arrayBuffer,
14
+ * filename: 'photo.jpg',
15
+ * contentType: 'image/jpeg',
16
+ * uploadedBy: userId
17
+ * });
18
+ *
19
+ * // Database operations
20
+ * const user = await db.queryOne<User>(database, 'SELECT * FROM users WHERE id = ?', [id]);
21
+ *
22
+ * // Cache operations
23
+ * const data = await cache.getOrSet(kv, 'user:123', {
24
+ * ttl: 3600,
25
+ * compute: () => fetchUser(id)
26
+ * });
27
+ * ```
28
+ */
29
+ // ============================================================================
30
+ // Storage Service (R2)
31
+ // ============================================================================
32
+ export * as storage from './storage.js';
33
+ export {
34
+ // Errors
35
+ StorageError,
36
+ // Operations
37
+ uploadFile, getFile, getFileMetadata, fileExists, deleteFile, deleteFileByKey,
38
+ // Metadata Operations
39
+ getFileRecord, getFileRecordByKey, listFiles, listAllFiles, listFolders, updateAltText,
40
+ // Validation
41
+ validateFile, isAllowedContentType,
42
+ // Response Helpers
43
+ shouldReturn304, buildFileHeaders } from './storage.js';
44
+ // ============================================================================
45
+ // Database Service (D1)
46
+ // ============================================================================
47
+ export * as db from './database.js';
48
+ export {
49
+ // Errors
50
+ DatabaseError,
51
+ // Utilities
52
+ generateId, now, futureTimestamp, isExpired,
53
+ // Query Helpers
54
+ queryOne, queryOneOrThrow, queryMany, execute, executeOrThrow,
55
+ // Batch Operations
56
+ batch, withSession,
57
+ // CRUD Helpers
58
+ insert, update, deleteWhere, deleteById,
59
+ // Existence Checks
60
+ exists, count } from './database.js';
61
+ // ============================================================================
62
+ // Cache Service (KV)
63
+ // ============================================================================
64
+ export * as cache from './cache.js';
65
+ export {
66
+ // Errors
67
+ CacheError,
68
+ // Operations
69
+ get as cacheGet, set as cacheSet, del as cacheDel, getOrSet, getOrSetSync,
70
+ // Batch Operations
71
+ delMany, delByPrefix,
72
+ // Utilities
73
+ has as cacheHas, touch,
74
+ // Rate Limiting
75
+ rateLimit,
76
+ // Constants
77
+ CACHE_DEFAULTS } from './cache.js';