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
@@ -0,0 +1,470 @@
1
+ /**
2
+ * In-memory implementation of DigitalObjectsProvider
3
+ *
4
+ * Used for testing and development. All data is stored in Maps.
5
+ */
6
+
7
+ import type {
8
+ DigitalObjectsProvider,
9
+ Noun,
10
+ NounDefinition,
11
+ Verb,
12
+ VerbDefinition,
13
+ Thing,
14
+ Action,
15
+ ActionStatusType,
16
+ ListOptions,
17
+ ActionOptions,
18
+ ValidationOptions,
19
+ Direction,
20
+ } from './types.js'
21
+ import {
22
+ DEFAULT_LIMIT,
23
+ MAX_LIMIT,
24
+ MAX_BATCH_SIZE,
25
+ validateDirection,
26
+ ActionStatus,
27
+ } from './types.js'
28
+ import { deriveNoun, deriveVerb } from './linguistic.js'
29
+ import { validateData } from './schema-validation.js'
30
+ import { NotFoundError, ValidationError } from './errors.js'
31
+
32
+ /**
33
+ * Calculate effective limit with safety bounds
34
+ */
35
+ function effectiveLimit(requestedLimit?: number): number {
36
+ return Math.min(requestedLimit ?? DEFAULT_LIMIT, MAX_LIMIT)
37
+ }
38
+
39
+ function generateId(): string {
40
+ return crypto.randomUUID()
41
+ }
42
+
43
+ // Dangerous field names that could enable prototype pollution or other attacks
44
+ const DANGEROUS_FIELDS = ['__proto__', 'constructor', 'prototype']
45
+
46
+ /**
47
+ * Validates a where clause field name to prevent JSON path traversal and prototype pollution.
48
+ * Throws ValidationError if the field name is invalid.
49
+ *
50
+ * Rejects:
51
+ * - Dots (.) in field names (JSON path traversal)
52
+ * - __proto__, constructor, prototype (prototype pollution)
53
+ * - Special JSON path characters ([, ], $, @)
54
+ */
55
+ function validateWhereField(field: string): void {
56
+ // Check for dangerous prototype-related field names
57
+ if (DANGEROUS_FIELDS.includes(field)) {
58
+ throw new ValidationError(`Invalid where field: '${field}' is not allowed`, [
59
+ { field, message: `Field name '${field}' is not allowed for security reasons` },
60
+ ])
61
+ }
62
+
63
+ // Check for dots (JSON path traversal)
64
+ if (field.includes('.')) {
65
+ throw new ValidationError(`Invalid where field: '${field}' contains dots`, [
66
+ { field, message: 'Field names cannot contain dots (JSON path traversal prevention)' },
67
+ ])
68
+ }
69
+
70
+ // Check for special JSON path characters
71
+ if (/[\[\]$@]/.test(field)) {
72
+ throw new ValidationError(`Invalid where field: '${field}' contains special characters`, [
73
+ { field, message: 'Field names cannot contain special JSON path characters ([, ], $, @)' },
74
+ ])
75
+ }
76
+
77
+ // Must match valid identifier pattern (starts with letter or underscore, followed by alphanumeric or underscore)
78
+ if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(field)) {
79
+ throw new ValidationError(`Invalid where field: '${field}'`, [
80
+ {
81
+ field,
82
+ message: 'Field name must be a valid identifier (letters, numbers, underscores only)',
83
+ },
84
+ ])
85
+ }
86
+ }
87
+
88
+ export class MemoryProvider implements DigitalObjectsProvider {
89
+ private nouns = new Map<string, Noun>()
90
+ private verbs = new Map<string, Verb>()
91
+ private things = new Map<string, Thing>()
92
+ private actions = new Map<string, Action>()
93
+
94
+ // ==================== Nouns ====================
95
+
96
+ async defineNoun(def: NounDefinition): Promise<Noun> {
97
+ const derived = deriveNoun(def.name)
98
+ const noun: Noun = {
99
+ name: def.name,
100
+ singular: def.singular ?? derived.singular,
101
+ plural: def.plural ?? derived.plural,
102
+ slug: derived.slug,
103
+ description: def.description,
104
+ schema: def.schema,
105
+ createdAt: new Date(),
106
+ }
107
+ this.nouns.set(noun.name, noun)
108
+ return noun
109
+ }
110
+
111
+ async getNoun(name: string): Promise<Noun | null> {
112
+ return this.nouns.get(name) ?? null
113
+ }
114
+
115
+ async listNouns(): Promise<Noun[]> {
116
+ return Array.from(this.nouns.values())
117
+ }
118
+
119
+ // ==================== Verbs ====================
120
+
121
+ async defineVerb(def: VerbDefinition): Promise<Verb> {
122
+ const derived = deriveVerb(def.name)
123
+ const verb: Verb = {
124
+ name: def.name,
125
+ action: def.action ?? derived.action,
126
+ act: def.act ?? derived.act,
127
+ activity: def.activity ?? derived.activity,
128
+ event: def.event ?? derived.event,
129
+ reverseBy: def.reverseBy ?? derived.reverseBy,
130
+ reverseAt: derived.reverseAt,
131
+ reverseIn: def.reverseIn ?? derived.reverseIn,
132
+ inverse: def.inverse,
133
+ description: def.description,
134
+ createdAt: new Date(),
135
+ }
136
+ this.verbs.set(verb.name, verb)
137
+ return verb
138
+ }
139
+
140
+ async getVerb(name: string): Promise<Verb | null> {
141
+ return this.verbs.get(name) ?? null
142
+ }
143
+
144
+ async listVerbs(): Promise<Verb[]> {
145
+ return Array.from(this.verbs.values())
146
+ }
147
+
148
+ // ==================== Things ====================
149
+
150
+ async create<T>(
151
+ noun: string,
152
+ data: T,
153
+ id?: string,
154
+ options?: ValidationOptions
155
+ ): Promise<Thing<T>> {
156
+ // Validate data against noun schema if validation is enabled
157
+ if (options?.validate) {
158
+ const nounDef = this.nouns.get(noun)
159
+ validateData(data as Record<string, unknown>, nounDef?.schema, options)
160
+ }
161
+
162
+ const thing: Thing<T> = {
163
+ id: id ?? generateId(),
164
+ noun,
165
+ data,
166
+ createdAt: new Date(),
167
+ updatedAt: new Date(),
168
+ }
169
+ this.things.set(thing.id, thing as Thing)
170
+ return thing
171
+ }
172
+
173
+ async get<T>(id: string): Promise<Thing<T> | null> {
174
+ return (this.things.get(id) as Thing<T>) ?? null
175
+ }
176
+
177
+ /**
178
+ * Batch fetch multiple things by IDs
179
+ * More efficient than N individual get() calls
180
+ */
181
+ async getMany<T>(ids: string[]): Promise<Thing<T>[]> {
182
+ if (ids.length === 0) return []
183
+
184
+ const results: Thing<T>[] = []
185
+ for (const id of ids) {
186
+ const thing = this.things.get(id)
187
+ if (thing) {
188
+ results.push(thing as Thing<T>)
189
+ }
190
+ }
191
+ return results
192
+ }
193
+
194
+ async list<T>(noun: string, options?: ListOptions): Promise<Thing<T>[]> {
195
+ let results = Array.from(this.things.values()).filter((t) => t.noun === noun) as Thing<T>[]
196
+
197
+ if (options?.where) {
198
+ // Use Object.getOwnPropertyNames to also catch __proto__ which is not enumerable with Object.keys
199
+ const whereKeys = Object.getOwnPropertyNames(options.where)
200
+ // Validate all field names before filtering
201
+ for (const key of whereKeys) {
202
+ validateWhereField(key)
203
+ }
204
+ results = results.filter((t) => {
205
+ for (const key of whereKeys) {
206
+ const value = (options.where as Record<string, unknown>)[key]
207
+ if ((t.data as Record<string, unknown>)[key] !== value) {
208
+ return false
209
+ }
210
+ }
211
+ return true
212
+ })
213
+ }
214
+
215
+ if (options?.orderBy) {
216
+ const key = options.orderBy
217
+ const dir = options.order === 'desc' ? -1 : 1
218
+ results.sort((a, b) => {
219
+ const aVal = (a.data as Record<string, unknown>)[key] as
220
+ | string
221
+ | number
222
+ | boolean
223
+ | null
224
+ | undefined
225
+ const bVal = (b.data as Record<string, unknown>)[key] as
226
+ | string
227
+ | number
228
+ | boolean
229
+ | null
230
+ | undefined
231
+ if (aVal == null && bVal == null) return 0
232
+ if (aVal == null) return 1 * dir
233
+ if (bVal == null) return -1 * dir
234
+ if (aVal < bVal) return -1 * dir
235
+ if (aVal > bVal) return 1 * dir
236
+ return 0
237
+ })
238
+ }
239
+
240
+ if (options?.offset) {
241
+ results = results.slice(options.offset)
242
+ }
243
+
244
+ // Apply limit with safety bounds
245
+ const limit = effectiveLimit(options?.limit)
246
+ results = results.slice(0, limit)
247
+
248
+ return results
249
+ }
250
+
251
+ async find<T>(noun: string, where: Partial<T>): Promise<Thing<T>[]> {
252
+ return this.list<T>(noun, { where: where as Record<string, unknown> })
253
+ }
254
+
255
+ async update<T>(id: string, data: Partial<T>, options?: ValidationOptions): Promise<Thing<T>> {
256
+ const existing = this.things.get(id)
257
+ if (!existing) {
258
+ throw new NotFoundError('Thing', id)
259
+ }
260
+
261
+ // Merge data for validation
262
+ const mergedData = { ...existing.data, ...data } as T
263
+
264
+ // Validate merged data against noun schema if validation is enabled
265
+ if (options?.validate) {
266
+ const nounDef = this.nouns.get(existing.noun)
267
+ validateData(mergedData as Record<string, unknown>, nounDef?.schema, options)
268
+ }
269
+
270
+ // Ensure updatedAt is always strictly newer than createdAt
271
+ const now = new Date()
272
+ const updatedAt =
273
+ now.getTime() <= existing.createdAt.getTime()
274
+ ? new Date(existing.createdAt.getTime() + 1)
275
+ : now
276
+
277
+ const updated: Thing<T> = {
278
+ ...existing,
279
+ data: mergedData,
280
+ updatedAt,
281
+ }
282
+ this.things.set(id, updated as Thing)
283
+ return updated
284
+ }
285
+
286
+ async delete(id: string): Promise<boolean> {
287
+ return this.things.delete(id)
288
+ }
289
+
290
+ async search<T>(query: string, options?: ListOptions): Promise<Thing<T>[]> {
291
+ const q = query.toLowerCase()
292
+ let results = Array.from(this.things.values()).filter((t) =>
293
+ JSON.stringify(t.data).toLowerCase().includes(q)
294
+ ) as Thing<T>[]
295
+
296
+ // Apply limit with safety bounds
297
+ const limit = effectiveLimit(options?.limit)
298
+ results = results.slice(0, limit)
299
+
300
+ return results
301
+ }
302
+
303
+ // ==================== Actions ====================
304
+
305
+ async perform<T>(verb: string, subject?: string, object?: string, data?: T): Promise<Action<T>> {
306
+ const action: Action<T> = {
307
+ id: generateId(),
308
+ verb,
309
+ subject,
310
+ object,
311
+ data,
312
+ status: ActionStatus.COMPLETED,
313
+ createdAt: new Date(),
314
+ completedAt: new Date(),
315
+ }
316
+ this.actions.set(action.id, action as Action)
317
+ return action
318
+ }
319
+
320
+ async getAction<T>(id: string): Promise<Action<T> | null> {
321
+ return (this.actions.get(id) as Action<T>) ?? null
322
+ }
323
+
324
+ async listActions<T>(options?: ActionOptions): Promise<Action<T>[]> {
325
+ let results = Array.from(this.actions.values()) as Action<T>[]
326
+
327
+ if (options?.verb) {
328
+ results = results.filter((a) => a.verb === options.verb)
329
+ }
330
+
331
+ if (options?.subject) {
332
+ results = results.filter((a) => a.subject === options.subject)
333
+ }
334
+
335
+ if (options?.object) {
336
+ results = results.filter((a) => a.object === options.object)
337
+ }
338
+
339
+ if (options?.status) {
340
+ const statuses = Array.isArray(options.status) ? options.status : [options.status]
341
+ results = results.filter((a) => statuses.includes(a.status))
342
+ }
343
+
344
+ // Apply limit with safety bounds
345
+ const limit = effectiveLimit(options?.limit)
346
+ results = results.slice(0, limit)
347
+
348
+ return results
349
+ }
350
+
351
+ async deleteAction(id: string): Promise<boolean> {
352
+ return this.actions.delete(id)
353
+ }
354
+
355
+ // ==================== Graph Traversal ====================
356
+
357
+ async related<T>(
358
+ id: string,
359
+ verb?: string,
360
+ direction: Direction = 'out',
361
+ options?: ListOptions
362
+ ): Promise<Thing<T>[]> {
363
+ const validDirection = validateDirection(direction)
364
+ const edgesList = await this.edges(id, verb, validDirection)
365
+ const relatedIds = new Set<string>()
366
+
367
+ for (const edge of edgesList) {
368
+ if (direction === 'out' || direction === 'both') {
369
+ if (edge.subject === id && edge.object) {
370
+ relatedIds.add(edge.object)
371
+ }
372
+ }
373
+ if (direction === 'in' || direction === 'both') {
374
+ if (edge.object === id && edge.subject) {
375
+ relatedIds.add(edge.subject)
376
+ }
377
+ }
378
+ }
379
+
380
+ // Use batch query instead of N individual get() calls (fixes N+1 pattern)
381
+ let results = await this.getMany<T>([...relatedIds])
382
+
383
+ // Apply limit with safety bounds
384
+ const limit = effectiveLimit(options?.limit)
385
+ results = results.slice(0, limit)
386
+
387
+ return results
388
+ }
389
+
390
+ async edges<T>(
391
+ id: string,
392
+ verb?: string,
393
+ direction: Direction = 'out',
394
+ options?: ListOptions
395
+ ): Promise<Action<T>[]> {
396
+ const validDirection = validateDirection(direction)
397
+ let results = Array.from(this.actions.values()) as Action<T>[]
398
+
399
+ if (verb) {
400
+ results = results.filter((a) => a.verb === verb)
401
+ }
402
+
403
+ results = results.filter((a) => {
404
+ if (validDirection === 'out') return a.subject === id
405
+ if (validDirection === 'in') return a.object === id
406
+ return a.subject === id || a.object === id
407
+ })
408
+
409
+ // Apply limit with safety bounds
410
+ const limit = effectiveLimit(options?.limit)
411
+ results = results.slice(0, limit)
412
+
413
+ return results
414
+ }
415
+
416
+ // ==================== Batch Operations ====================
417
+
418
+ async createMany<T>(noun: string, items: T[]): Promise<Thing<T>[]> {
419
+ if (items.length > MAX_BATCH_SIZE) {
420
+ throw new ValidationError(`Batch size ${items.length} exceeds maximum of ${MAX_BATCH_SIZE}`, [
421
+ { field: 'items', message: `Batch size exceeds maximum of ${MAX_BATCH_SIZE}` },
422
+ ])
423
+ }
424
+ return Promise.all(items.map((item) => this.create(noun, item)))
425
+ }
426
+
427
+ async updateMany<T>(updates: Array<{ id: string; data: Partial<T> }>): Promise<Thing<T>[]> {
428
+ if (updates.length > MAX_BATCH_SIZE) {
429
+ throw new ValidationError(
430
+ `Batch size ${updates.length} exceeds maximum of ${MAX_BATCH_SIZE}`,
431
+ [{ field: 'items', message: `Batch size exceeds maximum of ${MAX_BATCH_SIZE}` }]
432
+ )
433
+ }
434
+ return Promise.all(updates.map((u) => this.update(u.id, u.data)))
435
+ }
436
+
437
+ async deleteMany(ids: string[]): Promise<boolean[]> {
438
+ if (ids.length > MAX_BATCH_SIZE) {
439
+ throw new ValidationError(`Batch size ${ids.length} exceeds maximum of ${MAX_BATCH_SIZE}`, [
440
+ { field: 'ids', message: `Batch size exceeds maximum of ${MAX_BATCH_SIZE}` },
441
+ ])
442
+ }
443
+ return Promise.all(ids.map((id) => this.delete(id)))
444
+ }
445
+
446
+ async performMany<T>(
447
+ actions: Array<{ verb: string; subject?: string; object?: string; data?: T }>
448
+ ): Promise<Action<T>[]> {
449
+ if (actions.length > MAX_BATCH_SIZE) {
450
+ throw new ValidationError(
451
+ `Batch size ${actions.length} exceeds maximum of ${MAX_BATCH_SIZE}`,
452
+ [{ field: 'actions', message: `Batch size exceeds maximum of ${MAX_BATCH_SIZE}` }]
453
+ )
454
+ }
455
+ return Promise.all(actions.map((a) => this.perform(a.verb, a.subject, a.object, a.data)))
456
+ }
457
+
458
+ // ==================== Lifecycle ====================
459
+
460
+ async close(): Promise<void> {
461
+ this.nouns.clear()
462
+ this.verbs.clear()
463
+ this.things.clear()
464
+ this.actions.clear()
465
+ }
466
+ }
467
+
468
+ export function createMemoryProvider(): DigitalObjectsProvider {
469
+ return new MemoryProvider()
470
+ }