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,610 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
|
2
|
+
import { createDBProviderAdapter, type DBProvider } from './ai-database-adapter.js'
|
|
3
|
+
import { MemoryProvider } from './memory-provider.js'
|
|
4
|
+
|
|
5
|
+
describe('ai-database Adapter', () => {
|
|
6
|
+
let memoryProvider: MemoryProvider
|
|
7
|
+
let adapter: DBProvider
|
|
8
|
+
|
|
9
|
+
beforeEach(() => {
|
|
10
|
+
memoryProvider = new MemoryProvider()
|
|
11
|
+
adapter = createDBProviderAdapter(memoryProvider)
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
describe('get(type, id)', () => {
|
|
15
|
+
it('should retrieve an entity by type and id', async () => {
|
|
16
|
+
// Setup: create a thing directly in the memory provider
|
|
17
|
+
await memoryProvider.defineNoun({ name: 'User' })
|
|
18
|
+
const thing = await memoryProvider.create(
|
|
19
|
+
'User',
|
|
20
|
+
{ name: 'Alice', email: 'alice@example.com' },
|
|
21
|
+
'user-1'
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
// Test: get via adapter
|
|
25
|
+
const entity = await adapter.get('User', 'user-1')
|
|
26
|
+
|
|
27
|
+
expect(entity).not.toBeNull()
|
|
28
|
+
expect(entity!.$id).toBe('user-1')
|
|
29
|
+
expect(entity!.$type).toBe('User')
|
|
30
|
+
expect(entity!.name).toBe('Alice')
|
|
31
|
+
expect(entity!.email).toBe('alice@example.com')
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('should return null for non-existent entity', async () => {
|
|
35
|
+
const entity = await adapter.get('User', 'non-existent')
|
|
36
|
+
expect(entity).toBeNull()
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('should return null if entity exists but type does not match', async () => {
|
|
40
|
+
await memoryProvider.defineNoun({ name: 'User' })
|
|
41
|
+
await memoryProvider.create('User', { name: 'Bob' }, 'user-1')
|
|
42
|
+
|
|
43
|
+
// Try to get as a different type
|
|
44
|
+
const entity = await adapter.get('Admin', 'user-1')
|
|
45
|
+
expect(entity).toBeNull()
|
|
46
|
+
})
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
describe('list(type, options)', () => {
|
|
50
|
+
beforeEach(async () => {
|
|
51
|
+
await memoryProvider.defineNoun({ name: 'Product' })
|
|
52
|
+
await memoryProvider.create(
|
|
53
|
+
'Product',
|
|
54
|
+
{ name: 'Apple', price: 1.5, category: 'fruit' },
|
|
55
|
+
'prod-1'
|
|
56
|
+
)
|
|
57
|
+
await memoryProvider.create(
|
|
58
|
+
'Product',
|
|
59
|
+
{ name: 'Banana', price: 0.75, category: 'fruit' },
|
|
60
|
+
'prod-2'
|
|
61
|
+
)
|
|
62
|
+
await memoryProvider.create(
|
|
63
|
+
'Product',
|
|
64
|
+
{ name: 'Carrot', price: 0.5, category: 'vegetable' },
|
|
65
|
+
'prod-3'
|
|
66
|
+
)
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('should list all entities of a type', async () => {
|
|
70
|
+
const entities = await adapter.list('Product')
|
|
71
|
+
|
|
72
|
+
expect(entities).toHaveLength(3)
|
|
73
|
+
expect(entities.every((e) => e.$type === 'Product')).toBe(true)
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('should filter entities with where clause', async () => {
|
|
77
|
+
const entities = await adapter.list('Product', { where: { category: 'fruit' } })
|
|
78
|
+
|
|
79
|
+
expect(entities).toHaveLength(2)
|
|
80
|
+
expect(entities.every((e) => e.category === 'fruit')).toBe(true)
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('should limit results', async () => {
|
|
84
|
+
const entities = await adapter.list('Product', { limit: 2 })
|
|
85
|
+
expect(entities).toHaveLength(2)
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('should offset results', async () => {
|
|
89
|
+
const entities = await adapter.list('Product', { offset: 1 })
|
|
90
|
+
expect(entities).toHaveLength(2)
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it('should order results ascending', async () => {
|
|
94
|
+
const entities = await adapter.list('Product', { orderBy: 'price', order: 'asc' })
|
|
95
|
+
|
|
96
|
+
expect(entities[0].name).toBe('Carrot')
|
|
97
|
+
expect(entities[1].name).toBe('Banana')
|
|
98
|
+
expect(entities[2].name).toBe('Apple')
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it('should order results descending', async () => {
|
|
102
|
+
const entities = await adapter.list('Product', { orderBy: 'price', order: 'desc' })
|
|
103
|
+
|
|
104
|
+
expect(entities[0].name).toBe('Apple')
|
|
105
|
+
expect(entities[1].name).toBe('Banana')
|
|
106
|
+
expect(entities[2].name).toBe('Carrot')
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
it('should combine limit and offset for pagination', async () => {
|
|
110
|
+
const page1 = await adapter.list('Product', { limit: 2, offset: 0 })
|
|
111
|
+
const page2 = await adapter.list('Product', { limit: 2, offset: 2 })
|
|
112
|
+
|
|
113
|
+
expect(page1).toHaveLength(2)
|
|
114
|
+
expect(page2).toHaveLength(1)
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it('should return empty array for unknown type', async () => {
|
|
118
|
+
const entities = await adapter.list('Unknown')
|
|
119
|
+
expect(entities).toEqual([])
|
|
120
|
+
})
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
describe('search(type, query, options)', () => {
|
|
124
|
+
beforeEach(async () => {
|
|
125
|
+
await memoryProvider.defineNoun({ name: 'Article' })
|
|
126
|
+
await memoryProvider.defineNoun({ name: 'Comment' })
|
|
127
|
+
await memoryProvider.create(
|
|
128
|
+
'Article',
|
|
129
|
+
{ title: 'TypeScript Guide', content: 'Learn TypeScript basics' },
|
|
130
|
+
'art-1'
|
|
131
|
+
)
|
|
132
|
+
await memoryProvider.create(
|
|
133
|
+
'Article',
|
|
134
|
+
{ title: 'JavaScript Tips', content: 'Improve your JS skills' },
|
|
135
|
+
'art-2'
|
|
136
|
+
)
|
|
137
|
+
await memoryProvider.create('Comment', { text: 'Great TypeScript article!' }, 'com-1')
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
it('should search entities by query', async () => {
|
|
141
|
+
const entities = await adapter.search('Article', 'TypeScript')
|
|
142
|
+
|
|
143
|
+
expect(entities).toHaveLength(1)
|
|
144
|
+
expect(entities[0].$id).toBe('art-1')
|
|
145
|
+
expect(entities[0].$type).toBe('Article')
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
it('should filter by type (not return other types)', async () => {
|
|
149
|
+
// Search for 'TypeScript' which exists in both Article and Comment
|
|
150
|
+
const articles = await adapter.search('Article', 'TypeScript')
|
|
151
|
+
const comments = await adapter.search('Comment', 'TypeScript')
|
|
152
|
+
|
|
153
|
+
expect(articles).toHaveLength(1)
|
|
154
|
+
expect(articles[0].$type).toBe('Article')
|
|
155
|
+
expect(comments).toHaveLength(1)
|
|
156
|
+
expect(comments[0].$type).toBe('Comment')
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
it('should be case-insensitive', async () => {
|
|
160
|
+
const entities = await adapter.search('Article', 'typescript')
|
|
161
|
+
expect(entities).toHaveLength(1)
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
it('should return empty array for no matches', async () => {
|
|
165
|
+
const entities = await adapter.search('Article', 'Python')
|
|
166
|
+
expect(entities).toEqual([])
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
it('should respect limit option', async () => {
|
|
170
|
+
await memoryProvider.create(
|
|
171
|
+
'Article',
|
|
172
|
+
{ title: 'More TypeScript', content: 'Advanced TypeScript' },
|
|
173
|
+
'art-3'
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
// The search matches all content containing the query
|
|
177
|
+
const entities = await adapter.search('Article', 'TypeScript', { limit: 1 })
|
|
178
|
+
expect(entities).toHaveLength(1)
|
|
179
|
+
})
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
describe('create(type, id, data)', () => {
|
|
183
|
+
it('should create an entity with provided id', async () => {
|
|
184
|
+
const entity = await adapter.create('Task', 'task-1', {
|
|
185
|
+
title: 'Complete tests',
|
|
186
|
+
done: false,
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
expect(entity.$id).toBe('task-1')
|
|
190
|
+
expect(entity.$type).toBe('Task')
|
|
191
|
+
expect(entity.title).toBe('Complete tests')
|
|
192
|
+
expect(entity.done).toBe(false)
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
it('should create an entity with auto-generated id', async () => {
|
|
196
|
+
const entity = await adapter.create('Task', undefined, { title: 'Auto ID task' })
|
|
197
|
+
|
|
198
|
+
expect(entity.$id).toBeDefined()
|
|
199
|
+
expect(entity.$id).not.toBe('')
|
|
200
|
+
expect(entity.$type).toBe('Task')
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
it('should auto-define noun if not already defined', async () => {
|
|
204
|
+
// Noun does not exist yet
|
|
205
|
+
let noun = await memoryProvider.getNoun('NewType')
|
|
206
|
+
expect(noun).toBeNull()
|
|
207
|
+
|
|
208
|
+
// Create via adapter
|
|
209
|
+
await adapter.create('NewType', 'nt-1', { value: 42 })
|
|
210
|
+
|
|
211
|
+
// Noun should now exist
|
|
212
|
+
noun = await memoryProvider.getNoun('NewType')
|
|
213
|
+
expect(noun).not.toBeNull()
|
|
214
|
+
expect(noun!.name).toBe('NewType')
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
it('should not re-define noun if already exists', async () => {
|
|
218
|
+
// Pre-define noun with description
|
|
219
|
+
await memoryProvider.defineNoun({ name: 'Widget', description: 'A widget thing' })
|
|
220
|
+
|
|
221
|
+
// Create via adapter
|
|
222
|
+
await adapter.create('Widget', 'w-1', { size: 'large' })
|
|
223
|
+
|
|
224
|
+
// Original noun should still have description
|
|
225
|
+
const noun = await memoryProvider.getNoun('Widget')
|
|
226
|
+
expect(noun!.description).toBe('A widget thing')
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
it('should strip $id and $type from data before storing', async () => {
|
|
230
|
+
const entity = await adapter.create('Item', 'item-1', {
|
|
231
|
+
$id: 'should-be-ignored',
|
|
232
|
+
$type: 'ShouldBeIgnored',
|
|
233
|
+
name: 'Real Name',
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
// Entity should have correct $id and $type
|
|
237
|
+
expect(entity.$id).toBe('item-1')
|
|
238
|
+
expect(entity.$type).toBe('Item')
|
|
239
|
+
expect(entity.name).toBe('Real Name')
|
|
240
|
+
|
|
241
|
+
// Underlying thing should not have $id/$type in data
|
|
242
|
+
const thing = await memoryProvider.get('item-1')
|
|
243
|
+
expect(thing!.data).not.toHaveProperty('$id')
|
|
244
|
+
expect(thing!.data).not.toHaveProperty('$type')
|
|
245
|
+
})
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
describe('update(type, id, data)', () => {
|
|
249
|
+
beforeEach(async () => {
|
|
250
|
+
await memoryProvider.defineNoun({ name: 'Note' })
|
|
251
|
+
await memoryProvider.create(
|
|
252
|
+
'Note',
|
|
253
|
+
{ title: 'Original', content: 'Initial content' },
|
|
254
|
+
'note-1'
|
|
255
|
+
)
|
|
256
|
+
})
|
|
257
|
+
|
|
258
|
+
it('should update an entity', async () => {
|
|
259
|
+
const updated = await adapter.update('Note', 'note-1', { title: 'Updated Title' })
|
|
260
|
+
|
|
261
|
+
expect(updated.$id).toBe('note-1')
|
|
262
|
+
expect(updated.$type).toBe('Note')
|
|
263
|
+
expect(updated.title).toBe('Updated Title')
|
|
264
|
+
expect(updated.content).toBe('Initial content') // Preserved
|
|
265
|
+
})
|
|
266
|
+
|
|
267
|
+
it('should merge new data with existing data', async () => {
|
|
268
|
+
await adapter.update('Note', 'note-1', { tags: ['important'] })
|
|
269
|
+
|
|
270
|
+
const entity = await adapter.get('Note', 'note-1')
|
|
271
|
+
expect(entity!.title).toBe('Original')
|
|
272
|
+
expect(entity!.content).toBe('Initial content')
|
|
273
|
+
expect(entity!.tags).toEqual(['important'])
|
|
274
|
+
})
|
|
275
|
+
|
|
276
|
+
it('should throw error for non-existent entity', async () => {
|
|
277
|
+
await expect(adapter.update('Note', 'non-existent', { title: 'Test' })).rejects.toThrow(
|
|
278
|
+
'Thing not found'
|
|
279
|
+
)
|
|
280
|
+
})
|
|
281
|
+
|
|
282
|
+
it('should strip $id and $type from update data', async () => {
|
|
283
|
+
const updated = await adapter.update('Note', 'note-1', {
|
|
284
|
+
$id: 'ignored',
|
|
285
|
+
$type: 'Ignored',
|
|
286
|
+
content: 'New content',
|
|
287
|
+
})
|
|
288
|
+
|
|
289
|
+
expect(updated.$id).toBe('note-1')
|
|
290
|
+
expect(updated.$type).toBe('Note')
|
|
291
|
+
expect(updated.content).toBe('New content')
|
|
292
|
+
})
|
|
293
|
+
})
|
|
294
|
+
|
|
295
|
+
describe('delete(type, id)', () => {
|
|
296
|
+
beforeEach(async () => {
|
|
297
|
+
await memoryProvider.defineNoun({ name: 'Record' })
|
|
298
|
+
await memoryProvider.create('Record', { value: 123 }, 'rec-1')
|
|
299
|
+
})
|
|
300
|
+
|
|
301
|
+
it('should delete an existing entity', async () => {
|
|
302
|
+
const result = await adapter.delete('Record', 'rec-1')
|
|
303
|
+
|
|
304
|
+
expect(result).toBe(true)
|
|
305
|
+
expect(await adapter.get('Record', 'rec-1')).toBeNull()
|
|
306
|
+
})
|
|
307
|
+
|
|
308
|
+
it('should return false for non-existent entity', async () => {
|
|
309
|
+
const result = await adapter.delete('Record', 'non-existent')
|
|
310
|
+
expect(result).toBe(false)
|
|
311
|
+
})
|
|
312
|
+
|
|
313
|
+
it('should delete entity regardless of type parameter', async () => {
|
|
314
|
+
// The adapter's delete doesn't check type, just passes id to provider
|
|
315
|
+
const result = await adapter.delete('WrongType', 'rec-1')
|
|
316
|
+
expect(result).toBe(true)
|
|
317
|
+
expect(await memoryProvider.get('rec-1')).toBeNull()
|
|
318
|
+
})
|
|
319
|
+
})
|
|
320
|
+
|
|
321
|
+
describe('related(type, id, relation)', () => {
|
|
322
|
+
beforeEach(async () => {
|
|
323
|
+
await memoryProvider.defineNoun({ name: 'Author' })
|
|
324
|
+
await memoryProvider.defineNoun({ name: 'Book' })
|
|
325
|
+
await memoryProvider.defineNoun({ name: 'Publisher' })
|
|
326
|
+
await memoryProvider.defineVerb({ name: 'wrote' })
|
|
327
|
+
await memoryProvider.defineVerb({ name: 'publishedBy' })
|
|
328
|
+
|
|
329
|
+
await memoryProvider.create('Author', { name: 'Stephen King' }, 'author-1')
|
|
330
|
+
await memoryProvider.create('Book', { title: 'The Shining' }, 'book-1')
|
|
331
|
+
await memoryProvider.create('Book', { title: 'It' }, 'book-2')
|
|
332
|
+
await memoryProvider.create('Publisher', { name: 'Scribner' }, 'pub-1')
|
|
333
|
+
|
|
334
|
+
// Author wrote books
|
|
335
|
+
await memoryProvider.perform('wrote', 'author-1', 'book-1')
|
|
336
|
+
await memoryProvider.perform('wrote', 'author-1', 'book-2')
|
|
337
|
+
// Book published by publisher
|
|
338
|
+
await memoryProvider.perform('publishedBy', 'book-1', 'pub-1')
|
|
339
|
+
})
|
|
340
|
+
|
|
341
|
+
it('should get related entities of specified type via relation', async () => {
|
|
342
|
+
const books = await adapter.related('Book', 'author-1', 'wrote')
|
|
343
|
+
|
|
344
|
+
expect(books).toHaveLength(2)
|
|
345
|
+
expect(books.every((b) => b.$type === 'Book')).toBe(true)
|
|
346
|
+
expect(books.map((b) => b.title)).toContain('The Shining')
|
|
347
|
+
expect(books.map((b) => b.title)).toContain('It')
|
|
348
|
+
})
|
|
349
|
+
|
|
350
|
+
it('should filter results by type', async () => {
|
|
351
|
+
// Even if author has relations to books, asking for Publisher type should return empty
|
|
352
|
+
const publishers = await adapter.related('Publisher', 'author-1', 'wrote')
|
|
353
|
+
expect(publishers).toEqual([])
|
|
354
|
+
})
|
|
355
|
+
|
|
356
|
+
it('should handle inbound relations (both direction)', async () => {
|
|
357
|
+
// Get authors related to book-1 via 'wrote'
|
|
358
|
+
const authors = await adapter.related('Author', 'book-1', 'wrote')
|
|
359
|
+
|
|
360
|
+
expect(authors).toHaveLength(1)
|
|
361
|
+
expect(authors[0].name).toBe('Stephen King')
|
|
362
|
+
})
|
|
363
|
+
|
|
364
|
+
it('should return empty array for entity with no relations', async () => {
|
|
365
|
+
await memoryProvider.create('Author', { name: 'New Author' }, 'author-2')
|
|
366
|
+
|
|
367
|
+
const books = await adapter.related('Book', 'author-2', 'wrote')
|
|
368
|
+
expect(books).toEqual([])
|
|
369
|
+
})
|
|
370
|
+
|
|
371
|
+
it('should return empty array for unknown relation', async () => {
|
|
372
|
+
const items = await adapter.related('Book', 'author-1', 'unknownRelation')
|
|
373
|
+
expect(items).toEqual([])
|
|
374
|
+
})
|
|
375
|
+
})
|
|
376
|
+
|
|
377
|
+
describe('relate(fromType, fromId, relation, toType, toId)', () => {
|
|
378
|
+
beforeEach(async () => {
|
|
379
|
+
await memoryProvider.defineNoun({ name: 'Person' })
|
|
380
|
+
await memoryProvider.defineNoun({ name: 'Project' })
|
|
381
|
+
await memoryProvider.create('Person', { name: 'Alice' }, 'person-1')
|
|
382
|
+
await memoryProvider.create('Project', { name: 'Secret Project' }, 'project-1')
|
|
383
|
+
})
|
|
384
|
+
|
|
385
|
+
it('should create a relation between entities', async () => {
|
|
386
|
+
await adapter.relate('Person', 'person-1', 'worksOn', 'Project', 'project-1')
|
|
387
|
+
|
|
388
|
+
// Verify via related
|
|
389
|
+
const projects = await adapter.related('Project', 'person-1', 'worksOn')
|
|
390
|
+
expect(projects).toHaveLength(1)
|
|
391
|
+
expect(projects[0].$id).toBe('project-1')
|
|
392
|
+
})
|
|
393
|
+
|
|
394
|
+
it('should auto-define verb if not already defined', async () => {
|
|
395
|
+
// Verb does not exist yet
|
|
396
|
+
let verb = await memoryProvider.getVerb('manages')
|
|
397
|
+
expect(verb).toBeNull()
|
|
398
|
+
|
|
399
|
+
// Create relation
|
|
400
|
+
await adapter.relate('Person', 'person-1', 'manages', 'Project', 'project-1')
|
|
401
|
+
|
|
402
|
+
// Verb should now exist
|
|
403
|
+
verb = await memoryProvider.getVerb('manages')
|
|
404
|
+
expect(verb).not.toBeNull()
|
|
405
|
+
expect(verb!.name).toBe('manages')
|
|
406
|
+
})
|
|
407
|
+
|
|
408
|
+
it('should not re-define verb if already exists', async () => {
|
|
409
|
+
// Pre-define verb with inverse
|
|
410
|
+
await memoryProvider.defineVerb({ name: 'owns', inverse: 'ownedBy' })
|
|
411
|
+
|
|
412
|
+
// Create relation
|
|
413
|
+
await adapter.relate('Person', 'person-1', 'owns', 'Project', 'project-1')
|
|
414
|
+
|
|
415
|
+
// Original verb should still have inverse
|
|
416
|
+
const verb = await memoryProvider.getVerb('owns')
|
|
417
|
+
expect(verb!.inverse).toBe('ownedBy')
|
|
418
|
+
})
|
|
419
|
+
|
|
420
|
+
it('should pass metadata to perform', async () => {
|
|
421
|
+
await memoryProvider.defineVerb({ name: 'likes' })
|
|
422
|
+
|
|
423
|
+
await adapter.relate('Person', 'person-1', 'likes', 'Project', 'project-1', {
|
|
424
|
+
matchMode: 'fuzzy',
|
|
425
|
+
similarity: 0.85,
|
|
426
|
+
matchedType: 'Project',
|
|
427
|
+
})
|
|
428
|
+
|
|
429
|
+
// Verify action was created with metadata
|
|
430
|
+
const actions = await memoryProvider.listActions({ verb: 'likes' })
|
|
431
|
+
expect(actions).toHaveLength(1)
|
|
432
|
+
expect(actions[0].data).toEqual({
|
|
433
|
+
matchMode: 'fuzzy',
|
|
434
|
+
similarity: 0.85,
|
|
435
|
+
matchedType: 'Project',
|
|
436
|
+
})
|
|
437
|
+
})
|
|
438
|
+
|
|
439
|
+
it('should allow multiple relations between same entities', async () => {
|
|
440
|
+
await adapter.relate('Person', 'person-1', 'owns', 'Project', 'project-1')
|
|
441
|
+
await adapter.relate('Person', 'person-1', 'manages', 'Project', 'project-1')
|
|
442
|
+
|
|
443
|
+
const ownedProjects = await adapter.related('Project', 'person-1', 'owns')
|
|
444
|
+
const managedProjects = await adapter.related('Project', 'person-1', 'manages')
|
|
445
|
+
|
|
446
|
+
expect(ownedProjects).toHaveLength(1)
|
|
447
|
+
expect(managedProjects).toHaveLength(1)
|
|
448
|
+
})
|
|
449
|
+
})
|
|
450
|
+
|
|
451
|
+
describe('unrelate()', () => {
|
|
452
|
+
it('should delete the action representing the relation', async () => {
|
|
453
|
+
await memoryProvider.defineNoun({ name: 'Person' })
|
|
454
|
+
await memoryProvider.defineNoun({ name: 'Project' })
|
|
455
|
+
await memoryProvider.defineVerb({ name: 'owns' })
|
|
456
|
+
await memoryProvider.create('Person', { name: 'Test' }, 'person-1')
|
|
457
|
+
await memoryProvider.create('Project', { name: 'Proj' }, 'project-1')
|
|
458
|
+
|
|
459
|
+
// Create a relation
|
|
460
|
+
await adapter.relate('Person', 'person-1', 'owns', 'Project', 'project-1')
|
|
461
|
+
|
|
462
|
+
// Verify relation exists
|
|
463
|
+
const actionsBefore = await memoryProvider.listActions({
|
|
464
|
+
verb: 'owns',
|
|
465
|
+
subject: 'person-1',
|
|
466
|
+
object: 'project-1',
|
|
467
|
+
})
|
|
468
|
+
expect(actionsBefore).toHaveLength(1)
|
|
469
|
+
|
|
470
|
+
// Unrelate
|
|
471
|
+
await adapter.unrelate('Person', 'person-1', 'owns', 'Project', 'project-1')
|
|
472
|
+
|
|
473
|
+
// Verify relation is deleted
|
|
474
|
+
const actionsAfter = await memoryProvider.listActions({
|
|
475
|
+
verb: 'owns',
|
|
476
|
+
subject: 'person-1',
|
|
477
|
+
object: 'project-1',
|
|
478
|
+
})
|
|
479
|
+
expect(actionsAfter).toHaveLength(0)
|
|
480
|
+
})
|
|
481
|
+
|
|
482
|
+
it('should not throw an error when no relation exists', async () => {
|
|
483
|
+
await expect(adapter.unrelate('A', 'a1', 'rel', 'B', 'b1')).resolves.toBeUndefined()
|
|
484
|
+
})
|
|
485
|
+
|
|
486
|
+
it('should delete multiple matching actions (GDPR compliance)', async () => {
|
|
487
|
+
await memoryProvider.defineNoun({ name: 'User' })
|
|
488
|
+
await memoryProvider.defineNoun({ name: 'Document' })
|
|
489
|
+
await memoryProvider.defineVerb({ name: 'viewed' })
|
|
490
|
+
await memoryProvider.create('User', { name: 'Test' }, 'user-1')
|
|
491
|
+
await memoryProvider.create('Document', { name: 'Doc' }, 'doc-1')
|
|
492
|
+
|
|
493
|
+
// Create multiple view actions (e.g., user viewed same document multiple times)
|
|
494
|
+
await adapter.relate('User', 'user-1', 'viewed', 'Document', 'doc-1')
|
|
495
|
+
await adapter.relate('User', 'user-1', 'viewed', 'Document', 'doc-1')
|
|
496
|
+
await adapter.relate('User', 'user-1', 'viewed', 'Document', 'doc-1')
|
|
497
|
+
|
|
498
|
+
// Verify 3 relations exist
|
|
499
|
+
const actionsBefore = await memoryProvider.listActions({
|
|
500
|
+
verb: 'viewed',
|
|
501
|
+
subject: 'user-1',
|
|
502
|
+
object: 'doc-1',
|
|
503
|
+
})
|
|
504
|
+
expect(actionsBefore).toHaveLength(3)
|
|
505
|
+
|
|
506
|
+
// Unrelate should delete all
|
|
507
|
+
await adapter.unrelate('User', 'user-1', 'viewed', 'Document', 'doc-1')
|
|
508
|
+
|
|
509
|
+
// Verify all relations are deleted
|
|
510
|
+
const actionsAfter = await memoryProvider.listActions({
|
|
511
|
+
verb: 'viewed',
|
|
512
|
+
subject: 'user-1',
|
|
513
|
+
object: 'doc-1',
|
|
514
|
+
})
|
|
515
|
+
expect(actionsAfter).toHaveLength(0)
|
|
516
|
+
})
|
|
517
|
+
})
|
|
518
|
+
|
|
519
|
+
describe('Entity format conversion', () => {
|
|
520
|
+
it('should convert Thing to entity format with $id and $type', async () => {
|
|
521
|
+
await memoryProvider.defineNoun({ name: 'Entity' })
|
|
522
|
+
await memoryProvider.create('Entity', { foo: 'bar', count: 42 }, 'e-1')
|
|
523
|
+
|
|
524
|
+
const entity = await adapter.get('Entity', 'e-1')
|
|
525
|
+
|
|
526
|
+
expect(entity).toEqual({
|
|
527
|
+
$id: 'e-1',
|
|
528
|
+
$type: 'Entity',
|
|
529
|
+
foo: 'bar',
|
|
530
|
+
count: 42,
|
|
531
|
+
})
|
|
532
|
+
})
|
|
533
|
+
|
|
534
|
+
it('should preserve all data fields when converting', async () => {
|
|
535
|
+
await memoryProvider.defineNoun({ name: 'Complex' })
|
|
536
|
+
await memoryProvider.create(
|
|
537
|
+
'Complex',
|
|
538
|
+
{
|
|
539
|
+
string: 'text',
|
|
540
|
+
number: 123,
|
|
541
|
+
boolean: true,
|
|
542
|
+
array: [1, 2, 3],
|
|
543
|
+
nested: { a: 1, b: 2 },
|
|
544
|
+
null: null,
|
|
545
|
+
},
|
|
546
|
+
'c-1'
|
|
547
|
+
)
|
|
548
|
+
|
|
549
|
+
const entity = await adapter.get('Complex', 'c-1')
|
|
550
|
+
|
|
551
|
+
expect(entity!.string).toBe('text')
|
|
552
|
+
expect(entity!.number).toBe(123)
|
|
553
|
+
expect(entity!.boolean).toBe(true)
|
|
554
|
+
expect(entity!.array).toEqual([1, 2, 3])
|
|
555
|
+
expect(entity!.nested).toEqual({ a: 1, b: 2 })
|
|
556
|
+
expect(entity!.null).toBeNull()
|
|
557
|
+
})
|
|
558
|
+
})
|
|
559
|
+
|
|
560
|
+
describe('Integration scenarios', () => {
|
|
561
|
+
it('should handle a complete CRUD workflow', async () => {
|
|
562
|
+
// Create
|
|
563
|
+
const created = await adapter.create('Todo', 'todo-1', {
|
|
564
|
+
title: 'Write tests',
|
|
565
|
+
completed: false,
|
|
566
|
+
})
|
|
567
|
+
expect(created.$id).toBe('todo-1')
|
|
568
|
+
|
|
569
|
+
// Read
|
|
570
|
+
const read = await adapter.get('Todo', 'todo-1')
|
|
571
|
+
expect(read!.title).toBe('Write tests')
|
|
572
|
+
|
|
573
|
+
// Update
|
|
574
|
+
const updated = await adapter.update('Todo', 'todo-1', { completed: true })
|
|
575
|
+
expect(updated.completed).toBe(true)
|
|
576
|
+
|
|
577
|
+
// List
|
|
578
|
+
const list = await adapter.list('Todo')
|
|
579
|
+
expect(list).toHaveLength(1)
|
|
580
|
+
|
|
581
|
+
// Delete
|
|
582
|
+
const deleted = await adapter.delete('Todo', 'todo-1')
|
|
583
|
+
expect(deleted).toBe(true)
|
|
584
|
+
|
|
585
|
+
// Verify deletion
|
|
586
|
+
const afterDelete = await adapter.get('Todo', 'todo-1')
|
|
587
|
+
expect(afterDelete).toBeNull()
|
|
588
|
+
})
|
|
589
|
+
|
|
590
|
+
it('should handle a graph workflow with relations', async () => {
|
|
591
|
+
// Create entities
|
|
592
|
+
await adapter.create('Team', 'team-1', { name: 'Engineering' })
|
|
593
|
+
await adapter.create('Member', 'member-1', { name: 'Alice' })
|
|
594
|
+
await adapter.create('Member', 'member-2', { name: 'Bob' })
|
|
595
|
+
|
|
596
|
+
// Create relations
|
|
597
|
+
await adapter.relate('Member', 'member-1', 'belongsTo', 'Team', 'team-1')
|
|
598
|
+
await adapter.relate('Member', 'member-2', 'belongsTo', 'Team', 'team-1')
|
|
599
|
+
await adapter.relate('Member', 'member-1', 'manages', 'Member', 'member-2')
|
|
600
|
+
|
|
601
|
+
// Query relations
|
|
602
|
+
const teamMembers = await adapter.related('Member', 'team-1', 'belongsTo')
|
|
603
|
+
expect(teamMembers).toHaveLength(2)
|
|
604
|
+
|
|
605
|
+
const managed = await adapter.related('Member', 'member-1', 'manages')
|
|
606
|
+
expect(managed).toHaveLength(1)
|
|
607
|
+
expect(managed[0].name).toBe('Bob')
|
|
608
|
+
})
|
|
609
|
+
})
|
|
610
|
+
})
|