digital-objects 1.0.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 (87) hide show
  1. package/.turbo/turbo-build.log +4 -0
  2. package/CHANGELOG.md +25 -0
  3. package/LICENSE +21 -0
  4. package/README.md +476 -0
  5. package/dist/ai-database-adapter.d.ts +49 -0
  6. package/dist/ai-database-adapter.d.ts.map +1 -0
  7. package/dist/ai-database-adapter.js +89 -0
  8. package/dist/ai-database-adapter.js.map +1 -0
  9. package/dist/errors.d.ts +47 -0
  10. package/dist/errors.d.ts.map +1 -0
  11. package/dist/errors.js +72 -0
  12. package/dist/errors.js.map +1 -0
  13. package/dist/http-schemas.d.ts +165 -0
  14. package/dist/http-schemas.d.ts.map +1 -0
  15. package/dist/http-schemas.js +55 -0
  16. package/dist/http-schemas.js.map +1 -0
  17. package/dist/index.d.ts +29 -0
  18. package/dist/index.d.ts.map +1 -0
  19. package/dist/index.js +32 -0
  20. package/dist/index.js.map +1 -0
  21. package/dist/linguistic.d.ts +54 -0
  22. package/dist/linguistic.d.ts.map +1 -0
  23. package/dist/linguistic.js +226 -0
  24. package/dist/linguistic.js.map +1 -0
  25. package/dist/memory-provider.d.ts +46 -0
  26. package/dist/memory-provider.d.ts.map +1 -0
  27. package/dist/memory-provider.js +279 -0
  28. package/dist/memory-provider.js.map +1 -0
  29. package/dist/ns-client.d.ts +88 -0
  30. package/dist/ns-client.d.ts.map +1 -0
  31. package/dist/ns-client.js +253 -0
  32. package/dist/ns-client.js.map +1 -0
  33. package/dist/ns-exports.d.ts +23 -0
  34. package/dist/ns-exports.d.ts.map +1 -0
  35. package/dist/ns-exports.js +21 -0
  36. package/dist/ns-exports.js.map +1 -0
  37. package/dist/ns.d.ts +60 -0
  38. package/dist/ns.d.ts.map +1 -0
  39. package/dist/ns.js +818 -0
  40. package/dist/ns.js.map +1 -0
  41. package/dist/r2-persistence.d.ts +112 -0
  42. package/dist/r2-persistence.d.ts.map +1 -0
  43. package/dist/r2-persistence.js +252 -0
  44. package/dist/r2-persistence.js.map +1 -0
  45. package/dist/schema-validation.d.ts +80 -0
  46. package/dist/schema-validation.d.ts.map +1 -0
  47. package/dist/schema-validation.js +233 -0
  48. package/dist/schema-validation.js.map +1 -0
  49. package/dist/types.d.ts +184 -0
  50. package/dist/types.d.ts.map +1 -0
  51. package/dist/types.js +26 -0
  52. package/dist/types.js.map +1 -0
  53. package/package.json +55 -0
  54. package/src/ai-database-adapter.test.ts +610 -0
  55. package/src/ai-database-adapter.ts +189 -0
  56. package/src/benchmark.test.ts +109 -0
  57. package/src/errors.ts +91 -0
  58. package/src/http-schemas.ts +67 -0
  59. package/src/index.ts +87 -0
  60. package/src/linguistic.test.ts +1107 -0
  61. package/src/linguistic.ts +253 -0
  62. package/src/memory-provider.ts +470 -0
  63. package/src/ns-client.test.ts +1360 -0
  64. package/src/ns-client.ts +342 -0
  65. package/src/ns-exports.ts +23 -0
  66. package/src/ns.test.ts +1381 -0
  67. package/src/ns.ts +1215 -0
  68. package/src/provider.test.ts +675 -0
  69. package/src/r2-persistence.test.ts +263 -0
  70. package/src/r2-persistence.ts +367 -0
  71. package/src/schema-validation.test.ts +167 -0
  72. package/src/schema-validation.ts +330 -0
  73. package/src/types.ts +252 -0
  74. package/test/action-status.test.ts +42 -0
  75. package/test/batch-limits.test.ts +165 -0
  76. package/test/docs.test.ts +48 -0
  77. package/test/errors.test.ts +148 -0
  78. package/test/http-validation.test.ts +401 -0
  79. package/test/ns-client-errors.test.ts +208 -0
  80. package/test/ns-namespace.test.ts +307 -0
  81. package/test/performance.test.ts +168 -0
  82. package/test/schema-validation-error.test.ts +213 -0
  83. package/test/schema-validation.test.ts +440 -0
  84. package/test/search-escaping.test.ts +359 -0
  85. package/test/security.test.ts +322 -0
  86. package/tsconfig.json +10 -0
  87. package/wrangler.jsonc +16 -0
package/src/ns.ts ADDED
@@ -0,0 +1,1215 @@
1
+ /**
2
+ * NS - Namespace Durable Object
3
+ *
4
+ * SQLite-based implementation of DigitalObjectsProvider for Cloudflare Workers.
5
+ * Each NS instance represents a namespace (tenant) with isolated data.
6
+ */
7
+
8
+ /// <reference types="@cloudflare/workers-types" />
9
+
10
+ import type {
11
+ DigitalObjectsProvider,
12
+ Noun,
13
+ NounDefinition,
14
+ Verb,
15
+ VerbDefinition,
16
+ Thing,
17
+ Action,
18
+ ActionStatusType,
19
+ ListOptions,
20
+ ActionOptions,
21
+ ValidationOptions,
22
+ Direction,
23
+ } from './types.js'
24
+ import {
25
+ DEFAULT_LIMIT,
26
+ MAX_LIMIT,
27
+ MAX_BATCH_SIZE,
28
+ validateDirection,
29
+ ActionStatus,
30
+ } from './types.js'
31
+ import { deriveNoun, deriveVerb } from './linguistic.js'
32
+ import { validateData } from './schema-validation.js'
33
+ import { NotFoundError, ValidationError, errorToResponse } from './errors.js'
34
+ import { ZodError } from 'zod'
35
+ import {
36
+ NounDefinitionSchema,
37
+ VerbDefinitionSchema,
38
+ CreateThingSchema,
39
+ UpdateThingSchema,
40
+ PerformActionSchema,
41
+ BatchCreateThingsSchema,
42
+ BatchUpdateThingsSchema,
43
+ BatchDeleteThingsSchema,
44
+ BatchPerformActionsSchema,
45
+ } from './http-schemas.js'
46
+
47
+ /**
48
+ * Convert a ZodError to a ValidationError
49
+ */
50
+ function zodErrorToValidationError(error: ZodError): ValidationError {
51
+ const fieldErrors = error.errors.map((issue) => ({
52
+ field: issue.path.join('.') || 'root',
53
+ message: issue.message,
54
+ }))
55
+ return new ValidationError('Request validation failed', fieldErrors)
56
+ }
57
+
58
+ /**
59
+ * Calculate effective limit with safety bounds
60
+ */
61
+ function effectiveLimit(requestedLimit?: number): number {
62
+ return Math.min(requestedLimit ?? DEFAULT_LIMIT, MAX_LIMIT)
63
+ }
64
+
65
+ // Whitelist of allowed orderBy fields for SQL injection prevention
66
+ const ALLOWED_ORDER_FIELDS = [
67
+ 'createdAt',
68
+ 'updatedAt',
69
+ 'id',
70
+ 'noun',
71
+ 'verb',
72
+ 'status',
73
+ 'name',
74
+ 'title',
75
+ ]
76
+
77
+ /**
78
+ * Validates an orderBy field name to prevent SQL injection.
79
+ * Allows whitelisted fields or simple alphanumeric field names.
80
+ */
81
+ function validateOrderByField(field: string): boolean {
82
+ // Allow whitelisted fields
83
+ if (ALLOWED_ORDER_FIELDS.includes(field)) return true
84
+ // Only allow simple alphanumeric field names (letters, numbers, underscores)
85
+ // Must start with a letter or underscore
86
+ return /^[a-zA-Z_][a-zA-Z0-9_]*$/.test(field)
87
+ }
88
+
89
+ /**
90
+ * Validates a namespace ID to ensure it contains only safe characters.
91
+ * Used to validate the 'ns' query parameter before using it to identify a Durable Object.
92
+ *
93
+ * Allowed: alphanumeric characters, hyphens, underscores
94
+ * Maximum length: 64 characters
95
+ *
96
+ * @param ns - The namespace ID to validate
97
+ * @returns true if valid, false otherwise
98
+ */
99
+ export function validateNamespaceId(ns: string): boolean {
100
+ // Check length limit
101
+ if (ns.length > 64) return false
102
+ // Empty string is invalid
103
+ if (ns.length === 0) return false
104
+ // Allow alphanumeric, hyphens, underscores
105
+ return /^[a-zA-Z0-9_-]+$/.test(ns)
106
+ }
107
+
108
+ // Dangerous field names that could enable prototype pollution or other attacks
109
+ const DANGEROUS_FIELDS = ['__proto__', 'constructor', 'prototype']
110
+
111
+ /**
112
+ * Validates a where clause field name to prevent JSON path traversal and prototype pollution.
113
+ * Throws ValidationError if the field name is invalid.
114
+ *
115
+ * Rejects:
116
+ * - Dots (.) in field names (JSON path traversal)
117
+ * - __proto__, constructor, prototype (prototype pollution)
118
+ * - Special JSON path characters ([, ], $, @)
119
+ */
120
+ function validateWhereField(field: string): void {
121
+ // Check for dangerous prototype-related field names
122
+ if (DANGEROUS_FIELDS.includes(field)) {
123
+ throw new ValidationError(`Invalid where field: '${field}' is not allowed`, [
124
+ { field, message: `Field name '${field}' is not allowed for security reasons` },
125
+ ])
126
+ }
127
+
128
+ // Check for dots (JSON path traversal)
129
+ if (field.includes('.')) {
130
+ throw new ValidationError(`Invalid where field: '${field}' contains dots`, [
131
+ { field, message: 'Field names cannot contain dots (JSON path traversal prevention)' },
132
+ ])
133
+ }
134
+
135
+ // Check for special JSON path characters
136
+ if (/[\[\]$@]/.test(field)) {
137
+ throw new ValidationError(`Invalid where field: '${field}' contains special characters`, [
138
+ { field, message: 'Field names cannot contain special JSON path characters ([, ], $, @)' },
139
+ ])
140
+ }
141
+
142
+ // Must match valid identifier pattern (starts with letter or underscore, followed by alphanumeric or underscore)
143
+ if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(field)) {
144
+ throw new ValidationError(`Invalid where field: '${field}'`, [
145
+ {
146
+ field,
147
+ message: 'Field name must be a valid identifier (letters, numbers, underscores only)',
148
+ },
149
+ ])
150
+ }
151
+ }
152
+
153
+ /**
154
+ * Escapes LIKE pattern special characters (%, _, \) in a search query.
155
+ * This prevents SQL LIKE wildcards from being interpreted as pattern characters.
156
+ *
157
+ * - % matches zero or more characters in LIKE
158
+ * - _ matches exactly one character in LIKE
159
+ * - \ is the escape character itself
160
+ *
161
+ * Example: "100%" becomes "100\%" to match literal "100%"
162
+ */
163
+ function escapeLikePattern(query: string): string {
164
+ return query.replace(/[%_\\]/g, '\\$&')
165
+ }
166
+
167
+ // Environment bindings
168
+ export interface Env {
169
+ NS: DurableObjectNamespace
170
+ STORAGE?: R2Bucket
171
+ }
172
+
173
+ /**
174
+ * NS - Namespace Durable Object
175
+ */
176
+ export class NS implements DigitalObjectsProvider {
177
+ private sql: SqlStorage
178
+ private initialized = false
179
+
180
+ // Caches for noun and verb definitions to reduce database lookups
181
+ private nounCache = new Map<string, Noun>()
182
+ private verbCache = new Map<string, Verb>()
183
+
184
+ constructor(ctx: DurableObjectState, _env: Env) {
185
+ this.sql = ctx.storage.sql
186
+ }
187
+
188
+ private async ensureInitialized(): Promise<void> {
189
+ if (this.initialized) return
190
+
191
+ // Create tables
192
+ this.sql.exec(`
193
+ CREATE TABLE IF NOT EXISTS nouns (
194
+ name TEXT PRIMARY KEY,
195
+ singular TEXT NOT NULL,
196
+ plural TEXT NOT NULL,
197
+ slug TEXT NOT NULL,
198
+ description TEXT,
199
+ schema TEXT,
200
+ created_at INTEGER NOT NULL
201
+ );
202
+
203
+ CREATE TABLE IF NOT EXISTS verbs (
204
+ name TEXT PRIMARY KEY,
205
+ action TEXT NOT NULL,
206
+ act TEXT NOT NULL,
207
+ activity TEXT NOT NULL,
208
+ event TEXT NOT NULL,
209
+ reverse_by TEXT,
210
+ reverse_at TEXT,
211
+ reverse_in TEXT,
212
+ inverse TEXT,
213
+ description TEXT,
214
+ created_at INTEGER NOT NULL
215
+ );
216
+
217
+ CREATE TABLE IF NOT EXISTS things (
218
+ id TEXT PRIMARY KEY,
219
+ noun TEXT NOT NULL,
220
+ data TEXT NOT NULL,
221
+ created_at INTEGER NOT NULL,
222
+ updated_at INTEGER NOT NULL
223
+ );
224
+ CREATE INDEX IF NOT EXISTS idx_things_noun ON things(noun);
225
+
226
+ CREATE TABLE IF NOT EXISTS actions (
227
+ id TEXT PRIMARY KEY,
228
+ verb TEXT NOT NULL,
229
+ subject TEXT,
230
+ object TEXT,
231
+ data TEXT,
232
+ status TEXT NOT NULL DEFAULT 'completed',
233
+ created_at INTEGER NOT NULL,
234
+ completed_at INTEGER
235
+ );
236
+ CREATE INDEX IF NOT EXISTS idx_actions_verb ON actions(verb);
237
+ CREATE INDEX IF NOT EXISTS idx_actions_subject ON actions(subject);
238
+ CREATE INDEX IF NOT EXISTS idx_actions_object ON actions(object);
239
+ CREATE INDEX IF NOT EXISTS idx_actions_status ON actions(status);
240
+ `)
241
+
242
+ this.initialized = true
243
+ }
244
+
245
+ // HTTP API handler
246
+ async fetch(request: Request): Promise<Response> {
247
+ await this.ensureInitialized()
248
+
249
+ const url = new URL(request.url)
250
+ const path = url.pathname
251
+ const method = request.method
252
+
253
+ try {
254
+ // Route to appropriate handler
255
+ if (path === '/nouns' && method === 'POST') {
256
+ const rawBody = await request.json()
257
+ const body = NounDefinitionSchema.parse(rawBody)
258
+ const noun = await this.defineNoun(body)
259
+ return Response.json(noun)
260
+ }
261
+
262
+ if (path.startsWith('/nouns/') && method === 'GET') {
263
+ const name = decodeURIComponent(path.slice('/nouns/'.length))
264
+ const noun = await this.getNoun(name)
265
+ return noun ? Response.json(noun) : new Response('Not found', { status: 404 })
266
+ }
267
+
268
+ if (path === '/nouns' && method === 'GET') {
269
+ const nouns = await this.listNouns()
270
+ return Response.json(nouns)
271
+ }
272
+
273
+ if (path === '/verbs' && method === 'POST') {
274
+ const rawBody = await request.json()
275
+ const body = VerbDefinitionSchema.parse(rawBody)
276
+ const verb = await this.defineVerb(body)
277
+ return Response.json(verb)
278
+ }
279
+
280
+ if (path.startsWith('/verbs/') && method === 'GET') {
281
+ const name = decodeURIComponent(path.slice('/verbs/'.length))
282
+ const verb = await this.getVerb(name)
283
+ return verb ? Response.json(verb) : new Response('Not found', { status: 404 })
284
+ }
285
+
286
+ if (path === '/verbs' && method === 'GET') {
287
+ const verbs = await this.listVerbs()
288
+ return Response.json(verbs)
289
+ }
290
+
291
+ if (path === '/things' && method === 'POST') {
292
+ const rawBody = await request.json()
293
+ const { noun, data, id } = CreateThingSchema.parse(rawBody)
294
+ const thing = await this.create(noun, data, id)
295
+ return Response.json(thing)
296
+ }
297
+
298
+ if (path.startsWith('/things/') && method === 'GET') {
299
+ const id = decodeURIComponent(path.slice('/things/'.length))
300
+ const thing = await this.get(id)
301
+ return thing ? Response.json(thing) : new Response('Not found', { status: 404 })
302
+ }
303
+
304
+ if (path.startsWith('/things/') && method === 'PATCH') {
305
+ const id = decodeURIComponent(path.slice('/things/'.length))
306
+ const rawBody = await request.json()
307
+ const { data } = UpdateThingSchema.parse(rawBody)
308
+ const thing = await this.update(id, data)
309
+ return Response.json(thing)
310
+ }
311
+
312
+ if (path.startsWith('/things/') && method === 'DELETE') {
313
+ const id = decodeURIComponent(path.slice('/things/'.length))
314
+ const deleted = await this.delete(id)
315
+ return Response.json({ deleted })
316
+ }
317
+
318
+ if (path === '/things' && method === 'GET') {
319
+ const noun = url.searchParams.get('noun')
320
+ if (!noun) {
321
+ return new Response('noun parameter required', { status: 400 })
322
+ }
323
+ const options: ListOptions = {}
324
+ const limit = url.searchParams.get('limit')
325
+ const offset = url.searchParams.get('offset')
326
+ const orderBy = url.searchParams.get('orderBy')
327
+ const order = url.searchParams.get('order')
328
+ const whereParam = url.searchParams.get('where')
329
+ if (limit) options.limit = parseInt(limit, 10)
330
+ if (offset) options.offset = parseInt(offset, 10)
331
+ if (orderBy) options.orderBy = orderBy
332
+ if (order === 'asc' || order === 'desc') options.order = order
333
+ // Parse where parameter: format is "field=value" or "field1=value1&field2=value2"
334
+ if (whereParam) {
335
+ const where: Record<string, unknown> = {}
336
+ for (const pair of whereParam.split('&')) {
337
+ const eqIdx = pair.indexOf('=')
338
+ if (eqIdx > 0) {
339
+ const key = pair.substring(0, eqIdx)
340
+ const value = pair.substring(eqIdx + 1)
341
+ // Validation happens in list() method, but we validate here too for early error detection
342
+ validateWhereField(key)
343
+ where[key] = value
344
+ }
345
+ }
346
+ options.where = where
347
+ }
348
+ const things = await this.list(noun, options)
349
+ return Response.json(things)
350
+ }
351
+
352
+ if (path === '/search' && method === 'GET') {
353
+ const query = url.searchParams.get('q') ?? ''
354
+ const options: ListOptions = {}
355
+ const limit = url.searchParams.get('limit')
356
+ if (limit) options.limit = parseInt(limit, 10)
357
+ const things = await this.search(query, options)
358
+ return Response.json(things)
359
+ }
360
+
361
+ if (path === '/actions' && method === 'POST') {
362
+ const rawBody = await request.json()
363
+ const { verb, subject, object, data } = PerformActionSchema.parse(rawBody)
364
+ const action = await this.perform(verb, subject, object, data)
365
+ return Response.json(action)
366
+ }
367
+
368
+ if (path.startsWith('/actions/') && method === 'GET') {
369
+ const id = decodeURIComponent(path.slice('/actions/'.length))
370
+ const action = await this.getAction(id)
371
+ return action ? Response.json(action) : new Response('Not found', { status: 404 })
372
+ }
373
+
374
+ if (path.startsWith('/actions/') && method === 'DELETE') {
375
+ const id = decodeURIComponent(path.slice('/actions/'.length))
376
+ const deleted = await this.deleteAction(id)
377
+ return Response.json({ deleted })
378
+ }
379
+
380
+ if (path === '/actions' && method === 'GET') {
381
+ const options: ActionOptions = {}
382
+ const verb = url.searchParams.get('verb')
383
+ const subject = url.searchParams.get('subject')
384
+ const object = url.searchParams.get('object')
385
+ const limit = url.searchParams.get('limit')
386
+ const status = url.searchParams.get('status')
387
+ if (verb) options.verb = verb
388
+ if (subject) options.subject = subject
389
+ if (object) options.object = object
390
+ if (limit) options.limit = parseInt(limit, 10)
391
+ if (status) options.status = status as ActionStatusType
392
+ const actions = await this.listActions(options)
393
+ return Response.json(actions)
394
+ }
395
+
396
+ if (path.startsWith('/edges/') && method === 'GET') {
397
+ const id = decodeURIComponent(path.slice('/edges/'.length))
398
+ const verb = url.searchParams.get('verb') ?? undefined
399
+ const directionParam = url.searchParams.get('direction') ?? 'out'
400
+ const direction = validateDirection(directionParam)
401
+ const edges = await this.edges(id, verb, direction)
402
+ return Response.json(edges)
403
+ }
404
+
405
+ if (path.startsWith('/related/') && method === 'GET') {
406
+ const id = decodeURIComponent(path.slice('/related/'.length))
407
+ const verb = url.searchParams.get('verb') ?? undefined
408
+ const directionParam = url.searchParams.get('direction') ?? 'out'
409
+ const direction = validateDirection(directionParam)
410
+ const things = await this.related(id, verb, direction)
411
+ return Response.json(things)
412
+ }
413
+
414
+ // Batch operations
415
+ if (path === '/batch/things' && method === 'POST') {
416
+ const rawBody = await request.json()
417
+ const { noun, items } = BatchCreateThingsSchema.parse(rawBody)
418
+ const things = await this.createMany(noun, items)
419
+ return Response.json(things)
420
+ }
421
+
422
+ if (path === '/batch/things' && method === 'PATCH') {
423
+ const rawBody = await request.json()
424
+ const { updates } = BatchUpdateThingsSchema.parse(rawBody)
425
+ const things = await this.updateMany(updates)
426
+ return Response.json(things)
427
+ }
428
+
429
+ if (path === '/batch/things' && method === 'DELETE') {
430
+ const rawBody = await request.json()
431
+ const { ids } = BatchDeleteThingsSchema.parse(rawBody)
432
+ const results = await this.deleteMany(ids)
433
+ return Response.json(results)
434
+ }
435
+
436
+ if (path === '/batch/actions' && method === 'POST') {
437
+ const rawBody = await request.json()
438
+ const { actions } = BatchPerformActionsSchema.parse(rawBody)
439
+ const results = await this.performMany(actions)
440
+ return Response.json(results)
441
+ }
442
+
443
+ return Response.json({ error: 'NOT_FOUND', message: 'Endpoint not found' }, { status: 404 })
444
+ } catch (error) {
445
+ // Convert ZodError to ValidationError for consistent error handling
446
+ const normalizedError = error instanceof ZodError ? zodErrorToValidationError(error) : error
447
+ const { body, status } = errorToResponse(normalizedError)
448
+ return Response.json(body, { status })
449
+ }
450
+ }
451
+
452
+ // ==================== Nouns ====================
453
+
454
+ async defineNoun(def: NounDefinition): Promise<Noun> {
455
+ await this.ensureInitialized()
456
+
457
+ const derived = deriveNoun(def.name)
458
+ const now = Date.now()
459
+
460
+ this.sql.exec(
461
+ `INSERT OR REPLACE INTO nouns (name, singular, plural, slug, description, schema, created_at)
462
+ VALUES (?, ?, ?, ?, ?, ?, ?)`,
463
+ def.name,
464
+ def.singular ?? derived.singular,
465
+ def.plural ?? derived.plural,
466
+ derived.slug,
467
+ def.description ?? null,
468
+ def.schema ? JSON.stringify(def.schema) : null,
469
+ now
470
+ )
471
+
472
+ const noun: Noun = {
473
+ name: def.name,
474
+ singular: def.singular ?? derived.singular,
475
+ plural: def.plural ?? derived.plural,
476
+ slug: derived.slug,
477
+ description: def.description,
478
+ schema: def.schema,
479
+ createdAt: new Date(now),
480
+ }
481
+
482
+ // Update cache
483
+ this.nounCache.set(def.name, noun)
484
+
485
+ return noun
486
+ }
487
+
488
+ async getNoun(name: string): Promise<Noun | null> {
489
+ await this.ensureInitialized()
490
+
491
+ // Check cache first
492
+ const cached = this.nounCache.get(name)
493
+ if (cached) return cached
494
+
495
+ const rows = [...this.sql.exec('SELECT * FROM nouns WHERE name = ?', name)]
496
+ if (rows.length === 0) return null
497
+
498
+ const row = rows[0] as Record<string, unknown>
499
+ const noun: Noun = {
500
+ name: row.name as string,
501
+ singular: row.singular as string,
502
+ plural: row.plural as string,
503
+ slug: row.slug as string,
504
+ description: row.description as string | undefined,
505
+ schema: row.schema ? JSON.parse(row.schema as string) : undefined,
506
+ createdAt: new Date(row.created_at as number),
507
+ }
508
+
509
+ // Populate cache
510
+ this.nounCache.set(name, noun)
511
+
512
+ return noun
513
+ }
514
+
515
+ async listNouns(): Promise<Noun[]> {
516
+ await this.ensureInitialized()
517
+
518
+ const rows = [...this.sql.exec('SELECT * FROM nouns')]
519
+ return rows.map((row) => {
520
+ const r = row as Record<string, unknown>
521
+ return {
522
+ name: r.name as string,
523
+ singular: r.singular as string,
524
+ plural: r.plural as string,
525
+ slug: r.slug as string,
526
+ description: r.description as string | undefined,
527
+ schema: r.schema ? JSON.parse(r.schema as string) : undefined,
528
+ createdAt: new Date(r.created_at as number),
529
+ }
530
+ })
531
+ }
532
+
533
+ // ==================== Verbs ====================
534
+
535
+ async defineVerb(def: VerbDefinition): Promise<Verb> {
536
+ await this.ensureInitialized()
537
+
538
+ const derived = deriveVerb(def.name)
539
+ const now = Date.now()
540
+
541
+ this.sql.exec(
542
+ `INSERT OR REPLACE INTO verbs
543
+ (name, action, act, activity, event, reverse_by, reverse_at, reverse_in, inverse, description, created_at)
544
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
545
+ def.name,
546
+ def.action ?? derived.action,
547
+ def.act ?? derived.act,
548
+ def.activity ?? derived.activity,
549
+ def.event ?? derived.event,
550
+ def.reverseBy ?? derived.reverseBy,
551
+ derived.reverseAt,
552
+ def.reverseIn ?? derived.reverseIn,
553
+ def.inverse ?? null,
554
+ def.description ?? null,
555
+ now
556
+ )
557
+
558
+ const verb: Verb = {
559
+ name: def.name,
560
+ action: def.action ?? derived.action,
561
+ act: def.act ?? derived.act,
562
+ activity: def.activity ?? derived.activity,
563
+ event: def.event ?? derived.event,
564
+ reverseBy: def.reverseBy ?? derived.reverseBy,
565
+ reverseAt: derived.reverseAt,
566
+ reverseIn: def.reverseIn ?? derived.reverseIn,
567
+ inverse: def.inverse,
568
+ description: def.description,
569
+ createdAt: new Date(now),
570
+ }
571
+
572
+ // Update cache
573
+ this.verbCache.set(def.name, verb)
574
+
575
+ return verb
576
+ }
577
+
578
+ async getVerb(name: string): Promise<Verb | null> {
579
+ await this.ensureInitialized()
580
+
581
+ // Check cache first
582
+ const cached = this.verbCache.get(name)
583
+ if (cached) return cached
584
+
585
+ const rows = [...this.sql.exec('SELECT * FROM verbs WHERE name = ?', name)]
586
+ if (rows.length === 0) return null
587
+
588
+ const row = rows[0] as Record<string, unknown>
589
+ const verb: Verb = {
590
+ name: row.name as string,
591
+ action: row.action as string,
592
+ act: row.act as string,
593
+ activity: row.activity as string,
594
+ event: row.event as string,
595
+ reverseBy: row.reverse_by as string | undefined,
596
+ reverseAt: row.reverse_at as string | undefined,
597
+ reverseIn: row.reverse_in as string | undefined,
598
+ inverse: row.inverse as string | undefined,
599
+ description: row.description as string | undefined,
600
+ createdAt: new Date(row.created_at as number),
601
+ }
602
+
603
+ // Populate cache
604
+ this.verbCache.set(name, verb)
605
+
606
+ return verb
607
+ }
608
+
609
+ async listVerbs(): Promise<Verb[]> {
610
+ await this.ensureInitialized()
611
+
612
+ const rows = [...this.sql.exec('SELECT * FROM verbs')]
613
+ return rows.map((row) => {
614
+ const r = row as Record<string, unknown>
615
+ return {
616
+ name: r.name as string,
617
+ action: r.action as string,
618
+ act: r.act as string,
619
+ activity: r.activity as string,
620
+ event: r.event as string,
621
+ reverseBy: r.reverse_by as string | undefined,
622
+ reverseAt: r.reverse_at as string | undefined,
623
+ reverseIn: r.reverse_in as string | undefined,
624
+ inverse: r.inverse as string | undefined,
625
+ description: r.description as string | undefined,
626
+ createdAt: new Date(r.created_at as number),
627
+ }
628
+ })
629
+ }
630
+
631
+ // ==================== Things ====================
632
+
633
+ async create<T>(
634
+ noun: string,
635
+ data: T,
636
+ id?: string,
637
+ options?: ValidationOptions
638
+ ): Promise<Thing<T>> {
639
+ await this.ensureInitialized()
640
+
641
+ // Validate data against noun schema if validation is enabled
642
+ if (options?.validate) {
643
+ const nounDef = await this.getNoun(noun)
644
+ validateData(data as Record<string, unknown>, nounDef?.schema, options)
645
+ }
646
+
647
+ const thingId = id ?? crypto.randomUUID()
648
+ const now = Date.now()
649
+
650
+ this.sql.exec(
651
+ `INSERT INTO things (id, noun, data, created_at, updated_at)
652
+ VALUES (?, ?, ?, ?, ?)`,
653
+ thingId,
654
+ noun,
655
+ JSON.stringify(data),
656
+ now,
657
+ now
658
+ )
659
+
660
+ return {
661
+ id: thingId,
662
+ noun,
663
+ data,
664
+ createdAt: new Date(now),
665
+ updatedAt: new Date(now),
666
+ }
667
+ }
668
+
669
+ async get<T>(id: string): Promise<Thing<T> | null> {
670
+ await this.ensureInitialized()
671
+
672
+ const rows = [...this.sql.exec('SELECT * FROM things WHERE id = ?', id)]
673
+ if (rows.length === 0) return null
674
+
675
+ const row = rows[0] as Record<string, unknown>
676
+ return {
677
+ id: row.id as string,
678
+ noun: row.noun as string,
679
+ data: JSON.parse(row.data as string) as T,
680
+ createdAt: new Date(row.created_at as number),
681
+ updatedAt: new Date(row.updated_at as number),
682
+ }
683
+ }
684
+
685
+ /**
686
+ * Batch fetch multiple things by IDs using WHERE IN clause
687
+ * More efficient than N individual get() calls (fixes N+1 query pattern)
688
+ */
689
+ private async getMany<T>(ids: string[]): Promise<Thing<T>[]> {
690
+ if (ids.length === 0) return []
691
+
692
+ await this.ensureInitialized()
693
+
694
+ // Build WHERE IN clause with placeholders
695
+ const placeholders = ids.map(() => '?').join(',')
696
+ const rows = [...this.sql.exec(`SELECT * FROM things WHERE id IN (${placeholders})`, ...ids)]
697
+
698
+ return rows.map((row) => {
699
+ const r = row as Record<string, unknown>
700
+ return {
701
+ id: r.id as string,
702
+ noun: r.noun as string,
703
+ data: JSON.parse(r.data as string) as T,
704
+ createdAt: new Date(r.created_at as number),
705
+ updatedAt: new Date(r.updated_at as number),
706
+ }
707
+ })
708
+ }
709
+
710
+ async list<T>(noun: string, options?: ListOptions): Promise<Thing<T>[]> {
711
+ await this.ensureInitialized()
712
+
713
+ let sql = 'SELECT * FROM things WHERE noun = ?'
714
+ const params: unknown[] = [noun]
715
+
716
+ // Apply where filter in SQL using json_extract for better performance
717
+ if (options?.where) {
718
+ // Use Object.getOwnPropertyNames to also catch __proto__ which is not enumerable with Object.entries
719
+ const whereKeys = Object.getOwnPropertyNames(options.where)
720
+ for (const key of whereKeys) {
721
+ // Validate field name to prevent JSON path traversal and prototype pollution
722
+ validateWhereField(key)
723
+ const value = (options.where as Record<string, unknown>)[key]
724
+ sql += ` AND json_extract(data, '$.${key}') = ?`
725
+ // json_extract returns strings unquoted, numbers as numbers, booleans as 0/1, null as NULL
726
+ params.push(value)
727
+ }
728
+ }
729
+
730
+ if (options?.orderBy) {
731
+ // Validate orderBy field to prevent SQL injection
732
+ if (!validateOrderByField(options.orderBy)) {
733
+ throw new Error(`Invalid orderBy field: ${options.orderBy}`)
734
+ }
735
+ sql += ` ORDER BY json_extract(data, '$.${options.orderBy}')`
736
+ sql += options.order === 'desc' ? ' DESC' : ' ASC'
737
+ }
738
+
739
+ // Apply limit with safety bounds
740
+ const limit = effectiveLimit(options?.limit)
741
+ sql += ` LIMIT ?`
742
+ params.push(limit)
743
+
744
+ if (options?.offset) {
745
+ sql += ` OFFSET ?`
746
+ params.push(options.offset)
747
+ }
748
+
749
+ const rows = [...this.sql.exec(sql, ...params)]
750
+ const results = rows.map((row) => {
751
+ const r = row as Record<string, unknown>
752
+ return {
753
+ id: r.id as string,
754
+ noun: r.noun as string,
755
+ data: JSON.parse(r.data as string) as T,
756
+ createdAt: new Date(r.created_at as number),
757
+ updatedAt: new Date(r.updated_at as number),
758
+ }
759
+ })
760
+
761
+ return results
762
+ }
763
+
764
+ async find<T>(noun: string, where: Partial<T>): Promise<Thing<T>[]> {
765
+ return this.list<T>(noun, { where: where as Record<string, unknown> })
766
+ }
767
+
768
+ async update<T>(id: string, data: Partial<T>, options?: ValidationOptions): Promise<Thing<T>> {
769
+ await this.ensureInitialized()
770
+
771
+ const existing = await this.get<T>(id)
772
+ if (!existing) throw new NotFoundError('Thing', id)
773
+
774
+ const updated = { ...existing.data, ...data } as T
775
+
776
+ // Validate merged data against noun schema if validation is enabled
777
+ if (options?.validate) {
778
+ const nounDef = await this.getNoun(existing.noun)
779
+ validateData(updated as Record<string, unknown>, nounDef?.schema, options)
780
+ }
781
+
782
+ const now = Date.now()
783
+
784
+ this.sql.exec(
785
+ `UPDATE things SET data = ?, updated_at = ? WHERE id = ?`,
786
+ JSON.stringify(updated),
787
+ now,
788
+ id
789
+ )
790
+
791
+ return {
792
+ ...existing,
793
+ data: updated,
794
+ updatedAt: new Date(now),
795
+ }
796
+ }
797
+
798
+ async delete(id: string): Promise<boolean> {
799
+ await this.ensureInitialized()
800
+
801
+ const result = this.sql.exec('DELETE FROM things WHERE id = ?', id)
802
+ return result.rowsWritten > 0
803
+ }
804
+
805
+ async search<T>(query: string, options?: ListOptions): Promise<Thing<T>[]> {
806
+ await this.ensureInitialized()
807
+
808
+ // Escape LIKE wildcards (%, _, \) so they match literally
809
+ const q = `%${escapeLikePattern(query.toLowerCase())}%`
810
+ let sql = `SELECT * FROM things WHERE LOWER(data) LIKE ? ESCAPE '\\'`
811
+ const params: unknown[] = [q]
812
+
813
+ // Apply limit with safety bounds
814
+ const limit = effectiveLimit(options?.limit)
815
+ sql += ` LIMIT ?`
816
+ params.push(limit)
817
+
818
+ const rows = [...this.sql.exec(sql, ...params)]
819
+ return rows.map((row) => {
820
+ const r = row as Record<string, unknown>
821
+ return {
822
+ id: r.id as string,
823
+ noun: r.noun as string,
824
+ data: JSON.parse(r.data as string) as T,
825
+ createdAt: new Date(r.created_at as number),
826
+ updatedAt: new Date(r.updated_at as number),
827
+ }
828
+ })
829
+ }
830
+
831
+ // ==================== Actions ====================
832
+
833
+ async perform<T>(verb: string, subject?: string, object?: string, data?: T): Promise<Action<T>> {
834
+ await this.ensureInitialized()
835
+
836
+ const id = crypto.randomUUID()
837
+ const now = Date.now()
838
+
839
+ this.sql.exec(
840
+ `INSERT INTO actions (id, verb, subject, object, data, status, created_at, completed_at)
841
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
842
+ id,
843
+ verb,
844
+ subject ?? null,
845
+ object ?? null,
846
+ data ? JSON.stringify(data) : null,
847
+ ActionStatus.COMPLETED,
848
+ now,
849
+ now
850
+ )
851
+
852
+ return {
853
+ id,
854
+ verb,
855
+ subject,
856
+ object,
857
+ data,
858
+ status: ActionStatus.COMPLETED,
859
+ createdAt: new Date(now),
860
+ completedAt: new Date(now),
861
+ }
862
+ }
863
+
864
+ async getAction<T>(id: string): Promise<Action<T> | null> {
865
+ await this.ensureInitialized()
866
+
867
+ const rows = [...this.sql.exec('SELECT * FROM actions WHERE id = ?', id)]
868
+ if (rows.length === 0) return null
869
+
870
+ const row = rows[0] as Record<string, unknown>
871
+ return {
872
+ id: row.id as string,
873
+ verb: row.verb as string,
874
+ subject: row.subject as string | undefined,
875
+ object: row.object as string | undefined,
876
+ data: row.data ? (JSON.parse(row.data as string) as T) : undefined,
877
+ status: row.status as ActionStatusType,
878
+ createdAt: new Date(row.created_at as number),
879
+ completedAt: row.completed_at ? new Date(row.completed_at as number) : undefined,
880
+ }
881
+ }
882
+
883
+ async listActions<T>(options?: ActionOptions): Promise<Action<T>[]> {
884
+ await this.ensureInitialized()
885
+
886
+ let sql = 'SELECT * FROM actions WHERE 1=1'
887
+ const params: unknown[] = []
888
+
889
+ if (options?.verb) {
890
+ sql += ' AND verb = ?'
891
+ params.push(options.verb)
892
+ }
893
+
894
+ if (options?.subject) {
895
+ sql += ' AND subject = ?'
896
+ params.push(options.subject)
897
+ }
898
+
899
+ if (options?.object) {
900
+ sql += ' AND object = ?'
901
+ params.push(options.object)
902
+ }
903
+
904
+ if (options?.status) {
905
+ const statuses = Array.isArray(options.status) ? options.status : [options.status]
906
+ sql += ` AND status IN (${statuses.map(() => '?').join(', ')})`
907
+ params.push(...statuses)
908
+ }
909
+
910
+ // Apply limit with safety bounds
911
+ const limit = effectiveLimit(options?.limit)
912
+ sql += ' LIMIT ?'
913
+ params.push(limit)
914
+
915
+ const rows = [...this.sql.exec(sql, ...params)]
916
+ return rows.map((row) => {
917
+ const r = row as Record<string, unknown>
918
+ return {
919
+ id: r.id as string,
920
+ verb: r.verb as string,
921
+ subject: r.subject as string | undefined,
922
+ object: r.object as string | undefined,
923
+ data: r.data ? (JSON.parse(r.data as string) as T) : undefined,
924
+ status: r.status as ActionStatusType,
925
+ createdAt: new Date(r.created_at as number),
926
+ completedAt: r.completed_at ? new Date(r.completed_at as number) : undefined,
927
+ }
928
+ })
929
+ }
930
+
931
+ async deleteAction(id: string): Promise<boolean> {
932
+ await this.ensureInitialized()
933
+
934
+ const result = this.sql.exec('DELETE FROM actions WHERE id = ?', id)
935
+ return result.rowsWritten > 0
936
+ }
937
+
938
+ // ==================== Graph Traversal ====================
939
+
940
+ async related<T>(
941
+ id: string,
942
+ verb?: string,
943
+ direction: Direction = 'out',
944
+ options?: ListOptions
945
+ ): Promise<Thing<T>[]> {
946
+ const validDirection = validateDirection(direction)
947
+ const edgesList = await this.edges(id, verb, validDirection)
948
+ const relatedIds = new Set<string>()
949
+
950
+ for (const edge of edgesList) {
951
+ if (direction === 'out' || direction === 'both') {
952
+ if (edge.subject === id && edge.object) {
953
+ relatedIds.add(edge.object)
954
+ }
955
+ }
956
+ if (direction === 'in' || direction === 'both') {
957
+ if (edge.object === id && edge.subject) {
958
+ relatedIds.add(edge.subject)
959
+ }
960
+ }
961
+ }
962
+
963
+ // Use batch query instead of N individual get() calls (fixes N+1 pattern)
964
+ let results = await this.getMany<T>([...relatedIds])
965
+
966
+ // Apply limit with safety bounds
967
+ const limit = effectiveLimit(options?.limit)
968
+ results = results.slice(0, limit)
969
+
970
+ return results
971
+ }
972
+
973
+ async edges<T>(
974
+ id: string,
975
+ verb?: string,
976
+ direction: Direction = 'out',
977
+ options?: ListOptions
978
+ ): Promise<Action<T>[]> {
979
+ const validDirection = validateDirection(direction)
980
+ await this.ensureInitialized()
981
+
982
+ let sql: string
983
+ const params: unknown[] = []
984
+
985
+ if (validDirection === 'out') {
986
+ sql = 'SELECT * FROM actions WHERE subject = ?'
987
+ params.push(id)
988
+ } else if (validDirection === 'in') {
989
+ sql = 'SELECT * FROM actions WHERE object = ?'
990
+ params.push(id)
991
+ } else {
992
+ sql = 'SELECT * FROM actions WHERE subject = ? OR object = ?'
993
+ params.push(id, id)
994
+ }
995
+
996
+ if (verb) {
997
+ sql += ' AND verb = ?'
998
+ params.push(verb)
999
+ }
1000
+
1001
+ // Apply limit with safety bounds
1002
+ const limit = effectiveLimit(options?.limit)
1003
+ sql += ' LIMIT ?'
1004
+ params.push(limit)
1005
+
1006
+ const rows = [...this.sql.exec(sql, ...params)]
1007
+ return rows.map((row) => {
1008
+ const r = row as Record<string, unknown>
1009
+ return {
1010
+ id: r.id as string,
1011
+ verb: r.verb as string,
1012
+ subject: r.subject as string | undefined,
1013
+ object: r.object as string | undefined,
1014
+ data: r.data ? (JSON.parse(r.data as string) as T) : undefined,
1015
+ status: r.status as ActionStatusType,
1016
+ createdAt: new Date(r.created_at as number),
1017
+ completedAt: r.completed_at ? new Date(r.completed_at as number) : undefined,
1018
+ }
1019
+ })
1020
+ }
1021
+
1022
+ // ==================== Batch Operations ====================
1023
+
1024
+ async createMany<T>(noun: string, items: T[]): Promise<Thing<T>[]> {
1025
+ if (items.length > MAX_BATCH_SIZE) {
1026
+ throw new ValidationError(`Batch size ${items.length} exceeds maximum of ${MAX_BATCH_SIZE}`, [
1027
+ { field: 'items', message: `Batch size exceeds maximum of ${MAX_BATCH_SIZE}` },
1028
+ ])
1029
+ }
1030
+
1031
+ await this.ensureInitialized()
1032
+
1033
+ const now = Date.now()
1034
+ const results: Thing<T>[] = []
1035
+
1036
+ // Use a transaction for atomic batch insert
1037
+ this.sql.exec('BEGIN TRANSACTION')
1038
+ try {
1039
+ for (const item of items) {
1040
+ const thingId = crypto.randomUUID()
1041
+ this.sql.exec(
1042
+ `INSERT INTO things (id, noun, data, created_at, updated_at)
1043
+ VALUES (?, ?, ?, ?, ?)`,
1044
+ thingId,
1045
+ noun,
1046
+ JSON.stringify(item),
1047
+ now,
1048
+ now
1049
+ )
1050
+ results.push({
1051
+ id: thingId,
1052
+ noun,
1053
+ data: item,
1054
+ createdAt: new Date(now),
1055
+ updatedAt: new Date(now),
1056
+ })
1057
+ }
1058
+ this.sql.exec('COMMIT')
1059
+ } catch (error) {
1060
+ this.sql.exec('ROLLBACK')
1061
+ throw error
1062
+ }
1063
+
1064
+ return results
1065
+ }
1066
+
1067
+ async updateMany<T>(updates: Array<{ id: string; data: Partial<T> }>): Promise<Thing<T>[]> {
1068
+ if (updates.length > MAX_BATCH_SIZE) {
1069
+ throw new ValidationError(
1070
+ `Batch size ${updates.length} exceeds maximum of ${MAX_BATCH_SIZE}`,
1071
+ [{ field: 'items', message: `Batch size exceeds maximum of ${MAX_BATCH_SIZE}` }]
1072
+ )
1073
+ }
1074
+
1075
+ await this.ensureInitialized()
1076
+
1077
+ const now = Date.now()
1078
+ const results: Thing<T>[] = []
1079
+
1080
+ // Use a transaction for atomic batch update
1081
+ this.sql.exec('BEGIN TRANSACTION')
1082
+ try {
1083
+ for (const { id, data } of updates) {
1084
+ const existing = await this.get<T>(id)
1085
+ if (!existing) throw new NotFoundError('Thing', id)
1086
+
1087
+ const updated = { ...existing.data, ...data } as T
1088
+ this.sql.exec(
1089
+ `UPDATE things SET data = ?, updated_at = ? WHERE id = ?`,
1090
+ JSON.stringify(updated),
1091
+ now,
1092
+ id
1093
+ )
1094
+ results.push({
1095
+ ...existing,
1096
+ data: updated,
1097
+ updatedAt: new Date(now),
1098
+ })
1099
+ }
1100
+ this.sql.exec('COMMIT')
1101
+ } catch (error) {
1102
+ this.sql.exec('ROLLBACK')
1103
+ throw error
1104
+ }
1105
+
1106
+ return results
1107
+ }
1108
+
1109
+ async deleteMany(ids: string[]): Promise<boolean[]> {
1110
+ if (ids.length > MAX_BATCH_SIZE) {
1111
+ throw new ValidationError(`Batch size ${ids.length} exceeds maximum of ${MAX_BATCH_SIZE}`, [
1112
+ { field: 'ids', message: `Batch size exceeds maximum of ${MAX_BATCH_SIZE}` },
1113
+ ])
1114
+ }
1115
+
1116
+ await this.ensureInitialized()
1117
+
1118
+ const results: boolean[] = []
1119
+
1120
+ // Use a transaction for atomic batch delete
1121
+ this.sql.exec('BEGIN TRANSACTION')
1122
+ try {
1123
+ for (const id of ids) {
1124
+ const result = this.sql.exec('DELETE FROM things WHERE id = ?', id)
1125
+ results.push(result.rowsWritten > 0)
1126
+ }
1127
+ this.sql.exec('COMMIT')
1128
+ } catch (error) {
1129
+ this.sql.exec('ROLLBACK')
1130
+ throw error
1131
+ }
1132
+
1133
+ return results
1134
+ }
1135
+
1136
+ async performMany<T>(
1137
+ actions: Array<{ verb: string; subject?: string; object?: string; data?: T }>
1138
+ ): Promise<Action<T>[]> {
1139
+ if (actions.length > MAX_BATCH_SIZE) {
1140
+ throw new ValidationError(
1141
+ `Batch size ${actions.length} exceeds maximum of ${MAX_BATCH_SIZE}`,
1142
+ [{ field: 'actions', message: `Batch size exceeds maximum of ${MAX_BATCH_SIZE}` }]
1143
+ )
1144
+ }
1145
+
1146
+ await this.ensureInitialized()
1147
+
1148
+ const now = Date.now()
1149
+ const results: Action<T>[] = []
1150
+
1151
+ // Use a transaction for atomic batch insert
1152
+ this.sql.exec('BEGIN TRANSACTION')
1153
+ try {
1154
+ for (const action of actions) {
1155
+ const id = crypto.randomUUID()
1156
+ this.sql.exec(
1157
+ `INSERT INTO actions (id, verb, subject, object, data, status, created_at, completed_at)
1158
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
1159
+ id,
1160
+ action.verb,
1161
+ action.subject ?? null,
1162
+ action.object ?? null,
1163
+ action.data ? JSON.stringify(action.data) : null,
1164
+ ActionStatus.COMPLETED,
1165
+ now,
1166
+ now
1167
+ )
1168
+ results.push({
1169
+ id,
1170
+ verb: action.verb,
1171
+ subject: action.subject,
1172
+ object: action.object,
1173
+ data: action.data,
1174
+ status: ActionStatus.COMPLETED,
1175
+ createdAt: new Date(now),
1176
+ completedAt: new Date(now),
1177
+ })
1178
+ }
1179
+ this.sql.exec('COMMIT')
1180
+ } catch (error) {
1181
+ this.sql.exec('ROLLBACK')
1182
+ throw error
1183
+ }
1184
+
1185
+ return results
1186
+ }
1187
+
1188
+ async close(): Promise<void> {
1189
+ // No-op for Durable Objects (SQLite persists automatically)
1190
+ }
1191
+ }
1192
+
1193
+ export default {
1194
+ async fetch(request: Request, env: Env): Promise<Response> {
1195
+ const url = new URL(request.url)
1196
+ const namespaceId = url.searchParams.get('ns') ?? 'default'
1197
+
1198
+ // Validate namespace ID format
1199
+ if (!validateNamespaceId(namespaceId)) {
1200
+ return Response.json(
1201
+ {
1202
+ error: 'INVALID_NAMESPACE',
1203
+ message:
1204
+ 'Invalid namespace ID. Must be 1-64 characters, alphanumeric with hyphens and underscores only.',
1205
+ },
1206
+ { status: 400 }
1207
+ )
1208
+ }
1209
+
1210
+ const id = env.NS.idFromName(namespaceId)
1211
+ const stub = env.NS.get(id)
1212
+
1213
+ return stub.fetch(request)
1214
+ },
1215
+ }