@hotfusion/modeller 0.0.2 → 0.0.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,29 +1,26 @@
1
1
  # @hotfusion/modeller
2
2
 
3
- A schema-first, in-memory data layer for Node.js with hooks, workers, methods, uploads, streams, and event subscriptions. Built around AJV validation, mingo queries, and a pluggable `Adapter` for persistence.
3
+ A schema-first data layer for Node.js, built as a three-layer chain:
4
4
 
5
- ```ts
6
- import { Model, Adapter } from '@hotfusion/modeller';
5
+ ```
6
+ ┌──────────────┐ data primitives — schema, validation, CRUD,
7
+ │ core.ts │ indexes, persistence adapter, trash
8
+ └──────┬───────┘
9
+ │ extends
10
+ ┌──────▼───────┐ behavior — hooks, workers, methods, uploads,
11
+ │ model.ts │ streams, events, subscriptions, logging
12
+ └──────┬───────┘
13
+ │ consumed by
14
+ ┌──────▼───────┐ transport — HTTP + WebSocket exposure of any model
15
+ │ server.ts │ (auth, sessions, socket fan-out)
16
+ └──────────────┘
7
17
  ```
8
18
 
9
- ---
10
-
11
- ## Table of Contents
19
+ Each layer is usable on its own. Pick the layer that matches the problem you're solving.
12
20
 
13
- - [Quick Start](#quick-start)
14
- - [Schema](#schema)
15
- - [CRUD Methods](#crud-methods)
16
- - [Hooks](#hooks)
17
- - [Workers](#workers)
18
- - [Methods](#methods)
19
- - [Uploads](#uploads)
20
- - [Streams](#streams)
21
- - [Events & Subscriptions](#events--subscriptions)
22
- - [Internal Event Emitter](#internal-event-emitter)
23
- - [Extensions](#extensions)
24
- - [Adapter (Persistence)](#adapter-persistence)
25
- - [Logging](#logging)
26
- - [Trash](#trash)
21
+ ```ts
22
+ import { Adapter, Model, Server, Bundler, KeyGen } from '@hotfusion/modeller';
23
+ ```
27
24
 
28
25
  ---
29
26
 
@@ -43,451 +40,32 @@ const users = new Model('users', {
43
40
  }
44
41
  });
45
42
 
46
- await users.insert({ email: 'alex@hotfusion.ca', name: 'Alex' });
47
- const found = await users.get({ email: 'alex@hotfusion.ca' });
48
- const list = await users.list({}, { start: 0, count: 50 });
49
- ```
50
-
51
- ---
52
-
53
- ## Schema
54
-
55
- Schemas are JSON Schema (AJV-compatible) with a few Modeller-specific keywords:
56
-
57
- | Keyword | Effect |
58
- |-------------|-------------------------------------------------------------------------|
59
- | `unique` | Enforces uniqueness across all documents on `insert` / `update`. |
60
- | `private` | Field is stripped from read responses unless `{ private: true }` is set.|
61
- | `index` | Builds an in-memory index on the field for fast lookups. |
62
- | `protected` | Reserved for transport-layer protection (consumed by `Server`). |
63
- | `hidden` | Hint for client-side rendering. Not enforced by the core. |
64
- | `default` | Standard JSON Schema default. |
65
- | `model` | (async) References another model by id for FK-style validation. |
66
-
67
- `$ref` is resolved against `$defs` / `definitions`, and against any `schemes` passed to the constructor. A `$ref` of the form `someModel?key=_id&label=name` is rewritten into a model reference with display metadata.
68
-
69
- ---
70
-
71
- ## CRUD Methods
72
-
73
- All CRUD methods are async, fire `before:*` / `after:*` hooks, validate against the schema, and emit an event on the internal emitter.
74
-
75
- ### `insert(key?, ...patches, opts?)`
76
-
77
- Merges `key` and any number of patch objects into a single document, validates it, assigns an `_id` (BSON `ObjectId`), and stores it.
78
-
79
- ```ts
80
- await users.insert({ email: 'alex@hotfusion.ca' }, { name: 'Alex' });
81
- await users.insert({ email: 'admin@hotfusion.ca' }, { role: 'admin' }, { private: true });
82
- ```
83
-
84
- - Returns the inserted document. `private: true` includes `private` fields in the response.
85
- - Throws `{ code: 'VALIDATION_ERROR', errors }` or `{ code: 'UNIQUE_VIOLATION', field }`.
86
-
87
- ### `update(key, updates, opts?)`
88
-
89
- Finds a document by `key` (mingo query), shallow-merges `updates`, re-validates, and saves.
90
-
91
- ```ts
92
- await users.update({ _id: id }, { name: 'Alex H.' });
93
- ```
94
-
95
- Returns the merged document, or `null` if no match. Same error codes as `insert`.
96
-
97
- ### `delete(key)`
98
-
99
- Removes the document matching `key`. If `trash` is not disabled and an adapter is configured, the document is moved to the in-memory trash bin.
100
-
101
- ```ts
102
- await users.delete({ _id: id }); // → { _id } or null
103
- ```
104
-
105
- ### `get(key, opts?)`
106
-
107
- Returns a single document or `null`. Pass `{ private: true }` to include private fields.
108
-
109
- ```ts
110
- await users.get({ email: 'alex@hotfusion.ca' });
111
- ```
112
-
113
- ### `list(query?, opts?)`
114
-
115
- Paginated, **exact-match** query (mingo).
116
-
117
- ```ts
118
- await users.list({ role: 'admin' }, { start: 0, count: 25 });
119
- ```
120
-
121
- Default `count` is 10. Fires `before:list` and `after:list` with the query as payload `data`.
122
-
123
- ### `find(query?, opts?)`
124
-
125
- Like `list`, but **strings match as case-insensitive substrings**. Non-string values match exactly.
126
-
127
- ```ts
128
- await users.find({ name: 'al' }, { max: 10 });
129
- // matches 'Alex', 'Albert', ...
130
- ```
131
-
132
- Default `max` is 10. Does **not** fire hooks.
133
-
134
- ---
135
-
136
- ## Hooks
137
-
138
- Hooks run before or after a CRUD operation. They can short-circuit an operation by throwing, mutate the payload, or perform side effects. Hooks support both synchronous and asynchronous callbacks.
139
-
140
- ### Registering
141
-
142
- ```ts
143
- users.hook({
144
- id : 'normalize-email',
145
- on : 'before:insert',
146
- callback : async ({ data }) => {
147
- if (data?.email) data.email = data.email.toLowerCase();
148
- }
149
- });
150
- ```
151
-
152
- Multiple events can share a callback:
153
-
154
- ```ts
155
- users.hook({
156
- id : 'audit',
157
- on : ['after:insert', 'after:update', 'after:delete'],
158
- callback : ({ operation, key, data }) => audit.log(operation, key, data)
159
- });
160
- ```
161
-
162
- ### Removing
163
-
164
- ```ts
165
- users.unhook('normalize-email');
166
- ```
167
-
168
- ### Hook events
169
-
170
- | Event | Payload fields |
171
- |-------------------|-----------------------------------------------|
172
- | `before:insert` | `operation`, `timing`, `data` |
173
- | `after:insert` | `operation`, `timing`, `data`, `key: { _id }` |
174
- | `before:update` | `operation`, `timing`, `key`, `data` |
175
- | `after:update` | `operation`, `timing`, `key`, `data` |
176
- | `before:delete` | `operation`, `timing`, `key` |
177
- | `after:delete` | `operation`, `timing`, `key`, `data` |
178
- | `before:list` | `operation`, `timing`, `data` (= query) |
179
- | `after:list` | `operation`, `timing`, `data` (= query) |
180
-
181
- Note: `find` does not fire hooks; `get` does not fire hooks.
182
-
183
- ### Scheduled hooks
184
-
185
- Pass `schedule: <ms>` to defer execution via `setTimeout`. Scheduled hooks are fire-and-forget — they don't block the operation and their errors aren't propagated.
186
-
187
- ```ts
188
43
  users.hook({
189
- id : 'welcome-email',
190
- on : 'after:insert',
191
- schedule : 5_000,
192
- callback : ({ data }) => mailer.send(data.email, 'welcome')
193
- });
194
- ```
195
-
196
- ### Throwing from a hook
197
-
198
- A non-scheduled hook that throws aborts the operation. The error is logged and re-thrown to the caller.
199
-
200
- ---
201
-
202
- ## Workers
203
-
204
- Workers are interval-based background tasks bound to the model. They can be named (managed individually) or unsigned (managed in bulk).
205
-
206
- ### Registering
207
-
208
- ```ts
209
- users.worker({
210
- id : 'sync',
211
- interval : 30_000,
212
- immediate : true, // run once on start, default true
213
- handler : async (model) => {
214
- await model.pull();
215
- }
216
- });
217
- ```
218
-
219
- Omit `id` for an unsigned worker:
220
-
221
- ```ts
222
- users.worker({ interval: 60_000, handler: () => cleanup() });
223
- ```
224
-
225
- ### Lifecycle
226
-
227
- ```ts
228
- users.start(); // start all workers (named + unsigned)
229
- users.start('sync'); // start a specific named worker
230
- users.stop(); // stop all
231
- users.stop('sync'); // stop a specific named worker
232
- users.isRunning('sync'); // → boolean
233
- ```
234
-
235
- Errors thrown inside a worker handler are caught and logged at `error` level — they never crash the interval.
236
-
237
- ---
238
-
239
- ## Methods
240
-
241
- Methods are named, schemaless RPC-style operations attached to the model. Use them for anything that doesn't fit the CRUD shape.
242
-
243
- ```ts
244
- users.method({
245
- id : 'resetPassword',
246
- handler : async ({ email, newPassword }, model, ctx) => {
247
- const user = await model.get({ email });
248
- if (!user) throw { code: 'NOT_FOUND' };
249
- return model.update({ _id: user._id }, { password: newPassword });
250
- }
44
+ id: 'normalize-email',
45
+ on: 'before:insert',
46
+ callback: ({ data }) => { data.email = data.email.toLowerCase(); }
251
47
  });
252
48
 
253
- await users.call('resetPassword', { email, newPassword }, ctx);
254
- users.getMethods(); // → ['resetPassword']
255
- ```
256
-
257
- `call` throws `{ code: 'METHOD_NOT_FOUND', id }` if the method isn't registered.
258
-
259
- ---
260
-
261
- ## Uploads
262
-
263
- Uploads receive a `Buffer` plus a `fields` map (typically multipart form fields).
264
-
265
- ```ts
266
- users.upload({
267
- id : 'avatar',
268
- handler : async (file, fields, model) => {
269
- const path = await storage.put(file, fields.filename);
270
- return model.update({ _id: fields.userId }, { avatar: path });
271
- }
272
- });
273
-
274
- await users.callUpload('avatar', buffer, { userId, filename: 'me.png' });
275
- users.getUploads(); // → ['avatar']
276
- ```
277
-
278
- ---
279
-
280
- ## Streams
281
-
282
- Streams are similar to uploads but expect chunked `Buffer` data with arbitrary `meta`.
283
-
284
- ```ts
285
- users.stream({
286
- id : 'import',
287
- handler : async (chunk, meta, model) => {
288
- for (const row of parseCsv(chunk)) await model.insert(row);
289
- }
290
- });
291
-
292
- await users.callStream('import', chunk, { source: 'crm' });
293
- users.getStreams(); // → ['import']
294
- ```
295
-
296
- ---
297
-
298
- ## Events & Subscriptions
299
-
300
- Events are named, schema-validated handlers that clients subscribe to. Unlike `method` (one-shot RPC), events are designed for fan-out to multiple subscribers, scoped by query.
301
-
302
- ### Defining an event
303
-
304
- ```ts
305
- users.event({
306
- id : 'roleChanged',
307
- schema : {
308
- type : 'object',
309
- required : ['_id', 'role'],
310
- properties: {
311
- _id : { type: 'string' },
312
- role : { type: 'string', enum: ['user', 'admin'] }
313
- }
314
- },
315
- handler : async ({ _id, role }, model, ctx) => {
316
- return model.update({ _id }, { role });
317
- }
318
- });
319
- ```
320
-
321
- If `schema` is set, args are validated before the handler runs. Validation errors throw `{ code: 'VALIDATION_ERROR', errors }`.
322
-
323
- ### Subscribing
324
-
325
- A subscription is keyed by a normalized query — same query keys/values produce the same subscription instance.
326
-
327
- ```ts
328
- const { subscription, subId } = users.subscribe({ role: 'admin' });
329
-
330
- subscription.on('publish', payload => {
331
- console.log('admin update:', payload);
332
- });
333
-
334
- // shorthand — returns just the subscription
335
- const sub = users.subscription({ role: 'admin' });
336
- ```
337
-
338
- ### Dispatching an event from a subscription
339
-
340
- `dispatch` validates args (if a schema is set) and runs the matching event handler.
341
-
342
- ```ts
343
- await sub.dispatch('roleChanged', { _id, role: 'admin' }, ctx);
344
- ```
345
-
346
- Throws `{ code: 'EVENT_NOT_FOUND', id }` if the event isn't registered.
347
-
348
- ### Publishing to subscribers
349
-
350
- `publish` is your fan-out hook — it emits to every listener attached via `on('publish', ...)` for that subscription.
351
-
352
- ```ts
353
- sub.publish({ type: 'role-changed', _id, role: 'admin' });
354
- ```
355
-
356
- A common pattern is calling `publish` from inside an `after:update` hook to notify everyone watching that slice of the model.
357
-
358
- ### Inspection / cleanup
359
-
360
- ```ts
361
- sub.getEvents(); // → ['roleChanged']
362
- users.unsubscribe({ role: 'admin' });
363
- ```
364
-
365
- ---
366
-
367
- ## Internal Event Emitter
368
-
369
- Separate from the subscription system, every model is also an `EventEmitter`. CRUD operations emit on this emitter directly — useful for in-process listeners that don't need query scoping.
370
-
371
- ```ts
372
- users.on('insert', doc => console.log('inserted', doc));
373
- users.on('update', ({ key, data }) => console.log('updated', key, data));
374
- users.on('delete', result => console.log('deleted', result));
375
- users.on('log', entry => console.log(entry)); // log feed
376
- ```
377
-
378
- `on`, `off`, `once`, `emit` are all available. Built-in events: `insert`, `update`, `delete`, `log`.
379
-
380
- ---
381
-
382
- ## Extensions
383
-
384
- Pass `extensions: [...]` in the constructor options to mount sub-models as named properties on the parent.
385
-
386
- ```ts
387
- const ssh = new Model('ssh', sshSchema);
388
- const users = new Model('users', userSchema, { extensions: [ssh] });
389
-
390
- users.ssh.insert({ host: '...', port: 22, username: 'root' });
391
- users.getExtensions(); // → [Model('ssh')]
392
- ```
393
-
394
- The mount key is `extension.id`.
395
-
396
- ---
397
-
398
- ## Adapter (Persistence)
399
-
400
- Models are in-memory by default. Provide an `Adapter` subclass to persist on every write and rehydrate on `pull()`.
401
-
402
- ```ts
403
- import { Adapter, Model } from '@hotfusion/modeller';
404
-
405
- class FileAdapter extends Adapter {
406
- constructor(id) {
407
- super(id);
408
- this.path = `./data/${id}.json`;
409
- }
410
-
411
- async sync(id, doc) {
412
- // called on every insert / update / delete
413
- }
414
-
415
- async pull() {
416
- // return the array of documents to load into memory
417
- return JSON.parse(await fs.readFile(this.path, 'utf8'));
418
- }
419
- }
420
-
421
- const users = new Model('users', schema, { adapter: FileAdapter });
422
- await users.pull(); // hydrate before serving traffic
423
- ```
424
-
425
- When an adapter is configured, deletes are also moved to the trash bin (unless you pass `trash: false` in options).
426
-
427
- ---
428
-
429
- ## Logging
430
-
431
- A built-in logger surfaces hook, worker, method, event, and CRUD activity. Configure it at construction time:
432
-
433
- ```ts
434
- const users = new Model('users', schema, {
435
- level : 'debug', // 'debug' | 'info' | 'warn' | 'error' | 'silent'
436
- timestamp : true,
437
- features : { hook: true, worker: true, method: true, event: false, crud: true },
438
- onEntry : entry => myLogger.log(entry)
439
- });
440
- ```
441
-
442
- Or supply a custom adapter:
443
-
444
- ```ts
445
- {
446
- logAdapter: { log: entry => myStore.append(entry) }
447
- }
448
- ```
449
-
450
- You can also listen to `'log'` on the model itself:
451
-
452
- ```ts
453
- users.on('log', entry => sink.write(entry));
454
- ```
455
-
456
- Each entry includes `level`, `feature`, `modelId`, `message`, `callSite`, `timestamp`, and optional `data`.
457
-
458
- ---
459
-
460
- ## Trash
461
-
462
- When an adapter is configured, deletes go to an in-memory trash bin keyed by `_id`.
463
-
464
- ```ts
465
- await users.delete({ _id });
466
-
467
- // elsewhere — direct trash access isn't exposed on Model yet,
468
- // but Trash supports: restore, has, get, permanentDelete, empty,
469
- // size, list({ start, count }), find(query), findAll(query).
49
+ await users.insert({ email: 'alex@hotfusion.ca', name: 'Alex' });
470
50
  ```
471
51
 
472
- Pass `{ trash: false }` in model options to disable.
473
-
474
52
  ---
475
53
 
476
- ## Errors
54
+ ## Documentation
477
55
 
478
- All errors thrown by Modeller are plain objects with a `code`:
56
+ | Section | Covers |
57
+ |--------------------------------------|-----------------------------------------------------------------------|
58
+ | [Core layer](./docs/CORE.md) | Schema, validator, CRUD primitives, indexes, adapter, trash. |
59
+ | [Model layer](./docs/MODEL.md) | Hooks, workers, methods, uploads, streams, events, subscriptions, logging, extensions. |
60
+ | [Server layer](./docs/SERVER.md) | HTTP + WebSocket transport, auth, server events, public API. |
61
+ | [Utilities](./docs/UTILITIES.md) | `Bundler`, `KeyGen`, `Adapter`, encryption, JWT helpers. |
62
+ | [Errors](./docs/ERRORS.md) | Error codes thrown by the framework. |
63
+ | [Common patterns](./docs/PATTERNS.md)| Recipes for publish-on-update, FK validation, extensions, workers. |
479
64
 
480
- | Code | Source |
481
- |---------------------|-------------------------------------------------|
482
- | `VALIDATION_ERROR` | AJV failed on `insert` / `update` / event args |
483
- | `UNIQUE_VIOLATION` | A `unique` field collided |
484
- | `METHOD_NOT_FOUND` | `call('id', ...)` with unregistered id |
485
- | `UPLOAD_NOT_FOUND` | `callUpload('id', ...)` with unregistered id |
486
- | `STREAM_NOT_FOUND` | `callStream('id', ...)` with unregistered id |
487
- | `EVENT_NOT_FOUND` | `subscription.dispatch('id', ...)` mismatch |
65
+ Start with **Model** if you're building an app. Drop down to **Core** when you need to understand schema or persistence semantics. Read **Server** when you're ready to expose models over the network.
488
66
 
489
67
  ---
490
68
 
491
69
  ## License
492
70
 
493
- UNLICENSED — internal HotFusion project.
71
+ UNLICENSED — internal HotFusion project.
package/docs/CORE.md ADDED
@@ -0,0 +1,191 @@
1
+ # Core Layer
2
+
3
+ `Core` is the in-memory data primitive. It holds documents, validates them against a schema, supports indexed lookups, mingo queries, and an optional persistence adapter. Everything in this doc is inherited by `Model`, so you can ignore Core if you only ever use `Model` — but understanding it explains where the behavior comes from.
4
+
5
+ > [← Back to README](../README.md)
6
+
7
+ ---
8
+
9
+ ## Table of Contents
10
+
11
+ - [Schema](#schema)
12
+ - [Validator](#validator)
13
+ - [CRUD primitives](#crud-primitives)
14
+ - [Indexes](#indexes)
15
+ - [Adapter](#adapter)
16
+ - [Trash](#trash)
17
+
18
+ ---
19
+
20
+ ## Schema
21
+
22
+ Schemas are JSON Schema (AJV-compatible) plus a few Modeller-specific keywords:
23
+
24
+ | Keyword | Effect |
25
+ |-------------|-------------------------------------------------------------------------|
26
+ | `unique` | Enforces uniqueness across all documents on `insert` / `update`. |
27
+ | `private` | Field stripped from read responses unless `{ private: true }` is set. |
28
+ | `index` | Builds an in-memory index on the field for fast lookups. |
29
+ | `protected` | Reserved for transport-layer protection (consumed by `Server`). |
30
+ | `hidden` | Hint for client rendering. Not enforced. |
31
+ | `default` | Standard JSON Schema default. |
32
+ | `model` | Async FK-style validation against another model by id. |
33
+
34
+ ### `$ref` resolution
35
+
36
+ `$ref` resolves against `$defs` / `definitions` first, then against any `schemes` passed to the constructor (via `$id` match).
37
+
38
+ A `$ref` may also carry a query string:
39
+
40
+ ```
41
+ "someModel?key=_id&label=name&search=name,email&columns=name,role&min=1&max=5&unique"
42
+ ```
43
+
44
+ This rewrites the property into a model reference with display metadata (`key`, `label`, `search`, `columns`) — used by client tooling to render pickers. For `type: 'array'`, `min` / `max` / `unique` become `minItems` / `maxItems` / `uniqueItemProperties`.
45
+
46
+ ### Inspecting the schema
47
+
48
+ ```ts
49
+ model.getSchema(); // public — strips `private` fields
50
+ model.getSchema({ private: true }); // full
51
+ ```
52
+
53
+ ---
54
+
55
+ ## Validator
56
+
57
+ AJV with `allErrors`, `strict: false`, `ajv-formats`, and `ajv-errors`. Two custom formats are registered:
58
+
59
+ - `phone` — `+?[0-9 \-()]{7,10}`
60
+ - `password` — at least 8 chars, mixed case, digit, symbol
61
+
62
+ And one custom keyword:
63
+
64
+ - `model` (async) — value must reference an existing document in the named model. Used for FK-style references.
65
+
66
+ ```ts
67
+ properties: {
68
+ ownerId: { type: 'string', model: 'users.email' }
69
+ }
70
+ ```
71
+
72
+ ---
73
+
74
+ ## CRUD primitives
75
+
76
+ All CRUD methods are async. They throw `{ code, ... }` on failure (see [Errors](./ERRORS.md)).
77
+
78
+ ### `insert(key?, ...patches, opts?)`
79
+
80
+ Merges `key` and any patch objects into a single document, validates, assigns a BSON `ObjectId`, and stores it.
81
+
82
+ ```ts
83
+ await users.insert({ email: 'a@b.com' }, { name: 'Alex' });
84
+ await users.insert({ email: 'admin@b.com' }, { role: 'admin' }, { private: true });
85
+ ```
86
+
87
+ Returns the inserted document. `private: true` includes `private` fields in the response.
88
+
89
+ ### `update(key, updates, opts?)`
90
+
91
+ Finds a document by `key` (mingo query), shallow-merges `updates`, re-validates, and saves.
92
+
93
+ ```ts
94
+ await users.update({ _id }, { name: 'Alex H.' });
95
+ ```
96
+
97
+ Returns the merged document, or `null` if no match.
98
+
99
+ ### `delete(key)`
100
+
101
+ Removes the matching document. If an adapter is configured and `trash` is not disabled, the document is moved to the trash bin.
102
+
103
+ ```ts
104
+ await users.delete({ _id }); // → { _id } or null
105
+ ```
106
+
107
+ ### `get(key, opts?)`
108
+
109
+ Returns a single document or `null`.
110
+
111
+ ```ts
112
+ await users.get({ email: 'a@b.com' });
113
+ await users.get({ _id }, { private: true });
114
+ ```
115
+
116
+ ### `list(query?, opts?)`
117
+
118
+ Paginated, **exact-match** mingo query.
119
+
120
+ ```ts
121
+ await users.list({ role: 'admin' }, { start: 0, count: 25, private: false });
122
+ ```
123
+
124
+ Default `count` is 10.
125
+
126
+ ### `find(query?, opts?)`
127
+
128
+ Like `list`, but **strings match as case-insensitive substrings**. Non-string values match exactly. Returns an array.
129
+
130
+ ```ts
131
+ await users.find({ name: 'al' }, { max: 10 });
132
+ // → [{ name: 'Alex' }, { name: 'Albert' }, ...]
133
+ ```
134
+
135
+ Default `max` is 10.
136
+
137
+ > **`list` vs `find`** — `list` is for paginated, exact-match queries (admin views, dashboards). `find` is for autocomplete and search. `list` fires hooks; `find` does not.
138
+
139
+ ---
140
+
141
+ ## Indexes
142
+
143
+ Any property with `index: <number>` in its schema gets a per-field hash index built and maintained automatically. Indexes are used internally to keep large collections fast.
144
+
145
+ ```ts
146
+ properties: {
147
+ email : { type: 'string', index: 1 }
148
+ }
149
+ ```
150
+
151
+ ---
152
+
153
+ ## Adapter
154
+
155
+ Models are in-memory by default. Provide an `Adapter` subclass to persist on every write and rehydrate on `pull()`.
156
+
157
+ ```ts
158
+ import { Adapter, Model } from '@hotfusion/modeller';
159
+
160
+ class FileAdapter extends Adapter {
161
+ async sync(id, doc) { /* called on every write */ }
162
+ async pull() { /* return array of docs to load */ return []; }
163
+ }
164
+
165
+ const users = new Model('users', schema, { adapter: FileAdapter });
166
+ await users.pull(); // hydrate before serving traffic
167
+ ```
168
+
169
+ The Adapter contract:
170
+
171
+ ```ts
172
+ class Adapter {
173
+ constructor(id: string) {}
174
+ async sync(id: string, doc: { _id: string, ... }): Promise<void>;
175
+ async pull(): Promise<any[]>;
176
+ }
177
+ ```
178
+
179
+ Modeller calls `sync` on every `insert`, `update`, and `delete` — including trash moves. `pull()` is called manually when you want to rehydrate.
180
+
181
+ ---
182
+
183
+ ## Trash
184
+
185
+ When an adapter is configured, deleted documents go to an in-memory trash bin keyed by `_id`. The bin supports `restore`, `has`, `get`, `permanentDelete`, `empty`, `size`, `list({ start, count })`, `find(query)`, `findAll(query)`. Every trash operation also calls `adapter.sync('trash', doc)` so it can be persisted.
186
+
187
+ Pass `{ trash: false }` in model options to disable.
188
+
189
+ ---
190
+
191
+ > **Next:** [Model layer →](./MODEL.md)