@hotfusion/modeller 0.0.1 → 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 +71 -0
- package/dist/connector.js +2 -2
- package/dist/connector.js.map +1 -1
- package/dist/{modeller.js → core.js} +4 -4
- package/dist/core.js.map +1 -0
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/dist/model.js +3 -4
- package/dist/model.js.map +1 -1
- package/dist/types/{modeller.d.ts → core.d.ts} +2 -2
- package/dist/types/core.d.ts.map +1 -0
- package/dist/types/index.d.ts +1 -0
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/model.d.ts +3 -2
- package/dist/types/model.d.ts.map +1 -1
- package/dist/utils/_secret.key +1 -1
- package/docs/CORE.md +191 -0
- package/docs/ERRORS.md +90 -0
- package/docs/MODEL.md +296 -0
- package/docs/PATTERNS.md +182 -0
- package/docs/SERVER.md +88 -0
- package/docs/UTILITIES.md +111 -0
- package/package.json +6 -2
- package/dist/modeller.js.map +0 -1
- package/dist/types/modeller.d.ts.map +0 -1
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)
|
package/docs/ERRORS.md
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# Errors
|
|
2
|
+
|
|
3
|
+
All errors thrown by Modeller are plain objects with a `code` field. They aren't `Error` instances — catch them as plain objects and branch on `code`.
|
|
4
|
+
|
|
5
|
+
> [← Back to README](../README.md)
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## Error codes
|
|
10
|
+
|
|
11
|
+
| Code | Source | Extra fields |
|
|
12
|
+
|---------------------|-------------------------------------------------|-------------------------|
|
|
13
|
+
| `VALIDATION_ERROR` | AJV failed on `insert` / `update` / event args. | `errors` (AJV errors[]) |
|
|
14
|
+
| `UNIQUE_VIOLATION` | A `unique` field collided. | `field` |
|
|
15
|
+
| `METHOD_NOT_FOUND` | `call('id', ...)` with unregistered id. | `id` |
|
|
16
|
+
| `UPLOAD_NOT_FOUND` | `callUpload('id', ...)` with unregistered id. | `id` |
|
|
17
|
+
| `STREAM_NOT_FOUND` | `callStream('id', ...)` with unregistered id. | `id` |
|
|
18
|
+
| `EVENT_NOT_FOUND` | `subscription.dispatch('id', ...)` mismatch. | `id` |
|
|
19
|
+
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
## Catching
|
|
23
|
+
|
|
24
|
+
```ts
|
|
25
|
+
try {
|
|
26
|
+
await users.insert({ email: 'bad' });
|
|
27
|
+
} catch (err) {
|
|
28
|
+
switch (err.code) {
|
|
29
|
+
case 'VALIDATION_ERROR':
|
|
30
|
+
console.log('AJV errors:', err.errors);
|
|
31
|
+
break;
|
|
32
|
+
case 'UNIQUE_VIOLATION':
|
|
33
|
+
console.log('duplicate field:', err.field);
|
|
34
|
+
break;
|
|
35
|
+
default:
|
|
36
|
+
throw err;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
---
|
|
42
|
+
|
|
43
|
+
## Validation error shape
|
|
44
|
+
|
|
45
|
+
`VALIDATION_ERROR` carries the raw AJV error array on `errors`. Each entry has `instancePath`, `keyword`, `message`, `params`, and `schemaPath`.
|
|
46
|
+
|
|
47
|
+
```ts
|
|
48
|
+
{
|
|
49
|
+
code: 'VALIDATION_ERROR',
|
|
50
|
+
errors: [
|
|
51
|
+
{
|
|
52
|
+
instancePath : '/email',
|
|
53
|
+
keyword : 'format',
|
|
54
|
+
message : 'must match format "email"',
|
|
55
|
+
params : { format: 'email' },
|
|
56
|
+
schemaPath : '#/properties/email/format'
|
|
57
|
+
}
|
|
58
|
+
]
|
|
59
|
+
}
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
`ajv-errors` is enabled, so per-field custom messages declared via `errorMessage` in the schema show up in `message`.
|
|
63
|
+
|
|
64
|
+
---
|
|
65
|
+
|
|
66
|
+
## Hooks and errors
|
|
67
|
+
|
|
68
|
+
A non-scheduled hook that throws aborts the operation. The thrown value is logged at `error` level via the [logger](./MODEL.md#logging) and re-thrown to the caller. Throw a plain object with a `code` to keep error handling consistent:
|
|
69
|
+
|
|
70
|
+
```ts
|
|
71
|
+
users.hook({
|
|
72
|
+
id: 'check-quota',
|
|
73
|
+
on: 'before:insert',
|
|
74
|
+
callback: async ({ data }) => {
|
|
75
|
+
if (await overQuota(data)) throw { code: 'QUOTA_EXCEEDED' };
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Scheduled hooks (those with `schedule: <ms>`) are fire-and-forget. Errors from them are logged but not propagated.
|
|
81
|
+
|
|
82
|
+
---
|
|
83
|
+
|
|
84
|
+
## Workers and errors
|
|
85
|
+
|
|
86
|
+
Worker handler errors are caught and logged at `error` level. They never crash the interval — the worker keeps ticking. If you want a worker to stop on failure, call `model.stop('worker-id')` from inside the handler's catch block.
|
|
87
|
+
|
|
88
|
+
---
|
|
89
|
+
|
|
90
|
+
> **Related:** [Core →](./CORE.md) · [Model →](./MODEL.md)
|
package/docs/MODEL.md
ADDED
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
# Model Layer
|
|
2
|
+
|
|
3
|
+
`Model extends Core`. Everything from the [Core layer](./CORE.md) is available on a `Model` instance — `insert`, `update`, `delete`, `get`, `list`, `find`, `getSchema`, `pull`. On top of that, `Model` adds the behavior surface: hooks, workers, methods, uploads, streams, events, subscriptions, logging, and extensions.
|
|
4
|
+
|
|
5
|
+
> [← Back to README](../README.md)
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## Table of Contents
|
|
10
|
+
|
|
11
|
+
- [Hooks](#hooks)
|
|
12
|
+
- [Workers](#workers)
|
|
13
|
+
- [Methods](#methods)
|
|
14
|
+
- [Uploads](#uploads)
|
|
15
|
+
- [Streams](#streams)
|
|
16
|
+
- [Events & Subscriptions](#events--subscriptions)
|
|
17
|
+
- [Internal Event Emitter](#internal-event-emitter)
|
|
18
|
+
- [Extensions](#extensions)
|
|
19
|
+
- [Logging](#logging)
|
|
20
|
+
|
|
21
|
+
---
|
|
22
|
+
|
|
23
|
+
## Hooks
|
|
24
|
+
|
|
25
|
+
Hooks run before or after a CRUD operation. They can short-circuit by throwing, mutate the payload, or perform side effects. Both sync and async callbacks are supported.
|
|
26
|
+
|
|
27
|
+
```ts
|
|
28
|
+
users.hook({
|
|
29
|
+
id : 'normalize-email',
|
|
30
|
+
on : 'before:insert',
|
|
31
|
+
callback : async ({ data }) => {
|
|
32
|
+
if (data?.email) data.email = data.email.toLowerCase();
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
users.hook({
|
|
37
|
+
id : 'audit',
|
|
38
|
+
on : ['after:insert', 'after:update', 'after:delete'],
|
|
39
|
+
callback : ({ operation, key, data }) => audit.log(operation, key, data)
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
users.unhook('normalize-email');
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
### Hook events
|
|
46
|
+
|
|
47
|
+
| Event | Payload fields |
|
|
48
|
+
|-------------------|-----------------------------------------------|
|
|
49
|
+
| `before:insert` | `operation`, `timing`, `data` |
|
|
50
|
+
| `after:insert` | `operation`, `timing`, `data`, `key: { _id }` |
|
|
51
|
+
| `before:update` | `operation`, `timing`, `key`, `data` |
|
|
52
|
+
| `after:update` | `operation`, `timing`, `key`, `data` |
|
|
53
|
+
| `before:delete` | `operation`, `timing`, `key` |
|
|
54
|
+
| `after:delete` | `operation`, `timing`, `key`, `data` |
|
|
55
|
+
| `before:list` | `operation`, `timing`, `data` (= query) |
|
|
56
|
+
| `after:list` | `operation`, `timing`, `data` (= query) |
|
|
57
|
+
|
|
58
|
+
`get` and `find` do **not** fire hooks.
|
|
59
|
+
|
|
60
|
+
### Scheduled hooks
|
|
61
|
+
|
|
62
|
+
`schedule: <ms>` defers execution via `setTimeout`. Scheduled hooks are fire-and-forget — they don't block the operation and their errors aren't propagated.
|
|
63
|
+
|
|
64
|
+
```ts
|
|
65
|
+
users.hook({
|
|
66
|
+
id : 'welcome-email',
|
|
67
|
+
on : 'after:insert',
|
|
68
|
+
schedule : 5_000,
|
|
69
|
+
callback : ({ data }) => mailer.send(data.email, 'welcome')
|
|
70
|
+
});
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
### Throwing from a hook
|
|
74
|
+
|
|
75
|
+
A non-scheduled hook that throws aborts the operation. The error is logged at `error` level and re-thrown to the caller.
|
|
76
|
+
|
|
77
|
+
---
|
|
78
|
+
|
|
79
|
+
## Workers
|
|
80
|
+
|
|
81
|
+
Workers are interval-based background tasks bound to the model. They can be **named** (managed individually) or **unsigned** (managed in bulk).
|
|
82
|
+
|
|
83
|
+
```ts
|
|
84
|
+
users.worker({
|
|
85
|
+
id : 'sync',
|
|
86
|
+
interval : 30_000,
|
|
87
|
+
immediate : true, // run once on start, default true
|
|
88
|
+
handler : async (model) => { await model.pull(); }
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
users.worker({ interval: 60_000, handler: () => cleanup() }); // unsigned
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
### Lifecycle
|
|
95
|
+
|
|
96
|
+
```ts
|
|
97
|
+
users.start(); // start all (named + unsigned)
|
|
98
|
+
users.start('sync'); // start a specific named worker
|
|
99
|
+
users.stop(); // stop all
|
|
100
|
+
users.stop('sync'); // stop a specific named worker
|
|
101
|
+
users.isRunning('sync'); // → boolean
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
Errors thrown inside a worker handler are caught and logged — they never crash the interval.
|
|
105
|
+
|
|
106
|
+
---
|
|
107
|
+
|
|
108
|
+
## Methods
|
|
109
|
+
|
|
110
|
+
Methods are named, schemaless RPC-style operations. Use them for anything that doesn't fit the CRUD shape.
|
|
111
|
+
|
|
112
|
+
```ts
|
|
113
|
+
users.method({
|
|
114
|
+
id : 'resetPassword',
|
|
115
|
+
handler : async ({ email, newPassword }, model, ctx) => {
|
|
116
|
+
const u = await model.get({ email });
|
|
117
|
+
if (!u) throw { code: 'NOT_FOUND' };
|
|
118
|
+
return model.update({ _id: u._id }, { password: newPassword });
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
await users.call('resetPassword', { email, newPassword }, ctx);
|
|
123
|
+
users.getMethods(); // → ['resetPassword']
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
`call` throws `{ code: 'METHOD_NOT_FOUND', id }` if the method isn't registered.
|
|
127
|
+
|
|
128
|
+
---
|
|
129
|
+
|
|
130
|
+
## Uploads
|
|
131
|
+
|
|
132
|
+
Uploads receive a `Buffer` plus a `fields` map (typically multipart form fields).
|
|
133
|
+
|
|
134
|
+
```ts
|
|
135
|
+
users.upload({
|
|
136
|
+
id : 'avatar',
|
|
137
|
+
handler : async (file, fields, model) => {
|
|
138
|
+
const path = await storage.put(file, fields.filename);
|
|
139
|
+
return model.update({ _id: fields.userId }, { avatar: path });
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
await users.callUpload('avatar', buffer, { userId, filename: 'me.png' });
|
|
144
|
+
users.getUploads(); // → ['avatar']
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
---
|
|
148
|
+
|
|
149
|
+
## Streams
|
|
150
|
+
|
|
151
|
+
Streams are similar to uploads but expect chunked `Buffer` data with arbitrary `meta`.
|
|
152
|
+
|
|
153
|
+
```ts
|
|
154
|
+
users.stream({
|
|
155
|
+
id : 'import',
|
|
156
|
+
handler : async (chunk, meta, model) => {
|
|
157
|
+
for (const row of parseCsv(chunk)) await model.insert(row);
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
await users.callStream('import', chunk, { source: 'crm' });
|
|
162
|
+
users.getStreams(); // → ['import']
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
---
|
|
166
|
+
|
|
167
|
+
## Events & Subscriptions
|
|
168
|
+
|
|
169
|
+
Events are named, schema-validated handlers that clients **subscribe to**. Where `method` is one-shot RPC, events are designed for fan-out to multiple subscribers, scoped by query.
|
|
170
|
+
|
|
171
|
+
### Defining an event
|
|
172
|
+
|
|
173
|
+
```ts
|
|
174
|
+
users.event({
|
|
175
|
+
id : 'roleChanged',
|
|
176
|
+
schema : {
|
|
177
|
+
type : 'object',
|
|
178
|
+
required : ['_id', 'role'],
|
|
179
|
+
properties: {
|
|
180
|
+
_id : { type: 'string' },
|
|
181
|
+
role : { type: 'string', enum: ['user', 'admin'] }
|
|
182
|
+
}
|
|
183
|
+
},
|
|
184
|
+
handler: async ({ _id, role }, model, ctx) => {
|
|
185
|
+
return model.update({ _id }, { role });
|
|
186
|
+
}
|
|
187
|
+
});
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
If `schema` is set, args are validated before the handler runs. Validation errors throw `{ code: 'VALIDATION_ERROR', errors }`.
|
|
191
|
+
|
|
192
|
+
### Subscribing
|
|
193
|
+
|
|
194
|
+
A subscription is keyed by a normalized query — same query keys/values produce the same subscription instance.
|
|
195
|
+
|
|
196
|
+
```ts
|
|
197
|
+
const { subscription, subId } = users.subscribe({ role: 'admin' });
|
|
198
|
+
|
|
199
|
+
subscription.on('publish', payload => {
|
|
200
|
+
console.log('admin update:', payload);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
// shorthand — returns just the subscription
|
|
204
|
+
const sub = users.subscription({ role: 'admin' });
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
### Dispatching an event
|
|
208
|
+
|
|
209
|
+
`dispatch` validates args (if a schema is set) and runs the matching event handler.
|
|
210
|
+
|
|
211
|
+
```ts
|
|
212
|
+
await sub.dispatch('roleChanged', { _id, role: 'admin' }, ctx);
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
Throws `{ code: 'EVENT_NOT_FOUND', id }` if the event isn't registered.
|
|
216
|
+
|
|
217
|
+
### Publishing to subscribers
|
|
218
|
+
|
|
219
|
+
`publish` is your fan-out hook — it emits to every listener attached via `on('publish', ...)` for that subscription. The transport layer ([Server](./SERVER.md)) wires this into a Socket.IO room so browser clients receive it automatically.
|
|
220
|
+
|
|
221
|
+
```ts
|
|
222
|
+
sub.publish({ type: 'role-changed', _id, role: 'admin' });
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
A common pattern is calling `publish` from inside an `after:update` hook to notify everyone watching that slice of the model. See [Patterns](./PATTERNS.md).
|
|
226
|
+
|
|
227
|
+
### Inspection / cleanup
|
|
228
|
+
|
|
229
|
+
```ts
|
|
230
|
+
sub.getEvents(); // → ['roleChanged']
|
|
231
|
+
users.unsubscribe({ role: 'admin' });
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
---
|
|
235
|
+
|
|
236
|
+
## Internal Event Emitter
|
|
237
|
+
|
|
238
|
+
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.
|
|
239
|
+
|
|
240
|
+
```ts
|
|
241
|
+
users.on('insert', doc => log('inserted', doc));
|
|
242
|
+
users.on('update', ({ key, data }) => log('updated', key, data));
|
|
243
|
+
users.on('delete', result => log('deleted', result));
|
|
244
|
+
users.on('log', entry => sink.write(entry));
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
`on`, `off`, `once`, `emit` are all available. Built-in events: `insert`, `update`, `delete`, `log`.
|
|
248
|
+
|
|
249
|
+
---
|
|
250
|
+
|
|
251
|
+
## Extensions
|
|
252
|
+
|
|
253
|
+
Pass `extensions: [...]` in the constructor options to mount sub-models as named properties on the parent. The mount key is `extension.id`.
|
|
254
|
+
|
|
255
|
+
```ts
|
|
256
|
+
const ssh = new Model('ssh', sshSchema);
|
|
257
|
+
const users = new Model('users', userSchema, { extensions: [ssh] });
|
|
258
|
+
|
|
259
|
+
users.ssh.insert({ host: '...', port: 22, username: 'root' });
|
|
260
|
+
users.getExtensions(); // → [Model('ssh')]
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
This is how Modeller composes namespaces — a `system` model with `system.ssh`, `system.docker`, `system.firewall` extensions, all addressable as `system.ssh.insert(...)`.
|
|
264
|
+
|
|
265
|
+
---
|
|
266
|
+
|
|
267
|
+
## Logging
|
|
268
|
+
|
|
269
|
+
A built-in logger surfaces hook, worker, method, event, and CRUD activity.
|
|
270
|
+
|
|
271
|
+
```ts
|
|
272
|
+
const users = new Model('users', schema, {
|
|
273
|
+
level : 'debug', // 'debug' | 'info' | 'warn' | 'error' | 'silent'
|
|
274
|
+
timestamp : true,
|
|
275
|
+
features : { hook: true, worker: true, method: true, event: false, crud: true },
|
|
276
|
+
onEntry : entry => myLogger.log(entry)
|
|
277
|
+
});
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
Or supply a custom adapter:
|
|
281
|
+
|
|
282
|
+
```ts
|
|
283
|
+
{ logAdapter: { log: entry => myStore.append(entry) } }
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
Listen on the model itself:
|
|
287
|
+
|
|
288
|
+
```ts
|
|
289
|
+
users.on('log', entry => sink.write(entry));
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
Each entry includes `level`, `feature`, `modelId`, `message`, `callSite`, `timestamp`, and optional `data`. `initial` is `true` for one-shot registration messages (e.g. "Hook registered"), `false` for runtime activity.
|
|
293
|
+
|
|
294
|
+
---
|
|
295
|
+
|
|
296
|
+
> **Next:** [Server layer →](./SERVER.md)
|