rip-lang 3.15.0 → 3.15.2
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/README.md +2 -2
- package/bin/rip +1 -1
- package/docs/RIP-SCHEMA.md +4 -4
- package/docs/dist/rip.js +6 -6
- package/docs/dist/rip.min.js +83 -83
- package/docs/dist/rip.min.js.br +0 -0
- package/docs/extensions/duckdb/manifest.json +1 -1
- package/docs/extensions/duckdb/v1.5.2/linux_amd64/ripdb.duckdb_extension.gz +0 -0
- package/docs/extensions/duckdb/v1.5.2/osx_arm64/ripdb.duckdb_extension.gz +0 -0
- package/package.json +6 -8
- package/scripts/postinstall.js +27 -0
- package/src/AGENTS.md +27 -25
- package/src/browser.js +1 -1
- package/src/compiler.js +1 -1
- package/src/grammar/grammar.rip +1 -1
- package/src/lexer.js +1 -1
- package/src/schema/dts-emit.js +329 -0
- package/src/schema/loader-browser.js +55 -0
- package/src/schema/loader-server.js +65 -0
- package/src/schema/runtime-browser-stubs.js +51 -0
- package/src/schema/runtime-db-naming.js +34 -0
- package/src/schema/runtime-ddl.js +124 -0
- package/src/schema/runtime-orm.js +294 -0
- package/src/schema/runtime-validate.js +816 -0
- package/src/schema/runtime.generated.js +1315 -0
- package/src/schema/schema.js +1805 -0
- package/src/typecheck.js +2 -2
- package/src/types-emit.js +1 -1
|
@@ -0,0 +1,1315 @@
|
|
|
1
|
+
// AUTOGEN-NOTICE: do not edit by hand. Regenerate with:
|
|
2
|
+
// bun scripts/build-schema-runtime.js
|
|
3
|
+
// (or: bun run build:schema-runtime)
|
|
4
|
+
//
|
|
5
|
+
// Source fragments:
|
|
6
|
+
// src/schema/runtime-validate.js (universal — browser + server)
|
|
7
|
+
// src/schema/runtime-db-naming.js (server + migration)
|
|
8
|
+
// src/schema/runtime-orm.js (server + migration)
|
|
9
|
+
// src/schema/runtime-ddl.js (migration only)
|
|
10
|
+
// src/schema/runtime-browser-stubs.js (browser only)
|
|
11
|
+
//
|
|
12
|
+
// CI: bun scripts/build-schema-runtime.js --check fails if this file
|
|
13
|
+
// would change after regeneration. Edit the fragments, run the build
|
|
14
|
+
// script, and commit.
|
|
15
|
+
|
|
16
|
+
export const SCHEMA_RUNTIME_ABI_VERSION = 1;
|
|
17
|
+
|
|
18
|
+
export const SCHEMA_RUNTIME_WRAPPER_HEAD = `
|
|
19
|
+
// ---- Rip Schema Runtime ----------------------------------------------------
|
|
20
|
+
// Four layers, lazy compilation:
|
|
21
|
+
// 1 (descriptor) object passed to __schema({...}). Raw metadata.
|
|
22
|
+
// 2 (normalized) fields/methods/computed/hooks/relations/constraints.
|
|
23
|
+
// Collision checks. Table name derivation. Built once.
|
|
24
|
+
// 3 (validator) compiled validator plan. Built on first .parse.
|
|
25
|
+
// 4a (ORM plan) built on first .find/.create/.save.
|
|
26
|
+
// 4b (DDL plan) built on first .toSQL(). Independent of 4a.
|
|
27
|
+
//
|
|
28
|
+
// Instance-singleton model:
|
|
29
|
+
// The runtime installs itself on globalThis.__ripSchema the first time a
|
|
30
|
+
// compiled bundle executes. Subsequent bundles that inject the same runtime
|
|
31
|
+
// template detect the existing installation and bind to it instead of
|
|
32
|
+
// re-running the body — giving every bundle a single shared registry,
|
|
33
|
+
// adapter, and class identity. The IIFE wrapper below enforces that.
|
|
34
|
+
|
|
35
|
+
var { __schema, SchemaError, __SchemaRegistry, __schemaSetAdapter } = (function() {
|
|
36
|
+
if (typeof globalThis !== 'undefined' && globalThis.__ripSchema) {
|
|
37
|
+
if (globalThis.__ripSchema.__version !== 1) {
|
|
38
|
+
throw new Error(
|
|
39
|
+
"rip-schema runtime version mismatch: loaded runtime is v" +
|
|
40
|
+
globalThis.__ripSchema.__version +
|
|
41
|
+
", but this bundle expects v" + 1 +
|
|
42
|
+
". Two compiled Rip bundles with incompatible schema runtimes are loaded in the same process."
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
return globalThis.__ripSchema;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
`;
|
|
49
|
+
export const SCHEMA_RUNTIME_WRAPPER_TAIL = `
|
|
50
|
+
// __schemaSetAdapter is server/migration-only. In validate or browser
|
|
51
|
+
// modes it doesn't exist; export an undefined slot so destructure works.
|
|
52
|
+
const __schemaSetAdapterExport = typeof __schemaSetAdapter !== 'undefined'
|
|
53
|
+
? __schemaSetAdapter
|
|
54
|
+
: undefined;
|
|
55
|
+
const exports = {
|
|
56
|
+
__schema, SchemaError, __SchemaRegistry,
|
|
57
|
+
__schemaSetAdapter: __schemaSetAdapterExport,
|
|
58
|
+
__version: 1,
|
|
59
|
+
};
|
|
60
|
+
if (typeof globalThis !== 'undefined') globalThis.__ripSchema = exports;
|
|
61
|
+
return exports;
|
|
62
|
+
})();
|
|
63
|
+
|
|
64
|
+
// === End Schema Runtime ===
|
|
65
|
+
`;
|
|
66
|
+
|
|
67
|
+
export const SCHEMA_VALIDATE_RUNTIME = `class SchemaError extends Error {
|
|
68
|
+
constructor(issues, schemaName, schemaKind) {
|
|
69
|
+
super(__schemaFormatIssues(issues, schemaName));
|
|
70
|
+
this.name = 'SchemaError';
|
|
71
|
+
this.issues = issues;
|
|
72
|
+
this.schemaName = schemaName || null;
|
|
73
|
+
this.schemaKind = schemaKind || null;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function __schemaFormatIssues(issues, name) {
|
|
78
|
+
if (!issues || !issues.length) return 'SchemaError';
|
|
79
|
+
const head = name ? name + ': ' : '';
|
|
80
|
+
return head + issues.map(i => i.message || i.error || 'invalid').join('; ');
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const __SCHEMA_RESERVED_STATIC = new Set([
|
|
84
|
+
'parse','safe','ok','find','findMany','where','all','first','count','create','toSQL',
|
|
85
|
+
]);
|
|
86
|
+
const __SCHEMA_RESERVED_INSTANCE = new Set([
|
|
87
|
+
'save','destroy','reload','ok','errors','toJSON',
|
|
88
|
+
]);
|
|
89
|
+
const __SCHEMA_RESERVED = new Set([...__SCHEMA_RESERVED_STATIC, ...__SCHEMA_RESERVED_INSTANCE]);
|
|
90
|
+
|
|
91
|
+
const __schemaTypes = {
|
|
92
|
+
string: v => typeof v === 'string',
|
|
93
|
+
number: v => typeof v === 'number' && !Number.isNaN(v),
|
|
94
|
+
integer: v => Number.isInteger(v),
|
|
95
|
+
boolean: v => typeof v === 'boolean',
|
|
96
|
+
date: v => v instanceof Date && !Number.isNaN(v.getTime()),
|
|
97
|
+
datetime: v => v instanceof Date && !Number.isNaN(v.getTime()),
|
|
98
|
+
email: v => typeof v === 'string' && /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/.test(v),
|
|
99
|
+
url: v => typeof v === 'string' && /^https?:\\/\\/.+/.test(v),
|
|
100
|
+
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),
|
|
101
|
+
phone: v => typeof v === 'string' && /^[\\d\\s\\-+()]+$/.test(v),
|
|
102
|
+
zip: v => typeof v === 'string' && /^\\d{5}(-\\d{4})?$/.test(v),
|
|
103
|
+
text: v => typeof v === 'string',
|
|
104
|
+
json: v => v !== undefined,
|
|
105
|
+
any: () => true,
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
function __schemaCheckValue(v, typeName) {
|
|
109
|
+
const check = __schemaTypes[typeName];
|
|
110
|
+
return check ? check(v) : true;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function __schemaValidateValue(v, typeName) {
|
|
114
|
+
const prim = __schemaTypes[typeName];
|
|
115
|
+
if (prim) {
|
|
116
|
+
return prim(v) ? null : [{field: '', error: 'type', message: 'must be ' + typeName}];
|
|
117
|
+
}
|
|
118
|
+
const subDef = __SchemaRegistry.get(typeName);
|
|
119
|
+
if (!subDef) return null;
|
|
120
|
+
if (subDef.kind === 'enum') {
|
|
121
|
+
const errs = subDef._validateEnum(v, true);
|
|
122
|
+
return errs.length ? [{field: '', error: 'enum', message: errs[0].message}] : null;
|
|
123
|
+
}
|
|
124
|
+
if (subDef.kind === 'mixin') {
|
|
125
|
+
return [{field: '', error: 'type', message: ':mixin ' + typeName + ' is not usable as a field type'}];
|
|
126
|
+
}
|
|
127
|
+
if (v === null || typeof v !== 'object' || Array.isArray(v)) {
|
|
128
|
+
return [{field: '', error: 'type', message: 'must be a ' + typeName + ' object'}];
|
|
129
|
+
}
|
|
130
|
+
const subErrs = subDef._validateFields(v, true);
|
|
131
|
+
return subErrs.length ? subErrs : null;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function __schemaJoinField(head, child) {
|
|
135
|
+
if (!child) return head;
|
|
136
|
+
return head + (child.startsWith('[') ? child : '.' + child);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function __schemaRewriteMessage(joinedField, childField, childMessage) {
|
|
140
|
+
if (!childField) return joinedField + ' ' + childMessage;
|
|
141
|
+
if (childMessage.startsWith(childField)) {
|
|
142
|
+
return joinedField + childMessage.slice(childField.length);
|
|
143
|
+
}
|
|
144
|
+
return joinedField + ': ' + childMessage;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function __schemaSnake(s) { return s.replace(/([a-z0-9])([A-Z])/g, '$1_$2').toLowerCase(); }
|
|
148
|
+
|
|
149
|
+
function __schemaCamel(col) { return String(col).replace(/_([a-z])/g, (_, c) => c.toUpperCase()); }
|
|
150
|
+
|
|
151
|
+
const __SchemaRegistry = {
|
|
152
|
+
_entries: new Map(),
|
|
153
|
+
register(def) {
|
|
154
|
+
// Named schemas of any kind land here. Relations look up :model,
|
|
155
|
+
// @mixin Name looks up :mixin. Algebra (.extend etc.) accepts :shape
|
|
156
|
+
// and derived shapes. Kind is checked at lookup time.
|
|
157
|
+
if (!def.name) return;
|
|
158
|
+
// Most recent registration wins. Recompilation produces a fresh
|
|
159
|
+
// __SchemaDef with the same name; the registry rebinds. Cross-
|
|
160
|
+
// module name collisions should be avoided — schema names are
|
|
161
|
+
// app-global identifiers for relation resolution.
|
|
162
|
+
this._entries.set(def.name, { def, kind: def.kind });
|
|
163
|
+
},
|
|
164
|
+
get(name) {
|
|
165
|
+
const entry = this._entries.get(name);
|
|
166
|
+
return entry ? entry.def : null;
|
|
167
|
+
},
|
|
168
|
+
getKind(name, kind) {
|
|
169
|
+
const entry = this._entries.get(name);
|
|
170
|
+
return entry && entry.kind === kind ? entry.def : null;
|
|
171
|
+
},
|
|
172
|
+
has(name) { return this._entries.has(name); },
|
|
173
|
+
reset() { this._entries.clear(); },
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
class __SchemaDef {
|
|
177
|
+
constructor(desc) {
|
|
178
|
+
this._desc = desc;
|
|
179
|
+
this.kind = desc.kind;
|
|
180
|
+
this.name = desc.name || null;
|
|
181
|
+
this._norm = null;
|
|
182
|
+
this._klass = null;
|
|
183
|
+
this._sourceModel = null;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
_normalize() {
|
|
187
|
+
if (this._norm) return this._norm;
|
|
188
|
+
|
|
189
|
+
const fields = new Map();
|
|
190
|
+
const methods = new Map();
|
|
191
|
+
const computed = new Map();
|
|
192
|
+
const derived = new Map();
|
|
193
|
+
const hooks = new Map();
|
|
194
|
+
const directives = [];
|
|
195
|
+
const enumMembers = new Map();
|
|
196
|
+
const relations = new Map();
|
|
197
|
+
const ensures = [];
|
|
198
|
+
let timestamps = false;
|
|
199
|
+
let softDelete = false;
|
|
200
|
+
|
|
201
|
+
const collision = (n, where) => {
|
|
202
|
+
throw new SchemaError(
|
|
203
|
+
[{field: n, error: 'collision', message: n + ' collides with ' + where}],
|
|
204
|
+
this.name, this.kind);
|
|
205
|
+
};
|
|
206
|
+
const noteCollision = (n) => {
|
|
207
|
+
if (fields.has(n)) collision(n, 'field');
|
|
208
|
+
if (methods.has(n)) collision(n, 'method');
|
|
209
|
+
if (computed.has(n)) collision(n, 'computed');
|
|
210
|
+
if (hooks.has(n)) collision(n, 'hook');
|
|
211
|
+
if (relations.has(n)) collision(n, 'relation');
|
|
212
|
+
if (this.kind === 'model' && __SCHEMA_RESERVED.has(n)) collision(n, 'reserved ORM name');
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
for (const e of this._desc.entries) {
|
|
216
|
+
switch (e.tag) {
|
|
217
|
+
case 'field':
|
|
218
|
+
noteCollision(e.name);
|
|
219
|
+
fields.set(e.name, {
|
|
220
|
+
name: e.name,
|
|
221
|
+
required: e.modifiers.includes('!'),
|
|
222
|
+
unique: e.modifiers.includes('#'),
|
|
223
|
+
optional: e.modifiers.includes('?'),
|
|
224
|
+
typeName: e.typeName,
|
|
225
|
+
literals: e.literals || null,
|
|
226
|
+
array: e.array === true,
|
|
227
|
+
constraints: e.constraints || null,
|
|
228
|
+
transform: e.transform || null,
|
|
229
|
+
});
|
|
230
|
+
break;
|
|
231
|
+
case 'method':
|
|
232
|
+
noteCollision(e.name);
|
|
233
|
+
methods.set(e.name, e.fn);
|
|
234
|
+
break;
|
|
235
|
+
case 'computed':
|
|
236
|
+
noteCollision(e.name);
|
|
237
|
+
computed.set(e.name, e.fn);
|
|
238
|
+
break;
|
|
239
|
+
case 'derived':
|
|
240
|
+
noteCollision(e.name);
|
|
241
|
+
derived.set(e.name, e.fn);
|
|
242
|
+
break;
|
|
243
|
+
case 'hook':
|
|
244
|
+
if (hooks.has(e.name)) collision(e.name, 'duplicate hook');
|
|
245
|
+
hooks.set(e.name, e.fn);
|
|
246
|
+
break;
|
|
247
|
+
case 'directive': {
|
|
248
|
+
directives.push({ name: e.name, args: e.args || [] });
|
|
249
|
+
// @mixin is recorded but further handling is deferred to the
|
|
250
|
+
// post-pass so we can dedupe diamond includes and detect
|
|
251
|
+
// cycles with a full expansion stack. All other directives
|
|
252
|
+
// get their relation / timestamps / softDelete processing now.
|
|
253
|
+
if (e.name === 'mixin') break;
|
|
254
|
+
if (e.name === 'timestamps') timestamps = true;
|
|
255
|
+
if (e.name === 'softDelete') softDelete = true;
|
|
256
|
+
const rel = __schemaNormalizeDirectiveRelation(e, this.name);
|
|
257
|
+
if (rel) {
|
|
258
|
+
noteCollision(rel.accessor);
|
|
259
|
+
relations.set(rel.accessor, rel);
|
|
260
|
+
}
|
|
261
|
+
break;
|
|
262
|
+
}
|
|
263
|
+
case 'enum-member':
|
|
264
|
+
enumMembers.set(e.name, e.value !== undefined ? e.value : e.name);
|
|
265
|
+
break;
|
|
266
|
+
case 'ensure':
|
|
267
|
+
// @ensure entries are schema-level invariants (cross-field
|
|
268
|
+
// predicates). Declaration order is preserved so diagnostics
|
|
269
|
+
// come out in the order authored.
|
|
270
|
+
ensures.push({ message: e.message, fn: e.fn });
|
|
271
|
+
break;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// @mixin expansion (Phase 5). Depth-first, dedupes diamond includes
|
|
276
|
+
// in the same host expansion, detects cycles with full chain.
|
|
277
|
+
if (this.kind === 'model' || this.kind === 'shape' || this.kind === 'input' ||
|
|
278
|
+
this.kind === 'mixin') {
|
|
279
|
+
__schemaExpandMixins(this, fields, directives, {
|
|
280
|
+
stack: [this.name || '<anon>'],
|
|
281
|
+
seen: new Set([this.name || '<anon>']),
|
|
282
|
+
onCollision: (name, src) => collision(name, 'mixin-included field from ' + src),
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// Add implicit primary key for :model unless a field already marked primary.
|
|
287
|
+
const primaryKey = 'id';
|
|
288
|
+
const tableName = this.kind === 'model' ? __schemaTableName(this.name) : null;
|
|
289
|
+
|
|
290
|
+
this._norm = {
|
|
291
|
+
fields, methods, computed, derived, hooks, directives, enumMembers, relations,
|
|
292
|
+
ensures,
|
|
293
|
+
timestamps, softDelete, primaryKey, tableName,
|
|
294
|
+
};
|
|
295
|
+
return this._norm;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// Run eager-derived entries (!>) — one pass, in declaration order.
|
|
299
|
+
//
|
|
300
|
+
// Invariants worth keeping in mind here:
|
|
301
|
+
// - Fires at parse/safe time AND at DB hydrate time (declared fields
|
|
302
|
+
// are populated by then in both paths).
|
|
303
|
+
// - NOT re-run on field mutation — the value is materialized once at
|
|
304
|
+
// instance creation and stays. Use ~> for live recomputation.
|
|
305
|
+
// - Stored as own enumerable properties, so they round-trip through
|
|
306
|
+
// Object.keys and JSON.stringify. Excluded from DB persistence by
|
|
307
|
+
// _getSaveableData (writes declared fields only).
|
|
308
|
+
// - Thrown errors propagate. parse() wraps them into SchemaError
|
|
309
|
+
// before surfacing; safe() captures into {error: 'derived'}
|
|
310
|
+
// issues; hydrate lets them crash fast as data-integrity signals.
|
|
311
|
+
|
|
312
|
+
_applyEagerDerived(inst) {
|
|
313
|
+
const norm = this._normalize();
|
|
314
|
+
if (!norm.derived.size) return;
|
|
315
|
+
for (const [n, fn] of norm.derived) {
|
|
316
|
+
const v = fn.call(inst);
|
|
317
|
+
Object.defineProperty(inst, n, {
|
|
318
|
+
value: v, enumerable: true, writable: true, configurable: true,
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// Run '@ensure' predicates — schema-level cross-field invariants —
|
|
324
|
+
// against a fully-typed, fully-defaulted data object. Returns [] if
|
|
325
|
+
// all pass, or an array of {field: '', error: 'ensure', message}
|
|
326
|
+
// issues for every failing predicate.
|
|
327
|
+
//
|
|
328
|
+
// Naming: '_applyEnsures' mirrors '_applyTransforms' and
|
|
329
|
+
// '_applyEagerDerived' — runtime method name matches the directive
|
|
330
|
+
// it services. The industry term for this pattern is 'refinement'
|
|
331
|
+
// (Zod's '.refine', design-by-contract postconditions); in Rip the
|
|
332
|
+
// user-visible name is '@ensure' and the code tracks that.
|
|
333
|
+
//
|
|
334
|
+
// Semantics:
|
|
335
|
+
// - Truthy return → pass; falsy → fail with the declared message.
|
|
336
|
+
// - Thrown exception → fail with the declared message (the thrown
|
|
337
|
+
// error's own message is used only if the @ensure declared no
|
|
338
|
+
// message, which can't happen via the parser since message is
|
|
339
|
+
// required — but downstream code-built defs might omit it).
|
|
340
|
+
// - All @ensures run; declaration order preserved in output.
|
|
341
|
+
// - Caller short-circuits: per-field validation errors skip this
|
|
342
|
+
// step entirely (predicates assume field types are correct).
|
|
343
|
+
// - Skipped on _hydrate — trusted DB data bypasses @ensures.
|
|
344
|
+
|
|
345
|
+
_applyEnsures(data) {
|
|
346
|
+
const norm = this._normalize();
|
|
347
|
+
if (!norm.ensures.length) return [];
|
|
348
|
+
const errs = [];
|
|
349
|
+
for (const r of norm.ensures) {
|
|
350
|
+
let ok = false;
|
|
351
|
+
try {
|
|
352
|
+
ok = !!r.fn(data);
|
|
353
|
+
} catch (e) {
|
|
354
|
+
errs.push({
|
|
355
|
+
field: '', error: 'ensure',
|
|
356
|
+
message: r.message || e?.message || 'ensure failed',
|
|
357
|
+
});
|
|
358
|
+
continue;
|
|
359
|
+
}
|
|
360
|
+
if (!ok) {
|
|
361
|
+
errs.push({
|
|
362
|
+
field: '', error: 'ensure',
|
|
363
|
+
message: r.message || 'ensure failed',
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
return errs;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
_getClass() {
|
|
371
|
+
if (this._klass) return this._klass;
|
|
372
|
+
const norm = this._normalize();
|
|
373
|
+
const name = this.name || 'Schema';
|
|
374
|
+
const def = this;
|
|
375
|
+
|
|
376
|
+
const fieldNames = [...norm.fields.keys()];
|
|
377
|
+
const klass = ({[name]: class {
|
|
378
|
+
constructor(data, persisted = false) {
|
|
379
|
+
// Internal state is non-enumerable so Object.keys(inst) lists
|
|
380
|
+
// only declared fields that received a value.
|
|
381
|
+
Object.defineProperty(this, '_dirty', { value: new Set(), enumerable: false, writable: false, configurable: true });
|
|
382
|
+
Object.defineProperty(this, '_persisted', { value: persisted === true, enumerable: false, writable: true, configurable: true });
|
|
383
|
+
Object.defineProperty(this, '_snapshot', { value: null, enumerable: false, writable: true, configurable: true });
|
|
384
|
+
if (data && typeof data === 'object') {
|
|
385
|
+
for (const k of fieldNames) {
|
|
386
|
+
if (k in data && data[k] !== undefined) this[k] = data[k];
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
}})[name];
|
|
391
|
+
|
|
392
|
+
for (const [n, fn] of norm.methods) {
|
|
393
|
+
Object.defineProperty(klass.prototype, n, {
|
|
394
|
+
value: fn, writable: true, enumerable: false, configurable: true,
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
for (const [n, fn] of norm.computed) {
|
|
398
|
+
Object.defineProperty(klass.prototype, n, {
|
|
399
|
+
get: fn, enumerable: false, configurable: true,
|
|
400
|
+
});
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// Relation methods: user.organization(). Accepts no args; returns
|
|
404
|
+
// a promise to a target-model instance (or array for has_many).
|
|
405
|
+
for (const [acc, rel] of norm.relations) {
|
|
406
|
+
Object.defineProperty(klass.prototype, acc, {
|
|
407
|
+
enumerable: false, configurable: true,
|
|
408
|
+
value: async function() { return __schemaResolveRelation(def, this, rel); },
|
|
409
|
+
});
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// Instance ORM methods — only for :model kind.
|
|
413
|
+
if (this.kind === 'model') {
|
|
414
|
+
Object.defineProperty(klass.prototype, 'save', {
|
|
415
|
+
enumerable: false, configurable: true, writable: true,
|
|
416
|
+
value: async function() { return __schemaSave(def, this); },
|
|
417
|
+
});
|
|
418
|
+
Object.defineProperty(klass.prototype, 'destroy', {
|
|
419
|
+
enumerable: false, configurable: true, writable: true,
|
|
420
|
+
value: async function() { return __schemaDestroy(def, this); },
|
|
421
|
+
});
|
|
422
|
+
Object.defineProperty(klass.prototype, 'ok', {
|
|
423
|
+
enumerable: false, configurable: true, writable: true,
|
|
424
|
+
value: function() { return def._validateFields(this, false); },
|
|
425
|
+
});
|
|
426
|
+
Object.defineProperty(klass.prototype, 'errors', {
|
|
427
|
+
enumerable: false, configurable: true, writable: true,
|
|
428
|
+
value: function() { return def._validateFields(this, true); },
|
|
429
|
+
});
|
|
430
|
+
// toJSON mirrors the instance's own enumerable properties, which by
|
|
431
|
+
// construction are: the primary key, declared fields, @timestamps
|
|
432
|
+
// columns, @softDelete timestamp, @belongs_to FK columns, and any
|
|
433
|
+
// !> eager-derived fields. Internal state (_dirty, _persisted,
|
|
434
|
+
// _snapshot) is defined non-enumerable; methods and ~> computed
|
|
435
|
+
// getters live on the prototype. So iterating own keys picks up
|
|
436
|
+
// exactly the user-facing wire shape without special-casing each
|
|
437
|
+
// category — and stays correct when new implicit columns get added
|
|
438
|
+
// to the runtime.
|
|
439
|
+
Object.defineProperty(klass.prototype, 'toJSON', {
|
|
440
|
+
enumerable: false, configurable: true, writable: true,
|
|
441
|
+
value: function() {
|
|
442
|
+
const out = {};
|
|
443
|
+
for (const k of Object.keys(this)) out[k] = this[k];
|
|
444
|
+
return out;
|
|
445
|
+
},
|
|
446
|
+
});
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
this._klass = klass;
|
|
450
|
+
return klass;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
_hydrate(columns, row) {
|
|
454
|
+
// DB rows are trusted: hydrate into a class instance without
|
|
455
|
+
// revalidating. Column names arrive snake_case; declared fields live
|
|
456
|
+
// under their camelCase names, and implicit columns (id, created_at,
|
|
457
|
+
// updated_at, relation FKs) surface under their camelCase equivalents.
|
|
458
|
+
// Each snake_case column name also aliases the camelCase property via
|
|
459
|
+
// a non-enumerable accessor so order.user_id and order.userId read
|
|
460
|
+
// the same slot — useful when DB column names leak into user code
|
|
461
|
+
// via raw SQL helpers.
|
|
462
|
+
const data = {};
|
|
463
|
+
for (let i = 0; i < columns.length; i++) {
|
|
464
|
+
data[__schemaCamel(columns[i].name)] = row[i];
|
|
465
|
+
}
|
|
466
|
+
const k = this._getClass();
|
|
467
|
+
const inst = new k(data, true);
|
|
468
|
+
for (const key of Object.keys(data)) {
|
|
469
|
+
if (!(key in inst)) {
|
|
470
|
+
Object.defineProperty(inst, key, {
|
|
471
|
+
value: data[key], enumerable: true, writable: true, configurable: true,
|
|
472
|
+
});
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
for (let i = 0; i < columns.length; i++) {
|
|
476
|
+
const snake = columns[i].name;
|
|
477
|
+
const camel = __schemaCamel(snake);
|
|
478
|
+
if (snake !== camel && !(snake in inst)) {
|
|
479
|
+
Object.defineProperty(inst, snake, {
|
|
480
|
+
enumerable: false, configurable: true,
|
|
481
|
+
get() { return this[camel]; },
|
|
482
|
+
set(v) { this[camel] = v; },
|
|
483
|
+
});
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
// Eager-derived fields re-run on hydrate — they're not persisted
|
|
487
|
+
// and must be re-computed from the declared fields now present.
|
|
488
|
+
this._applyEagerDerived(inst);
|
|
489
|
+
return inst;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
_validateFields(data, collect) {
|
|
493
|
+
const norm = this._normalize();
|
|
494
|
+
const errors = collect ? [] : null;
|
|
495
|
+
for (const [n, f] of norm.fields) {
|
|
496
|
+
const v = data == null ? undefined : data[n];
|
|
497
|
+
if (v === undefined || v === null) {
|
|
498
|
+
if (f.required) {
|
|
499
|
+
if (!collect) return false;
|
|
500
|
+
errors.push({field: n, error: 'required', message: n + ' is required'});
|
|
501
|
+
}
|
|
502
|
+
continue;
|
|
503
|
+
}
|
|
504
|
+
if (f.array) {
|
|
505
|
+
if (!Array.isArray(v)) {
|
|
506
|
+
if (!collect) return false;
|
|
507
|
+
errors.push({field: n, error: 'type', message: n + ' must be an array'});
|
|
508
|
+
continue;
|
|
509
|
+
}
|
|
510
|
+
let bad = false;
|
|
511
|
+
for (let i = 0; i < v.length; i++) {
|
|
512
|
+
const issues = __schemaValidateValue(v[i], f.typeName);
|
|
513
|
+
if (issues) {
|
|
514
|
+
if (!collect) return false;
|
|
515
|
+
const head = n + '[' + i + ']';
|
|
516
|
+
for (const e of issues) {
|
|
517
|
+
const joined = __schemaJoinField(head, e.field);
|
|
518
|
+
errors.push({
|
|
519
|
+
field: joined,
|
|
520
|
+
error: e.error,
|
|
521
|
+
message: __schemaRewriteMessage(joined, e.field, e.message),
|
|
522
|
+
});
|
|
523
|
+
}
|
|
524
|
+
bad = true;
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
if (bad) continue;
|
|
528
|
+
} else if (f.typeName === 'literal-union') {
|
|
529
|
+
if (!f.literals.includes(v)) {
|
|
530
|
+
if (!collect) return false;
|
|
531
|
+
errors.push({field: n, error: 'enum', message: n + ' must be one of ' + f.literals.map(l => JSON.stringify(l)).join(', ')});
|
|
532
|
+
continue;
|
|
533
|
+
}
|
|
534
|
+
} else {
|
|
535
|
+
const issues = __schemaValidateValue(v, f.typeName);
|
|
536
|
+
if (issues) {
|
|
537
|
+
if (!collect) return false;
|
|
538
|
+
for (const e of issues) {
|
|
539
|
+
const joined = __schemaJoinField(n, e.field);
|
|
540
|
+
errors.push({
|
|
541
|
+
field: joined,
|
|
542
|
+
error: e.error,
|
|
543
|
+
message: __schemaRewriteMessage(joined, e.field, e.message),
|
|
544
|
+
});
|
|
545
|
+
}
|
|
546
|
+
continue;
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
// Apply constraint checks.
|
|
550
|
+
const c = f.constraints;
|
|
551
|
+
if (c) {
|
|
552
|
+
if (typeof v === 'string') {
|
|
553
|
+
if (c.min != null && v.length < c.min) { if (!collect) return false; errors.push({field: n, error: 'min', message: n + ' must be at least ' + c.min + ' chars'}); }
|
|
554
|
+
if (c.max != null && v.length > c.max) { if (!collect) return false; errors.push({field: n, error: 'max', message: n + ' must be at most ' + c.max + ' chars'}); }
|
|
555
|
+
if (c.regex && !c.regex.test(v)) { if (!collect) return false; errors.push({field: n, error: 'pattern', message: n + ' is invalid'}); }
|
|
556
|
+
} else if (typeof v === 'number') {
|
|
557
|
+
if (c.min != null && v < c.min) { if (!collect) return false; errors.push({field: n, error: 'min', message: n + ' must be >= ' + c.min}); }
|
|
558
|
+
if (c.max != null && v > c.max) { if (!collect) return false; errors.push({field: n, error: 'max', message: n + ' must be <= ' + c.max}); }
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
return collect ? errors : true;
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
_applyDefaults(data) {
|
|
566
|
+
const norm = this._normalize();
|
|
567
|
+
for (const [n, f] of norm.fields) {
|
|
568
|
+
if ((data[n] === undefined || data[n] === null) && f.constraints?.default !== undefined) {
|
|
569
|
+
const d = f.constraints.default;
|
|
570
|
+
data[n] = (typeof d === 'object' && d !== null && !(d instanceof RegExp))
|
|
571
|
+
? structuredClone(d) : d;
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
return data;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
// Inline field transforms run once during parse (and safe/ok), never
|
|
578
|
+
// during DB hydrate. Each transform receives the whole raw input
|
|
579
|
+
// object as 'it'; its return value becomes the field's candidate
|
|
580
|
+
// value before default + validation. Transform errors surface as
|
|
581
|
+
// {error: 'transform'} issues on the final result.
|
|
582
|
+
|
|
583
|
+
_applyTransforms(raw, working) {
|
|
584
|
+
const norm = this._normalize();
|
|
585
|
+
const errors = [];
|
|
586
|
+
for (const [n, f] of norm.fields) {
|
|
587
|
+
if (!f.transform) continue;
|
|
588
|
+
try {
|
|
589
|
+
working[n] = f.transform(raw);
|
|
590
|
+
} catch (e) {
|
|
591
|
+
errors.push({field: n, error: 'transform', message: e?.message || String(e)});
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
return errors;
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
_validateEnum(data, collect) {
|
|
598
|
+
const norm = this._normalize();
|
|
599
|
+
for (const [n, v] of norm.enumMembers) {
|
|
600
|
+
if (data === n || data === v) return collect ? [] : true;
|
|
601
|
+
}
|
|
602
|
+
if (!collect) return false;
|
|
603
|
+
const members = [...norm.enumMembers.keys()].join(', ');
|
|
604
|
+
return [{field: '', error: 'enum', message: (this.name || 'enum') + ' expected one of: ' + members}];
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
_materializeEnum(data) {
|
|
608
|
+
const norm = this._normalize();
|
|
609
|
+
for (const [n, v] of norm.enumMembers) {
|
|
610
|
+
if (data === n || data === v) return v;
|
|
611
|
+
}
|
|
612
|
+
return data;
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
// Canonical field parse pipeline — run per-field in declaration order,
|
|
616
|
+
// then an after-fields pass for eager-derived. This is the SINGLE
|
|
617
|
+
// source of truth for parse-time field semantics; _hydrate bypasses
|
|
618
|
+
// steps 1-5 entirely (DB rows arrive canonical) and picks up at step 7.
|
|
619
|
+
//
|
|
620
|
+
// 1. Obtain raw candidate — transform(raw) if declared, else raw[name]
|
|
621
|
+
// 2. Apply default — if candidate missing/undefined
|
|
622
|
+
// 3. Required check — optional/required/nullability
|
|
623
|
+
// 4. Type validation — primitive / literal-union / array
|
|
624
|
+
// 5. Constraint checks — range, regex, attrs
|
|
625
|
+
// 6. Assign to instance — own enumerable property
|
|
626
|
+
// 7. Eager-derived pass — run !> entries in declaration order
|
|
627
|
+
//
|
|
628
|
+
// Transforms (step 1) run on parse/safe/ok only. Hydrate skips them
|
|
629
|
+
// because DB columns already hold the canonical values. Eager-derived
|
|
630
|
+
// (step 7) fires on BOTH paths so hydrated instances have the same
|
|
631
|
+
// shape as parsed ones.
|
|
632
|
+
|
|
633
|
+
parse(data) {
|
|
634
|
+
if (this.kind === 'mixin') {
|
|
635
|
+
throw new Error(":mixin schema '" + (this.name || 'anon') + "' is not instantiable");
|
|
636
|
+
}
|
|
637
|
+
if (this.kind === 'enum') {
|
|
638
|
+
const errs = this._validateEnum(data, true);
|
|
639
|
+
if (errs.length) throw new SchemaError(errs, this.name, this.kind);
|
|
640
|
+
return this._materializeEnum(data);
|
|
641
|
+
}
|
|
642
|
+
const raw = data || {};
|
|
643
|
+
const working = { ...raw };
|
|
644
|
+
const transformErrors = this._applyTransforms(raw, working);
|
|
645
|
+
this._applyDefaults(working);
|
|
646
|
+
const errs = transformErrors.concat(this._validateFields(working, true));
|
|
647
|
+
if (errs.length) throw new SchemaError(errs, this.name, this.kind);
|
|
648
|
+
// @ensure runs AFTER per-field validation so predicates can
|
|
649
|
+
// assume declared fields are typed and defaulted. A field-level
|
|
650
|
+
// failure short-circuits: we never reach this line with errs.
|
|
651
|
+
const ensureErrs = this._applyEnsures(working);
|
|
652
|
+
if (ensureErrs.length) throw new SchemaError(ensureErrs, this.name, this.kind);
|
|
653
|
+
const klass = this._getClass();
|
|
654
|
+
const inst = new klass(working, false);
|
|
655
|
+
this._applyEagerDerived(inst);
|
|
656
|
+
return inst;
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
safe(data) {
|
|
660
|
+
if (this.kind === 'mixin') {
|
|
661
|
+
return {ok: false, value: null, errors: [{field: '', error: 'mixin', message: 'not instantiable'}]};
|
|
662
|
+
}
|
|
663
|
+
if (this.kind === 'enum') {
|
|
664
|
+
const errs = this._validateEnum(data, true);
|
|
665
|
+
if (errs.length) return {ok: false, value: null, errors: errs};
|
|
666
|
+
return {ok: true, value: this._materializeEnum(data), errors: null};
|
|
667
|
+
}
|
|
668
|
+
const raw = data || {};
|
|
669
|
+
const working = { ...raw };
|
|
670
|
+
const transformErrors = this._applyTransforms(raw, working);
|
|
671
|
+
this._applyDefaults(working);
|
|
672
|
+
const errs = transformErrors.concat(this._validateFields(working, true));
|
|
673
|
+
if (errs.length) return {ok: false, value: null, errors: errs};
|
|
674
|
+
const ensureErrs = this._applyEnsures(working);
|
|
675
|
+
if (ensureErrs.length) return {ok: false, value: null, errors: ensureErrs};
|
|
676
|
+
const klass = this._getClass();
|
|
677
|
+
const inst = new klass(working, false);
|
|
678
|
+
try { this._applyEagerDerived(inst); }
|
|
679
|
+
catch (e) {
|
|
680
|
+
return {ok: false, value: null, errors: [{field: '', error: 'derived', message: e?.message || String(e)}]};
|
|
681
|
+
}
|
|
682
|
+
return {ok: true, value: inst, errors: null};
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
ok(data) {
|
|
686
|
+
if (this.kind === 'mixin') return false;
|
|
687
|
+
if (this.kind === 'enum') return this._validateEnum(data, false);
|
|
688
|
+
const raw = data || {};
|
|
689
|
+
const working = { ...raw };
|
|
690
|
+
const transformErrors = this._applyTransforms(raw, working);
|
|
691
|
+
if (transformErrors.length) return false;
|
|
692
|
+
this._applyDefaults(working);
|
|
693
|
+
if (!this._validateFields(working, false)) return false;
|
|
694
|
+
// Per-field validation passed — @ensure predicates are the final gate.
|
|
695
|
+
return this._applyEnsures(working).length === 0;
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
// ---- :model static ORM methods --------------------------------------------
|
|
699
|
+
|
|
700
|
+
pick(...keys) {
|
|
701
|
+
return __schemaDerive(this, (src) => {
|
|
702
|
+
const names = __schemaFlatten(keys);
|
|
703
|
+
const out = new Map();
|
|
704
|
+
for (const k of names) {
|
|
705
|
+
if (!src.has(k)) throw new Error("pick: unknown field '" + k + "' on " + (this.name || 'schema'));
|
|
706
|
+
out.set(k, src.get(k));
|
|
707
|
+
}
|
|
708
|
+
return out;
|
|
709
|
+
});
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
omit(...keys) {
|
|
713
|
+
return __schemaDerive(this, (src) => {
|
|
714
|
+
const drop = new Set(__schemaFlatten(keys));
|
|
715
|
+
const out = new Map();
|
|
716
|
+
for (const [k, v] of src) if (!drop.has(k)) out.set(k, v);
|
|
717
|
+
return out;
|
|
718
|
+
});
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
partial() {
|
|
722
|
+
return __schemaDerive(this, (src) => {
|
|
723
|
+
const out = new Map();
|
|
724
|
+
for (const [k, v] of src) out.set(k, { ...v, required: false });
|
|
725
|
+
return out;
|
|
726
|
+
});
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
required(...keys) {
|
|
730
|
+
return __schemaDerive(this, (src) => {
|
|
731
|
+
const req = new Set(__schemaFlatten(keys));
|
|
732
|
+
const out = new Map();
|
|
733
|
+
for (const [k, v] of src) out.set(k, { ...v, required: req.has(k) ? true : v.required });
|
|
734
|
+
return out;
|
|
735
|
+
});
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
extend(other) {
|
|
739
|
+
if (!(other instanceof __SchemaDef)) {
|
|
740
|
+
throw new Error('extend(): argument must be a schema value');
|
|
741
|
+
}
|
|
742
|
+
return __schemaDerive(this, (src) => {
|
|
743
|
+
const merged = new Map(src);
|
|
744
|
+
const otherFields = other._normalize().fields;
|
|
745
|
+
for (const [k, v] of otherFields) {
|
|
746
|
+
if (merged.has(k)) {
|
|
747
|
+
throw new Error("extend(): field '" + k + "' collides between " + (this.name || 'schema') + " and " + (other.name || 'other'));
|
|
748
|
+
}
|
|
749
|
+
merged.set(k, v);
|
|
750
|
+
}
|
|
751
|
+
return merged;
|
|
752
|
+
});
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
function __schemaFlatten(keys) {
|
|
757
|
+
const out = [];
|
|
758
|
+
for (const k of keys) {
|
|
759
|
+
if (typeof k === 'symbol') out.push(Symbol.keyFor(k) || k.description);
|
|
760
|
+
else if (Array.isArray(k)) for (const kk of k) out.push(typeof kk === 'symbol' ? (Symbol.keyFor(kk) || kk.description) : kk);
|
|
761
|
+
else out.push(k);
|
|
762
|
+
}
|
|
763
|
+
return out;
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
function __schemaDerive(source, transform) {
|
|
767
|
+
const src = source._normalize().fields;
|
|
768
|
+
const derivedFields = transform(src);
|
|
769
|
+
const entries = [];
|
|
770
|
+
for (const [, f] of derivedFields) {
|
|
771
|
+
const mods = [];
|
|
772
|
+
if (f.required) mods.push('!');
|
|
773
|
+
if (f.unique) mods.push('#');
|
|
774
|
+
if (f.optional && !f.required) mods.push('?');
|
|
775
|
+
entries.push({
|
|
776
|
+
tag: 'field', name: f.name, modifiers: mods,
|
|
777
|
+
typeName: f.typeName, array: f.array,
|
|
778
|
+
literals: f.literals || null,
|
|
779
|
+
constraints: f.constraints,
|
|
780
|
+
transform: f.transform || null,
|
|
781
|
+
});
|
|
782
|
+
}
|
|
783
|
+
const name = (source.name || 'Schema') + 'Derived';
|
|
784
|
+
const derived = new __SchemaDef({ kind: 'shape', name, entries });
|
|
785
|
+
// sourceModel propagates through chained algebra. Tooling can follow
|
|
786
|
+
// the chain back to the original :model for projection hints.
|
|
787
|
+
derived._sourceModel = source._sourceModel || (source.kind === 'model' ? source : null);
|
|
788
|
+
return derived;
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
function __schemaNormalizeDirectiveRelation(directive, ownerModel) {
|
|
792
|
+
const args = directive.args;
|
|
793
|
+
if (!args || !args.length) return null;
|
|
794
|
+
const a = args[0];
|
|
795
|
+
const name = directive.name;
|
|
796
|
+
if (name === 'belongs_to') {
|
|
797
|
+
const targetLc = a.target[0].toLowerCase() + a.target.slice(1);
|
|
798
|
+
return { kind: 'belongsTo', target: a.target, accessor: targetLc, foreignKey: __schemaFkName(a.target), optional: !!a.optional };
|
|
799
|
+
}
|
|
800
|
+
if (name === 'has_one' || name === 'one') {
|
|
801
|
+
const targetLc = a.target[0].toLowerCase() + a.target.slice(1);
|
|
802
|
+
return { kind: 'hasOne', target: a.target, accessor: targetLc, foreignKey: __schemaFkName(ownerModel), optional: !!a.optional };
|
|
803
|
+
}
|
|
804
|
+
if (name === 'has_many' || name === 'many') {
|
|
805
|
+
const targetLc = a.target[0].toLowerCase() + a.target.slice(1);
|
|
806
|
+
return { kind: 'hasMany', target: a.target, accessor: __schemaPluralize(targetLc), foreignKey: __schemaFkName(ownerModel), optional: !!a.optional };
|
|
807
|
+
}
|
|
808
|
+
return null;
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
function __schemaExpandMixins(host, fields, directives, ctx) {
|
|
812
|
+
for (const d of directives) {
|
|
813
|
+
if (d.name !== 'mixin' || !d.args || !d.args[0]) continue;
|
|
814
|
+
const target = d.args[0].target;
|
|
815
|
+
if (!target) continue;
|
|
816
|
+
if (ctx.stack.includes(target)) {
|
|
817
|
+
throw new SchemaError(
|
|
818
|
+
[{field: '', error: 'mixin-cycle', message: 'mixin cycle: ' + ctx.stack.concat(target).join(' -> ')}],
|
|
819
|
+
host.name, host.kind);
|
|
820
|
+
}
|
|
821
|
+
if (ctx.seen.has(target)) continue;
|
|
822
|
+
const mx = __SchemaRegistry.getKind(target, 'mixin');
|
|
823
|
+
if (!mx) {
|
|
824
|
+
throw new SchemaError(
|
|
825
|
+
[{field: '', error: 'mixin-missing', message: 'unknown mixin: ' + target}],
|
|
826
|
+
host.name, host.kind);
|
|
827
|
+
}
|
|
828
|
+
ctx.seen.add(target);
|
|
829
|
+
ctx.stack.push(target);
|
|
830
|
+
// Recurse into nested mixins first (depth-first).
|
|
831
|
+
const childDirectives = mx._desc.entries.filter(e => e.tag === 'directive' && e.name === 'mixin')
|
|
832
|
+
.map(e => ({ name: e.name, args: e.args || [] }));
|
|
833
|
+
__schemaExpandMixins(host, fields, childDirectives, ctx);
|
|
834
|
+
// Then contribute the mixin's own fields.
|
|
835
|
+
for (const e of mx._desc.entries) {
|
|
836
|
+
if (e.tag !== 'field') continue;
|
|
837
|
+
if (fields.has(e.name)) {
|
|
838
|
+
throw new SchemaError(
|
|
839
|
+
[{field: e.name, error: 'mixin-collision', message: e.name + ' from mixin ' + target + ' collides with existing field'}],
|
|
840
|
+
host.name, host.kind);
|
|
841
|
+
}
|
|
842
|
+
fields.set(e.name, {
|
|
843
|
+
name: e.name,
|
|
844
|
+
required: e.modifiers.includes('!'),
|
|
845
|
+
unique: e.modifiers.includes('#'),
|
|
846
|
+
optional: e.modifiers.includes('?'),
|
|
847
|
+
typeName: e.typeName,
|
|
848
|
+
literals: e.literals || null,
|
|
849
|
+
array: e.array === true,
|
|
850
|
+
constraints: e.constraints || null,
|
|
851
|
+
transform: e.transform || null,
|
|
852
|
+
});
|
|
853
|
+
}
|
|
854
|
+
ctx.stack.pop();
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
function __schema(descriptor) {
|
|
859
|
+
const def = new __SchemaDef(descriptor);
|
|
860
|
+
// Every user-declared named schema lands in the registry so
|
|
861
|
+
// nested-typed fields (address! Address, items! OrderItem[],
|
|
862
|
+
// role! Role) can resolve their type reference at validate time.
|
|
863
|
+
// Algebra-derived schemas (.pick/.omit/.partial/…) bypass this
|
|
864
|
+
// factory so their synthetic names don't shadow the source.
|
|
865
|
+
if (def.name) __SchemaRegistry.register(def);
|
|
866
|
+
return def;
|
|
867
|
+
}
|
|
868
|
+
`;
|
|
869
|
+
export const SCHEMA_DB_NAMING_RUNTIME = `const __SCHEMA_UNCOUNTABLE = new Set(['equipment','information','rice','money','species','series','fish','sheep','data']);
|
|
870
|
+
|
|
871
|
+
const __SCHEMA_IRREGULAR = new Map([['person','people'],['man','men'],['woman','women'],['child','children'],['tooth','teeth'],['foot','feet'],['mouse','mice']]);
|
|
872
|
+
|
|
873
|
+
function __schemaPluralize(w) {
|
|
874
|
+
const lw = w.toLowerCase();
|
|
875
|
+
if (__SCHEMA_UNCOUNTABLE.has(lw)) return w;
|
|
876
|
+
if (__SCHEMA_IRREGULAR.has(lw)) return __SCHEMA_IRREGULAR.get(lw);
|
|
877
|
+
// Preserve case of the input — pluralizer operates on the trailing form
|
|
878
|
+
// but keeps the rest unchanged, so orderItem becomes orderItems
|
|
879
|
+
// and User becomes Users.
|
|
880
|
+
if (/[^aeiouy]y$/i.test(w)) return w.slice(0, -1) + 'ies';
|
|
881
|
+
if (/(s|x|z|ch|sh)$/i.test(w)) return w + 'es';
|
|
882
|
+
return w + 's';
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
function __schemaTableName(model) { return __schemaPluralize(__schemaSnake(model)); }
|
|
886
|
+
|
|
887
|
+
function __schemaFkName(model) { return __schemaSnake(model) + '_id'; }
|
|
888
|
+
`;
|
|
889
|
+
export const SCHEMA_ORM_RUNTIME = `function __schemaDefaultAdapter() {
|
|
890
|
+
const url = (typeof process !== 'undefined' && process.env?.DB_URL) || 'http://localhost:4213';
|
|
891
|
+
return {
|
|
892
|
+
async query(sql, params) {
|
|
893
|
+
const body = params && params.length ? { sql, params } : { sql };
|
|
894
|
+
const res = await fetch(url + '/sql', {
|
|
895
|
+
method: 'POST',
|
|
896
|
+
headers: { 'Content-Type': 'application/json' },
|
|
897
|
+
body: JSON.stringify(body),
|
|
898
|
+
});
|
|
899
|
+
const data = await res.json();
|
|
900
|
+
if (data.error) throw new Error(data.error);
|
|
901
|
+
return data;
|
|
902
|
+
}
|
|
903
|
+
};
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
let __schemaAdapter = __schemaDefaultAdapter();
|
|
907
|
+
|
|
908
|
+
function __schemaSetAdapter(a) { __schemaAdapter = a; }
|
|
909
|
+
|
|
910
|
+
class __SchemaQuery {
|
|
911
|
+
constructor(def, opts = {}) {
|
|
912
|
+
this._def = def;
|
|
913
|
+
this._clauses = [];
|
|
914
|
+
this._params = [];
|
|
915
|
+
this._limit = null;
|
|
916
|
+
this._offset = null;
|
|
917
|
+
this._order = null;
|
|
918
|
+
this._includeDeleted = opts.includeDeleted === true;
|
|
919
|
+
}
|
|
920
|
+
where(cond, ...params) {
|
|
921
|
+
if (typeof cond === 'string') {
|
|
922
|
+
this._clauses.push(cond);
|
|
923
|
+
this._params.push(...params);
|
|
924
|
+
} else if (cond && typeof cond === 'object') {
|
|
925
|
+
for (const [k, v] of Object.entries(cond)) {
|
|
926
|
+
const col = __schemaSnake(k);
|
|
927
|
+
if (v === null || v === undefined) {
|
|
928
|
+
this._clauses.push('"' + col + '" IS NULL');
|
|
929
|
+
} else {
|
|
930
|
+
this._clauses.push('"' + col + '" = ?');
|
|
931
|
+
this._params.push(v);
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
return this;
|
|
936
|
+
}
|
|
937
|
+
limit(n) { this._limit = n; return this; }
|
|
938
|
+
offset(n) { this._offset = n; return this; }
|
|
939
|
+
order(spec) { this._order = spec; return this; }
|
|
940
|
+
orderBy(spec) { return this.order(spec); }
|
|
941
|
+
_buildSQL() {
|
|
942
|
+
const n = this._def._normalize();
|
|
943
|
+
const table = n.tableName;
|
|
944
|
+
const parts = ['SELECT * FROM "' + table + '"'];
|
|
945
|
+
const where = [...this._clauses];
|
|
946
|
+
if (!this._includeDeleted && n.softDelete) where.push('"deleted_at" IS NULL');
|
|
947
|
+
if (where.length) parts.push('WHERE ' + where.join(' AND '));
|
|
948
|
+
if (this._order) parts.push('ORDER BY ' + this._order);
|
|
949
|
+
if (this._limit != null) parts.push('LIMIT ' + this._limit);
|
|
950
|
+
if (this._offset != null) parts.push('OFFSET ' + this._offset);
|
|
951
|
+
return parts.join(' ');
|
|
952
|
+
}
|
|
953
|
+
async all() {
|
|
954
|
+
const sql = this._buildSQL();
|
|
955
|
+
const res = await __schemaAdapter.query(sql, this._params);
|
|
956
|
+
return (res.data || []).map(row => this._def._hydrate(res.columns, row));
|
|
957
|
+
}
|
|
958
|
+
async first() {
|
|
959
|
+
this._limit = 1;
|
|
960
|
+
const arr = await this.all();
|
|
961
|
+
return arr[0] || null;
|
|
962
|
+
}
|
|
963
|
+
async count() {
|
|
964
|
+
const n = this._def._normalize();
|
|
965
|
+
const parts = ['SELECT COUNT(*) FROM "' + n.tableName + '"'];
|
|
966
|
+
const where = [...this._clauses];
|
|
967
|
+
if (!this._includeDeleted && n.softDelete) where.push('"deleted_at" IS NULL');
|
|
968
|
+
if (where.length) parts.push('WHERE ' + where.join(' AND '));
|
|
969
|
+
const res = await __schemaAdapter.query(parts.join(' '), this._params);
|
|
970
|
+
return res.data?.[0]?.[0] || 0;
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
async function __schemaResolveRelation(def, inst, rel) {
|
|
975
|
+
const target = __SchemaRegistry.get(rel.target);
|
|
976
|
+
if (!target) throw new Error('schema: unknown relation target "' + rel.target + '" from ' + (def.name || 'anon'));
|
|
977
|
+
const pk = def._normalize().primaryKey;
|
|
978
|
+
if (rel.kind === 'belongsTo') {
|
|
979
|
+
const fk = inst[__schemaCamel(rel.foreignKey)];
|
|
980
|
+
return fk != null ? await target.find(fk) : null;
|
|
981
|
+
}
|
|
982
|
+
if (rel.kind === 'hasOne') {
|
|
983
|
+
return await target.where({ [rel.foreignKey]: inst[pk] }).first();
|
|
984
|
+
}
|
|
985
|
+
if (rel.kind === 'hasMany') {
|
|
986
|
+
return await target.where({ [rel.foreignKey]: inst[pk] }).all();
|
|
987
|
+
}
|
|
988
|
+
return null;
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
async function __schemaRunHook(def, inst, name) {
|
|
992
|
+
const fn = def._normalize().hooks.get(name);
|
|
993
|
+
if (fn) await fn.call(inst);
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
async function __schemaSave(def, inst) {
|
|
997
|
+
const norm = def._normalize();
|
|
998
|
+
const isNew = !inst._persisted;
|
|
999
|
+
|
|
1000
|
+
await __schemaRunHook(def, inst, 'beforeValidation');
|
|
1001
|
+
const errs = def._validateFields(inst, true);
|
|
1002
|
+
if (errs.length) throw new SchemaError(errs, def.name, def.kind);
|
|
1003
|
+
await __schemaRunHook(def, inst, 'afterValidation');
|
|
1004
|
+
|
|
1005
|
+
await __schemaRunHook(def, inst, 'beforeSave');
|
|
1006
|
+
if (isNew) await __schemaRunHook(def, inst, 'beforeCreate');
|
|
1007
|
+
else await __schemaRunHook(def, inst, 'beforeUpdate');
|
|
1008
|
+
|
|
1009
|
+
if (isNew) {
|
|
1010
|
+
const cols = [], placeholders = [], values = [];
|
|
1011
|
+
for (const [n, f] of norm.fields) {
|
|
1012
|
+
const v = inst[n];
|
|
1013
|
+
if (v == null) continue;
|
|
1014
|
+
cols.push('"' + __schemaSnake(n) + '"');
|
|
1015
|
+
placeholders.push('?');
|
|
1016
|
+
values.push(__schemaSerialize(v, f));
|
|
1017
|
+
}
|
|
1018
|
+
// Include relation FKs. belongsTo FKs are camelCase properties on
|
|
1019
|
+
// the instance (e.g. organizationId for organization_id).
|
|
1020
|
+
for (const [, rel] of norm.relations) {
|
|
1021
|
+
if (rel.kind !== 'belongsTo') continue;
|
|
1022
|
+
const fkCamel = __schemaCamel(rel.foreignKey);
|
|
1023
|
+
const v = inst[fkCamel];
|
|
1024
|
+
if (v != null) {
|
|
1025
|
+
cols.push('"' + rel.foreignKey + '"');
|
|
1026
|
+
placeholders.push('?');
|
|
1027
|
+
values.push(v);
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
const sql = 'INSERT INTO "' + norm.tableName + '" (' + cols.join(', ') + ') VALUES (' + placeholders.join(', ') + ') RETURNING *';
|
|
1031
|
+
const res = await __schemaAdapter.query(sql, values);
|
|
1032
|
+
if (res.data?.[0] && res.columns) {
|
|
1033
|
+
for (let i = 0; i < res.columns.length; i++) {
|
|
1034
|
+
const snake = res.columns[i].name;
|
|
1035
|
+
const key = __schemaCamel(snake);
|
|
1036
|
+
if (!(key in inst)) {
|
|
1037
|
+
Object.defineProperty(inst, key, { value: res.data[0][i], enumerable: true, writable: true, configurable: true });
|
|
1038
|
+
} else {
|
|
1039
|
+
inst[key] = res.data[0][i];
|
|
1040
|
+
}
|
|
1041
|
+
if (snake !== key && !(snake in inst)) {
|
|
1042
|
+
Object.defineProperty(inst, snake, {
|
|
1043
|
+
enumerable: false, configurable: true,
|
|
1044
|
+
get() { return this[key]; },
|
|
1045
|
+
set(v) { this[key] = v; },
|
|
1046
|
+
});
|
|
1047
|
+
}
|
|
1048
|
+
}
|
|
1049
|
+
}
|
|
1050
|
+
// Now that the RETURNING columns (id, @timestamps, FKs) are on the
|
|
1051
|
+
// instance, !> eager-derived fields can see them. Mirrors the hydrate
|
|
1052
|
+
// path, which runs _applyEagerDerived once all declared fields are
|
|
1053
|
+
// populated. Per-docs semantics ("materialize once, not reactive")
|
|
1054
|
+
// still hold — we're firing once, at end of construction, not on
|
|
1055
|
+
// subsequent mutations.
|
|
1056
|
+
def._applyEagerDerived(inst);
|
|
1057
|
+
inst._persisted = true;
|
|
1058
|
+
} else {
|
|
1059
|
+
const sets = [], values = [];
|
|
1060
|
+
for (const [n, f] of norm.fields) {
|
|
1061
|
+
sets.push('"' + __schemaSnake(n) + '" = ?');
|
|
1062
|
+
values.push(__schemaSerialize(inst[n], f));
|
|
1063
|
+
}
|
|
1064
|
+
if (sets.length) {
|
|
1065
|
+
const pk = norm.primaryKey;
|
|
1066
|
+
values.push(inst[pk]);
|
|
1067
|
+
const sql = 'UPDATE "' + norm.tableName + '" SET ' + sets.join(', ') + ' WHERE "' + pk + '" = ?';
|
|
1068
|
+
await __schemaAdapter.query(sql, values);
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
1071
|
+
inst._dirty.clear();
|
|
1072
|
+
|
|
1073
|
+
if (isNew) await __schemaRunHook(def, inst, 'afterCreate');
|
|
1074
|
+
else await __schemaRunHook(def, inst, 'afterUpdate');
|
|
1075
|
+
await __schemaRunHook(def, inst, 'afterSave');
|
|
1076
|
+
return inst;
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
async function __schemaDestroy(def, inst) {
|
|
1080
|
+
if (!inst._persisted) return inst;
|
|
1081
|
+
const norm = def._normalize();
|
|
1082
|
+
await __schemaRunHook(def, inst, 'beforeDestroy');
|
|
1083
|
+
if (norm.softDelete) {
|
|
1084
|
+
const now = new Date().toISOString();
|
|
1085
|
+
await __schemaAdapter.query('UPDATE "' + norm.tableName + '" SET "deleted_at" = ? WHERE "' + norm.primaryKey + '" = ?', [now, inst[norm.primaryKey]]);
|
|
1086
|
+
inst.deletedAt = now;
|
|
1087
|
+
} else {
|
|
1088
|
+
await __schemaAdapter.query('DELETE FROM "' + norm.tableName + '" WHERE "' + norm.primaryKey + '" = ?', [inst[norm.primaryKey]]);
|
|
1089
|
+
inst._persisted = false;
|
|
1090
|
+
}
|
|
1091
|
+
await __schemaRunHook(def, inst, 'afterDestroy');
|
|
1092
|
+
return inst;
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
function __schemaSerialize(v, field) {
|
|
1096
|
+
if (field && field.typeName === 'json' && v != null && typeof v === 'object') {
|
|
1097
|
+
return JSON.stringify(v);
|
|
1098
|
+
}
|
|
1099
|
+
return v;
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
// ORM prototype augmentations — added to __SchemaDef
|
|
1103
|
+
|
|
1104
|
+
__SchemaDef.prototype.find = async function (id) {
|
|
1105
|
+
this._assertModel('find');
|
|
1106
|
+
const norm = this._normalize();
|
|
1107
|
+
const soft = norm.softDelete ? ' AND "deleted_at" IS NULL' : '';
|
|
1108
|
+
const sql = 'SELECT * FROM "' + norm.tableName + '" WHERE "' + norm.primaryKey + '" = ?' + soft + ' LIMIT 1';
|
|
1109
|
+
const res = await __schemaAdapter.query(sql, [id]);
|
|
1110
|
+
if (!res.rows) return null;
|
|
1111
|
+
return this._hydrate(res.columns, res.data[0]);
|
|
1112
|
+
};
|
|
1113
|
+
|
|
1114
|
+
__SchemaDef.prototype.where = function (cond, ...params) {
|
|
1115
|
+
this._assertModel('where');
|
|
1116
|
+
return new __SchemaQuery(this).where(cond, ...params);
|
|
1117
|
+
};
|
|
1118
|
+
|
|
1119
|
+
__SchemaDef.prototype.all = function () {
|
|
1120
|
+
this._assertModel('all');
|
|
1121
|
+
return new __SchemaQuery(this).all();
|
|
1122
|
+
};
|
|
1123
|
+
|
|
1124
|
+
__SchemaDef.prototype.first = function () {
|
|
1125
|
+
this._assertModel('first');
|
|
1126
|
+
return new __SchemaQuery(this).first();
|
|
1127
|
+
};
|
|
1128
|
+
|
|
1129
|
+
__SchemaDef.prototype.count = function () {
|
|
1130
|
+
this._assertModel('count');
|
|
1131
|
+
return new __SchemaQuery(this).count();
|
|
1132
|
+
};
|
|
1133
|
+
|
|
1134
|
+
__SchemaDef.prototype.create = async function (data) {
|
|
1135
|
+
this._assertModel('create');
|
|
1136
|
+
// Input keys may be snake_case or camelCase; the runtime
|
|
1137
|
+
// canonicalizes to camelCase so instance properties line up with
|
|
1138
|
+
// declared field names.
|
|
1139
|
+
const klass = this._getClass();
|
|
1140
|
+
const canonical = {};
|
|
1141
|
+
if (data && typeof data === 'object') {
|
|
1142
|
+
for (const k of Object.keys(data)) canonical[__schemaCamel(k)] = data[k];
|
|
1143
|
+
}
|
|
1144
|
+
const inst = new klass(this._applyDefaults(canonical), false);
|
|
1145
|
+
// FK columns like user_id canonicalize to userId and need to
|
|
1146
|
+
// round-trip through the INSERT path, so attach them as own
|
|
1147
|
+
// properties even though they aren't declared fields.
|
|
1148
|
+
for (const [k, v] of Object.entries(canonical)) {
|
|
1149
|
+
if (!(k in inst)) {
|
|
1150
|
+
Object.defineProperty(inst, k, { value: v, enumerable: true, writable: true, configurable: true });
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
1153
|
+
await __schemaSave(this, inst);
|
|
1154
|
+
return inst;
|
|
1155
|
+
};
|
|
1156
|
+
|
|
1157
|
+
__SchemaDef.prototype._assertModel = function (api) {
|
|
1158
|
+
if (this.kind !== 'model') {
|
|
1159
|
+
throw new Error('schema: .' + api + '() is :model-only (got :' + this.kind + ')');
|
|
1160
|
+
}
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
// ---- Schema algebra (Phase 6) --------------------------------------------
|
|
1164
|
+
// Invariant: every algebra operation returns a :shape. Model algebra
|
|
1165
|
+
// strips ORM; :shape algebra drops behavior. Derived shapes preserve
|
|
1166
|
+
// field metadata (constraints, defaults, modifiers) from the source
|
|
1167
|
+
// normalized descriptor.
|
|
1168
|
+
`;
|
|
1169
|
+
export const SCHEMA_DDL_RUNTIME = `const __SCHEMA_SQL_TYPES = {
|
|
1170
|
+
string: 'VARCHAR', text: 'TEXT', integer: 'INTEGER', number: 'DOUBLE',
|
|
1171
|
+
boolean: 'BOOLEAN', date: 'DATE', datetime: 'TIMESTAMP', email: 'VARCHAR',
|
|
1172
|
+
url: 'VARCHAR', uuid: 'UUID', phone: 'VARCHAR', zip: 'VARCHAR', json: 'JSON', any: 'JSON',
|
|
1173
|
+
};
|
|
1174
|
+
|
|
1175
|
+
function __schemaToSQL(def, options) {
|
|
1176
|
+
const opts = options || {};
|
|
1177
|
+
const { dropFirst = false, header } = opts;
|
|
1178
|
+
const norm = def._normalize();
|
|
1179
|
+
const blocks = [];
|
|
1180
|
+
if (header) blocks.push(header);
|
|
1181
|
+
|
|
1182
|
+
const table = norm.tableName;
|
|
1183
|
+
const seq = table + '_seq';
|
|
1184
|
+
if (dropFirst) {
|
|
1185
|
+
blocks.push('DROP TABLE IF EXISTS ' + table + ' CASCADE;\\nDROP SEQUENCE IF EXISTS ' + seq + ';');
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
// Sequence seed: explicit option wins over @idStart directive wins over 1.
|
|
1189
|
+
// DuckDB 1.5.2 does not implement ALTER SEQUENCE ... RESTART WITH N, so the
|
|
1190
|
+
// baseline has to be set at creation — hence the knob lives here, not in a
|
|
1191
|
+
// post-create migration.
|
|
1192
|
+
let idStart = 1;
|
|
1193
|
+
for (const d of norm.directives) {
|
|
1194
|
+
if (d.name === 'idStart' && d.args?.[0] && Number.isInteger(d.args[0].value)) {
|
|
1195
|
+
idStart = d.args[0].value;
|
|
1196
|
+
}
|
|
1197
|
+
}
|
|
1198
|
+
if (opts.idStart !== undefined) {
|
|
1199
|
+
if (!Number.isInteger(opts.idStart)) {
|
|
1200
|
+
throw new Error('schema.toSQL(): idStart must be an integer; got ' + String(opts.idStart));
|
|
1201
|
+
}
|
|
1202
|
+
idStart = opts.idStart;
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
const columns = [];
|
|
1206
|
+
const indexes = [];
|
|
1207
|
+
columns.push(' ' + norm.primaryKey + " INTEGER PRIMARY KEY DEFAULT nextval('" + seq + "')");
|
|
1208
|
+
|
|
1209
|
+
for (const [n, f] of norm.fields) {
|
|
1210
|
+
columns.push(__schemaColumnDDL(n, f));
|
|
1211
|
+
if (f.unique) {
|
|
1212
|
+
indexes.push('CREATE UNIQUE INDEX idx_' + table + '_' + __schemaSnake(n) + ' ON ' + table + ' ("' + __schemaSnake(n) + '");');
|
|
1213
|
+
}
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
for (const [, rel] of norm.relations) {
|
|
1217
|
+
if (rel.kind !== 'belongsTo') continue;
|
|
1218
|
+
const refTable = __schemaTableName(rel.target);
|
|
1219
|
+
const notNull = rel.optional ? '' : ' NOT NULL';
|
|
1220
|
+
columns.push(' ' + rel.foreignKey + ' INTEGER' + notNull + ' REFERENCES ' + refTable + '(id)');
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
if (norm.timestamps) {
|
|
1224
|
+
columns.push(' created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP');
|
|
1225
|
+
columns.push(' updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP');
|
|
1226
|
+
}
|
|
1227
|
+
if (norm.softDelete) {
|
|
1228
|
+
columns.push(' deleted_at TIMESTAMP');
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
// @index directives
|
|
1232
|
+
for (const d of norm.directives) {
|
|
1233
|
+
if (d.name !== 'index') continue;
|
|
1234
|
+
const ixArgs = d.args?.[0] || {};
|
|
1235
|
+
const fields = (ixArgs.fields || []).map(__schemaSnake);
|
|
1236
|
+
if (!fields.length) continue;
|
|
1237
|
+
const u = ixArgs.unique ? 'UNIQUE ' : '';
|
|
1238
|
+
indexes.push('CREATE ' + u + 'INDEX idx_' + table + '_' + fields.join('_') + ' ON ' + table + ' (' + fields.map(f => '"' + f + '"').join(', ') + ');');
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1241
|
+
blocks.push('CREATE SEQUENCE ' + seq + ' START ' + idStart + ';');
|
|
1242
|
+
blocks.push('CREATE TABLE ' + table + ' (\\n' + columns.join(',\\n') + '\\n);');
|
|
1243
|
+
if (indexes.length) blocks.push(indexes.join('\\n'));
|
|
1244
|
+
|
|
1245
|
+
return blocks.join('\\n\\n') + '\\n';
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
function __schemaColumnDDL(name, field) {
|
|
1249
|
+
let base = __SCHEMA_SQL_TYPES[field.typeName] || 'VARCHAR';
|
|
1250
|
+
if (field.array) base = 'JSON';
|
|
1251
|
+
if (base === 'VARCHAR' && field.constraints?.max != null) {
|
|
1252
|
+
base = 'VARCHAR(' + field.constraints.max + ')';
|
|
1253
|
+
}
|
|
1254
|
+
const parts = [' ' + __schemaSnake(name) + ' ' + base];
|
|
1255
|
+
if (field.required) parts.push('NOT NULL');
|
|
1256
|
+
if (field.unique) parts.push('UNIQUE');
|
|
1257
|
+
if (field.constraints?.default !== undefined) {
|
|
1258
|
+
parts.push('DEFAULT ' + __schemaSQLDefault(field.constraints.default));
|
|
1259
|
+
}
|
|
1260
|
+
return parts.join(' ');
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1263
|
+
function __schemaSQLDefault(v) {
|
|
1264
|
+
if (v === true) return 'true';
|
|
1265
|
+
if (v === false) return 'false';
|
|
1266
|
+
if (v === null) return 'NULL';
|
|
1267
|
+
if (typeof v === 'number') return String(v);
|
|
1268
|
+
if (typeof v === 'string') return "'" + v.replace(/'/g, "''") + "'";
|
|
1269
|
+
return "'" + String(v).replace(/'/g, "''") + "'";
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
// DDL prototype augmentation — added to __SchemaDef
|
|
1273
|
+
|
|
1274
|
+
__SchemaDef.prototype.toSQL = function (options) {
|
|
1275
|
+
this._assertModel('toSQL');
|
|
1276
|
+
return __schemaToSQL(this, options);
|
|
1277
|
+
};
|
|
1278
|
+
`;
|
|
1279
|
+
export const SCHEMA_BROWSER_STUBS_RUNTIME = `// Browser stubs — throwing replacements for every ORM / DDL helper that
|
|
1280
|
+
// the validate fragment references but doesn't implement. Loaded ONLY
|
|
1281
|
+
// in browser mode.
|
|
1282
|
+
//
|
|
1283
|
+
// The validate fragment's \`_makeClass\`, \`_normalize\`, and
|
|
1284
|
+
// \`__schemaNormalizeDirectiveRelation\` reference helpers that live in
|
|
1285
|
+
// db-naming, orm, and ddl fragments at runtime. Browser mode doesn't
|
|
1286
|
+
// include those fragments, so we provide thin throwing stubs here so
|
|
1287
|
+
// browser-side schema declarations parse and validate cleanly while
|
|
1288
|
+
// any attempt to use server-only behavior fails with a helpful message.
|
|
1289
|
+
|
|
1290
|
+
const __schemaBrowserStub = (api) => function() {
|
|
1291
|
+
throw new Error(
|
|
1292
|
+
"schema." + api + "() is not available in the browser. " +
|
|
1293
|
+
"Import @rip-lang/db on the server."
|
|
1294
|
+
);
|
|
1295
|
+
};
|
|
1296
|
+
|
|
1297
|
+
// Static / class-level methods on __SchemaDef
|
|
1298
|
+
__SchemaDef.prototype.find = __schemaBrowserStub('find');
|
|
1299
|
+
__SchemaDef.prototype.where = __schemaBrowserStub('where');
|
|
1300
|
+
__SchemaDef.prototype.all = __schemaBrowserStub('all');
|
|
1301
|
+
__SchemaDef.prototype.first = __schemaBrowserStub('first');
|
|
1302
|
+
__SchemaDef.prototype.count = __schemaBrowserStub('count');
|
|
1303
|
+
__SchemaDef.prototype.create = __schemaBrowserStub('create');
|
|
1304
|
+
__SchemaDef.prototype.toSQL = __schemaBrowserStub('toSQL');
|
|
1305
|
+
|
|
1306
|
+
// Helpers referenced by the validate fragment that are otherwise
|
|
1307
|
+
// defined in db-naming / orm fragments. Kept inert (return safe
|
|
1308
|
+
// defaults or throw on use) so validate's _makeClass / _normalize
|
|
1309
|
+
// can run end-to-end in browser context.
|
|
1310
|
+
function __schemaSave() { throw new Error("schema instance.save() is not available in the browser. Import @rip-lang/db on the server."); }
|
|
1311
|
+
function __schemaDestroy() { throw new Error("schema instance.destroy() is not available in the browser. Import @rip-lang/db on the server."); }
|
|
1312
|
+
function __schemaTableName(m) { return null; } // returned only for :model normalize; never used downstream in browser
|
|
1313
|
+
function __schemaPluralize(w) { return w; } // identity — relations work for type-resolution but never query
|
|
1314
|
+
function __schemaFkName(m) { return ''; } // ditto
|
|
1315
|
+
`;
|