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.test.ts ADDED
@@ -0,0 +1,1381 @@
1
+ /**
2
+ * NS Durable Object Tests
3
+ *
4
+ * Since we can't easily test actual Durable Objects in vitest,
5
+ * we mock the SqlStorage interface and test the NS class methods directly.
6
+ */
7
+
8
+ import { describe, it, expect, beforeEach, vi, type Mock } from 'vitest'
9
+ import { NS, type Env } from './ns'
10
+
11
+ // Mock data storage for our fake SQLite
12
+ type Row = Record<string, unknown>
13
+
14
+ interface MockSqlStorage {
15
+ exec: Mock<(...args: unknown[]) => { rowsWritten: number } & Iterable<Row>>
16
+ _tables: Map<string, Row[]>
17
+ _lastQuery: string
18
+ _lastParams: unknown[]
19
+ }
20
+
21
+ // Create a mock SqlStorage that simulates SQLite behavior
22
+ const createMockSqlStorage = (): MockSqlStorage => {
23
+ const tables = new Map<string, Row[]>()
24
+ let lastQuery = ''
25
+ let lastParams: unknown[] = []
26
+
27
+ // Initialize tables
28
+ tables.set('nouns', [])
29
+ tables.set('verbs', [])
30
+ tables.set('things', [])
31
+ tables.set('actions', [])
32
+
33
+ const exec = vi.fn((...args: unknown[]) => {
34
+ const sql = args[0] as string
35
+ const params = args.slice(1)
36
+ lastQuery = sql
37
+ lastParams = params
38
+
39
+ // Handle CREATE TABLE / CREATE INDEX (schema initialization)
40
+ if (sql.includes('CREATE TABLE') || sql.includes('CREATE INDEX')) {
41
+ return { rowsWritten: 0, [Symbol.iterator]: () => [][Symbol.iterator]() }
42
+ }
43
+
44
+ // Handle INSERT OR REPLACE INTO nouns
45
+ if (sql.includes('INSERT OR REPLACE INTO nouns')) {
46
+ const row: Row = {
47
+ name: params[0],
48
+ singular: params[1],
49
+ plural: params[2],
50
+ slug: params[3],
51
+ description: params[4],
52
+ schema: params[5],
53
+ created_at: params[6],
54
+ }
55
+ const nouns = tables.get('nouns')!
56
+ const existingIndex = nouns.findIndex((n) => n.name === params[0])
57
+ if (existingIndex >= 0) {
58
+ nouns[existingIndex] = row
59
+ } else {
60
+ nouns.push(row)
61
+ }
62
+ return { rowsWritten: 1, [Symbol.iterator]: () => [][Symbol.iterator]() }
63
+ }
64
+
65
+ // Handle INSERT OR REPLACE INTO verbs
66
+ if (sql.includes('INSERT OR REPLACE INTO verbs')) {
67
+ const row: Row = {
68
+ name: params[0],
69
+ action: params[1],
70
+ act: params[2],
71
+ activity: params[3],
72
+ event: params[4],
73
+ reverse_by: params[5],
74
+ reverse_at: params[6],
75
+ inverse: params[7],
76
+ description: params[8],
77
+ created_at: params[9],
78
+ }
79
+ const verbs = tables.get('verbs')!
80
+ const existingIndex = verbs.findIndex((v) => v.name === params[0])
81
+ if (existingIndex >= 0) {
82
+ verbs[existingIndex] = row
83
+ } else {
84
+ verbs.push(row)
85
+ }
86
+ return { rowsWritten: 1, [Symbol.iterator]: () => [][Symbol.iterator]() }
87
+ }
88
+
89
+ // Handle INSERT INTO things
90
+ if (sql.includes('INSERT INTO things')) {
91
+ const row: Row = {
92
+ id: params[0],
93
+ noun: params[1],
94
+ data: params[2],
95
+ created_at: params[3],
96
+ updated_at: params[4],
97
+ }
98
+ tables.get('things')!.push(row)
99
+ return { rowsWritten: 1, [Symbol.iterator]: () => [][Symbol.iterator]() }
100
+ }
101
+
102
+ // Handle INSERT INTO actions
103
+ if (sql.includes('INSERT INTO actions')) {
104
+ const row: Row = {
105
+ id: params[0],
106
+ verb: params[1],
107
+ subject: params[2],
108
+ object: params[3],
109
+ data: params[4],
110
+ status: params[5],
111
+ created_at: params[6],
112
+ completed_at: params[7],
113
+ }
114
+ tables.get('actions')!.push(row)
115
+ return { rowsWritten: 1, [Symbol.iterator]: () => [][Symbol.iterator]() }
116
+ }
117
+
118
+ // Handle SELECT * FROM nouns WHERE name = ?
119
+ if (sql.includes('SELECT * FROM nouns WHERE name = ?')) {
120
+ const results = tables.get('nouns')!.filter((n) => n.name === params[0])
121
+ return { rowsWritten: 0, [Symbol.iterator]: () => results[Symbol.iterator]() }
122
+ }
123
+
124
+ // Handle SELECT * FROM nouns (list all)
125
+ if (sql === 'SELECT * FROM nouns') {
126
+ const results = tables.get('nouns')!
127
+ return { rowsWritten: 0, [Symbol.iterator]: () => results[Symbol.iterator]() }
128
+ }
129
+
130
+ // Handle SELECT * FROM verbs WHERE name = ?
131
+ if (sql.includes('SELECT * FROM verbs WHERE name = ?')) {
132
+ const results = tables.get('verbs')!.filter((v) => v.name === params[0])
133
+ return { rowsWritten: 0, [Symbol.iterator]: () => results[Symbol.iterator]() }
134
+ }
135
+
136
+ // Handle SELECT * FROM verbs (list all)
137
+ if (sql === 'SELECT * FROM verbs') {
138
+ const results = tables.get('verbs')!
139
+ return { rowsWritten: 0, [Symbol.iterator]: () => results[Symbol.iterator]() }
140
+ }
141
+
142
+ // Handle SELECT * FROM things WHERE id = ?
143
+ if (sql.includes('SELECT * FROM things WHERE id = ?')) {
144
+ const results = tables.get('things')!.filter((t) => t.id === params[0])
145
+ return { rowsWritten: 0, [Symbol.iterator]: () => results[Symbol.iterator]() }
146
+ }
147
+
148
+ // Handle SELECT * FROM things WHERE id IN (?, ?, ...) - batch query for getMany()
149
+ if (sql.includes('SELECT * FROM things WHERE id IN (')) {
150
+ const ids = params as string[]
151
+ const results = tables.get('things')!.filter((t) => ids.includes(t.id as string))
152
+ return { rowsWritten: 0, [Symbol.iterator]: () => results[Symbol.iterator]() }
153
+ }
154
+
155
+ // Handle SELECT * FROM things WHERE noun = ? (with potential json_extract, ORDER BY, LIMIT, OFFSET)
156
+ if (sql.includes('SELECT * FROM things WHERE noun = ?')) {
157
+ let results = tables.get('things')!.filter((t) => t.noun === params[0])
158
+ let paramIndex = 1
159
+
160
+ // Handle json_extract WHERE clauses for filtering
161
+ const jsonExtractMatches = sql.match(/json_extract\(data, '\$\.(\w+)'\) = \?/g)
162
+ if (jsonExtractMatches) {
163
+ for (const match of jsonExtractMatches) {
164
+ const fieldMatch = match.match(/json_extract\(data, '\$\.(\w+)'\)/)
165
+ if (fieldMatch) {
166
+ const field = fieldMatch[1]
167
+ const value = params[paramIndex++]
168
+ results = results.filter((t) => {
169
+ const data = typeof t.data === 'string' ? JSON.parse(t.data as string) : t.data
170
+ return data[field] === value
171
+ })
172
+ }
173
+ }
174
+ }
175
+
176
+ // Handle LIMIT
177
+ const limitMatch = sql.match(/LIMIT \?/)
178
+ if (limitMatch) {
179
+ const limit = params[paramIndex++] as number
180
+ results = results.slice(0, limit)
181
+ }
182
+
183
+ return { rowsWritten: 0, [Symbol.iterator]: () => results[Symbol.iterator]() }
184
+ }
185
+
186
+ // Handle UPDATE things
187
+ if (sql.includes('UPDATE things SET data = ?')) {
188
+ const things = tables.get('things')!
189
+ const idx = things.findIndex((t) => t.id === params[2])
190
+ if (idx >= 0) {
191
+ things[idx].data = params[0]
192
+ things[idx].updated_at = params[1]
193
+ }
194
+ return { rowsWritten: idx >= 0 ? 1 : 0, [Symbol.iterator]: () => [][Symbol.iterator]() }
195
+ }
196
+
197
+ // Handle DELETE FROM things
198
+ if (sql.includes('DELETE FROM things WHERE id = ?')) {
199
+ const things = tables.get('things')!
200
+ const idx = things.findIndex((t) => t.id === params[0])
201
+ if (idx >= 0) {
202
+ things.splice(idx, 1)
203
+ return { rowsWritten: 1, [Symbol.iterator]: () => [][Symbol.iterator]() }
204
+ }
205
+ return { rowsWritten: 0, [Symbol.iterator]: () => [][Symbol.iterator]() }
206
+ }
207
+
208
+ // Handle search query
209
+ if (sql.includes('WHERE LOWER(data) LIKE ?')) {
210
+ const query = (params[0] as string).replace(/%/g, '').toLowerCase()
211
+ let results = tables.get('things')!.filter((t) => {
212
+ const data = (t.data as string).toLowerCase()
213
+ return data.includes(query)
214
+ })
215
+
216
+ // Handle LIMIT for search
217
+ if (params.length > 1) {
218
+ results = results.slice(0, params[1] as number)
219
+ }
220
+
221
+ return { rowsWritten: 0, [Symbol.iterator]: () => results[Symbol.iterator]() }
222
+ }
223
+
224
+ // Handle SELECT * FROM actions WHERE id = ?
225
+ if (sql.includes('SELECT * FROM actions WHERE id = ?')) {
226
+ const results = tables.get('actions')!.filter((a) => a.id === params[0])
227
+ return { rowsWritten: 0, [Symbol.iterator]: () => results[Symbol.iterator]() }
228
+ }
229
+
230
+ // Handle SELECT * FROM actions WHERE 1=1 (with filters)
231
+ if (sql.includes('SELECT * FROM actions WHERE 1=1')) {
232
+ let results = [...tables.get('actions')!]
233
+
234
+ // Parse and apply filters
235
+ let paramIndex = 0
236
+ if (sql.includes('AND verb = ?')) {
237
+ results = results.filter((a) => a.verb === params[paramIndex++])
238
+ }
239
+ if (sql.includes('AND subject = ?')) {
240
+ results = results.filter((a) => a.subject === params[paramIndex++])
241
+ }
242
+ if (sql.includes('AND object = ?')) {
243
+ results = results.filter((a) => a.object === params[paramIndex++])
244
+ }
245
+ if (sql.includes('AND status IN')) {
246
+ const statusCount = (sql.match(/\?/g) || []).length - paramIndex
247
+ const statuses = params.slice(paramIndex, paramIndex + statusCount)
248
+ paramIndex += statusCount
249
+ results = results.filter((a) => statuses.includes(a.status))
250
+ }
251
+ if (sql.includes('LIMIT ?')) {
252
+ const limit = params[paramIndex] as number
253
+ results = results.slice(0, limit)
254
+ }
255
+
256
+ return { rowsWritten: 0, [Symbol.iterator]: () => results[Symbol.iterator]() }
257
+ }
258
+
259
+ // Handle SELECT * FROM actions WHERE subject = ? (edges out)
260
+ if (sql.includes('SELECT * FROM actions WHERE subject = ?') && !sql.includes('OR object')) {
261
+ let results = tables.get('actions')!.filter((a) => a.subject === params[0])
262
+ if (sql.includes('AND verb = ?') && params.length > 1) {
263
+ results = results.filter((a) => a.verb === params[1])
264
+ }
265
+ return { rowsWritten: 0, [Symbol.iterator]: () => results[Symbol.iterator]() }
266
+ }
267
+
268
+ // Handle SELECT * FROM actions WHERE object = ? (edges in)
269
+ if (sql.includes('SELECT * FROM actions WHERE object = ?') && !sql.includes('OR')) {
270
+ let results = tables.get('actions')!.filter((a) => a.object === params[0])
271
+ if (sql.includes('AND verb = ?') && params.length > 1) {
272
+ results = results.filter((a) => a.verb === params[1])
273
+ }
274
+ return { rowsWritten: 0, [Symbol.iterator]: () => results[Symbol.iterator]() }
275
+ }
276
+
277
+ // Handle SELECT * FROM actions WHERE subject = ? OR object = ? (edges both)
278
+ if (sql.includes('SELECT * FROM actions WHERE subject = ? OR object = ?')) {
279
+ let results = tables
280
+ .get('actions')!
281
+ .filter((a) => a.subject === params[0] || a.object === params[1])
282
+ if (sql.includes('AND verb = ?') && params.length > 2) {
283
+ results = results.filter((a) => a.verb === params[2])
284
+ }
285
+ return { rowsWritten: 0, [Symbol.iterator]: () => results[Symbol.iterator]() }
286
+ }
287
+
288
+ // Default: return empty
289
+ return { rowsWritten: 0, [Symbol.iterator]: () => [][Symbol.iterator]() }
290
+ })
291
+
292
+ return {
293
+ exec,
294
+ _tables: tables,
295
+ _lastQuery: lastQuery,
296
+ _lastParams: lastParams,
297
+ }
298
+ }
299
+
300
+ // Create mock DurableObjectState
301
+ const createMockState = (mockSql: MockSqlStorage) => ({
302
+ storage: {
303
+ sql: mockSql,
304
+ },
305
+ })
306
+
307
+ // Create mock Env
308
+ const createMockEnv = (): Env => ({
309
+ NS: {
310
+ idFromName: vi.fn(),
311
+ get: vi.fn(),
312
+ } as unknown as DurableObjectNamespace,
313
+ })
314
+
315
+ describe('NS Durable Object', () => {
316
+ let ns: NS
317
+ let mockSql: MockSqlStorage
318
+ let mockEnv: Env
319
+
320
+ beforeEach(() => {
321
+ mockSql = createMockSqlStorage()
322
+ const mockState = createMockState(mockSql)
323
+ mockEnv = createMockEnv()
324
+ ns = new NS(mockState as unknown as DurableObjectState, mockEnv)
325
+ })
326
+
327
+ describe('Schema Initialization', () => {
328
+ it('should create all required tables on first operation', async () => {
329
+ // Trigger initialization by calling any method
330
+ await ns.listNouns()
331
+
332
+ // Check that CREATE TABLE statements were executed
333
+ const calls = mockSql.exec.mock.calls
334
+ const createTableCall = calls.find((call) => {
335
+ const sql = call[0] as string
336
+ return sql.includes('CREATE TABLE')
337
+ })
338
+
339
+ expect(createTableCall).toBeDefined()
340
+ const sql = createTableCall![0] as string
341
+
342
+ // Verify all tables are created
343
+ expect(sql).toContain('CREATE TABLE IF NOT EXISTS nouns')
344
+ expect(sql).toContain('CREATE TABLE IF NOT EXISTS verbs')
345
+ expect(sql).toContain('CREATE TABLE IF NOT EXISTS things')
346
+ expect(sql).toContain('CREATE TABLE IF NOT EXISTS actions')
347
+ })
348
+
349
+ it('should create indexes on things and actions tables', async () => {
350
+ await ns.listNouns()
351
+
352
+ const calls = mockSql.exec.mock.calls
353
+ const createTableCall = calls.find((call) => {
354
+ const sql = call[0] as string
355
+ return sql.includes('CREATE INDEX')
356
+ })
357
+
358
+ expect(createTableCall).toBeDefined()
359
+ const sql = createTableCall![0] as string
360
+
361
+ expect(sql).toContain('CREATE INDEX IF NOT EXISTS idx_things_noun')
362
+ expect(sql).toContain('CREATE INDEX IF NOT EXISTS idx_actions_verb')
363
+ expect(sql).toContain('CREATE INDEX IF NOT EXISTS idx_actions_subject')
364
+ expect(sql).toContain('CREATE INDEX IF NOT EXISTS idx_actions_object')
365
+ })
366
+
367
+ it('should only initialize once (memoization)', async () => {
368
+ await ns.listNouns()
369
+ await ns.listNouns()
370
+ await ns.listNouns()
371
+
372
+ // Count CREATE TABLE calls
373
+ const createCalls = mockSql.exec.mock.calls.filter((call) => {
374
+ const sql = call[0] as string
375
+ return sql.includes('CREATE TABLE IF NOT EXISTS nouns')
376
+ })
377
+
378
+ expect(createCalls.length).toBe(1)
379
+ })
380
+ })
381
+
382
+ describe('Noun Operations', () => {
383
+ it('should define a noun with auto-derived forms', async () => {
384
+ const noun = await ns.defineNoun({ name: 'Post' })
385
+
386
+ expect(noun.name).toBe('Post')
387
+ expect(noun.singular).toBe('post')
388
+ expect(noun.plural).toBe('posts')
389
+ expect(noun.slug).toBe('post')
390
+ expect(noun.createdAt).toBeInstanceOf(Date)
391
+ })
392
+
393
+ it('should define a noun with explicit forms', async () => {
394
+ const noun = await ns.defineNoun({
395
+ name: 'Person',
396
+ singular: 'person',
397
+ plural: 'people',
398
+ description: 'A human being',
399
+ })
400
+
401
+ expect(noun.singular).toBe('person')
402
+ expect(noun.plural).toBe('people')
403
+ expect(noun.description).toBe('A human being')
404
+ })
405
+
406
+ it('should define a noun with schema', async () => {
407
+ const schema = { title: 'string', body: 'markdown' as const }
408
+ const noun = await ns.defineNoun({
409
+ name: 'Article',
410
+ schema,
411
+ })
412
+
413
+ expect(noun.schema).toEqual(schema)
414
+ })
415
+
416
+ it('should get a noun by name', async () => {
417
+ await ns.defineNoun({ name: 'Author' })
418
+ const noun = await ns.getNoun('Author')
419
+
420
+ expect(noun).not.toBeNull()
421
+ expect(noun!.name).toBe('Author')
422
+ expect(noun!.singular).toBe('author')
423
+ })
424
+
425
+ it('should return null for unknown noun', async () => {
426
+ const noun = await ns.getNoun('NonExistent')
427
+ expect(noun).toBeNull()
428
+ })
429
+
430
+ it('should list all nouns', async () => {
431
+ await ns.defineNoun({ name: 'Post' })
432
+ await ns.defineNoun({ name: 'Author' })
433
+
434
+ const nouns = await ns.listNouns()
435
+ expect(nouns).toHaveLength(2)
436
+ expect(nouns.map((n) => n.name)).toContain('Post')
437
+ expect(nouns.map((n) => n.name)).toContain('Author')
438
+ })
439
+
440
+ it('should handle upsert (INSERT OR REPLACE)', async () => {
441
+ await ns.defineNoun({ name: 'Post', description: 'Original' })
442
+ await ns.defineNoun({ name: 'Post', description: 'Updated' })
443
+
444
+ const noun = await ns.getNoun('Post')
445
+ expect(noun!.description).toBe('Updated')
446
+
447
+ const nouns = await ns.listNouns()
448
+ expect(nouns).toHaveLength(1)
449
+ })
450
+ })
451
+
452
+ describe('Verb Operations', () => {
453
+ it('should define a verb with auto-derived conjugations', async () => {
454
+ const verb = await ns.defineVerb({ name: 'create' })
455
+
456
+ expect(verb.name).toBe('create')
457
+ expect(verb.action).toBe('create')
458
+ expect(verb.act).toBe('creates')
459
+ expect(verb.activity).toBe('creating')
460
+ expect(verb.event).toBe('created')
461
+ expect(verb.reverseBy).toBe('createdBy')
462
+ expect(verb.reverseAt).toBe('createdAt')
463
+ })
464
+
465
+ it('should define a verb with explicit conjugations', async () => {
466
+ const verb = await ns.defineVerb({
467
+ name: 'write',
468
+ action: 'write',
469
+ act: 'writes',
470
+ activity: 'writing',
471
+ event: 'written',
472
+ })
473
+
474
+ expect(verb.event).toBe('written')
475
+ })
476
+
477
+ it('should define a verb with inverse', async () => {
478
+ const verb = await ns.defineVerb({
479
+ name: 'publish',
480
+ inverse: 'unpublish',
481
+ description: 'Make content public',
482
+ })
483
+
484
+ expect(verb.inverse).toBe('unpublish')
485
+ expect(verb.description).toBe('Make content public')
486
+ })
487
+
488
+ it('should get a verb by name', async () => {
489
+ await ns.defineVerb({ name: 'like' })
490
+ const verb = await ns.getVerb('like')
491
+
492
+ expect(verb).not.toBeNull()
493
+ expect(verb!.name).toBe('like')
494
+ expect(verb!.activity).toBe('liking')
495
+ })
496
+
497
+ it('should return null for unknown verb', async () => {
498
+ const verb = await ns.getVerb('nonexistent')
499
+ expect(verb).toBeNull()
500
+ })
501
+
502
+ it('should list all verbs', async () => {
503
+ await ns.defineVerb({ name: 'create' })
504
+ await ns.defineVerb({ name: 'update' })
505
+ await ns.defineVerb({ name: 'delete' })
506
+
507
+ const verbs = await ns.listVerbs()
508
+ expect(verbs).toHaveLength(3)
509
+ })
510
+ })
511
+
512
+ describe('Thing Operations', () => {
513
+ it('should create a thing with auto-generated ID', async () => {
514
+ const thing = await ns.create('Post', { title: 'Hello World' })
515
+
516
+ expect(thing.id).toBeDefined()
517
+ expect(thing.id.length).toBeGreaterThan(0)
518
+ expect(thing.noun).toBe('Post')
519
+ expect(thing.data).toEqual({ title: 'Hello World' })
520
+ expect(thing.createdAt).toBeInstanceOf(Date)
521
+ expect(thing.updatedAt).toBeInstanceOf(Date)
522
+ })
523
+
524
+ it('should create a thing with custom ID', async () => {
525
+ const thing = await ns.create('Post', { title: 'Custom' }, 'my-custom-id')
526
+ expect(thing.id).toBe('my-custom-id')
527
+ })
528
+
529
+ it('should get a thing by ID', async () => {
530
+ const created = await ns.create('Post', { title: 'Test', body: 'Content' })
531
+ const retrieved = await ns.get(created.id)
532
+
533
+ expect(retrieved).not.toBeNull()
534
+ expect(retrieved!.id).toBe(created.id)
535
+ expect(retrieved!.data).toEqual({ title: 'Test', body: 'Content' })
536
+ })
537
+
538
+ it('should return null for unknown thing', async () => {
539
+ const thing = await ns.get('nonexistent-id')
540
+ expect(thing).toBeNull()
541
+ })
542
+
543
+ it('should list things by noun', async () => {
544
+ await ns.create('Post', { title: 'First' })
545
+ await ns.create('Post', { title: 'Second' })
546
+ await ns.create('Author', { name: 'Alice' })
547
+
548
+ const posts = await ns.list('Post')
549
+ expect(posts).toHaveLength(2)
550
+ expect(posts.every((p) => p.noun === 'Post')).toBe(true)
551
+ })
552
+
553
+ it('should list things with limit option', async () => {
554
+ await ns.create('Post', { title: 'First' })
555
+ await ns.create('Post', { title: 'Second' })
556
+ await ns.create('Post', { title: 'Third' })
557
+
558
+ const posts = await ns.list('Post', { limit: 2 })
559
+ expect(posts).toHaveLength(2)
560
+ })
561
+
562
+ it('should update a thing', async () => {
563
+ const created = await ns.create('Post', { title: 'Original', status: 'draft' })
564
+ const updated = await ns.update(created.id, { title: 'Updated' })
565
+
566
+ expect(updated.data).toEqual({ title: 'Updated', status: 'draft' })
567
+ expect(updated.updatedAt.getTime()).toBeGreaterThanOrEqual(created.createdAt.getTime())
568
+ })
569
+
570
+ it('should throw error when updating non-existent thing', async () => {
571
+ await expect(ns.update('nonexistent', { title: 'Test' })).rejects.toThrow(
572
+ 'Thing not found: nonexistent'
573
+ )
574
+ })
575
+
576
+ it('should delete a thing', async () => {
577
+ const created = await ns.create('Post', { title: 'ToDelete' })
578
+ const deleted = await ns.delete(created.id)
579
+
580
+ expect(deleted).toBe(true)
581
+ })
582
+
583
+ it('should return false when deleting non-existent thing', async () => {
584
+ const deleted = await ns.delete('nonexistent-id')
585
+ expect(deleted).toBe(false)
586
+ })
587
+
588
+ it('should find things by criteria', async () => {
589
+ await ns.create('Post', { title: 'Draft 1', status: 'draft' })
590
+ await ns.create('Post', { title: 'Published', status: 'published' })
591
+ await ns.create('Post', { title: 'Draft 2', status: 'draft' })
592
+
593
+ const drafts = await ns.find<{ title: string; status: string }>('Post', { status: 'draft' })
594
+ expect(drafts).toHaveLength(2)
595
+ expect(drafts.every((p) => p.data.status === 'draft')).toBe(true)
596
+ })
597
+
598
+ it('should search things by query', async () => {
599
+ await ns.create('Post', { title: 'Hello World', body: 'This is a test' })
600
+ await ns.create('Post', { title: 'Goodbye World', body: 'Another post' })
601
+
602
+ const results = await ns.search('hello')
603
+ expect(results).toHaveLength(1)
604
+ expect(results[0].data.title).toBe('Hello World')
605
+ })
606
+
607
+ it('should search with limit', async () => {
608
+ await ns.create('Post', { title: 'Test 1', body: 'testing' })
609
+ await ns.create('Post', { title: 'Test 2', body: 'testing again' })
610
+ await ns.create('Post', { title: 'Test 3', body: 'testing more' })
611
+
612
+ const results = await ns.search('test', { limit: 2 })
613
+ expect(results).toHaveLength(2)
614
+ })
615
+ })
616
+
617
+ describe('Action Operations', () => {
618
+ it('should perform an action with subject and object', async () => {
619
+ const author = await ns.create('Author', { name: 'Alice' })
620
+ const post = await ns.create('Post', { title: 'My Post' })
621
+
622
+ const action = await ns.perform('write', author.id, post.id)
623
+
624
+ expect(action.id).toBeDefined()
625
+ expect(action.verb).toBe('write')
626
+ expect(action.subject).toBe(author.id)
627
+ expect(action.object).toBe(post.id)
628
+ expect(action.status).toBe('completed')
629
+ expect(action.createdAt).toBeInstanceOf(Date)
630
+ expect(action.completedAt).toBeInstanceOf(Date)
631
+ })
632
+
633
+ it('should perform an action with data payload', async () => {
634
+ const post = await ns.create('Post', { title: 'Draft' })
635
+
636
+ const action = await ns.perform('publish', undefined, post.id, {
637
+ publishedBy: 'admin',
638
+ publishedAt: '2024-01-15',
639
+ })
640
+
641
+ expect(action.data).toEqual({
642
+ publishedBy: 'admin',
643
+ publishedAt: '2024-01-15',
644
+ })
645
+ })
646
+
647
+ it('should perform an action with only subject', async () => {
648
+ const user = await ns.create('User', { name: 'Bob' })
649
+
650
+ const action = await ns.perform('login', user.id)
651
+
652
+ expect(action.subject).toBe(user.id)
653
+ expect(action.object).toBeUndefined()
654
+ })
655
+
656
+ it('should perform an action with only object', async () => {
657
+ const resource = await ns.create('Resource', { name: 'Document' })
658
+
659
+ const action = await ns.perform('view', undefined, resource.id)
660
+
661
+ expect(action.subject).toBeUndefined()
662
+ expect(action.object).toBe(resource.id)
663
+ })
664
+
665
+ it('should get an action by ID', async () => {
666
+ const created = await ns.perform('test', 'subject-1', 'object-1', { key: 'value' })
667
+ const retrieved = await ns.getAction(created.id)
668
+
669
+ expect(retrieved).not.toBeNull()
670
+ expect(retrieved!.id).toBe(created.id)
671
+ expect(retrieved!.verb).toBe('test')
672
+ expect(retrieved!.data).toEqual({ key: 'value' })
673
+ })
674
+
675
+ it('should return null for unknown action', async () => {
676
+ const action = await ns.getAction('nonexistent')
677
+ expect(action).toBeNull()
678
+ })
679
+
680
+ it('should list actions with verb filter', async () => {
681
+ await ns.perform('write', 'a1', 'p1')
682
+ await ns.perform('write', 'a2', 'p2')
683
+ await ns.perform('publish', undefined, 'p1')
684
+
685
+ const writeActions = await ns.listActions({ verb: 'write' })
686
+ expect(writeActions.length).toBeGreaterThanOrEqual(1)
687
+ expect(writeActions.every((a) => a.verb === 'write')).toBe(true)
688
+ })
689
+
690
+ it('should list actions with subject filter', async () => {
691
+ await ns.perform('write', 'author-1', 'post-1')
692
+ await ns.perform('write', 'author-1', 'post-2')
693
+ await ns.perform('write', 'author-2', 'post-3')
694
+
695
+ const authorActions = await ns.listActions({ subject: 'author-1' })
696
+ expect(authorActions.length).toBeGreaterThanOrEqual(1)
697
+ expect(authorActions.every((a) => a.subject === 'author-1')).toBe(true)
698
+ })
699
+
700
+ it('should list actions with object filter', async () => {
701
+ await ns.perform('like', 'user-1', 'post-1')
702
+ await ns.perform('like', 'user-2', 'post-1')
703
+ await ns.perform('like', 'user-3', 'post-2')
704
+
705
+ const postActions = await ns.listActions({ object: 'post-1' })
706
+ expect(postActions.length).toBeGreaterThanOrEqual(1)
707
+ expect(postActions.every((a) => a.object === 'post-1')).toBe(true)
708
+ })
709
+
710
+ it('should list actions with limit', async () => {
711
+ await ns.perform('action', 's1', 'o1')
712
+ await ns.perform('action', 's2', 'o2')
713
+ await ns.perform('action', 's3', 'o3')
714
+
715
+ const actions = await ns.listActions({ limit: 2 })
716
+ expect(actions).toHaveLength(2)
717
+ })
718
+ })
719
+
720
+ describe('Graph Traversal', () => {
721
+ beforeEach(async () => {
722
+ // Set up a small graph
723
+ await ns.create('Author', { name: 'Alice' }, 'alice')
724
+ await ns.create('Author', { name: 'Bob' }, 'bob')
725
+ await ns.create('Post', { title: 'Post 1' }, 'post-1')
726
+ await ns.create('Post', { title: 'Post 2' }, 'post-2')
727
+ await ns.create('Post', { title: 'Post 3' }, 'post-3')
728
+
729
+ // Alice writes post-1 and post-2
730
+ await ns.perform('write', 'alice', 'post-1')
731
+ await ns.perform('write', 'alice', 'post-2')
732
+
733
+ // Bob writes post-3
734
+ await ns.perform('write', 'bob', 'post-3')
735
+
736
+ // Bob likes post-1
737
+ await ns.perform('like', 'bob', 'post-1')
738
+ })
739
+
740
+ describe('edges()', () => {
741
+ it('should get outbound edges for a thing', async () => {
742
+ const edges = await ns.edges('alice', undefined, 'out')
743
+
744
+ expect(edges).toHaveLength(2)
745
+ expect(edges.every((e) => e.subject === 'alice')).toBe(true)
746
+ })
747
+
748
+ it('should get inbound edges for a thing', async () => {
749
+ const edges = await ns.edges('post-1', undefined, 'in')
750
+
751
+ expect(edges).toHaveLength(2) // written by alice, liked by bob
752
+ expect(edges.every((e) => e.object === 'post-1')).toBe(true)
753
+ })
754
+
755
+ it('should get both directions edges', async () => {
756
+ const edges = await ns.edges('bob', undefined, 'both')
757
+
758
+ // Bob has: write (out), like (out)
759
+ expect(edges.length).toBeGreaterThanOrEqual(2)
760
+ })
761
+
762
+ it('should filter edges by verb', async () => {
763
+ const edges = await ns.edges('bob', 'like', 'out')
764
+
765
+ expect(edges).toHaveLength(1)
766
+ expect(edges[0].verb).toBe('like')
767
+ expect(edges[0].object).toBe('post-1')
768
+ })
769
+ })
770
+
771
+ describe('related()', () => {
772
+ it('should get related things via outbound edges', async () => {
773
+ const posts = await ns.related('alice', 'write', 'out')
774
+
775
+ expect(posts).toHaveLength(2)
776
+ expect(posts.map((p) => p.id)).toContain('post-1')
777
+ expect(posts.map((p) => p.id)).toContain('post-2')
778
+ })
779
+
780
+ it('should get related things via inbound edges', async () => {
781
+ const authors = await ns.related('post-1', 'write', 'in')
782
+
783
+ expect(authors).toHaveLength(1)
784
+ expect(authors[0].id).toBe('alice')
785
+ })
786
+
787
+ it('should get related things in both directions', async () => {
788
+ const related = await ns.related('bob', undefined, 'both')
789
+
790
+ // Bob -> post-3 (write), Bob -> post-1 (like)
791
+ expect(related.length).toBeGreaterThanOrEqual(2)
792
+ })
793
+
794
+ it('should return empty array when no relations', async () => {
795
+ const related = await ns.related('post-3', 'like', 'in')
796
+ expect(related).toHaveLength(0)
797
+ })
798
+ })
799
+ })
800
+
801
+ describe('HTTP Request Handling', () => {
802
+ // Helper to create mock requests
803
+ const createRequest = (
804
+ method: string,
805
+ path: string,
806
+ body?: unknown,
807
+ searchParams?: Record<string, string>
808
+ ) => {
809
+ let url = `https://example.com${path}`
810
+ if (searchParams) {
811
+ const params = new URLSearchParams(searchParams)
812
+ url += `?${params.toString()}`
813
+ }
814
+ return new Request(url, {
815
+ method,
816
+ headers: body ? { 'Content-Type': 'application/json' } : {},
817
+ body: body ? JSON.stringify(body) : undefined,
818
+ })
819
+ }
820
+
821
+ describe('Noun Routes', () => {
822
+ it('POST /nouns should define a noun', async () => {
823
+ const request = createRequest('POST', '/nouns', { name: 'Post' })
824
+ const response = await ns.fetch(request)
825
+
826
+ expect(response.status).toBe(200)
827
+ const noun = await response.json()
828
+ expect(noun.name).toBe('Post')
829
+ })
830
+
831
+ it('GET /nouns should list all nouns', async () => {
832
+ await ns.defineNoun({ name: 'Post' })
833
+ await ns.defineNoun({ name: 'Author' })
834
+
835
+ const request = createRequest('GET', '/nouns')
836
+ const response = await ns.fetch(request)
837
+
838
+ expect(response.status).toBe(200)
839
+ const nouns = await response.json()
840
+ expect(nouns).toHaveLength(2)
841
+ })
842
+
843
+ it('GET /nouns/:name should get a specific noun', async () => {
844
+ await ns.defineNoun({ name: 'Post' })
845
+
846
+ const request = createRequest('GET', '/nouns/Post')
847
+ const response = await ns.fetch(request)
848
+
849
+ expect(response.status).toBe(200)
850
+ const noun = await response.json()
851
+ expect(noun.name).toBe('Post')
852
+ })
853
+
854
+ it('GET /nouns/:name should return 404 for unknown noun', async () => {
855
+ const request = createRequest('GET', '/nouns/Unknown')
856
+ const response = await ns.fetch(request)
857
+
858
+ expect(response.status).toBe(404)
859
+ })
860
+
861
+ it('GET /nouns/:name should handle URL-encoded names', async () => {
862
+ await ns.defineNoun({ name: 'Blog Post' })
863
+
864
+ const request = createRequest('GET', '/nouns/Blog%20Post')
865
+ const response = await ns.fetch(request)
866
+
867
+ expect(response.status).toBe(200)
868
+ const noun = await response.json()
869
+ expect(noun.name).toBe('Blog Post')
870
+ })
871
+ })
872
+
873
+ describe('Verb Routes', () => {
874
+ it('POST /verbs should define a verb', async () => {
875
+ const request = createRequest('POST', '/verbs', { name: 'create' })
876
+ const response = await ns.fetch(request)
877
+
878
+ expect(response.status).toBe(200)
879
+ const verb = await response.json()
880
+ expect(verb.name).toBe('create')
881
+ expect(verb.event).toBe('created')
882
+ })
883
+
884
+ it('GET /verbs should list all verbs', async () => {
885
+ await ns.defineVerb({ name: 'create' })
886
+ await ns.defineVerb({ name: 'update' })
887
+
888
+ const request = createRequest('GET', '/verbs')
889
+ const response = await ns.fetch(request)
890
+
891
+ expect(response.status).toBe(200)
892
+ const verbs = await response.json()
893
+ expect(verbs).toHaveLength(2)
894
+ })
895
+
896
+ it('GET /verbs/:name should get a specific verb', async () => {
897
+ await ns.defineVerb({ name: 'publish' })
898
+
899
+ const request = createRequest('GET', '/verbs/publish')
900
+ const response = await ns.fetch(request)
901
+
902
+ expect(response.status).toBe(200)
903
+ const verb = await response.json()
904
+ expect(verb.name).toBe('publish')
905
+ })
906
+
907
+ it('GET /verbs/:name should return 404 for unknown verb', async () => {
908
+ const request = createRequest('GET', '/verbs/unknown')
909
+ const response = await ns.fetch(request)
910
+
911
+ expect(response.status).toBe(404)
912
+ })
913
+ })
914
+
915
+ describe('Thing Routes', () => {
916
+ it('POST /things should create a thing', async () => {
917
+ const request = createRequest('POST', '/things', {
918
+ noun: 'Post',
919
+ data: { title: 'Hello' },
920
+ })
921
+ const response = await ns.fetch(request)
922
+
923
+ expect(response.status).toBe(200)
924
+ const thing = await response.json()
925
+ expect(thing.noun).toBe('Post')
926
+ expect(thing.data.title).toBe('Hello')
927
+ })
928
+
929
+ it('POST /things should support custom ID', async () => {
930
+ const request = createRequest('POST', '/things', {
931
+ noun: 'Post',
932
+ data: { title: 'Custom' },
933
+ id: 'my-id',
934
+ })
935
+ const response = await ns.fetch(request)
936
+
937
+ expect(response.status).toBe(200)
938
+ const thing = await response.json()
939
+ expect(thing.id).toBe('my-id')
940
+ })
941
+
942
+ it('GET /things/:id should get a thing', async () => {
943
+ const created = await ns.create('Post', { title: 'Test' })
944
+
945
+ const request = createRequest('GET', `/things/${created.id}`)
946
+ const response = await ns.fetch(request)
947
+
948
+ expect(response.status).toBe(200)
949
+ const thing = await response.json()
950
+ expect(thing.id).toBe(created.id)
951
+ })
952
+
953
+ it('GET /things/:id should return 404 for unknown thing', async () => {
954
+ const request = createRequest('GET', '/things/nonexistent')
955
+ const response = await ns.fetch(request)
956
+
957
+ expect(response.status).toBe(404)
958
+ })
959
+
960
+ it('GET /things should list things by noun', async () => {
961
+ await ns.create('Post', { title: 'First' })
962
+ await ns.create('Post', { title: 'Second' })
963
+
964
+ const request = createRequest('GET', '/things', undefined, { noun: 'Post' })
965
+ const response = await ns.fetch(request)
966
+
967
+ expect(response.status).toBe(200)
968
+ const things = await response.json()
969
+ expect(things).toHaveLength(2)
970
+ })
971
+
972
+ it('GET /things without noun should return 400', async () => {
973
+ const request = createRequest('GET', '/things')
974
+ const response = await ns.fetch(request)
975
+
976
+ expect(response.status).toBe(400)
977
+ expect(await response.text()).toBe('noun parameter required')
978
+ })
979
+
980
+ it('GET /things should support pagination params', async () => {
981
+ await ns.create('Post', { title: '1' })
982
+ await ns.create('Post', { title: '2' })
983
+ await ns.create('Post', { title: '3' })
984
+
985
+ const request = createRequest('GET', '/things', undefined, {
986
+ noun: 'Post',
987
+ limit: '2',
988
+ })
989
+ const response = await ns.fetch(request)
990
+
991
+ expect(response.status).toBe(200)
992
+ const things = await response.json()
993
+ expect(things).toHaveLength(2)
994
+ })
995
+
996
+ it('PATCH /things/:id should update a thing', async () => {
997
+ const created = await ns.create('Post', { title: 'Original' })
998
+
999
+ const request = createRequest('PATCH', `/things/${created.id}`, {
1000
+ data: { title: 'Updated' },
1001
+ })
1002
+ const response = await ns.fetch(request)
1003
+
1004
+ expect(response.status).toBe(200)
1005
+ const thing = await response.json()
1006
+ expect(thing.data.title).toBe('Updated')
1007
+ })
1008
+
1009
+ it('DELETE /things/:id should delete a thing', async () => {
1010
+ const created = await ns.create('Post', { title: 'ToDelete' })
1011
+
1012
+ const request = createRequest('DELETE', `/things/${created.id}`)
1013
+ const response = await ns.fetch(request)
1014
+
1015
+ expect(response.status).toBe(200)
1016
+ const result = await response.json()
1017
+ expect(result.deleted).toBe(true)
1018
+ })
1019
+ })
1020
+
1021
+ describe('Search Route', () => {
1022
+ it('GET /search should search things', async () => {
1023
+ await ns.create('Post', { title: 'Hello World' })
1024
+ await ns.create('Post', { title: 'Goodbye' })
1025
+
1026
+ const request = createRequest('GET', '/search', undefined, { q: 'hello' })
1027
+ const response = await ns.fetch(request)
1028
+
1029
+ expect(response.status).toBe(200)
1030
+ const results = await response.json()
1031
+ expect(results).toHaveLength(1)
1032
+ })
1033
+
1034
+ it('GET /search should support limit', async () => {
1035
+ await ns.create('Post', { title: 'Test 1' })
1036
+ await ns.create('Post', { title: 'Test 2' })
1037
+ await ns.create('Post', { title: 'Test 3' })
1038
+
1039
+ const request = createRequest('GET', '/search', undefined, { q: 'test', limit: '2' })
1040
+ const response = await ns.fetch(request)
1041
+
1042
+ expect(response.status).toBe(200)
1043
+ const results = await response.json()
1044
+ expect(results).toHaveLength(2)
1045
+ })
1046
+ })
1047
+
1048
+ describe('Action Routes', () => {
1049
+ it('POST /actions should perform an action', async () => {
1050
+ const request = createRequest('POST', '/actions', {
1051
+ verb: 'like',
1052
+ subject: 'user-1',
1053
+ object: 'post-1',
1054
+ })
1055
+ const response = await ns.fetch(request)
1056
+
1057
+ expect(response.status).toBe(200)
1058
+ const action = await response.json()
1059
+ expect(action.verb).toBe('like')
1060
+ expect(action.status).toBe('completed')
1061
+ })
1062
+
1063
+ it('POST /actions should support data payload', async () => {
1064
+ const request = createRequest('POST', '/actions', {
1065
+ verb: 'rate',
1066
+ subject: 'user-1',
1067
+ object: 'product-1',
1068
+ data: { stars: 5, comment: 'Great!' },
1069
+ })
1070
+ const response = await ns.fetch(request)
1071
+
1072
+ expect(response.status).toBe(200)
1073
+ const action = await response.json()
1074
+ expect(action.data.stars).toBe(5)
1075
+ })
1076
+
1077
+ it('GET /actions/:id should get an action', async () => {
1078
+ const created = await ns.perform('test', 'a', 'b')
1079
+
1080
+ const request = createRequest('GET', `/actions/${created.id}`)
1081
+ const response = await ns.fetch(request)
1082
+
1083
+ expect(response.status).toBe(200)
1084
+ const action = await response.json()
1085
+ expect(action.id).toBe(created.id)
1086
+ })
1087
+
1088
+ it('GET /actions/:id should return 404 for unknown action', async () => {
1089
+ const request = createRequest('GET', '/actions/nonexistent')
1090
+ const response = await ns.fetch(request)
1091
+
1092
+ expect(response.status).toBe(404)
1093
+ })
1094
+
1095
+ it('GET /actions should list actions with filters', async () => {
1096
+ await ns.perform('write', 'a1', 'p1')
1097
+ await ns.perform('like', 'u1', 'p1')
1098
+
1099
+ const request = createRequest('GET', '/actions', undefined, { verb: 'write' })
1100
+ const response = await ns.fetch(request)
1101
+
1102
+ expect(response.status).toBe(200)
1103
+ const actions = await response.json()
1104
+ expect(actions).toHaveLength(1)
1105
+ expect(actions[0].verb).toBe('write')
1106
+ })
1107
+ })
1108
+
1109
+ describe('Graph Routes', () => {
1110
+ beforeEach(async () => {
1111
+ await ns.create('Author', { name: 'Alice' }, 'alice')
1112
+ await ns.create('Post', { title: 'Post 1' }, 'post-1')
1113
+ await ns.perform('write', 'alice', 'post-1')
1114
+ })
1115
+
1116
+ it('GET /edges/:id should get edges', async () => {
1117
+ const request = createRequest('GET', '/edges/alice')
1118
+ const response = await ns.fetch(request)
1119
+
1120
+ expect(response.status).toBe(200)
1121
+ const edges = await response.json()
1122
+ expect(edges).toHaveLength(1)
1123
+ })
1124
+
1125
+ it('GET /edges/:id should support verb filter', async () => {
1126
+ await ns.perform('like', 'alice', 'post-1')
1127
+
1128
+ const request = createRequest('GET', '/edges/alice', undefined, { verb: 'write' })
1129
+ const response = await ns.fetch(request)
1130
+
1131
+ expect(response.status).toBe(200)
1132
+ const edges = await response.json()
1133
+ expect(edges).toHaveLength(1)
1134
+ expect(edges[0].verb).toBe('write')
1135
+ })
1136
+
1137
+ it('GET /edges/:id should support direction param', async () => {
1138
+ const request = createRequest('GET', '/edges/post-1', undefined, { direction: 'in' })
1139
+ const response = await ns.fetch(request)
1140
+
1141
+ expect(response.status).toBe(200)
1142
+ const edges = await response.json()
1143
+ expect(edges).toHaveLength(1)
1144
+ expect(edges[0].object).toBe('post-1')
1145
+ })
1146
+
1147
+ it('GET /related/:id should get related things', async () => {
1148
+ const request = createRequest('GET', '/related/alice')
1149
+ const response = await ns.fetch(request)
1150
+
1151
+ expect(response.status).toBe(200)
1152
+ const related = await response.json()
1153
+ expect(related).toHaveLength(1)
1154
+ expect(related[0].id).toBe('post-1')
1155
+ })
1156
+
1157
+ it('GET /related/:id should support verb and direction params', async () => {
1158
+ const request = createRequest('GET', '/related/post-1', undefined, {
1159
+ verb: 'write',
1160
+ direction: 'in',
1161
+ })
1162
+ const response = await ns.fetch(request)
1163
+
1164
+ expect(response.status).toBe(200)
1165
+ const related = await response.json()
1166
+ expect(related).toHaveLength(1)
1167
+ expect(related[0].id).toBe('alice')
1168
+ })
1169
+ })
1170
+
1171
+ describe('Error Handling', () => {
1172
+ it('should return 404 for unknown routes', async () => {
1173
+ const request = createRequest('GET', '/unknown/route')
1174
+ const response = await ns.fetch(request)
1175
+
1176
+ expect(response.status).toBe(404)
1177
+ })
1178
+
1179
+ it('should return 404 for not found errors with proper error response', async () => {
1180
+ // Create a request that will cause a NotFoundError (update non-existent thing)
1181
+ const request = createRequest('PATCH', '/things/nonexistent', { data: { title: 'Test' } })
1182
+ const response = await ns.fetch(request)
1183
+
1184
+ expect(response.status).toBe(404)
1185
+ const body = await response.json()
1186
+ expect(body.error).toBe('NOT_FOUND')
1187
+ expect(body.message).toContain('Thing not found')
1188
+ })
1189
+
1190
+ it('should handle malformed JSON gracefully', async () => {
1191
+ const request = new Request('https://example.com/things', {
1192
+ method: 'POST',
1193
+ headers: { 'Content-Type': 'application/json' },
1194
+ body: 'not valid json',
1195
+ })
1196
+
1197
+ const response = await ns.fetch(request)
1198
+ expect(response.status).toBe(500)
1199
+ })
1200
+ })
1201
+ })
1202
+
1203
+ describe('SQL Query Generation', () => {
1204
+ it('should use parameterized queries for nouns', async () => {
1205
+ await ns.defineNoun({ name: "Test'; DROP TABLE nouns;--" })
1206
+
1207
+ // The SQL should use parameterized queries, not string interpolation
1208
+ const insertCall = mockSql.exec.mock.calls.find((call) => {
1209
+ const sql = call[0] as string
1210
+ return sql.includes('INSERT OR REPLACE INTO nouns')
1211
+ })
1212
+
1213
+ expect(insertCall).toBeDefined()
1214
+ // Parameters should be passed separately, not interpolated into the SQL
1215
+ expect(insertCall![1]).toBe("Test'; DROP TABLE nouns;--")
1216
+ })
1217
+
1218
+ it('should use parameterized queries for things', async () => {
1219
+ await ns.create('Post', { title: "Test'; DROP TABLE things;--" })
1220
+
1221
+ const insertCall = mockSql.exec.mock.calls.find((call) => {
1222
+ const sql = call[0] as string
1223
+ return sql.includes('INSERT INTO things')
1224
+ })
1225
+
1226
+ expect(insertCall).toBeDefined()
1227
+ // Data is JSON stringified and passed as parameter at index 3 (id, noun, data, ...)
1228
+ const dataParam = insertCall![3] as string
1229
+ expect(dataParam).toContain("Test'; DROP TABLE things;--")
1230
+ })
1231
+
1232
+ it('should use parameterized queries for actions', async () => {
1233
+ await ns.perform('test', "subject'; DROP TABLE actions;--", 'object')
1234
+
1235
+ const insertCall = mockSql.exec.mock.calls.find((call) => {
1236
+ const sql = call[0] as string
1237
+ return sql.includes('INSERT INTO actions')
1238
+ })
1239
+
1240
+ expect(insertCall).toBeDefined()
1241
+ // Subject is at index 3 (id, verb, subject, object, ...)
1242
+ expect(insertCall![3]).toBe("subject'; DROP TABLE actions;--")
1243
+ })
1244
+
1245
+ it('should use parameterized queries for search', async () => {
1246
+ await ns.search("'; DROP TABLE things;--")
1247
+
1248
+ const searchCall = mockSql.exec.mock.calls.find((call) => {
1249
+ const sql = call[0] as string
1250
+ return sql.includes('LIKE ?')
1251
+ })
1252
+
1253
+ expect(searchCall).toBeDefined()
1254
+ // The query should be passed as a parameter (wrapped with % for LIKE)
1255
+ const queryParam = searchCall![1] as string
1256
+ expect(queryParam.toLowerCase()).toContain("'; drop table things;--")
1257
+ })
1258
+ })
1259
+
1260
+ describe('Data Serialization', () => {
1261
+ it('should properly serialize and deserialize JSON data in things', async () => {
1262
+ const complexData = {
1263
+ nested: { deep: { value: 123 } },
1264
+ array: [1, 2, 3],
1265
+ boolean: true,
1266
+ nullValue: null,
1267
+ }
1268
+
1269
+ const created = await ns.create('Test', complexData)
1270
+ const retrieved = await ns.get(created.id)
1271
+
1272
+ expect(retrieved!.data).toEqual(complexData)
1273
+ })
1274
+
1275
+ it('should properly serialize and deserialize schema in nouns', async () => {
1276
+ const schema = {
1277
+ title: 'string' as const,
1278
+ body: 'markdown' as const,
1279
+ published: 'boolean' as const,
1280
+ }
1281
+
1282
+ await ns.defineNoun({ name: 'Article', schema })
1283
+ const noun = await ns.getNoun('Article')
1284
+
1285
+ expect(noun!.schema).toEqual(schema)
1286
+ })
1287
+
1288
+ it('should properly serialize and deserialize action data', async () => {
1289
+ const actionData = {
1290
+ metadata: { source: 'api', version: '1.0' },
1291
+ timestamp: 1234567890,
1292
+ }
1293
+
1294
+ const action = await ns.perform('test', 'subject', 'object', actionData)
1295
+ const retrieved = await ns.getAction(action.id)
1296
+
1297
+ expect(retrieved!.data).toEqual(actionData)
1298
+ })
1299
+
1300
+ it('should handle undefined schema gracefully', async () => {
1301
+ await ns.defineNoun({ name: 'Simple' })
1302
+ const noun = await ns.getNoun('Simple')
1303
+
1304
+ expect(noun!.schema).toBeUndefined()
1305
+ })
1306
+
1307
+ it('should handle undefined action data gracefully', async () => {
1308
+ const action = await ns.perform('test', 'subject', 'object')
1309
+ const retrieved = await ns.getAction(action.id)
1310
+
1311
+ expect(retrieved!.data).toBeUndefined()
1312
+ })
1313
+ })
1314
+
1315
+ describe('Edge Cases', () => {
1316
+ it('should handle empty string IDs', async () => {
1317
+ const thing = await ns.create('Post', { title: 'Test' }, '')
1318
+ expect(thing.id).toBe('')
1319
+
1320
+ const retrieved = await ns.get('')
1321
+ expect(retrieved!.data.title).toBe('Test')
1322
+ })
1323
+
1324
+ it('should handle special characters in noun names', async () => {
1325
+ const noun = await ns.defineNoun({ name: 'Blog Post Category' })
1326
+ expect(noun.slug).toBe('blog-post-category')
1327
+
1328
+ const retrieved = await ns.getNoun('Blog Post Category')
1329
+ expect(retrieved!.name).toBe('Blog Post Category')
1330
+ })
1331
+
1332
+ it('should handle empty data objects', async () => {
1333
+ const thing = await ns.create('Empty', {})
1334
+ expect(thing.data).toEqual({})
1335
+
1336
+ const retrieved = await ns.get(thing.id)
1337
+ expect(retrieved!.data).toEqual({})
1338
+ })
1339
+
1340
+ it('should handle very long strings in data', async () => {
1341
+ const longString = 'a'.repeat(10000)
1342
+ const thing = await ns.create('Post', { content: longString })
1343
+
1344
+ const retrieved = await ns.get(thing.id)
1345
+ expect(retrieved!.data.content).toBe(longString)
1346
+ })
1347
+
1348
+ it('should handle unicode characters', async () => {
1349
+ const unicodeData = {
1350
+ emoji: '\u{1F600}\u{1F389}',
1351
+ japanese: '\u3053\u3093\u306B\u3061\u306F',
1352
+ arabic: '\u0645\u0631\u062D\u0628\u0627',
1353
+ }
1354
+
1355
+ const thing = await ns.create('Unicode', unicodeData)
1356
+ const retrieved = await ns.get(thing.id)
1357
+
1358
+ expect(retrieved!.data).toEqual(unicodeData)
1359
+ })
1360
+
1361
+ it('should handle concurrent operations', async () => {
1362
+ // Create multiple things concurrently
1363
+ const promises = Array.from({ length: 10 }, (_, i) =>
1364
+ ns.create('Post', { title: `Post ${i}` })
1365
+ )
1366
+
1367
+ const results = await Promise.all(promises)
1368
+ expect(results).toHaveLength(10)
1369
+
1370
+ // All should have unique IDs
1371
+ const ids = new Set(results.map((r) => r.id))
1372
+ expect(ids.size).toBe(10)
1373
+ })
1374
+ })
1375
+
1376
+ describe('close() method', () => {
1377
+ it('should be a no-op and resolve successfully', async () => {
1378
+ await expect(ns.close()).resolves.toBeUndefined()
1379
+ })
1380
+ })
1381
+ })