@rip-lang/schema 0.2.1

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/runtime.js ADDED
@@ -0,0 +1,494 @@
1
+ // ==============================================================================
2
+ // Schema Runtime - Lightweight validation and model factory
3
+ //
4
+ // Design principles:
5
+ // - Plain objects (no Proxy overhead)
6
+ // - Compiled validators (fast execution)
7
+ // - Validate on demand (not on every set)
8
+ // - Zero dependencies
9
+ //
10
+ // Author: Steve Shreeve <steve.shreeve@gmail.com>
11
+ // Date: January 2026
12
+ // ==============================================================================
13
+
14
+ import { readFileSync } from 'fs'
15
+ import { parse as _rawParse } from './parser.js'
16
+ import { formatParseError } from './errors.js'
17
+ import { generateSQL } from './emit-sql.js'
18
+ import { generateTypes } from './emit-types.js'
19
+ import { generateZod } from './emit-zod.js'
20
+
21
+ // =============================================================================
22
+ // Built-in Type Validators
23
+ // =============================================================================
24
+
25
+ const Types = {
26
+ // Primitives
27
+ string: (v) => typeof v === 'string',
28
+ number: (v) => typeof v === 'number' && !Number.isNaN(v),
29
+ integer: (v) => Number.isInteger(v),
30
+ boolean: (v) => typeof v === 'boolean',
31
+ date: (v) => v instanceof Date && !Number.isNaN(v.getTime()),
32
+ datetime: (v) => v instanceof Date && !Number.isNaN(v.getTime()),
33
+
34
+ // String formats
35
+ email: (v) => typeof v === 'string' && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v),
36
+ url: (v) => typeof v === 'string' && /^https?:\/\/.+/.test(v),
37
+ uuid: (v) => typeof v === 'string' && /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(v),
38
+ phone: (v) => typeof v === 'string' && /^[\d\s\-+()]+$/.test(v),
39
+
40
+ // Special
41
+ text: (v) => typeof v === 'string',
42
+ json: (v) => v !== undefined,
43
+ any: () => true,
44
+ }
45
+
46
+ // Constraint validators
47
+ const Constraints = {
48
+ min: (v, min, type) => {
49
+ if (type === 'string' || type === 'text') return v.length >= min
50
+ if (type === 'number' || type === 'integer') return v >= min
51
+ if (Array.isArray(v)) return v.length >= min
52
+ return true
53
+ },
54
+ max: (v, max, type) => {
55
+ if (type === 'string' || type === 'text') return v.length <= max
56
+ if (type === 'number' || type === 'integer') return v <= max
57
+ if (Array.isArray(v)) return v.length <= max
58
+ return true
59
+ },
60
+ pattern: (v, pattern) => {
61
+ if (typeof v !== 'string') return false
62
+ const regex = typeof pattern === 'string' ? new RegExp(pattern) : pattern
63
+ return regex.test(v)
64
+ },
65
+ }
66
+
67
+ // =============================================================================
68
+ // Schema Registry
69
+ // =============================================================================
70
+
71
+ export class Schema {
72
+ constructor(source) {
73
+ this.types = new Map() // @type definitions
74
+ this.models = new Map() // @model definitions
75
+ this.enums = new Map() // @enum definitions
76
+ this.validators = new Map() // Compiled validators per model
77
+ this._ast = null // Raw AST (retained for code generation)
78
+
79
+ if (source) this.register(this.constructor._parse(source))
80
+ }
81
+
82
+ static _parse(source, filename) {
83
+ try {
84
+ return _rawParse(source)
85
+ } catch (err) {
86
+ throw formatParseError(err, source, filename)
87
+ }
88
+ }
89
+
90
+ // ---------------------------------------------------------------------------
91
+ // Factory
92
+ // ---------------------------------------------------------------------------
93
+
94
+ static load(path, base) {
95
+ const resolved = base ? new URL(path, base) : path
96
+ const source = readFileSync(resolved, 'utf-8')
97
+ const filename = typeof resolved === 'string' ? resolved : resolved.pathname
98
+ const schema = new Schema()
99
+ schema.register(Schema._parse(source, filename))
100
+ return schema
101
+ }
102
+
103
+ // ---------------------------------------------------------------------------
104
+ // Registration
105
+ // ---------------------------------------------------------------------------
106
+
107
+ /**
108
+ * Register schema definitions from parsed AST
109
+ */
110
+ register(ast) {
111
+ if (!Array.isArray(ast) || ast[0] !== 'schema') {
112
+ throw new Error('Invalid schema AST')
113
+ }
114
+
115
+ this._ast = ast
116
+
117
+ for (let i = 1; i < ast.length; i++) {
118
+ const def = ast[i]
119
+ if (!Array.isArray(def)) continue
120
+
121
+ switch (def[0]) {
122
+ case 'enum':
123
+ this._registerEnum(def)
124
+ break
125
+ case 'type':
126
+ this._registerType(def)
127
+ break
128
+ case 'model':
129
+ this._registerModel(def)
130
+ break
131
+ }
132
+ }
133
+ }
134
+
135
+ // ---------------------------------------------------------------------------
136
+ // Code generation convenience methods
137
+ // ---------------------------------------------------------------------------
138
+
139
+ toSQL() {
140
+ return generateSQL(this._ast)
141
+ }
142
+
143
+ toTypes() {
144
+ return generateTypes(this._ast)
145
+ }
146
+
147
+ toZod() {
148
+ return generateZod(this._ast)
149
+ }
150
+
151
+ _registerEnum(def) {
152
+ // ["enum", name, values]
153
+ // Simple: ["admin", "user", "guest"]
154
+ // Valued: [["pending", 0], ["active", 1]]
155
+ const [, name, values] = def
156
+ const members = Array.isArray(values[0])
157
+ ? values.map(v => v[0]) // Extract member names from valued enums
158
+ : values
159
+ this.enums.set(name, new Set(members))
160
+ }
161
+
162
+ _registerType(def) {
163
+ // ["type", name, parent, body]
164
+ const [, name, parent, body] = def
165
+ const fields = this._parseFields(body)
166
+ this.types.set(name, { name, parent, fields, directives: {} })
167
+
168
+ // Pre-compile validator for this type
169
+ this._compileValidator(name, 'type')
170
+ }
171
+
172
+ _registerModel(def) {
173
+ // ["model", name, parent, body]
174
+ const [, name, parent, body] = def
175
+ const fields = this._parseFields(body)
176
+ const directives = this._parseDirectives(body)
177
+ this.models.set(name, { name, parent, fields, directives })
178
+
179
+ // Pre-compile validator for this model
180
+ this._compileValidator(name)
181
+ }
182
+
183
+ _parseFields(body) {
184
+ const fields = new Map()
185
+ if (!Array.isArray(body)) return fields
186
+
187
+ for (const item of body) {
188
+ if (!Array.isArray(item)) continue
189
+ if (item[0] === 'field') {
190
+ // ["field", name, modifiers, type, constraints, attrs]
191
+ const [, name, modifiers, type, constraints, attrs] = item
192
+ fields.set(name, {
193
+ name,
194
+ required: modifiers?.includes('!') ?? false,
195
+ unique: modifiers?.includes('#') ?? false,
196
+ optional: modifiers?.includes('?') ?? false,
197
+ type: Array.isArray(type) && type[0] === 'array' ? { array: true, of: type[1] } : type,
198
+ constraints: this._parseConstraints(constraints, type),
199
+ attrs,
200
+ })
201
+ }
202
+ }
203
+ return fields
204
+ }
205
+
206
+ _parseConstraints(constraints, type) {
207
+ if (!Array.isArray(constraints) || constraints.length === 0) {
208
+ return null
209
+ }
210
+
211
+ // Constraints are: [min], [min, max], [default], [min, max, default]
212
+ const result = {}
213
+
214
+ if (constraints.length === 1) {
215
+ // Single value = default
216
+ result.default = constraints[0]
217
+ } else if (constraints.length === 2) {
218
+ // Two values = min, max
219
+ result.min = constraints[0]
220
+ result.max = constraints[1]
221
+ } else if (constraints.length >= 3) {
222
+ // Three values = min, max, default
223
+ result.min = constraints[0]
224
+ result.max = constraints[1]
225
+ result.default = constraints[2]
226
+ }
227
+
228
+ return result
229
+ }
230
+
231
+ _parseDirectives(body) {
232
+ const directives = {}
233
+ if (!Array.isArray(body)) return directives
234
+
235
+ for (const item of body) {
236
+ if (!Array.isArray(item)) continue
237
+ switch (item[0]) {
238
+ case 'timestamps':
239
+ directives.timestamps = true
240
+ break
241
+ case 'softDelete':
242
+ directives.softDelete = true
243
+ break
244
+ case 'index':
245
+ directives.indexes = directives.indexes || []
246
+ directives.indexes.push({ fields: item[1], unique: item[2] })
247
+ break
248
+ case 'belongs_to':
249
+ directives.belongsTo = directives.belongsTo || []
250
+ directives.belongsTo.push({ model: item[1], options: item[2] })
251
+ break
252
+ case 'has_many':
253
+ directives.hasMany = directives.hasMany || []
254
+ directives.hasMany.push({ model: item[1], options: item[2] })
255
+ break
256
+ case 'has_one':
257
+ directives.hasOne = directives.hasOne || []
258
+ directives.hasOne.push({ model: item[1], options: item[2] })
259
+ break
260
+ case 'link':
261
+ directives.links = directives.links || []
262
+ directives.links.push({ role: item[1], model: item[2], options: item[3] })
263
+ break
264
+ }
265
+ }
266
+ return directives
267
+ }
268
+
269
+ // ---------------------------------------------------------------------------
270
+ // Validator Compilation
271
+ // ---------------------------------------------------------------------------
272
+
273
+ _compileValidator(modelName, kind = 'model') {
274
+ const model = kind === 'type' ? this.types.get(modelName) : this.models.get(modelName)
275
+ if (!model) return
276
+
277
+ const checks = []
278
+
279
+ for (const [fieldName, field] of model.fields) {
280
+ checks.push(this._compileFieldValidator(fieldName, field))
281
+ }
282
+
283
+ // Return a function that runs all checks and collects errors
284
+ const validator = (obj) => {
285
+ const errors = []
286
+ for (const check of checks) {
287
+ const error = check(obj)
288
+ if (error) errors.push(error)
289
+ }
290
+ return errors.length > 0 ? errors : null
291
+ }
292
+
293
+ this.validators.set(modelName, validator)
294
+ }
295
+
296
+ _compileFieldValidator(fieldName, field) {
297
+ const { required, type, constraints } = field
298
+ const isArray = typeof type === 'object' && type.array
299
+ const baseType = isArray ? type.of : type
300
+
301
+ return (obj) => {
302
+ const value = obj[fieldName]
303
+
304
+ // Check required
305
+ if (value === undefined || value === null) {
306
+ if (required) {
307
+ return { field: fieldName, error: 'required', message: `${fieldName} is required` }
308
+ }
309
+ return null // Optional and missing is OK
310
+ }
311
+
312
+ // Check array type
313
+ if (isArray) {
314
+ if (!Array.isArray(value)) {
315
+ return { field: fieldName, error: 'type', message: `${fieldName} must be an array` }
316
+ }
317
+ // Validate each item
318
+ for (let i = 0; i < value.length; i++) {
319
+ const itemError = this._validateValue(value[i], baseType, fieldName, constraints)
320
+ if (itemError) {
321
+ return { ...itemError, index: i }
322
+ }
323
+ }
324
+ return null
325
+ }
326
+
327
+ // Check single value
328
+ return this._validateValue(value, baseType, fieldName, constraints)
329
+ }
330
+ }
331
+
332
+ _validateValue(value, type, fieldName, constraints) {
333
+ // Check built-in type
334
+ const typeValidator = Types[type]
335
+ if (typeValidator) {
336
+ if (!typeValidator(value)) {
337
+ return { field: fieldName, error: 'type', message: `${fieldName} must be a valid ${type}` }
338
+ }
339
+ } else if (this.enums.has(type)) {
340
+ // Check enum
341
+ const enumValues = this.enums.get(type)
342
+ if (!enumValues.has(value)) {
343
+ return { field: fieldName, error: 'enum', message: `${fieldName} must be one of: ${[...enumValues].join(', ')}` }
344
+ }
345
+ } else if (this.types.has(type)) {
346
+ // Nested type - validate recursively
347
+ const nestedErrors = this.validate(type, value)
348
+ if (nestedErrors) {
349
+ return { field: fieldName, error: 'nested', errors: nestedErrors }
350
+ }
351
+ }
352
+ // Unknown type - allow (might be a forward reference or external type)
353
+
354
+ // Check constraints
355
+ if (constraints) {
356
+ if (constraints.min !== undefined && !Constraints.min(value, constraints.min, type)) {
357
+ return { field: fieldName, error: 'min', message: `${fieldName} must be at least ${constraints.min}` }
358
+ }
359
+ if (constraints.max !== undefined && !Constraints.max(value, constraints.max, type)) {
360
+ return { field: fieldName, error: 'max', message: `${fieldName} must be at most ${constraints.max}` }
361
+ }
362
+ if (constraints.pattern !== undefined && !Constraints.pattern(value, constraints.pattern)) {
363
+ return { field: fieldName, error: 'pattern', message: `${fieldName} has invalid format` }
364
+ }
365
+ }
366
+
367
+ return null
368
+ }
369
+
370
+ // ---------------------------------------------------------------------------
371
+ // Public API
372
+ // ---------------------------------------------------------------------------
373
+
374
+ /**
375
+ * Create a new model instance with defaults applied
376
+ */
377
+ create(modelName, data = {}) {
378
+ const model = this.models.get(modelName) || this.types.get(modelName)
379
+ if (!model) {
380
+ throw new Error(`Unknown model: ${modelName}`)
381
+ }
382
+
383
+ const instance = { ...data }
384
+
385
+ // Apply defaults
386
+ for (const [fieldName, field] of model.fields) {
387
+ if (instance[fieldName] === undefined && field.constraints?.default !== undefined) {
388
+ const def = field.constraints.default
389
+ instance[fieldName] = (typeof def === 'object' && def !== null) ? structuredClone(def) : def
390
+ }
391
+ }
392
+
393
+ // Add timestamps if configured
394
+ if (model.directives?.timestamps) {
395
+ const now = new Date()
396
+ instance.createdAt = instance.createdAt || now
397
+ instance.updatedAt = instance.updatedAt || now
398
+ }
399
+
400
+ // Attach metadata
401
+ Object.defineProperty(instance, '$model', { value: modelName, enumerable: false })
402
+ Object.defineProperty(instance, '$schema', { value: this, enumerable: false })
403
+ Object.defineProperty(instance, '$validate', {
404
+ value: () => this.validate(modelName, instance),
405
+ enumerable: false,
406
+ })
407
+
408
+ return instance
409
+ }
410
+
411
+ /**
412
+ * Validate an object against a model or type schema
413
+ * Returns null if valid, array of errors if invalid
414
+ */
415
+ validate(modelName, obj) {
416
+ // Check for pre-compiled validator
417
+ let validator = this.validators.get(modelName)
418
+
419
+ // If not found, try to compile on-demand
420
+ if (!validator) {
421
+ if (this.types.has(modelName)) {
422
+ this._compileValidator(modelName, 'type')
423
+ } else if (this.models.has(modelName)) {
424
+ this._compileValidator(modelName, 'model')
425
+ }
426
+ validator = this.validators.get(modelName)
427
+ }
428
+
429
+ if (validator) {
430
+ return validator(obj)
431
+ }
432
+
433
+ return null // Unknown model/type - no validation
434
+ }
435
+
436
+ /**
437
+ * Check if a single value is valid for a field
438
+ */
439
+ isValid(modelName, fieldName, value) {
440
+ const model = this.models.get(modelName) || this.types.get(modelName)
441
+ if (!model) return true
442
+
443
+ const field = model.fields.get(fieldName)
444
+ if (!field) return true
445
+
446
+ const tempObj = { [fieldName]: value }
447
+ const errors = this.validate(modelName, tempObj)
448
+ return !errors || !errors.some(e => e.field === fieldName)
449
+ }
450
+
451
+ /**
452
+ * Get model definition
453
+ */
454
+ getModel(name) {
455
+ return this.models.get(name)
456
+ }
457
+
458
+ /**
459
+ * Get type definition
460
+ */
461
+ getType(name) {
462
+ return this.types.get(name)
463
+ }
464
+
465
+ /**
466
+ * Get enum values
467
+ */
468
+ getEnum(name) {
469
+ return this.enums.get(name)
470
+ }
471
+
472
+ /**
473
+ * List all registered models
474
+ */
475
+ listModels() {
476
+ return [...this.models.keys()]
477
+ }
478
+
479
+ /**
480
+ * List all registered types
481
+ */
482
+ listTypes() {
483
+ return [...this.types.keys()]
484
+ }
485
+
486
+ /**
487
+ * List all registered enums
488
+ */
489
+ listEnums() {
490
+ return [...this.enums.keys()]
491
+ }
492
+ }
493
+
494
+ export default Schema