digital-objects 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +4 -0
- package/CHANGELOG.md +25 -0
- package/LICENSE +21 -0
- package/README.md +476 -0
- package/dist/ai-database-adapter.d.ts +49 -0
- package/dist/ai-database-adapter.d.ts.map +1 -0
- package/dist/ai-database-adapter.js +89 -0
- package/dist/ai-database-adapter.js.map +1 -0
- package/dist/errors.d.ts +47 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +72 -0
- package/dist/errors.js.map +1 -0
- package/dist/http-schemas.d.ts +165 -0
- package/dist/http-schemas.d.ts.map +1 -0
- package/dist/http-schemas.js +55 -0
- package/dist/http-schemas.js.map +1 -0
- package/dist/index.d.ts +29 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +32 -0
- package/dist/index.js.map +1 -0
- package/dist/linguistic.d.ts +54 -0
- package/dist/linguistic.d.ts.map +1 -0
- package/dist/linguistic.js +226 -0
- package/dist/linguistic.js.map +1 -0
- package/dist/memory-provider.d.ts +46 -0
- package/dist/memory-provider.d.ts.map +1 -0
- package/dist/memory-provider.js +279 -0
- package/dist/memory-provider.js.map +1 -0
- package/dist/ns-client.d.ts +88 -0
- package/dist/ns-client.d.ts.map +1 -0
- package/dist/ns-client.js +253 -0
- package/dist/ns-client.js.map +1 -0
- package/dist/ns-exports.d.ts +23 -0
- package/dist/ns-exports.d.ts.map +1 -0
- package/dist/ns-exports.js +21 -0
- package/dist/ns-exports.js.map +1 -0
- package/dist/ns.d.ts +60 -0
- package/dist/ns.d.ts.map +1 -0
- package/dist/ns.js +818 -0
- package/dist/ns.js.map +1 -0
- package/dist/r2-persistence.d.ts +112 -0
- package/dist/r2-persistence.d.ts.map +1 -0
- package/dist/r2-persistence.js +252 -0
- package/dist/r2-persistence.js.map +1 -0
- package/dist/schema-validation.d.ts +80 -0
- package/dist/schema-validation.d.ts.map +1 -0
- package/dist/schema-validation.js +233 -0
- package/dist/schema-validation.js.map +1 -0
- package/dist/types.d.ts +184 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +26 -0
- package/dist/types.js.map +1 -0
- package/package.json +55 -0
- package/src/ai-database-adapter.test.ts +610 -0
- package/src/ai-database-adapter.ts +189 -0
- package/src/benchmark.test.ts +109 -0
- package/src/errors.ts +91 -0
- package/src/http-schemas.ts +67 -0
- package/src/index.ts +87 -0
- package/src/linguistic.test.ts +1107 -0
- package/src/linguistic.ts +253 -0
- package/src/memory-provider.ts +470 -0
- package/src/ns-client.test.ts +1360 -0
- package/src/ns-client.ts +342 -0
- package/src/ns-exports.ts +23 -0
- package/src/ns.test.ts +1381 -0
- package/src/ns.ts +1215 -0
- package/src/provider.test.ts +675 -0
- package/src/r2-persistence.test.ts +263 -0
- package/src/r2-persistence.ts +367 -0
- package/src/schema-validation.test.ts +167 -0
- package/src/schema-validation.ts +330 -0
- package/src/types.ts +252 -0
- package/test/action-status.test.ts +42 -0
- package/test/batch-limits.test.ts +165 -0
- package/test/docs.test.ts +48 -0
- package/test/errors.test.ts +148 -0
- package/test/http-validation.test.ts +401 -0
- package/test/ns-client-errors.test.ts +208 -0
- package/test/ns-namespace.test.ts +307 -0
- package/test/performance.test.ts +168 -0
- package/test/schema-validation-error.test.ts +213 -0
- package/test/schema-validation.test.ts +440 -0
- package/test/search-escaping.test.ts +359 -0
- package/test/security.test.ts +322 -0
- package/tsconfig.json +10 -0
- package/wrangler.jsonc +16 -0
|
@@ -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
|
+
})
|