@active-record-ts/active-model 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Alexander Stathis
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,40 @@
1
+ # `@active-record-ts/active-model`
2
+
3
+ Typed attributes, dirty tracking, validations, and callbacks for plain TypeScript classes — no database. A port of Rails' [`ActiveModel`](https://github.com/rails/rails/tree/main/activemodel).
4
+
5
+ Used by [`@active-record-ts/active-record`](../active-record) for persistence; usable on its own for form objects, API payloads, etc.
6
+
7
+ ## Usage
8
+
9
+ ```ts
10
+ import { Model } from '@active-record-ts/active-model';
11
+
12
+ class Signup extends Model {
13
+ declare email: string;
14
+ declare age: number;
15
+ }
16
+
17
+ Signup.attribute('email', 'string');
18
+ Signup.attribute('age', 'integer');
19
+ Signup.validates('email', { presence: true, format: { with: /@/ } });
20
+ Signup.validates('age', { numericality: { greaterThanOrEqualTo: 13 } });
21
+
22
+ const s = new Signup({ email: 'a@b.c', age: 12 });
23
+ await s.validate(); // false
24
+ s.errors.fullMessages; // ["Age must be greater than or equal to 13"]
25
+ ```
26
+
27
+ ## Features
28
+
29
+ - **Typed attributes** — `attribute(name, type)`. Built-ins: `string`, `integer`, `bigint`, `float`, `decimal`, `boolean`, `date`, `datetime`, `binary`, `json`. Register your own with `registerType`.
30
+ - **Dirty tracking** — `record.changed()`, `record.changes()`, `record.wasChanged(attr)`, `record.savedChanges()`.
31
+ - **Validations** — `presence`, `absence`, `format`, `length`, `numericality`, `inclusion`, `exclusion`, `acceptance`, `confirmation`.
32
+ - **Callbacks** — `beforeValidation`, `afterValidation`, `beforeSave`, `afterSave`, `aroundSave`. Throw `HaltError` (or call `throwAbort()`) inside a `before*` callback to halt the chain.
33
+ - **Errors** — `record.errors` exposes Rails-style `add`, `on`, `fullMessages`, etc.
34
+ - **Inflector** — `camelize`, `underscore`, `pluralize`, `tableize`.
35
+
36
+ ## Test
37
+
38
+ ```bash
39
+ bun run test:active-model
40
+ ```
package/package.json ADDED
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "@active-record-ts/active-model",
3
+ "version": "1.0.0",
4
+ "description": "Rails-style ActiveModel for TypeScript — typed attributes, dirty tracking, validations, callbacks.",
5
+ "author": "Alexander Stathis <stathis.alexanderj@gmail.com> (github.com/stathis-alexander)",
6
+ "license": "MIT",
7
+ "type": "module",
8
+ "module": "src/index.ts",
9
+ "files": ["src", "README.md", "LICENSE"],
10
+ "repository": {
11
+ "type": "git",
12
+ "url": "git+https://github.com/stathis-alexander/active-record-ts.git",
13
+ "directory": "packages/active-model"
14
+ },
15
+ "bugs": "https://github.com/stathis-alexander/active-record-ts/issues",
16
+ "homepage": "https://github.com/stathis-alexander/active-record-ts/tree/main/packages/active-model#readme",
17
+ "publishConfig": {
18
+ "access": "public"
19
+ },
20
+ "exports": {
21
+ ".": {
22
+ "types": "./src/index.ts",
23
+ "import": "./src/index.ts",
24
+ "default": "./src/index.ts"
25
+ }
26
+ },
27
+ "peerDependencies": {
28
+ "typescript": "^5"
29
+ },
30
+ "scripts": {
31
+ "test": "bun test",
32
+ "typecheck": "bun tsc --noEmit"
33
+ }
34
+ }
@@ -0,0 +1,221 @@
1
+ /**
2
+ * Per-instance attribute storage with dirty tracking.
3
+ *
4
+ * Two layers of values:
5
+ * - `original` — the value when the record was loaded from the DB (or
6
+ * last `save`/`commit`). Used to compute the dirty diff.
7
+ * - `current` — the working value, updated by every assignment.
8
+ *
9
+ * Dirty surface (mirrors Rails' `ActiveModel::Dirty`):
10
+ * - `changed()` — list of attribute names that differ from original
11
+ * - `changes()` — `{ name: [from, to] }` map of pending changes
12
+ * - `attributeChanged(name)` — boolean
13
+ * - `attributeWas(name)` — original value
14
+ * - `savedChanges()` — changes that were applied during the last save
15
+ * - `attributePreviouslyChanged(name)` — boolean (post-save introspection)
16
+ */
17
+
18
+ import { type Type, valuesEqual } from './Type';
19
+
20
+ /** Declaration of a single attribute on a model. */
21
+ export type AttributeDefinition = {
22
+ name: string;
23
+ type: Type;
24
+ /** Default applied when a record is new and the attribute is not assigned. */
25
+ default?: unknown;
26
+ };
27
+
28
+ export class AttributeSet {
29
+ private readonly definitions = new Map<string, AttributeDefinition>();
30
+ /** Insertion-ordered list of attribute names, for stable iteration. */
31
+ private readonly names: string[] = [];
32
+
33
+ /** Register an attribute (or replace an existing one). */
34
+ define(definition: AttributeDefinition): void {
35
+ if (!this.definitions.has(definition.name)) this.names.push(definition.name);
36
+ this.definitions.set(definition.name, definition);
37
+ }
38
+
39
+ has(name: string): boolean {
40
+ return this.definitions.has(name);
41
+ }
42
+ get(name: string): AttributeDefinition | undefined {
43
+ return this.definitions.get(name);
44
+ }
45
+ keys(): string[] {
46
+ return this.names.slice();
47
+ }
48
+ /** Stable iteration of definitions in declaration order. */
49
+ *[Symbol.iterator](): IterableIterator<AttributeDefinition> {
50
+ for (const name of this.names) yield this.definitions.get(name)!;
51
+ }
52
+ size(): number {
53
+ return this.definitions.size;
54
+ }
55
+ clone(): AttributeSet {
56
+ const copy = new AttributeSet();
57
+ for (const def of this) copy.define(def);
58
+ return copy;
59
+ }
60
+ }
61
+
62
+ /** Per-instance attribute state — values + originals + last-save snapshot. */
63
+ export class Attributes {
64
+ private readonly current = new Map<string, unknown>();
65
+ private readonly original = new Map<string, unknown>();
66
+ private previousChanges: Map<string, [unknown, unknown]> = new Map();
67
+ private readonly accessedNames = new Set<string>();
68
+
69
+ constructor(private readonly set: AttributeSet) {}
70
+
71
+ /** Hydrate attributes after loading from the DB. Skips dirty tracking. */
72
+ hydrate(raw: Record<string, unknown>): void {
73
+ for (const def of this.set) {
74
+ const incoming = raw[def.name];
75
+ const value = def.type.deserialize(incoming);
76
+ this.current.set(def.name, value);
77
+ this.original.set(def.name, value);
78
+ }
79
+ }
80
+
81
+ /** Hydrate attributes for a brand-new record (applies defaults). */
82
+ hydrateDefaults(overrides: Record<string, unknown> = {}): void {
83
+ for (const def of this.set) {
84
+ const provided = def.name in overrides;
85
+ const raw = provided ? overrides[def.name] : def.default;
86
+ const value = def.type.cast(raw);
87
+ this.current.set(def.name, value);
88
+ this.original.set(def.name, value);
89
+ }
90
+ }
91
+
92
+ /** Read an attribute by name, returning the canonical (cast) value. */
93
+ read(name: string): unknown {
94
+ this.accessedNames.add(name);
95
+ return this.current.get(name);
96
+ }
97
+
98
+ /** Names that have been read since hydration. Mirrors Rails' `accessed_attributes`. */
99
+ accessed(): string[] {
100
+ return [...this.accessedNames].filter((n) => this.set.has(n));
101
+ }
102
+
103
+ /** Write an attribute by name. Type-casts before storing. */
104
+ write(name: string, value: unknown): void {
105
+ const def = this.set.get(name);
106
+ if (!def) {
107
+ this.current.set(name, value);
108
+ return;
109
+ }
110
+ this.current.set(name, def.type.cast(value));
111
+ }
112
+
113
+ /**
114
+ * Explicitly mark `name` as having been mutated in place — without
115
+ * actually writing a new value. Mirrors Rails' `name_will_change!`
116
+ * which captures the current value into the original snapshot for
117
+ * later mutation detection.
118
+ */
119
+ willChange(name: string): void {
120
+ if (!this.current.has(name) && !this.original.has(name)) return;
121
+ // The next read sees the current value as "the new value"; rewind
122
+ // original to a snapshot taken BEFORE further mutation.
123
+ const value = this.current.get(name);
124
+ // Stash a deep-ish copy of the current value as the original so subsequent
125
+ // in-place mutation to `current` is detectable as a change.
126
+ const snapshot = typeof value === 'object' && value !== null
127
+ ? (Array.isArray(value) ? [...value] : { ...value })
128
+ : value;
129
+ this.original.set(name, snapshot);
130
+ }
131
+
132
+ /** Was this attribute changed since the last commit? */
133
+ changed(name: string): boolean {
134
+ if (!this.current.has(name)) return false;
135
+ const def = this.set.get(name);
136
+ const original = this.original.get(name);
137
+ const value = this.current.get(name);
138
+ if (!def) return original !== value;
139
+ return !valuesEqual(def.type, original as never, value as never);
140
+ }
141
+
142
+ /** List of attribute names that differ from their originals. */
143
+ changedAttributes(): string[] {
144
+ const result: string[] = [];
145
+ for (const name of this.set.keys()) {
146
+ if (this.changed(name)) result.push(name);
147
+ }
148
+ return result;
149
+ }
150
+
151
+ /** `{ name: [from, to] }` for every changed attribute. */
152
+ changes(): Record<string, [unknown, unknown]> {
153
+ const out: Record<string, [unknown, unknown]> = {};
154
+ for (const name of this.changedAttributes()) {
155
+ out[name] = [this.original.get(name), this.current.get(name)];
156
+ }
157
+ return out;
158
+ }
159
+
160
+ /** Original value of `name` (current value if unchanged). */
161
+ was(name: string): unknown {
162
+ return this.original.get(name);
163
+ }
164
+
165
+ /** Snapshot for SAVE — return the canonical values for each attribute. */
166
+ toHash(): Record<string, unknown> {
167
+ const out: Record<string, unknown> = {};
168
+ for (const name of this.set.keys()) out[name] = this.current.get(name);
169
+ return out;
170
+ }
171
+
172
+ /** Snapshot of only the dirty attributes (for UPDATE statements). */
173
+ dirtyHash(): Record<string, unknown> {
174
+ const out: Record<string, unknown> = {};
175
+ for (const name of this.changedAttributes()) out[name] = this.current.get(name);
176
+ return out;
177
+ }
178
+
179
+ /** Serialized snapshot of all attributes (driver-ready values). */
180
+ serializedHash(): Record<string, unknown> {
181
+ const out: Record<string, unknown> = {};
182
+ for (const def of this.set) {
183
+ out[def.name] = def.type.serialize(this.current.get(def.name) as never);
184
+ }
185
+ return out;
186
+ }
187
+
188
+ /** Mark the current values as the new baseline. Captures previous changes. */
189
+ commit(): void {
190
+ const previous = new Map<string, [unknown, unknown]>();
191
+ for (const name of this.changedAttributes()) {
192
+ previous.set(name, [this.original.get(name), this.current.get(name)]);
193
+ this.original.set(name, this.current.get(name));
194
+ }
195
+ this.previousChanges = previous;
196
+ }
197
+
198
+ /** Drop all pending and recorded changes — used by `clearChangesInformation`. */
199
+ clearChanges(): void {
200
+ for (const [name, value] of this.current) this.original.set(name, value);
201
+ this.previousChanges = new Map();
202
+ }
203
+
204
+ /** Revert all pending changes. */
205
+ restore(): void {
206
+ for (const name of this.changedAttributes()) {
207
+ this.current.set(name, this.original.get(name));
208
+ }
209
+ }
210
+
211
+ /** Changes that were applied during the last `commit`. */
212
+ savedChanges(): Record<string, [unknown, unknown]> {
213
+ const out: Record<string, [unknown, unknown]> = {};
214
+ for (const [name, pair] of this.previousChanges) out[name] = pair;
215
+ return out;
216
+ }
217
+
218
+ attributeSet(): AttributeSet {
219
+ return this.set;
220
+ }
221
+ }
@@ -0,0 +1,158 @@
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
+ };