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
@@ -0,0 +1,167 @@
1
+ import { describe, it, expect, beforeEach } from 'vitest'
2
+ import { createMemoryProvider } from './memory-provider.js'
3
+ import type { DigitalObjectsProvider, FieldDefinition } from './types.js'
4
+ import { ValidationError } from './errors.js'
5
+
6
+ describe('Schema Validation', () => {
7
+ let provider: DigitalObjectsProvider
8
+
9
+ beforeEach(async () => {
10
+ provider = createMemoryProvider()
11
+ })
12
+
13
+ describe('Thing validation against Noun schema', () => {
14
+ beforeEach(async () => {
15
+ await provider.defineNoun({
16
+ name: 'User',
17
+ schema: {
18
+ name: { type: 'string', required: true },
19
+ email: { type: 'string', required: true },
20
+ age: { type: 'number' },
21
+ },
22
+ })
23
+ })
24
+
25
+ it('should accept valid data matching schema', async () => {
26
+ const thing = await provider.create(
27
+ 'User',
28
+ {
29
+ name: 'Alice',
30
+ email: 'alice@example.com',
31
+ age: 30,
32
+ },
33
+ undefined,
34
+ { validate: true }
35
+ )
36
+ expect(thing.data.name).toBe('Alice')
37
+ })
38
+
39
+ it('should reject data missing required fields', async () => {
40
+ try {
41
+ await provider.create('User', { name: 'Alice' }, undefined, { validate: true })
42
+ expect.fail('Should have thrown')
43
+ } catch (error) {
44
+ expect(error).toBeInstanceOf(ValidationError)
45
+ const validationError = error as ValidationError
46
+ const fields = validationError.errors.map((e) => e.field)
47
+ expect(fields).toContain('email')
48
+ }
49
+ })
50
+
51
+ it('should reject data with wrong types', async () => {
52
+ try {
53
+ await provider.create(
54
+ 'User',
55
+ { name: 'Alice', email: 'a@b.com', age: 'thirty' },
56
+ undefined,
57
+ {
58
+ validate: true,
59
+ }
60
+ )
61
+ expect.fail('Should have thrown')
62
+ } catch (error) {
63
+ expect(error).toBeInstanceOf(ValidationError)
64
+ const validationError = error as ValidationError
65
+ const ageError = validationError.errors.find((e) => e.field === 'age')
66
+ expect(ageError).toBeDefined()
67
+ expect(ageError?.message).toMatch(/number/i)
68
+ }
69
+ })
70
+
71
+ it('should allow extra fields not in schema', async () => {
72
+ const thing = await provider.create(
73
+ 'User',
74
+ {
75
+ name: 'Alice',
76
+ email: 'alice@example.com',
77
+ nickname: 'Ali',
78
+ },
79
+ undefined,
80
+ { validate: true }
81
+ )
82
+ expect(thing.data.nickname).toBe('Ali')
83
+ })
84
+ })
85
+
86
+ describe('Validation opt-in behavior', () => {
87
+ it('should skip validation when validate option is false', async () => {
88
+ await provider.defineNoun({
89
+ name: 'Strict',
90
+ schema: { required: { type: 'string', required: true } },
91
+ })
92
+ // Should NOT throw without validate: true
93
+ const thing = await provider.create('Strict', {})
94
+ expect(thing).toBeDefined()
95
+ })
96
+
97
+ it('should skip validation when no schema defined', async () => {
98
+ await provider.defineNoun({ name: 'Flexible' })
99
+ const thing = await provider.create('Flexible', { anything: 'goes' }, undefined, {
100
+ validate: true,
101
+ })
102
+ expect(thing.data.anything).toBe('goes')
103
+ })
104
+ })
105
+
106
+ describe('Nested object schemas', () => {
107
+ it('should validate nested objects', async () => {
108
+ await provider.defineNoun({
109
+ name: 'Profile',
110
+ schema: {
111
+ user: { type: 'object', required: true },
112
+ },
113
+ })
114
+ try {
115
+ await provider.create('Profile', { user: 'not-an-object' }, undefined, { validate: true })
116
+ expect.fail('Should have thrown')
117
+ } catch (error) {
118
+ expect(error).toBeInstanceOf(ValidationError)
119
+ const validationError = error as ValidationError
120
+ const userError = validationError.errors.find((e) => e.field === 'user')
121
+ expect(userError).toBeDefined()
122
+ expect(userError?.message).toMatch(/object/i)
123
+ }
124
+ })
125
+ })
126
+
127
+ describe('Array field validation', () => {
128
+ it('should validate array fields', async () => {
129
+ await provider.defineNoun({
130
+ name: 'Post',
131
+ schema: {
132
+ tags: { type: 'array' },
133
+ },
134
+ })
135
+ try {
136
+ await provider.create('Post', { tags: 'not-array' }, undefined, { validate: true })
137
+ expect.fail('Should have thrown')
138
+ } catch (error) {
139
+ expect(error).toBeInstanceOf(ValidationError)
140
+ const validationError = error as ValidationError
141
+ const tagsError = validationError.errors.find((e) => e.field === 'tags')
142
+ expect(tagsError).toBeDefined()
143
+ expect(tagsError?.message).toMatch(/array/i)
144
+ }
145
+ })
146
+ })
147
+
148
+ describe('Update validation', () => {
149
+ it('should validate updates when validate option is true', async () => {
150
+ await provider.defineNoun({
151
+ name: 'Item',
152
+ schema: { count: { type: 'number' } },
153
+ })
154
+ const item = await provider.create('Item', { count: 1 })
155
+ try {
156
+ await provider.update(item.id, { count: 'two' }, { validate: true })
157
+ expect.fail('Should have thrown')
158
+ } catch (error) {
159
+ expect(error).toBeInstanceOf(ValidationError)
160
+ const validationError = error as ValidationError
161
+ const countError = validationError.errors.find((e) => e.field === 'count')
162
+ expect(countError).toBeDefined()
163
+ expect(countError?.message).toMatch(/number/i)
164
+ }
165
+ })
166
+ })
167
+ })
@@ -0,0 +1,330 @@
1
+ /**
2
+ * Runtime schema validation for Digital Objects
3
+ *
4
+ * Validates Thing data against Noun schemas when validation is enabled.
5
+ * Provides clear, actionable error messages with field paths and suggestions.
6
+ */
7
+
8
+ import type { FieldDefinition, ExtendedFieldDefinition, ValidationOptions } from './types.js'
9
+ import { ValidationError } from './errors.js'
10
+
11
+ /**
12
+ * Schema validation error with detailed context
13
+ *
14
+ * Provides comprehensive information about validation failures
15
+ * including field paths, type information, and suggestions for fixes.
16
+ */
17
+ export interface SchemaValidationError {
18
+ /** Full path to the field (e.g., 'address.city' for nested objects) */
19
+ field: string
20
+ /** Human-readable error message */
21
+ message: string
22
+ /** Expected type or constraint */
23
+ expected: string
24
+ /** Actual value's type or description */
25
+ received: string
26
+ /** Optional suggestion for fixing the error */
27
+ suggestion?: string
28
+ /** Error code for programmatic handling */
29
+ code: ValidationErrorCode
30
+ }
31
+
32
+ /**
33
+ * Error codes for programmatic error handling
34
+ */
35
+ export type ValidationErrorCode =
36
+ | 'REQUIRED_FIELD'
37
+ | 'TYPE_MISMATCH'
38
+ | 'INVALID_FORMAT'
39
+ | 'UNKNOWN_FIELD'
40
+
41
+ /**
42
+ * Result from validateOnly() for pre-flight validation
43
+ */
44
+ export interface ValidationResult {
45
+ valid: boolean
46
+ errors: SchemaValidationError[]
47
+ }
48
+
49
+ /**
50
+ * Type guard to check if a field definition is an extended definition (object with type)
51
+ */
52
+ function isExtendedFieldDefinition(def: FieldDefinition): def is ExtendedFieldDefinition {
53
+ return typeof def === 'object' && def !== null && 'type' in def
54
+ }
55
+
56
+ /**
57
+ * Get the actual JavaScript type of a value for validation purposes
58
+ */
59
+ function getActualType(value: unknown): string {
60
+ if (value === null) return 'null'
61
+ if (Array.isArray(value)) return 'array'
62
+ if (value instanceof Date) return 'date'
63
+ return typeof value
64
+ }
65
+
66
+ /**
67
+ * Get a suggestion for fixing a type mismatch error
68
+ */
69
+ function getSuggestion(expected: string, actual: string, value: unknown): string | undefined {
70
+ // String to number
71
+ if (expected === 'number' && actual === 'string') {
72
+ const num = Number(value)
73
+ if (!isNaN(num)) {
74
+ return `Convert to number: ${num}`
75
+ }
76
+ return 'Provide a valid number'
77
+ }
78
+
79
+ // Number to string
80
+ if (expected === 'string' && actual === 'number') {
81
+ return `Convert to string: "${value}"`
82
+ }
83
+
84
+ // String to boolean
85
+ if (expected === 'boolean' && actual === 'string') {
86
+ const str = String(value).toLowerCase()
87
+ if (str === 'true' || str === 'false') {
88
+ return `Convert to boolean: ${str === 'true'}`
89
+ }
90
+ return 'Use true or false'
91
+ }
92
+
93
+ // Array expected
94
+ if (expected === 'array' && actual !== 'array') {
95
+ return 'Wrap value in an array: [value]'
96
+ }
97
+
98
+ // Object expected
99
+ if (expected === 'object' && actual !== 'object') {
100
+ return 'Provide an object: { ... }'
101
+ }
102
+
103
+ // Date/datetime (expected string-based type but got wrong type)
104
+ if ((expected === 'date' || expected === 'datetime') && actual !== 'string') {
105
+ return 'Provide a valid ISO 8601 date string'
106
+ }
107
+
108
+ // URL (expected string-based type but got wrong type)
109
+ if (expected === 'url' && actual !== 'string') {
110
+ return 'Provide a valid URL starting with http:// or https://'
111
+ }
112
+
113
+ // Markdown
114
+ if (expected === 'markdown' && actual !== 'string') {
115
+ return 'Provide a string containing markdown content'
116
+ }
117
+
118
+ return undefined
119
+ }
120
+
121
+ /**
122
+ * Create a validation error with consistent formatting
123
+ */
124
+ function createError(
125
+ field: string,
126
+ code: ValidationErrorCode,
127
+ expected: string,
128
+ received: string,
129
+ value?: unknown
130
+ ): SchemaValidationError {
131
+ let message: string
132
+
133
+ switch (code) {
134
+ case 'REQUIRED_FIELD':
135
+ message = `Missing required field '${field}'`
136
+ break
137
+ case 'TYPE_MISMATCH':
138
+ message = `Field '${field}' has wrong type: expected ${expected}, got ${received}`
139
+ break
140
+ case 'INVALID_FORMAT':
141
+ message = `Field '${field}' has invalid format: expected ${expected}`
142
+ break
143
+ case 'UNKNOWN_FIELD':
144
+ message = `Unknown field '${field}'`
145
+ break
146
+ }
147
+
148
+ const error: SchemaValidationError = {
149
+ field,
150
+ message,
151
+ expected,
152
+ received,
153
+ code,
154
+ }
155
+
156
+ // Add suggestion if available
157
+ const suggestion = getSuggestion(expected, received, value)
158
+ if (suggestion) {
159
+ error.suggestion = suggestion
160
+ }
161
+
162
+ return error
163
+ }
164
+
165
+ /**
166
+ * Internal validation logic that collects errors
167
+ *
168
+ * @param data - The data to validate
169
+ * @param schema - The schema to validate against
170
+ * @param path - Current field path for nested objects
171
+ * @returns Array of validation errors
172
+ */
173
+ function collectErrors(
174
+ data: Record<string, unknown>,
175
+ schema: Record<string, FieldDefinition>,
176
+ path: string = ''
177
+ ): SchemaValidationError[] {
178
+ const errors: SchemaValidationError[] = []
179
+
180
+ for (const [field, def] of Object.entries(schema)) {
181
+ const fieldPath = path ? `${path}.${field}` : field
182
+ const value = data[field]
183
+
184
+ // Handle extended field definitions (object with type and required)
185
+ if (isExtendedFieldDefinition(def)) {
186
+ // Check required fields
187
+ if (def.required && (value === undefined || value === null)) {
188
+ errors.push(
189
+ createError(
190
+ fieldPath,
191
+ 'REQUIRED_FIELD',
192
+ def.type,
193
+ value === null ? 'null' : 'undefined',
194
+ value
195
+ )
196
+ )
197
+ continue
198
+ }
199
+
200
+ // Skip validation if field is not present and not required
201
+ if (value === undefined) continue
202
+
203
+ // Type validation
204
+ const actualType = getActualType(value)
205
+
206
+ // Special handling for string-based types that should validate as strings
207
+ const stringBasedTypes = ['date', 'datetime', 'url', 'markdown', 'json']
208
+ const normalizedExpected = stringBasedTypes.includes(def.type) ? 'string' : def.type
209
+
210
+ if (normalizedExpected !== actualType) {
211
+ errors.push(createError(fieldPath, 'TYPE_MISMATCH', def.type, actualType, value))
212
+ }
213
+ } else {
214
+ // Simple field type (string like 'string', 'number', etc.)
215
+ // These are not required by default and just validate type if present
216
+ if (value === undefined) continue
217
+
218
+ const actualType = getActualType(value)
219
+ const expectedType = def.replace('?', '') // Remove optional marker
220
+
221
+ // Skip relation types (contain '.')
222
+ if (expectedType.includes('.')) continue
223
+
224
+ // Skip bracket-wrapped relation types
225
+ if (expectedType.startsWith('[') && expectedType.endsWith(']')) continue
226
+
227
+ // Special handling for string-based types that should validate as strings
228
+ const stringBasedTypes = ['date', 'datetime', 'url', 'markdown', 'json']
229
+ const normalizedExpected = stringBasedTypes.includes(expectedType) ? 'string' : expectedType
230
+
231
+ if (normalizedExpected !== actualType) {
232
+ errors.push(createError(fieldPath, 'TYPE_MISMATCH', expectedType, actualType, value))
233
+ }
234
+ }
235
+ }
236
+
237
+ return errors
238
+ }
239
+
240
+ /**
241
+ * Format validation errors for display in error messages
242
+ */
243
+ function formatErrors(errors: SchemaValidationError[]): string {
244
+ if (errors.length === 0) return ''
245
+
246
+ const lines = errors.map((e) => {
247
+ let line = ` - ${e.message}`
248
+ if (e.suggestion) {
249
+ line += ` (${e.suggestion})`
250
+ }
251
+ return line
252
+ })
253
+
254
+ return lines.join('\n')
255
+ }
256
+
257
+ /**
258
+ * Validates data against a schema definition without throwing
259
+ *
260
+ * Use this for pre-flight validation to check if data is valid
261
+ * before attempting create/update operations.
262
+ *
263
+ * @param data - The data to validate
264
+ * @param schema - The schema to validate against (from Noun.schema)
265
+ * @returns ValidationResult with valid boolean and errors array
266
+ *
267
+ * @example
268
+ * ```typescript
269
+ * const result = validateOnly(userData, userSchema)
270
+ * if (!result.valid) {
271
+ * console.log('Validation errors:', result.errors)
272
+ * // Handle errors gracefully
273
+ * }
274
+ * ```
275
+ */
276
+ export function validateOnly(
277
+ data: Record<string, unknown>,
278
+ schema: Record<string, FieldDefinition> | undefined
279
+ ): ValidationResult {
280
+ if (!schema) {
281
+ return { valid: true, errors: [] }
282
+ }
283
+
284
+ const errors = collectErrors(data, schema)
285
+ return {
286
+ valid: errors.length === 0,
287
+ errors,
288
+ }
289
+ }
290
+
291
+ /**
292
+ * Validates data against a schema definition
293
+ *
294
+ * @param data - The data to validate
295
+ * @param schema - The schema to validate against (from Noun.schema)
296
+ * @param options - Validation options (validate: true to enable)
297
+ * @throws Error if validation fails with detailed error messages
298
+ *
299
+ * @example
300
+ * ```typescript
301
+ * // Enable validation with options
302
+ * await provider.create('User', userData, undefined, { validate: true })
303
+ *
304
+ * // Error message example:
305
+ * // Validation failed (2 errors):
306
+ * // - Missing required field 'email'
307
+ * // - Field 'age' has wrong type: expected number, got string (Convert to number: 25)
308
+ * ```
309
+ */
310
+ export function validateData(
311
+ data: Record<string, unknown>,
312
+ schema: Record<string, FieldDefinition> | undefined,
313
+ options?: ValidationOptions
314
+ ): void {
315
+ // Skip validation if not enabled or no schema
316
+ if (!options?.validate || !schema) return
317
+
318
+ const errors = collectErrors(data, schema)
319
+
320
+ if (errors.length > 0) {
321
+ const errorCount = errors.length === 1 ? '1 error' : `${errors.length} errors`
322
+ throw new ValidationError(
323
+ `Validation failed (${errorCount})`,
324
+ errors.map((e) => ({ field: e.field, message: e.message }))
325
+ )
326
+ }
327
+ }
328
+
329
+ // Re-export ValidationOptions for convenience
330
+ export type { ValidationOptions }