@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 +32 -454
- 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 +2 -1
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)
|
package/docs/PATTERNS.md
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
# Common Patterns
|
|
2
|
+
|
|
3
|
+
Recipes that show the [Core → Model → Server](../README.md) chain in action. Each pattern is small, self-contained, and reflects how the framework is meant to be composed.
|
|
4
|
+
|
|
5
|
+
> [← Back to README](../README.md)
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## Publish on update
|
|
10
|
+
|
|
11
|
+
Notify every client subscribed to a slice of a model whenever a document in that slice changes. The `after:update` hook is the natural seam.
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
users.hook({
|
|
15
|
+
id: 'broadcast',
|
|
16
|
+
on: 'after:update',
|
|
17
|
+
callback: ({ key, data }) => {
|
|
18
|
+
users.subscription({ _id: key._id }).publish({ type: 'updated', data });
|
|
19
|
+
}
|
|
20
|
+
});
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
The [Server layer](./SERVER.md) wires the subscription into a Socket.IO room, so every browser client subscribed to `{ _id }` receives the payload automatically.
|
|
24
|
+
|
|
25
|
+
---
|
|
26
|
+
|
|
27
|
+
## FK validation across models
|
|
28
|
+
|
|
29
|
+
Use the async `model` keyword to require that a field references an existing document in another model.
|
|
30
|
+
|
|
31
|
+
```ts
|
|
32
|
+
const orders = new Model('orders', {
|
|
33
|
+
type: 'object',
|
|
34
|
+
properties: {
|
|
35
|
+
userId: { type: 'string', model: 'users.email' }
|
|
36
|
+
}
|
|
37
|
+
}, { schemes: [users.getSchema()] });
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
`Validator` rejects an `insert` whose `userId` doesn't exist in the `users` model. See [Validator](./CORE.md#validator).
|
|
41
|
+
|
|
42
|
+
---
|
|
43
|
+
|
|
44
|
+
## Composing a system root via extensions
|
|
45
|
+
|
|
46
|
+
`extensions` mounts sub-models as named properties on the parent — addressable as `parent.childId.method(...)`.
|
|
47
|
+
|
|
48
|
+
```ts
|
|
49
|
+
const ssh = new Model('ssh', sshSchema);
|
|
50
|
+
const docker = new Model('docker', dockerSchema);
|
|
51
|
+
const firewall = new Model('firewall', firewallSchema);
|
|
52
|
+
|
|
53
|
+
const system = new Model('system', systemSchema, {
|
|
54
|
+
extensions: [ssh, docker, firewall]
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
await system.ssh.insert({ host: '...', port: 22, username: 'root' });
|
|
58
|
+
await system.firewall.list();
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
This pattern is how Modeller composes namespaces. Each extension keeps its own hooks, workers, methods, events, and schema — they aren't merged, just mounted.
|
|
62
|
+
|
|
63
|
+
See [Extensions](./MODEL.md#extensions).
|
|
64
|
+
|
|
65
|
+
---
|
|
66
|
+
|
|
67
|
+
## Background sync worker
|
|
68
|
+
|
|
69
|
+
Reconcile in-memory state with an external source on an interval.
|
|
70
|
+
|
|
71
|
+
```ts
|
|
72
|
+
firewall.worker({
|
|
73
|
+
id : 'sync-rules',
|
|
74
|
+
interval : 30_000,
|
|
75
|
+
handler : async (model) => {
|
|
76
|
+
const live = await readLiveUFW();
|
|
77
|
+
for (const rule of live) {
|
|
78
|
+
const existing = await model.get({ id: rule.id });
|
|
79
|
+
if (!existing) await model.insert(rule);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
firewall.start('sync-rules');
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Errors inside the handler are logged but never crash the interval. See [Workers](./MODEL.md#workers).
|
|
88
|
+
|
|
89
|
+
---
|
|
90
|
+
|
|
91
|
+
## Quota / authorization in `before:insert`
|
|
92
|
+
|
|
93
|
+
Throw from a `before:` hook to abort an operation.
|
|
94
|
+
|
|
95
|
+
```ts
|
|
96
|
+
users.hook({
|
|
97
|
+
id: 'check-quota',
|
|
98
|
+
on: 'before:insert',
|
|
99
|
+
callback: async ({ data }) => {
|
|
100
|
+
if (await overQuota(data.tenantId)) {
|
|
101
|
+
throw { code: 'QUOTA_EXCEEDED' };
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
The thrown object propagates back to the caller of `insert()`. See [Errors](./ERRORS.md).
|
|
108
|
+
|
|
109
|
+
---
|
|
110
|
+
|
|
111
|
+
## Schedule a side effect after insert
|
|
112
|
+
|
|
113
|
+
Send a welcome email five seconds after a user is created — without blocking the insert.
|
|
114
|
+
|
|
115
|
+
```ts
|
|
116
|
+
users.hook({
|
|
117
|
+
id : 'welcome-email',
|
|
118
|
+
on : 'after:insert',
|
|
119
|
+
schedule : 5_000,
|
|
120
|
+
callback : ({ data }) => mailer.send(data.email, 'welcome')
|
|
121
|
+
});
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
`schedule` makes the hook fire-and-forget via `setTimeout`. Errors from scheduled hooks are logged but don't propagate.
|
|
125
|
+
|
|
126
|
+
---
|
|
127
|
+
|
|
128
|
+
## Persist with an adapter, hydrate on boot
|
|
129
|
+
|
|
130
|
+
Wire an `Adapter` so writes are durable, then `pull()` once at startup.
|
|
131
|
+
|
|
132
|
+
```ts
|
|
133
|
+
class FileAdapter extends Adapter {
|
|
134
|
+
async sync(id, doc) { await fs.writeFile(`./data/${id}.json`, JSON.stringify(doc)); }
|
|
135
|
+
async pull() { return JSON.parse(await fs.readFile('./data/users.json', 'utf8')); }
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const users = new Model('users', schema, { adapter: FileAdapter });
|
|
139
|
+
await users.pull(); // hydrate before serving traffic
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
See [Adapter](./CORE.md#adapter).
|
|
143
|
+
|
|
144
|
+
---
|
|
145
|
+
|
|
146
|
+
## Method as RPC, event as fan-out
|
|
147
|
+
|
|
148
|
+
Two different shapes for "do something":
|
|
149
|
+
|
|
150
|
+
- **Method** — one caller, one return value. Use for actions like `resetPassword`, `regenerateApiKey`.
|
|
151
|
+
- **Event** — one or many callers via a subscription, validated args, multi-subscriber fan-out. Use when the action should also notify watchers.
|
|
152
|
+
|
|
153
|
+
```ts
|
|
154
|
+
// method — RPC
|
|
155
|
+
users.method({ id: 'resetPassword', handler: async (...) => { /* ... */ } });
|
|
156
|
+
|
|
157
|
+
// event — broadcasts via subscription
|
|
158
|
+
users.event({ id: 'roleChanged', schema, handler: async (...) => { /* ... */ } });
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
If in doubt, start with a method. Promote to an event only when more than one consumer needs to know it happened.
|
|
162
|
+
|
|
163
|
+
---
|
|
164
|
+
|
|
165
|
+
## Listening in-process vs over the wire
|
|
166
|
+
|
|
167
|
+
Two emitters live on every model. Use the right one for the job:
|
|
168
|
+
|
|
169
|
+
- **`model.on('insert' | 'update' | 'delete' | 'log', ...)`** — in-process, no query scoping. Use for audit logs, metrics, internal indexers.
|
|
170
|
+
- **`model.subscription(query).on('publish', ...)`** — query-scoped fan-out, designed to be bridged to clients via the [Server layer](./SERVER.md).
|
|
171
|
+
|
|
172
|
+
```ts
|
|
173
|
+
// in-process: every write
|
|
174
|
+
users.on('update', ({ key, data }) => metrics.increment('users.updated'));
|
|
175
|
+
|
|
176
|
+
// per-query: clients listening for admin changes
|
|
177
|
+
users.subscription({ role: 'admin' }).on('publish', payload => doSomething(payload));
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
---
|
|
181
|
+
|
|
182
|
+
> **Related:** [Model →](./MODEL.md) · [Errors →](./ERRORS.md)
|
package/docs/SERVER.md
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# Server Layer
|
|
2
|
+
|
|
3
|
+
`Server` is a thin transport that exposes models over HTTP and WebSocket. The package abstracts the underlying Koa + Socket.IO + OIDC + busboy machinery — you don't interact with it directly.
|
|
4
|
+
|
|
5
|
+
> [← Back to README](../README.md)
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## The mental model
|
|
10
|
+
|
|
11
|
+
- **Models in, HTTP + WS out.** Methods, uploads, streams, and events become endpoints.
|
|
12
|
+
- **Subscriptions become rooms.** `subscription.publish(payload)` reaches every connected client subscribed to that query.
|
|
13
|
+
- **A `/@metadata` endpoint** advertises every model, scope, operation, and event so clients can build typed proxies at runtime.
|
|
14
|
+
- **Auth is OIDC + JWT.** Routes can opt in with `protected: true`.
|
|
15
|
+
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
## Quick example
|
|
19
|
+
|
|
20
|
+
```ts
|
|
21
|
+
import { Server, Model } from '@hotfusion/modeller';
|
|
22
|
+
|
|
23
|
+
const server = new Server(3030, { domain: 'api', secret: process.env.SECRET });
|
|
24
|
+
|
|
25
|
+
server.route({
|
|
26
|
+
method : 'post',
|
|
27
|
+
path : '/system/ping',
|
|
28
|
+
callback : async (ctx) => ({ ok: true, ts: Date.now() })
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
server.start();
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
---
|
|
35
|
+
|
|
36
|
+
## Public methods
|
|
37
|
+
|
|
38
|
+
| Method | Purpose |
|
|
39
|
+
|-------------------------------------------------|-------------------------------------------------------|
|
|
40
|
+
| `route({ method, path, callback, protected? })` | Register a custom HTTP route. |
|
|
41
|
+
| `upload(path, handler)` | Multipart form-data endpoint, parsed into a Buffer. |
|
|
42
|
+
| `stream(handler)` | Single global handler for chunked socket uploads with resume. |
|
|
43
|
+
| `setOIDCProviders(path, providers)` | Wire OIDC providers under a base path. |
|
|
44
|
+
| `start()` | Bind to the port, mount routes, start Socket.IO. |
|
|
45
|
+
|
|
46
|
+
The full HTTP/WS surface is generated from your models — you don't need to wire each operation manually.
|
|
47
|
+
|
|
48
|
+
---
|
|
49
|
+
|
|
50
|
+
## Server events
|
|
51
|
+
|
|
52
|
+
```ts
|
|
53
|
+
server.on('mounted', (s) => console.log('listening'));
|
|
54
|
+
server.on('connection', ({ socket }) => /* socket.io connection */);
|
|
55
|
+
server.on('request', ({ ctx, flags }) => /* every HTTP request */);
|
|
56
|
+
server.on('authenticated', ({ profile, provider, ctx, middleware }) => /* OIDC success */);
|
|
57
|
+
server.on('provider', ({ token, sid, session }) => /* token issued */);
|
|
58
|
+
server.on('uploaded', ({ file, buffer, resolve }) => resolve({ /* meta */ }));
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
| Event | When it fires |
|
|
62
|
+
|-----------------|------------------------------------------------------------|
|
|
63
|
+
| `mounted` | After `start()` has bound the port and mounted routes. |
|
|
64
|
+
| `connection` | A new Socket.IO client connects. |
|
|
65
|
+
| `request` | Every incoming HTTP request, with `protected` flag. |
|
|
66
|
+
| `authenticated` | OIDC profile successfully verified. |
|
|
67
|
+
| `provider` | A provider has issued a token; session is established. |
|
|
68
|
+
| `uploaded` | A streamed upload finished assembling. Resolve with meta. |
|
|
69
|
+
|
|
70
|
+
---
|
|
71
|
+
|
|
72
|
+
## Auth
|
|
73
|
+
|
|
74
|
+
`{ protected: true }` on a route requires a valid `Authorization: Bearer <jwt>` header. The token is verified against the secret passed to the constructor (or auto-generated via `KeyGen.getSystemSecret()` if omitted).
|
|
75
|
+
|
|
76
|
+
`setOIDCProviders` wires multiple OIDC providers under a base path. The server handles the redirect, callback, token exchange, and session creation. Listen to `authenticated` and `provider` to react to login.
|
|
77
|
+
|
|
78
|
+
---
|
|
79
|
+
|
|
80
|
+
## Scope
|
|
81
|
+
|
|
82
|
+
This package is meant to be used as an abstraction. The Server layer intentionally keeps a narrow surface so consumers don't need to know Koa, Socket.IO, OIDC client config, or busboy internals.
|
|
83
|
+
|
|
84
|
+
If you need to dig deeper — custom middleware, custom socket rooms, OIDC introspection — read `src/server.ts` directly. The public API documented here is the contract.
|
|
85
|
+
|
|
86
|
+
---
|
|
87
|
+
|
|
88
|
+
> **Related:** [Utilities →](./UTILITIES.md) · [Errors →](./ERRORS.md)
|