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.
- package/.turbo/turbo-build.log +4 -0
- package/CHANGELOG.md +25 -0
- package/LICENSE +21 -0
- package/README.md +476 -0
- package/dist/ai-database-adapter.d.ts +49 -0
- package/dist/ai-database-adapter.d.ts.map +1 -0
- package/dist/ai-database-adapter.js +89 -0
- package/dist/ai-database-adapter.js.map +1 -0
- package/dist/errors.d.ts +47 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +72 -0
- package/dist/errors.js.map +1 -0
- package/dist/http-schemas.d.ts +165 -0
- package/dist/http-schemas.d.ts.map +1 -0
- package/dist/http-schemas.js +55 -0
- package/dist/http-schemas.js.map +1 -0
- package/dist/index.d.ts +29 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +32 -0
- package/dist/index.js.map +1 -0
- package/dist/linguistic.d.ts +54 -0
- package/dist/linguistic.d.ts.map +1 -0
- package/dist/linguistic.js +226 -0
- package/dist/linguistic.js.map +1 -0
- package/dist/memory-provider.d.ts +46 -0
- package/dist/memory-provider.d.ts.map +1 -0
- package/dist/memory-provider.js +279 -0
- package/dist/memory-provider.js.map +1 -0
- package/dist/ns-client.d.ts +88 -0
- package/dist/ns-client.d.ts.map +1 -0
- package/dist/ns-client.js +253 -0
- package/dist/ns-client.js.map +1 -0
- package/dist/ns-exports.d.ts +23 -0
- package/dist/ns-exports.d.ts.map +1 -0
- package/dist/ns-exports.js +21 -0
- package/dist/ns-exports.js.map +1 -0
- package/dist/ns.d.ts +60 -0
- package/dist/ns.d.ts.map +1 -0
- package/dist/ns.js +818 -0
- package/dist/ns.js.map +1 -0
- package/dist/r2-persistence.d.ts +112 -0
- package/dist/r2-persistence.d.ts.map +1 -0
- package/dist/r2-persistence.js +252 -0
- package/dist/r2-persistence.js.map +1 -0
- package/dist/schema-validation.d.ts +80 -0
- package/dist/schema-validation.d.ts.map +1 -0
- package/dist/schema-validation.js +233 -0
- package/dist/schema-validation.js.map +1 -0
- package/dist/types.d.ts +184 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +26 -0
- package/dist/types.js.map +1 -0
- package/package.json +55 -0
- package/src/ai-database-adapter.test.ts +610 -0
- package/src/ai-database-adapter.ts +189 -0
- package/src/benchmark.test.ts +109 -0
- package/src/errors.ts +91 -0
- package/src/http-schemas.ts +67 -0
- package/src/index.ts +87 -0
- package/src/linguistic.test.ts +1107 -0
- package/src/linguistic.ts +253 -0
- package/src/memory-provider.ts +470 -0
- package/src/ns-client.test.ts +1360 -0
- package/src/ns-client.ts +342 -0
- package/src/ns-exports.ts +23 -0
- package/src/ns.test.ts +1381 -0
- package/src/ns.ts +1215 -0
- package/src/provider.test.ts +675 -0
- package/src/r2-persistence.test.ts +263 -0
- package/src/r2-persistence.ts +367 -0
- package/src/schema-validation.test.ts +167 -0
- package/src/schema-validation.ts +330 -0
- package/src/types.ts +252 -0
- package/test/action-status.test.ts +42 -0
- package/test/batch-limits.test.ts +165 -0
- package/test/docs.test.ts +48 -0
- package/test/errors.test.ts +148 -0
- package/test/http-validation.test.ts +401 -0
- package/test/ns-client-errors.test.ts +208 -0
- package/test/ns-namespace.test.ts +307 -0
- package/test/performance.test.ts +168 -0
- package/test/schema-validation-error.test.ts +213 -0
- package/test/schema-validation.test.ts +440 -0
- package/test/search-escaping.test.ts +359 -0
- package/test/security.test.ts +322 -0
- package/tsconfig.json +10 -0
- 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
|
+
}
|