@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/src/Callbacks.ts DELETED
@@ -1,158 +0,0 @@
1
- /**
2
- * Per-class callback chains for save/create/update/destroy/validation.
3
- *
4
- * Each chain holds an ordered list of callbacks. A `before` callback may
5
- * abort the chain by returning `false`. `around` callbacks receive a
6
- * `yield` function that runs the rest of the chain.
7
- *
8
- * The shape mirrors `ActiveSupport::Callbacks` enough to express Rails-y
9
- * lifecycle hooks, without trying to be a general callback framework.
10
- */
11
-
12
- export type CallbackKind = 'before' | 'after' | 'around';
13
- export type CallbackEvent =
14
- | 'validation'
15
- | 'save'
16
- | 'create'
17
- | 'update'
18
- | 'destroy'
19
- | 'commit'
20
- | 'rollback'
21
- | 'initialize'
22
- | 'find'
23
- | 'touch';
24
-
25
- export type CallbackFn<T> = (record: T) => void | boolean | Promise<void | boolean>;
26
- export type AroundCallbackFn<T> = (record: T, run: () => Promise<void>) => Promise<void>;
27
-
28
- type CallbackEntry<T> = {
29
- kind: CallbackKind;
30
- fn: CallbackFn<T> | AroundCallbackFn<T>;
31
- /** Conditional guard — if it returns false, the callback is skipped. */
32
- if?: (record: T) => boolean;
33
- unless?: (record: T) => boolean;
34
- /** Limit the callback to one or more contexts (e.g. validation context). */
35
- on?: string | string[];
36
- };
37
-
38
- /** Sentinel — throw inside a callback to cleanly halt the chain. */
39
- export class HaltError extends Error {
40
- constructor() {
41
- super('Callback chain halted');
42
- }
43
- }
44
-
45
- /**
46
- * Symbol-shaped sentinel callers can throw to halt the chain — mirrors
47
- * Rails' `throw :abort` semantics. The chain swallows it and treats it
48
- * as a `false` return from a `before_*` callback.
49
- */
50
- export const ABORT_SENTINEL = Symbol.for('@active-record-ts/active-model:abort');
51
-
52
- /** Convenience helper for chain implementations: convert "throw :abort" to a clean halt. */
53
- export const throwAbort = (): never => {
54
- throw ABORT_SENTINEL;
55
- };
56
-
57
- export class CallbackChain<T> {
58
- private readonly chains: Record<CallbackEvent, CallbackEntry<T>[]> = {
59
- validation: [],
60
- save: [],
61
- create: [],
62
- update: [],
63
- destroy: [],
64
- commit: [],
65
- rollback: [],
66
- initialize: [],
67
- find: [],
68
- touch: [],
69
- };
70
-
71
- /** Register a new callback under `event` of `kind`. */
72
- add(event: CallbackEvent, kind: CallbackKind, fn: CallbackFn<T> | AroundCallbackFn<T>,
73
- options?: { if?: (record: T) => boolean; unless?: (record: T) => boolean; on?: string | string[] }): void {
74
- this.chains[event].push({ kind, fn, if: options?.if, unless: options?.unless, on: options?.on });
75
- }
76
-
77
- /** Copy chains from a parent so subclasses inherit before extending. */
78
- inheritFrom(parent: CallbackChain<T>): void {
79
- for (const event of Object.keys(this.chains) as CallbackEvent[]) {
80
- this.chains[event] = [...parent.chains[event]];
81
- }
82
- }
83
-
84
- /**
85
- * Run the chain around `body`. Returns `false` when a `before` callback
86
- * halts; otherwise returns the return value of `body`. Pass a `context`
87
- * to filter callbacks registered with `on:` — primarily used for
88
- * validation contexts (`create`/`update`) but available everywhere.
89
- */
90
- async run(event: CallbackEvent, record: T, body: () => Promise<void | boolean>, context?: string): Promise<boolean> {
91
- const entries = this.chains[event];
92
- const befores = entries.filter((e) => e.kind === 'before');
93
- const afters = entries.filter((e) => e.kind === 'after');
94
- const arounds = entries.filter((e) => e.kind === 'around');
95
-
96
- for (const entry of befores) {
97
- if (!guard(entry, record, context)) continue;
98
- try {
99
- const result = await (entry.fn as CallbackFn<T>)(record);
100
- if (result === false) return false;
101
- } catch (err) {
102
- if (err instanceof HaltError) return false;
103
- if (err === ABORT_SENTINEL) return false;
104
- throw err;
105
- }
106
- }
107
-
108
- // Build the inner runner as a chain of around callbacks wrapping `body`.
109
- // Capture body's return value so after-callbacks can be skipped when it
110
- // returns false (mirrors Rails' "after callbacks skipped when block
111
- // returns false" behavior). When there are no around callbacks, we
112
- // skip the wrapper to keep the microtask cost identical to a bare
113
- // `await body()` — some callers (after_initialize) rely on that.
114
- let bodyHalted = false;
115
- if (arounds.length === 0) {
116
- const r = await body();
117
- if (r === false) bodyHalted = true;
118
- } else {
119
- const bodyRunner = async (): Promise<void> => {
120
- const r = await body();
121
- if (r === false) bodyHalted = true;
122
- };
123
- let runner: () => Promise<void> = bodyRunner;
124
- for (let i = arounds.length - 1; i >= 0; i--) {
125
- const around = arounds[i];
126
- if (!around || !guard(around, record, context)) continue;
127
- const inner = runner;
128
- runner = () => (around.fn as AroundCallbackFn<T>)(record, inner);
129
- }
130
- await runner();
131
- }
132
-
133
- if (bodyHalted) return false;
134
-
135
- for (const entry of afters) {
136
- if (!guard(entry, record, context)) continue;
137
- try {
138
- await (entry.fn as CallbackFn<T>)(record);
139
- } catch (err) {
140
- if (err instanceof HaltError) break;
141
- if (err === ABORT_SENTINEL) break;
142
- throw err;
143
- }
144
- }
145
- return true;
146
- }
147
- }
148
-
149
- const guard = <T>(entry: CallbackEntry<T>, record: T, context?: string): boolean => {
150
- if (entry.on !== undefined) {
151
- const wanted = Array.isArray(entry.on) ? entry.on : [entry.on];
152
- if (context === undefined) return false;
153
- if (!wanted.includes(context)) return false;
154
- }
155
- if (entry.if && !entry.if(record)) return false;
156
- if (entry.unless && entry.unless(record)) return false;
157
- return true;
158
- };
package/src/Errors.ts DELETED
@@ -1,398 +0,0 @@
1
- /**
2
- * Error collection on a model instance. Mirrors `ActiveModel::Errors`
3
- * minus i18n — message strings are stored verbatim. The full set of
4
- * Rails behaviors we now support:
5
- *
6
- * - `add(attribute, typeOrMessage, options?)` where the second argument
7
- * can be a known symbol-style type (e.g. `'blank'`, `'too_short'`)
8
- * that maps to a default message string, or a free-form message.
9
- * - `on(attribute)` returns the list of messages for an attribute.
10
- * - `attributeNames` returns the unique attributes carrying an error.
11
- * - `added(attribute, type?, options?)` predicate.
12
- * - `where(attribute, type?, options?)` returns the matching entries.
13
- * - `messagesFor` / `fullMessagesFor` with optional type filter.
14
- * - `merge(other)` / `copy(other)`.
15
- */
16
-
17
- export type ErrorEntry = {
18
- attribute: string;
19
- message: string;
20
- /** Optional discriminator for the validator that produced this error. */
21
- type?: string;
22
- /** Optional structured payload, e.g. `{ count: 2 }` for length validators. */
23
- options?: Record<string, unknown>;
24
- };
25
-
26
- /**
27
- * Rich error object exposed by `errors.objects` / `errors.first` / iteration.
28
- * Mirrors a subset of Rails' `ActiveModel::Error` — enough for callers
29
- * who want to introspect (attribute / type / options / full message) rather
30
- * than work with bare strings.
31
- */
32
- export class ErrorObject {
33
- constructor(private readonly entry: ErrorEntry) {}
34
- get attribute(): string { return this.entry.attribute; }
35
- get message(): string { return this.entry.message; }
36
- get type(): string | undefined { return this.entry.type; }
37
- get options(): Record<string, unknown> | undefined { return this.entry.options; }
38
- fullMessage(): string {
39
- return this.entry.attribute === BASE ? this.entry.message : `${humanize(this.entry.attribute)} ${this.entry.message}`;
40
- }
41
- /** Internal accessor for `Errors#import` — returns the wrapped entry. */
42
- toEntry(): ErrorEntry {
43
- return this.entry;
44
- }
45
- }
46
-
47
- /** Special attribute name for errors that aren't tied to a specific column. */
48
- export const BASE = 'base';
49
-
50
- /**
51
- * Default messages for known error types. Mirrors a small subset of
52
- * Rails' `errors.messages.*` locale entries — enough to keep tests
53
- * green without pulling in a full i18n backend. Format strings expand
54
- * `%{key}` against the options hash at lookup time.
55
- */
56
- const DEFAULT_MESSAGES: Record<string, string> = {
57
- invalid: 'is invalid',
58
- blank: "can't be blank",
59
- present: 'must be blank',
60
- too_short: 'is too short (minimum is %{count} characters)',
61
- too_long: 'is too long (maximum is %{count} characters)',
62
- wrong_length: 'is the wrong length (should be %{count} characters)',
63
- taken: 'has already been taken',
64
- not_a_number: 'is not a number',
65
- greater_than: 'must be greater than %{count}',
66
- greater_than_or_equal_to: 'must be greater than or equal to %{count}',
67
- less_than: 'must be less than %{count}',
68
- less_than_or_equal_to: 'must be less than or equal to %{count}',
69
- equal_to: 'must be equal to %{count}',
70
- odd: 'must be odd',
71
- even: 'must be even',
72
- inclusion: 'is not included in the list',
73
- exclusion: 'is reserved',
74
- accepted: 'must be accepted',
75
- confirmation: "doesn't match %{attribute}",
76
- empty: "can't be empty",
77
- };
78
-
79
- const isKnownType = (value: string): boolean => Object.prototype.hasOwnProperty.call(DEFAULT_MESSAGES, value);
80
-
81
- const interpolate = (template: string, options: Record<string, unknown> = {}): string =>
82
- template.replace(/%\{(\w+)\}/g, (_, key) => (key in options ? String(options[key]) : `%{${key}}`));
83
-
84
- export type AddOptions = {
85
- type?: string;
86
- options?: Record<string, unknown>;
87
- };
88
-
89
- export class Errors {
90
- private readonly entries: ErrorEntry[] = [];
91
-
92
- /**
93
- * Record an error. The second argument can be:
94
- * - a free-form message string (e.g. `errors.add('name', "can't be empty")`)
95
- * - a known symbol-style type that resolves to a default message
96
- * (e.g. `errors.add('name', 'blank')` -> "can't be blank")
97
- *
98
- * Pass extra interpolation values or a custom `type` via the third arg.
99
- */
100
- add(attribute: string, messageOrType: string = 'invalid', options: AddOptions & Record<string, unknown> = {}): ErrorEntry {
101
- const { type: explicitType, options: explicitOptions, message: explicitMessage, ...payload } = options as {
102
- type?: string;
103
- options?: Record<string, unknown>;
104
- message?: string;
105
- };
106
- const mergedOptions = { ...payload, ...(explicitOptions ?? {}) };
107
- let type: string | undefined;
108
- let message: string;
109
- if (explicitType) {
110
- type = explicitType;
111
- message = explicitMessage ?? (isKnownType(messageOrType) ? interpolate(DEFAULT_MESSAGES[messageOrType]!, mergedOptions) : messageOrType);
112
- } else if (isKnownType(messageOrType)) {
113
- type = messageOrType;
114
- message = explicitMessage ?? interpolate(DEFAULT_MESSAGES[messageOrType]!, mergedOptions);
115
- } else {
116
- message = explicitMessage ?? messageOrType;
117
- }
118
- const entry: ErrorEntry = {
119
- attribute,
120
- message,
121
- ...(type !== undefined ? { type } : {}),
122
- ...(Object.keys(mergedOptions).length > 0 ? { options: mergedOptions } : {}),
123
- };
124
- this.entries.push(entry);
125
- return entry;
126
- }
127
-
128
- /** True when there are no errors at all. */
129
- get empty(): boolean {
130
- return this.entries.length === 0;
131
- }
132
- /** True when at least one error has been recorded. */
133
- get any(): boolean {
134
- return this.entries.length > 0;
135
- }
136
-
137
- clear(): void {
138
- this.entries.length = 0;
139
- }
140
- delete(attribute: string, type?: string, matchOptions?: Record<string, unknown>): string[] {
141
- const deleted: string[] = [];
142
- for (let i = this.entries.length - 1; i >= 0; i--) {
143
- const e = this.entries[i]!;
144
- if (e.attribute !== attribute) continue;
145
- if (type !== undefined && e.type !== type) continue;
146
- if (matchOptions && !matchesOptions(e.options, matchOptions)) continue;
147
- deleted.unshift(e.message);
148
- this.entries.splice(i, 1);
149
- }
150
- return deleted;
151
- }
152
-
153
- on(attribute: string): string[] {
154
- return this.entries.filter((e) => e.attribute === attribute).map((e) => e.message);
155
- }
156
- includes(attribute: string): boolean {
157
- return this.entries.some((e) => e.attribute === attribute);
158
- }
159
-
160
- /** All `[attribute, message]` pairs, in insertion order. */
161
- get messages(): Record<string, string[]> {
162
- const out: Record<string, string[]> = {};
163
- for (const entry of this.entries) {
164
- (out[entry.attribute] ??= []).push(entry.message);
165
- }
166
- return out;
167
- }
168
-
169
- /** Distinct attributes that currently carry at least one error. */
170
- get attributeNames(): string[] {
171
- const seen = new Set<string>();
172
- const ordered: string[] = [];
173
- for (const entry of this.entries) {
174
- if (!seen.has(entry.attribute)) {
175
- seen.add(entry.attribute);
176
- ordered.push(entry.attribute);
177
- }
178
- }
179
- return ordered;
180
- }
181
-
182
- /** Predicate: was an error matching the given attribute (and optional type / options) added? */
183
- added(attribute: string, type?: string, matchOptions?: Record<string, unknown>): boolean {
184
- return this.entries.some((e) => {
185
- if (e.attribute !== attribute) return false;
186
- if (type !== undefined && e.type !== type) return false;
187
- if (matchOptions && !matchesOptions(e.options, matchOptions)) return false;
188
- return true;
189
- });
190
- }
191
-
192
- /** Filter entries by attribute / type / options. */
193
- where(attribute: string, type?: string, matchOptions?: Record<string, unknown>): ErrorEntry[] {
194
- return this.entries.filter((e) => {
195
- if (e.attribute !== attribute) return false;
196
- if (type !== undefined && e.type !== type) return false;
197
- if (matchOptions && !matchesOptions(e.options, matchOptions)) return false;
198
- return true;
199
- });
200
- }
201
-
202
- /** Messages for a specific attribute, optionally filtered by type. */
203
- messagesFor(attribute: string, type?: string): string[] {
204
- return this.where(attribute, type).map((e) => e.message);
205
- }
206
-
207
- /** Human-readable strings like `"Name can't be blank"`. */
208
- get fullMessages(): string[] {
209
- return this.entries.map((e) => (e.attribute === BASE ? e.message : `${humanize(e.attribute)} ${e.message}`));
210
- }
211
-
212
- fullMessagesFor(attribute: string, type?: string): string[] {
213
- return this.where(attribute, type).map((e) => (attribute === BASE ? e.message : `${humanize(attribute)} ${e.message}`));
214
- }
215
-
216
- /** Standalone full-message formatter (no entries side effect). */
217
- fullMessage(attribute: string, message: string): string {
218
- return attribute === BASE ? message : `${humanize(attribute)} ${message}`;
219
- }
220
-
221
- /** Append every entry from `other` to this collection. */
222
- merge(other: Errors): this {
223
- if (other === this) return this;
224
- for (const entry of other.entries) this.entries.push({ ...entry, options: entry.options ? { ...entry.options } : undefined });
225
- return this;
226
- }
227
-
228
- /** Replace this collection's entries with copies of another's. */
229
- copy(other: Errors): this {
230
- this.clear();
231
- return this.merge(other);
232
- }
233
-
234
- /**
235
- * Return a new `Errors` collection with the same entries. Subsequent
236
- * modifications on either side don't affect the other.
237
- */
238
- dup(): Errors {
239
- const copy = new Errors();
240
- copy.merge(this);
241
- return copy;
242
- }
243
-
244
- /** Rich `ErrorObject` instances for each entry, in insertion order. */
245
- get objects(): ErrorObject[] {
246
- return this.entries.map((e) => new ErrorObject(e));
247
- }
248
-
249
- /** First error (rich object) or `null`. */
250
- get first(): ErrorObject | null {
251
- return this.entries[0] ? new ErrorObject(this.entries[0]) : null;
252
- }
253
-
254
- /**
255
- * Structured details payload. Each attribute maps to a list of
256
- * `{ error: type, ...options }` records. Mirrors Rails' `errors.details`.
257
- */
258
- get details(): Record<string, Array<{ error: string | undefined } & Record<string, unknown>>> {
259
- const out: Record<string, Array<{ error: string | undefined } & Record<string, unknown>>> = {};
260
- for (const entry of this.entries) {
261
- (out[entry.attribute] ??= []).push({ error: entry.type, ...(entry.options ?? {}) });
262
- }
263
- return out;
264
- }
265
-
266
- /** Group rich error objects by attribute. */
267
- groupByAttribute(): Record<string, ErrorObject[]> {
268
- const out: Record<string, ErrorObject[]> = {};
269
- for (const entry of this.entries) {
270
- (out[entry.attribute] ??= []).push(new ErrorObject(entry));
271
- }
272
- return out;
273
- }
274
-
275
- /** Indifferent indexer — `errors.get('name')` is equivalent to `errors.on('name')`. */
276
- get(attribute: string): string[] {
277
- return this.on(attribute);
278
- }
279
-
280
- /**
281
- * Remove duplicate entries — same attribute + message + type. Mirrors
282
- * Rails' `errors.uniq!`.
283
- */
284
- uniq(): this {
285
- const seen = new Set<string>();
286
- for (let i = this.entries.length - 1; i >= 0; i--) {
287
- const e = this.entries[i]!;
288
- const key = `${e.attribute}${e.message}${e.type ?? ''}`;
289
- if (seen.has(key)) this.entries.splice(i, 1);
290
- else seen.add(key);
291
- }
292
- return this;
293
- }
294
-
295
- /** Import a foreign `ErrorEntry` or `ErrorObject`, optionally overriding attribute/type. */
296
- import(
297
- error: ErrorEntry | ErrorObject,
298
- overrides: { attribute?: string; type?: string } = {},
299
- ): ErrorEntry {
300
- const base: ErrorEntry = error instanceof ErrorObject ? error.toEntry() : error;
301
- const next: ErrorEntry = {
302
- attribute: overrides.attribute ?? base.attribute,
303
- message: base.message,
304
- type: overrides.type ?? base.type,
305
- options: base.options ? { ...base.options } : undefined,
306
- };
307
- this.entries.push(next);
308
- return next;
309
- }
310
-
311
- /**
312
- * Indifferent indexer — returns `errors.on(attribute)` via a Proxy
313
- * pseudo-property, supporting both `errors.byAttribute('name')` and
314
- * the bracket-access shape `(errors as any)['name']`. Mirrors Rails'
315
- * `errors[:name]` / `errors['name']`. (We can't do real bracket access
316
- * without wrapping every Errors instance in a Proxy; this method-style
317
- * accessor is the idiomatic TS equivalent.)
318
- */
319
- byAttribute(attribute: string): string[] {
320
- return this.on(attribute);
321
- }
322
-
323
- /**
324
- * Rails-style `as_json`. With `{ fullMessages: true }`, each entry is
325
- * the humanized "Attribute msg" form. Default returns the same shape
326
- * as `messages`.
327
- */
328
- asJson(options: { fullMessages?: boolean } = {}): Record<string, string[]> {
329
- if (!options.fullMessages) return this.messages;
330
- const out: Record<string, string[]> = {};
331
- for (const entry of this.entries) {
332
- const full = entry.attribute === BASE ? entry.message : `${humanize(entry.attribute)} ${entry.message}`;
333
- (out[entry.attribute] ??= []).push(full);
334
- }
335
- return out;
336
- }
337
-
338
- /**
339
- * `to_hash(true)` returns full-message variant; otherwise plain messages.
340
- * Convenience shim over `asJson({ fullMessages })`.
341
- */
342
- toHash(fullMessages = false): Record<string, string[]> {
343
- return this.asJson({ fullMessages });
344
- }
345
-
346
- /**
347
- * `of_kind?` — true when an entry exists whose attribute and type-or-
348
- * message both match. Mirrors Rails' `errors.of_kind?(:name, :blank)`.
349
- */
350
- ofKind(attribute: string, typeOrMessage?: string): boolean {
351
- if (typeOrMessage === undefined) return this.includes(attribute);
352
- return this.entries.some((e) => {
353
- if (e.attribute !== attribute) return false;
354
- // Match either the type discriminator or the literal message.
355
- return e.type === typeOrMessage || e.message === typeOrMessage;
356
- });
357
- }
358
-
359
- /** Debug-friendly string representation. */
360
- inspect(): string {
361
- const parts = this.entries.map((e) => `#<Error attribute=${e.attribute}, message=${JSON.stringify(e.message)}>`);
362
- return `#<Errors:[${parts.join(', ')}]>`;
363
- }
364
-
365
- get count(): number {
366
- return this.entries.length;
367
- }
368
- get size(): number {
369
- return this.entries.length;
370
- }
371
-
372
- [Symbol.iterator](): IterableIterator<ErrorEntry> {
373
- return this.entries[Symbol.iterator]();
374
- }
375
-
376
- toJSON(): Record<string, string[]> {
377
- return this.messages;
378
- }
379
- }
380
-
381
- /** Two options hashes match when every key in `expected` has the same value in `actual`. */
382
- const matchesOptions = (actual: Record<string, unknown> | undefined, expected: Record<string, unknown>): boolean => {
383
- if (!actual) return Object.keys(expected).length === 0;
384
- for (const [k, v] of Object.entries(expected)) {
385
- if (actual[k] !== v) return false;
386
- }
387
- return true;
388
- };
389
-
390
- /** Rails-style humanize: split camelCase / snake_case, lower-case everything, then upcase only the first letter. */
391
- const humanize = (attribute: string): string => {
392
- // Dotted attribute names like `replies.name` (nested attributes) collapse
393
- // to a single phrase: dots become underscores, then snake/camel split.
394
- // Matches `String#humanize` in Rails.
395
- const flattened = attribute.replace(/\./g, '_');
396
- const spaced = flattened.replace(/[_-]+/g, ' ').replace(/([a-z])([A-Z])/g, '$1 $2').toLowerCase();
397
- return spaced.charAt(0).toUpperCase() + spaced.slice(1);
398
- };