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,307 @@
1
+ /**
2
+ * Namespace ID Validation Tests
3
+ *
4
+ * Tests for validateNamespaceId() function and worker fetch handler integration.
5
+ *
6
+ * Issue: aip-b5g0
7
+ *
8
+ * The namespace ID is extracted from the 'ns' query parameter and used to
9
+ * identify a Durable Object instance. Invalid namespace IDs should be rejected
10
+ * with a 400 Bad Request response.
11
+ *
12
+ * Valid namespace IDs:
13
+ * - Alphanumeric characters (a-z, A-Z, 0-9)
14
+ * - Hyphens (-)
15
+ * - Underscores (_)
16
+ * - Length: 1-64 characters
17
+ *
18
+ * Invalid namespace IDs:
19
+ * - Empty string
20
+ * - Dots, slashes, special characters
21
+ * - Length > 64 characters
22
+ */
23
+
24
+ import { describe, it, expect, vi } from 'vitest'
25
+ import { validateNamespaceId } from '../src/ns.js'
26
+ import worker from '../src/ns.js'
27
+ import type { Env } from '../src/ns.js'
28
+
29
+ describe('Namespace ID Validation', () => {
30
+ describe('validateNamespaceId()', () => {
31
+ describe('valid namespace patterns', () => {
32
+ it('should accept simple alphanumeric namespaces', () => {
33
+ expect(validateNamespaceId('default')).toBe(true)
34
+ expect(validateNamespaceId('tenant1')).toBe(true)
35
+ expect(validateNamespaceId('MyNamespace')).toBe(true)
36
+ expect(validateNamespaceId('UPPERCASE')).toBe(true)
37
+ expect(validateNamespaceId('lowercase')).toBe(true)
38
+ expect(validateNamespaceId('MixedCase123')).toBe(true)
39
+ })
40
+
41
+ it('should accept namespaces with hyphens', () => {
42
+ expect(validateNamespaceId('my-namespace')).toBe(true)
43
+ expect(validateNamespaceId('tenant-1')).toBe(true)
44
+ expect(validateNamespaceId('a-b-c-d')).toBe(true)
45
+ expect(validateNamespaceId('My-Mixed-Case')).toBe(true)
46
+ })
47
+
48
+ it('should accept namespaces with underscores', () => {
49
+ expect(validateNamespaceId('my_namespace')).toBe(true)
50
+ expect(validateNamespaceId('tenant_1')).toBe(true)
51
+ expect(validateNamespaceId('a_b_c_d')).toBe(true)
52
+ expect(validateNamespaceId('My_Mixed_Case')).toBe(true)
53
+ })
54
+
55
+ it('should accept namespaces with mixed hyphens and underscores', () => {
56
+ expect(validateNamespaceId('my-namespace_v1')).toBe(true)
57
+ expect(validateNamespaceId('tenant_1-prod')).toBe(true)
58
+ expect(validateNamespaceId('a_b-c_d')).toBe(true)
59
+ })
60
+
61
+ it('should accept single character namespaces', () => {
62
+ expect(validateNamespaceId('a')).toBe(true)
63
+ expect(validateNamespaceId('Z')).toBe(true)
64
+ expect(validateNamespaceId('1')).toBe(true)
65
+ expect(validateNamespaceId('_')).toBe(true)
66
+ expect(validateNamespaceId('-')).toBe(true)
67
+ })
68
+
69
+ it('should accept namespaces starting with numbers', () => {
70
+ expect(validateNamespaceId('123')).toBe(true)
71
+ expect(validateNamespaceId('1st-tenant')).toBe(true)
72
+ expect(validateNamespaceId('2_namespace')).toBe(true)
73
+ })
74
+
75
+ it('should accept maximum length namespace (64 chars)', () => {
76
+ const maxLengthNs = 'a'.repeat(64)
77
+ expect(validateNamespaceId(maxLengthNs)).toBe(true)
78
+ })
79
+ })
80
+
81
+ describe('invalid namespace patterns', () => {
82
+ it('should reject empty string', () => {
83
+ expect(validateNamespaceId('')).toBe(false)
84
+ })
85
+
86
+ it('should reject namespaces with dots', () => {
87
+ expect(validateNamespaceId('namespace.v1')).toBe(false)
88
+ expect(validateNamespaceId('a.b.c')).toBe(false)
89
+ expect(validateNamespaceId('.hidden')).toBe(false)
90
+ expect(validateNamespaceId('trailing.')).toBe(false)
91
+ })
92
+
93
+ it('should reject namespaces with slashes', () => {
94
+ expect(validateNamespaceId('namespace/v1')).toBe(false)
95
+ expect(validateNamespaceId('a/b/c')).toBe(false)
96
+ expect(validateNamespaceId('/leading')).toBe(false)
97
+ expect(validateNamespaceId('trailing/')).toBe(false)
98
+ expect(validateNamespaceId('back\\slash')).toBe(false)
99
+ })
100
+
101
+ it('should reject namespaces with special characters', () => {
102
+ expect(validateNamespaceId('namespace!')).toBe(false)
103
+ expect(validateNamespaceId('namespace@')).toBe(false)
104
+ expect(validateNamespaceId('namespace#')).toBe(false)
105
+ expect(validateNamespaceId('namespace$')).toBe(false)
106
+ expect(validateNamespaceId('namespace%')).toBe(false)
107
+ expect(validateNamespaceId('namespace^')).toBe(false)
108
+ expect(validateNamespaceId('namespace&')).toBe(false)
109
+ expect(validateNamespaceId('namespace*')).toBe(false)
110
+ expect(validateNamespaceId('namespace(')).toBe(false)
111
+ expect(validateNamespaceId('namespace)')).toBe(false)
112
+ expect(validateNamespaceId('namespace=')).toBe(false)
113
+ expect(validateNamespaceId('namespace+')).toBe(false)
114
+ expect(validateNamespaceId('namespace[')).toBe(false)
115
+ expect(validateNamespaceId('namespace]')).toBe(false)
116
+ expect(validateNamespaceId('namespace{')).toBe(false)
117
+ expect(validateNamespaceId('namespace}')).toBe(false)
118
+ expect(validateNamespaceId('namespace|')).toBe(false)
119
+ expect(validateNamespaceId('namespace;')).toBe(false)
120
+ expect(validateNamespaceId('namespace:')).toBe(false)
121
+ expect(validateNamespaceId("namespace'")).toBe(false)
122
+ expect(validateNamespaceId('namespace"')).toBe(false)
123
+ expect(validateNamespaceId('namespace<')).toBe(false)
124
+ expect(validateNamespaceId('namespace>')).toBe(false)
125
+ expect(validateNamespaceId('namespace,')).toBe(false)
126
+ expect(validateNamespaceId('namespace?')).toBe(false)
127
+ expect(validateNamespaceId('namespace`')).toBe(false)
128
+ expect(validateNamespaceId('namespace~')).toBe(false)
129
+ })
130
+
131
+ it('should reject namespaces with spaces', () => {
132
+ expect(validateNamespaceId('my namespace')).toBe(false)
133
+ expect(validateNamespaceId(' leading')).toBe(false)
134
+ expect(validateNamespaceId('trailing ')).toBe(false)
135
+ expect(validateNamespaceId(' ')).toBe(false)
136
+ })
137
+
138
+ it('should reject namespaces exceeding 64 characters', () => {
139
+ const tooLongNs = 'a'.repeat(65)
140
+ expect(validateNamespaceId(tooLongNs)).toBe(false)
141
+ })
142
+
143
+ it('should reject very long namespaces', () => {
144
+ const veryLongNs = 'a'.repeat(100)
145
+ expect(validateNamespaceId(veryLongNs)).toBe(false)
146
+
147
+ const extremelyLongNs = 'a'.repeat(1000)
148
+ expect(validateNamespaceId(extremelyLongNs)).toBe(false)
149
+ })
150
+
151
+ it('should reject namespaces with unicode characters', () => {
152
+ expect(validateNamespaceId('namespace\u0000')).toBe(false) // null byte
153
+ expect(validateNamespaceId('namespace\u00e9')).toBe(false) // é
154
+ expect(validateNamespaceId('namespace\u4e2d')).toBe(false) // 中
155
+ expect(validateNamespaceId('\u2603')).toBe(false) // snowman emoji
156
+ })
157
+
158
+ it('should reject namespaces with newlines or tabs', () => {
159
+ expect(validateNamespaceId('namespace\n')).toBe(false)
160
+ expect(validateNamespaceId('namespace\r')).toBe(false)
161
+ expect(validateNamespaceId('namespace\t')).toBe(false)
162
+ expect(validateNamespaceId('\nleading')).toBe(false)
163
+ })
164
+ })
165
+
166
+ describe('edge cases', () => {
167
+ it('should handle boundary length (63, 64, 65 characters)', () => {
168
+ expect(validateNamespaceId('a'.repeat(63))).toBe(true) // just under
169
+ expect(validateNamespaceId('a'.repeat(64))).toBe(true) // exactly at limit
170
+ expect(validateNamespaceId('a'.repeat(65))).toBe(false) // just over
171
+ })
172
+ })
173
+ })
174
+
175
+ describe('Worker fetch handler integration', () => {
176
+ const createMockEnv = (): Env => {
177
+ const mockStub = {
178
+ fetch: vi.fn().mockResolvedValue(new Response('OK')),
179
+ }
180
+
181
+ return {
182
+ NS: {
183
+ idFromName: vi.fn().mockReturnValue('mock-id'),
184
+ get: vi.fn().mockReturnValue(mockStub),
185
+ } as unknown as DurableObjectNamespace,
186
+ }
187
+ }
188
+
189
+ it('should allow valid namespace and forward to Durable Object', async () => {
190
+ const env = createMockEnv()
191
+ const request = new Request('https://example.com/nouns?ns=my-tenant-1')
192
+
193
+ const response = await worker.fetch(request, env)
194
+
195
+ expect(response.status).toBe(200)
196
+ expect(env.NS.idFromName).toHaveBeenCalledWith('my-tenant-1')
197
+ })
198
+
199
+ it('should use default namespace when ns parameter is omitted', async () => {
200
+ const env = createMockEnv()
201
+ const request = new Request('https://example.com/nouns')
202
+
203
+ const response = await worker.fetch(request, env)
204
+
205
+ expect(response.status).toBe(200)
206
+ expect(env.NS.idFromName).toHaveBeenCalledWith('default')
207
+ })
208
+
209
+ it('should return 400 for invalid namespace with dots', async () => {
210
+ const env = createMockEnv()
211
+ const request = new Request('https://example.com/nouns?ns=my.namespace')
212
+
213
+ const response = await worker.fetch(request, env)
214
+
215
+ expect(response.status).toBe(400)
216
+ const body = await response.json()
217
+ expect(body.error).toBe('INVALID_NAMESPACE')
218
+ expect(body.message).toContain('Invalid namespace ID')
219
+ // Should NOT have called idFromName
220
+ expect(env.NS.idFromName).not.toHaveBeenCalled()
221
+ })
222
+
223
+ it('should return 400 for invalid namespace with slashes', async () => {
224
+ const env = createMockEnv()
225
+ const request = new Request('https://example.com/nouns?ns=namespace/v1')
226
+
227
+ const response = await worker.fetch(request, env)
228
+
229
+ expect(response.status).toBe(400)
230
+ const body = await response.json()
231
+ expect(body.error).toBe('INVALID_NAMESPACE')
232
+ expect(env.NS.idFromName).not.toHaveBeenCalled()
233
+ })
234
+
235
+ it('should return 400 for invalid namespace with special characters', async () => {
236
+ const env = createMockEnv()
237
+ const request = new Request('https://example.com/nouns?ns=' + encodeURIComponent('ns@#$%'))
238
+
239
+ const response = await worker.fetch(request, env)
240
+
241
+ expect(response.status).toBe(400)
242
+ const body = await response.json()
243
+ expect(body.error).toBe('INVALID_NAMESPACE')
244
+ expect(env.NS.idFromName).not.toHaveBeenCalled()
245
+ })
246
+
247
+ it('should return 400 for namespace exceeding 64 characters', async () => {
248
+ const env = createMockEnv()
249
+ const longNs = 'a'.repeat(65)
250
+ const request = new Request(`https://example.com/nouns?ns=${longNs}`)
251
+
252
+ const response = await worker.fetch(request, env)
253
+
254
+ expect(response.status).toBe(400)
255
+ const body = await response.json()
256
+ expect(body.error).toBe('INVALID_NAMESPACE')
257
+ expect(env.NS.idFromName).not.toHaveBeenCalled()
258
+ })
259
+
260
+ it('should return 400 for empty namespace string', async () => {
261
+ const env = createMockEnv()
262
+ const request = new Request('https://example.com/nouns?ns=')
263
+
264
+ const response = await worker.fetch(request, env)
265
+
266
+ expect(response.status).toBe(400)
267
+ const body = await response.json()
268
+ expect(body.error).toBe('INVALID_NAMESPACE')
269
+ expect(env.NS.idFromName).not.toHaveBeenCalled()
270
+ })
271
+
272
+ it('should return 400 for namespace with spaces', async () => {
273
+ const env = createMockEnv()
274
+ const request = new Request(
275
+ 'https://example.com/nouns?ns=' + encodeURIComponent('my namespace')
276
+ )
277
+
278
+ const response = await worker.fetch(request, env)
279
+
280
+ expect(response.status).toBe(400)
281
+ const body = await response.json()
282
+ expect(body.error).toBe('INVALID_NAMESPACE')
283
+ expect(env.NS.idFromName).not.toHaveBeenCalled()
284
+ })
285
+
286
+ it('should allow valid namespaces with hyphens and underscores', async () => {
287
+ const env = createMockEnv()
288
+ const request = new Request('https://example.com/nouns?ns=my-tenant_v1')
289
+
290
+ const response = await worker.fetch(request, env)
291
+
292
+ expect(response.status).toBe(200)
293
+ expect(env.NS.idFromName).toHaveBeenCalledWith('my-tenant_v1')
294
+ })
295
+
296
+ it('should allow 64-character namespace (boundary test)', async () => {
297
+ const env = createMockEnv()
298
+ const maxNs = 'a'.repeat(64)
299
+ const request = new Request(`https://example.com/nouns?ns=${maxNs}`)
300
+
301
+ const response = await worker.fetch(request, env)
302
+
303
+ expect(response.status).toBe(200)
304
+ expect(env.NS.idFromName).toHaveBeenCalledWith(maxNs)
305
+ })
306
+ })
307
+ })
@@ -0,0 +1,168 @@
1
+ /**
2
+ * Performance tests for N+1 query patterns
3
+ *
4
+ * These tests verify that related() uses batch queries instead of N+1 patterns.
5
+ */
6
+
7
+ import { describe, it, expect, beforeEach, vi } from 'vitest'
8
+ import { createMemoryProvider, MemoryProvider } from '../src/memory-provider.js'
9
+ import type { DigitalObjectsProvider, Thing } from '../src/types.js'
10
+
11
+ describe('N+1 Query Pattern Prevention', () => {
12
+ let provider: MemoryProvider
13
+
14
+ beforeEach(async () => {
15
+ provider = createMemoryProvider() as MemoryProvider
16
+ await provider.defineNoun({ name: 'Item' })
17
+ await provider.defineVerb({ name: 'link' })
18
+ })
19
+
20
+ describe('related() should use batch query', () => {
21
+ it('should fetch 100 related items with batch query, not 100 individual queries', async () => {
22
+ // Create source item
23
+ const source = await provider.create('Item', { name: 'source' })
24
+
25
+ // Create 100 target items and link them
26
+ const targets: Thing<{ name: string }>[] = []
27
+ for (let i = 0; i < 100; i++) {
28
+ const target = await provider.create('Item', { name: `target-${i}` })
29
+ targets.push(target)
30
+ await provider.perform('link', source.id, target.id)
31
+ }
32
+
33
+ // Spy on get() to count individual queries
34
+ const getSpy = vi.spyOn(provider, 'get')
35
+
36
+ // Fetch related items
37
+ const related = await provider.related(source.id, 'link', 'out')
38
+
39
+ // Verify we got all related items
40
+ expect(related).toHaveLength(100)
41
+
42
+ // KEY TEST: Should NOT call get() 100 times (N+1 pattern)
43
+ // With batch query, get() should be called 0 times (uses getMany internally)
44
+ expect(getSpy).toHaveBeenCalledTimes(0)
45
+ })
46
+
47
+ it('should have getMany() method for batch fetching', async () => {
48
+ // Test that getMany method exists
49
+ expect(typeof (provider as any).getMany).toBe('function')
50
+ })
51
+
52
+ it('getMany() should fetch multiple items in single operation', async () => {
53
+ // Create multiple items
54
+ const item1 = await provider.create('Item', { name: 'item-1' })
55
+ const item2 = await provider.create('Item', { name: 'item-2' })
56
+ const item3 = await provider.create('Item', { name: 'item-3' })
57
+
58
+ // Fetch all at once
59
+ const items = await (provider as any).getMany([item1.id, item2.id, item3.id])
60
+
61
+ expect(items).toHaveLength(3)
62
+ expect(items.map((i: Thing<{ name: string }>) => i.data.name).sort()).toEqual([
63
+ 'item-1',
64
+ 'item-2',
65
+ 'item-3',
66
+ ])
67
+ })
68
+
69
+ it('getMany() should return empty array for empty input', async () => {
70
+ const items = await (provider as any).getMany([])
71
+ expect(items).toEqual([])
72
+ })
73
+
74
+ it('getMany() should skip non-existent IDs', async () => {
75
+ const item1 = await provider.create('Item', { name: 'item-1' })
76
+
77
+ const items = await (provider as any).getMany([
78
+ item1.id,
79
+ 'non-existent-id-1',
80
+ 'non-existent-id-2',
81
+ ])
82
+
83
+ expect(items).toHaveLength(1)
84
+ expect(items[0].id).toBe(item1.id)
85
+ })
86
+ })
87
+
88
+ describe('related() performance benchmark', () => {
89
+ it('should be faster with batch query than N+1', async () => {
90
+ // Create source and many targets
91
+ const source = await provider.create('Item', { name: 'source' })
92
+ for (let i = 0; i < 100; i++) {
93
+ const target = await provider.create('Item', { name: `target-${i}` })
94
+ await provider.perform('link', source.id, target.id)
95
+ }
96
+
97
+ // Benchmark batch query approach
98
+ const start = performance.now()
99
+ const related = await provider.related(source.id, 'link', 'out')
100
+ const batchTime = performance.now() - start
101
+
102
+ expect(related).toHaveLength(100)
103
+
104
+ // Log performance for visibility
105
+ console.log(`related() with 100 items: ${batchTime.toFixed(2)}ms`)
106
+
107
+ // Should be fast - batch query should complete well under 50ms
108
+ expect(batchTime).toBeLessThan(50)
109
+ })
110
+
111
+ it('should handle large edge sets efficiently (500 edges)', async () => {
112
+ const source = await provider.create('Item', { name: 'source' })
113
+ for (let i = 0; i < 500; i++) {
114
+ const target = await provider.create('Item', { name: `target-${i}` })
115
+ await provider.perform('link', source.id, target.id)
116
+ }
117
+
118
+ const start = performance.now()
119
+ const related = await provider.related(source.id, 'link', 'out')
120
+ const elapsed = performance.now() - start
121
+
122
+ console.log(`related() with 500 items: ${elapsed.toFixed(2)}ms`)
123
+
124
+ // With batch query, should still be reasonably fast
125
+ expect(elapsed).toBeLessThan(200)
126
+ })
127
+ })
128
+
129
+ describe('related() with direction variations', () => {
130
+ it('should use batch query for inbound relations', async () => {
131
+ const target = await provider.create('Item', { name: 'target' })
132
+
133
+ // Create 50 sources pointing to this target
134
+ for (let i = 0; i < 50; i++) {
135
+ const source = await provider.create('Item', { name: `source-${i}` })
136
+ await provider.perform('link', source.id, target.id)
137
+ }
138
+
139
+ const getSpy = vi.spyOn(provider, 'get')
140
+ const inbound = await provider.related(target.id, 'link', 'in')
141
+
142
+ expect(inbound).toHaveLength(50)
143
+ // Should NOT call get() 50 times
144
+ expect(getSpy).toHaveBeenCalledTimes(0)
145
+ })
146
+
147
+ it('should use batch query for bidirectional relations', async () => {
148
+ const node = await provider.create('Item', { name: 'node' })
149
+
150
+ // Create edges in both directions
151
+ for (let i = 0; i < 25; i++) {
152
+ const other = await provider.create('Item', { name: `outbound-${i}` })
153
+ await provider.perform('link', node.id, other.id)
154
+ }
155
+ for (let i = 0; i < 25; i++) {
156
+ const other = await provider.create('Item', { name: `inbound-${i}` })
157
+ await provider.perform('link', other.id, node.id)
158
+ }
159
+
160
+ const getSpy = vi.spyOn(provider, 'get')
161
+ const both = await provider.related(node.id, 'link', 'both')
162
+
163
+ expect(both).toHaveLength(50)
164
+ // Should NOT call get() 50 times
165
+ expect(getSpy).toHaveBeenCalledTimes(0)
166
+ })
167
+ })
168
+ })
@@ -0,0 +1,213 @@
1
+ /**
2
+ * Tests for ValidationError consistency in schema-validation.ts
3
+ *
4
+ * These tests verify that validateData() throws ValidationError (not generic Error)
5
+ * for consistency with the error handling patterns in digital-objects.
6
+ */
7
+ import { describe, it, expect } from 'vitest'
8
+ import { validateData } from '../src/schema-validation.js'
9
+ import { ValidationError, DigitalObjectsError } from '../src/errors.js'
10
+ import type { FieldDefinition } from '../src/types.js'
11
+
12
+ describe('ValidationError Consistency', () => {
13
+ describe('validateData() error type', () => {
14
+ it('should throw ValidationError, not generic Error', () => {
15
+ const schema: Record<string, FieldDefinition> = {
16
+ email: { type: 'string', required: true },
17
+ }
18
+
19
+ let thrownError: unknown
20
+
21
+ try {
22
+ validateData({}, schema, { validate: true })
23
+ expect.fail('Should have thrown')
24
+ } catch (error) {
25
+ thrownError = error
26
+ }
27
+
28
+ // This should be ValidationError, not generic Error
29
+ expect(thrownError).toBeInstanceOf(ValidationError)
30
+ })
31
+
32
+ it('should NOT throw just a generic Error', () => {
33
+ const schema: Record<string, FieldDefinition> = {
34
+ email: { type: 'string', required: true },
35
+ }
36
+
37
+ let thrownError: unknown
38
+
39
+ try {
40
+ validateData({}, schema, { validate: true })
41
+ expect.fail('Should have thrown')
42
+ } catch (error) {
43
+ thrownError = error
44
+ }
45
+
46
+ // Verify constructor name is ValidationError, not just Error
47
+ expect((thrownError as Error).constructor.name).toBe('ValidationError')
48
+ })
49
+ })
50
+
51
+ describe('ValidationError inheritance chain', () => {
52
+ it('should inherit from DigitalObjectsError', () => {
53
+ const schema: Record<string, FieldDefinition> = {
54
+ name: { type: 'string', required: true },
55
+ }
56
+
57
+ let thrownError: unknown
58
+
59
+ try {
60
+ validateData({}, schema, { validate: true })
61
+ expect.fail('Should have thrown')
62
+ } catch (error) {
63
+ thrownError = error
64
+ }
65
+
66
+ expect(thrownError).toBeInstanceOf(DigitalObjectsError)
67
+ })
68
+
69
+ it('should also be an instance of Error', () => {
70
+ const schema: Record<string, FieldDefinition> = {
71
+ name: { type: 'string', required: true },
72
+ }
73
+
74
+ let thrownError: unknown
75
+
76
+ try {
77
+ validateData({}, schema, { validate: true })
78
+ expect.fail('Should have thrown')
79
+ } catch (error) {
80
+ thrownError = error
81
+ }
82
+
83
+ expect(thrownError).toBeInstanceOf(Error)
84
+ })
85
+ })
86
+
87
+ describe('ValidationError properties', () => {
88
+ it('should have proper fieldErrors array', () => {
89
+ const schema: Record<string, FieldDefinition> = {
90
+ email: { type: 'string', required: true },
91
+ age: 'number',
92
+ }
93
+
94
+ let thrownError: unknown
95
+
96
+ try {
97
+ validateData({ age: 'not-a-number' }, schema, { validate: true })
98
+ expect.fail('Should have thrown')
99
+ } catch (error) {
100
+ thrownError = error
101
+ }
102
+
103
+ expect(thrownError).toBeInstanceOf(ValidationError)
104
+
105
+ const validationError = thrownError as ValidationError
106
+ expect(validationError.errors).toBeDefined()
107
+ expect(Array.isArray(validationError.errors)).toBe(true)
108
+ expect(validationError.errors.length).toBeGreaterThan(0)
109
+ })
110
+
111
+ it('should have field and message in each error', () => {
112
+ const schema: Record<string, FieldDefinition> = {
113
+ email: { type: 'string', required: true },
114
+ }
115
+
116
+ let thrownError: unknown
117
+
118
+ try {
119
+ validateData({}, schema, { validate: true })
120
+ expect.fail('Should have thrown')
121
+ } catch (error) {
122
+ thrownError = error
123
+ }
124
+
125
+ const validationError = thrownError as ValidationError
126
+
127
+ for (const fieldError of validationError.errors) {
128
+ expect(fieldError).toHaveProperty('field')
129
+ expect(fieldError).toHaveProperty('message')
130
+ expect(typeof fieldError.field).toBe('string')
131
+ expect(typeof fieldError.message).toBe('string')
132
+ }
133
+ })
134
+
135
+ it('should have correct field names in errors', () => {
136
+ const schema: Record<string, FieldDefinition> = {
137
+ email: { type: 'string', required: true },
138
+ name: { type: 'string', required: true },
139
+ }
140
+
141
+ let thrownError: unknown
142
+
143
+ try {
144
+ validateData({}, schema, { validate: true })
145
+ expect.fail('Should have thrown')
146
+ } catch (error) {
147
+ thrownError = error
148
+ }
149
+
150
+ const validationError = thrownError as ValidationError
151
+ const fieldNames = validationError.errors.map((e) => e.field)
152
+
153
+ expect(fieldNames).toContain('email')
154
+ expect(fieldNames).toContain('name')
155
+ })
156
+
157
+ it('should have VALIDATION_ERROR code', () => {
158
+ const schema: Record<string, FieldDefinition> = {
159
+ email: { type: 'string', required: true },
160
+ }
161
+
162
+ let thrownError: unknown
163
+
164
+ try {
165
+ validateData({}, schema, { validate: true })
166
+ expect.fail('Should have thrown')
167
+ } catch (error) {
168
+ thrownError = error
169
+ }
170
+
171
+ const validationError = thrownError as ValidationError
172
+ expect(validationError.code).toBe('VALIDATION_ERROR')
173
+ })
174
+
175
+ it('should have 400 status code', () => {
176
+ const schema: Record<string, FieldDefinition> = {
177
+ email: { type: 'string', required: true },
178
+ }
179
+
180
+ let thrownError: unknown
181
+
182
+ try {
183
+ validateData({}, schema, { validate: true })
184
+ expect.fail('Should have thrown')
185
+ } catch (error) {
186
+ thrownError = error
187
+ }
188
+
189
+ const validationError = thrownError as ValidationError
190
+ expect(validationError.statusCode).toBe(400)
191
+ })
192
+ })
193
+
194
+ describe('ValidationError message', () => {
195
+ it('should contain error count in message', () => {
196
+ const schema: Record<string, FieldDefinition> = {
197
+ email: { type: 'string', required: true },
198
+ }
199
+
200
+ let thrownError: unknown
201
+
202
+ try {
203
+ validateData({}, schema, { validate: true })
204
+ expect.fail('Should have thrown')
205
+ } catch (error) {
206
+ thrownError = error
207
+ }
208
+
209
+ const validationError = thrownError as ValidationError
210
+ expect(validationError.message).toMatch(/Validation failed/)
211
+ })
212
+ })
213
+ })