@jwerre/vellum 1.0.1 → 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.
@@ -15,7 +15,6 @@ export interface FetchOptions extends Partial<VellumConfig> {
15
15
  * @template T - The data object type that the models represent
16
16
  *
17
17
  * @example
18
- * ```typescript
19
18
  * class UserCollection extends Collection<UserModel, User> {
20
19
  * model = UserModel;
21
20
  * endpoint = () => '/api/users';
@@ -24,7 +23,6 @@ export interface FetchOptions extends Partial<VellumConfig> {
24
23
  * const users = new UserCollection();
25
24
  * await users.fetch(); // Loads users from API
26
25
  * users.add({ name: 'John', email: 'john@example.com' }); // Adds new user
27
- * ```
28
26
  */
29
27
  export declare abstract class Collection<M extends Model<T>, T extends object> {
30
28
  /** Reactive array of model instances in the collection */
@@ -41,7 +39,6 @@ export declare abstract class Collection<M extends Model<T>, T extends object> {
41
39
  * @param models - Optional array of data objects to initialize the collection with
42
40
  *
43
41
  * @example
44
- * ```typescript
45
42
  * // Create empty collection
46
43
  * const collection = new UserCollection();
47
44
  *
@@ -50,7 +47,6 @@ export declare abstract class Collection<M extends Model<T>, T extends object> {
50
47
  * { id: 1, name: 'John' },
51
48
  * { id: 2, name: 'Jane' }
52
49
  * ]);
53
- * ```
54
50
  */
55
51
  constructor(models?: T[]);
56
52
  /** Gets the number of items in the collection */
@@ -62,14 +58,12 @@ export declare abstract class Collection<M extends Model<T>, T extends object> {
62
58
  * @returns The model instance that was added to the collection
63
59
  *
64
60
  * @example
65
- * ```typescript
66
61
  * // Add raw data
67
62
  * const user = collection.add({ name: 'John', email: 'john@example.com' });
68
63
  *
69
64
  * // Add existing model instance
70
65
  * const existingUser = new UserModel({ name: 'Jane' });
71
66
  * collection.add(existingUser);
72
- * ```
73
67
  */
74
68
  add(data: T | M): M;
75
69
  /**
@@ -78,13 +72,11 @@ export declare abstract class Collection<M extends Model<T>, T extends object> {
78
72
  * @param data - An array of raw data objects to populate the collection with
79
73
  *
80
74
  * @example
81
- * ```typescript
82
75
  * // Reset collection with new user data
83
76
  * collection.reset([
84
77
  * { id: 1, name: 'John', email: 'john@example.com' },
85
78
  * { id: 2, name: 'Jane', email: 'jane@example.com' }
86
79
  * ]);
87
- * ```
88
80
  */
89
81
  reset(data: T[]): void;
90
82
  /**
@@ -95,13 +87,11 @@ export declare abstract class Collection<M extends Model<T>, T extends object> {
95
87
  * @returns The first matching item, or undefined if no match is found.
96
88
  *
97
89
  * @example
98
- * ```typescript
99
90
  * // Find a user by ID
100
91
  * const user = collection.find({ id: 123 });
101
92
  *
102
93
  * // Find by multiple properties
103
94
  * const activeAdmin = collection.find({ role: 'admin', status: 'active' });
104
- * ```
105
95
  */
106
96
  find(query: Partial<T>): M | undefined;
107
97
  /**
@@ -115,7 +105,6 @@ export declare abstract class Collection<M extends Model<T>, T extends object> {
115
105
  * @throws {Error} Throws an error if the HTTP request fails or returns a non-ok status
116
106
  *
117
107
  * @example
118
- * ```typescript
119
108
  * // Fetch all items
120
109
  * await collection.fetch();
121
110
  *
@@ -123,7 +112,6 @@ export declare abstract class Collection<M extends Model<T>, T extends object> {
123
112
  * await collection.fetch({
124
113
  * search: { limit: 30, after: 29 }
125
114
  * });
126
- * ```
127
115
  */
128
116
  fetch(options?: FetchOptions): Promise<void>;
129
117
  }
@@ -12,7 +12,6 @@ import { vellumConfig } from './config.svelte';
12
12
  * @template T - The data object type that the models represent
13
13
  *
14
14
  * @example
15
- * ```typescript
16
15
  * class UserCollection extends Collection<UserModel, User> {
17
16
  * model = UserModel;
18
17
  * endpoint = () => '/api/users';
@@ -21,7 +20,6 @@ import { vellumConfig } from './config.svelte';
21
20
  * const users = new UserCollection();
22
21
  * await users.fetch(); // Loads users from API
23
22
  * users.add({ name: 'John', email: 'john@example.com' }); // Adds new user
24
- * ```
25
23
  */
26
24
  export class Collection {
27
25
  /** Reactive array of model instances in the collection */
@@ -32,7 +30,6 @@ export class Collection {
32
30
  * @param models - Optional array of data objects to initialize the collection with
33
31
  *
34
32
  * @example
35
- * ```typescript
36
33
  * // Create empty collection
37
34
  * const collection = new UserCollection();
38
35
  *
@@ -41,7 +38,6 @@ export class Collection {
41
38
  * { id: 1, name: 'John' },
42
39
  * { id: 2, name: 'Jane' }
43
40
  * ]);
44
- * ```
45
41
  */
46
42
  constructor(models = []) {
47
43
  if (models.length > 0) {
@@ -59,14 +55,12 @@ export class Collection {
59
55
  * @returns The model instance that was added to the collection
60
56
  *
61
57
  * @example
62
- * ```typescript
63
58
  * // Add raw data
64
59
  * const user = collection.add({ name: 'John', email: 'john@example.com' });
65
60
  *
66
61
  * // Add existing model instance
67
62
  * const existingUser = new UserModel({ name: 'Jane' });
68
63
  * collection.add(existingUser);
69
- * ```
70
64
  */
71
65
  add(data) {
72
66
  const instance = data instanceof Model ? data : new this.model(data);
@@ -79,13 +73,11 @@ export class Collection {
79
73
  * @param data - An array of raw data objects to populate the collection with
80
74
  *
81
75
  * @example
82
- * ```typescript
83
76
  * // Reset collection with new user data
84
77
  * collection.reset([
85
78
  * { id: 1, name: 'John', email: 'john@example.com' },
86
79
  * { id: 2, name: 'Jane', email: 'jane@example.com' }
87
80
  * ]);
88
- * ```
89
81
  */
90
82
  reset(data) {
91
83
  this.items = data.map((attrs) => new this.model(attrs));
@@ -98,13 +90,11 @@ export class Collection {
98
90
  * @returns The first matching item, or undefined if no match is found.
99
91
  *
100
92
  * @example
101
- * ```typescript
102
93
  * // Find a user by ID
103
94
  * const user = collection.find({ id: 123 });
104
95
  *
105
96
  * // Find by multiple properties
106
97
  * const activeAdmin = collection.find({ role: 'admin', status: 'active' });
107
- * ```
108
98
  */
109
99
  find(query) {
110
100
  return this.items.find((item) => {
@@ -124,7 +114,6 @@ export class Collection {
124
114
  * @throws {Error} Throws an error if the HTTP request fails or returns a non-ok status
125
115
  *
126
116
  * @example
127
- * ```typescript
128
117
  * // Fetch all items
129
118
  * await collection.fetch();
130
119
  *
@@ -132,7 +121,6 @@ export class Collection {
132
121
  * await collection.fetch({
133
122
  * search: { limit: 30, after: 29 }
134
123
  * });
135
- * ```
136
124
  */
137
125
  async fetch(options = {}) {
138
126
  let query = '';
@@ -1,7 +1,57 @@
1
1
  import { type VellumConfig } from './config.svelte';
2
+ import { ValidationError } from './errors/validation_error.js';
2
3
  export interface SyncOptions extends Partial<VellumConfig> {
3
4
  endpoint?: string;
4
5
  }
6
+ export interface ValidationOptions {
7
+ validate?: boolean;
8
+ silent?: boolean;
9
+ [key: string]: unknown;
10
+ }
11
+ /**
12
+ * Abstract base class for creating model instances that interact with RESTful APIs.
13
+ *
14
+ * The Model class provides a structured way to manage data objects with full CRUD
15
+ * (Create, Read, Update, Delete) capabilities. It includes built-in HTTP synchronization,
16
+ * attribute management, and data validation features. This class is designed to work
17
+ * with Svelte's reactivity system using the `$state` rune for automatic UI updates.
18
+ *
19
+ * Key features:
20
+ * - Type-safe attribute access and manipulation
21
+ * - Automatic HTTP synchronization with RESTful APIs
22
+ * - Built-in HTML escaping for XSS prevention
23
+ * - Configurable ID attributes for different database schemas
24
+ * - Reactive attributes that integrate with Svelte's state management
25
+ * - Support for both single attribute and bulk attribute operations
26
+ *
27
+ * @template T - The type definition for the model's attributes, must extend object
28
+ * @abstract This class must be extended by concrete model implementations
29
+ *
30
+ * @example
31
+ * // Define a User model
32
+ * interface UserAttributes {
33
+ * id?: number;
34
+ * name: string;
35
+ * email: string;
36
+ * createdAt?: Date;
37
+ * }
38
+ *
39
+ * class User extends Model<UserAttributes> {
40
+ * endpoint() {
41
+ * return '/users';
42
+ * }
43
+ * defaults() {
44
+ * return { name: '', createdAt: new Date() };
45
+ * }
46
+ * }
47
+ *
48
+ * // Create and use a model instance
49
+ * const user = new User({ name: 'John Doe', email: 'john@example.com' });
50
+ * await user.save(); // Creates new user on server
51
+ * user.set('name', 'Jane Doe');
52
+ * await user.save(); // Updates existing user
53
+ * await user.destroy(); // Deletes user
54
+ */
5
55
  export declare abstract class Model<T extends object> {
6
56
  #private;
7
57
  /**
@@ -20,8 +70,73 @@ export declare abstract class Model<T extends object> {
20
70
  * return '/users';
21
71
  * }
22
72
  */
23
- abstract endpoint(): string;
73
+ protected abstract endpoint(): string;
74
+ /**
75
+ * The name of the attribute that serves as the unique identifier for this model instance.
76
+ *
77
+ * This private field stores the attribute name that will be used to identify the model's
78
+ * primary key when performing operations like determining if the model is new, constructing
79
+ * URLs for API requests, and managing model identity. The default value is 'id', but it
80
+ * can be customized through the ModelOptions parameter in the constructor.
81
+ *
82
+ * @protected
83
+ * @type {string}
84
+ * @default 'id'
85
+ * @example
86
+ * // Default behavior uses 'id' as the identifier
87
+ * const user = new User({ id: 1, name: 'John' });
88
+ *
89
+ * // Custom ID attribute can be specified in constructor options
90
+ * class User extends Model<UserSchema> {
91
+ * idAttribute = '_id
92
+ * endpoint(): string {
93
+ * return '/users';
94
+ * }
95
+ * }
96
+ * const user = new User({ _id: '507f1f77bcf86cd799439011', name: 'John' });
97
+ */
98
+ protected idAttribute: string;
99
+ /**
100
+ * Creates a new instance of Model.
101
+ */
24
102
  constructor(data?: Partial<T>);
103
+ /**
104
+ * Gets the latest validation error.
105
+ *
106
+ * @returns {ValidationError || undefined} An instance of ValidationError if validation failed, otherwise undefined
107
+ */
108
+ get validationError(): ValidationError | undefined;
109
+ /**
110
+ * Provides default attribute values for new model instances.
111
+ *
112
+ * This method is called during model construction to establish initial attribute
113
+ * values before applying any user-provided data. Subclasses can override this
114
+ * method to define default values for their specific attributes, ensuring that
115
+ * models always have sensible initial state.
116
+ *
117
+ * The defaults are applied first, then any data passed to the constructor will
118
+ * override these default values. This allows for flexible model initialization
119
+ * where some attributes have fallback values while others can be explicitly set.
120
+ *
121
+ * @protected
122
+ * @returns {Partial<T>} A partial object containing default attribute values
123
+ *
124
+ * @example
125
+ * // Override in a User model subclass
126
+ * protected defaults(): Partial<UserAttributes> {
127
+ * return {
128
+ * role: 'user',
129
+ * isActive: true,
130
+ * createdAt: new Date()
131
+ * };
132
+ * }
133
+ *
134
+ * @example
135
+ * // Creating a model with defaults
136
+ * const user = new User({ name: 'John' });
137
+ * // Resulting attributes: { role: 'user', isActive: true, createdAt: Date, name: 'John' }
138
+ */
139
+ protected defaults(): Partial<T>;
25
140
  /**
26
141
  * Retrieves the value of a specific attribute from the model.
27
142
  *
@@ -47,21 +162,148 @@ export declare abstract class Model<T extends object> {
47
162
  * shallow merge, meaning that only the top-level properties specified in the attrs
48
163
  * parameter will be updated, while other existing attributes remain unchanged.
49
164
  *
165
+ * The method supports optional validation through the ValidationOptions parameter.
166
+ * If validation is enabled and fails, the attributes will not be updated and the
167
+ * method will return false. The validationError property will be set with details
168
+ * about the validation failure.
169
+ *
170
+ * @overload
171
+ * @param {K} key - The attribute key to set
172
+ * @param {T[K]} value - The value to set for the specified key
173
+ * @param {ValidationOptions} [options] - Optional validation and behavior settings
174
+ * @param {boolean} [options.validate] - If true, validates attributes before setting them
175
+ * @param {boolean} [options.silent] - If true, suppresses validation error setting
176
+ * @returns {boolean} True if attributes were set successfully, false if validation failed
177
+ *
178
+ * @overload
50
179
  * @param {Partial<T>} attrs - A partial object containing the attributes to update
180
+ * @param {ValidationOptions} [options] - Optional validation and behavior settings
181
+ * @param {boolean} [options.validate] - If true, validates attributes before setting them
182
+ * @param {boolean} [options.silent] - If true, suppresses validation error setting
183
+ * @returns {boolean} True if attributes were set successfully, false if validation failed
184
+ *
185
+ * @example
186
+ * // Set a single attribute
187
+ * user.set('name', 'Jane');
188
+ *
189
+ * // Set multiple attributes
190
+ * user.set({ name: 'Jane', email: 'jane@example.com' });
191
+ *
192
+ * // Set with validation enabled
193
+ * const success = user.set({ email: 'invalid-email' }, { validate: true });
194
+ * if (!success) {
195
+ * console.log('Validation failed:', user.validationError);
196
+ * }
197
+ *
198
+ * // Set attributes silently without validation errors
199
+ * user.set({ name: '' }, { validate: true, silent: true });
200
+ */
201
+ set<K extends keyof T>(key: K, value: T[K], options?: ValidationOptions): boolean;
202
+ set(attrs: Partial<T>, options?: ValidationOptions): boolean;
203
+ /**
204
+ * Checks whether a specific attribute has a non-null, non-undefined value.
205
+ *
206
+ * This method provides a way to determine if an attribute exists and has a
207
+ * meaningful value. It returns true if the attribute is set to any value
208
+ * other than null or undefined, including falsy values like false, 0, or
209
+ * empty strings, which are considered valid values.
210
+ *
211
+ * @template K - The key type, constrained to keys of T
212
+ * @param {K} key - The attribute key to check
213
+ * @returns {boolean} True if the attribute has a non-null, non-undefined value
214
+ * @example
215
+ * // Assuming a User model with attributes { id: number, name: string, email?: string }
216
+ * const user = new User({ id: 1, name: 'John', email: null });
217
+ *
218
+ * user.has('id'); // Returns true (value is 1)
219
+ * user.has('name'); // Returns true (value is 'John')
220
+ * user.has('email'); // Returns false (value is null)
221
+ *
222
+ * // Even falsy values are considered "set"
223
+ * user.set({ name: '' });
224
+ * user.has('name'); // Returns true (empty string is a valid value)
225
+ */
226
+ has<K extends keyof T>(key: K): boolean;
227
+ /**
228
+ * Removes a specific attribute from the model by deleting it from the internal attributes hash.
229
+ *
230
+ * This method permanently removes an attribute from the model instance. Once unset,
231
+ * the attribute will no longer exist on the model and subsequent calls to get() for
232
+ * that key will return undefined. This is different from setting an attribute to
233
+ * null or undefined, as the property is completely removed from the attributes object.
234
+ *
235
+ * @template K - The key type, constrained to keys of T
236
+ * @param {K} key - The attribute key to remove from the model
51
237
  * @returns {void}
52
238
  * @example
53
239
  * // Assuming a User model with attributes { id: number, name: string, email: string }
54
240
  * const user = new User({ id: 1, name: 'John', email: 'john@example.com' });
55
241
  *
56
- * // Update multiple attributes at once
242
+ * user.has('email'); // Returns true
243
+ * user.unset('email'); // Remove the email attribute
244
+ * user.has('email'); // Returns false
245
+ * user.get('email'); // Returns undefined
246
+ *
247
+ * // The attribute is completely removed, not just set to undefined
248
+ * const userData = user.toJSON(); // { id: 1, name: 'John' }
249
+ */
250
+ unset<K extends keyof T>(key: K): void;
251
+ /**
252
+ * Removes all attributes from the model, including the id attribute.
253
+ *
254
+ * This method completely clears the model instance by removing all stored attributes,
255
+ * effectively resetting it to an empty state. After calling clear(), the model will
256
+ * behave as if it were newly instantiated with no data. This includes removing the
257
+ * ID attribute, which means the model will be considered "new" after clearing.
258
+ *
259
+ * This is useful when you want to reuse a model instance for different data or
260
+ * reset a model to its initial state without creating a new instance.
261
+ *
262
+ * @returns {void}
263
+ * @example
264
+ * // Clear all data from a user model
265
+ * const user = new User({ id: 1, name: 'John', email: 'john@example.com' });
266
+ * user.clear();
267
+ *
268
+ * user.has('id'); // Returns false
269
+ * user.has('name'); // Returns false
270
+ * user.isNew(); // Returns true
271
+ * user.toJSON(); // Returns {}
272
+ *
273
+ * // Model can be reused with new data
57
274
  * user.set({ name: 'Jane', email: 'jane@example.com' });
58
- * // Now user has { id: 1, name: 'Jane', email: 'jane@example.com' }
275
+ */
276
+ clear(): void;
277
+ /**
278
+ * Retrieves and escapes the HTML content of a specific attribute from the model.
279
+ *
280
+ * This method provides a safe way to access model attributes that may contain
281
+ * user-generated content or data that will be rendered in HTML contexts. It
282
+ * automatically applies HTML escaping to prevent XSS attacks and ensure safe
283
+ * rendering of potentially dangerous content.
284
+ *
285
+ * The method uses the escapeHTML utility function to convert special HTML
286
+ * characters (such as <, >, &, ", and ') into their corresponding HTML entities,
287
+ * making the content safe for direct insertion into HTML templates.
59
288
  *
60
- * // Update a single attribute
61
- * user.set({ name: 'Bob' });
62
- * // Now user has { id: 1, name: 'Bob', email: 'jane@example.com' }
289
+ * @template K - The key type, constrained to keys of T
290
+ * @param {K} key - The attribute key to retrieve and escape the value for
291
+ * @returns {T[K]} The HTML-escaped value associated with the specified key
292
+ * @example
293
+ * // Assuming a Post model with attributes { id: number, title: string, content: string }
294
+ * const post = new Post({
295
+ * id: 1,
296
+ * title: 'Hello <script>alert("XSS")</script>',
297
+ * content: 'This is "safe" & secure content'
298
+ * });
299
+ *
300
+ * const safeTitle = post.escape('title');
301
+ * // Returns: 'Hello &lt;script&gt;alert(&quot;XSS&quot;)&lt;/script&gt;'
302
+ *
303
+ * const safeContent = post.escape('content');
304
+ * // Returns: 'This is &quot;safe&quot; &amp; secure content'
63
305
  */
64
- set(attrs: Partial<T>): void;
306
+ escape<K extends keyof T>(key: K): string;
65
307
  /**
66
308
  * Determines whether this model instance is new (not yet persisted).
67
309
  * A model is considered new if it doesn't have an 'id' or '_id' attribute.
@@ -69,6 +311,69 @@ export declare abstract class Model<T extends object> {
69
311
  * @returns {boolean} true if the model is new, false if it has been persisted
70
312
  */
71
313
  isNew(): boolean;
314
+ /**
315
+ * Validates the current model instance and returns whether it passes validation.
316
+ *
317
+ * This method performs validation on the model's current attributes using the
318
+ * validate() method defined by the subclass. It's a convenience method that
319
+ * allows you to check if a model is valid without having to manually call
320
+ * the validation logic.
321
+ *
322
+ * If validation fails, the validationError property will be set with details
323
+ * about what went wrong. If validation passes, validationError will be cleared.
324
+ *
325
+ * @param {ValidationOptions} [options] - Optional validation configuration
326
+ * @param {boolean} [options.silent] - If true, suppresses validation error setting
327
+ * @returns {boolean} True if the model passes validation, false otherwise
328
+ *
329
+ * @example
330
+ * // Check if a user model is valid
331
+ * const user = new User({ name: '', email: 'invalid-email' });
332
+ *
333
+ * if (!user.isValid()) {
334
+ * console.log('Validation failed:', user.validationError);
335
+ * // Handle validation errors
336
+ * } else {
337
+ * // Proceed with saving or other operations
338
+ * await user.save();
339
+ * }
340
+ *
341
+ * @example
342
+ * // Validate silently without setting validationError
343
+ * const isValid = user.isValid({ silent: true });
344
+ * // user.validationError remains unchanged
345
+ */
346
+ isValid(options?: ValidationOptions): boolean;
347
+ /**
348
+ * Validates the model's attributes using custom validation logic.
349
+ *
350
+ * This method is intended to be overridden by subclasses to implement custom
351
+ * validation rules. By default, it returns undefined (no validation errors).
352
+ * If validation fails, this method should return an error - either a simple
353
+ * string message or a complete error object.
354
+ *
355
+ * The validate method is automatically called by save() before persisting data,
356
+ * and can also be explicitly called by set() when the {validate: true} option
357
+ * is passed. If validation fails, the save operation is aborted and model
358
+ * attributes are not modified.
359
+ *
360
+ * @param {Partial<T>} attributes - The attributes to validate
361
+ * @param {any} [options] - Additional options passed from set() or save()
362
+ * @returns {any} Returns undefined if valid, or an error (string/object) if invalid
363
+ *
364
+ * @example
365
+ * // Override in a User model subclass
366
+ * validate(attributes: Partial<UserAttributes>) {
367
+ * if (!attributes.email) {
368
+ * return 'Email is required';
369
+ * }
370
+ * if (!attributes.email.includes('@')) {
371
+ * return { email: 'Invalid email format' };
372
+ * }
373
+ * // Return undefined if validation passes
374
+ * }
375
+ */
376
+ validate(attributes: Partial<T>, options?: ValidationOptions): string | undefined;
72
377
  /**
73
378
  * Performs HTTP synchronization with the server for CRUD operations.
74
379
  *
@@ -138,7 +443,13 @@ export declare abstract class Model<T extends object> {
138
443
  * generates additional fields (like timestamps, computed values, or normalized data)
139
444
  * during the save process.
140
445
  *
141
- * @returns {Promise<void>} A promise that resolves when the save operation completes
446
+ * @param {ValidationOptions & SyncOptions} [options] - Optional configuration for save behavior
447
+ * @param {boolean} [options.validate] - Whether to validate before saving (defaults to true)
448
+ * @param {boolean} [options.silent] - If true, suppresses validation error setting
449
+ * @param {string} [options.endpoint] - Override the default endpoint for this save operation
450
+ * @param {Record<string, string>} [options.headers] - Additional headers to include in the request
451
+ * @param {string} [options.origin] - Override the base URL origin for this request
452
+ * @returns {Promise<boolean>} A promise that resolves to true if save succeeds, false if validation fails
142
453
  * @throws {Error} Throws an error if the HTTP request fails or server returns an error
143
454
  *
144
455
  * @example
@@ -149,8 +460,17 @@ export declare abstract class Model<T extends object> {
149
460
  * // Update an existing user
150
461
  * existingUser.set({ name: 'Jane' });
151
462
  * await existingUser.save(); // PUT request with updated data
463
+ *
464
+ * // Save without validation
465
+ * await user.save({ validate: false });
466
+ *
467
+ * // Save with custom endpoint and headers
468
+ * await user.save({
469
+ * endpoint: '/api/v2/users',
470
+ * headers: { 'Custom-Header': 'value' }
471
+ * });
152
472
  */
153
- save(): Promise<void>;
473
+ save(options?: ValidationOptions & SyncOptions): Promise<boolean>;
154
474
  /**
155
475
  * Deletes the model from the server.
156
476
  *