@active-record-ts/active-model 1.0.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js ADDED
@@ -0,0 +1,1808 @@
1
+ // src/Type.ts
2
+ var isNullish = (value) => value === null || value === void 0;
3
+ var StringType = class {
4
+ type = "string";
5
+ cast(value) {
6
+ if (isNullish(value)) return null;
7
+ if (typeof value === "string") return value;
8
+ if (typeof value === "number" || typeof value === "bigint" || typeof value === "boolean") return String(value);
9
+ if (value instanceof Date) return value.toISOString();
10
+ return String(value);
11
+ }
12
+ serialize(value) {
13
+ return value;
14
+ }
15
+ deserialize(value) {
16
+ return this.cast(value);
17
+ }
18
+ };
19
+ var IntegerType = class {
20
+ type = "integer";
21
+ cast(value) {
22
+ if (isNullish(value) || value === "") return null;
23
+ if (typeof value === "number") return Number.isFinite(value) ? Math.trunc(value) : null;
24
+ if (typeof value === "bigint") return Number(value);
25
+ if (typeof value === "boolean") return value ? 1 : 0;
26
+ if (typeof value === "string") {
27
+ const parsed = Number.parseInt(value, 10);
28
+ return Number.isNaN(parsed) ? null : parsed;
29
+ }
30
+ return null;
31
+ }
32
+ serialize(value) {
33
+ return value;
34
+ }
35
+ deserialize(value) {
36
+ return this.cast(value);
37
+ }
38
+ };
39
+ var BigIntType = class {
40
+ type = "bigint";
41
+ cast(value) {
42
+ if (isNullish(value) || value === "") return null;
43
+ if (typeof value === "bigint") return value;
44
+ if (typeof value === "number") return Number.isFinite(value) ? BigInt(Math.trunc(value)) : null;
45
+ if (typeof value === "string") {
46
+ try {
47
+ return BigInt(value);
48
+ } catch {
49
+ return null;
50
+ }
51
+ }
52
+ return null;
53
+ }
54
+ serialize(value) {
55
+ return value;
56
+ }
57
+ deserialize(value) {
58
+ return this.cast(value);
59
+ }
60
+ };
61
+ var FloatType = class {
62
+ type = "float";
63
+ cast(value) {
64
+ if (isNullish(value) || value === "") return null;
65
+ if (typeof value === "number") return Number.isFinite(value) ? value : null;
66
+ if (typeof value === "bigint") return Number(value);
67
+ if (typeof value === "string") {
68
+ const parsed = Number.parseFloat(value);
69
+ return Number.isNaN(parsed) ? null : parsed;
70
+ }
71
+ if (typeof value === "boolean") return value ? 1 : 0;
72
+ return null;
73
+ }
74
+ serialize(value) {
75
+ return value;
76
+ }
77
+ deserialize(value) {
78
+ return this.cast(value);
79
+ }
80
+ };
81
+ var DecimalType = class {
82
+ type = "decimal";
83
+ cast(value) {
84
+ if (isNullish(value) || value === "") return null;
85
+ if (typeof value === "string") return value;
86
+ if (typeof value === "number" || typeof value === "bigint") return String(value);
87
+ return String(value);
88
+ }
89
+ serialize(value) {
90
+ return value;
91
+ }
92
+ deserialize(value) {
93
+ return this.cast(value);
94
+ }
95
+ };
96
+ var TRUTHY = /* @__PURE__ */ new Set(["1", "t", "T", "true", "TRUE", "on", "ON", 1, true]);
97
+ var FALSY = /* @__PURE__ */ new Set(["0", "f", "F", "false", "FALSE", "off", "OFF", 0, false]);
98
+ var BooleanType = class {
99
+ type = "boolean";
100
+ cast(value) {
101
+ if (isNullish(value) || value === "") return null;
102
+ if (TRUTHY.has(value)) return true;
103
+ if (FALSY.has(value)) return false;
104
+ return Boolean(value);
105
+ }
106
+ serialize(value) {
107
+ return value;
108
+ }
109
+ deserialize(value) {
110
+ return this.cast(value);
111
+ }
112
+ };
113
+ var dateOnly = (date) => {
114
+ const d = new Date(date.getTime());
115
+ d.setUTCHours(0, 0, 0, 0);
116
+ return d;
117
+ };
118
+ var DateType = class {
119
+ type = "date";
120
+ cast(value) {
121
+ if (isNullish(value) || value === "") return null;
122
+ if (value instanceof Date) return dateOnly(value);
123
+ if (typeof value === "string") {
124
+ const d = new Date(/^\d{4}-\d{2}-\d{2}$/.test(value) ? `${value}T00:00:00.000Z` : value);
125
+ return Number.isNaN(d.getTime()) ? null : dateOnly(d);
126
+ }
127
+ if (typeof value === "number") {
128
+ const d = new Date(value);
129
+ return Number.isNaN(d.getTime()) ? null : dateOnly(d);
130
+ }
131
+ return null;
132
+ }
133
+ serialize(value) {
134
+ if (value == null) return null;
135
+ return value.toISOString().slice(0, 10);
136
+ }
137
+ deserialize(value) {
138
+ return this.cast(value);
139
+ }
140
+ equals(a, b) {
141
+ if (a == null || b == null) return a === b;
142
+ return a.getTime() === b.getTime();
143
+ }
144
+ };
145
+ var DateTimeType = class {
146
+ type = "datetime";
147
+ cast(value) {
148
+ if (isNullish(value) || value === "") return null;
149
+ if (value instanceof Date) return new Date(value.getTime());
150
+ if (typeof value === "string") {
151
+ const d = new Date(value);
152
+ return Number.isNaN(d.getTime()) ? null : d;
153
+ }
154
+ if (typeof value === "number") {
155
+ const d = new Date(value);
156
+ return Number.isNaN(d.getTime()) ? null : d;
157
+ }
158
+ return null;
159
+ }
160
+ serialize(value) {
161
+ if (value == null) return null;
162
+ return value.toISOString();
163
+ }
164
+ deserialize(value) {
165
+ return this.cast(value);
166
+ }
167
+ equals(a, b) {
168
+ if (a == null || b == null) return a === b;
169
+ return a.getTime() === b.getTime();
170
+ }
171
+ };
172
+ var JSONType = class {
173
+ type = "json";
174
+ cast(value) {
175
+ if (isNullish(value)) return null;
176
+ if (typeof value === "string") {
177
+ try {
178
+ return JSON.parse(value);
179
+ } catch {
180
+ return null;
181
+ }
182
+ }
183
+ return value;
184
+ }
185
+ serialize(value) {
186
+ if (value == null) return null;
187
+ return JSON.stringify(value);
188
+ }
189
+ deserialize(value) {
190
+ return this.cast(value);
191
+ }
192
+ };
193
+ var BinaryType = class {
194
+ type = "binary";
195
+ cast(value) {
196
+ if (isNullish(value)) return null;
197
+ if (value instanceof Uint8Array) return value;
198
+ if (typeof value === "string") return new TextEncoder().encode(value);
199
+ if (Array.isArray(value)) return new Uint8Array(value);
200
+ return null;
201
+ }
202
+ serialize(value) {
203
+ return value;
204
+ }
205
+ deserialize(value) {
206
+ return this.cast(value);
207
+ }
208
+ equals(a, b) {
209
+ if (a == null || b == null) return a === b;
210
+ if (a.length !== b.length) return false;
211
+ for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
212
+ return true;
213
+ }
214
+ };
215
+ var ValueType = class {
216
+ type = "value";
217
+ cast(value) {
218
+ return isNullish(value) ? null : value;
219
+ }
220
+ serialize(value) {
221
+ return value;
222
+ }
223
+ deserialize(value) {
224
+ return isNullish(value) ? null : value;
225
+ }
226
+ };
227
+ var valuesEqual = (type, a, b) => type.equals ? type.equals(a, b) : a === b;
228
+ var REGISTRY = /* @__PURE__ */ new Map();
229
+ var registerType = (name, factory) => {
230
+ REGISTRY.set(name, factory);
231
+ };
232
+ var lookupType = (name) => {
233
+ const factory = REGISTRY.get(name);
234
+ if (!factory) throw new Error(`Unknown attribute type: ${name}`);
235
+ return factory();
236
+ };
237
+ var hasType = (name) => REGISTRY.has(name);
238
+ registerType("string", () => new StringType());
239
+ registerType("text", () => new StringType());
240
+ registerType("integer", () => new IntegerType());
241
+ registerType("int", () => new IntegerType());
242
+ registerType("bigint", () => new BigIntType());
243
+ registerType("float", () => new FloatType());
244
+ registerType("double", () => new FloatType());
245
+ registerType("decimal", () => new DecimalType());
246
+ registerType("numeric", () => new DecimalType());
247
+ registerType("boolean", () => new BooleanType());
248
+ registerType("bool", () => new BooleanType());
249
+ registerType("date", () => new DateType());
250
+ registerType("datetime", () => new DateTimeType());
251
+ registerType("timestamp", () => new DateTimeType());
252
+ registerType("time", () => new DateTimeType());
253
+ registerType("json", () => new JSONType());
254
+ registerType("jsonb", () => new JSONType());
255
+ registerType("binary", () => new BinaryType());
256
+ registerType("blob", () => new BinaryType());
257
+ registerType("bytea", () => new BinaryType());
258
+ registerType("value", () => new ValueType());
259
+
260
+ // src/AttributeSet.ts
261
+ var AttributeSet = class _AttributeSet {
262
+ definitions = /* @__PURE__ */ new Map();
263
+ /** Insertion-ordered list of attribute names, for stable iteration. */
264
+ names = [];
265
+ /** Register an attribute (or replace an existing one). */
266
+ define(definition) {
267
+ if (!this.definitions.has(definition.name)) this.names.push(definition.name);
268
+ this.definitions.set(definition.name, definition);
269
+ }
270
+ has(name) {
271
+ return this.definitions.has(name);
272
+ }
273
+ get(name) {
274
+ return this.definitions.get(name);
275
+ }
276
+ keys() {
277
+ return this.names.slice();
278
+ }
279
+ /** Stable iteration of definitions in declaration order. */
280
+ *[Symbol.iterator]() {
281
+ for (const name of this.names) yield this.definitions.get(name);
282
+ }
283
+ size() {
284
+ return this.definitions.size;
285
+ }
286
+ clone() {
287
+ const copy = new _AttributeSet();
288
+ for (const def of this) copy.define(def);
289
+ return copy;
290
+ }
291
+ };
292
+ var Attributes = class {
293
+ constructor(set) {
294
+ this.set = set;
295
+ }
296
+ set;
297
+ current = /* @__PURE__ */ new Map();
298
+ original = /* @__PURE__ */ new Map();
299
+ previousChanges = /* @__PURE__ */ new Map();
300
+ accessedNames = /* @__PURE__ */ new Set();
301
+ /** Hydrate attributes after loading from the DB. Skips dirty tracking. */
302
+ hydrate(raw) {
303
+ for (const def of this.set) {
304
+ const incoming = raw[def.name];
305
+ const value = def.type.deserialize(incoming);
306
+ this.current.set(def.name, value);
307
+ this.original.set(def.name, value);
308
+ }
309
+ }
310
+ /** Hydrate attributes for a brand-new record (applies defaults). */
311
+ hydrateDefaults(overrides = {}) {
312
+ for (const def of this.set) {
313
+ const provided = def.name in overrides;
314
+ const raw = provided ? overrides[def.name] : def.default;
315
+ const value = def.type.cast(raw);
316
+ this.current.set(def.name, value);
317
+ this.original.set(def.name, value);
318
+ }
319
+ }
320
+ /** Read an attribute by name, returning the canonical (cast) value. */
321
+ read(name) {
322
+ this.accessedNames.add(name);
323
+ return this.current.get(name);
324
+ }
325
+ /** Names that have been read since hydration. Mirrors Rails' `accessed_attributes`. */
326
+ accessed() {
327
+ return [...this.accessedNames].filter((n) => this.set.has(n));
328
+ }
329
+ /** Write an attribute by name. Type-casts before storing. */
330
+ write(name, value) {
331
+ const def = this.set.get(name);
332
+ if (!def) {
333
+ this.current.set(name, value);
334
+ return;
335
+ }
336
+ this.current.set(name, def.type.cast(value));
337
+ }
338
+ /**
339
+ * Explicitly mark `name` as having been mutated in place — without
340
+ * actually writing a new value. Mirrors Rails' `name_will_change!`
341
+ * which captures the current value into the original snapshot for
342
+ * later mutation detection.
343
+ */
344
+ willChange(name) {
345
+ if (!this.current.has(name) && !this.original.has(name)) return;
346
+ const value = this.current.get(name);
347
+ const snapshot = typeof value === "object" && value !== null ? Array.isArray(value) ? [...value] : { ...value } : value;
348
+ this.original.set(name, snapshot);
349
+ }
350
+ /** Was this attribute changed since the last commit? */
351
+ changed(name) {
352
+ if (!this.current.has(name)) return false;
353
+ const def = this.set.get(name);
354
+ const original = this.original.get(name);
355
+ const value = this.current.get(name);
356
+ if (!def) return original !== value;
357
+ return !valuesEqual(def.type, original, value);
358
+ }
359
+ /** List of attribute names that differ from their originals. */
360
+ changedAttributes() {
361
+ const result = [];
362
+ for (const name of this.set.keys()) {
363
+ if (this.changed(name)) result.push(name);
364
+ }
365
+ return result;
366
+ }
367
+ /** `{ name: [from, to] }` for every changed attribute. */
368
+ changes() {
369
+ const out = {};
370
+ for (const name of this.changedAttributes()) {
371
+ out[name] = [this.original.get(name), this.current.get(name)];
372
+ }
373
+ return out;
374
+ }
375
+ /** Original value of `name` (current value if unchanged). */
376
+ was(name) {
377
+ return this.original.get(name);
378
+ }
379
+ /** Snapshot for SAVE — return the canonical values for each attribute. */
380
+ toHash() {
381
+ const out = {};
382
+ for (const name of this.set.keys()) out[name] = this.current.get(name);
383
+ return out;
384
+ }
385
+ /** Snapshot of only the dirty attributes (for UPDATE statements). */
386
+ dirtyHash() {
387
+ const out = {};
388
+ for (const name of this.changedAttributes()) out[name] = this.current.get(name);
389
+ return out;
390
+ }
391
+ /** Serialized snapshot of all attributes (driver-ready values). */
392
+ serializedHash() {
393
+ const out = {};
394
+ for (const def of this.set) {
395
+ out[def.name] = def.type.serialize(this.current.get(def.name));
396
+ }
397
+ return out;
398
+ }
399
+ /** Mark the current values as the new baseline. Captures previous changes. */
400
+ commit() {
401
+ const previous = /* @__PURE__ */ new Map();
402
+ for (const name of this.changedAttributes()) {
403
+ previous.set(name, [this.original.get(name), this.current.get(name)]);
404
+ this.original.set(name, this.current.get(name));
405
+ }
406
+ this.previousChanges = previous;
407
+ }
408
+ /** Drop all pending and recorded changes — used by `clearChangesInformation`. */
409
+ clearChanges() {
410
+ for (const [name, value] of this.current) this.original.set(name, value);
411
+ this.previousChanges = /* @__PURE__ */ new Map();
412
+ }
413
+ /** Revert all pending changes. */
414
+ restore() {
415
+ for (const name of this.changedAttributes()) {
416
+ this.current.set(name, this.original.get(name));
417
+ }
418
+ }
419
+ /** Changes that were applied during the last `commit`. */
420
+ savedChanges() {
421
+ const out = {};
422
+ for (const [name, pair] of this.previousChanges) out[name] = pair;
423
+ return out;
424
+ }
425
+ attributeSet() {
426
+ return this.set;
427
+ }
428
+ };
429
+
430
+ // src/Callbacks.ts
431
+ var HaltError = class extends Error {
432
+ constructor() {
433
+ super("Callback chain halted");
434
+ }
435
+ };
436
+ var ABORT_SENTINEL = /* @__PURE__ */ Symbol.for("@active-record-ts/active-model:abort");
437
+ var throwAbort = () => {
438
+ throw ABORT_SENTINEL;
439
+ };
440
+ var CallbackChain = class {
441
+ chains = {
442
+ validation: [],
443
+ save: [],
444
+ create: [],
445
+ update: [],
446
+ destroy: [],
447
+ commit: [],
448
+ rollback: [],
449
+ initialize: [],
450
+ find: [],
451
+ touch: []
452
+ };
453
+ /** Register a new callback under `event` of `kind`. */
454
+ add(event, kind, fn, options) {
455
+ this.chains[event].push({ kind, fn, if: options?.if, unless: options?.unless, on: options?.on });
456
+ }
457
+ /** Copy chains from a parent so subclasses inherit before extending. */
458
+ inheritFrom(parent) {
459
+ for (const event of Object.keys(this.chains)) {
460
+ this.chains[event] = [...parent.chains[event]];
461
+ }
462
+ }
463
+ /**
464
+ * Run the chain around `body`. Returns `false` when a `before` callback
465
+ * halts; otherwise returns the return value of `body`. Pass a `context`
466
+ * to filter callbacks registered with `on:` — primarily used for
467
+ * validation contexts (`create`/`update`) but available everywhere.
468
+ */
469
+ async run(event, record, body, context) {
470
+ const entries = this.chains[event];
471
+ const befores = entries.filter((e) => e.kind === "before");
472
+ const afters = entries.filter((e) => e.kind === "after");
473
+ const arounds = entries.filter((e) => e.kind === "around");
474
+ for (const entry of befores) {
475
+ if (!guard(entry, record, context)) continue;
476
+ try {
477
+ const result = await entry.fn(record);
478
+ if (result === false) return false;
479
+ } catch (err) {
480
+ if (err instanceof HaltError) return false;
481
+ if (err === ABORT_SENTINEL) return false;
482
+ throw err;
483
+ }
484
+ }
485
+ let bodyHalted = false;
486
+ if (arounds.length === 0) {
487
+ const r = await body();
488
+ if (r === false) bodyHalted = true;
489
+ } else {
490
+ const bodyRunner = async () => {
491
+ const r = await body();
492
+ if (r === false) bodyHalted = true;
493
+ };
494
+ let runner = bodyRunner;
495
+ for (let i = arounds.length - 1; i >= 0; i--) {
496
+ const around = arounds[i];
497
+ if (!around || !guard(around, record, context)) continue;
498
+ const inner = runner;
499
+ runner = () => around.fn(record, inner);
500
+ }
501
+ await runner();
502
+ }
503
+ if (bodyHalted) return false;
504
+ for (const entry of afters) {
505
+ if (!guard(entry, record, context)) continue;
506
+ try {
507
+ await entry.fn(record);
508
+ } catch (err) {
509
+ if (err instanceof HaltError) break;
510
+ if (err === ABORT_SENTINEL) break;
511
+ throw err;
512
+ }
513
+ }
514
+ return true;
515
+ }
516
+ };
517
+ var guard = (entry, record, context) => {
518
+ if (entry.on !== void 0) {
519
+ const wanted = Array.isArray(entry.on) ? entry.on : [entry.on];
520
+ if (context === void 0) return false;
521
+ if (!wanted.includes(context)) return false;
522
+ }
523
+ if (entry.if && !entry.if(record)) return false;
524
+ if (entry.unless && entry.unless(record)) return false;
525
+ return true;
526
+ };
527
+
528
+ // src/Errors.ts
529
+ var ErrorObject = class {
530
+ constructor(entry) {
531
+ this.entry = entry;
532
+ }
533
+ entry;
534
+ get attribute() {
535
+ return this.entry.attribute;
536
+ }
537
+ get message() {
538
+ return this.entry.message;
539
+ }
540
+ get type() {
541
+ return this.entry.type;
542
+ }
543
+ get options() {
544
+ return this.entry.options;
545
+ }
546
+ fullMessage() {
547
+ return this.entry.attribute === BASE ? this.entry.message : `${humanize(this.entry.attribute)} ${this.entry.message}`;
548
+ }
549
+ /** Internal accessor for `Errors#import` — returns the wrapped entry. */
550
+ toEntry() {
551
+ return this.entry;
552
+ }
553
+ };
554
+ var BASE = "base";
555
+ var DEFAULT_MESSAGES = {
556
+ invalid: "is invalid",
557
+ blank: "can't be blank",
558
+ present: "must be blank",
559
+ too_short: "is too short (minimum is %{count} characters)",
560
+ too_long: "is too long (maximum is %{count} characters)",
561
+ wrong_length: "is the wrong length (should be %{count} characters)",
562
+ taken: "has already been taken",
563
+ not_a_number: "is not a number",
564
+ greater_than: "must be greater than %{count}",
565
+ greater_than_or_equal_to: "must be greater than or equal to %{count}",
566
+ less_than: "must be less than %{count}",
567
+ less_than_or_equal_to: "must be less than or equal to %{count}",
568
+ equal_to: "must be equal to %{count}",
569
+ odd: "must be odd",
570
+ even: "must be even",
571
+ inclusion: "is not included in the list",
572
+ exclusion: "is reserved",
573
+ accepted: "must be accepted",
574
+ confirmation: "doesn't match %{attribute}",
575
+ empty: "can't be empty"
576
+ };
577
+ var isKnownType = (value) => Object.hasOwn(DEFAULT_MESSAGES, value);
578
+ var interpolate = (template, options = {}) => template.replace(/%\{(\w+)\}/g, (_, key) => key in options ? String(options[key]) : `%{${key}}`);
579
+ var Errors = class _Errors {
580
+ entries = [];
581
+ /**
582
+ * Record an error. The second argument can be:
583
+ * - a free-form message string (e.g. `errors.add('name', "can't be empty")`)
584
+ * - a known symbol-style type that resolves to a default message
585
+ * (e.g. `errors.add('name', 'blank')` -> "can't be blank")
586
+ *
587
+ * Pass extra interpolation values or a custom `type` via the third arg.
588
+ */
589
+ add(attribute, messageOrType = "invalid", options = {}) {
590
+ const {
591
+ type: explicitType,
592
+ options: explicitOptions,
593
+ message: explicitMessage,
594
+ ...payload
595
+ } = options;
596
+ const mergedOptions = { ...payload, ...explicitOptions ?? {} };
597
+ let type;
598
+ let message;
599
+ if (explicitType) {
600
+ type = explicitType;
601
+ message = explicitMessage ?? (isKnownType(messageOrType) ? interpolate(DEFAULT_MESSAGES[messageOrType], mergedOptions) : messageOrType);
602
+ } else if (isKnownType(messageOrType)) {
603
+ type = messageOrType;
604
+ message = explicitMessage ?? interpolate(DEFAULT_MESSAGES[messageOrType], mergedOptions);
605
+ } else {
606
+ message = explicitMessage ?? messageOrType;
607
+ }
608
+ const entry = {
609
+ attribute,
610
+ message,
611
+ ...type !== void 0 ? { type } : {},
612
+ ...Object.keys(mergedOptions).length > 0 ? { options: mergedOptions } : {}
613
+ };
614
+ this.entries.push(entry);
615
+ return entry;
616
+ }
617
+ /** True when there are no errors at all. */
618
+ get empty() {
619
+ return this.entries.length === 0;
620
+ }
621
+ /** True when at least one error has been recorded. */
622
+ get any() {
623
+ return this.entries.length > 0;
624
+ }
625
+ clear() {
626
+ this.entries.length = 0;
627
+ }
628
+ delete(attribute, type, matchOptions) {
629
+ const deleted = [];
630
+ for (let i = this.entries.length - 1; i >= 0; i--) {
631
+ const e = this.entries[i];
632
+ if (e.attribute !== attribute) continue;
633
+ if (type !== void 0 && e.type !== type) continue;
634
+ if (matchOptions && !matchesOptions(e.options, matchOptions)) continue;
635
+ deleted.unshift(e.message);
636
+ this.entries.splice(i, 1);
637
+ }
638
+ return deleted;
639
+ }
640
+ on(attribute) {
641
+ return this.entries.filter((e) => e.attribute === attribute).map((e) => e.message);
642
+ }
643
+ includes(attribute) {
644
+ return this.entries.some((e) => e.attribute === attribute);
645
+ }
646
+ /** All `[attribute, message]` pairs, in insertion order. */
647
+ get messages() {
648
+ const out = {};
649
+ for (const entry of this.entries) {
650
+ const bucket = out[entry.attribute] ?? [];
651
+ bucket.push(entry.message);
652
+ out[entry.attribute] = bucket;
653
+ }
654
+ return out;
655
+ }
656
+ /** Distinct attributes that currently carry at least one error. */
657
+ get attributeNames() {
658
+ const seen = /* @__PURE__ */ new Set();
659
+ const ordered = [];
660
+ for (const entry of this.entries) {
661
+ if (!seen.has(entry.attribute)) {
662
+ seen.add(entry.attribute);
663
+ ordered.push(entry.attribute);
664
+ }
665
+ }
666
+ return ordered;
667
+ }
668
+ /** Predicate: was an error matching the given attribute (and optional type / options) added? */
669
+ added(attribute, type, matchOptions) {
670
+ return this.entries.some((e) => {
671
+ if (e.attribute !== attribute) return false;
672
+ if (type !== void 0 && e.type !== type) return false;
673
+ if (matchOptions && !matchesOptions(e.options, matchOptions)) return false;
674
+ return true;
675
+ });
676
+ }
677
+ /** Filter entries by attribute / type / options. */
678
+ where(attribute, type, matchOptions) {
679
+ return this.entries.filter((e) => {
680
+ if (e.attribute !== attribute) return false;
681
+ if (type !== void 0 && e.type !== type) return false;
682
+ if (matchOptions && !matchesOptions(e.options, matchOptions)) return false;
683
+ return true;
684
+ });
685
+ }
686
+ /** Messages for a specific attribute, optionally filtered by type. */
687
+ messagesFor(attribute, type) {
688
+ return this.where(attribute, type).map((e) => e.message);
689
+ }
690
+ /** Human-readable strings like `"Name can't be blank"`. */
691
+ get fullMessages() {
692
+ return this.entries.map((e) => e.attribute === BASE ? e.message : `${humanize(e.attribute)} ${e.message}`);
693
+ }
694
+ fullMessagesFor(attribute, type) {
695
+ return this.where(attribute, type).map(
696
+ (e) => attribute === BASE ? e.message : `${humanize(attribute)} ${e.message}`
697
+ );
698
+ }
699
+ /** Standalone full-message formatter (no entries side effect). */
700
+ fullMessage(attribute, message) {
701
+ return attribute === BASE ? message : `${humanize(attribute)} ${message}`;
702
+ }
703
+ /** Append every entry from `other` to this collection. */
704
+ merge(other) {
705
+ if (other === this) return this;
706
+ for (const entry of other.entries)
707
+ this.entries.push({ ...entry, options: entry.options ? { ...entry.options } : void 0 });
708
+ return this;
709
+ }
710
+ /** Replace this collection's entries with copies of another's. */
711
+ copy(other) {
712
+ this.clear();
713
+ return this.merge(other);
714
+ }
715
+ /**
716
+ * Return a new `Errors` collection with the same entries. Subsequent
717
+ * modifications on either side don't affect the other.
718
+ */
719
+ dup() {
720
+ const copy = new _Errors();
721
+ copy.merge(this);
722
+ return copy;
723
+ }
724
+ /** Rich `ErrorObject` instances for each entry, in insertion order. */
725
+ get objects() {
726
+ return this.entries.map((e) => new ErrorObject(e));
727
+ }
728
+ /** First error (rich object) or `null`. */
729
+ get first() {
730
+ return this.entries[0] ? new ErrorObject(this.entries[0]) : null;
731
+ }
732
+ /**
733
+ * Structured details payload. Each attribute maps to a list of
734
+ * `{ error: type, ...options }` records. Mirrors Rails' `errors.details`.
735
+ */
736
+ get details() {
737
+ const out = {};
738
+ for (const entry of this.entries) {
739
+ const bucket = out[entry.attribute] ?? [];
740
+ bucket.push({ error: entry.type, ...entry.options ?? {} });
741
+ out[entry.attribute] = bucket;
742
+ }
743
+ return out;
744
+ }
745
+ /** Group rich error objects by attribute. */
746
+ groupByAttribute() {
747
+ const out = {};
748
+ for (const entry of this.entries) {
749
+ const bucket = out[entry.attribute] ?? [];
750
+ bucket.push(new ErrorObject(entry));
751
+ out[entry.attribute] = bucket;
752
+ }
753
+ return out;
754
+ }
755
+ /** Indifferent indexer — `errors.get('name')` is equivalent to `errors.on('name')`. */
756
+ get(attribute) {
757
+ return this.on(attribute);
758
+ }
759
+ /**
760
+ * Remove duplicate entries — same attribute + message + type. Mirrors
761
+ * Rails' `errors.uniq!`.
762
+ */
763
+ uniq() {
764
+ const seen = /* @__PURE__ */ new Set();
765
+ for (let i = this.entries.length - 1; i >= 0; i--) {
766
+ const e = this.entries[i];
767
+ const key = `${e.attribute}\0${e.message}\0${e.type ?? ""}`;
768
+ if (seen.has(key)) this.entries.splice(i, 1);
769
+ else seen.add(key);
770
+ }
771
+ return this;
772
+ }
773
+ /** Import a foreign `ErrorEntry` or `ErrorObject`, optionally overriding attribute/type. */
774
+ import(error, overrides = {}) {
775
+ const base = error instanceof ErrorObject ? error.toEntry() : error;
776
+ const next = {
777
+ attribute: overrides.attribute ?? base.attribute,
778
+ message: base.message,
779
+ type: overrides.type ?? base.type,
780
+ options: base.options ? { ...base.options } : void 0
781
+ };
782
+ this.entries.push(next);
783
+ return next;
784
+ }
785
+ /**
786
+ * Indifferent indexer — returns `errors.on(attribute)` via a Proxy
787
+ * pseudo-property, supporting both `errors.byAttribute('name')` and
788
+ * the bracket-access shape `(errors as any)['name']`. Mirrors Rails'
789
+ * `errors[:name]` / `errors['name']`. (We can't do real bracket access
790
+ * without wrapping every Errors instance in a Proxy; this method-style
791
+ * accessor is the idiomatic TS equivalent.)
792
+ */
793
+ byAttribute(attribute) {
794
+ return this.on(attribute);
795
+ }
796
+ /**
797
+ * Rails-style `as_json`. With `{ fullMessages: true }`, each entry is
798
+ * the humanized "Attribute msg" form. Default returns the same shape
799
+ * as `messages`.
800
+ */
801
+ asJson(options = {}) {
802
+ if (!options.fullMessages) return this.messages;
803
+ const out = {};
804
+ for (const entry of this.entries) {
805
+ const full = entry.attribute === BASE ? entry.message : `${humanize(entry.attribute)} ${entry.message}`;
806
+ const bucket = out[entry.attribute] ?? [];
807
+ bucket.push(full);
808
+ out[entry.attribute] = bucket;
809
+ }
810
+ return out;
811
+ }
812
+ /**
813
+ * `to_hash(true)` returns full-message variant; otherwise plain messages.
814
+ * Convenience shim over `asJson({ fullMessages })`.
815
+ */
816
+ toHash(fullMessages = false) {
817
+ return this.asJson({ fullMessages });
818
+ }
819
+ /**
820
+ * `of_kind?` — true when an entry exists whose attribute and type-or-
821
+ * message both match. Mirrors Rails' `errors.of_kind?(:name, :blank)`.
822
+ */
823
+ ofKind(attribute, typeOrMessage) {
824
+ if (typeOrMessage === void 0) return this.includes(attribute);
825
+ return this.entries.some((e) => {
826
+ if (e.attribute !== attribute) return false;
827
+ return e.type === typeOrMessage || e.message === typeOrMessage;
828
+ });
829
+ }
830
+ /** Debug-friendly string representation. */
831
+ inspect() {
832
+ const parts = this.entries.map((e) => `#<Error attribute=${e.attribute}, message=${JSON.stringify(e.message)}>`);
833
+ return `#<Errors:[${parts.join(", ")}]>`;
834
+ }
835
+ get count() {
836
+ return this.entries.length;
837
+ }
838
+ get size() {
839
+ return this.entries.length;
840
+ }
841
+ [Symbol.iterator]() {
842
+ return this.entries[Symbol.iterator]();
843
+ }
844
+ toJSON() {
845
+ return this.messages;
846
+ }
847
+ };
848
+ var matchesOptions = (actual, expected) => {
849
+ if (!actual) return Object.keys(expected).length === 0;
850
+ for (const [k, v] of Object.entries(expected)) {
851
+ if (actual[k] !== v) return false;
852
+ }
853
+ return true;
854
+ };
855
+ var humanize = (attribute) => {
856
+ const flattened = attribute.replace(/\./g, "_");
857
+ const spaced = flattened.replace(/[_-]+/g, " ").replace(/([a-z])([A-Z])/g, "$1 $2").toLowerCase();
858
+ return spaced.charAt(0).toUpperCase() + spaced.slice(1);
859
+ };
860
+
861
+ // src/Validator.ts
862
+ var blank = (value) => {
863
+ if (value === null || value === void 0) return true;
864
+ if (typeof value === "string") return value.trim() === "";
865
+ if (Array.isArray(value)) return value.length === 0;
866
+ if (value instanceof Set || value instanceof Map) return value.size === 0;
867
+ return false;
868
+ };
869
+ var StrictValidationFailed = class extends Error {
870
+ constructor(message) {
871
+ super(message);
872
+ }
873
+ };
874
+ var shouldValidate = (record, options = {}, context) => {
875
+ if (options.on !== void 0) {
876
+ const wanted = Array.isArray(options.on) ? options.on : [options.on];
877
+ if (context === void 0) return false;
878
+ if (!wanted.includes(context)) return false;
879
+ }
880
+ if (options.exceptOn !== void 0 && context !== void 0) {
881
+ const excluded = Array.isArray(options.exceptOn) ? options.exceptOn : [options.exceptOn];
882
+ if (excluded.includes(context)) return false;
883
+ }
884
+ if (options.if && !options.if(record)) return false;
885
+ if (options.unless && options.unless(record)) return false;
886
+ return true;
887
+ };
888
+ var skipForNullable = (value, options = {}) => {
889
+ if (options.allowNull && (value === null || value === void 0)) return true;
890
+ if (options.allowBlank && blank(value)) return true;
891
+ return false;
892
+ };
893
+ var reader = (attribute) => (r) => r[attribute];
894
+ var recordError = (options, errors, attribute, defaultMessage, meta = {}, record, value) => {
895
+ let message;
896
+ if (typeof options.message === "function") {
897
+ if (record === void 0) {
898
+ message = defaultMessage;
899
+ } else {
900
+ message = options.message(record, { attribute, value, type: meta.type });
901
+ }
902
+ } else if (typeof options.message === "string") {
903
+ message = options.message;
904
+ } else {
905
+ message = defaultMessage;
906
+ }
907
+ if (options.strict) {
908
+ const fullMessage = errors.fullMessage(attribute, message);
909
+ if (typeof options.strict === "function") {
910
+ throw new options.strict(fullMessage);
911
+ }
912
+ throw new StrictValidationFailed(fullMessage);
913
+ }
914
+ errors.add(attribute, message, meta);
915
+ };
916
+ var BlockValidator = class {
917
+ constructor(fn, options = {}) {
918
+ this.fn = fn;
919
+ this.options = options;
920
+ }
921
+ fn;
922
+ options;
923
+ kind = "block";
924
+ attributes = [];
925
+ async validate(record, errors, context) {
926
+ if (!shouldValidate(record, this.options, context)) return;
927
+ await this.fn(record, errors, context);
928
+ }
929
+ };
930
+ var PresenceValidator = class {
931
+ constructor(attribute, options = {}) {
932
+ this.attribute = attribute;
933
+ this.options = options;
934
+ this.attributes = [attribute];
935
+ }
936
+ attribute;
937
+ options;
938
+ kind = "presence";
939
+ attributes;
940
+ validate(record, errors, context) {
941
+ if (!shouldValidate(record, this.options, context)) return;
942
+ const value = reader(this.attribute)(record);
943
+ if (blank(value)) {
944
+ recordError(this.options, errors, this.attribute, "can't be blank", { type: "presence" }, record, value);
945
+ }
946
+ }
947
+ };
948
+ var AbsenceValidator = class {
949
+ constructor(attribute, options = {}) {
950
+ this.attribute = attribute;
951
+ this.options = options;
952
+ this.attributes = [attribute];
953
+ }
954
+ attribute;
955
+ options;
956
+ kind = "absence";
957
+ attributes;
958
+ validate(record, errors, context) {
959
+ if (!shouldValidate(record, this.options, context)) return;
960
+ const value = reader(this.attribute)(record);
961
+ if (!blank(value)) {
962
+ recordError(this.options, errors, this.attribute, "must be blank", { type: "absence" }, record, value);
963
+ }
964
+ }
965
+ };
966
+ var LengthValidator = class {
967
+ constructor(attribute, options = {}) {
968
+ this.attribute = attribute;
969
+ this.options = options;
970
+ this.attributes = [attribute];
971
+ }
972
+ attribute;
973
+ options;
974
+ kind = "length";
975
+ attributes;
976
+ validate(record, errors, context) {
977
+ if (!shouldValidate(record, this.options, context)) return;
978
+ const value = reader(this.attribute)(record);
979
+ if (skipForNullable(value, this.options)) return;
980
+ const len = lengthOf(value);
981
+ const o = this.options;
982
+ if (o.in) {
983
+ const [min, max] = o.in;
984
+ if (len < min) {
985
+ recordError(
986
+ this.options,
987
+ errors,
988
+ this.attribute,
989
+ o.tooShort ?? `is too short (minimum is ${min} characters)`,
990
+ { type: "length" },
991
+ record,
992
+ value
993
+ );
994
+ return;
995
+ }
996
+ if (len > max) {
997
+ recordError(
998
+ this.options,
999
+ errors,
1000
+ this.attribute,
1001
+ o.tooLong ?? `is too long (maximum is ${max} characters)`,
1002
+ { type: "length" },
1003
+ record,
1004
+ value
1005
+ );
1006
+ return;
1007
+ }
1008
+ }
1009
+ if (o.is !== void 0 && len !== o.is) {
1010
+ recordError(
1011
+ this.options,
1012
+ errors,
1013
+ this.attribute,
1014
+ o.wrongLength ?? `is the wrong length (should be ${o.is} characters)`,
1015
+ { type: "length" },
1016
+ record,
1017
+ value
1018
+ );
1019
+ return;
1020
+ }
1021
+ if (o.minimum !== void 0 && len < o.minimum) {
1022
+ recordError(
1023
+ this.options,
1024
+ errors,
1025
+ this.attribute,
1026
+ o.tooShort ?? `is too short (minimum is ${o.minimum} characters)`,
1027
+ { type: "length" },
1028
+ record,
1029
+ value
1030
+ );
1031
+ return;
1032
+ }
1033
+ if (o.maximum !== void 0 && len > o.maximum) {
1034
+ recordError(
1035
+ this.options,
1036
+ errors,
1037
+ this.attribute,
1038
+ o.tooLong ?? `is too long (maximum is ${o.maximum} characters)`,
1039
+ { type: "length" },
1040
+ record,
1041
+ value
1042
+ );
1043
+ return;
1044
+ }
1045
+ }
1046
+ };
1047
+ var lengthOf = (value) => {
1048
+ if (value == null) return 0;
1049
+ if (typeof value === "string" || Array.isArray(value)) return value.length;
1050
+ if (value instanceof Set || value instanceof Map) return value.size;
1051
+ return String(value).length;
1052
+ };
1053
+ var FormatValidator = class {
1054
+ constructor(attribute, options) {
1055
+ this.attribute = attribute;
1056
+ this.options = options;
1057
+ this.attributes = [attribute];
1058
+ }
1059
+ attribute;
1060
+ options;
1061
+ kind = "format";
1062
+ attributes;
1063
+ validate(record, errors, context) {
1064
+ if (!shouldValidate(record, this.options, context)) return;
1065
+ const value = reader(this.attribute)(record);
1066
+ if (skipForNullable(value, this.options)) return;
1067
+ const str = value == null ? "" : String(value);
1068
+ if (this.options.with && !this.options.with.test(str)) {
1069
+ recordError(this.options, errors, this.attribute, "is invalid", { type: "format" }, record, value);
1070
+ }
1071
+ if (this.options.without && this.options.without.test(str)) {
1072
+ recordError(this.options, errors, this.attribute, "is invalid", { type: "format" }, record, value);
1073
+ }
1074
+ }
1075
+ };
1076
+ var InclusionValidator = class {
1077
+ constructor(attribute, options) {
1078
+ this.attribute = attribute;
1079
+ this.options = options;
1080
+ this.attributes = [attribute];
1081
+ }
1082
+ attribute;
1083
+ options;
1084
+ kind = "inclusion";
1085
+ attributes;
1086
+ validate(record, errors, context) {
1087
+ if (!shouldValidate(record, this.options, context)) return;
1088
+ const value = reader(this.attribute)(record);
1089
+ if (skipForNullable(value, this.options)) return;
1090
+ if (!this.options.in.includes(value)) {
1091
+ recordError(
1092
+ this.options,
1093
+ errors,
1094
+ this.attribute,
1095
+ "is not included in the list",
1096
+ { type: "inclusion" },
1097
+ record,
1098
+ value
1099
+ );
1100
+ }
1101
+ }
1102
+ };
1103
+ var ExclusionValidator = class {
1104
+ constructor(attribute, options) {
1105
+ this.attribute = attribute;
1106
+ this.options = options;
1107
+ this.attributes = [attribute];
1108
+ }
1109
+ attribute;
1110
+ options;
1111
+ kind = "exclusion";
1112
+ attributes;
1113
+ validate(record, errors, context) {
1114
+ if (!shouldValidate(record, this.options, context)) return;
1115
+ const value = reader(this.attribute)(record);
1116
+ if (skipForNullable(value, this.options)) return;
1117
+ if (this.options.in.includes(value)) {
1118
+ recordError(this.options, errors, this.attribute, "is reserved", { type: "exclusion" }, record, value);
1119
+ }
1120
+ }
1121
+ };
1122
+ var NumericalityValidator = class {
1123
+ constructor(attribute, options = {}) {
1124
+ this.attribute = attribute;
1125
+ this.options = options;
1126
+ this.attributes = [attribute];
1127
+ }
1128
+ attribute;
1129
+ options;
1130
+ kind = "numericality";
1131
+ attributes;
1132
+ validate(record, errors, context) {
1133
+ if (!shouldValidate(record, this.options, context)) return;
1134
+ const value = reader(this.attribute)(record);
1135
+ if (skipForNullable(value, this.options)) return;
1136
+ const num = Number(value);
1137
+ if (Number.isNaN(num) || !Number.isFinite(num)) {
1138
+ recordError(this.options, errors, this.attribute, "is not a number", { type: "numericality" }, record, value);
1139
+ return;
1140
+ }
1141
+ const o = this.options;
1142
+ if (o.onlyInteger && !Number.isInteger(num)) {
1143
+ recordError(this.options, errors, this.attribute, "must be an integer", { type: "numericality.only_integer" });
1144
+ }
1145
+ if (o.greaterThan !== void 0 && !(num > o.greaterThan)) {
1146
+ recordError(
1147
+ this.options,
1148
+ errors,
1149
+ this.attribute,
1150
+ `must be greater than ${o.greaterThan}`,
1151
+ { type: "numericality.greater_than" },
1152
+ record,
1153
+ value
1154
+ );
1155
+ }
1156
+ if (o.greaterThanOrEqualTo !== void 0 && !(num >= o.greaterThanOrEqualTo)) {
1157
+ recordError(
1158
+ this.options,
1159
+ errors,
1160
+ this.attribute,
1161
+ `must be greater than or equal to ${o.greaterThanOrEqualTo}`,
1162
+ { type: "numericality.greater_than_or_equal_to" },
1163
+ record,
1164
+ value
1165
+ );
1166
+ }
1167
+ if (o.lessThan !== void 0 && !(num < o.lessThan)) {
1168
+ recordError(
1169
+ this.options,
1170
+ errors,
1171
+ this.attribute,
1172
+ `must be less than ${o.lessThan}`,
1173
+ { type: "numericality.less_than" },
1174
+ record,
1175
+ value
1176
+ );
1177
+ }
1178
+ if (o.lessThanOrEqualTo !== void 0 && !(num <= o.lessThanOrEqualTo)) {
1179
+ recordError(
1180
+ this.options,
1181
+ errors,
1182
+ this.attribute,
1183
+ `must be less than or equal to ${o.lessThanOrEqualTo}`,
1184
+ { type: "numericality.less_than_or_equal_to" },
1185
+ record,
1186
+ value
1187
+ );
1188
+ }
1189
+ if (o.equalTo !== void 0 && num !== o.equalTo) {
1190
+ recordError(
1191
+ this.options,
1192
+ errors,
1193
+ this.attribute,
1194
+ `must be equal to ${o.equalTo}`,
1195
+ { type: "numericality.equal_to" },
1196
+ record,
1197
+ value
1198
+ );
1199
+ }
1200
+ if (o.odd && num % 2 === 0)
1201
+ recordError(this.options, errors, this.attribute, "must be odd", { type: "numericality.odd" });
1202
+ if (o.even && num % 2 !== 0)
1203
+ recordError(this.options, errors, this.attribute, "must be even", { type: "numericality.even" });
1204
+ }
1205
+ };
1206
+ var AcceptanceValidator = class {
1207
+ constructor(attribute, options = {}) {
1208
+ this.attribute = attribute;
1209
+ this.options = options;
1210
+ this.attributes = [attribute];
1211
+ }
1212
+ attribute;
1213
+ options;
1214
+ kind = "acceptance";
1215
+ attributes;
1216
+ validate(record, errors, context) {
1217
+ if (!shouldValidate(record, this.options, context)) return;
1218
+ const accept = this.options.accept ?? [true, "1", 1];
1219
+ const value = reader(this.attribute)(record);
1220
+ if (!accept.includes(value)) {
1221
+ recordError(this.options, errors, this.attribute, "must be accepted", { type: "acceptance" }, record, value);
1222
+ }
1223
+ }
1224
+ };
1225
+ var ConfirmationValidator = class {
1226
+ constructor(attribute, options = {}) {
1227
+ this.attribute = attribute;
1228
+ this.options = options;
1229
+ this.attributes = [attribute];
1230
+ }
1231
+ attribute;
1232
+ options;
1233
+ kind = "confirmation";
1234
+ attributes;
1235
+ validate(record, errors, context) {
1236
+ if (!shouldValidate(record, this.options, context)) return;
1237
+ const value = reader(this.attribute)(record);
1238
+ const confirmation = reader(`${this.attribute}Confirmation`)(record);
1239
+ if (confirmation === void 0) return;
1240
+ const a = value == null ? "" : String(value);
1241
+ const b = confirmation == null ? "" : String(confirmation);
1242
+ const equal = this.options.caseSensitive === false ? a.toLowerCase() === b.toLowerCase() : a === b;
1243
+ if (!equal) {
1244
+ recordError(
1245
+ this.options,
1246
+ errors,
1247
+ `${this.attribute}Confirmation`,
1248
+ "doesn't match",
1249
+ { type: "confirmation" },
1250
+ record,
1251
+ value
1252
+ );
1253
+ }
1254
+ }
1255
+ };
1256
+
1257
+ // src/Model.ts
1258
+ var ValidationError = class extends Error {
1259
+ constructor(record) {
1260
+ super(`Validation failed: ${record.errors.fullMessages.join(", ")}`);
1261
+ this.record = record;
1262
+ }
1263
+ record;
1264
+ };
1265
+ var resolveType = (ref) => typeof ref === "string" ? lookupType(ref) : ref;
1266
+ var REGISTRY2 = /* @__PURE__ */ Symbol.for("@active-record-ts/active-model:registry");
1267
+ var getRegistry = (ctor) => {
1268
+ const own = ctor[REGISTRY2];
1269
+ if (own && Object.hasOwn(ctor, REGISTRY2)) return own;
1270
+ const parent = Object.getPrototypeOf(ctor);
1271
+ const parentReg = parent && parent !== Function.prototype ? getRegistry(parent) : null;
1272
+ const fresh = {
1273
+ attributeSet: parentReg ? parentReg.attributeSet.clone() : new AttributeSet(),
1274
+ validators: parentReg ? [...parentReg.validators] : [],
1275
+ callbacks: new CallbackChain()
1276
+ };
1277
+ if (parentReg) fresh.callbacks.inheritFrom(parentReg.callbacks);
1278
+ Object.defineProperty(ctor, REGISTRY2, { value: fresh, enumerable: false, configurable: true, writable: false });
1279
+ return fresh;
1280
+ };
1281
+ var camelizeSuffix = (name) => name.replace(/(?:^|[_-])([a-z0-9])/gi, (_, c) => c.toUpperCase()).replace(/[^A-Za-z0-9]/g, "");
1282
+ var definePerAttributeDirty = (target, names) => {
1283
+ for (const name of names) {
1284
+ const suffix = camelizeSuffix(name);
1285
+ const camelName = lowerFirst(suffix);
1286
+ const helpers = {
1287
+ [`${camelName}Changed`]() {
1288
+ return this.attributeChanged(name);
1289
+ },
1290
+ [`${camelName}Was`]() {
1291
+ return this.attributeWas(name);
1292
+ },
1293
+ [`${camelName}Change`]() {
1294
+ if (!this.attributeChanged(name)) return null;
1295
+ return [this.attributeWas(name), this.readAttribute(name)];
1296
+ },
1297
+ [`restore${suffix}`]() {
1298
+ this.writeAttribute(name, this.attributeWas(name));
1299
+ },
1300
+ [`${camelName}WillChange`]() {
1301
+ this.willChange(name);
1302
+ },
1303
+ [`${camelName}PreviouslyChanged`]() {
1304
+ return Object.hasOwn(this.savedChanges(), name);
1305
+ },
1306
+ [`${camelName}PreviousChange`]() {
1307
+ const saved = this.savedChanges();
1308
+ return Object.hasOwn(saved, name) ? saved[name] : null;
1309
+ }
1310
+ };
1311
+ for (const [methodName, fn] of Object.entries(helpers)) {
1312
+ if (Object.hasOwn(target.prototype, methodName)) continue;
1313
+ Object.defineProperty(target.prototype, methodName, {
1314
+ configurable: true,
1315
+ enumerable: false,
1316
+ writable: true,
1317
+ value: fn
1318
+ });
1319
+ }
1320
+ }
1321
+ };
1322
+ var lowerFirst = (s) => s.length === 0 ? s : s.charAt(0).toLowerCase() + s.slice(1);
1323
+ var defineAccessors = (target, names) => {
1324
+ for (const name of names) {
1325
+ if (Object.hasOwn(target.prototype, name)) continue;
1326
+ let proto = Object.getPrototypeOf(target.prototype);
1327
+ let inheritedAccessor = false;
1328
+ while (proto) {
1329
+ const desc = Object.getOwnPropertyDescriptor(proto, name);
1330
+ if (desc && (desc.get || desc.set)) {
1331
+ inheritedAccessor = true;
1332
+ break;
1333
+ }
1334
+ proto = Object.getPrototypeOf(proto);
1335
+ }
1336
+ if (inheritedAccessor) continue;
1337
+ Object.defineProperty(target.prototype, name, {
1338
+ configurable: true,
1339
+ enumerable: true,
1340
+ get() {
1341
+ return this.readAttribute(name);
1342
+ },
1343
+ set(value) {
1344
+ this.writeAttribute(name, value);
1345
+ }
1346
+ });
1347
+ }
1348
+ };
1349
+ var Model = class {
1350
+ /** Per-instance attribute state, lazily initialized. */
1351
+ _attributes;
1352
+ /** Per-instance error collection. */
1353
+ errors = new Errors();
1354
+ // biome-ignore lint/complexity/noBannedTypes: constructor body sets up attributes uniformly
1355
+ constructor(values = {}) {
1356
+ const ctor = this.constructor;
1357
+ const reg = getRegistry(ctor);
1358
+ this._attributes = new Attributes(reg.attributeSet);
1359
+ this._attributes.hydrateDefaults(values);
1360
+ const names = reg.attributeSet.keys();
1361
+ defineAccessors(ctor, names);
1362
+ definePerAttributeDirty(ctor, names);
1363
+ void reg.callbacks.run("initialize", this, async () => {
1364
+ });
1365
+ }
1366
+ // ──────────────────────────── attribute IO ────────────────────────────
1367
+ readAttribute(name) {
1368
+ return this._attributes.read(name);
1369
+ }
1370
+ writeAttribute(name, value) {
1371
+ this._attributes.write(name, value);
1372
+ }
1373
+ /** Plain object snapshot. */
1374
+ attributes() {
1375
+ return this._attributes.toHash();
1376
+ }
1377
+ assignAttributes(values) {
1378
+ for (const [name, value] of Object.entries(values)) this.writeAttribute(name, value);
1379
+ return this;
1380
+ }
1381
+ // ──────────────────────────── dirty tracking ────────────────────────────
1382
+ changed() {
1383
+ return this._attributes.changedAttributes();
1384
+ }
1385
+ changes() {
1386
+ return this._attributes.changes();
1387
+ }
1388
+ attributeChanged(name) {
1389
+ return this._attributes.changed(name);
1390
+ }
1391
+ attributeWas(name) {
1392
+ return this._attributes.was(name);
1393
+ }
1394
+ savedChanges() {
1395
+ return this._attributes.savedChanges();
1396
+ }
1397
+ /**
1398
+ * Tell the dirty tracker that `name` is about to be mutated in place,
1399
+ * so subsequent reads detect it as a change. Mirrors Rails'
1400
+ * `name_will_change!`.
1401
+ */
1402
+ willChange(name) {
1403
+ this._attributes.willChange(name);
1404
+ }
1405
+ /**
1406
+ * Revert pending changes. With no argument restores every changed
1407
+ * attribute; with `names` restores only the listed ones. Mirrors
1408
+ * Rails' `restore_attributes(['name'])`.
1409
+ */
1410
+ restoreAttributes(names) {
1411
+ if (!names) {
1412
+ this._attributes.restore();
1413
+ return;
1414
+ }
1415
+ for (const name of names) {
1416
+ this._attributes.write(name, this._attributes.was(name));
1417
+ }
1418
+ }
1419
+ /** Reset both pending and last-saved changes — Rails' `clear_changes_information`. */
1420
+ clearChangesInformation() {
1421
+ this._attributes.clearChanges();
1422
+ }
1423
+ // ──────────────────────────── validation ────────────────────────────
1424
+ async validate(context) {
1425
+ this.errors.clear();
1426
+ const ctor = this.constructor;
1427
+ const reg = getRegistry(ctor);
1428
+ await reg.callbacks.run(
1429
+ "validation",
1430
+ this,
1431
+ async () => {
1432
+ for (const v of reg.validators) await v.validate(this, this.errors, context);
1433
+ },
1434
+ context
1435
+ );
1436
+ return this.errors.empty;
1437
+ }
1438
+ async isValid(context) {
1439
+ return this.validate(context);
1440
+ }
1441
+ async isInvalid(context) {
1442
+ return !await this.validate(context);
1443
+ }
1444
+ /** Mirrors Rails' `validate!`. Throws when invalid, returns true otherwise. */
1445
+ async validateOrThrow(context) {
1446
+ const ok = await this.validate(context);
1447
+ if (!ok) throw new ValidationError(this);
1448
+ return true;
1449
+ }
1450
+ // ──────────────────────────── helpers ────────────────────────────
1451
+ toJSON(options = {}) {
1452
+ const all = this.attributes();
1453
+ if (options.only) {
1454
+ const out = {};
1455
+ for (const name of options.only) if (name in all) out[name] = all[name];
1456
+ return out;
1457
+ }
1458
+ if (options.except) {
1459
+ const out = {};
1460
+ const exclude = new Set(options.except);
1461
+ for (const [name, value] of Object.entries(all)) if (!exclude.has(name)) out[name] = value;
1462
+ return out;
1463
+ }
1464
+ return all;
1465
+ }
1466
+ /**
1467
+ * Return a new instance of the same class with the same attribute
1468
+ * values. Mirrors Rails' `record.dup`. The AR layer overrides this
1469
+ * to also clear the primary key so the duplicate looks like a fresh
1470
+ * (unsaved) record.
1471
+ */
1472
+ dup() {
1473
+ const ctor = this.constructor;
1474
+ const copy = new ctor();
1475
+ copy._attributes.hydrate(this.attributes());
1476
+ return copy;
1477
+ }
1478
+ // ──────────────────────────── class-side configuration ────────────────────────────
1479
+ /** Register an attribute on this subclass. Returns the constructor for chaining. */
1480
+ static attribute(name, type, options) {
1481
+ const reg = getRegistry(this);
1482
+ reg.attributeSet.define({ name, type: resolveType(type), default: options?.default });
1483
+ defineAccessors(this, [name]);
1484
+ definePerAttributeDirty(this, [name]);
1485
+ return this;
1486
+ }
1487
+ /** Access the per-class `AttributeSet`. */
1488
+ static attributesSchema() {
1489
+ return getRegistry(this).attributeSet;
1490
+ }
1491
+ /** Register a validator instance. */
1492
+ static validatesWith(validator) {
1493
+ getRegistry(this).validators.push(validator);
1494
+ return this;
1495
+ }
1496
+ /**
1497
+ * Register a free-form block validator — `Klass.validate((record, errors) => ...)`.
1498
+ * The function may be async and receives the same `(record, errors, context)`
1499
+ * arguments every built-in validator does.
1500
+ */
1501
+ static validate(fn, options = {}) {
1502
+ return this.validatesWith(new BlockValidator(fn, options));
1503
+ }
1504
+ /** All registered validators in declaration order (inherited + own). */
1505
+ static validators() {
1506
+ return getRegistry(this).validators;
1507
+ }
1508
+ /** Validators that target any of the given attribute names. */
1509
+ static validatorsOn(...attributes) {
1510
+ const wanted = new Set(attributes);
1511
+ return this.validators().filter((v) => {
1512
+ const attrs = v.attributes;
1513
+ if (!attrs) return false;
1514
+ for (const a of attrs) if (wanted.has(a)) return true;
1515
+ return false;
1516
+ });
1517
+ }
1518
+ /** Reset all per-class validators. Rails' `clear_validators!`. */
1519
+ static clearValidators() {
1520
+ getRegistry(this).validators.length = 0;
1521
+ return this;
1522
+ }
1523
+ /**
1524
+ * Run a function once per attribute, accumulating errors. Mirrors Rails'
1525
+ * `validates_each :a, :b do |record, attr, value| ... end`.
1526
+ *
1527
+ * Klass.validatesEach(['a', 'b'], (record, attr, value, errors) => {
1528
+ * if (!ok(value)) errors.add(attr, 'bad');
1529
+ * });
1530
+ */
1531
+ static validatesEach(attributes, fn, options = {}) {
1532
+ return this.validate(async (record, errors) => {
1533
+ for (const attr of attributes) {
1534
+ const value = record[attr];
1535
+ await fn(record, attr, value, errors);
1536
+ }
1537
+ }, options);
1538
+ }
1539
+ /** Add a presence validator. */
1540
+ static validatesPresenceOf(attribute, options = {}) {
1541
+ return this.validatesWith(new PresenceValidator(attribute, options));
1542
+ }
1543
+ static validatesAbsenceOf(attribute, options = {}) {
1544
+ return this.validatesWith(new AbsenceValidator(attribute, options));
1545
+ }
1546
+ static validatesLengthOf(attribute, options) {
1547
+ return this.validatesWith(new LengthValidator(attribute, options));
1548
+ }
1549
+ static validatesFormatOf(attribute, options) {
1550
+ return this.validatesWith(new FormatValidator(attribute, options));
1551
+ }
1552
+ static validatesInclusionOf(attribute, options) {
1553
+ return this.validatesWith(new InclusionValidator(attribute, options));
1554
+ }
1555
+ static validatesExclusionOf(attribute, options) {
1556
+ return this.validatesWith(new ExclusionValidator(attribute, options));
1557
+ }
1558
+ static validatesNumericalityOf(attribute, options = {}) {
1559
+ return this.validatesWith(new NumericalityValidator(attribute, options));
1560
+ }
1561
+ static validatesAcceptanceOf(attribute, options = {}) {
1562
+ return this.validatesWith(new AcceptanceValidator(attribute, options));
1563
+ }
1564
+ static validatesConfirmationOf(attribute, options = {}) {
1565
+ return this.validatesWith(new ConfirmationValidator(attribute, options));
1566
+ }
1567
+ /**
1568
+ * Sugar mirroring Rails' `validates :name, presence: true, length: { minimum: 2 }`.
1569
+ */
1570
+ static validates(attribute, rules) {
1571
+ if (rules.presence) this.validatesPresenceOf(attribute, rules.presence === true ? {} : rules.presence);
1572
+ if (rules.absence) this.validatesAbsenceOf(attribute, rules.absence === true ? {} : rules.absence);
1573
+ if (rules.length) this.validatesLengthOf(attribute, rules.length);
1574
+ if (rules.format) this.validatesFormatOf(attribute, rules.format);
1575
+ if (rules.inclusion) this.validatesInclusionOf(attribute, rules.inclusion);
1576
+ if (rules.exclusion) this.validatesExclusionOf(attribute, rules.exclusion);
1577
+ if (rules.numericality)
1578
+ this.validatesNumericalityOf(attribute, rules.numericality === true ? {} : rules.numericality);
1579
+ if (rules.acceptance) this.validatesAcceptanceOf(attribute, rules.acceptance === true ? {} : rules.acceptance);
1580
+ if (rules.confirmation)
1581
+ this.validatesConfirmationOf(attribute, rules.confirmation === true ? {} : rules.confirmation);
1582
+ return this;
1583
+ }
1584
+ // ──────────────────────────── callbacks ────────────────────────────
1585
+ /** Options that can be passed to any callback registration helper. */
1586
+ static setCallback(event, kind, fn, options) {
1587
+ getRegistry(this).callbacks.add(event, kind, fn, options);
1588
+ return this;
1589
+ }
1590
+ static beforeValidation(fn, options) {
1591
+ return this.setCallback("validation", "before", fn, options);
1592
+ }
1593
+ static afterValidation(fn, options) {
1594
+ return this.setCallback("validation", "after", fn, options);
1595
+ }
1596
+ static beforeSave(fn) {
1597
+ return this.setCallback("save", "before", fn);
1598
+ }
1599
+ static afterSave(fn) {
1600
+ return this.setCallback("save", "after", fn);
1601
+ }
1602
+ static aroundSave(fn) {
1603
+ return this.setCallback("save", "around", fn);
1604
+ }
1605
+ static beforeCreate(...fns) {
1606
+ for (const fn of fns) this.setCallback("create", "before", fn);
1607
+ return this;
1608
+ }
1609
+ static afterCreate(...fns) {
1610
+ for (const fn of fns) this.setCallback("create", "after", fn);
1611
+ return this;
1612
+ }
1613
+ static beforeUpdate(fn) {
1614
+ return this.setCallback("update", "before", fn);
1615
+ }
1616
+ static afterUpdate(fn) {
1617
+ return this.setCallback("update", "after", fn);
1618
+ }
1619
+ static beforeDestroy(fn) {
1620
+ return this.setCallback("destroy", "before", fn);
1621
+ }
1622
+ static afterDestroy(fn) {
1623
+ return this.setCallback("destroy", "after", fn);
1624
+ }
1625
+ /**
1626
+ * Run `fn` after the outermost transaction surrounding this record's
1627
+ * save / destroy commits. When the record is touched outside a
1628
+ * transaction, the callback fires immediately after the save.
1629
+ */
1630
+ static afterCommit(fn, options) {
1631
+ return this.setCallback("commit", "after", fn, options);
1632
+ }
1633
+ /** Run `fn` when the surrounding transaction rolls back. */
1634
+ static afterRollback(fn, options) {
1635
+ return this.setCallback("rollback", "after", fn, options);
1636
+ }
1637
+ /** Run `fn` whenever a new instance is constructed. */
1638
+ static afterInitialize(fn) {
1639
+ return this.setCallback("initialize", "after", fn);
1640
+ }
1641
+ /** Run `fn` when a record is hydrated from the database. */
1642
+ static afterFind(fn) {
1643
+ return this.setCallback("find", "after", fn);
1644
+ }
1645
+ /** Run `fn` after `touch()`. */
1646
+ static afterTouch(fn) {
1647
+ return this.setCallback("touch", "after", fn);
1648
+ }
1649
+ /** Run a registered callback chain — typically used by ActiveRecord persistence. */
1650
+ static async runCallbacks(event, record, body, context) {
1651
+ return getRegistry(this).callbacks.run(event, record, body, context);
1652
+ }
1653
+ };
1654
+
1655
+ // src/inflector.ts
1656
+ var IRREGULAR = [
1657
+ ["person", "people"],
1658
+ ["man", "men"],
1659
+ ["woman", "women"],
1660
+ ["child", "children"],
1661
+ ["ox", "oxen"],
1662
+ ["mouse", "mice"],
1663
+ ["octopus", "octopi"],
1664
+ ["cactus", "cacti"],
1665
+ ["goose", "geese"],
1666
+ ["foot", "feet"],
1667
+ ["tooth", "teeth"]
1668
+ ];
1669
+ var UNCOUNTABLE = /* @__PURE__ */ new Set(["equipment", "information", "rice", "money", "species", "series", "fish", "sheep"]);
1670
+ var PLURAL_RULES = [
1671
+ [/(quiz)$/i, "$1zes"],
1672
+ [/^(ox)$/i, "$1en"],
1673
+ [/([m|l])ouse$/i, "$1ice"],
1674
+ [/(matr|vert|ind)ix|ex$/i, "$1ices"],
1675
+ [/(x|ch|ss|sh)$/i, "$1es"],
1676
+ [/([^aeiouy]|qu)y$/i, "$1ies"],
1677
+ [/(hive)$/i, "$1s"],
1678
+ [/(?:([^f])fe|([lr])f)$/i, "$1$2ves"],
1679
+ [/sis$/i, "ses"],
1680
+ [/([ti])um$/i, "$1a"],
1681
+ [/(buffal|tomat)o$/i, "$1oes"],
1682
+ [/(bu)s$/i, "$1ses"],
1683
+ [/(alias|status)$/i, "$1es"],
1684
+ [/(octop|vir)us$/i, "$1i"],
1685
+ [/(ax|test)is$/i, "$1es"],
1686
+ [/s$/i, "s"],
1687
+ [/$/, "s"]
1688
+ ];
1689
+ var pluralize = (word) => {
1690
+ if (!word) return word;
1691
+ const lower = word.toLowerCase();
1692
+ if (UNCOUNTABLE.has(lower)) return word;
1693
+ for (const [singular, plural] of IRREGULAR) {
1694
+ if (lower === singular) return plural;
1695
+ if (lower === plural) return plural;
1696
+ }
1697
+ for (const [pattern, replacement] of PLURAL_RULES) {
1698
+ if (pattern.test(word)) return word.replace(pattern, replacement);
1699
+ }
1700
+ return `${word}s`;
1701
+ };
1702
+ var underscore = (word) => word.replace(/([A-Z]+)([A-Z][a-z])/g, "$1_$2").replace(/([a-z\d])([A-Z])/g, "$1_$2").replace(/-/g, "_").toLowerCase();
1703
+ var camelize = (word, lower = false) => {
1704
+ const camel = word.split(/[_-]/).map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join("");
1705
+ return lower ? camel.charAt(0).toLowerCase() + camel.slice(1) : camel;
1706
+ };
1707
+ var tableize = (className) => pluralize(underscore(className));
1708
+
1709
+ // src/Name.ts
1710
+ var UNCOUNTABLE2 = /* @__PURE__ */ new Set(["equipment", "information", "rice", "money", "species", "series", "fish", "sheep"]);
1711
+ var humanize2 = (word) => {
1712
+ const spaced = word.replace(/_/g, " ").toLowerCase();
1713
+ return spaced.charAt(0).toUpperCase() + spaced.slice(1);
1714
+ };
1715
+ var Name = class {
1716
+ /** The class itself (or its name, when called with a class-name string). */
1717
+ klass;
1718
+ /** The fully-qualified class name as written. */
1719
+ name;
1720
+ /** Snake-case version of the (possibly namespaced) class name. e.g. `Post::TrackBack` -> `post_track_back`. */
1721
+ singular;
1722
+ /** Plural form of `singular`. e.g. `post_track_back` -> `post_track_backs`. */
1723
+ plural;
1724
+ /** Last segment, snake-cased. e.g. `Post::TrackBack` -> `track_back`. */
1725
+ element;
1726
+ /** Forward-slash-joined collection name. e.g. `Post::TrackBack` -> `post/track_backs`. */
1727
+ collection;
1728
+ /** Rails routing helper — plural element with optional namespace prefix. */
1729
+ route_key;
1730
+ /** `singular` minus internal namespace separators. */
1731
+ param_key;
1732
+ /** Slash-joined namespace key. e.g. `Post::TrackBack` -> `post/track_back`. */
1733
+ i18n_key;
1734
+ /** Human-friendly element name. e.g. `track_back` -> `Track back`. */
1735
+ human;
1736
+ /**
1737
+ * Build a `Name` from a class (uses `klass.name`) or directly from a
1738
+ * Ruby-style namespaced string ("Post::TrackBack").
1739
+ */
1740
+ constructor(klassOrName) {
1741
+ this.klass = typeof klassOrName === "string" ? { name: klassOrName } : klassOrName;
1742
+ this.name = this.klass.name;
1743
+ const parts = this.name.split("::");
1744
+ const last = parts[parts.length - 1];
1745
+ const nsParts = parts.slice(0, -1).map((p) => underscore(p));
1746
+ const lastSnake = underscore(last);
1747
+ this.singular = [...nsParts, lastSnake].join("_");
1748
+ this.plural = pluralize(this.singular);
1749
+ this.element = lastSnake;
1750
+ const elementPlural = pluralize(lastSnake);
1751
+ this.collection = nsParts.length === 0 ? elementPlural : `${nsParts.join("/")}/${elementPlural}`;
1752
+ this.route_key = nsParts.length === 0 ? elementPlural : `${nsParts.join("_")}_${elementPlural}`;
1753
+ this.param_key = this.singular;
1754
+ this.i18n_key = nsParts.length === 0 ? lastSnake : `${nsParts.join("/")}/${lastSnake}`;
1755
+ this.human = humanize2(lastSnake);
1756
+ }
1757
+ /** Whether the singular form is uncountable (plural === singular). */
1758
+ get uncountable() {
1759
+ return UNCOUNTABLE2.has(this.singular.toLowerCase());
1760
+ }
1761
+ toString() {
1762
+ return this.name;
1763
+ }
1764
+ };
1765
+ export {
1766
+ ABORT_SENTINEL,
1767
+ AbsenceValidator,
1768
+ AcceptanceValidator,
1769
+ AttributeSet,
1770
+ Attributes,
1771
+ BASE,
1772
+ BigIntType,
1773
+ BinaryType,
1774
+ BooleanType,
1775
+ CallbackChain,
1776
+ ConfirmationValidator,
1777
+ DateTimeType,
1778
+ DateType,
1779
+ DecimalType,
1780
+ ErrorObject,
1781
+ Errors,
1782
+ ExclusionValidator,
1783
+ FloatType,
1784
+ FormatValidator,
1785
+ HaltError,
1786
+ InclusionValidator,
1787
+ IntegerType,
1788
+ JSONType,
1789
+ LengthValidator,
1790
+ Model,
1791
+ Name,
1792
+ NumericalityValidator,
1793
+ PresenceValidator,
1794
+ StrictValidationFailed,
1795
+ StringType,
1796
+ ValidationError,
1797
+ ValueType,
1798
+ camelize,
1799
+ hasType,
1800
+ lookupType,
1801
+ pluralize,
1802
+ registerType,
1803
+ tableize,
1804
+ throwAbort,
1805
+ underscore,
1806
+ valuesEqual
1807
+ };
1808
+ //# sourceMappingURL=index.js.map