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
package/src/types.ts
ADDED
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Core types for Digital Objects
|
|
3
|
+
*
|
|
4
|
+
* A unified nouns/verbs/things/actions model that provides:
|
|
5
|
+
* - Linguistic consistency (singular/plural, conjugations)
|
|
6
|
+
* - Entity definitions (Nouns) and instances (Things)
|
|
7
|
+
* - Action definitions (Verbs) and instances (Actions)
|
|
8
|
+
* - Graph relationships through Actions
|
|
9
|
+
* - Event sourcing and audit trails
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Query limit constants to prevent memory exhaustion
|
|
14
|
+
*/
|
|
15
|
+
export const DEFAULT_LIMIT = 100
|
|
16
|
+
export const MAX_LIMIT = 1000
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Maximum batch size to prevent DoS attacks
|
|
20
|
+
*/
|
|
21
|
+
export const MAX_BATCH_SIZE = 1000
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Direction for graph traversal
|
|
25
|
+
*/
|
|
26
|
+
export type Direction = 'in' | 'out' | 'both'
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Validates direction parameter for graph traversal methods.
|
|
30
|
+
* @throws Error if direction is not 'in', 'out', or 'both'
|
|
31
|
+
*/
|
|
32
|
+
export function validateDirection(direction: string): Direction {
|
|
33
|
+
if (direction !== 'in' && direction !== 'out' && direction !== 'both') {
|
|
34
|
+
throw new Error(`Invalid direction: "${direction}". Must be "in", "out", or "both".`)
|
|
35
|
+
}
|
|
36
|
+
return direction
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Noun - Entity type definition with linguistic forms
|
|
41
|
+
*/
|
|
42
|
+
export interface Noun {
|
|
43
|
+
name: string // 'Post', 'Author'
|
|
44
|
+
singular: string // 'post', 'author'
|
|
45
|
+
plural: string // 'posts', 'authors'
|
|
46
|
+
slug: string // URL-safe: 'post', 'author'
|
|
47
|
+
description?: string
|
|
48
|
+
schema?: Record<string, FieldDefinition>
|
|
49
|
+
createdAt: Date
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface NounDefinition {
|
|
53
|
+
name: string
|
|
54
|
+
singular?: string // Auto-derived if not provided
|
|
55
|
+
plural?: string // Auto-derived if not provided
|
|
56
|
+
description?: string
|
|
57
|
+
schema?: Record<string, FieldDefinition>
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Verb - Action definition with all conjugations
|
|
62
|
+
*/
|
|
63
|
+
export interface Verb {
|
|
64
|
+
name: string // 'create', 'publish'
|
|
65
|
+
action: string // 'create' (imperative)
|
|
66
|
+
act: string // 'creates' (3rd person)
|
|
67
|
+
activity: string // 'creating' (gerund)
|
|
68
|
+
event: string // 'created' (past participle)
|
|
69
|
+
reverseBy?: string // 'createdBy'
|
|
70
|
+
reverseAt?: string // 'createdAt'
|
|
71
|
+
reverseIn?: string // 'createdIn'
|
|
72
|
+
inverse?: string // 'delete'
|
|
73
|
+
description?: string
|
|
74
|
+
createdAt: Date
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface VerbDefinition {
|
|
78
|
+
name: string
|
|
79
|
+
// All forms auto-derived if not provided
|
|
80
|
+
action?: string
|
|
81
|
+
act?: string
|
|
82
|
+
activity?: string
|
|
83
|
+
event?: string
|
|
84
|
+
reverseBy?: string
|
|
85
|
+
reverseAt?: string
|
|
86
|
+
reverseIn?: string
|
|
87
|
+
inverse?: string
|
|
88
|
+
description?: string
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Thing - Entity instance
|
|
93
|
+
*/
|
|
94
|
+
export interface Thing<T = Record<string, unknown>> {
|
|
95
|
+
id: string
|
|
96
|
+
noun: string // References noun.name
|
|
97
|
+
data: T
|
|
98
|
+
createdAt: Date
|
|
99
|
+
updatedAt: Date
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Action - Events + Relationships + Audit Trail (unified!)
|
|
104
|
+
*
|
|
105
|
+
* An action represents:
|
|
106
|
+
* - A graph edge (subject --verb--> object)
|
|
107
|
+
* - An event (something happened)
|
|
108
|
+
* - An audit record (who did what when)
|
|
109
|
+
*/
|
|
110
|
+
export interface Action<T = Record<string, unknown>> {
|
|
111
|
+
id: string
|
|
112
|
+
verb: string // References verb.name
|
|
113
|
+
subject?: string // Thing ID (actor/from)
|
|
114
|
+
object?: string // Thing ID (target/to)
|
|
115
|
+
data?: T // Payload/metadata
|
|
116
|
+
status: ActionStatusType
|
|
117
|
+
createdAt: Date
|
|
118
|
+
completedAt?: Date
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* ActionStatus constants - use these instead of string literals
|
|
123
|
+
*/
|
|
124
|
+
export const ActionStatus = {
|
|
125
|
+
PENDING: 'pending',
|
|
126
|
+
ACTIVE: 'active',
|
|
127
|
+
COMPLETED: 'completed',
|
|
128
|
+
FAILED: 'failed',
|
|
129
|
+
CANCELLED: 'cancelled',
|
|
130
|
+
} as const
|
|
131
|
+
|
|
132
|
+
export type ActionStatusType = (typeof ActionStatus)[keyof typeof ActionStatus]
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Field definition for schemas
|
|
136
|
+
*
|
|
137
|
+
* Can be either a simple type string or an extended definition with options
|
|
138
|
+
*/
|
|
139
|
+
export type FieldDefinition = SimpleFieldType | ExtendedFieldDefinition
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Simple field type - just a type string
|
|
143
|
+
*/
|
|
144
|
+
export type SimpleFieldType =
|
|
145
|
+
| PrimitiveType
|
|
146
|
+
| `${string}.${string}` // Relation: 'Author.posts'
|
|
147
|
+
| `[${string}.${string}]` // Array relation: '[Tag.posts]'
|
|
148
|
+
| `${PrimitiveType}?` // Optional
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Extended field definition with options like required, default, etc.
|
|
152
|
+
*/
|
|
153
|
+
export interface ExtendedFieldDefinition {
|
|
154
|
+
type: PrimitiveType | 'object' | 'array'
|
|
155
|
+
required?: boolean
|
|
156
|
+
default?: unknown
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export type PrimitiveType =
|
|
160
|
+
| 'string'
|
|
161
|
+
| 'number'
|
|
162
|
+
| 'boolean'
|
|
163
|
+
| 'date'
|
|
164
|
+
| 'datetime'
|
|
165
|
+
| 'json'
|
|
166
|
+
| 'markdown'
|
|
167
|
+
| 'url'
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Validation options for create/update operations
|
|
171
|
+
*/
|
|
172
|
+
export interface ValidationOptions {
|
|
173
|
+
validate?: boolean
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* List options for queries
|
|
178
|
+
*/
|
|
179
|
+
export interface ListOptions {
|
|
180
|
+
limit?: number
|
|
181
|
+
offset?: number
|
|
182
|
+
where?: Record<string, unknown>
|
|
183
|
+
orderBy?: string
|
|
184
|
+
order?: 'asc' | 'desc'
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Action query options
|
|
189
|
+
*/
|
|
190
|
+
export interface ActionOptions extends ListOptions {
|
|
191
|
+
verb?: string
|
|
192
|
+
subject?: string
|
|
193
|
+
object?: string
|
|
194
|
+
status?: ActionStatusType | ActionStatusType[]
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* DigitalObjectsProvider - Core storage interface
|
|
199
|
+
*
|
|
200
|
+
* Implementations: MemoryProvider, NS (DurableObject)
|
|
201
|
+
*/
|
|
202
|
+
export interface DigitalObjectsProvider {
|
|
203
|
+
// Nouns
|
|
204
|
+
defineNoun(def: NounDefinition): Promise<Noun>
|
|
205
|
+
getNoun(name: string): Promise<Noun | null>
|
|
206
|
+
listNouns(): Promise<Noun[]>
|
|
207
|
+
|
|
208
|
+
// Verbs
|
|
209
|
+
defineVerb(def: VerbDefinition): Promise<Verb>
|
|
210
|
+
getVerb(name: string): Promise<Verb | null>
|
|
211
|
+
listVerbs(): Promise<Verb[]>
|
|
212
|
+
|
|
213
|
+
// Things
|
|
214
|
+
create<T>(noun: string, data: T, id?: string, options?: ValidationOptions): Promise<Thing<T>>
|
|
215
|
+
get<T>(id: string): Promise<Thing<T> | null>
|
|
216
|
+
list<T>(noun: string, options?: ListOptions): Promise<Thing<T>[]>
|
|
217
|
+
find<T>(noun: string, where: Partial<T>): Promise<Thing<T>[]>
|
|
218
|
+
update<T>(id: string, data: Partial<T>, options?: ValidationOptions): Promise<Thing<T>>
|
|
219
|
+
delete(id: string): Promise<boolean>
|
|
220
|
+
search<T>(query: string, options?: ListOptions): Promise<Thing<T>[]>
|
|
221
|
+
|
|
222
|
+
// Actions (events + edges)
|
|
223
|
+
perform<T>(verb: string, subject?: string, object?: string, data?: T): Promise<Action<T>>
|
|
224
|
+
getAction<T>(id: string): Promise<Action<T> | null>
|
|
225
|
+
listActions<T>(options?: ActionOptions): Promise<Action<T>[]>
|
|
226
|
+
deleteAction(id: string): Promise<boolean>
|
|
227
|
+
|
|
228
|
+
// Graph traversal (via actions)
|
|
229
|
+
related<T>(
|
|
230
|
+
id: string,
|
|
231
|
+
verb?: string,
|
|
232
|
+
direction?: Direction,
|
|
233
|
+
options?: ListOptions
|
|
234
|
+
): Promise<Thing<T>[]>
|
|
235
|
+
edges<T>(
|
|
236
|
+
id: string,
|
|
237
|
+
verb?: string,
|
|
238
|
+
direction?: Direction,
|
|
239
|
+
options?: ListOptions
|
|
240
|
+
): Promise<Action<T>[]>
|
|
241
|
+
|
|
242
|
+
// Batch operations
|
|
243
|
+
createMany<T>(noun: string, items: T[]): Promise<Thing<T>[]>
|
|
244
|
+
updateMany<T>(updates: Array<{ id: string; data: Partial<T> }>): Promise<Thing<T>[]>
|
|
245
|
+
deleteMany(ids: string[]): Promise<boolean[]>
|
|
246
|
+
performMany<T>(
|
|
247
|
+
actions: Array<{ verb: string; subject?: string; object?: string; data?: T }>
|
|
248
|
+
): Promise<Action<T>[]>
|
|
249
|
+
|
|
250
|
+
// Lifecycle
|
|
251
|
+
close?(): Promise<void>
|
|
252
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { ActionStatus, MemoryProvider } from '../src/index.js'
|
|
3
|
+
|
|
4
|
+
describe('ActionStatus Constants', () => {
|
|
5
|
+
describe('ActionStatus export', () => {
|
|
6
|
+
it('should export ActionStatus const object from types', () => {
|
|
7
|
+
expect(ActionStatus).toBeDefined()
|
|
8
|
+
expect(typeof ActionStatus).toBe('object')
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
it('should have COMPLETED equal to "completed"', () => {
|
|
12
|
+
expect(ActionStatus.COMPLETED).toBe('completed')
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
it('should have PENDING equal to "pending"', () => {
|
|
16
|
+
expect(ActionStatus.PENDING).toBe('pending')
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it('should have ACTIVE equal to "active"', () => {
|
|
20
|
+
expect(ActionStatus.ACTIVE).toBe('active')
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('should have FAILED equal to "failed"', () => {
|
|
24
|
+
expect(ActionStatus.FAILED).toBe('failed')
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('should have CANCELLED equal to "cancelled"', () => {
|
|
28
|
+
expect(ActionStatus.CANCELLED).toBe('cancelled')
|
|
29
|
+
})
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
describe('ActionStatus usage in perform()', () => {
|
|
33
|
+
it('should return action with status equal to ActionStatus.COMPLETED', async () => {
|
|
34
|
+
const provider = new MemoryProvider()
|
|
35
|
+
await provider.defineVerb({ name: 'create' })
|
|
36
|
+
|
|
37
|
+
const action = await provider.perform('create')
|
|
38
|
+
|
|
39
|
+
expect(action.status).toBe(ActionStatus.COMPLETED)
|
|
40
|
+
})
|
|
41
|
+
})
|
|
42
|
+
})
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TDD RED Phase Tests: Batch Operation Size Limits
|
|
3
|
+
*
|
|
4
|
+
* Issue: aip-kl39
|
|
5
|
+
* Security: Prevent DoS attacks via unlimited batch operations
|
|
6
|
+
*
|
|
7
|
+
* These tests should FAIL initially, proving no limits exist.
|
|
8
|
+
* The GREEN phase (aip-eihe) will add the implementation.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { describe, it, expect, beforeEach } from 'vitest'
|
|
12
|
+
import { MemoryProvider } from '../src/memory-provider.js'
|
|
13
|
+
import { ValidationError } from '../src/errors.js'
|
|
14
|
+
import type { DigitalObjectsProvider } from '../src/types.js'
|
|
15
|
+
|
|
16
|
+
// This constant should be exported from types.ts in GREEN phase
|
|
17
|
+
const EXPECTED_MAX_BATCH_SIZE = 1000
|
|
18
|
+
|
|
19
|
+
describe('Batch Operation Size Limits', () => {
|
|
20
|
+
let provider: DigitalObjectsProvider
|
|
21
|
+
|
|
22
|
+
beforeEach(() => {
|
|
23
|
+
provider = new MemoryProvider()
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
describe('MAX_BATCH_SIZE constant', () => {
|
|
27
|
+
it('should export MAX_BATCH_SIZE constant from types.ts', async () => {
|
|
28
|
+
// This test verifies that MAX_BATCH_SIZE is exported from types.ts
|
|
29
|
+
// In RED phase, this will fail because the constant doesn't exist
|
|
30
|
+
const types = await import('../src/types.js')
|
|
31
|
+
|
|
32
|
+
expect(types).toHaveProperty('MAX_BATCH_SIZE')
|
|
33
|
+
expect((types as { MAX_BATCH_SIZE?: number }).MAX_BATCH_SIZE).toBe(EXPECTED_MAX_BATCH_SIZE)
|
|
34
|
+
})
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
describe('createMany', () => {
|
|
38
|
+
it('should reject batch with more than 1000 items', async () => {
|
|
39
|
+
const items = Array.from({ length: 1001 }, (_, i) => ({ name: `Item ${i}` }))
|
|
40
|
+
|
|
41
|
+
await expect(provider.createMany('thing', items)).rejects.toThrow(ValidationError)
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('should include batch size in error message', async () => {
|
|
45
|
+
const items = Array.from({ length: 1001 }, (_, i) => ({ name: `Item ${i}` }))
|
|
46
|
+
|
|
47
|
+
try {
|
|
48
|
+
await provider.createMany('thing', items)
|
|
49
|
+
expect.fail('Expected ValidationError to be thrown')
|
|
50
|
+
} catch (error) {
|
|
51
|
+
expect(error).toBeInstanceOf(ValidationError)
|
|
52
|
+
expect((error as ValidationError).message).toContain('1000')
|
|
53
|
+
expect((error as ValidationError).message).toContain('1001')
|
|
54
|
+
}
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('should allow exactly 1000 items', async () => {
|
|
58
|
+
const items = Array.from({ length: 1000 }, (_, i) => ({ name: `Item ${i}` }))
|
|
59
|
+
|
|
60
|
+
// This should not throw - 1000 is the limit, not over it
|
|
61
|
+
const results = await provider.createMany('thing', items)
|
|
62
|
+
expect(results).toHaveLength(1000)
|
|
63
|
+
})
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
describe('updateMany', () => {
|
|
67
|
+
it('should reject batch with more than 1000 items', async () => {
|
|
68
|
+
// Create items first
|
|
69
|
+
const items = Array.from({ length: 10 }, (_, i) => ({ name: `Item ${i}` }))
|
|
70
|
+
const created = await provider.createMany('thing', items)
|
|
71
|
+
|
|
72
|
+
// Try to update more than 1000 (even if they don't exist)
|
|
73
|
+
const updates = Array.from({ length: 1001 }, (_, i) => ({
|
|
74
|
+
id: created[i % created.length].id,
|
|
75
|
+
data: { name: `Updated ${i}` },
|
|
76
|
+
}))
|
|
77
|
+
|
|
78
|
+
await expect(provider.updateMany(updates)).rejects.toThrow(ValidationError)
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('should include batch size in error message', async () => {
|
|
82
|
+
const updates = Array.from({ length: 1001 }, (_, i) => ({
|
|
83
|
+
id: `fake-id-${i}`,
|
|
84
|
+
data: { name: `Updated ${i}` },
|
|
85
|
+
}))
|
|
86
|
+
|
|
87
|
+
try {
|
|
88
|
+
await provider.updateMany(updates)
|
|
89
|
+
expect.fail('Expected ValidationError to be thrown')
|
|
90
|
+
} catch (error) {
|
|
91
|
+
expect(error).toBeInstanceOf(ValidationError)
|
|
92
|
+
expect((error as ValidationError).message).toContain('1000')
|
|
93
|
+
expect((error as ValidationError).message).toContain('1001')
|
|
94
|
+
}
|
|
95
|
+
})
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
describe('deleteMany', () => {
|
|
99
|
+
it('should reject batch with more than 1000 items', async () => {
|
|
100
|
+
const ids = Array.from({ length: 1001 }, (_, i) => `fake-id-${i}`)
|
|
101
|
+
|
|
102
|
+
await expect(provider.deleteMany(ids)).rejects.toThrow(ValidationError)
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it('should include batch size in error message', async () => {
|
|
106
|
+
const ids = Array.from({ length: 1001 }, (_, i) => `fake-id-${i}`)
|
|
107
|
+
|
|
108
|
+
try {
|
|
109
|
+
await provider.deleteMany(ids)
|
|
110
|
+
expect.fail('Expected ValidationError to be thrown')
|
|
111
|
+
} catch (error) {
|
|
112
|
+
expect(error).toBeInstanceOf(ValidationError)
|
|
113
|
+
expect((error as ValidationError).message).toContain('1000')
|
|
114
|
+
expect((error as ValidationError).message).toContain('1001')
|
|
115
|
+
}
|
|
116
|
+
})
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
describe('performMany', () => {
|
|
120
|
+
it('should reject batch with more than 1000 items', async () => {
|
|
121
|
+
const actions = Array.from({ length: 1001 }, (_, i) => ({
|
|
122
|
+
verb: 'test',
|
|
123
|
+
data: { index: i },
|
|
124
|
+
}))
|
|
125
|
+
|
|
126
|
+
await expect(provider.performMany(actions)).rejects.toThrow(ValidationError)
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
it('should include batch size in error message', async () => {
|
|
130
|
+
const actions = Array.from({ length: 1001 }, (_, i) => ({
|
|
131
|
+
verb: 'test',
|
|
132
|
+
data: { index: i },
|
|
133
|
+
}))
|
|
134
|
+
|
|
135
|
+
try {
|
|
136
|
+
await provider.performMany(actions)
|
|
137
|
+
expect.fail('Expected ValidationError to be thrown')
|
|
138
|
+
} catch (error) {
|
|
139
|
+
expect(error).toBeInstanceOf(ValidationError)
|
|
140
|
+
expect((error as ValidationError).message).toContain('1000')
|
|
141
|
+
expect((error as ValidationError).message).toContain('1001')
|
|
142
|
+
}
|
|
143
|
+
})
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
describe('Security validation', () => {
|
|
147
|
+
it('should validate batch size before processing any items', async () => {
|
|
148
|
+
// This ensures the validation happens BEFORE any expensive operations
|
|
149
|
+
// Not after processing some items
|
|
150
|
+
const items = Array.from({ length: 1001 }, (_, i) => ({ name: `Item ${i}` }))
|
|
151
|
+
|
|
152
|
+
const startTime = Date.now()
|
|
153
|
+
try {
|
|
154
|
+
await provider.createMany('thing', items)
|
|
155
|
+
expect.fail('Expected ValidationError to be thrown')
|
|
156
|
+
} catch (error) {
|
|
157
|
+
const elapsed = Date.now() - startTime
|
|
158
|
+
// Should fail fast - well under 100ms for just validation
|
|
159
|
+
// If it takes longer, items are being processed before validation
|
|
160
|
+
expect(elapsed).toBeLessThan(100)
|
|
161
|
+
expect(error).toBeInstanceOf(ValidationError)
|
|
162
|
+
}
|
|
163
|
+
})
|
|
164
|
+
})
|
|
165
|
+
})
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { existsSync, readFileSync } from 'fs'
|
|
3
|
+
import { join } from 'path'
|
|
4
|
+
|
|
5
|
+
const CONTENT_DIR = join(__dirname, '../../../content/digital-objects')
|
|
6
|
+
|
|
7
|
+
describe('Documentation Structure', () => {
|
|
8
|
+
describe('content/digital-objects/ folder', () => {
|
|
9
|
+
it('should exist', () => {
|
|
10
|
+
expect(existsSync(CONTENT_DIR)).toBe(true)
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
it('should have meta.json with proper structure', () => {
|
|
14
|
+
const metaPath = join(CONTENT_DIR, 'meta.json')
|
|
15
|
+
expect(existsSync(metaPath)).toBe(true)
|
|
16
|
+
const meta = JSON.parse(readFileSync(metaPath, 'utf-8'))
|
|
17
|
+
expect(meta.title).toBe('Digital Objects')
|
|
18
|
+
expect(meta.root).toBe(true)
|
|
19
|
+
expect(Array.isArray(meta.pages)).toBe(true)
|
|
20
|
+
})
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
describe('Required MDX files', () => {
|
|
24
|
+
const requiredFiles = [
|
|
25
|
+
'index.mdx',
|
|
26
|
+
'nouns.mdx',
|
|
27
|
+
'verbs.mdx',
|
|
28
|
+
'things.mdx',
|
|
29
|
+
'actions.mdx',
|
|
30
|
+
'graph.mdx',
|
|
31
|
+
'providers.mdx',
|
|
32
|
+
'r2-persistence.mdx',
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
for (const file of requiredFiles) {
|
|
36
|
+
it(`should have ${file}`, () => {
|
|
37
|
+
expect(existsSync(join(CONTENT_DIR, file))).toBe(true)
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it(`${file} should have proper frontmatter`, () => {
|
|
41
|
+
const content = readFileSync(join(CONTENT_DIR, file), 'utf-8')
|
|
42
|
+
expect(content.startsWith('---')).toBe(true)
|
|
43
|
+
expect(content).toMatch(/title:\s*.+/)
|
|
44
|
+
expect(content).toMatch(/description:\s*.+/)
|
|
45
|
+
})
|
|
46
|
+
}
|
|
47
|
+
})
|
|
48
|
+
})
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import {
|
|
3
|
+
DigitalObjectsError,
|
|
4
|
+
NotFoundError,
|
|
5
|
+
ValidationError,
|
|
6
|
+
ConflictError,
|
|
7
|
+
errorToResponse,
|
|
8
|
+
} from '../src/errors.js'
|
|
9
|
+
import { MemoryProvider } from '../src/memory-provider.js'
|
|
10
|
+
|
|
11
|
+
describe('Error Classes', () => {
|
|
12
|
+
describe('DigitalObjectsError', () => {
|
|
13
|
+
it('should create error with message, code, and statusCode', () => {
|
|
14
|
+
const error = new DigitalObjectsError('Something went wrong', 'CUSTOM_ERROR', 503)
|
|
15
|
+
|
|
16
|
+
expect(error.message).toBe('Something went wrong')
|
|
17
|
+
expect(error.code).toBe('CUSTOM_ERROR')
|
|
18
|
+
expect(error.statusCode).toBe(503)
|
|
19
|
+
expect(error.name).toBe('DigitalObjectsError')
|
|
20
|
+
expect(error instanceof Error).toBe(true)
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('should default statusCode to 500', () => {
|
|
24
|
+
const error = new DigitalObjectsError('Server error', 'SERVER_ERROR')
|
|
25
|
+
|
|
26
|
+
expect(error.statusCode).toBe(500)
|
|
27
|
+
})
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
describe('NotFoundError', () => {
|
|
31
|
+
it('should create error with type and id', () => {
|
|
32
|
+
const error = new NotFoundError('Thing', 'abc-123')
|
|
33
|
+
|
|
34
|
+
expect(error.message).toBe('Thing not found: abc-123')
|
|
35
|
+
expect(error.code).toBe('NOT_FOUND')
|
|
36
|
+
expect(error.statusCode).toBe(404)
|
|
37
|
+
expect(error.name).toBe('NotFoundError')
|
|
38
|
+
expect(error instanceof DigitalObjectsError).toBe(true)
|
|
39
|
+
})
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
describe('ValidationError', () => {
|
|
43
|
+
it('should create error with message and field errors', () => {
|
|
44
|
+
const fieldErrors = [
|
|
45
|
+
{ field: 'email', message: 'Invalid email format' },
|
|
46
|
+
{ field: 'age', message: 'Must be a positive number' },
|
|
47
|
+
]
|
|
48
|
+
const error = new ValidationError('Validation failed', fieldErrors)
|
|
49
|
+
|
|
50
|
+
expect(error.message).toBe('Validation failed')
|
|
51
|
+
expect(error.code).toBe('VALIDATION_ERROR')
|
|
52
|
+
expect(error.statusCode).toBe(400)
|
|
53
|
+
expect(error.name).toBe('ValidationError')
|
|
54
|
+
expect(error.errors).toEqual(fieldErrors)
|
|
55
|
+
expect(error instanceof DigitalObjectsError).toBe(true)
|
|
56
|
+
})
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
describe('ConflictError', () => {
|
|
60
|
+
it('should create error with message', () => {
|
|
61
|
+
const error = new ConflictError('Resource already exists')
|
|
62
|
+
|
|
63
|
+
expect(error.message).toBe('Resource already exists')
|
|
64
|
+
expect(error.code).toBe('CONFLICT')
|
|
65
|
+
expect(error.statusCode).toBe(409)
|
|
66
|
+
expect(error.name).toBe('ConflictError')
|
|
67
|
+
expect(error instanceof DigitalObjectsError).toBe(true)
|
|
68
|
+
})
|
|
69
|
+
})
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
describe('errorToResponse', () => {
|
|
73
|
+
it('should convert DigitalObjectsError to HTTP response', () => {
|
|
74
|
+
const error = new DigitalObjectsError('Custom error', 'CUSTOM', 422)
|
|
75
|
+
const { body, status } = errorToResponse(error)
|
|
76
|
+
|
|
77
|
+
expect(status).toBe(422)
|
|
78
|
+
expect(body).toEqual({
|
|
79
|
+
error: 'CUSTOM',
|
|
80
|
+
message: 'Custom error',
|
|
81
|
+
})
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('should convert NotFoundError to HTTP response', () => {
|
|
85
|
+
const error = new NotFoundError('User', 'user-123')
|
|
86
|
+
const { body, status } = errorToResponse(error)
|
|
87
|
+
|
|
88
|
+
expect(status).toBe(404)
|
|
89
|
+
expect(body).toEqual({
|
|
90
|
+
error: 'NOT_FOUND',
|
|
91
|
+
message: 'User not found: user-123',
|
|
92
|
+
})
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('should convert ValidationError to HTTP response with field errors', () => {
|
|
96
|
+
const fieldErrors = [{ field: 'name', message: 'Required' }]
|
|
97
|
+
const error = new ValidationError('Invalid input', fieldErrors)
|
|
98
|
+
const { body, status } = errorToResponse(error)
|
|
99
|
+
|
|
100
|
+
expect(status).toBe(400)
|
|
101
|
+
expect(body).toEqual({
|
|
102
|
+
error: 'VALIDATION_ERROR',
|
|
103
|
+
message: 'Invalid input',
|
|
104
|
+
errors: fieldErrors,
|
|
105
|
+
})
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it('should convert unknown errors to generic 500 response', () => {
|
|
109
|
+
const error = new Error('Internal failure')
|
|
110
|
+
const { body, status } = errorToResponse(error)
|
|
111
|
+
|
|
112
|
+
expect(status).toBe(500)
|
|
113
|
+
expect(body).toEqual({
|
|
114
|
+
error: 'INTERNAL_ERROR',
|
|
115
|
+
message: 'An internal error occurred',
|
|
116
|
+
})
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
it('should convert non-Error values to generic 500 response', () => {
|
|
120
|
+
const { body, status } = errorToResponse('string error')
|
|
121
|
+
|
|
122
|
+
expect(status).toBe(500)
|
|
123
|
+
expect(body).toEqual({
|
|
124
|
+
error: 'INTERNAL_ERROR',
|
|
125
|
+
message: 'An internal error occurred',
|
|
126
|
+
})
|
|
127
|
+
})
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
describe('Provider Error Integration', () => {
|
|
131
|
+
describe('MemoryProvider', () => {
|
|
132
|
+
it('should throw NotFoundError when updating non-existent thing', async () => {
|
|
133
|
+
const provider = new MemoryProvider()
|
|
134
|
+
|
|
135
|
+
await expect(provider.update('non-existent-id', { name: 'test' })).rejects.toThrow(
|
|
136
|
+
NotFoundError
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
try {
|
|
140
|
+
await provider.update('non-existent-id', { name: 'test' })
|
|
141
|
+
} catch (error) {
|
|
142
|
+
expect(error).toBeInstanceOf(NotFoundError)
|
|
143
|
+
expect((error as NotFoundError).message).toBe('Thing not found: non-existent-id')
|
|
144
|
+
expect((error as NotFoundError).statusCode).toBe(404)
|
|
145
|
+
}
|
|
146
|
+
})
|
|
147
|
+
})
|
|
148
|
+
})
|