@jwerre/vellum 1.3.0-next.1 → 1.3.0-next.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Vellum
2
2
 
3
- Vellum is a lightweight, structural state management library for Svelte 5. Vellum provides a robust Model and Collection base powered by Svelte Runes. It bridges the gap between raw objects and complex state logic, offering a typed, class-based approach to managing data-heavy applications.
3
+ Vellum is a lightweight, structural state management library for Svelte 5. Vellum provides a robust Model and Collection base that is inspired by Backbone.js but powered by Svelte Runes. It bridges the gap between raw objects and complex state logic, offering a typed, class-based approach to managing data-heavy applications.
4
4
 
5
5
  ## Features
6
6
 
@@ -61,9 +61,7 @@ interface BookSchema {
61
61
 
62
62
  export class Book extends Model<BookSchema> {
63
63
 
64
- endpoint() {
65
- return `/v1/books`;
66
- }
64
+ endpoint = `/v1/books`;
67
65
 
68
66
  defaults() {
69
67
  return {
@@ -108,10 +106,7 @@ import { Book } from './Book.svelte.js';
108
106
 
109
107
  export class Books extends Collection<Book, BookSchema> {
110
108
  model = Book;
111
-
112
- endpoint() {
113
- return `/v1/books`;
114
- }
109
+ endpoint = `/v1/books`;
115
110
 
116
111
  // Derived state for the entire collection
117
112
  classicCount = $derived(this.items.filter((u) => u.isClassic()).length);
@@ -124,40 +119,20 @@ Vellum works seamlessly with Svelte 5 components.
124
119
 
125
120
  ```svelte
126
121
  <script lang="ts">
127
- import { Books } from './Books';
128
-
129
- const books = new Books([
130
- {
131
- id: 1,
132
- title: 'The Great Gatsby',
133
- author: 'F. Scott Fitzgerald',
134
- year: 1925,
135
- genre: 'literature'
136
- },
137
- {
138
- id: 2,
139
- title: 'To Kill a Mockingbird',
140
- author: 'Harper Lee',
141
- year: 1960,
142
- genre: 'literature'
143
- },
144
- {
145
- id: 3,
146
- title: 'The Pillars of the Earth',
147
- author: 'Ken Follett',
148
- year: 1989,
149
- genre: 'literature'
150
- }
151
- ]);
152
-
153
- function addBook() {
154
- books.add({
155
- title: 'Moby Dick',
156
- author: 'Herman Melville',
157
- year: 1851,
158
- genre: 'Adventure',
159
- summary: 'The obsessive quest of Captain Ahab to seek revenge on the white whale.'
160
- });
122
+ import { Book } from './Book.svelte.js';
123
+ import { Books } from './Books.svelte.js';
124
+
125
+ const books = new Books();
126
+ await books.fetch();
127
+
128
+ async function onSubmitBook(e: Event) {
129
+ e.preventDefault();
130
+ const form = e.target as HTMLFormElement;
131
+ const formData = new FormData(form);
132
+ const data = Object.fromEntries(formData) as BookSchema;
133
+ const book = new Book(data);
134
+ await book.save();
135
+ books.add(book);
161
136
  }
162
137
  </script>
163
138
 
@@ -170,8 +145,18 @@ Vellum works seamlessly with Svelte 5 components.
170
145
  {/if}
171
146
  {/each}
172
147
  </ul>
173
-
174
- <button onclick={addBook}>Add Moby Dick</button>
148
+ <form onsubmit={onSubmitBook}>
149
+ <div>
150
+ <label for="title">Title</label>
151
+ <input type="text" id="title" name="title" />
152
+ </div>
153
+ <div>
154
+ <label for="author">Author</label>
155
+ <input type="text" id="author" name="author" />
156
+ </div>
157
+ ...
158
+ <button type="submit">+ Add book</button>
159
+ </form>
175
160
  ```
176
161
 
177
162
  ### Working example
@@ -199,60 +184,72 @@ There is a working example of Vellum in the `routes` directory. To run it, clone
199
184
  - [idAttribute](#idattribute)
200
185
  - [Examples](#examples-2)
201
186
  - [validationError](#validationerror)
187
+ - [changed](#changed)
188
+ - [previous](#previous)
202
189
  - [defaults](#defaults)
203
190
  - [Examples](#examples-3)
204
- - [get](#get)
191
+ - [hasChanged](#haschanged)
205
192
  - [Parameters](#parameters-2)
206
193
  - [Examples](#examples-4)
207
- - [has](#has)
194
+ - [get](#get)
208
195
  - [Parameters](#parameters-3)
209
196
  - [Examples](#examples-5)
210
- - [unset](#unset)
197
+ - [has](#has)
211
198
  - [Parameters](#parameters-4)
212
199
  - [Examples](#examples-6)
213
- - [clear](#clear)
214
- - [Examples](#examples-7)
215
- - [escape](#escape)
200
+ - [unset](#unset)
216
201
  - [Parameters](#parameters-5)
202
+ - [Examples](#examples-7)
203
+ - [clear](#clear)
217
204
  - [Examples](#examples-8)
218
- - [isNew](#isnew)
219
- - [isValid](#isvalid)
205
+ - [escape](#escape)
220
206
  - [Parameters](#parameters-6)
221
207
  - [Examples](#examples-9)
222
- - [validate](#validate)
208
+ - [isNew](#isnew)
209
+ - [isValid](#isvalid)
223
210
  - [Parameters](#parameters-7)
224
211
  - [Examples](#examples-10)
225
- - [sync](#sync)
212
+ - [validate](#validate)
226
213
  - [Parameters](#parameters-8)
227
214
  - [Examples](#examples-11)
228
- - [fetch](#fetch)
229
- - [Examples](#examples-12)
230
- - [save](#save)
215
+ - [clone](#clone)
231
216
  - [Parameters](#parameters-9)
217
+ - [Examples](#examples-12)
218
+ - [sync](#sync)
219
+ - [Parameters](#parameters-10)
232
220
  - [Examples](#examples-13)
233
- - [destroy](#destroy)
221
+ - [fetch](#fetch)
234
222
  - [Examples](#examples-14)
235
- - [toJSON](#tojson)
223
+ - [save](#save)
224
+ - [Parameters](#parameters-11)
236
225
  - [Examples](#examples-15)
226
+ - [destroy](#destroy)
227
+ - [Examples](#examples-16)
228
+ - [toJSON](#tojson)
229
+ - [Examples](#examples-17)
230
+ - [attributes](#attributes)
237
231
  - [validationError](#validationerror-1)
232
+ - [changed](#changed-1)
233
+ - [previous](#previous-1)
238
234
  - [Collection](#collection)
239
- - [Parameters](#parameters-10)
240
- - [Examples](#examples-16)
235
+ - [Parameters](#parameters-12)
236
+ - [Examples](#examples-18)
241
237
  - [items](#items)
242
238
  - [length](#length)
243
239
  - [add](#add)
244
- - [Parameters](#parameters-11)
245
- - [Examples](#examples-17)
240
+ - [Parameters](#parameters-13)
241
+ - [Examples](#examples-19)
246
242
  - [sort](#sort)
243
+ - [Examples](#examples-20)
247
244
  - [reset](#reset)
248
- - [Parameters](#parameters-12)
249
- - [Examples](#examples-18)
245
+ - [Parameters](#parameters-14)
246
+ - [Examples](#examples-21)
250
247
  - [find](#find)
251
- - [Parameters](#parameters-13)
252
- - [Examples](#examples-19)
248
+ - [Parameters](#parameters-15)
249
+ - [Examples](#examples-22)
253
250
  - [fetch](#fetch-1)
254
- - [Parameters](#parameters-14)
255
- - [Examples](#examples-20)
251
+ - [Parameters](#parameters-16)
252
+ - [Examples](#examples-23)
256
253
 
257
254
  ### vellumConfig
258
255
 
@@ -329,9 +326,8 @@ interface UserAttributes {
329
326
  }
330
327
 
331
328
  class User extends Model<UserAttributes> {
332
- endpoint() {
333
- return '/users';
334
- }
329
+ endpoint = '/users';
330
+
335
331
  defaults() {
336
332
  return { name: '', createdAt: new Date() };
337
333
  }
@@ -365,9 +361,7 @@ const user = new User({ id: 1, name: 'John' });
365
361
  // Custom ID attribute can be specified in constructor options
366
362
  class User extends Model<UserSchema> {
367
363
  idAttribute = '_id
368
- endpoint(): string {
369
- return '/users';
370
- }
364
+ endpoint = '/users';
371
365
  }
372
366
  const user = new User({ _id: '507f1f77bcf86cd799439011', name: 'John' });
373
367
  ```
@@ -376,6 +370,20 @@ const user = new User({ _id: '507f1f77bcf86cd799439011', name: 'John' });
376
370
 
377
371
  Gets the latest validation error.
378
372
 
373
+ #### changed
374
+
375
+ Gets the hash of attributes that have changed since the last set call.
376
+ This is a readonly accessor - the changed object cannot be modified directly.
377
+
378
+ Returns **Partial\<T>** An object containing all attributes that have changed
379
+
380
+ #### previous
381
+
382
+ Gets the hash of previous attribute values before they were changed.
383
+ This is a readonly accessor - the previous object cannot be modified directly.
384
+
385
+ Returns **Partial\<T>** An object containing the previous values of changed attributes
386
+
379
387
  #### defaults
380
388
 
381
389
  Provides default attribute values for new model instances.
@@ -410,6 +418,37 @@ const user = new User({ name: 'John' });
410
418
 
411
419
  Returns **Partial\<T>** A partial object containing default attribute values
412
420
 
421
+ #### hasChanged
422
+
423
+ Determines if attributes have changed since the last set call.
424
+
425
+ This method checks whether any attributes were modified in the most recent
426
+ set operation. When called without arguments, it returns true if any attributes
427
+ have changed. When called with a specific attribute key, it returns true only
428
+ if that particular attribute was changed.
429
+
430
+ ##### Parameters
431
+
432
+ - `attr` &#x20;
433
+
434
+ ##### Examples
435
+
436
+ ```javascript
437
+ // Check if any attributes changed
438
+ const user = new User({ name: 'John', email: 'john@example.com' });
439
+ user.set('name', 'Jane');
440
+ user.hasChanged(); // Returns true
441
+ ```
442
+
443
+ ```javascript
444
+ // Check if a specific attribute changed
445
+ user.set('name', 'Jane');
446
+ user.hasChanged('name'); // Returns true
447
+ user.hasChanged('email'); // Returns false
448
+ ```
449
+
450
+ Returns **[boolean](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Boolean)** True if attributes have changed, false otherwise
451
+
413
452
  #### get
414
453
 
415
454
  Retrieves the value of a specific attribute from the model.
@@ -638,19 +677,62 @@ validate(attributes: Partial<UserAttributes>) {
638
677
 
639
678
  Returns **any** Returns undefined if valid, or an error (string/object) if invalid
640
679
 
680
+ #### clone
681
+
682
+ Creates a new instance of the model with identical attributes.
683
+
684
+ This method returns a new model instance that is a clone of the current model,
685
+ with all attributes copied to the new instance. The clone is a separate object
686
+ with its own state, so modifications to the clone will not affect the original
687
+ model and vice versa. By default, the ID is removed so the cloned instance is
688
+ considered "new" (isNew() returns true).
689
+
690
+ ##### Parameters
691
+
692
+ - `options` **CloneOptions?** Configuration options for cloning
693
+ - `options.keepId` **[boolean](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Boolean)** If true, preserves the ID in the clone (optional, default `false`)
694
+ - `options.deep` **[boolean](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Boolean)** If true, performs a deep clone of nested objects/arrays (optional, default `false`)
695
+
696
+ ##### Examples
697
+
698
+ ```javascript
699
+ // Clone a user model (default - no ID)
700
+ const user = new User({ id: 1, name: 'John', email: 'john@example.com' });
701
+ const userCopy = user.clone();
702
+ console.log(userCopy.isNew()); // true
703
+ console.log(userCopy.get('id')); // undefined
704
+ ```
705
+
706
+ ```javascript
707
+ // Clone with ID preserved
708
+ const userCopy = user.clone({ keepId: true });
709
+ console.log(userCopy.isNew()); // false
710
+ console.log(userCopy.get('id')); // 1
711
+ ```
712
+
713
+ ```javascript
714
+ // Deep clone for nested objects
715
+ const user = new User({ id: 1, name: 'John', settings: { theme: 'dark' } });
716
+ const userCopy = user.clone({ deep: true });
717
+ userCopy.get('settings').theme = 'light';
718
+ console.log(user.get('settings').theme); // 'dark' (unchanged)
719
+ ```
720
+
721
+ Returns **this** A new instance of the same model class with cloned attributes
722
+
641
723
  #### sync
642
724
 
643
725
  Performs HTTP synchronization with the server for CRUD operations.
644
726
 
645
727
  This method handles all HTTP communication between the model and the server,
646
728
  automatically constructing the appropriate URL based on the model's ID and
647
- endpoint(). It supports all standard REST operations and provides type-safe
729
+ endpoint. It supports all standard REST operations and provides type-safe
648
730
  response handling.
649
731
 
650
732
  The URL construction follows REST conventions:
651
733
 
652
- - For new models (no ID): uses collection endpoint `${baseUrl}${endpoint()}`
653
- - For existing models (with ID): uses resource endpoint `${baseUrl}${endpoint()}/${id}`
734
+ - For new models (no ID): uses collection endpoint `${baseUrl}${endpoint}`
735
+ - For existing models (with ID): uses resource endpoint `${baseUrl}${endpoint}/${id}`
654
736
 
655
737
  ##### Parameters
656
738
 
@@ -759,7 +841,7 @@ on the server). If the model is new and has no ID, the method will return withou
759
841
  performing any operation.
760
842
 
761
843
  The DELETE request is sent to the model's specific resource endpoint using the
762
- pattern `${baseUrl}${endpoint()}/${id}`. After successful deletion, the model
844
+ pattern `${baseUrl}${endpoint}/${id}`. After successful deletion, the model
763
845
  instance remains in memory but the corresponding server resource is removed.
764
846
 
765
847
  ##### Examples
@@ -805,11 +887,35 @@ const jsonString = JSON.stringify(user.toJSON());
805
887
 
806
888
  Returns **T** A plain object containing all of the model's attributes
807
889
 
890
+ ### attributes
891
+
892
+ Internal reactive storage for all model attributes.
893
+
894
+ This private field uses Svelte's $state rune to create a reactive object that
895
+ holds all the model's attribute data. When attributes are modified, this reactivity
896
+ ensures that any Svelte components using the model will automatically re-render
897
+ to reflect the changes.
898
+
899
+ The attributes are initialized as an empty object cast to type T, and are populated
900
+ during construction by merging default values with provided data. All attribute
901
+ access and modification should go through the public API methods (get, set, has, etc.)
902
+ rather than directly accessing this field.
903
+
808
904
  ### validationError
809
905
 
810
906
  Validation error property that gets set when validation fails.
811
907
  This property contains the error returned by the validate method.
812
908
 
909
+ ### changed
910
+
911
+ Internal hash containing all attributes that have changed since the last set call.
912
+ This property tracks which attributes have been modified and their new values.
913
+
914
+ ### previous
915
+
916
+ Internal hash containing the previous values of attributes before they were changed.
917
+ This property stores the original values of attributes from before the last set call.
918
+
813
919
  ### Collection
814
920
 
815
921
  Abstract base class for managing collections of Model instances.
@@ -827,7 +933,7 @@ system for automatic UI updates.
827
933
  ```javascript
828
934
  class UserCollection extends Collection<UserModel, User> {
829
935
  model = UserModel;
830
- endpoint = () => '/api/users';
936
+ endpoint = '/api/users';
831
937
  }
832
938
 
833
939
  const users = new UserCollection();
@@ -867,7 +973,25 @@ Returns **any** The model instance that was added to the collection
867
973
  #### sort
868
974
 
869
975
  Sorts the collection using the comparator if one is defined.
870
- Called automatically when items are added to the collection.
976
+
977
+ This method is called automatically when items are added to the collection via `add()` or `reset()`.
978
+ You can also call it manually to re-sort the collection after modifying model attributes.
979
+
980
+ The sorting behavior depends on the type of comparator defined:
981
+
982
+ - **String attribute**: Sorts by the specified model attribute in ascending order
983
+ - **sortBy function** (1 argument): Sorts by the return value of the function in ascending order
984
+ - **sort function** (2 arguments): Uses a custom comparator that returns -1, 0, or 1
985
+
986
+ If no comparator is defined or the comparator returns undefined, no sorting is performed.
987
+
988
+ ##### Examples
989
+
990
+ ```javascript
991
+ // Manual sorting after updating a model
992
+ user.set('priority', 5);
993
+ collection.sort();
994
+ ```
871
995
 
872
996
  #### reset
873
997
 
@@ -1,5 +1,8 @@
1
1
  import { Model } from './Model.svelte';
2
2
  import { type VellumConfig } from './config.svelte';
3
+ export interface CollectionOptions extends Partial<VellumConfig> {
4
+ model?: unknown;
5
+ }
3
6
  export interface FetchOptions extends Partial<VellumConfig> {
4
7
  endpoint?: string;
5
8
  search?: Record<string, string | number | boolean>;
@@ -17,7 +20,7 @@ export interface FetchOptions extends Partial<VellumConfig> {
17
20
  * @example
18
21
  * class UserCollection extends Collection<UserModel, User> {
19
22
  * model = UserModel;
20
- * endpoint = () => '/api/users';
23
+ * endpoint = '/api/users';
21
24
  * }
22
25
  *
23
26
  * const users = new UserCollection();
@@ -31,8 +34,16 @@ export declare abstract class Collection<M extends Model<T>, T extends object> {
31
34
  abstract model: {
32
35
  new (data: Partial<T>): M;
33
36
  };
34
- /** Returns the API endpoint URL for this collection */
35
- abstract endpoint(): string;
37
+ /**
38
+ * The base URL path for API endpoints related to this model.
39
+ *
40
+ * Define this as a property in your subclass.
41
+ * @example
42
+ * class User extends Collection<UserSchema> {
43
+ * endpoint = '/users';
44
+ * }
45
+ */
46
+ protected abstract endpoint: string;
36
47
  /**
37
48
  * Optional comparator for sorting the collection.
38
49
  *
@@ -83,7 +94,8 @@ export declare abstract class Collection<M extends Model<T>, T extends object> {
83
94
  * const collection = new UserCollection();
84
95
  *
85
96
  * // Create collection with initial data
86
- * const collection = new UserCollection([
97
+ * const collection = new UserCollection();
98
+ * collection.reset([
87
99
  * { id: 1, name: 'John' },
88
100
  * { id: 2, name: 'Jane' }
89
101
  * ]);
@@ -14,7 +14,7 @@ import { vellumConfig } from './config.svelte';
14
14
  * @example
15
15
  * class UserCollection extends Collection<UserModel, User> {
16
16
  * model = UserModel;
17
- * endpoint = () => '/api/users';
17
+ * endpoint = '/api/users';
18
18
  * }
19
19
  *
20
20
  * const users = new UserCollection();
@@ -34,14 +34,21 @@ export class Collection {
34
34
  * const collection = new UserCollection();
35
35
  *
36
36
  * // Create collection with initial data
37
- * const collection = new UserCollection([
37
+ * const collection = new UserCollection();
38
+ * collection.reset([
38
39
  * { id: 1, name: 'John' },
39
40
  * { id: 2, name: 'Jane' }
40
41
  * ]);
41
42
  */
42
43
  constructor(models = []) {
43
44
  if (models.length > 0) {
44
- this.reset(models);
45
+ try {
46
+ this.reset(models);
47
+ }
48
+ catch (error) {
49
+ console.error('Failed to initialize collection with data:', error);
50
+ console.error('Initializing the collection with data requires model to be defined as getter since model is not available in constructor.');
51
+ }
45
52
  }
46
53
  }
47
54
  /** Gets the number of items in the collection */
@@ -194,7 +201,7 @@ export class Collection {
194
201
  }
195
202
  query = `?${params.toString()}`;
196
203
  }
197
- const endpoint = options?.endpoint?.length ? options.endpoint : this.endpoint();
204
+ const endpoint = options?.endpoint?.length ? options.endpoint : this.endpoint;
198
205
  const fullUrl = `${vellumConfig.origin}${endpoint}${query}`;
199
206
  const response = await fetch(fullUrl, {
200
207
  headers: { ...vellumConfig.headers }
@@ -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
  *
@@ -37,9 +41,8 @@ export interface ValidationOptions {
37
41
  * }
38
42
  *
39
43
  * class User extends Model<UserAttributes> {
40
- * endpoint() {
41
- * return '/users';
42
- * }
44
+ * endpoint = '/users';
45
+
43
46
  * defaults() {
44
47
  * return { name: '', createdAt: new Date() };
45
48
  * }
@@ -59,18 +62,16 @@ export declare abstract class Model<T extends object> {
59
62
  * for API endpoints related to this model.
60
63
  *
61
64
  * This method returns the root URL segment that will be appended to the base API URL
62
- * to form complete endpoints for CRUD operations. For example, if endpoint() returns
65
+ * to form complete endpoints for CRUD operations. For example, if endpoint is set to
63
66
  * '/users', the full URL for API calls would be `${baseUrl}/users` for collections
64
67
  * or `${baseUrl}/users/{id}` for individual resources.
65
68
  *
66
69
  * @returns {string} The root URL path for this model's API endpoints (e.g., '/users', '/posts')
67
70
  * @example
68
71
  * // In a User model subclass:
69
- * endpoint(): string {
70
- * return '/users';
71
- * }
72
+ * endpoint = '/users';
72
73
  */
73
- protected abstract endpoint(): string;
74
+ protected abstract endpoint: string;
74
75
  /**
75
76
  * The name of the attribute that serves as the unique identifier for this model instance.
76
77
  *
@@ -89,9 +90,7 @@ export declare abstract class Model<T extends object> {
89
90
  * // Custom ID attribute can be specified in constructor options
90
91
  * class User extends Model<UserSchema> {
91
92
  * idAttribute = '_id
92
- * endpoint(): string {
93
- * return '/users';
94
- * }
93
+ * endpoint = '/users';
95
94
  * }
96
95
  * const user = new User({ _id: '507f1f77bcf86cd799439011', name: 'John' });
97
96
  */
@@ -120,30 +119,6 @@ export declare abstract class Model<T extends object> {
120
119
  * @returns {Partial<T>} An object containing the previous values of changed attributes
121
120
  */
122
121
  get previous(): Partial<T>;
123
- /**
124
- * Determines if attributes have changed since the last set call.
125
- *
126
- * This method checks whether any attributes were modified in the most recent
127
- * set operation. When called without arguments, it returns true if any attributes
128
- * have changed. When called with a specific attribute key, it returns true only
129
- * if that particular attribute was changed.
130
- *
131
- * @param {keyof T} [attr] - Optional attribute key to check for changes
132
- * @returns {boolean} True if attributes have changed, false otherwise
133
- *
134
- * @example
135
- * // Check if any attributes changed
136
- * const user = new User({ name: 'John', email: 'john@example.com' });
137
- * user.set('name', 'Jane');
138
- * user.hasChanged(); // Returns true
139
- *
140
- * @example
141
- * // Check if a specific attribute changed
142
- * user.set('name', 'Jane');
143
- * user.hasChanged('name'); // Returns true
144
- * user.hasChanged('email'); // Returns false
145
- */
146
- hasChanged(attr?: keyof T): boolean;
147
122
  /**
148
123
  * Provides default attribute values for new model instances.
149
124
  *
@@ -175,6 +150,30 @@ export declare abstract class Model<T extends object> {
175
150
  * // Resulting attributes: { role: 'user', isActive: true, createdAt: Date, name: 'John' }
176
151
  */
177
152
  protected defaults(): Partial<T>;
153
+ /**
154
+ * Determines if attributes have changed since the last set call.
155
+ *
156
+ * This method checks whether any attributes were modified in the most recent
157
+ * set operation. When called without arguments, it returns true if any attributes
158
+ * have changed. When called with a specific attribute key, it returns true only
159
+ * if that particular attribute was changed.
160
+ *
161
+ * @param {keyof T} [attr] - Optional attribute key to check for changes
162
+ * @returns {boolean} True if attributes have changed, false otherwise
163
+ *
164
+ * @example
165
+ * // Check if any attributes changed
166
+ * const user = new User({ name: 'John', email: 'john@example.com' });
167
+ * user.set('name', 'Jane');
168
+ * user.hasChanged(); // Returns true
169
+ *
170
+ * @example
171
+ * // Check if a specific attribute changed
172
+ * user.set('name', 'Jane');
173
+ * user.hasChanged('name'); // Returns true
174
+ * user.hasChanged('email'); // Returns false
175
+ */
176
+ hasChanged(attr?: keyof T): boolean;
178
177
  /**
179
178
  * Retrieves the value of a specific attribute from the model.
180
179
  *
@@ -412,17 +411,52 @@ export declare abstract class Model<T extends object> {
412
411
  * }
413
412
  */
414
413
  validate(attributes: Partial<T>, options?: ValidationOptions): string | undefined;
414
+ /**
415
+ * Creates a new instance of the model with identical attributes.
416
+ *
417
+ * This method returns a new model instance that is a clone of the current model,
418
+ * with all attributes copied to the new instance. The clone is a separate object
419
+ * with its own state, so modifications to the clone will not affect the original
420
+ * model and vice versa. By default, the ID is removed so the cloned instance is
421
+ * considered "new" (isNew() returns true).
422
+ *
423
+ * @param {CloneOptions} [options] - Configuration options for cloning
424
+ * @param {boolean} [options.keepId=false] - If true, preserves the ID in the clone
425
+ * @param {boolean} [options.deep=false] - If true, performs a deep clone of nested objects/arrays
426
+ * @returns {this} A new instance of the same model class with cloned attributes
427
+ *
428
+ * @example
429
+ * // Clone a user model (default - no ID)
430
+ * const user = new User({ id: 1, name: 'John', email: 'john@example.com' });
431
+ * const userCopy = user.clone();
432
+ * console.log(userCopy.isNew()); // true
433
+ * console.log(userCopy.get('id')); // undefined
434
+ *
435
+ * @example
436
+ * // Clone with ID preserved
437
+ * const userCopy = user.clone({ keepId: true });
438
+ * console.log(userCopy.isNew()); // false
439
+ * console.log(userCopy.get('id')); // 1
440
+ *
441
+ * @example
442
+ * // Deep clone for nested objects
443
+ * const user = new User({ id: 1, name: 'John', settings: { theme: 'dark' } });
444
+ * const userCopy = user.clone({ deep: true });
445
+ * userCopy.get('settings').theme = 'light';
446
+ * console.log(user.get('settings').theme); // 'dark' (unchanged)
447
+ */
448
+ clone(options?: CloneOptions): this;
415
449
  /**
416
450
  * Performs HTTP synchronization with the server for CRUD operations.
417
451
  *
418
452
  * This method handles all HTTP communication between the model and the server,
419
453
  * automatically constructing the appropriate URL based on the model's ID and
420
- * endpoint(). It supports all standard REST operations and provides type-safe
454
+ * endpoint. It supports all standard REST operations and provides type-safe
421
455
  * response handling.
422
456
  *
423
457
  * The URL construction follows REST conventions:
424
- * - For new models (no ID): uses collection endpoint `${baseUrl}${endpoint()}`
425
- * - For existing models (with ID): uses resource endpoint `${baseUrl}${endpoint()}/${id}`
458
+ * - For new models (no ID): uses collection endpoint `${baseUrl}${endpoint}`
459
+ * - For existing models (with ID): uses resource endpoint `${baseUrl}${endpoint}/${id}`
426
460
  *
427
461
  * @template R - The expected response type, defaults to T (the model's attribute type)
428
462
  * @param {('GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE')} [method='GET'] - The HTTP method to use (defaults to 'GET')
@@ -518,7 +552,7 @@ export declare abstract class Model<T extends object> {
518
552
  * performing any operation.
519
553
  *
520
554
  * The DELETE request is sent to the model's specific resource endpoint using the
521
- * pattern `${baseUrl}${endpoint()}/${id}`. After successful deletion, the model
555
+ * pattern `${baseUrl}${endpoint}/${id}`. After successful deletion, the model
522
556
  * instance remains in memory but the corresponding server resource is removed.
523
557
  *
524
558
  * @returns {Promise<void>} A promise that resolves when the delete operation completes
@@ -30,9 +30,8 @@ import { escapeHTML } from './utils.js';
30
30
  * }
31
31
  *
32
32
  * class User extends Model<UserAttributes> {
33
- * endpoint() {
34
- * return '/users';
35
- * }
33
+ * endpoint = '/users';
34
+
36
35
  * defaults() {
37
36
  * return { name: '', createdAt: new Date() };
38
37
  * }
@@ -46,6 +45,19 @@ import { escapeHTML } from './utils.js';
46
45
  * await user.destroy(); // Deletes user
47
46
  */
48
47
  export class Model {
48
+ /**
49
+ * Internal reactive storage for all model attributes.
50
+ *
51
+ * This private field uses Svelte's $state rune to create a reactive object that
52
+ * holds all the model's attribute data. When attributes are modified, this reactivity
53
+ * ensures that any Svelte components using the model will automatically re-render
54
+ * to reflect the changes.
55
+ *
56
+ * The attributes are initialized as an empty object cast to type T, and are populated
57
+ * during construction by merging default values with provided data. All attribute
58
+ * access and modification should go through the public API methods (get, set, has, etc.)
59
+ * rather than directly accessing this field.
60
+ */
49
61
  #attributes = $state({});
50
62
  /**
51
63
  * Validation error property that gets set when validation fails.
@@ -56,12 +68,12 @@ export class Model {
56
68
  * Internal hash containing all attributes that have changed since the last set call.
57
69
  * This property tracks which attributes have been modified and their new values.
58
70
  */
59
- #changed = {};
71
+ #changed = $state({});
60
72
  /**
61
73
  * Internal hash containing the previous values of attributes before they were changed.
62
74
  * This property stores the original values of attributes from before the last set call.
63
75
  */
64
- #previous = {};
76
+ #previous = $state({});
65
77
  /**
66
78
  * The name of the attribute that serves as the unique identifier for this model instance.
67
79
  *
@@ -80,9 +92,7 @@ export class Model {
80
92
  * // Custom ID attribute can be specified in constructor options
81
93
  * class User extends Model<UserSchema> {
82
94
  * idAttribute = '_id
83
- * endpoint(): string {
84
- * return '/users';
85
- * }
95
+ * endpoint = '/users';
86
96
  * }
87
97
  * const user = new User({ _id: '507f1f77bcf86cd799439011', name: 'John' });
88
98
  */
@@ -120,35 +130,6 @@ export class Model {
120
130
  get previous() {
121
131
  return this.#previous;
122
132
  }
123
- /**
124
- * Determines if attributes have changed since the last set call.
125
- *
126
- * This method checks whether any attributes were modified in the most recent
127
- * set operation. When called without arguments, it returns true if any attributes
128
- * have changed. When called with a specific attribute key, it returns true only
129
- * if that particular attribute was changed.
130
- *
131
- * @param {keyof T} [attr] - Optional attribute key to check for changes
132
- * @returns {boolean} True if attributes have changed, false otherwise
133
- *
134
- * @example
135
- * // Check if any attributes changed
136
- * const user = new User({ name: 'John', email: 'john@example.com' });
137
- * user.set('name', 'Jane');
138
- * user.hasChanged(); // Returns true
139
- *
140
- * @example
141
- * // Check if a specific attribute changed
142
- * user.set('name', 'Jane');
143
- * user.hasChanged('name'); // Returns true
144
- * user.hasChanged('email'); // Returns false
145
- */
146
- hasChanged(attr) {
147
- if (attr !== undefined) {
148
- return attr in this.#changed;
149
- }
150
- return Object.keys(this.#changed).length > 0;
151
- }
152
133
  /**
153
134
  * Provides default attribute values for new model instances.
154
135
  *
@@ -182,6 +163,35 @@ export class Model {
182
163
  defaults() {
183
164
  return {};
184
165
  }
166
+ /**
167
+ * Determines if attributes have changed since the last set call.
168
+ *
169
+ * This method checks whether any attributes were modified in the most recent
170
+ * set operation. When called without arguments, it returns true if any attributes
171
+ * have changed. When called with a specific attribute key, it returns true only
172
+ * if that particular attribute was changed.
173
+ *
174
+ * @param {keyof T} [attr] - Optional attribute key to check for changes
175
+ * @returns {boolean} True if attributes have changed, false otherwise
176
+ *
177
+ * @example
178
+ * // Check if any attributes changed
179
+ * const user = new User({ name: 'John', email: 'john@example.com' });
180
+ * user.set('name', 'Jane');
181
+ * user.hasChanged(); // Returns true
182
+ *
183
+ * @example
184
+ * // Check if a specific attribute changed
185
+ * user.set('name', 'Jane');
186
+ * user.hasChanged('name'); // Returns true
187
+ * user.hasChanged('email'); // Returns false
188
+ */
189
+ hasChanged(attr) {
190
+ if (attr !== undefined) {
191
+ return attr in this.#changed;
192
+ }
193
+ return Object.keys(this.#changed).length > 0;
194
+ }
185
195
  /**
186
196
  * Retrieves the value of a specific attribute from the model.
187
197
  *
@@ -422,17 +432,64 @@ export class Model {
422
432
  // Default implementation - no validation
423
433
  return undefined;
424
434
  }
435
+ /**
436
+ * Creates a new instance of the model with identical attributes.
437
+ *
438
+ * This method returns a new model instance that is a clone of the current model,
439
+ * with all attributes copied to the new instance. The clone is a separate object
440
+ * with its own state, so modifications to the clone will not affect the original
441
+ * model and vice versa. By default, the ID is removed so the cloned instance is
442
+ * considered "new" (isNew() returns true).
443
+ *
444
+ * @param {CloneOptions} [options] - Configuration options for cloning
445
+ * @param {boolean} [options.keepId=false] - If true, preserves the ID in the clone
446
+ * @param {boolean} [options.deep=false] - If true, performs a deep clone of nested objects/arrays
447
+ * @returns {this} A new instance of the same model class with cloned attributes
448
+ *
449
+ * @example
450
+ * // Clone a user model (default - no ID)
451
+ * const user = new User({ id: 1, name: 'John', email: 'john@example.com' });
452
+ * const userCopy = user.clone();
453
+ * console.log(userCopy.isNew()); // true
454
+ * console.log(userCopy.get('id')); // undefined
455
+ *
456
+ * @example
457
+ * // Clone with ID preserved
458
+ * const userCopy = user.clone({ keepId: true });
459
+ * console.log(userCopy.isNew()); // false
460
+ * console.log(userCopy.get('id')); // 1
461
+ *
462
+ * @example
463
+ * // Deep clone for nested objects
464
+ * const user = new User({ id: 1, name: 'John', settings: { theme: 'dark' } });
465
+ * const userCopy = user.clone({ deep: true });
466
+ * userCopy.get('settings').theme = 'light';
467
+ * console.log(user.get('settings').theme); // 'dark' (unchanged)
468
+ */
469
+ clone(options) {
470
+ const Constructor = this.constructor;
471
+ let attrs = this.toJSON();
472
+ // Remove ID unless keepId is true
473
+ if (!options?.keepId) {
474
+ delete attrs[this.idAttribute];
475
+ }
476
+ // Perform deep clone if requested
477
+ if (options?.deep) {
478
+ attrs = structuredClone(attrs);
479
+ }
480
+ return new Constructor(attrs);
481
+ }
425
482
  /**
426
483
  * Performs HTTP synchronization with the server for CRUD operations.
427
484
  *
428
485
  * This method handles all HTTP communication between the model and the server,
429
486
  * automatically constructing the appropriate URL based on the model's ID and
430
- * endpoint(). It supports all standard REST operations and provides type-safe
487
+ * endpoint. It supports all standard REST operations and provides type-safe
431
488
  * response handling.
432
489
  *
433
490
  * The URL construction follows REST conventions:
434
- * - For new models (no ID): uses collection endpoint `${baseUrl}${endpoint()}`
435
- * - For existing models (with ID): uses resource endpoint `${baseUrl}${endpoint()}/${id}`
491
+ * - For new models (no ID): uses collection endpoint `${baseUrl}${endpoint}`
492
+ * - For existing models (with ID): uses resource endpoint `${baseUrl}${endpoint}/${id}`
436
493
  *
437
494
  * @template R - The expected response type, defaults to T (the model's attribute type)
438
495
  * @param {('GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE')} [method='GET'] - The HTTP method to use (defaults to 'GET')
@@ -455,7 +512,7 @@ export class Model {
455
512
  */
456
513
  async sync(method = 'GET', body, options = {}) {
457
514
  const id = this.#getId();
458
- const endpoint = options?.endpoint?.length ? options.endpoint : this.endpoint();
515
+ const endpoint = options?.endpoint?.length ? options.endpoint : this.endpoint;
459
516
  const fullUrl = `${vellumConfig.origin}${endpoint}`;
460
517
  const url = id ? `${fullUrl}/${id}` : fullUrl;
461
518
  const fetchOpts = {
@@ -569,7 +626,7 @@ export class Model {
569
626
  * performing any operation.
570
627
  *
571
628
  * The DELETE request is sent to the model's specific resource endpoint using the
572
- * pattern `${baseUrl}${endpoint()}/${id}`. After successful deletion, the model
629
+ * pattern `${baseUrl}${endpoint}/${id}`. After successful deletion, the model
573
630
  * instance remains in memory but the corresponding server resource is removed.
574
631
  *
575
632
  * @returns {Promise<void>} A promise that resolves when the delete operation completes
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  export { vellumConfig, configureVellum } from './config.svelte.js';
2
2
  export { type ValidationDetails, ValidationError } from './errors/validation_error.js';
3
3
  export { type SyncOptions, type ValidationOptions, Model } from './Model.svelte.js';
4
- export { type FetchOptions, Collection } from './Collection.svelte.js';
4
+ export { type CollectionOptions, type FetchOptions, Collection } from './Collection.svelte.js';
5
5
  export * as utils from './utils.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jwerre/vellum",
3
- "version": "1.3.0-next.1",
3
+ "version": "1.3.0-next.3",
4
4
  "description": "Structural state management library for Svelte 5",
5
5
  "repository": {
6
6
  "type": "git",