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,675 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from 'vitest'
|
|
2
|
+
import type { DigitalObjectsProvider, Noun, Verb, Thing, Action } from './types'
|
|
3
|
+
import { DEFAULT_LIMIT, MAX_LIMIT, validateDirection } from './types'
|
|
4
|
+
import { createMemoryProvider } from './memory-provider'
|
|
5
|
+
|
|
6
|
+
const createProvider = createMemoryProvider
|
|
7
|
+
|
|
8
|
+
describe('validateDirection', () => {
|
|
9
|
+
it('should return valid direction values unchanged', () => {
|
|
10
|
+
expect(validateDirection('in')).toBe('in')
|
|
11
|
+
expect(validateDirection('out')).toBe('out')
|
|
12
|
+
expect(validateDirection('both')).toBe('both')
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
it('should throw error for invalid direction values', () => {
|
|
16
|
+
expect(() => validateDirection('invalid')).toThrow(
|
|
17
|
+
'Invalid direction: "invalid". Must be "in", "out", or "both".'
|
|
18
|
+
)
|
|
19
|
+
expect(() => validateDirection('')).toThrow(
|
|
20
|
+
'Invalid direction: "". Must be "in", "out", or "both".'
|
|
21
|
+
)
|
|
22
|
+
expect(() => validateDirection('up')).toThrow(
|
|
23
|
+
'Invalid direction: "up". Must be "in", "out", or "both".'
|
|
24
|
+
)
|
|
25
|
+
expect(() => validateDirection('IN')).toThrow(
|
|
26
|
+
'Invalid direction: "IN". Must be "in", "out", or "both".'
|
|
27
|
+
)
|
|
28
|
+
})
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
describe('DigitalObjectsProvider Contract', () => {
|
|
32
|
+
let provider: DigitalObjectsProvider
|
|
33
|
+
|
|
34
|
+
beforeEach(() => {
|
|
35
|
+
provider = createProvider()
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
describe('Nouns', () => {
|
|
39
|
+
it('should define a noun with auto-derived forms', async () => {
|
|
40
|
+
const noun = await provider.defineNoun({ name: 'Post' })
|
|
41
|
+
|
|
42
|
+
expect(noun.name).toBe('Post')
|
|
43
|
+
expect(noun.singular).toBe('post')
|
|
44
|
+
expect(noun.plural).toBe('posts')
|
|
45
|
+
expect(noun.slug).toBe('post')
|
|
46
|
+
expect(noun.createdAt).toBeInstanceOf(Date)
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('should define a noun with explicit forms', async () => {
|
|
50
|
+
const noun = await provider.defineNoun({
|
|
51
|
+
name: 'Person',
|
|
52
|
+
singular: 'person',
|
|
53
|
+
plural: 'people',
|
|
54
|
+
description: 'A human being',
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
expect(noun.plural).toBe('people')
|
|
58
|
+
expect(noun.description).toBe('A human being')
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('should get a noun by name', async () => {
|
|
62
|
+
await provider.defineNoun({ name: 'Author' })
|
|
63
|
+
const noun = await provider.getNoun('Author')
|
|
64
|
+
|
|
65
|
+
expect(noun).not.toBeNull()
|
|
66
|
+
expect(noun!.name).toBe('Author')
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('should return null for unknown noun', async () => {
|
|
70
|
+
const noun = await provider.getNoun('Unknown')
|
|
71
|
+
expect(noun).toBeNull()
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('should list all nouns', async () => {
|
|
75
|
+
await provider.defineNoun({ name: 'Post' })
|
|
76
|
+
await provider.defineNoun({ name: 'Author' })
|
|
77
|
+
|
|
78
|
+
const nouns = await provider.listNouns()
|
|
79
|
+
expect(nouns).toHaveLength(2)
|
|
80
|
+
})
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
describe('Verbs', () => {
|
|
84
|
+
it('should define a verb with auto-derived conjugations', async () => {
|
|
85
|
+
const verb = await provider.defineVerb({ name: 'create' })
|
|
86
|
+
|
|
87
|
+
expect(verb.name).toBe('create')
|
|
88
|
+
expect(verb.action).toBe('create')
|
|
89
|
+
expect(verb.act).toBe('creates')
|
|
90
|
+
expect(verb.activity).toBe('creating')
|
|
91
|
+
expect(verb.event).toBe('created')
|
|
92
|
+
expect(verb.reverseBy).toBe('createdBy')
|
|
93
|
+
expect(verb.reverseAt).toBe('createdAt')
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('should define a verb with inverse', async () => {
|
|
97
|
+
const verb = await provider.defineVerb({
|
|
98
|
+
name: 'publish',
|
|
99
|
+
inverse: 'unpublish',
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
expect(verb.inverse).toBe('unpublish')
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it('should handle irregular verbs', async () => {
|
|
106
|
+
const verb = await provider.defineVerb({
|
|
107
|
+
name: 'write',
|
|
108
|
+
event: 'written', // Irregular past participle
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
expect(verb.event).toBe('written')
|
|
112
|
+
})
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
describe('Things', () => {
|
|
116
|
+
beforeEach(async () => {
|
|
117
|
+
await provider.defineNoun({ name: 'Post' })
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
it('should create a thing', async () => {
|
|
121
|
+
const thing = await provider.create('Post', { title: 'Hello World' })
|
|
122
|
+
|
|
123
|
+
expect(thing.id).toBeDefined()
|
|
124
|
+
expect(thing.noun).toBe('Post')
|
|
125
|
+
expect(thing.data.title).toBe('Hello World')
|
|
126
|
+
expect(thing.createdAt).toBeInstanceOf(Date)
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
it('should create a thing with custom ID', async () => {
|
|
130
|
+
const thing = await provider.create('Post', { title: 'Custom' }, 'custom-id')
|
|
131
|
+
expect(thing.id).toBe('custom-id')
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
it('should get a thing by ID', async () => {
|
|
135
|
+
const created = await provider.create('Post', { title: 'Test' })
|
|
136
|
+
const thing = await provider.get(created.id)
|
|
137
|
+
|
|
138
|
+
expect(thing).not.toBeNull()
|
|
139
|
+
expect(thing!.data.title).toBe('Test')
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
it('should list things by noun', async () => {
|
|
143
|
+
await provider.create('Post', { title: 'First' })
|
|
144
|
+
await provider.create('Post', { title: 'Second' })
|
|
145
|
+
|
|
146
|
+
const posts = await provider.list('Post')
|
|
147
|
+
expect(posts).toHaveLength(2)
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
it('should update a thing', async () => {
|
|
151
|
+
const created = await provider.create('Post', { title: 'Original' })
|
|
152
|
+
const updated = await provider.update(created.id, { title: 'Updated' })
|
|
153
|
+
|
|
154
|
+
expect(updated.data.title).toBe('Updated')
|
|
155
|
+
expect(updated.updatedAt.getTime()).toBeGreaterThan(created.createdAt.getTime())
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
it('should delete a thing', async () => {
|
|
159
|
+
const created = await provider.create('Post', { title: 'ToDelete' })
|
|
160
|
+
const deleted = await provider.delete(created.id)
|
|
161
|
+
|
|
162
|
+
expect(deleted).toBe(true)
|
|
163
|
+
expect(await provider.get(created.id)).toBeNull()
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
it('should find things by criteria', async () => {
|
|
167
|
+
await provider.create('Post', { title: 'Draft', status: 'draft' })
|
|
168
|
+
await provider.create('Post', { title: 'Published', status: 'published' })
|
|
169
|
+
|
|
170
|
+
const drafts = await provider.find('Post', { status: 'draft' })
|
|
171
|
+
expect(drafts).toHaveLength(1)
|
|
172
|
+
expect(drafts[0].data.title).toBe('Draft')
|
|
173
|
+
})
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
describe('Actions (Events + Edges)', () => {
|
|
177
|
+
beforeEach(async () => {
|
|
178
|
+
await provider.defineNoun({ name: 'Author' })
|
|
179
|
+
await provider.defineNoun({ name: 'Post' })
|
|
180
|
+
await provider.defineVerb({ name: 'write' })
|
|
181
|
+
await provider.defineVerb({ name: 'publish' })
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
it('should perform an action (create edge)', async () => {
|
|
185
|
+
const author = await provider.create('Author', { name: 'Alice' })
|
|
186
|
+
const post = await provider.create('Post', { title: 'My Post' })
|
|
187
|
+
|
|
188
|
+
const action = await provider.perform('write', author.id, post.id)
|
|
189
|
+
|
|
190
|
+
expect(action.id).toBeDefined()
|
|
191
|
+
expect(action.verb).toBe('write')
|
|
192
|
+
expect(action.subject).toBe(author.id)
|
|
193
|
+
expect(action.object).toBe(post.id)
|
|
194
|
+
expect(action.status).toBe('completed')
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
it('should perform an action with data', async () => {
|
|
198
|
+
const post = await provider.create('Post', { title: 'Draft' })
|
|
199
|
+
|
|
200
|
+
const action = await provider.perform('publish', undefined, post.id, {
|
|
201
|
+
publishedAt: new Date(),
|
|
202
|
+
publishedBy: 'admin',
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
expect(action.data?.publishedBy).toBe('admin')
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
it('should traverse graph via related()', async () => {
|
|
209
|
+
const author = await provider.create('Author', { name: 'Bob' })
|
|
210
|
+
const post1 = await provider.create('Post', { title: 'Post 1' })
|
|
211
|
+
const post2 = await provider.create('Post', { title: 'Post 2' })
|
|
212
|
+
|
|
213
|
+
await provider.perform('write', author.id, post1.id)
|
|
214
|
+
await provider.perform('write', author.id, post2.id)
|
|
215
|
+
|
|
216
|
+
// Outbound: what did author write?
|
|
217
|
+
const written = await provider.related(author.id, 'write', 'out')
|
|
218
|
+
expect(written).toHaveLength(2)
|
|
219
|
+
|
|
220
|
+
// Inbound: who wrote this post?
|
|
221
|
+
const authors = await provider.related(post1.id, 'write', 'in')
|
|
222
|
+
expect(authors).toHaveLength(1)
|
|
223
|
+
expect(authors[0].data.name).toBe('Bob')
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
it('should list actions with filters', async () => {
|
|
227
|
+
const author = await provider.create('Author', { name: 'Carol' })
|
|
228
|
+
const post = await provider.create('Post', { title: 'Test' })
|
|
229
|
+
|
|
230
|
+
await provider.perform('write', author.id, post.id)
|
|
231
|
+
await provider.perform('publish', undefined, post.id)
|
|
232
|
+
|
|
233
|
+
const writeActions = await provider.listActions({ verb: 'write' })
|
|
234
|
+
expect(writeActions).toHaveLength(1)
|
|
235
|
+
})
|
|
236
|
+
|
|
237
|
+
it('should get edges for a thing', async () => {
|
|
238
|
+
const author = await provider.create('Author', { name: 'Dan' })
|
|
239
|
+
const post = await provider.create('Post', { title: 'Test' })
|
|
240
|
+
|
|
241
|
+
await provider.perform('write', author.id, post.id)
|
|
242
|
+
|
|
243
|
+
const outEdges = await provider.edges(author.id, undefined, 'out')
|
|
244
|
+
expect(outEdges).toHaveLength(1)
|
|
245
|
+
expect(outEdges[0].verb).toBe('write')
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
it('should delete an action (GDPR compliance)', async () => {
|
|
249
|
+
const author = await provider.create('Author', { name: 'Eve' })
|
|
250
|
+
const post = await provider.create('Post', { title: 'Test' })
|
|
251
|
+
|
|
252
|
+
const action = await provider.perform('write', author.id, post.id)
|
|
253
|
+
|
|
254
|
+
// Verify action exists
|
|
255
|
+
expect(await provider.getAction(action.id)).not.toBeNull()
|
|
256
|
+
|
|
257
|
+
// Delete the action
|
|
258
|
+
const deleted = await provider.deleteAction(action.id)
|
|
259
|
+
expect(deleted).toBe(true)
|
|
260
|
+
|
|
261
|
+
// Verify action is gone
|
|
262
|
+
expect(await provider.getAction(action.id)).toBeNull()
|
|
263
|
+
})
|
|
264
|
+
|
|
265
|
+
it('should return false when deleting non-existent action', async () => {
|
|
266
|
+
const deleted = await provider.deleteAction('non-existent-id')
|
|
267
|
+
expect(deleted).toBe(false)
|
|
268
|
+
})
|
|
269
|
+
|
|
270
|
+
it('should remove edge from graph after deleteAction', async () => {
|
|
271
|
+
const author = await provider.create('Author', { name: 'Frank' })
|
|
272
|
+
const post = await provider.create('Post', { title: 'Test' })
|
|
273
|
+
|
|
274
|
+
const action = await provider.perform('write', author.id, post.id)
|
|
275
|
+
|
|
276
|
+
// Verify edge exists
|
|
277
|
+
const edgesBefore = await provider.edges(author.id, 'write', 'out')
|
|
278
|
+
expect(edgesBefore).toHaveLength(1)
|
|
279
|
+
|
|
280
|
+
// Delete the action
|
|
281
|
+
await provider.deleteAction(action.id)
|
|
282
|
+
|
|
283
|
+
// Verify edge is gone
|
|
284
|
+
const edgesAfter = await provider.edges(author.id, 'write', 'out')
|
|
285
|
+
expect(edgesAfter).toHaveLength(0)
|
|
286
|
+
})
|
|
287
|
+
|
|
288
|
+
it('should remove relation after deleteAction', async () => {
|
|
289
|
+
const author = await provider.create('Author', { name: 'Grace' })
|
|
290
|
+
const post = await provider.create('Post', { title: 'Test' })
|
|
291
|
+
|
|
292
|
+
const action = await provider.perform('write', author.id, post.id)
|
|
293
|
+
|
|
294
|
+
// Verify relation exists
|
|
295
|
+
const relatedBefore = await provider.related(author.id, 'write', 'out')
|
|
296
|
+
expect(relatedBefore).toHaveLength(1)
|
|
297
|
+
|
|
298
|
+
// Delete the action
|
|
299
|
+
await provider.deleteAction(action.id)
|
|
300
|
+
|
|
301
|
+
// Verify relation is gone
|
|
302
|
+
const relatedAfter = await provider.related(author.id, 'write', 'out')
|
|
303
|
+
expect(relatedAfter).toHaveLength(0)
|
|
304
|
+
})
|
|
305
|
+
|
|
306
|
+
it('should throw error for invalid direction in related()', async () => {
|
|
307
|
+
const author = await provider.create('Author', { name: 'Henry' })
|
|
308
|
+
|
|
309
|
+
await expect(
|
|
310
|
+
provider.related(author.id, 'write', 'invalid' as 'out' | 'in' | 'both')
|
|
311
|
+
).rejects.toThrow('Invalid direction: "invalid". Must be "in", "out", or "both".')
|
|
312
|
+
})
|
|
313
|
+
|
|
314
|
+
it('should throw error for invalid direction in edges()', async () => {
|
|
315
|
+
const author = await provider.create('Author', { name: 'Ivy' })
|
|
316
|
+
|
|
317
|
+
await expect(
|
|
318
|
+
provider.edges(author.id, 'write', 'sideways' as 'out' | 'in' | 'both')
|
|
319
|
+
).rejects.toThrow('Invalid direction: "sideways". Must be "in", "out", or "both".')
|
|
320
|
+
})
|
|
321
|
+
|
|
322
|
+
it('should accept valid direction values', async () => {
|
|
323
|
+
const author = await provider.create('Author', { name: 'Jack' })
|
|
324
|
+
const post = await provider.create('Post', { title: 'Test' })
|
|
325
|
+
await provider.perform('write', author.id, post.id)
|
|
326
|
+
|
|
327
|
+
// All valid directions should work without throwing
|
|
328
|
+
await expect(provider.related(author.id, 'write', 'out')).resolves.toHaveLength(1)
|
|
329
|
+
await expect(provider.related(post.id, 'write', 'in')).resolves.toHaveLength(1)
|
|
330
|
+
await expect(provider.related(author.id, 'write', 'both')).resolves.toHaveLength(1)
|
|
331
|
+
|
|
332
|
+
await expect(provider.edges(author.id, 'write', 'out')).resolves.toHaveLength(1)
|
|
333
|
+
await expect(provider.edges(post.id, 'write', 'in')).resolves.toHaveLength(1)
|
|
334
|
+
await expect(provider.edges(author.id, 'write', 'both')).resolves.toHaveLength(1)
|
|
335
|
+
})
|
|
336
|
+
})
|
|
337
|
+
|
|
338
|
+
describe('Query Limits', () => {
|
|
339
|
+
beforeEach(async () => {
|
|
340
|
+
await provider.defineNoun({ name: 'Item' })
|
|
341
|
+
await provider.defineVerb({ name: 'connect' })
|
|
342
|
+
})
|
|
343
|
+
|
|
344
|
+
it('should apply DEFAULT_LIMIT when no limit specified for list()', async () => {
|
|
345
|
+
// Create more items than DEFAULT_LIMIT
|
|
346
|
+
const itemCount = DEFAULT_LIMIT + 50
|
|
347
|
+
for (let i = 0; i < itemCount; i++) {
|
|
348
|
+
await provider.create('Item', { index: i })
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
const items = await provider.list('Item')
|
|
352
|
+
expect(items).toHaveLength(DEFAULT_LIMIT)
|
|
353
|
+
})
|
|
354
|
+
|
|
355
|
+
it('should enforce MAX_LIMIT for list() even when higher limit requested', async () => {
|
|
356
|
+
// Create more items than MAX_LIMIT
|
|
357
|
+
const itemCount = MAX_LIMIT + 50
|
|
358
|
+
for (let i = 0; i < itemCount; i++) {
|
|
359
|
+
await provider.create('Item', { index: i })
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
const items = await provider.list('Item', { limit: MAX_LIMIT + 500 })
|
|
363
|
+
expect(items).toHaveLength(MAX_LIMIT)
|
|
364
|
+
})
|
|
365
|
+
|
|
366
|
+
it('should respect user limit when below MAX_LIMIT for list()', async () => {
|
|
367
|
+
for (let i = 0; i < 50; i++) {
|
|
368
|
+
await provider.create('Item', { index: i })
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
const items = await provider.list('Item', { limit: 10 })
|
|
372
|
+
expect(items).toHaveLength(10)
|
|
373
|
+
})
|
|
374
|
+
|
|
375
|
+
it('should apply DEFAULT_LIMIT when no limit specified for search()', async () => {
|
|
376
|
+
const itemCount = DEFAULT_LIMIT + 50
|
|
377
|
+
for (let i = 0; i < itemCount; i++) {
|
|
378
|
+
await provider.create('Item', { name: `searchable-${i}` })
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const results = await provider.search('searchable')
|
|
382
|
+
expect(results).toHaveLength(DEFAULT_LIMIT)
|
|
383
|
+
})
|
|
384
|
+
|
|
385
|
+
it('should enforce MAX_LIMIT for search() even when higher limit requested', async () => {
|
|
386
|
+
const itemCount = MAX_LIMIT + 50
|
|
387
|
+
for (let i = 0; i < itemCount; i++) {
|
|
388
|
+
await provider.create('Item', { name: `searchable-${i}` })
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
const results = await provider.search('searchable', { limit: MAX_LIMIT + 500 })
|
|
392
|
+
expect(results).toHaveLength(MAX_LIMIT)
|
|
393
|
+
})
|
|
394
|
+
|
|
395
|
+
it('should apply DEFAULT_LIMIT when no limit specified for listActions()', async () => {
|
|
396
|
+
const item = await provider.create('Item', { name: 'target' })
|
|
397
|
+
const actionCount = DEFAULT_LIMIT + 50
|
|
398
|
+
for (let i = 0; i < actionCount; i++) {
|
|
399
|
+
await provider.perform('connect', undefined, item.id, { index: i })
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
const actions = await provider.listActions()
|
|
403
|
+
expect(actions).toHaveLength(DEFAULT_LIMIT)
|
|
404
|
+
})
|
|
405
|
+
|
|
406
|
+
it('should enforce MAX_LIMIT for listActions() even when higher limit requested', async () => {
|
|
407
|
+
const item = await provider.create('Item', { name: 'target' })
|
|
408
|
+
const actionCount = MAX_LIMIT + 50
|
|
409
|
+
for (let i = 0; i < actionCount; i++) {
|
|
410
|
+
await provider.perform('connect', undefined, item.id, { index: i })
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
const actions = await provider.listActions({ limit: MAX_LIMIT + 500 })
|
|
414
|
+
expect(actions).toHaveLength(MAX_LIMIT)
|
|
415
|
+
})
|
|
416
|
+
|
|
417
|
+
it('should apply DEFAULT_LIMIT when no limit specified for edges()', async () => {
|
|
418
|
+
const source = await provider.create('Item', { name: 'source' })
|
|
419
|
+
const edgeCount = DEFAULT_LIMIT + 50
|
|
420
|
+
for (let i = 0; i < edgeCount; i++) {
|
|
421
|
+
const target = await provider.create('Item', { name: `target-${i}` })
|
|
422
|
+
await provider.perform('connect', source.id, target.id)
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
const edges = await provider.edges(source.id, 'connect', 'out')
|
|
426
|
+
expect(edges).toHaveLength(DEFAULT_LIMIT)
|
|
427
|
+
})
|
|
428
|
+
|
|
429
|
+
it('should apply DEFAULT_LIMIT when no limit specified for related()', async () => {
|
|
430
|
+
const source = await provider.create('Item', { name: 'source' })
|
|
431
|
+
const relatedCount = DEFAULT_LIMIT + 50
|
|
432
|
+
for (let i = 0; i < relatedCount; i++) {
|
|
433
|
+
const target = await provider.create('Item', { name: `target-${i}` })
|
|
434
|
+
await provider.perform('connect', source.id, target.id)
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
const related = await provider.related(source.id, 'connect', 'out')
|
|
438
|
+
expect(related).toHaveLength(DEFAULT_LIMIT)
|
|
439
|
+
})
|
|
440
|
+
|
|
441
|
+
it('should export DEFAULT_LIMIT and MAX_LIMIT constants', () => {
|
|
442
|
+
expect(DEFAULT_LIMIT).toBe(100)
|
|
443
|
+
expect(MAX_LIMIT).toBe(1000)
|
|
444
|
+
})
|
|
445
|
+
})
|
|
446
|
+
|
|
447
|
+
describe('Batch Operations', () => {
|
|
448
|
+
beforeEach(async () => {
|
|
449
|
+
await provider.defineNoun({ name: 'Product' })
|
|
450
|
+
await provider.defineVerb({ name: 'tag' })
|
|
451
|
+
})
|
|
452
|
+
|
|
453
|
+
describe('createMany', () => {
|
|
454
|
+
it('should create multiple things at once', async () => {
|
|
455
|
+
const items = [
|
|
456
|
+
{ name: 'Product A', price: 10 },
|
|
457
|
+
{ name: 'Product B', price: 20 },
|
|
458
|
+
{ name: 'Product C', price: 30 },
|
|
459
|
+
]
|
|
460
|
+
|
|
461
|
+
const created = await provider.createMany('Product', items)
|
|
462
|
+
|
|
463
|
+
expect(created).toHaveLength(3)
|
|
464
|
+
expect(created[0].data.name).toBe('Product A')
|
|
465
|
+
expect(created[1].data.name).toBe('Product B')
|
|
466
|
+
expect(created[2].data.name).toBe('Product C')
|
|
467
|
+
expect(created[0].noun).toBe('Product')
|
|
468
|
+
expect(created[0].id).toBeDefined()
|
|
469
|
+
})
|
|
470
|
+
|
|
471
|
+
it('should return empty array for empty input', async () => {
|
|
472
|
+
const created = await provider.createMany('Product', [])
|
|
473
|
+
expect(created).toHaveLength(0)
|
|
474
|
+
})
|
|
475
|
+
|
|
476
|
+
it('should persist all created things', async () => {
|
|
477
|
+
const items = [
|
|
478
|
+
{ name: 'P1', price: 100 },
|
|
479
|
+
{ name: 'P2', price: 200 },
|
|
480
|
+
]
|
|
481
|
+
|
|
482
|
+
const created = await provider.createMany('Product', items)
|
|
483
|
+
|
|
484
|
+
// Verify all are persisted
|
|
485
|
+
for (const thing of created) {
|
|
486
|
+
const fetched = await provider.get(thing.id)
|
|
487
|
+
expect(fetched).not.toBeNull()
|
|
488
|
+
expect(fetched!.data).toEqual(thing.data)
|
|
489
|
+
}
|
|
490
|
+
})
|
|
491
|
+
})
|
|
492
|
+
|
|
493
|
+
describe('updateMany', () => {
|
|
494
|
+
it('should update multiple things at once', async () => {
|
|
495
|
+
const p1 = await provider.create('Product', { name: 'P1', price: 10 })
|
|
496
|
+
const p2 = await provider.create('Product', { name: 'P2', price: 20 })
|
|
497
|
+
const p3 = await provider.create('Product', { name: 'P3', price: 30 })
|
|
498
|
+
|
|
499
|
+
const updated = await provider.updateMany([
|
|
500
|
+
{ id: p1.id, data: { price: 15 } },
|
|
501
|
+
{ id: p2.id, data: { price: 25 } },
|
|
502
|
+
{ id: p3.id, data: { price: 35, discount: true } },
|
|
503
|
+
])
|
|
504
|
+
|
|
505
|
+
expect(updated).toHaveLength(3)
|
|
506
|
+
expect(updated[0].data.price).toBe(15)
|
|
507
|
+
expect(updated[0].data.name).toBe('P1') // Original data preserved
|
|
508
|
+
expect(updated[1].data.price).toBe(25)
|
|
509
|
+
expect(updated[2].data.price).toBe(35)
|
|
510
|
+
expect(updated[2].data.discount).toBe(true)
|
|
511
|
+
})
|
|
512
|
+
|
|
513
|
+
it('should return empty array for empty input', async () => {
|
|
514
|
+
const updated = await provider.updateMany([])
|
|
515
|
+
expect(updated).toHaveLength(0)
|
|
516
|
+
})
|
|
517
|
+
|
|
518
|
+
it('should throw error if any ID not found', async () => {
|
|
519
|
+
const p1 = await provider.create('Product', { name: 'P1', price: 10 })
|
|
520
|
+
|
|
521
|
+
await expect(
|
|
522
|
+
provider.updateMany([
|
|
523
|
+
{ id: p1.id, data: { price: 15 } },
|
|
524
|
+
{ id: 'non-existent-id', data: { price: 25 } },
|
|
525
|
+
])
|
|
526
|
+
).rejects.toThrow()
|
|
527
|
+
})
|
|
528
|
+
|
|
529
|
+
it('should persist all updates', async () => {
|
|
530
|
+
const p1 = await provider.create('Product', { name: 'P1', price: 10 })
|
|
531
|
+
const p2 = await provider.create('Product', { name: 'P2', price: 20 })
|
|
532
|
+
|
|
533
|
+
await provider.updateMany([
|
|
534
|
+
{ id: p1.id, data: { price: 100 } },
|
|
535
|
+
{ id: p2.id, data: { price: 200 } },
|
|
536
|
+
])
|
|
537
|
+
|
|
538
|
+
const fetched1 = await provider.get(p1.id)
|
|
539
|
+
const fetched2 = await provider.get(p2.id)
|
|
540
|
+
|
|
541
|
+
expect(fetched1!.data.price).toBe(100)
|
|
542
|
+
expect(fetched2!.data.price).toBe(200)
|
|
543
|
+
})
|
|
544
|
+
})
|
|
545
|
+
|
|
546
|
+
describe('deleteMany', () => {
|
|
547
|
+
it('should delete multiple things at once', async () => {
|
|
548
|
+
const p1 = await provider.create('Product', { name: 'P1' })
|
|
549
|
+
const p2 = await provider.create('Product', { name: 'P2' })
|
|
550
|
+
const p3 = await provider.create('Product', { name: 'P3' })
|
|
551
|
+
|
|
552
|
+
const results = await provider.deleteMany([p1.id, p2.id, p3.id])
|
|
553
|
+
|
|
554
|
+
expect(results).toHaveLength(3)
|
|
555
|
+
expect(results).toEqual([true, true, true])
|
|
556
|
+
|
|
557
|
+
// Verify all are deleted
|
|
558
|
+
expect(await provider.get(p1.id)).toBeNull()
|
|
559
|
+
expect(await provider.get(p2.id)).toBeNull()
|
|
560
|
+
expect(await provider.get(p3.id)).toBeNull()
|
|
561
|
+
})
|
|
562
|
+
|
|
563
|
+
it('should return empty array for empty input', async () => {
|
|
564
|
+
const results = await provider.deleteMany([])
|
|
565
|
+
expect(results).toHaveLength(0)
|
|
566
|
+
})
|
|
567
|
+
|
|
568
|
+
it('should return false for non-existent IDs', async () => {
|
|
569
|
+
const p1 = await provider.create('Product', { name: 'P1' })
|
|
570
|
+
|
|
571
|
+
const results = await provider.deleteMany([p1.id, 'non-existent-1', 'non-existent-2'])
|
|
572
|
+
|
|
573
|
+
expect(results).toHaveLength(3)
|
|
574
|
+
expect(results[0]).toBe(true) // Existing was deleted
|
|
575
|
+
expect(results[1]).toBe(false) // Non-existent
|
|
576
|
+
expect(results[2]).toBe(false) // Non-existent
|
|
577
|
+
})
|
|
578
|
+
|
|
579
|
+
it('should only delete specified items', async () => {
|
|
580
|
+
const p1 = await provider.create('Product', { name: 'P1' })
|
|
581
|
+
const p2 = await provider.create('Product', { name: 'P2' })
|
|
582
|
+
const p3 = await provider.create('Product', { name: 'P3' })
|
|
583
|
+
|
|
584
|
+
await provider.deleteMany([p1.id, p3.id])
|
|
585
|
+
|
|
586
|
+
expect(await provider.get(p1.id)).toBeNull()
|
|
587
|
+
expect(await provider.get(p2.id)).not.toBeNull() // P2 should remain
|
|
588
|
+
expect(await provider.get(p3.id)).toBeNull()
|
|
589
|
+
})
|
|
590
|
+
})
|
|
591
|
+
|
|
592
|
+
describe('performMany', () => {
|
|
593
|
+
it('should perform multiple actions at once', async () => {
|
|
594
|
+
const p1 = await provider.create('Product', { name: 'P1' })
|
|
595
|
+
const p2 = await provider.create('Product', { name: 'P2' })
|
|
596
|
+
const p3 = await provider.create('Product', { name: 'P3' })
|
|
597
|
+
|
|
598
|
+
const actions = await provider.performMany([
|
|
599
|
+
{ verb: 'tag', subject: undefined, object: p1.id, data: { tag: 'electronics' } },
|
|
600
|
+
{ verb: 'tag', subject: undefined, object: p2.id, data: { tag: 'clothing' } },
|
|
601
|
+
{ verb: 'tag', subject: undefined, object: p3.id, data: { tag: 'books' } },
|
|
602
|
+
])
|
|
603
|
+
|
|
604
|
+
expect(actions).toHaveLength(3)
|
|
605
|
+
expect(actions[0].verb).toBe('tag')
|
|
606
|
+
expect(actions[0].object).toBe(p1.id)
|
|
607
|
+
expect(actions[0].data?.tag).toBe('electronics')
|
|
608
|
+
expect(actions[1].data?.tag).toBe('clothing')
|
|
609
|
+
expect(actions[2].data?.tag).toBe('books')
|
|
610
|
+
expect(actions[0].status).toBe('completed')
|
|
611
|
+
})
|
|
612
|
+
|
|
613
|
+
it('should return empty array for empty input', async () => {
|
|
614
|
+
const actions = await provider.performMany([])
|
|
615
|
+
expect(actions).toHaveLength(0)
|
|
616
|
+
})
|
|
617
|
+
|
|
618
|
+
it('should persist all performed actions', async () => {
|
|
619
|
+
const p1 = await provider.create('Product', { name: 'P1' })
|
|
620
|
+
const p2 = await provider.create('Product', { name: 'P2' })
|
|
621
|
+
|
|
622
|
+
const performed = await provider.performMany([
|
|
623
|
+
{ verb: 'tag', object: p1.id, data: { tag: 'sale' } },
|
|
624
|
+
{ verb: 'tag', object: p2.id, data: { tag: 'new' } },
|
|
625
|
+
])
|
|
626
|
+
|
|
627
|
+
// Verify all actions are persisted
|
|
628
|
+
for (const action of performed) {
|
|
629
|
+
const fetched = await provider.getAction(action.id)
|
|
630
|
+
expect(fetched).not.toBeNull()
|
|
631
|
+
expect(fetched!.verb).toBe('tag')
|
|
632
|
+
}
|
|
633
|
+
})
|
|
634
|
+
|
|
635
|
+
it('should support actions with subject and object', async () => {
|
|
636
|
+
await provider.defineNoun({ name: 'User' })
|
|
637
|
+
const user = await provider.create('User', { name: 'Admin' })
|
|
638
|
+
const product = await provider.create('Product', { name: 'Item' })
|
|
639
|
+
|
|
640
|
+
const actions = await provider.performMany([
|
|
641
|
+
{ verb: 'tag', subject: user.id, object: product.id, data: { action: 'categorized' } },
|
|
642
|
+
])
|
|
643
|
+
|
|
644
|
+
expect(actions[0].subject).toBe(user.id)
|
|
645
|
+
expect(actions[0].object).toBe(product.id)
|
|
646
|
+
})
|
|
647
|
+
|
|
648
|
+
it('should support actions without data', async () => {
|
|
649
|
+
const p1 = await provider.create('Product', { name: 'P1' })
|
|
650
|
+
|
|
651
|
+
const actions = await provider.performMany([{ verb: 'tag', object: p1.id }])
|
|
652
|
+
|
|
653
|
+
expect(actions).toHaveLength(1)
|
|
654
|
+
expect(actions[0].data).toBeUndefined()
|
|
655
|
+
})
|
|
656
|
+
})
|
|
657
|
+
|
|
658
|
+
describe('batch performance benefit', () => {
|
|
659
|
+
it('should handle large batches efficiently', async () => {
|
|
660
|
+
const items = Array.from({ length: 100 }, (_, i) => ({
|
|
661
|
+
name: `Product ${i}`,
|
|
662
|
+
price: i * 10,
|
|
663
|
+
}))
|
|
664
|
+
|
|
665
|
+
const start = Date.now()
|
|
666
|
+
const created = await provider.createMany('Product', items)
|
|
667
|
+
const duration = Date.now() - start
|
|
668
|
+
|
|
669
|
+
expect(created).toHaveLength(100)
|
|
670
|
+
// Just verify it completes - performance varies by implementation
|
|
671
|
+
expect(duration).toBeLessThan(5000) // Should be much faster, but give buffer
|
|
672
|
+
})
|
|
673
|
+
})
|
|
674
|
+
})
|
|
675
|
+
})
|