coll-fns 1.0.0 → 1.2.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.
package/README.md CHANGED
@@ -1,372 +1,517 @@
1
- # coll-fns
1
+ A functional interface to **join MongoDB collections** and add **hooks before and after** insertions, updates and removals.
2
2
 
3
- A universal collection manipulation library that provides a unified API for working with different database backends through a protocol-based architecture.
3
+ # Overview
4
4
 
5
- ## Overview
5
+ Skip the repetitive glue code for joining collections and wiring related data. Define joins between collections once, then **fetch documents in the exact shape you need in a nested tree**. The data fetching is optimized to minimize database queries.
6
6
 
7
- `coll-fns` abstracts common database operations (CRUD, joins, field projections) behind a consistent interface, allowing you to write database-agnostic code that works across MongoDB, Meteor, and other data sources.
7
+ Stop repeating business logic all over your code base. Define hooks on collections that will be triggered conditionally **before or after insertions, updates or removals**: data validation, change propagation, logging, etc.
8
8
 
9
- **Originally designed for Meteor**, `coll-fns` solves the challenge of writing isomorphic database code that works seamlessly on both client (synchronous) and server (asynchronous) with the exact same API.
9
+ # Table of contents
10
10
 
11
- ## Key Features
11
+ - [Overview](#overview)
12
+ - [Table of contents](#table-of-contents)
13
+ - [Rationale](#rationale)
14
+ - [Installation and configuration](#installation-and-configuration)
15
+ - [`setProtocol(protocol)`](#setprotocolprotocol)
16
+ - [Bypassing `coll-fns`](#bypassing-coll-fns)
17
+ - [Joins and fetch](#joins-and-fetch)
18
+ - [Quick start examples](#quick-start-examples)
19
+ - [`join(Coll, joinDefinitions)`](#joincoll-joindefinitions)
20
+ - [Simple array join](#simple-array-join)
21
+ - [Sub-array joins](#sub-array-joins)
22
+ - [Filtered array-joins](#filtered-array-joins)
23
+ - [Object joins](#object-joins)
24
+ - [Function joins](#function-joins)
25
+ - [Recursive joins](#recursive-joins)
26
+ - [Join additional options](#join-additional-options)
27
+ - [`postFetch`](#postfetch)
28
+ - [`getJoins`](#getjoins)
29
+ - [`fetchList(Coll, selector, options)`](#fetchlistcoll-selector-options)
30
+ - [`fields` option and joins](#fields-option-and-joins)
31
+ - [Examples](#examples)
32
+ - [`setJoinPrefix(prefix)`](#setjoinprefixprefix)
33
+ - [Nested Joins](#nested-joins)
34
+ - [Recursion levels](#recursion-levels)
35
+ - [Documents transformation](#documents-transformation)
36
+ - [`fetchOne(Coll, selector, options)`](#fetchonecoll-selector-options)
37
+ - [`fetchIds(Coll, selector, options)`](#fetchidscoll-selector-options)
38
+ - [`exists(Coll, selector)`](#existscoll-selector)
39
+ - [`count(Coll, selector)`](#countcoll-selector)
40
+ - [`flattenFields(fields)`](#flattenfieldsfields)
41
+ - [Hooks and write operations](#hooks-and-write-operations)
42
+ - [`hook(Coll, hooksObj)`](#hookcoll-hooksobj)
43
+ - [Before hooks](#before-hooks)
44
+ - [After hooks](#after-hooks)
45
+ - [Hook definition properties](#hook-definition-properties)
46
+ - [Examples](#examples-1)
47
+ - [`insert(Coll, doc)`](#insertcoll-doc)
48
+ - [`update(Coll, selector, modifier, options)`](#updatecoll-selector-modifier-options)
49
+ - [`remove(Coll, selector)`](#removecoll-selector)
50
+ - [Hook best practices](#hook-best-practices)
51
+ - [License](#license)
12
52
 
13
- - 🔗 **Powerful Join System**: Define relationships between collections with automatic field resolution and nested joins
14
- - 🊝 **Extensible Hooks**: React to CRUD operations with before/after hooks for validation, transformation, and side effects
15
- - 🌐 **Isomorphic by Design**: Write once, run anywhere - same API for client-side (sync) and server-side (async) code
16
- - 🔌 **Protocol-based Architecture**: Switch between different database backends seamlessly
17
- - 📊 **Advanced Field Projections**: Support for nested fields, dot notation, and MongoDB-style projections
18
- - 🔄 **Promise/async Support**: Works with both synchronous and asynchronous protocols
19
- - 📝 **TypeScript-ready**: Includes JSDoc types for better IDE support
53
+ # Rationale
20
54
 
21
- ## Installation
55
+ Click on any element to unfold it and better understand the rationale behind this library!
56
+
57
+ <details>
58
+ <summary><strong>Data normalization</strong></summary>
59
+
60
+ While document databases with eventual consistency such as MongoDB are quite powerful, they often encourage denormalization patterns where information from one document must be copied on related documents. If everything goes well, one should be able to limit data fetching to self-contained documents only... (yeah, right).
61
+
62
+ This pattern adds much unwelcome complexity:
63
+
64
+ - the fields to denormalize must be determined in advance
65
+ - adding functionalities that would require new related fields means more denormalization and migration scripts
66
+ - the denormalized data must be kept in sync (somehow)
67
+ - denormalization can fail halfway through, leading to an incoherent state
68
+
69
+ I designed this library as a Meteor developer. I was hence _forced_ to use MongoDB documents database instead of a normalized database. Yet, I wanted to join collections so I could
70
+
71
+ - **keep data in only one place** (where it made most sense)
72
+ - **retrieve documents easily in an intuitive shape**.
73
+
74
+ Joins allow to do so. They are declared in advance between collections, defining how different types of documents relate to each other in a single place, instead of repeating this logic each time when querying the data. Data fetching knows about them and allows **arbitrary depth relationships** where documents are returned as **simple nested tree-shaped documents** without the need for helpers, methods or other complex structures... It's just data!
75
+
76
+ And joins can be defined in code shared by both server and client (I _hate_ redundant code 😒).
77
+
78
+ </details>
79
+
80
+ <details>
81
+ <summary><strong>DRY business logic</strong></summary>
82
+
83
+ Although joins between collections reduce the need for denormalization, it is often essential to update related documents based on what happens to another one (cascading removals, for example).
84
+
85
+ Reacting to data changes to propagate the information on the wire through API calls is also quite frequent. As is the need for final validation before commiting a change.
86
+
87
+ So how should we go about it? Should this code be incorporated in each mutation path? Should we create custom functions to update a specific type of document so all these side-effects are executed? If so, how do we not forget to use these functions instead of the normal collection methods? And how do we reduce the amount of boilerplate code that will simply handle the mechanics of data manipulation?
88
+
89
+ **Hooks** were introduced to solve these issues. The are defined in advance on each collection to **intercept or react to insertions, updates and removals**.
90
+
91
+ Even better, they can be defined so they fire only if certain conditions are met! And they can be **defined in many places**, making it much easier to group hooks by area of concern, instead of spreading their logic all over the place.
92
+
93
+ Focussing on the business logic, describing it only once, wasting no time on boilerplate code, that's a time (and sanity) saver for sure! ðŸĪŠ
94
+
95
+ </details>
96
+
97
+ <details>
98
+ <summary><strong>Protocol implementations</strong></summary>
99
+
100
+ I also faced a challenge when **migrating from Meteor 2.0 to 3.0**, which rendered isomorphic code cumbersome (same code running on the server and the client).
101
+
102
+ On the server, the new Meteor architecture now required the use of promises and async/await constructs.
103
+
104
+ On the client, data fetching with Minimongo must be synchronous in most frameworks to avoid complicated front-end code to handle promises.
105
+
106
+ I wanted a tool that would help me **keep the isomorphic query capabilities** while eliminating redundant glue code.
107
+
108
+ By designing the library with a protocol architecture, the same code can be run with a different database implementation.
109
+
110
+ On the client, I use a synchronous Minimongo implementation.
111
+
112
+ On the server, while still on Meteor 2.0, I also used a synchronous implementation that worked fine with Fibers. When I got ready to move to async/await and Meteor 3.0, I simply switched to an async protocol implementation without so much refactoring!
113
+
114
+ (Of course, the code had to be refactored to actually use `coll-fns`, but it comes with so much powerful features that it would have been a go-to anyway!)
115
+
116
+ And since it uses a protocol, it can be used with the **native MongoDB driver** too (built-in) and could even be adapted to almost any type of database... ðŸĪŊ
117
+
118
+ </details>
119
+
120
+ <details style="margin-bottom: 1rem">
121
+ <summary><strong>Functional API</strong></summary>
122
+
123
+ A lot of libraries that add functionalities to the database layer mutate the collection instances themselves or, when more respectful, offer ways to extend the collection constructor somehow.
124
+
125
+ In either case, it can lead to potential issues where different enhancement libraries conflict with each other. Method names might change, data might be saved in added fields on the collection instances (is `_joins` _really_ safe to use?)...
126
+
127
+ `coll-fns`, as the name implies, offers a **functional API**. Instead of doing `Collection.insert(doc)`, you would `insert(Collection, doc)`. I know... Moving the left parenthesis and replacing the dot with a comma is a lot to ask 😉, but it comes with benefits!
128
+
129
+ Instead of mutating the collections themselves, **joins and hooks definitions are saved in a global registry**. No collection instance is harmed (mutated) in the process. You could have a fancy custom collection wrapped and distorted by many different libraries; `coll-fns` won't add a thing to it.
130
+
131
+ Doing so makes it easy to offer a protocol interface: **the type of collection involved doesn't matter at all**. Heck, the collections could even be table names as strings and it would still work (if you implement a custom protocol)!
132
+
133
+ For Meteor developers, it also means being able to enhance the `Meteor.users` collection itself... event without access to instantiation code! ðŸĪ“
134
+
135
+ </details>
136
+
137
+ # Installation and configuration
138
+
139
+ **IMPORTANT**: For concision, the **examples will use the synchronous Meteor protocol** to avoid `async/await` boilerplate. Of course, your code will have to be adapted when used with an asynchronous protocol.
22
140
 
23
141
  ```bash
24
142
  npm install coll-fns
25
143
  ```
26
144
 
27
- ## Quick Start
145
+ ## `setProtocol(protocol)`
146
+
147
+ You will have to **define which protocol to use** before using any of the library's functionality.
28
148
 
29
149
  ```js
30
- import {
31
- setProtocol,
32
- fetchList,
33
- insert,
34
- update,
35
- remove,
36
- count,
37
- protocols,
38
- } from "coll-fns";
150
+ import { setProtocol, protocols } from "coll-fns";
39
151
 
40
- // Set up your protocol
41
- setProtocol(protocols.node(mongoClient));
152
+ /* Built-in protocols include:
153
+ * - meteorAsync
154
+ * - meteorSync
155
+ * - node
156
+ *
157
+ * Can also define a custom protocol! */
158
+ setProtocol(protocols.meteorAsync);
159
+ ```
42
160
 
43
- // Use the API
44
- const users = await fetchList(UsersCollection, { age: { $gte: 18 } });
45
- await insert(UsersCollection, { name: "Alice", age: 25 });
46
- await update(UsersCollection, { name: "Alice" }, { $set: { age: 26 } });
47
- const total = await count(UsersCollection, {});
48
- await remove(UsersCollection, { name: "Alice" });
161
+ In a Meteor project, you should probably define a **different protocol on client** (synchronous) **and server** (asynchronous).
162
+
163
+ ```js
164
+ import { setProtocol, protocols } from "coll-fns";
165
+
166
+ const protocol = Meteor.isServer ? protocols.meteorAsync : protocols.meteorSync;
167
+ setProtocol(protocol);
49
168
  ```
50
169
 
51
- ## Joins: The Power Feature
170
+ There's also a **native NodeJS MongoDB driver** protocol built-in (`protocols.node`).
52
171
 
53
- One of the most powerful features of `coll-fns` is its ability to define declarative joins between collections, eliminating the need for manual data fetching and aggregation.
172
+ <details style="margin-bottom: 1rem">
173
+ <summary><strong>Custom protocol</strong></summary>
54
174
 
55
- ### Basic Join Example
175
+ You could even define a **custom protocol** for the library to work with another interface to MongoDB or even to a completely different storage system! Joins and hooks should then work the same way (let me know if you do ðŸĪ“!).
56
176
 
57
177
  ```js
58
- const posts = await fetchList(
59
- PostsCollection,
60
- {},
61
- {
62
- joins: {
63
- author: {
64
- coll: UsersCollection,
65
- on: ["authorId", "_id"], // [local field, foreign field]
66
- fields: { name: 1, avatar: 1, email: 1 },
67
- },
68
- },
69
- fields: {
70
- title: 1,
71
- content: 1,
72
- "+": { author: 1 }, // Use '+' prefix to include join fields
73
- },
74
- }
75
- );
178
+ import { setProtocol } from "coll-fns";
76
179
 
77
- // Result: Each post includes an 'author' object with name, avatar, and email
78
- ```
180
+ const customProtocol = {
181
+ /* Return a documents count */
182
+ count(/* Coll, selector = {}, options = {} */) {},
79
183
 
80
- ### One-to-Many Joins
184
+ /* Return a list of documents. */
185
+ findList(/* Coll, selector = {}, options = {} */) {},
81
186
 
82
- ```js
83
- const posts = await fetchList(
84
- PostsCollection,
85
- {},
86
- {
87
- joins: {
88
- comments: {
89
- coll: CommentsCollection,
90
- on: ["_id", "postId"],
91
- many: true, // Returns an array of related documents
92
- fields: { text: 1, createdAt: 1 },
93
- },
94
- },
95
- fields: {
96
- title: 1,
97
- "+": { comments: 1 },
98
- },
99
- }
100
- );
187
+ /* Return the name of the collection. */
188
+ getName(/* Coll */) {},
189
+
190
+ /* Optional. Return a function that will transform each document
191
+ * after being fetched with descendants. */
192
+ getTransform(/* Coll */) {},
101
193
 
102
- // Result: Each post includes a 'comments' array
194
+ /* Insert a document in a collection
195
+ * and return the inserted _id. */
196
+ insert(/* Coll, doc, options */) {},
197
+
198
+ /* Remove documents in a collection
199
+ * and return the number of removed documents. */
200
+ remove(/* Coll, selector, options */) {},
201
+
202
+ /* Update documents in a collection
203
+ * and return the number of modified documents. */
204
+ update(/* Coll, selector, modifier, options */) {},
205
+ };
206
+
207
+ setProtocol(customProtocol);
103
208
  ```
104
209
 
105
- ### Nested Joins
210
+ </details>
211
+
212
+ ## Bypassing `coll-fns`
106
213
 
107
- Joins can be nested to fetch deeply related data:
214
+ `coll-fns` intentionally keeps collection instances untouched. It doesn't add any methods nor change exsting ones' behaviour. To bypass any `coll-fns` functionality, simply **use the normal collection methods** (ex: `Coll.insert`, `Coll.find().fetchAsync()`, `Coll.removeOne()`). Joins and hooks will only get fired when using the library's functions... and if joins and hooks have been pre-defined, of course! 😉
215
+
216
+ # Joins and fetch
217
+
218
+ ## Quick start examples
108
219
 
109
220
  ```js
110
- const posts = await fetchList(
111
- PostsCollection,
221
+ import { fetchList, join } from "coll-fns";
222
+ import { Comments, Posts, Users } from "/collections";
223
+
224
+ /* Define joins on Posts collection */
225
+ join(Posts, {
226
+ /* One-to-one join */
227
+ author: {
228
+ Coll: Users,
229
+ on: ["authorId", "_id"],
230
+ single: true,
231
+ },
232
+
233
+ /* One-to-many join */
234
+ comments: {
235
+ Coll: Comments,
236
+ on: ["_id", "postId"],
237
+ /* `single` defaults to false,
238
+ * so joined docs are returned as an array */
239
+ },
240
+ });
241
+
242
+ /* Fetch data with nested joined documents in the requested shape. */
243
+ fetchList(
244
+ Posts,
112
245
  {},
113
246
  {
114
- joins: {
115
- author: {
116
- coll: UsersCollection,
117
- on: ["authorId", "_id"],
118
- fields: { name: 1, avatar: 1 },
119
- },
120
- comments: {
121
- coll: CommentsCollection,
122
- on: ["_id", "postId"],
123
- many: true,
124
- fields: { text: 1, "+": { user: 1 } },
125
- joins: {
126
- user: {
127
- coll: UsersCollection,
128
- on: ["userId", "_id"],
129
- fields: { name: 1, avatar: 1 },
130
- },
131
- },
132
- },
133
- },
134
247
  fields: {
135
- title: 1,
136
- content: 1,
137
- "+": { author: 1, comments: 1 },
248
+ title: 1, // <= Own
249
+ author: { birthdate: 0 }, // <= Falsy = anything but these fields
250
+ comments: { text: 1 },
138
251
  },
139
252
  }
140
253
  );
254
+ ```
141
255
 
142
- // Result: Posts with author details and comments, each comment with user details
256
+ ```jsonc
257
+ [
258
+ {
259
+ "title": "Blabla",
260
+ "authorId": "foo", // <= Included by join definition
261
+ "author": {
262
+ "name": "Foo Bar",
263
+ "genre": "non-fiction",
264
+ },
265
+ /* Comments is a one-to-many join, so is returned as a list */
266
+ "comments": [{ "text": "Nice!" }, { "text": "Great!" }],
267
+ },
268
+ ]
143
269
  ```
144
270
 
145
- ### Recursive Join Depth Control
271
+ ## `join(Coll, joinDefinitions)`
272
+
273
+ Collections can be joined together with **globally pre-registered joins** to greatly simplify optimized data fetching.
146
274
 
147
- Control the depth of recursive joins to prevent infinite loops:
275
+ Joins are **not symmetrical by default**. Each collection should define its own relationships.
148
276
 
149
277
  ```js
150
- const users = await fetchList(
151
- UsersCollection,
152
- {},
278
+ import { join } from "coll-fns";
279
+
280
+ join(
281
+ /* Parent collection */
282
+ Coll,
283
+
284
+ /* Map of joins on children collections.
285
+ * Each key is the name of the field
286
+ * where joined docs will be placed. */
153
287
  {
154
- joins: {
155
- friends: {
156
- coll: UsersCollection,
157
- on: ["friendIds", "_id"],
158
- many: true,
159
- fields: { name: 1, "+": { friends: 1 } },
160
- joins: {
161
- friends: {
162
- coll: UsersCollection,
163
- on: ["friendIds", "_id"],
164
- many: true,
165
- },
166
- },
167
- },
288
+ joinProp1: {
289
+ /* joinDefinition */
168
290
  },
169
- fields: {
170
- name: 1,
171
- "+": { friends: 2 }, // Limit to 2 levels deep
291
+
292
+ joinProp2: {
293
+ /* joinDefinition */
172
294
  },
173
295
  }
174
296
  );
175
297
  ```
176
298
 
177
- ## Hooks: Extensibility Made Easy
299
+ Collections can define **as many joins as needed** without impacting performance. They will be used only when explicitly fetched. They can be **declared in different places** (as long as join names don't collide).
300
+
301
+ In the context of Meteor, joins could (should?) be **defined in shared client and server code**, but some might only ever be used in one environement or the other. They could also define a set of common joins, but add others in environment specific code.
178
302
 
179
- Hooks allow you to react to CRUD operations, enabling validation, transformation, logging, and side effects without modifying your core business logic.
303
+ By default, joins link one document from the parent collection to multiple ones from the child collection. In the case of a **one-to-one relationship**, the `single` property should be set to true.
180
304
 
181
- ### Setting Up Hooks
305
+ There are three main types of join definitions based on the argument to the `on` property: **array**, **object** and **function** joins.
306
+
307
+ ### Simple array join
308
+
309
+ `on` can be defined as an array of `[parentProp, childProp]` equality.
182
310
 
183
311
  ```js
184
- import { hook } from "coll-fns";
312
+ import { join } from "coll-fns";
313
+ import { Comments, Posts, Users } from "/collections";
314
+
315
+ join(Posts, {
316
+ author: {
317
+ Coll: Users,
318
+ /* `post.authorId === user._id` */
319
+ on: ["authorId", "_id"],
320
+ /* Single doc instead of a list */
321
+ single: true,
322
+ },
185
323
 
186
- // Before insert hook - validation and transformation
187
- hook(UsersCollection, "insert", "before", (doc) => {
188
- if (!doc.email) {
189
- throw new Error("Email is required");
190
- }
191
- // Transform data
192
- doc.email = doc.email.toLowerCase();
193
- doc.createdAt = new Date();
194
- return doc;
324
+ comments: {
325
+ Coll: Comments,
326
+ /* `post._id === comment.postId` */
327
+ on: ["_id", "postId"],
328
+ },
195
329
  });
196
330
 
197
- // After insert hook - side effects
198
- hook(UsersCollection, "insert", "after", (doc) => {
199
- console.log(`New user created: ${doc.name}`);
200
- // Send welcome email, update analytics, etc.
201
- sendWelcomeEmail(doc.email);
331
+ /* Reversed join from user to posts */
332
+ join(Users, {
333
+ posts: {
334
+ Coll: Posts,
335
+ on: ["_id", "authorId"],
336
+ },
202
337
  });
203
338
  ```
204
339
 
205
- ### Available Hook Types
206
-
207
- Hooks can be attached to any CRUD operation:
208
-
209
- - **`insert`**: Before/after document insertion
210
- - **`update`**: Before/after document updates
211
- - **`remove`**: Before/after document removal
212
- - **`fetch`**: Before/after fetching documents (useful for filtering)
213
-
214
- ### Hook Examples
340
+ ### Sub-array joins
215
341
 
216
- #### Validation and Authorization
342
+ Sometimes, the property referencing linked documents is an array (of ids, usually). In that case, the name of the array property should be nested in an array.
217
343
 
218
344
  ```js
219
- // Prevent unauthorized updates
220
- hook(PostsCollection, "update", "before", (selector, modifier, options) => {
221
- const currentUserId = getCurrentUserId();
222
- const post = fetchList(PostsCollection, selector)[0];
223
-
224
- if (post.authorId !== currentUserId) {
225
- throw new Error("Unauthorized");
226
- }
345
+ import { join } from "coll-fns";
346
+ import { Actions, Resources } from "/collections";
347
+
348
+ /* Each action can be associated with many resources and vice-versa.
349
+ * Resource's `actionIds` array is the link between them. */
350
+ join(Actions, {
351
+ resources: {
352
+ Coll: Resources,
353
+ on: ["_id", ["actionIds"]],
354
+ },
355
+ });
227
356
 
228
- return [selector, modifier, options];
357
+ /* The reverse join will flip the property names. */
358
+ join(Resources, {
359
+ actions: {
360
+ Coll: Actions,
361
+ on: [["actionIds"], "_id"],
362
+ },
229
363
  });
230
364
  ```
231
365
 
232
- #### Automatic Timestamps
366
+ ### Filtered array-joins
367
+
368
+ Some joins should target only specific documents in the foreign collection. A complementary selector can be passed to the third `on` array argument.
233
369
 
234
370
  ```js
235
- // Add timestamps automatically
236
- hook(PostsCollection, "insert", "before", (doc) => {
237
- doc.createdAt = new Date();
238
- doc.updatedAt = new Date();
239
- return doc;
240
- });
371
+ import { join } from "coll-fns";
372
+ import { Actions, Resources } from "/collections";
373
+
374
+ join(Resources, {
375
+ /* Only active tasks (third array element is a selector) */
376
+ activeTasks: {
377
+ Coll: Tasks,
378
+ on: ["_id", "resourceId", { active: true }],
379
+ },
241
380
 
242
- hook(PostsCollection, "update", "before", (selector, modifier, options) => {
243
- if (!modifier.$set) modifier.$set = {};
244
- modifier.$set.updatedAt = new Date();
245
- return [selector, modifier, options];
381
+ /* All tasks associated with a resource */
382
+ tasks: {
383
+ Coll: Tasks,
384
+ on: ["_id", "resourceId"],
385
+ },
246
386
  });
247
387
  ```
248
388
 
249
- #### Audit Logging
389
+ ### Object joins
390
+
391
+ The `on` join definition property can be an object representing a selector. It will always retrieve the same linked documents.
250
392
 
251
393
  ```js
252
- // Log all changes
253
- hook(PostsCollection, "update", "after", (result, selector, modifier) => {
254
- logToAuditTrail({
255
- collection: "posts",
256
- action: "update",
257
- selector,
258
- modifier,
259
- timestamp: new Date(),
260
- userId: getCurrentUserId(),
261
- });
394
+ import { join } from "coll-fns";
395
+ import { Factory, Workers } from "../collections";
396
+
397
+ join(Workers, {
398
+ /* All workers will have the same `factory` props. */
399
+ factory: {
400
+ Coll: Factory,
401
+ on: { name: "FACTORY ABC" },
402
+ single: true,
403
+ },
262
404
  });
263
405
  ```
264
406
 
265
- #### Data Denormalization
407
+ ### Function joins
266
408
 
267
- ```js
268
- // Update denormalized data
269
- hook(UsersCollection, "update", "after", (result, selector, modifier) => {
270
- const userId = selector._id;
271
- const userName = modifier.$set?.name;
409
+ When joins are too complex to be defined with an array or object (although rare), a function can be used as the `on` property. Each parent document will be passed to this function, which should return a selector to use on the child collection.
272
410
 
273
- if (userName) {
274
- // Update user name in all their posts
275
- update(
276
- PostsCollection,
277
- { authorId: userId },
278
- { $set: { authorName: userName } },
279
- { multi: true }
280
- );
281
- }
411
+ When using function-based joins, **a `fields` property should be added** to the join definition to declare which fields the parent document needs for the join to work:
412
+
413
+ ```js
414
+ import { join } from "coll-fns";
415
+ import { Comments, Posts } from "/collections";
416
+ import { twoMonthsPrior } from "/lib/dates";
417
+
418
+ join(Posts, {
419
+ recentComments: {
420
+ Coll: Comments,
421
+ on: (post) => {
422
+ const { _id: postId, postedAt } = post;
423
+
424
+ /* This argument must be defined at runtime. */
425
+ const minDate = twoMonthsPrior(postedAt);
426
+
427
+ /* Return a selector for the Comments collection */
428
+ return {
429
+ createdAt: { $gte: minDate },
430
+ postId,
431
+ };
432
+ },
433
+ /* Parent fields needed in the join function */
434
+ fields: {
435
+ _id: 1, // Optional. _id is implicit in any fetch.
436
+ postedAt: 1,
437
+ },
438
+ },
282
439
  });
283
440
  ```
284
441
 
285
- ## Meteor Integration: Isomorphic by Design
442
+ ### Recursive joins
286
443
 
287
- `coll-fns` was specifically designed to solve Meteor's challenge of writing code that works both on the client (synchronous) and server (asynchronous) with the same API.
288
-
289
- ### Server-Side (Async)
444
+ A collection can define joins on itself.
290
445
 
291
446
  ```js
292
- // server/main.js
293
- import { setProtocol, protocols } from "coll-fns";
294
-
295
- setProtocol(protocols.meteorAsync);
296
-
297
- // Methods automatically work with async
298
- Meteor.methods({
299
- async createPost(title, content) {
300
- const user = await fetchOne(UsersCollection, { _id: this.userId });
301
- return await insert(PostsCollection, {
302
- title,
303
- content,
304
- authorId: this.userId,
305
- });
447
+ import { join } from "coll-fns";
448
+ import { Users } from "/collections";
449
+
450
+ join(Users, {
451
+ friends: {
452
+ /* Use the same collection in the join definition */
453
+ Coll: Users,
454
+ on: [["friendIds"], "_id"],
306
455
  },
307
456
  });
308
457
  ```
309
458
 
310
- ### Client-Side (Sync)
459
+ ### Join additional options
311
460
 
312
- ```js
313
- // client/main.js
314
- import { setProtocol, protocols } from "coll-fns";
461
+ Any additional properties defined on the join (other than `Coll`, `on`, `single`, `postFetch`) will be treated as options to pass to the nested documents `fetchList`. It usually includes:
315
462
 
316
- setProtocol(protocols.meteorSync);
463
+ - `limit`: Maximum joined documents count
464
+ - `skip`: Documents to skip in the fetch
465
+ - `sort`: Sort order of joined documents
317
466
 
318
- // Same API, synchronous execution
319
- const posts = fetchList(
320
- PostsCollection,
321
- {},
322
- {
323
- joins: {
324
- author: {
325
- coll: UsersCollection,
326
- on: ["authorId", "_id"],
327
- },
467
+ ### `postFetch`
468
+
469
+ Children documents might need to be modified (transformed, ordered, filtered...) after being fetched. The `postFetch: (childrenDocs, parentDoc) => childrenDocs` join definition property can be used to do so.
470
+
471
+ The second argument of the function is the parent document. If some of its properties are needed, they should be declared in the `fields` property to ensure they are not missing from the requested fetched fields.
472
+
473
+ ```js
474
+ import { join } from "coll-fns";
475
+ import { Actions, Resources } from "/collections";
476
+ import { sortTasks } from "/lib/tasks";
477
+
478
+ join(Resources, {
479
+ tasks: {
480
+ Coll: Tasks,
481
+ on: ["_id", "resourceId"],
482
+
483
+ /* Ensure `tasksOrder` will be fetched */
484
+ fields: { tasksOrder: 1 },
485
+
486
+ /* Transform the joined tasks documents based on parent resource. */
487
+ postFetch(tasks, resource) {
488
+ const { tasksOrder = [] } = resource;
489
+ return sortTasks(tasks, tasksOrder);
328
490
  },
329
- fields: { title: 1, "+": { author: 1 } },
330
- }
331
- );
491
+ },
492
+ });
332
493
  ```
333
494
 
334
- ### Shared Code
495
+ ### `getJoins`
335
496
 
336
- ```js
337
- // imports/api/posts.js
338
- import { fetchList } from "coll-fns";
497
+ Use `getJoins(Coll)` to retrieve the complete dictionary of the collection's joins.
339
498
 
340
- // This function works on both client and server!
341
- export function getPostsWithAuthors() {
342
- return fetchList(
343
- PostsCollection,
344
- {},
345
- {
346
- joins: {
347
- author: {
348
- coll: UsersCollection,
349
- on: ["authorId", "_id"],
350
- fields: { name: 1, avatar: 1 },
351
- },
352
- },
353
- fields: { title: 1, content: 1, "+": { author: 1 } },
354
- }
355
- );
356
- }
357
- ```
499
+ ## `fetchList(Coll, selector, options)`
358
500
 
359
- ## API Reference
501
+ Fetch documents with the ability to **use collection joins**.
360
502
 
361
- ### Core Functions
503
+ **Options:**
362
504
 
363
- #### `fetchList(collection, selector, options)`
505
+ - `fields`: Field projection object
506
+ - `limit`: Maximum number of documents
507
+ - `skip`: Number of documents to skip
508
+ - `sort`: Sort specification
364
509
 
365
- Fetch an array of documents from a collection.
510
+ In its simplest form, `fetchList` can be used in much the same way as Meteor's `Coll.find(...args).fetch()`.
366
511
 
367
512
  ```js
368
513
  const users = await fetchList(
369
- UsersCollection,
514
+ Users,
370
515
  { status: "active" },
371
516
  {
372
517
  fields: { name: 1, email: 1 },
@@ -377,173 +522,366 @@ const users = await fetchList(
377
522
  );
378
523
  ```
379
524
 
380
- **Options:**
525
+ ### `fields` option and joins
381
526
 
382
- - `fields`: Field projection object
383
- - `sort`: Sort specification
384
- - `limit`: Maximum number of documents
385
- - `skip`: Number of documents to skip
386
- - `joins`: Join definitions for related collections
527
+ Contrary to regular projection objects, they can use nested properties `{ car: { make: 1 } }` instead of dot-string ones `{ car: 1, "car.make": 1 }`.
387
528
 
388
- #### `fetchOne(collection, selector, options)`
529
+ The joins defined on the collections must be **explicitly specified in the `fields`** object for the children documents to be fetched. The combined presence of join or own fields determines the shape of the fetched documents.
389
530
 
390
- Fetch a single document from a collection.
531
+ #### Examples
532
+
533
+ <details>
534
+ <summary>Join definitions for examples</summary>
391
535
 
392
536
  ```js
393
- const user = await fetchOne(
394
- UsersCollection,
395
- { _id: userId },
396
- {
397
- fields: { name: 1, email: 1 },
398
- }
399
- );
537
+ import { fetchList, join } from "coll-fns";
538
+ import { Comments, Posts, Users } from "/collections";
539
+
540
+ /* Define joins on Posts collection */
541
+ join(Posts, {
542
+ /* One-to-one join */
543
+ author: {
544
+ Coll: Users,
545
+ on: ["authorId", "_id"],
546
+ single: true,
547
+ },
548
+
549
+ /* One-to-many join */
550
+ comments: {
551
+ Coll: Comments,
552
+ on: ["_id", "postId"],
553
+ /* `single` defaults to false,
554
+ * so joined docs are returned as an array */
555
+ },
556
+ });
400
557
  ```
401
558
 
402
- #### `fetchIds(collection, selector, options)`
559
+ </details>
403
560
 
404
- Fetch only the `_id` field of matching documents.
561
+ <details>
562
+ <summary>Undefined (all) own fields</summary>
405
563
 
406
564
  ```js
407
- const userIds = await fetchIds(UsersCollection, { status: "active" });
408
- // Returns: ['id1', 'id2', 'id3']
565
+ fetchList(Posts, {});
409
566
  ```
410
567
 
411
- #### `exists(collection, selector)`
412
-
413
- Check if any documents match the selector.
414
-
415
- ```js
416
- const hasActiveUsers = await exists(UsersCollection, { status: "active" });
417
- // Returns: true or false
568
+ ```json
569
+ [{ "title": "Blabla", "authorId": "foo", "likes": 7 }]
418
570
  ```
419
571
 
420
- #### `insert(collection, doc)`
572
+ </details>
421
573
 
422
- Insert a document into a collection.
574
+ <details>
575
+ <summary>Some own fields</summary>
423
576
 
424
577
  ```js
425
- const newUser = await insert(UsersCollection, {
426
- name: "Bob",
427
- email: "bob@example.com",
428
- });
578
+ fetchList(
579
+ Posts,
580
+ {},
581
+ {
582
+ fields: {
583
+ title: true, // <= Own. Any truthy value works
584
+ },
585
+ }
586
+ );
429
587
  ```
430
588
 
431
- #### `update(collection, selector, modifier, options)`
589
+ ```json
590
+ [{ "title": "Blabla" }]
591
+ ```
432
592
 
433
- Update documents matching the selector.
593
+ </details>
594
+
595
+ <details>
596
+ <summary>Undefined (all) own fields, truthy (all) join fields</summary>
434
597
 
435
598
  ```js
436
- await update(
437
- UsersCollection,
438
- { status: "pending" },
439
- { $set: { status: "active" } },
440
- { multi: true }
599
+ fetchList(
600
+ Posts,
601
+ {},
602
+ {
603
+ fields: {
604
+ author: 1, // <= Join
605
+ },
606
+ }
441
607
  );
442
608
  ```
443
609
 
444
- #### `remove(collection, selector)`
445
-
446
- Remove documents matching the selector.
447
-
448
- ```js
449
- await remove(UsersCollection, { inactive: true });
610
+ ```jsonc
611
+ [
612
+ {
613
+ "title": "Blabla",
614
+ "authorId": "foo",
615
+ "likes": 7,
616
+ "author": {
617
+ "name": "Foo Bar",
618
+ "birthdate": "Some Date",
619
+ "genre": "non-fiction",
620
+ },
621
+ },
622
+ ]
450
623
  ```
451
624
 
452
- #### `count(collection, selector)`
625
+ </details>
453
626
 
454
- Count documents matching the selector.
627
+ <details>
628
+ <summary>Some own fields, truthy (all) join fields</summary>
455
629
 
456
630
  ```js
457
- const activeUsers = await count(UsersCollection, { status: "active" });
631
+ fetchList(
632
+ Posts,
633
+ {},
634
+ {
635
+ fields: {
636
+ title: 1, // <= Own
637
+ author: 1, // <= Join
638
+ },
639
+ }
640
+ );
458
641
  ```
459
642
 
460
- ### Hook Functions
643
+ ```jsonc
644
+ [
645
+ {
646
+ "title": "Blabla",
647
+ "authorId": "foo", // <= Included by join definition
648
+ "author": {
649
+ "name": "Foo Bar",
650
+ "birthdate": "Some Date",
651
+ "genre": "non-fiction",
652
+ },
653
+ },
654
+ ]
655
+ ```
461
656
 
462
- #### `hook(collection, operation, timing, fn)`
657
+ </details>
463
658
 
464
- Add a hook to a collection operation.
659
+ <details>
660
+ <summary>Some own fields, some join fields</summary>
465
661
 
466
662
  ```js
467
- hook(UsersCollection, "insert", "before", (doc) => {
468
- // Modify or validate doc
469
- return doc;
470
- });
663
+ fetchList(
664
+ Posts,
665
+ {},
666
+ {
667
+ fields: {
668
+ title: 1, // <= Own
669
+ author: { birthdate: 0 }, // <= Falsy = anything but these fields
670
+ comments: { text: 1 },
671
+ },
672
+ }
673
+ );
471
674
  ```
472
675
 
473
- **Parameters:**
676
+ ```jsonc
677
+ [
678
+ {
679
+ "title": "Blabla",
680
+ "authorId": "foo", // <= Included by join definition
681
+ "author": {
682
+ "name": "Foo Bar",
683
+ "genre": "non-fiction",
684
+ },
685
+ /* Comments is a one-to-many join, so is returned as a list */
686
+ "comments": [{ "text": "Nice!" }, { "text": "Great!" }],
687
+ },
688
+ ]
689
+ ```
474
690
 
475
- - `collection`: The collection to hook into
476
- - `operation`: `'insert'`, `'update'`, `'remove'`, or `'fetch'`
477
- - `timing`: `'before'` or `'after'`
478
- - `fn`: Hook function (return value depends on operation and timing)
691
+ </details>
479
692
 
480
- ### Protocol Management
693
+ ### `setJoinPrefix(prefix)`
481
694
 
482
- #### `setProtocol(protocol)`
695
+ If this combination approach seems confusing, it is possible to define a prefix that must be explicitly used when joined documents should be used. **The prefix will be removed** from the returned documents.
483
696
 
484
- Set the active database protocol.
697
+ Setting the prefix to null or undefined allows using join fields at the document root like any normal field.
485
698
 
486
699
  ```js
487
- import { setProtocol, protocols } from "coll-fns";
700
+ import { setJoinPrefix } from "coll-fns";
488
701
 
489
- setProtocol(protocols.meteorAsync);
702
+ /* All join fields will have to be prefixed with "+" */
703
+ setJoinPrefix("+");
704
+
705
+ /* Some own fields, some join fields */
706
+ fetchList(
707
+ Posts,
708
+ {},
709
+ {
710
+ fields: {
711
+ title: 1, // <= Own
712
+
713
+ /* Join fields must be nested under the prefix key */
714
+ "+": {
715
+ author: { name: 1, birthdate: 1 }, // <= Join sub fields
716
+ },
717
+ },
718
+ }
719
+ );
490
720
  ```
491
721
 
492
- #### `getProtocol()`
722
+ This option could also be useful if a document can have some denormalized data with the same property name as the join. The denormalized values or the joined document would then be returned based on the use of the prefix.
723
+
724
+ If, for some reason, you need to retrieve the prefix, you can do so with `getJoinPrefix(Coll)`.
725
+
726
+ ### Nested Joins
493
727
 
494
- Get the current active protocol.
728
+ Joins can be nested to fetch deeply related data. See [Hooks best practices](#hook-best-practices) for how hooks can be used with nested joins.
495
729
 
496
730
  ```js
497
- const currentProtocol = getProtocol();
498
- ```
731
+ import { fetchList } from "coll-fns";
499
732
 
500
- #### `updateProtocol(updates)`
733
+ const posts = fetchList(
734
+ Posts,
735
+ {},
736
+ {
737
+ fields: {
738
+ title: 1,
501
739
 
502
- Update specific methods of the current protocol.
740
+ /* Level 1 : One-to-many join */
741
+ comments: {
742
+ text: 1,
503
743
 
504
- ```js
505
- import { updateProtocol } from "coll-fns";
744
+ /* Level 2 : One-to-one join */
745
+ user: {
746
+ username: 1,
747
+ },
748
+ },
749
+ },
750
+ }
751
+ );
752
+ ```
506
753
 
507
- updateProtocol({
508
- fetch: customFetchImplementation,
509
- });
754
+ ```json
755
+ {
756
+ "title": "Blabla",
757
+ "comments": [
758
+ { "text": "Nice!", "user": { "username": "foo"} },
759
+ { "text": "Great!", "user": { "username": "bar" } }
760
+ ]
761
+ },
510
762
  ```
511
763
 
512
- ### Field Projections
764
+ ### Recursion levels
513
765
 
514
- #### Nested Fields
766
+ When a field is declared using a positive number, its value is treated as a recursion limit. This could help preventing infinite loops. The value `Infinity` can even be used to go as deep as possible (to exhaustion), although it involves a greater risk of infinite loops.
515
767
 
516
768
  ```js
517
- // Nested object notation
518
- const users = await fetchList(
519
- UsersCollection,
769
+ import { join } from "coll-fns";
770
+ import { Users } from "/collections";
771
+
772
+ /* Pre-register recursive join */
773
+ join(Users, {
774
+ friends: {
775
+ Coll: Users,
776
+ on: [["friendIds"], "_id"],
777
+ },
778
+ });
779
+
780
+ fetchList(
781
+ Users,
520
782
  {},
521
783
  {
522
784
  fields: {
523
785
  name: 1,
524
- address: {
525
- street: 1,
526
- city: 1,
527
- },
786
+ /* Join field. Limit to 2 levels deep, reusing parent fields */
787
+ friends: 2,
528
788
  },
529
789
  }
530
790
  );
791
+ ```
792
+
793
+ ### Documents transformation
531
794
 
532
- // Dot notation (MongoDB-style)
795
+ Documents can be transformed after fetching. Collection-level transforms are automatically applied if the protocol allows it:
796
+
797
+ **Meteor:**
798
+
799
+ ```js
800
+ import { Mongo } from "meteor/mongo";
801
+
802
+ const Users = new Mongo.Collection("users", {
803
+ transform: (doc) => ({
804
+ ...doc,
805
+ fullName: `${doc.firstName} ${doc.lastName}`,
806
+ }),
807
+ });
808
+ ```
809
+
810
+ **For a specific fetch**, pass a `transform` option:
811
+
812
+ ```js
533
813
  const users = await fetchList(
534
- UsersCollection,
535
- {},
814
+ Users,
815
+ { status: "active" },
816
+ {
817
+ transform: (doc) => ({
818
+ ...doc,
819
+ fullName: `${doc.firstName} ${doc.lastName}`,
820
+ }),
821
+ }
822
+ );
823
+ ```
824
+
825
+ To skip a collection's transform, pass `transform: null`. Transforms are applied **after joins resolve**, so they have access to joined data. See [Nested Joins](#nested-joins) for examples of using transforms with complex data structures.
826
+
827
+ ## `fetchOne(Coll, selector, options)`
828
+
829
+ Fetch a single document from a collection. Same behaviour as `fetchList`.
830
+
831
+ ```js
832
+ import { fetchOne } from "coll-fns";
833
+ import { Users } from "/collections";
834
+
835
+ const user = fetchOne(
836
+ Users,
837
+ { _id: userId },
536
838
  {
537
839
  fields: {
538
840
  name: 1,
539
- "address.street": 1,
540
- "address.city": 1,
841
+ friends: 1, // <= Join
541
842
  },
542
843
  }
543
844
  );
544
845
  ```
545
846
 
546
- #### Flattening Fields
847
+ ## `fetchIds(Coll, selector, options)`
848
+
849
+ Fetch only the `_id` field of matching documents. `fields` option will be ignored.
850
+
851
+ ```js
852
+ import { fetchOne } from "coll-fns";
853
+ import { Users } from "/collections";
854
+
855
+ const userIds = fetchIds(Users, { status: "active" });
856
+ ```
857
+
858
+ ## `exists(Coll, selector)`
859
+
860
+ Check if any document matches the selector.
861
+
862
+ ```js
863
+ import { fetchOne } from "coll-fns";
864
+ import { Users } from "/collections";
865
+
866
+ const hasActiveUsers = exists(Users, { status: "active" });
867
+ // Returns: true or false
868
+ ```
869
+
870
+ ## `count(Coll, selector)`
871
+
872
+ Count documents matching the selector.
873
+
874
+ ```js
875
+ import { fetchOne } from "coll-fns";
876
+ import { Users } from "/collections";
877
+
878
+ const activeUsersCount = count(UsersCollection, { status: "active" });
879
+ // Returns an integer
880
+ ```
881
+
882
+ ## `flattenFields(fields)`
883
+
884
+ Flatten a general field specifiers object (which could include nested objects) into a MongoDB-compatible one that uses dot-notation.
547
885
 
548
886
  ```js
549
887
  import { flattenFields } from "coll-fns";
@@ -558,120 +896,382 @@ const flattened = flattenFields({
558
896
  // Result: { name: 1, 'address.street': 1, 'address.city': 1 }
559
897
  ```
560
898
 
561
- ## Available Protocols
899
+ # Hooks and write operations
900
+
901
+ Hooks allow you to **intercept and react to data mutations** (insertions, updates, removals) on collections. They are triggered conditionally **before or after** write operations, making them ideal for validation, cascading updates, logging, and other side effects.
562
902
 
563
- All protocols are available through the `protocols` namespace:
903
+ ## `hook(Coll, hooksObj)`
904
+
905
+ Register hooks on a collection to run before or after specific write operations (insert, update, remove).
906
+
907
+ The same object argument can define multiple hook types. Each hook type is defined using an array of hook definitions, making it possible to **define multiple hooks at once**.
908
+
909
+ Hooks can be **defined in multiple places** in your codebase. This allows grouping functionally related hooks together.
564
910
 
565
911
  ```js
566
- import { protocols } from "coll-fns";
912
+ import { hook } from "coll-fns";
913
+ import { Users, Posts } from "/collections";
914
+
915
+ hook(Users, {
916
+ beforeInsert: [
917
+ {
918
+ fn(doc) {
919
+ if (!doc.email) throw new Error("Email is required");
920
+
921
+ doc.createdAt = new Date();
922
+ },
923
+ },
924
+ ],
925
+
926
+ onInserted: [
927
+ {
928
+ fn(doc) {
929
+ console.log(`New user created: ${doc._id}`);
930
+ },
931
+ },
932
+ ],
933
+ });
567
934
  ```
568
935
 
569
- ### Node.js (MongoDB)
936
+ ### Before hooks
570
937
 
571
- ```js
572
- import { setProtocol, protocols } from "coll-fns";
573
- import { MongoClient } from "mongodb";
938
+ These hooks run **before** the write operation and can **prevent the operation** by throwing an error.
939
+
940
+ - **`beforeInsert`**: Runs before inserting a document. Receives `(doc)`.
941
+ - **`beforeUpdate`**: Runs before updating documents. Receives `([...docsToUpdate], modifier)`.
942
+ - **`beforeRemove`**: Runs before removing documents. Receives `([...docsToRemove])`.
943
+
944
+ Although arguments can be mutated, it is not the main purpose of these hooks. Mutations are brittle and hard to debug.
945
+
946
+ `beforeUpdate` and `beforeRemove` receive an **array of targeted documents**, whereas `beforeInsert` receives a **single document**.
947
+
948
+ ### After hooks
574
949
 
575
- const client = new MongoClient(url);
576
- await client.connect();
577
- setProtocol(protocols.node(client));
950
+ These hooks run **after** the write operation completes and are **fire-and-forget** (not awaited). They should not throw errors that should get back to the caller.
951
+
952
+ - **`onInserted`**: Runs after a document is inserted. Receives `(doc)`.
953
+ - **`onUpdated`**: Runs after a document is updated. Receives `(afterDoc, beforeDoc)`.
954
+ - **`onRemoved`**: Runs after a document is removed. Receives `(doc)`.
955
+
956
+ ### Hook definition properties
957
+
958
+ Each hook definition is an object with the following properties:
959
+
960
+ ```js
961
+ {
962
+ /* Required. The function to execute.
963
+ * Arguments depend on the hook type (see above). */
964
+ fn(...args) { /* ... */ },
965
+
966
+ /* Optional. Fields to fetch for the documents passed to the hook.
967
+ * Fields for multiple hooks of the same type are automatically combined.
968
+ * If any hook of a type requests all fields with `undefined` or `true`,
969
+ * all other similar hooks will also get the entire documents.
970
+ * Has no effect on `beforeInsert`: the doc to be inserted is the argument. */
971
+ fields: { name: 1, email: 1 },
972
+
973
+ /* Optional (`onUpdated` only). If true, fetch the document state
974
+ * before the update with the same `fields` value.
975
+ * Otherwise, only _id is fetched initially (they would have been
976
+ * needed anyway to fetch their "after" versions). */
977
+ before: true,
978
+
979
+ /* Optional. Predicate that prevents the hook from running if it
980
+ * returns a truthy value. Receives the same arguments as fn. */
981
+ unless(doc) { return doc.isBot; },
982
+
983
+ /* Optional. Predicate that allows the hook to run only if it
984
+ * returns a truthy value. Receives the same arguments as fn. */
985
+ when(doc) { return doc.status === "pending"; },
986
+
987
+ /* Optional handler called if the hook function throws an error.
988
+ * A default handler that logs to console.error is defined
989
+ * for after-hooks (onInserted, onUpdated, onRemoved)
990
+ * to prevent an error from crashing the server. */
991
+ onError(err, hookDef) { /* ... */ },
992
+ }
578
993
  ```
579
994
 
580
- ### Meteor (Synchronous)
995
+ ### Examples
996
+
997
+ <details>
998
+ <summary>Data validation</summary>
581
999
 
582
1000
  ```js
583
- import { setProtocol, protocols } from "coll-fns";
1001
+ hook(Users, {
1002
+ beforeInsert: [
1003
+ {
1004
+ fn(doc) {
1005
+ if (!doc.email || !doc.email.includes("@")) {
1006
+ throw new Error("Invalid email");
1007
+ }
1008
+ },
1009
+ },
1010
+ ],
1011
+ });
1012
+ ```
1013
+
1014
+ </details>
584
1015
 
585
- setProtocol(protocols.meteorSync);
1016
+ <details>
1017
+ <summary>Cascading updates</summary>
1018
+
1019
+ ```js
1020
+ /* If user's name changed, update their posts' denormalized data */
1021
+ hook(Users, {
1022
+ onUpdated: [
1023
+ {
1024
+ fields: { name: 1 },
1025
+ /* Use `when` predicate to run the hook only on this condition.
1026
+ * Could also have used `unless` or checked condition inside `fn`. */
1027
+ when: (after, before) => after.name !== before.name,
1028
+
1029
+ /* Effect to run - uses update() which also supports hooks */
1030
+ fn(after) {
1031
+ const { _id, name } = after;
1032
+ update(Posts, { authorId: _id }, { $set: { authorName: name } });
1033
+ },
1034
+ },
1035
+ ],
1036
+ });
586
1037
  ```
587
1038
 
588
- ### Meteor (Asynchronous)
1039
+ </details>
1040
+
1041
+ <details>
1042
+ <summary>Conditional hooks with when/unless</summary>
589
1043
 
590
1044
  ```js
591
- import { setProtocol, protocols } from "coll-fns";
1045
+ hook(Posts, {
1046
+ beforeRemove: [
1047
+ {
1048
+ /* Limit fetched fields of docs to be removed */
1049
+ fields: { _id: 1 },
1050
+ /* Only run for non-admin users */
1051
+ unless() {
1052
+ return Meteor.user()?.isAdmin;
1053
+ },
1054
+ fn() {
1055
+ throw new Error("Only admins can delete posts");
1056
+ },
1057
+ },
1058
+ ],
592
1059
 
593
- setProtocol(protocols.meteorAsync);
1060
+ onRemoved: [
1061
+ {
1062
+ /* Only log removal of published posts */
1063
+ when(doc) {
1064
+ return doc.status === "published";
1065
+ },
1066
+ fn(doc) {
1067
+ logEvent("post_deleted", { postId: doc._id });
1068
+ },
1069
+ },
1070
+ ],
1071
+ });
594
1072
  ```
595
1073
 
596
- ## Advanced Usage
1074
+ </details>
597
1075
 
598
- ### Custom Protocols
599
-
600
- Create your own protocol by implementing the required methods:
1076
+ <details>
1077
+ <summary>Cascading removals</summary>
601
1078
 
602
1079
  ```js
603
- const customProtocol = {
604
- fetch: (coll, selector, options) => {
605
- /* ... */
606
- },
607
- insert: (coll, doc) => {
608
- /* ... */
609
- },
610
- update: (coll, selector, modifier, options) => {
611
- /* ... */
612
- },
613
- remove: (coll, selector) => {
614
- /* ... */
615
- },
616
- count: (coll, selector) => {
617
- /* ... */
618
- },
619
- };
1080
+ hook(Users, {
1081
+ beforeRemove: [
1082
+ {
1083
+ fn(usersToRemove) {
1084
+ const userIds = usersToRemove.map((u) => u._id);
1085
+
1086
+ /* Prevent removal if user has published posts */
1087
+ const hasPublished = exists(Posts, {
1088
+ authorId: { $in: userIds },
1089
+ status: "published",
1090
+ });
1091
+
1092
+ if (hasPublished) {
1093
+ throw new Error("Cannot delete users with published posts");
1094
+ }
1095
+ },
1096
+ },
1097
+ ],
620
1098
 
621
- setProtocol(customProtocol);
1099
+ onRemoved: [
1100
+ {
1101
+ fn(user) {
1102
+ /* Clean up related data after user is removed.
1103
+ * See remove() for more details on how this integrates with hooks. */
1104
+ remove(Comments, { authorId: user._id });
1105
+ },
1106
+ },
1107
+ ],
1108
+ });
622
1109
  ```
623
1110
 
624
- ### Join Management
1111
+ </details>
1112
+
1113
+ The data mutation methods below use basically the same arguments as [Meteor collection methods](https://docs.meteor.com/api/collections.html#Mongo-Collection-updateAsync).
625
1114
 
626
- #### Setting Custom Join Prefix
1115
+ ## `insert(Coll, doc)`
627
1116
 
628
- By default, join fields are prefixed with `+`. You can customize this:
1117
+ Insert a document into a collection. Returns the document \_id. Runs `beforeInsert` and `onInserted` hooks if defined.
629
1118
 
630
1119
  ```js
631
- import { setJoinPrefix } from "coll-fns";
1120
+ const newUser = insert(Users, {
1121
+ name: "Bob",
1122
+ email: "bob@example.com",
1123
+ });
1124
+ ```
1125
+
1126
+ **Execution flow:**
632
1127
 
633
- setJoinPrefix("joins"); // Now use { joins: { author: 1 } } instead of { '+': { author: 1 } }
1128
+ 1. Run `beforeInsert` hooks (can throw to prevent insertion)
1129
+ 2. Insert the document
1130
+ 3. Fire `onInserted` hooks asynchronously (without awaiting)
1131
+
1132
+ ## `update(Coll, selector, modifier, options)`
1133
+
1134
+ Update documents matching the selector. Returns the number of documents modified. Runs `beforeUpdate` and `onUpdated` hooks if defined. Updates **multiple documents by default** (unlike Meteor's behavior).
1135
+
1136
+ ```js
1137
+ update(Users, { status: "pending" }, { $set: { status: "active" } });
634
1138
  ```
635
1139
 
636
- If join prefix is set to a falsy value, join fields can be declared at the document root like any native field.
1140
+ **Execution flow:**
1141
+
1142
+ 1. Fetch target documents with `beforeUpdate` and `onUpdated.before` fields
1143
+ 2. Run `beforeUpdate` hooks with `(docsToUpdate, modifier)` (can throw to prevent update)
1144
+ 3. Execute the update
1145
+ 4. Fetch updated documents again with `onUpdated` fields
1146
+ 5. Fire `onUpdated` hooks asynchronously with `(afterDoc, beforeDoc)` pairs
1147
+
1148
+ **Options:**
1149
+
1150
+ - `multi` (default: `true`): Update multiple documents or just the first match;
1151
+ - `arrayFilters`: Optional. Used in combination with [MongoDB filtered positional operator](https://www.mongodb.com/docs/manual/reference/operator/update/positional-filtered/) to specify which elements to modify in an array field.
1152
+
1153
+ ## `remove(Coll, selector)`
1154
+
1155
+ Remove documents matching the selector. Runs `beforeRemove` and `onRemoved` hooks if defined.
637
1156
 
638
1157
  ```js
639
- import { setJoinPrefix } from "coll-fns";
1158
+ remove(Users, { inactive: true });
1159
+ ```
1160
+
1161
+ **Execution flow:**
1162
+
1163
+ 1. Fetch documents matching the selector with `beforeRemove` and `onRemoved` fields
1164
+ 2. Run `beforeRemove` hooks with matched documents (can throw to prevent removal)
1165
+ 3. Remove the documents
1166
+ 4. Fire `onRemoved` hooks asynchronously with each removed document
640
1167
 
641
- setJoinPrefix(null); // Now use { author: 1 } instead of { '+': { author: 1 } }
1168
+ ## Hook best practices
1169
+
1170
+ <details>
1171
+ <summary><strong>Error handling</strong></summary>
1172
+
1173
+ **Before hooks** should throw errors to prevent operations:
1174
+
1175
+ ```js
1176
+ hook(Users, {
1177
+ beforeInsert: [
1178
+ {
1179
+ fn(doc) {
1180
+ if (!isValidEmail(doc.email)) {
1181
+ throw new Error("Invalid email");
1182
+ }
1183
+ },
1184
+ },
1185
+ ],
1186
+ });
642
1187
  ```
643
1188
 
644
- #### Getting Join Configuration
1189
+ **After hooks** have a default error handler that logs to `console.error`. Define a custom `onError` handler if you need different behavior. Receives (err, hookDef) where hookDef is the hook definition enhanced with metadata, including `Coll`, `collName` and `hookType`.
645
1190
 
646
1191
  ```js
647
- import { getJoins, getJoinPrefix } from "coll-fns";
1192
+ hook(Users, {
1193
+ onInserted: [
1194
+ {
1195
+ fn(doc) {
1196
+ /* ... */
1197
+ },
1198
+ onError(err, hookDef) {
1199
+ logToService(err, hookDef.collName);
1200
+ },
1201
+ },
1202
+ ],
1203
+ });
1204
+ ```
1205
+
1206
+ </details>
1207
+
1208
+ <details>
1209
+ <summary><strong>Field optimization</strong></summary>
1210
+
1211
+ Always declare which fields your hook needs with the `fields` property. This reduces database queries and improves performance:
648
1212
 
649
- const joins = getJoins(fields); // Extract join definitions from fields
650
- const prefix = getJoinPrefix(); // Get current join prefix (default: '+')
1213
+ ```js
1214
+ hook(Posts, {
1215
+ onUpdated: [
1216
+ {
1217
+ /* Only fetch these fields */
1218
+ fields: { authorId: 1, title: 1 },
1219
+ fn(afterPost, beforePost) {
1220
+ if (afterPost.title !== beforePost.title) {
1221
+ notifySubscribers(afterPost);
1222
+ }
1223
+ },
1224
+ },
1225
+ ],
1226
+ });
651
1227
  ```
652
1228
 
653
- ## Project Structure
1229
+ </details>
1230
+
1231
+ <details>
1232
+ <summary><strong>Conditional execution</strong></summary>
1233
+
1234
+ Use `when` and `unless` to avoid unnecessary side effects while keeping code clean and predictable:
654
1235
 
1236
+ ```js
1237
+ hook(Users, {
1238
+ onUpdated: [
1239
+ {
1240
+ fields: { status: 1 },
1241
+ /* Only run if status actually changed */
1242
+ unless(after, before) {
1243
+ return after.status === before.status;
1244
+ },
1245
+ fn(after, before) {
1246
+ sendStatusChangeEmail(after);
1247
+ },
1248
+ },
1249
+ ],
1250
+ });
655
1251
  ```
656
- src/
657
- ├── count.js - Count operation
658
- ├── fetch.js - Fetch operations (fetchList, fetchOne, fetchIds, exists)
659
- ├── fields.js - Field projection utilities
660
- ├── hook.js - Hook system for extending operations
661
- ├── index.js - Main exports
662
- ├── insert.js - Insert operation
663
- ├── join.js - Join functionality
664
- ├── protocol.js - Protocol management
665
- ├── remove.js - Remove operation
666
- ├── update.js - Update operation
667
- ├── util.js - Utility functions
668
- └── protocols/ - Database protocol implementations
669
- ├── index.js
670
- ├── meteorAsync.js
671
- ├── meteorSync.js
672
- └── node.js
1252
+
1253
+ </details>
1254
+
1255
+ <details style="margin-bottom: 1rem">
1256
+ <summary><strong>Async operations</strong></summary>
1257
+
1258
+ Hooks support both synchronous and asynchronous code. Returning a promise from a before-hook will delay the write operation:
1259
+
1260
+ ```js
1261
+ hook(Users, {
1262
+ beforeInsert: [
1263
+ {
1264
+ async fn(doc) {
1265
+ /* Wait for external service */
1266
+ doc.externalId = await createExternalUser(doc);
1267
+ },
1268
+ },
1269
+ ],
1270
+ });
673
1271
  ```
674
1272
 
675
- ## License
1273
+ </details>
1274
+
1275
+ # License
676
1276
 
677
1277
  MIT