digital-objects 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/.turbo/turbo-build.log +4 -0
  2. package/CHANGELOG.md +25 -0
  3. package/LICENSE +21 -0
  4. package/README.md +476 -0
  5. package/dist/ai-database-adapter.d.ts +49 -0
  6. package/dist/ai-database-adapter.d.ts.map +1 -0
  7. package/dist/ai-database-adapter.js +89 -0
  8. package/dist/ai-database-adapter.js.map +1 -0
  9. package/dist/errors.d.ts +47 -0
  10. package/dist/errors.d.ts.map +1 -0
  11. package/dist/errors.js +72 -0
  12. package/dist/errors.js.map +1 -0
  13. package/dist/http-schemas.d.ts +165 -0
  14. package/dist/http-schemas.d.ts.map +1 -0
  15. package/dist/http-schemas.js +55 -0
  16. package/dist/http-schemas.js.map +1 -0
  17. package/dist/index.d.ts +29 -0
  18. package/dist/index.d.ts.map +1 -0
  19. package/dist/index.js +32 -0
  20. package/dist/index.js.map +1 -0
  21. package/dist/linguistic.d.ts +54 -0
  22. package/dist/linguistic.d.ts.map +1 -0
  23. package/dist/linguistic.js +226 -0
  24. package/dist/linguistic.js.map +1 -0
  25. package/dist/memory-provider.d.ts +46 -0
  26. package/dist/memory-provider.d.ts.map +1 -0
  27. package/dist/memory-provider.js +279 -0
  28. package/dist/memory-provider.js.map +1 -0
  29. package/dist/ns-client.d.ts +88 -0
  30. package/dist/ns-client.d.ts.map +1 -0
  31. package/dist/ns-client.js +253 -0
  32. package/dist/ns-client.js.map +1 -0
  33. package/dist/ns-exports.d.ts +23 -0
  34. package/dist/ns-exports.d.ts.map +1 -0
  35. package/dist/ns-exports.js +21 -0
  36. package/dist/ns-exports.js.map +1 -0
  37. package/dist/ns.d.ts +60 -0
  38. package/dist/ns.d.ts.map +1 -0
  39. package/dist/ns.js +818 -0
  40. package/dist/ns.js.map +1 -0
  41. package/dist/r2-persistence.d.ts +112 -0
  42. package/dist/r2-persistence.d.ts.map +1 -0
  43. package/dist/r2-persistence.js +252 -0
  44. package/dist/r2-persistence.js.map +1 -0
  45. package/dist/schema-validation.d.ts +80 -0
  46. package/dist/schema-validation.d.ts.map +1 -0
  47. package/dist/schema-validation.js +233 -0
  48. package/dist/schema-validation.js.map +1 -0
  49. package/dist/types.d.ts +184 -0
  50. package/dist/types.d.ts.map +1 -0
  51. package/dist/types.js +26 -0
  52. package/dist/types.js.map +1 -0
  53. package/package.json +55 -0
  54. package/src/ai-database-adapter.test.ts +610 -0
  55. package/src/ai-database-adapter.ts +189 -0
  56. package/src/benchmark.test.ts +109 -0
  57. package/src/errors.ts +91 -0
  58. package/src/http-schemas.ts +67 -0
  59. package/src/index.ts +87 -0
  60. package/src/linguistic.test.ts +1107 -0
  61. package/src/linguistic.ts +253 -0
  62. package/src/memory-provider.ts +470 -0
  63. package/src/ns-client.test.ts +1360 -0
  64. package/src/ns-client.ts +342 -0
  65. package/src/ns-exports.ts +23 -0
  66. package/src/ns.test.ts +1381 -0
  67. package/src/ns.ts +1215 -0
  68. package/src/provider.test.ts +675 -0
  69. package/src/r2-persistence.test.ts +263 -0
  70. package/src/r2-persistence.ts +367 -0
  71. package/src/schema-validation.test.ts +167 -0
  72. package/src/schema-validation.ts +330 -0
  73. package/src/types.ts +252 -0
  74. package/test/action-status.test.ts +42 -0
  75. package/test/batch-limits.test.ts +165 -0
  76. package/test/docs.test.ts +48 -0
  77. package/test/errors.test.ts +148 -0
  78. package/test/http-validation.test.ts +401 -0
  79. package/test/ns-client-errors.test.ts +208 -0
  80. package/test/ns-namespace.test.ts +307 -0
  81. package/test/performance.test.ts +168 -0
  82. package/test/schema-validation-error.test.ts +213 -0
  83. package/test/schema-validation.test.ts +440 -0
  84. package/test/search-escaping.test.ts +359 -0
  85. package/test/security.test.ts +322 -0
  86. package/tsconfig.json +10 -0
  87. package/wrangler.jsonc +16 -0
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
+ })