@jwerre/vellum 1.2.0 → 1.3.0-next.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -108,9 +108,24 @@ export declare abstract class Collection<M extends Model<T>, T extends object> {
108
108
  add(data: T | M): M;
109
109
  /**
110
110
  * Sorts the collection using the comparator if one is defined.
111
- * Called automatically when items are added to the collection.
111
+ *
112
+ * This method is called automatically when items are added to the collection via `add()` or `reset()`.
113
+ * You can also call it manually to re-sort the collection after modifying model attributes.
114
+ *
115
+ * The sorting behavior depends on the type of comparator defined:
116
+ *
117
+ * - **String attribute**: Sorts by the specified model attribute in ascending order
118
+ * - **sortBy function** (1 argument): Sorts by the return value of the function in ascending order
119
+ * - **sort function** (2 arguments): Uses a custom comparator that returns -1, 0, or 1
120
+ *
121
+ * If no comparator is defined or the comparator returns undefined, no sorting is performed.
122
+ *
123
+ * @example
124
+ * // Manual sorting after updating a model
125
+ * user.set('priority', 5);
126
+ * collection.sort();
112
127
  */
113
- private sort;
128
+ sort(): void;
114
129
  /**
115
130
  * Resets the collection with new data, replacing all existing items.
116
131
  *
@@ -70,7 +70,22 @@ export class Collection {
70
70
  }
71
71
  /**
72
72
  * Sorts the collection using the comparator if one is defined.
73
- * Called automatically when items are added to the collection.
73
+ *
74
+ * This method is called automatically when items are added to the collection via `add()` or `reset()`.
75
+ * You can also call it manually to re-sort the collection after modifying model attributes.
76
+ *
77
+ * The sorting behavior depends on the type of comparator defined:
78
+ *
79
+ * - **String attribute**: Sorts by the specified model attribute in ascending order
80
+ * - **sortBy function** (1 argument): Sorts by the return value of the function in ascending order
81
+ * - **sort function** (2 arguments): Uses a custom comparator that returns -1, 0, or 1
82
+ *
83
+ * If no comparator is defined or the comparator returns undefined, no sorting is performed.
84
+ *
85
+ * @example
86
+ * // Manual sorting after updating a model
87
+ * user.set('priority', 5);
88
+ * collection.sort();
74
89
  */
75
90
  sort() {
76
91
  if (!this.comparator) {
@@ -8,6 +8,10 @@ export interface ValidationOptions {
8
8
  silent?: boolean;
9
9
  [key: string]: unknown;
10
10
  }
11
+ export interface CloneOptions {
12
+ keepId?: boolean;
13
+ deep?: boolean;
14
+ }
11
15
  /**
12
16
  * Abstract base class for creating model instances that interact with RESTful APIs.
13
17
  *
@@ -106,6 +110,44 @@ export declare abstract class Model<T extends object> {
106
110
  * @returns {ValidationError || undefined} An instance of ValidationError if validation failed, otherwise undefined
107
111
  */
108
112
  get validationError(): ValidationError | undefined;
113
+ /**
114
+ * Gets the hash of attributes that have changed since the last set call.
115
+ * This is a readonly accessor - the changed object cannot be modified directly.
116
+ *
117
+ * @returns {Partial<T>} An object containing all attributes that have changed
118
+ */
119
+ get changed(): Partial<T>;
120
+ /**
121
+ * Gets the hash of previous attribute values before they were changed.
122
+ * This is a readonly accessor - the previous object cannot be modified directly.
123
+ *
124
+ * @returns {Partial<T>} An object containing the previous values of changed attributes
125
+ */
126
+ get previous(): Partial<T>;
127
+ /**
128
+ * Determines if attributes have changed since the last set call.
129
+ *
130
+ * This method checks whether any attributes were modified in the most recent
131
+ * set operation. When called without arguments, it returns true if any attributes
132
+ * have changed. When called with a specific attribute key, it returns true only
133
+ * if that particular attribute was changed.
134
+ *
135
+ * @param {keyof T} [attr] - Optional attribute key to check for changes
136
+ * @returns {boolean} True if attributes have changed, false otherwise
137
+ *
138
+ * @example
139
+ * // Check if any attributes changed
140
+ * const user = new User({ name: 'John', email: 'john@example.com' });
141
+ * user.set('name', 'Jane');
142
+ * user.hasChanged(); // Returns true
143
+ *
144
+ * @example
145
+ * // Check if a specific attribute changed
146
+ * user.set('name', 'Jane');
147
+ * user.hasChanged('name'); // Returns true
148
+ * user.hasChanged('email'); // Returns false
149
+ */
150
+ hasChanged(attr?: keyof T): boolean;
109
151
  /**
110
152
  * Provides default attribute values for new model instances.
111
153
  *
@@ -374,6 +416,41 @@ export declare abstract class Model<T extends object> {
374
416
  * }
375
417
  */
376
418
  validate(attributes: Partial<T>, options?: ValidationOptions): string | undefined;
419
+ /**
420
+ * Creates a new instance of the model with identical attributes.
421
+ *
422
+ * This method returns a new model instance that is a clone of the current model,
423
+ * with all attributes copied to the new instance. The clone is a separate object
424
+ * with its own state, so modifications to the clone will not affect the original
425
+ * model and vice versa. By default, the ID is removed so the cloned instance is
426
+ * considered "new" (isNew() returns true).
427
+ *
428
+ * @param {CloneOptions} [options] - Configuration options for cloning
429
+ * @param {boolean} [options.keepId=false] - If true, preserves the ID in the clone
430
+ * @param {boolean} [options.deep=false] - If true, performs a deep clone of nested objects/arrays
431
+ * @returns {this} A new instance of the same model class with cloned attributes
432
+ *
433
+ * @example
434
+ * // Clone a user model (default - no ID)
435
+ * const user = new User({ id: 1, name: 'John', email: 'john@example.com' });
436
+ * const userCopy = user.clone();
437
+ * console.log(userCopy.isNew()); // true
438
+ * console.log(userCopy.get('id')); // undefined
439
+ *
440
+ * @example
441
+ * // Clone with ID preserved
442
+ * const userCopy = user.clone({ keepId: true });
443
+ * console.log(userCopy.isNew()); // false
444
+ * console.log(userCopy.get('id')); // 1
445
+ *
446
+ * @example
447
+ * // Deep clone for nested objects
448
+ * const user = new User({ id: 1, name: 'John', settings: { theme: 'dark' } });
449
+ * const userCopy = user.clone({ deep: true });
450
+ * userCopy.get('settings').theme = 'light';
451
+ * console.log(user.get('settings').theme); // 'dark' (unchanged)
452
+ */
453
+ clone(options?: CloneOptions): this;
377
454
  /**
378
455
  * Performs HTTP synchronization with the server for CRUD operations.
379
456
  *
@@ -46,12 +46,38 @@ import { escapeHTML } from './utils.js';
46
46
  * await user.destroy(); // Deletes user
47
47
  */
48
48
  export class Model {
49
+ /**
50
+ * Internal reactive storage for all model attributes.
51
+ *
52
+ * This private field uses Svelte's $state rune to create a reactive object that
53
+ * holds all the model's attribute data. When attributes are modified, this reactivity
54
+ * ensures that any Svelte components using the model will automatically re-render
55
+ * to reflect the changes.
56
+ *
57
+ * The attributes are initialized as an empty object cast to type T, and are populated
58
+ * during construction by merging default values with provided data. All attribute
59
+ * access and modification should go through the public API methods (get, set, has, etc.)
60
+ * rather than directly accessing this field.
61
+ *
62
+ * @private
63
+ * @type {T}
64
+ */
49
65
  #attributes = $state({});
50
66
  /**
51
67
  * Validation error property that gets set when validation fails.
52
68
  * This property contains the error returned by the validate method.
53
69
  */
54
70
  #validationError = $state();
71
+ /**
72
+ * Internal hash containing all attributes that have changed since the last set call.
73
+ * This property tracks which attributes have been modified and their new values.
74
+ */
75
+ #changed = {};
76
+ /**
77
+ * Internal hash containing the previous values of attributes before they were changed.
78
+ * This property stores the original values of attributes from before the last set call.
79
+ */
80
+ #previous = {};
55
81
  /**
56
82
  * The name of the attribute that serves as the unique identifier for this model instance.
57
83
  *
@@ -92,6 +118,53 @@ export class Model {
92
118
  get validationError() {
93
119
  return this.#validationError;
94
120
  }
121
+ /**
122
+ * Gets the hash of attributes that have changed since the last set call.
123
+ * This is a readonly accessor - the changed object cannot be modified directly.
124
+ *
125
+ * @returns {Partial<T>} An object containing all attributes that have changed
126
+ */
127
+ get changed() {
128
+ return this.#changed;
129
+ }
130
+ /**
131
+ * Gets the hash of previous attribute values before they were changed.
132
+ * This is a readonly accessor - the previous object cannot be modified directly.
133
+ *
134
+ * @returns {Partial<T>} An object containing the previous values of changed attributes
135
+ */
136
+ get previous() {
137
+ return this.#previous;
138
+ }
139
+ /**
140
+ * Determines if attributes have changed since the last set call.
141
+ *
142
+ * This method checks whether any attributes were modified in the most recent
143
+ * set operation. When called without arguments, it returns true if any attributes
144
+ * have changed. When called with a specific attribute key, it returns true only
145
+ * if that particular attribute was changed.
146
+ *
147
+ * @param {keyof T} [attr] - Optional attribute key to check for changes
148
+ * @returns {boolean} True if attributes have changed, false otherwise
149
+ *
150
+ * @example
151
+ * // Check if any attributes changed
152
+ * const user = new User({ name: 'John', email: 'john@example.com' });
153
+ * user.set('name', 'Jane');
154
+ * user.hasChanged(); // Returns true
155
+ *
156
+ * @example
157
+ * // Check if a specific attribute changed
158
+ * user.set('name', 'Jane');
159
+ * user.hasChanged('name'); // Returns true
160
+ * user.hasChanged('email'); // Returns false
161
+ */
162
+ hasChanged(attr) {
163
+ if (attr !== undefined) {
164
+ return attr in this.#changed;
165
+ }
166
+ return Object.keys(this.#changed).length > 0;
167
+ }
95
168
  /**
96
169
  * Provides default attribute values for new model instances.
97
170
  *
@@ -158,6 +231,14 @@ export class Model {
158
231
  if (opts?.validate && !this.#doValidation({ ...this.#attributes, ...attrs }, opts)) {
159
232
  return false;
160
233
  }
234
+ // Store previous values and record new changes
235
+ this.#previous = {};
236
+ for (const key in attrs) {
237
+ if (key in this.#attributes) {
238
+ this.#previous[key] = this.#attributes[key];
239
+ }
240
+ }
241
+ this.#changed = attrs;
161
242
  Object.assign(this.#attributes, attrs);
162
243
  return true;
163
244
  }
@@ -357,6 +438,53 @@ export class Model {
357
438
  // Default implementation - no validation
358
439
  return undefined;
359
440
  }
441
+ /**
442
+ * Creates a new instance of the model with identical attributes.
443
+ *
444
+ * This method returns a new model instance that is a clone of the current model,
445
+ * with all attributes copied to the new instance. The clone is a separate object
446
+ * with its own state, so modifications to the clone will not affect the original
447
+ * model and vice versa. By default, the ID is removed so the cloned instance is
448
+ * considered "new" (isNew() returns true).
449
+ *
450
+ * @param {CloneOptions} [options] - Configuration options for cloning
451
+ * @param {boolean} [options.keepId=false] - If true, preserves the ID in the clone
452
+ * @param {boolean} [options.deep=false] - If true, performs a deep clone of nested objects/arrays
453
+ * @returns {this} A new instance of the same model class with cloned attributes
454
+ *
455
+ * @example
456
+ * // Clone a user model (default - no ID)
457
+ * const user = new User({ id: 1, name: 'John', email: 'john@example.com' });
458
+ * const userCopy = user.clone();
459
+ * console.log(userCopy.isNew()); // true
460
+ * console.log(userCopy.get('id')); // undefined
461
+ *
462
+ * @example
463
+ * // Clone with ID preserved
464
+ * const userCopy = user.clone({ keepId: true });
465
+ * console.log(userCopy.isNew()); // false
466
+ * console.log(userCopy.get('id')); // 1
467
+ *
468
+ * @example
469
+ * // Deep clone for nested objects
470
+ * const user = new User({ id: 1, name: 'John', settings: { theme: 'dark' } });
471
+ * const userCopy = user.clone({ deep: true });
472
+ * userCopy.get('settings').theme = 'light';
473
+ * console.log(user.get('settings').theme); // 'dark' (unchanged)
474
+ */
475
+ clone(options) {
476
+ const Constructor = this.constructor;
477
+ let attrs = this.toJSON();
478
+ // Remove ID unless keepId is true
479
+ if (!options?.keepId) {
480
+ delete attrs[this.idAttribute];
481
+ }
482
+ // Perform deep clone if requested
483
+ if (options?.deep) {
484
+ attrs = structuredClone(attrs);
485
+ }
486
+ return new Constructor(attrs);
487
+ }
360
488
  /**
361
489
  * Performs HTTP synchronization with the server for CRUD operations.
362
490
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jwerre/vellum",
3
- "version": "1.2.0",
3
+ "version": "1.3.0-next.2",
4
4
  "description": "Structural state management library for Svelte 5",
5
5
  "repository": {
6
6
  "type": "git",