coll-fns 1.1.0 → 1.3.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,958 +1,1386 @@
1
- # coll-fns
1
+ A functional interface to **join MongoDB collections** and add **hooks before and after** insertions, updates and removals.
2
2
 
3
- Work with MongoDB collections using declarative joins and reusable hooks—fetch related docs without boilerplate and keep cross-collection logic in one place.
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
- Skip the repetitive glue code for joining collections and wiring related data. Define your relationships once, then ask for only the fields you need in a nested tree at query time. You keep flexibility (less denormalization needed) while still fetching children efficiently—often better than ad-hoc code copied across endpoints.
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
- Hooks let you centralize cross-collection side effects and validations (e.g., propagate changes, enforce rules) so you don't repeat that logic in every mutation path.
9
+ # Table of contents
10
10
 
11
- **Works identically on Meteor server (async) and client (sync)** with the same code, and supports any MongoDB-compatible backend you plug in.
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
+ - [`configurePool(options)`](#configurepooloptions)
51
+ - [Default behavior](#default-behavior)
52
+ - [Options](#options)
53
+ - [Hook best practices](#hook-best-practices)
54
+ - [License](#license)
12
55
 
13
- ## Key Features
56
+ # Rationale
14
57
 
15
- - **Powerful Join System**: Define relationships between collections with automatic field resolution and nested joins
16
- - **Extensible Hooks**: React to CRUD operations with before/after hooks for validation, transformation, and side effects
17
- - **Isomorphic by Design**: Write once, run anywhere - same API for client-side (sync) and server-side (async) code
18
- - **Protocol-based Architecture**: Switch between different database backends seamlessly
19
- - **Advanced Field Projections**: Support for nested fields, dot notation, and MongoDB-style projections
20
- - **Promise/async Support**: Works with both synchronous and asynchronous protocols
21
- - **TypeScript-ready**: Includes JSDoc types for better IDE support
58
+ Click on any element to unfold it and better understand the rationale behind this library!
22
59
 
23
- ## Installation
60
+ <details>
61
+ <summary><strong>Data normalization</strong></summary>
62
+
63
+ 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).
64
+
65
+ This pattern adds much unwelcome complexity:
66
+
67
+ - the fields to denormalize must be determined in advance
68
+ - adding functionalities that would require new related fields means more denormalization and migration scripts
69
+ - the denormalized data must be kept in sync (somehow)
70
+ - denormalization can fail halfway through, leading to an incoherent state
71
+
72
+ 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
73
+
74
+ - **keep data in only one place** (where it made most sense)
75
+ - **retrieve documents easily in an intuitive shape**.
76
+
77
+ 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!
78
+
79
+ And joins can be defined in code shared by both server and client (I _hate_ redundant code 😒).
80
+
81
+ </details>
82
+
83
+ <details>
84
+ <summary><strong>DRY business logic</strong></summary>
85
+
86
+ 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).
87
+
88
+ 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.
89
+
90
+ 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?
91
+
92
+ **Hooks** were introduced to solve these issues. The are defined in advance on each collection to **intercept or react to insertions, updates and removals**.
93
+
94
+ 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.
95
+
96
+ Focussing on the business logic, describing it only once, wasting no time on boilerplate code, that's a time (and sanity) saver for sure! 🤪
97
+
98
+ </details>
99
+
100
+ <details>
101
+ <summary><strong>Protocol implementations</strong></summary>
102
+
103
+ 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).
104
+
105
+ On the server, the new Meteor architecture now required the use of promises and async/await constructs.
106
+
107
+ On the client, data fetching with Minimongo must be synchronous in most frameworks to avoid complicated front-end code to handle promises.
108
+
109
+ I wanted a tool that would help me **keep the isomorphic query capabilities** while eliminating redundant glue code.
110
+
111
+ By designing the library with a protocol architecture, the same code can be run with a different database implementation.
112
+
113
+ On the client, I use a synchronous Minimongo implementation.
114
+
115
+ 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!
116
+
117
+ (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!)
118
+
119
+ 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... 🤯
120
+
121
+ </details>
122
+
123
+ <details style="margin-bottom: 1rem">
124
+ <summary><strong>Functional API</strong></summary>
125
+
126
+ 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.
127
+
128
+ 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?)...
129
+
130
+ `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!
131
+
132
+ 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.
133
+
134
+ 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)!
135
+
136
+ For Meteor developers, it also means being able to enhance the `Meteor.users` collection itself... event without access to instantiation code! 🤓
137
+
138
+ </details>
139
+
140
+ # Installation and configuration
141
+
142
+ **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.
24
143
 
25
144
  ```bash
26
145
  npm install coll-fns
27
146
  ```
28
147
 
29
- ## Quick Start
148
+ ## `setProtocol(protocol)`
149
+
150
+ You will have to **define which protocol to use** before using any of the library's functionality.
30
151
 
31
152
  ```js
32
- import {
33
- setProtocol,
34
- fetchList,
35
- insert,
36
- update,
37
- remove,
38
- count,
39
- join,
40
- protocols,
41
- } from "coll-fns";
153
+ import { setProtocol, protocols } from "coll-fns";
42
154
 
43
- // Set up your protocol
44
- setProtocol(protocols.node(mongoClient));
155
+ /* Built-in protocols include:
156
+ * - meteorAsync
157
+ * - meteorSync
158
+ * - node
159
+ *
160
+ * Can also define a custom protocol! */
161
+ setProtocol(protocols.meteorAsync);
162
+ ```
45
163
 
46
- // Use the API
47
- const users = await fetchList(UsersCollection, { age: { $gte: 18 } });
48
- await insert(UsersCollection, { name: "Alice", age: 25 });
49
- await update(UsersCollection, { name: "Alice" }, { $set: { age: 26 } });
50
- const total = await count(UsersCollection, {});
51
- await remove(UsersCollection, { name: "Alice" });
164
+ In a Meteor project, you should probably define a **different protocol on client** (synchronous) **and server** (asynchronous).
52
165
 
53
- // Define a join to fetch authors with posts
54
- join(PostsCollection, {
55
- author: {
56
- Coll: UsersCollection,
57
- on: ["authorId", "_id"],
58
- single: true,
59
- },
60
- });
166
+ ```js
167
+ import { setProtocol, protocols } from "coll-fns";
61
168
 
62
- // Use the join in a fetch
63
- const posts = await fetchList(
64
- PostsCollection,
65
- {},
66
- {
67
- fields: {
68
- title: 1,
69
- content: 1,
70
- "+": { author: 1 },
71
- },
72
- }
73
- );
74
- // Result: Each post includes its author's name and email
169
+ const protocol = Meteor.isServer ? protocols.meteorAsync : protocols.meteorSync;
170
+ setProtocol(protocol);
171
+ ```
172
+
173
+ There's also a **native NodeJS MongoDB driver** protocol built-in (`protocols.node`).
174
+
175
+ <details style="margin-bottom: 1rem">
176
+ <summary><strong>Custom protocol</strong></summary>
177
+
178
+ 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 🤓!).
179
+
180
+ ```js
181
+ import { setProtocol } from "coll-fns";
182
+
183
+ const customProtocol = {
184
+ /* Return a documents count */
185
+ count(/* Coll, selector = {}, options = {} */) {},
186
+
187
+ /* Return a list of documents. */
188
+ findList(/* Coll, selector = {}, options = {} */) {},
189
+
190
+ /* Return the name of the collection. */
191
+ getName(/* Coll */) {},
192
+
193
+ /* Optional. Return a function that will transform each document
194
+ * after being fetched with descendants. */
195
+ getTransform(/* Coll */) {},
196
+
197
+ /* Insert a document in a collection
198
+ * and return the inserted _id. */
199
+ insert(/* Coll, doc, options */) {},
200
+
201
+ /* Remove documents in a collection
202
+ * and return the number of removed documents. */
203
+ remove(/* Coll, selector, options */) {},
204
+
205
+ /* Update documents in a collection
206
+ * and return the number of modified documents. */
207
+ update(/* Coll, selector, modifier, options */) {},
208
+ };
209
+
210
+ setProtocol(customProtocol);
75
211
  ```
76
212
 
77
- ## Joins: The Power Feature
213
+ </details>
78
214
 
79
- 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.
215
+ ## Bypassing `coll-fns`
80
216
 
81
- Joins must be **pre-registered globally** for a collection using the `join()` function. Once defined, they're available for all fetch operations on that collection.
217
+ `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! 😉
82
218
 
83
- ### Defining and Using Joins
219
+ # Joins and fetch
84
220
 
85
- Register joins once (typically during initialization) and reference them in fetch calls:
221
+ ## Quick start examples
86
222
 
87
223
  ```js
88
- import { join, fetchList } from "coll-fns";
224
+ import { fetchList, join } from "coll-fns";
225
+ import { Comments, Posts, Users } from "/collections";
89
226
 
90
- // Define joins globally for a collection (usually in initialization code)
91
- join(PostsCollection, {
227
+ /* Define joins on Posts collection */
228
+ join(Posts, {
229
+ /* One-to-one join */
92
230
  author: {
93
- Coll: UsersCollection,
231
+ Coll: Users,
94
232
  on: ["authorId", "_id"],
95
233
  single: true,
96
234
  },
235
+
236
+ /* One-to-many join */
97
237
  comments: {
98
- Coll: CommentsCollection,
238
+ Coll: Comments,
99
239
  on: ["_id", "postId"],
240
+ /* `single` defaults to false,
241
+ * so joined docs are returned as an array */
100
242
  },
101
243
  });
102
244
 
103
- // Now use fetch without re-specifying the join definitions
104
- const posts = await fetchList(
105
- PostsCollection,
106
- { status: "published" },
245
+ /* Fetch data with nested joined documents in the requested shape. */
246
+ fetchList(
247
+ Posts,
248
+ {},
107
249
  {
108
250
  fields: {
109
- title: 1,
110
- content: 1,
111
- "+": { author: 1, comments: 1 }, // Reference pre-defined joins
251
+ title: 1, // <= Own
252
+ author: { birthdate: 0 }, // <= Falsy = anything but these fields
253
+ comments: { text: 1 },
112
254
  },
113
255
  }
114
256
  );
115
-
116
- // Result: Each post includes author and comments as defined
117
257
  ```
118
258
 
119
- **Note on `fields` in join definitions:** The optional `fields` property within a join definition specifies which fields of the _joined_ collection to include. It's particularly useful when using function-based joins (where `on` is a function) because the parent document may not have all required linking keys fetched by default. For simple array-based joins, `fields` is optional — omit it to fetch all fields from the joined collection.
120
-
121
- ### Basic Join Example
122
-
123
- ```js
124
- const posts = await fetchList(
125
- PostsCollection,
126
- {},
259
+ ```jsonc
260
+ [
127
261
  {
128
- fields: {
129
- title: 1,
130
- content: 1,
131
- "+": { author: 1 }, // Use '+' prefix to include join fields
262
+ "title": "Blabla",
263
+ "authorId": "foo", // <= Included by join definition
264
+ "author": {
265
+ "name": "Foo Bar",
266
+ "genre": "non-fiction",
132
267
  },
133
- }
134
- );
135
-
136
- // Result: Each post includes an 'author' object with name, avatar, and email
137
- // (assuming 'author' was pre-registered via join(PostsCollection, { author: { ... } }))
268
+ /* Comments is a one-to-many join, so is returned as a list */
269
+ "comments": [{ "text": "Nice!" }, { "text": "Great!" }],
270
+ },
271
+ ]
138
272
  ```
139
273
 
140
- ### One-to-Many Joins
274
+ ## `join(Coll, joinDefinitions)`
275
+
276
+ Collections can be joined together with **globally pre-registered joins** to greatly simplify optimized data fetching.
277
+
278
+ Joins are **not symmetrical by default**. Each collection should define its own relationships.
141
279
 
142
280
  ```js
143
- import { join, fetchList } from "coll-fns";
281
+ import { join } from "coll-fns";
144
282
 
145
- // Pre-register the join
146
- join(PostsCollection, {
147
- comments: {
148
- Coll: CommentsCollection,
149
- on: ["_id", "postId"],
150
- // Note: 'single' defaults to false, so joined docs are returned as an array
151
- fields: { text: 1, createdAt: 1 },
152
- },
153
- });
283
+ join(
284
+ /* Parent collection */
285
+ Coll,
154
286
 
155
- // Use in fetch
156
- const posts = await fetchList(
157
- PostsCollection,
158
- {},
287
+ /* Map of joins on children collections.
288
+ * Each key is the name of the field
289
+ * where joined docs will be placed. */
159
290
  {
160
- fields: {
161
- title: 1,
162
- "+": { comments: 1 },
291
+ joinProp1: {
292
+ /* joinDefinition */
293
+ },
294
+
295
+ joinProp2: {
296
+ /* joinDefinition */
163
297
  },
164
298
  }
165
299
  );
166
-
167
- // Result: Each post includes a 'comments' array
168
300
  ```
169
301
 
170
- ### Nested Joins
302
+ 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).
303
+
304
+ 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.
305
+
306
+ 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.
307
+
308
+ There are three main types of join definitions based on the argument to the `on` property: **array**, **object** and **function** joins.
309
+
310
+ ### Simple array join
171
311
 
172
- Joins can be nested to fetch deeply related data. Register all joins upfront:
312
+ `on` can be defined as an array of `[parentProp, childProp]` equality.
173
313
 
174
314
  ```js
175
- import { join, fetchList } from "coll-fns";
315
+ import { join } from "coll-fns";
316
+ import { Comments, Posts, Users } from "/collections";
176
317
 
177
- // Pre-register joins for PostsCollection
178
- join(PostsCollection, {
318
+ join(Posts, {
179
319
  author: {
180
- Coll: UsersCollection,
320
+ Coll: Users,
321
+ /* `post.authorId === user._id` */
181
322
  on: ["authorId", "_id"],
323
+ /* Single doc instead of a list */
182
324
  single: true,
183
- fields: { name: 1, avatar: 1 },
184
325
  },
326
+
185
327
  comments: {
186
- Coll: CommentsCollection,
328
+ Coll: Comments,
329
+ /* `post._id === comment.postId` */
187
330
  on: ["_id", "postId"],
188
- fields: { text: 1, "+": { user: 1 } },
189
331
  },
190
332
  });
191
333
 
192
- // Pre-register joins for CommentsCollection
193
- join(CommentsCollection, {
194
- user: {
195
- Coll: UsersCollection,
196
- on: ["userId", "_id"],
197
- single: true,
198
- fields: { name: 1, avatar: 1 },
334
+ /* Reversed join from user to posts */
335
+ join(Users, {
336
+ posts: {
337
+ Coll: Posts,
338
+ on: ["_id", "authorId"],
199
339
  },
200
340
  });
341
+ ```
201
342
 
202
- // Use in fetch - nested joins are resolved automatically
203
- const posts = await fetchList(
204
- PostsCollection,
205
- {},
206
- {
207
- fields: {
208
- title: 1,
209
- content: 1,
210
- "+": { author: 1, comments: 1 },
211
- },
212
- }
213
- );
343
+ ### Sub-array joins
344
+
345
+ 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.
346
+
347
+ ```js
348
+ import { join } from "coll-fns";
349
+ import { Actions, Resources } from "/collections";
350
+
351
+ /* Each action can be associated with many resources and vice-versa.
352
+ * Resource's `actionIds` array is the link between them. */
353
+ join(Actions, {
354
+ resources: {
355
+ Coll: Resources,
356
+ on: ["_id", ["actionIds"]],
357
+ },
358
+ });
214
359
 
215
- // Result: Posts with author details and comments, each comment with user details
360
+ /* The reverse join will flip the property names. */
361
+ join(Resources, {
362
+ actions: {
363
+ Coll: Actions,
364
+ on: [["actionIds"], "_id"],
365
+ },
366
+ });
216
367
  ```
217
368
 
218
- ### Recursive Join Depth Control
369
+ ### Filtered array-joins
219
370
 
220
- Control the depth of recursive joins to prevent infinite loops:
371
+ Some joins should target only specific documents in the foreign collection. A complementary selector can be passed to the third `on` array argument.
221
372
 
222
373
  ```js
223
- import { join, fetchList } from "coll-fns";
374
+ import { join } from "coll-fns";
375
+ import { Actions, Resources } from "/collections";
224
376
 
225
- // Pre-register recursive join
226
- join(UsersCollection, {
227
- friends: {
228
- Coll: UsersCollection,
229
- on: ["friendIds", "_id"],
377
+ join(Resources, {
378
+ /* Only active tasks (third array element is a selector) */
379
+ activeTasks: {
380
+ Coll: Tasks,
381
+ on: ["_id", "resourceId", { active: true }],
230
382
  },
231
- });
232
383
 
233
- // Use in fetch - specify depth in fields with '+' prefix
234
- const users = await fetchList(
235
- UsersCollection,
236
- {},
237
- {
238
- fields: {
239
- name: 1,
240
- "+": { friends: 2 }, // Limit to 2 levels deep
241
- },
242
- }
243
- );
384
+ /* All tasks associated with a resource */
385
+ tasks: {
386
+ Coll: Tasks,
387
+ on: ["_id", "resourceId"],
388
+ },
389
+ });
244
390
  ```
245
391
 
246
- ### Function-Based Joins with `fields`
392
+ ### Object joins
247
393
 
248
- When using function-based joins (where `on` is a function), the `fields` property declares which fields the parent document needs for the join to work:
394
+ The `on` join definition property can be an object representing a selector. It will always retrieve the same linked documents.
249
395
 
250
396
  ```js
251
- import { join, fetchList } from "coll-fns";
397
+ import { join } from "coll-fns";
398
+ import { Factory, Workers } from "../collections";
252
399
 
253
- // Join comments where the parent doc's userId field is used to compute the selector
254
- join(PostsCollection, {
255
- userComments: {
256
- Coll: CommentsCollection,
257
- // Function form: receives parent doc, returns selector for joined collection
258
- on: (post) => ({ userId: post.userId, postId: post._id }),
259
- // Declare which parent fields are required for the join function
260
- fields: { userId: 1 },
400
+ join(Workers, {
401
+ /* All workers will have the same `factory` props. */
402
+ factory: {
403
+ Coll: Factory,
404
+ on: { name: "FACTORY ABC" },
405
+ single: true,
261
406
  },
262
407
  });
408
+ ```
263
409
 
264
- // Use in fetch
265
- const posts = await fetchList(
266
- PostsCollection,
267
- {},
268
- {
410
+ ### Function joins
411
+
412
+ 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.
413
+
414
+ 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:
415
+
416
+ ```js
417
+ import { join } from "coll-fns";
418
+ import { Comments, Posts } from "/collections";
419
+ import { twoMonthsPrior } from "/lib/dates";
420
+
421
+ join(Posts, {
422
+ recentComments: {
423
+ Coll: Comments,
424
+ on: (post) => {
425
+ const { _id: postId, postedAt } = post;
426
+
427
+ /* This argument must be defined at runtime. */
428
+ const minDate = twoMonthsPrior(postedAt);
429
+
430
+ /* Return a selector for the Comments collection */
431
+ return {
432
+ createdAt: { $gte: minDate },
433
+ postId,
434
+ };
435
+ },
436
+ /* Parent fields needed in the join function */
269
437
  fields: {
270
- title: 1,
271
- "+": { userComments: 1 },
438
+ _id: 1, // Optional. _id is implicit in any fetch.
439
+ postedAt: 1,
272
440
  },
273
- }
274
- );
441
+ },
442
+ });
275
443
  ```
276
444
 
277
- ## Hooks: Extensibility Made Easy
445
+ ### Recursive joins
278
446
 
279
- Hooks allow you to react to CRUD operations, enabling validation, transformation, logging, and side effects without modifying your core business logic.
280
-
281
- ### Setting Up Hooks
447
+ A collection can define joins on itself.
282
448
 
283
449
  ```js
284
- import { hook } from "coll-fns";
285
-
286
- // Before insert hook - validation and transformation
287
- hook(UsersCollection, "insert", "before", (doc) => {
288
- if (!doc.email) {
289
- throw new Error("Email is required");
290
- }
291
- // Transform data
292
- doc.email = doc.email.toLowerCase();
293
- doc.createdAt = new Date();
294
- return doc;
295
- });
450
+ import { join } from "coll-fns";
451
+ import { Users } from "/collections";
296
452
 
297
- // After insert hook - side effects
298
- hook(UsersCollection, "insert", "after", (doc) => {
299
- console.log(`New user created: ${doc.name}`);
300
- // Send welcome email, update analytics, etc.
301
- sendWelcomeEmail(doc.email);
453
+ join(Users, {
454
+ friends: {
455
+ /* Use the same collection in the join definition */
456
+ Coll: Users,
457
+ on: [["friendIds"], "_id"],
458
+ },
302
459
  });
303
460
  ```
304
461
 
305
- ### Available Hook Types
462
+ ### Join additional options
306
463
 
307
- Hooks can be attached to any CRUD operation:
464
+ 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:
308
465
 
309
- - **`insert`**: Before/after document insertion
310
- - **`update`**: Before/after document updates
311
- - **`remove`**: Before/after document removal
312
- - **`fetch`**: Before/after fetching documents (useful for filtering)
466
+ - `limit`: Maximum joined documents count
467
+ - `skip`: Documents to skip in the fetch
468
+ - `sort`: Sort order of joined documents
313
469
 
314
- ### Hook Examples
470
+ ### `postFetch`
315
471
 
316
- #### Validation and Authorization
472
+ 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.
473
+
474
+ 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.
317
475
 
318
476
  ```js
319
- // Prevent unauthorized updates
320
- hook(PostsCollection, "update", "before", (selector, modifier, options) => {
321
- const currentUserId = getCurrentUserId();
322
- const post = fetchList(PostsCollection, selector)[0];
477
+ import { join } from "coll-fns";
478
+ import { Actions, Resources } from "/collections";
479
+ import { sortTasks } from "/lib/tasks";
323
480
 
324
- if (post.authorId !== currentUserId) {
325
- throw new Error("Unauthorized");
326
- }
481
+ join(Resources, {
482
+ tasks: {
483
+ Coll: Tasks,
484
+ on: ["_id", "resourceId"],
485
+
486
+ /* Ensure `tasksOrder` will be fetched */
487
+ fields: { tasksOrder: 1 },
327
488
 
328
- return [selector, modifier, options];
489
+ /* Transform the joined tasks documents based on parent resource. */
490
+ postFetch(tasks, resource) {
491
+ const { tasksOrder = [] } = resource;
492
+ return sortTasks(tasks, tasksOrder);
493
+ },
494
+ },
329
495
  });
330
496
  ```
331
497
 
332
- #### Automatic Timestamps
498
+ ### `getJoins`
333
499
 
334
- ```js
335
- // Add timestamps automatically
336
- hook(PostsCollection, "insert", "before", (doc) => {
337
- doc.createdAt = new Date();
338
- doc.updatedAt = new Date();
339
- return doc;
340
- });
500
+ Use `getJoins(Coll)` to retrieve the complete dictionary of the collection's joins.
341
501
 
342
- hook(PostsCollection, "update", "before", (selector, modifier, options) => {
343
- if (!modifier.$set) modifier.$set = {};
344
- modifier.$set.updatedAt = new Date();
345
- return [selector, modifier, options];
346
- });
347
- ```
502
+ ## `fetchList(Coll, selector, options)`
348
503
 
349
- #### Audit Logging
504
+ Fetch documents with the ability to **use collection joins**.
350
505
 
351
- ```js
352
- // Log all changes
353
- hook(PostsCollection, "update", "after", (result, selector, modifier) => {
354
- logToAuditTrail({
355
- collection: "posts",
356
- action: "update",
357
- selector,
358
- modifier,
359
- timestamp: new Date(),
360
- userId: getCurrentUserId(),
361
- });
362
- });
363
- ```
506
+ **Options:**
507
+
508
+ - `fields`: Field projection object
509
+ - `limit`: Maximum number of documents
510
+ - `skip`: Number of documents to skip
511
+ - `sort`: Sort specification
364
512
 
365
- #### Data Denormalization
513
+ In its simplest form, `fetchList` can be used in much the same way as Meteor's `Coll.find(...args).fetch()`.
366
514
 
367
515
  ```js
368
- // Update denormalized data
369
- hook(UsersCollection, "update", "after", (result, selector, modifier) => {
370
- const userId = selector._id;
371
- const userName = modifier.$set?.name;
372
-
373
- if (userName) {
374
- // Update user name in all their posts
375
- update(
376
- PostsCollection,
377
- { authorId: userId },
378
- { $set: { authorName: userName } },
379
- { multi: true }
380
- );
516
+ const users = await fetchList(
517
+ Users,
518
+ { status: "active" },
519
+ {
520
+ fields: { name: 1, email: 1 },
521
+ sort: { createdAt: -1 },
522
+ limit: 10,
523
+ skip: 0,
381
524
  }
382
- });
525
+ );
383
526
  ```
384
527
 
385
- ## Error Handling
528
+ ### `fields` option and joins
529
+
530
+ Contrary to regular projection objects, they can use nested properties `{ car: { make: 1 } }` instead of dot-string ones `{ car: 1, "car.make": 1 }`.
386
531
 
387
- `coll-fns` provides multiple mechanisms for handling errors in database operations and validations.
532
+ 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.
388
533
 
389
- ### Throwing Errors in Hooks
534
+ #### Examples
390
535
 
391
- Hooks can throw errors to prevent operations from completing. This is useful for validation, authorization checks, and data integrity:
536
+ <details>
537
+ <summary>Join definitions for examples</summary>
392
538
 
393
539
  ```js
394
- import { hook } from "coll-fns";
540
+ import { fetchList, join } from "coll-fns";
541
+ import { Comments, Posts, Users } from "/collections";
395
542
 
396
- // Prevent invalid operations by throwing in before hooks
397
- hook(UsersCollection, "insert", "before", (doc) => {
398
- if (!doc.email || !doc.email.includes("@")) {
399
- throw new Error("Invalid email format");
400
- }
401
- return doc;
402
- });
543
+ /* Define joins on Posts collection */
544
+ join(Posts, {
545
+ /* One-to-one join */
546
+ author: {
547
+ Coll: Users,
548
+ on: ["authorId", "_id"],
549
+ single: true,
550
+ },
403
551
 
404
- // Usage
405
- try {
406
- await insert(UsersCollection, { name: "John", email: "invalid" });
407
- } catch (error) {
408
- console.error("Insert failed:", error.message);
409
- // Output: Insert failed: Invalid email format
410
- }
552
+ /* One-to-many join */
553
+ comments: {
554
+ Coll: Comments,
555
+ on: ["_id", "postId"],
556
+ /* `single` defaults to false,
557
+ * so joined docs are returned as an array */
558
+ },
559
+ });
411
560
  ```
412
561
 
413
- ### Async/Await Error Handling
562
+ </details>
414
563
 
415
- All operations support both sync and async protocols. Use standard try/catch for async operations:
564
+ <details>
565
+ <summary>Undefined (all) own fields</summary>
416
566
 
417
567
  ```js
418
- import { fetchList, update, remove } from "coll-fns";
419
-
420
- async function safeUpdatePosts() {
421
- try {
422
- const posts = await fetchList(PostsCollection, { status: "draft" });
423
- console.log(`Found ${posts.length} draft posts`);
568
+ fetchList(Posts, {});
569
+ ```
424
570
 
425
- for (const post of posts) {
426
- await update(
427
- PostsCollection,
428
- { _id: post._id },
429
- { $set: { status: "published" } }
430
- );
431
- }
432
- } catch (error) {
433
- console.error("Update operation failed:", error);
434
- // Handle database error, network issue, validation error, etc.
435
- }
436
- }
571
+ ```json
572
+ [{ "title": "Blabla", "authorId": "foo", "likes": 7 }]
437
573
  ```
438
574
 
439
- ### Join Validation Errors
575
+ </details>
440
576
 
441
- Joins are validated when registered globally. Invalid join definitions throw errors immediately:
577
+ <details>
578
+ <summary>Some own fields</summary>
442
579
 
443
580
  ```js
444
- import { join } from "coll-fns";
445
-
446
- try {
447
- // Missing required 'Coll' property
448
- join(PostsCollection, {
449
- author: {
450
- on: ["authorId", "_id"], // Error: Missing Coll
581
+ fetchList(
582
+ Posts,
583
+ {},
584
+ {
585
+ fields: {
586
+ title: true, // <= Own. Any truthy value works
451
587
  },
452
- });
453
- } catch (error) {
454
- console.error(error.message);
455
- // Output: Collection 'Coll' for 'author' join is required.
456
- }
588
+ }
589
+ );
590
+ ```
457
591
 
458
- try {
459
- // Missing required 'on' condition
460
- join(PostsCollection, {
461
- comments: {
462
- Coll: CommentsCollection,
463
- // Error: Missing on
464
- },
465
- });
466
- } catch (error) {
467
- console.error(error.message);
468
- // Output: Join 'comments' has no 'on' condition specified.
469
- }
592
+ ```json
593
+ [{ "title": "Blabla" }]
470
594
  ```
471
595
 
472
- ### Authorization in Hooks
596
+ </details>
473
597
 
474
- Use hooks to enforce authorization rules and throw errors for unauthorized operations:
598
+ <details>
599
+ <summary>Undefined (all) own fields, truthy (all) join fields</summary>
475
600
 
476
601
  ```js
477
- hook(PostsCollection, "update", "before", (selector, modifier, options) => {
478
- const currentUserId = getCurrentUserId();
479
- const [post] = fetchOne(PostsCollection, selector);
480
-
481
- if (!post) {
482
- throw new Error("Post not found");
602
+ fetchList(
603
+ Posts,
604
+ {},
605
+ {
606
+ fields: {
607
+ author: 1, // <= Join
608
+ },
483
609
  }
610
+ );
611
+ ```
484
612
 
485
- if (post.authorId !== currentUserId) {
486
- throw new Error("Unauthorized: You can only edit your own posts");
487
- }
613
+ ```jsonc
614
+ [
615
+ {
616
+ "title": "Blabla",
617
+ "authorId": "foo",
618
+ "likes": 7,
619
+ "author": {
620
+ "name": "Foo Bar",
621
+ "birthdate": "Some Date",
622
+ "genre": "non-fiction",
623
+ },
624
+ },
625
+ ]
626
+ ```
488
627
 
489
- return [selector, modifier, options];
490
- });
628
+ </details>
491
629
 
492
- // Usage
493
- try {
494
- await update(
495
- PostsCollection,
496
- { _id: "123" },
497
- { $set: { title: "New Title" } }
498
- );
499
- } catch (error) {
500
- if (error.message.includes("Unauthorized")) {
501
- // Handle authorization error
502
- } else if (error.message === "Post not found") {
503
- // Handle not found error
630
+ <details>
631
+ <summary>Some own fields, truthy (all) join fields</summary>
632
+
633
+ ```js
634
+ fetchList(
635
+ Posts,
636
+ {},
637
+ {
638
+ fields: {
639
+ title: 1, // <= Own
640
+ author: 1, // <= Join
641
+ },
504
642
  }
505
- }
643
+ );
644
+ ```
645
+
646
+ ```jsonc
647
+ [
648
+ {
649
+ "title": "Blabla",
650
+ "authorId": "foo", // <= Included by join definition
651
+ "author": {
652
+ "name": "Foo Bar",
653
+ "birthdate": "Some Date",
654
+ "genre": "non-fiction",
655
+ },
656
+ },
657
+ ]
506
658
  ```
507
659
 
508
- ### Handling Join Fetch Errors
660
+ </details>
509
661
 
510
- When joins fail during fetch operations, errors propagate through the promise chain:
662
+ <details>
663
+ <summary>Some own fields, some join fields</summary>
511
664
 
512
665
  ```js
513
- import { join, fetchList } from "coll-fns";
666
+ fetchList(
667
+ Posts,
668
+ {},
669
+ {
670
+ fields: {
671
+ title: 1, // <= Own
672
+ author: { birthdate: 0 }, // <= Falsy = anything but these fields
673
+ comments: { text: 1 },
674
+ },
675
+ }
676
+ );
677
+ ```
514
678
 
515
- // Pre-register the join
516
- join(PostsCollection, {
517
- author: {
518
- Coll: UsersCollection,
519
- on: ["authorId", "_id"],
520
- single: true,
679
+ ```jsonc
680
+ [
681
+ {
682
+ "title": "Blabla",
683
+ "authorId": "foo", // <= Included by join definition
684
+ "author": {
685
+ "name": "Foo Bar",
686
+ "genre": "non-fiction",
687
+ },
688
+ /* Comments is a one-to-many join, so is returned as a list */
689
+ "comments": [{ "text": "Nice!" }, { "text": "Great!" }],
521
690
  },
522
- });
523
-
524
- // Use in fetch
525
- try {
526
- const posts = await fetchList(
527
- PostsCollection,
528
- {},
529
- {
530
- fields: { title: 1, "+": { author: 1 } },
531
- }
532
- );
533
- } catch (error) {
534
- console.error("Failed to fetch posts with authors:", error);
535
- // Errors from nested joins are propagated here
536
- }
691
+ ]
537
692
  ```
538
693
 
539
- ### Protocol-Level Error Handling
694
+ </details>
695
+
696
+ ### `setJoinPrefix(prefix)`
540
697
 
541
- When a protocol method is not implemented, `coll-fns` throws a descriptive error:
698
+ 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.
699
+
700
+ Setting the prefix to null or undefined allows using join fields at the document root like any normal field.
542
701
 
543
702
  ```js
544
- import { setProtocol, fetchList } from "coll-fns";
703
+ import { setJoinPrefix } from "coll-fns";
545
704
 
546
- // Using incomplete protocol
547
- setProtocol({
548
- // Missing required methods
549
- });
705
+ /* All join fields will have to be prefixed with "+" */
706
+ setJoinPrefix("+");
550
707
 
551
- try {
552
- await fetchList(SomeCollection, {});
553
- } catch (error) {
554
- console.error(error.message);
555
- // Output: 'findList' method must be defined with 'setProtocol'.
556
- }
708
+ /* Some own fields, some join fields */
709
+ fetchList(
710
+ Posts,
711
+ {},
712
+ {
713
+ fields: {
714
+ title: 1, // <= Own
715
+
716
+ /* Join fields must be nested under the prefix key */
717
+ "+": {
718
+ author: { name: 1, birthdate: 1 }, // <= Join sub fields
719
+ },
720
+ },
721
+ }
722
+ );
557
723
  ```
558
724
 
559
- ## Meteor Integration: Isomorphic by Design
725
+ 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.
560
726
 
561
- `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.
727
+ If, for some reason, you need to retrieve the prefix, you can do so with `getJoinPrefix(Coll)`.
562
728
 
563
- ### Server-Side (Async)
729
+ ### Nested Joins
730
+
731
+ 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.
564
732
 
565
733
  ```js
566
- // server/main.js
567
- import { setProtocol, protocols } from "coll-fns";
734
+ import { fetchList } from "coll-fns";
568
735
 
569
- setProtocol(protocols.meteorAsync);
736
+ const posts = fetchList(
737
+ Posts,
738
+ {},
739
+ {
740
+ fields: {
741
+ title: 1,
742
+
743
+ /* Level 1 : One-to-many join */
744
+ comments: {
745
+ text: 1,
746
+
747
+ /* Level 2 : One-to-one join */
748
+ user: {
749
+ username: 1,
750
+ },
751
+ },
752
+ },
753
+ }
754
+ );
755
+ ```
570
756
 
571
- // Methods automatically work with async
572
- Meteor.methods({
573
- async createPost(title, content) {
574
- const user = await fetchOne(UsersCollection, { _id: this.userId });
575
- return await insert(PostsCollection, {
576
- title,
577
- content,
578
- authorId: this.userId,
579
- });
757
+ ```json
758
+ {
759
+ "title": "Blabla",
760
+ "comments": [
761
+ { "text": "Nice!", "user": { "username": "foo"} },
762
+ { "text": "Great!", "user": { "username": "bar" } }
763
+ ]
580
764
  },
581
- });
582
765
  ```
583
766
 
584
- ### Client-Side (Sync)
767
+ ### Recursion levels
585
768
 
586
- ```js
587
- // client/main.js
588
- import { setProtocol, protocols, join } from "coll-fns";
769
+ 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.
589
770
 
590
- setProtocol(protocols.meteorSync);
771
+ ```js
772
+ import { join } from "coll-fns";
773
+ import { Users } from "/collections";
591
774
 
592
- // Pre-register joins (same as server)
593
- join(PostsCollection, {
594
- author: {
595
- Coll: UsersCollection,
596
- on: ["authorId", "_id"],
597
- single: true,
775
+ /* Pre-register recursive join */
776
+ join(Users, {
777
+ friends: {
778
+ Coll: Users,
779
+ on: [["friendIds"], "_id"],
598
780
  },
599
781
  });
600
782
 
601
- // Same API, synchronous execution
602
- const posts = fetchList(
603
- PostsCollection,
783
+ fetchList(
784
+ Users,
604
785
  {},
605
- { fields: { title: 1, "+": { author: 1 } } }
786
+ {
787
+ fields: {
788
+ name: 1,
789
+ /* Join field. Limit to 2 levels deep, reusing parent fields */
790
+ friends: 2,
791
+ },
792
+ }
606
793
  );
607
794
  ```
608
795
 
609
- ### Shared Code
796
+ ### Documents transformation
797
+
798
+ Documents can be transformed after fetching. Collection-level transforms are automatically applied if the protocol allows it:
799
+
800
+ **Meteor:**
610
801
 
611
802
  ```js
612
- // imports/api/posts.js
613
- import { join, fetchList } from "coll-fns";
803
+ import { Mongo } from "meteor/mongo";
614
804
 
615
- // Pre-register joins once
616
- join(PostsCollection, {
617
- author: {
618
- Coll: UsersCollection,
619
- on: ["authorId", "_id"],
620
- single: true,
621
- fields: { name: 1, avatar: 1 },
622
- },
805
+ const Users = new Mongo.Collection("users", {
806
+ transform: (doc) => ({
807
+ ...doc,
808
+ fullName: `${doc.firstName} ${doc.lastName}`,
809
+ }),
623
810
  });
624
-
625
- // This function works on both client and server!
626
- export function getPostsWithAuthors() {
627
- return fetchList(
628
- PostsCollection,
629
- {},
630
- {
631
- fields: {
632
- title: 1,
633
- content: 1,
634
- "+": { author: 1 },
635
- },
636
- }
637
- );
638
- }
639
811
  ```
640
812
 
641
- ## API Reference
642
-
643
- ### Core Functions
644
-
645
- #### `fetchList(collection, selector, options)`
646
-
647
- Fetch an array of documents from a collection.
813
+ **For a specific fetch**, pass a `transform` option:
648
814
 
649
815
  ```js
650
816
  const users = await fetchList(
651
- UsersCollection,
817
+ Users,
652
818
  { status: "active" },
653
819
  {
654
- fields: { name: 1, email: 1 },
655
- sort: { createdAt: -1 },
656
- limit: 10,
657
- skip: 0,
820
+ transform: (doc) => ({
821
+ ...doc,
822
+ fullName: `${doc.firstName} ${doc.lastName}`,
823
+ }),
658
824
  }
659
825
  );
660
826
  ```
661
827
 
662
- **Options:**
663
-
664
- - `fields`: Field projection object
665
- - `sort`: Sort specification
666
- - `limit`: Maximum number of documents
667
- - `skip`: Number of documents to skip
828
+ 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.
668
829
 
669
- #### `fetchOne(collection, selector, options)`
830
+ ## `fetchOne(Coll, selector, options)`
670
831
 
671
- Fetch a single document from a collection.
832
+ Fetch a single document from a collection. Same behaviour as `fetchList`.
672
833
 
673
834
  ```js
674
- const user = await fetchOne(
675
- UsersCollection,
835
+ import { fetchOne } from "coll-fns";
836
+ import { Users } from "/collections";
837
+
838
+ const user = fetchOne(
839
+ Users,
676
840
  { _id: userId },
677
841
  {
678
- fields: { name: 1, email: 1 },
842
+ fields: {
843
+ name: 1,
844
+ friends: 1, // <= Join
845
+ },
679
846
  }
680
847
  );
681
848
  ```
682
849
 
683
- #### `fetchIds(collection, selector, options)`
850
+ ## `fetchIds(Coll, selector, options)`
684
851
 
685
- Fetch only the `_id` field of matching documents.
852
+ Fetch only the `_id` field of matching documents. `fields` option will be ignored.
686
853
 
687
854
  ```js
688
- const userIds = await fetchIds(UsersCollection, { status: "active" });
689
- // Returns: ['id1', 'id2', 'id3']
855
+ import { fetchOne } from "coll-fns";
856
+ import { Users } from "/collections";
857
+
858
+ const userIds = fetchIds(Users, { status: "active" });
690
859
  ```
691
860
 
692
- #### `exists(collection, selector)`
861
+ ## `exists(Coll, selector)`
693
862
 
694
- Check if any documents match the selector.
863
+ Check if any document matches the selector.
695
864
 
696
865
  ```js
697
- const hasActiveUsers = await exists(UsersCollection, { status: "active" });
866
+ import { fetchOne } from "coll-fns";
867
+ import { Users } from "/collections";
868
+
869
+ const hasActiveUsers = exists(Users, { status: "active" });
698
870
  // Returns: true or false
699
871
  ```
700
872
 
701
- #### `insert(collection, doc)`
873
+ ## `count(Coll, selector)`
702
874
 
703
- Insert a document into a collection.
875
+ Count documents matching the selector.
704
876
 
705
877
  ```js
706
- const newUser = await insert(UsersCollection, {
707
- name: "Bob",
708
- email: "bob@example.com",
709
- });
878
+ import { fetchOne } from "coll-fns";
879
+ import { Users } from "/collections";
880
+
881
+ const activeUsersCount = count(UsersCollection, { status: "active" });
882
+ // Returns an integer
710
883
  ```
711
884
 
712
- #### `update(collection, selector, modifier, options)`
885
+ ## `flattenFields(fields)`
713
886
 
714
- Update documents matching the selector.
887
+ Flatten a general field specifiers object (which could include nested objects) into a MongoDB-compatible one that uses dot-notation.
715
888
 
716
889
  ```js
717
- await update(
718
- UsersCollection,
719
- { status: "pending" },
720
- { $set: { status: "active" } },
721
- { multi: true }
722
- );
890
+ import { flattenFields } from "coll-fns";
891
+
892
+ const flattened = flattenFields({
893
+ name: 1,
894
+ address: {
895
+ street: 1,
896
+ city: 1,
897
+ },
898
+ });
899
+ // Result: { name: 1, 'address.street': 1, 'address.city': 1 }
723
900
  ```
724
901
 
725
- #### `remove(collection, selector)`
902
+ # Hooks and write operations
726
903
 
727
- Remove documents matching the selector.
904
+ 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.
728
905
 
729
- ```js
730
- await remove(UsersCollection, { inactive: true });
731
- ```
906
+ ## `hook(Coll, hooksObj)`
732
907
 
733
- #### `count(collection, selector)`
908
+ Register hooks on a collection to run before or after specific write operations (insert, update, remove).
734
909
 
735
- Count documents matching the selector.
910
+ 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**.
911
+
912
+ Hooks can be **defined in multiple places** in your codebase. This allows grouping functionally related hooks together.
736
913
 
737
914
  ```js
738
- const activeUsers = await count(UsersCollection, { status: "active" });
915
+ import { hook } from "coll-fns";
916
+ import { Users, Posts } from "/collections";
917
+
918
+ hook(Users, {
919
+ beforeInsert: [
920
+ {
921
+ fn(doc) {
922
+ if (!doc.email) throw new Error("Email is required");
923
+
924
+ doc.createdAt = new Date();
925
+ },
926
+ },
927
+ ],
928
+
929
+ onInserted: [
930
+ {
931
+ fn(doc) {
932
+ console.log(`New user created: ${doc._id}`);
933
+ },
934
+ },
935
+ ],
936
+ });
739
937
  ```
740
938
 
741
- ### Hook Functions
939
+ ### Before hooks
940
+
941
+ These hooks run **before** the write operation and can **prevent the operation** by throwing an error.
942
+
943
+ - **`beforeInsert`**: Runs before inserting a document. Receives `(doc)`.
944
+ - **`beforeUpdate`**: Runs before updating documents. Receives `([...docsToUpdate], modifier)`.
945
+ - **`beforeRemove`**: Runs before removing documents. Receives `([...docsToRemove])`.
946
+
947
+ Although arguments can be mutated, it is not the main purpose of these hooks. Mutations are brittle and hard to debug.
948
+
949
+ `beforeUpdate` and `beforeRemove` receive an **array of targeted documents**, whereas `beforeInsert` receives a **single document**.
950
+
951
+ ### After hooks
742
952
 
743
- #### `hook(collection, operation, timing, fn)`
953
+ These hooks run **after** the write operation completes and are **fire-and-forget** (not awaited by the caller of the collection function). They are usually used to trigger side-effects. They should not throw errors that should get back to the caller.
744
954
 
745
- Add a hook to a collection operation.
955
+ - **`onInserted`**: Runs after a document is inserted. Receives `(doc)`.
956
+ - **`onUpdated`**: Runs after a document is updated. Receives `(afterDoc, beforeDoc)`.
957
+ - **`onRemoved`**: Runs after a document is removed. Receives `(doc)`.
958
+
959
+ **IMPORTANT!**
960
+
961
+ 1. These hooks **might create incoherent state** when used as a denormalization technique (a common and helpful use case) if a downstream update fails. It is **NOT inherent to `coll-fns`**, but rather to eventual consistent database designs. Even if the after hooks were awaited, errors would not rollback prior successful updates.
962
+
963
+ 2. Although hooks can define `onError` callbacks, if `fn` executes async code, **it MUST await it or return it as a promise**. Otherwise, `onError` callback will never get fired because the function will be running in a separate promise context. If `fn` starts async work and doesn’t return/await it, any error will become an **unhandled rejection (and may crash the process)**.
964
+
965
+ ❌ **Wrong**
746
966
 
747
967
  ```js
748
- hook(UsersCollection, "insert", "before", (doc) => {
749
- // Modify or validate doc
750
- return doc;
968
+ hook(Coll, {
969
+ fn(doc) {
970
+ update(/* Some other collection */); // not awaited / not returned
971
+ },
751
972
  });
752
973
  ```
753
974
 
754
- **Parameters:**
975
+ Might crash the process!
755
976
 
756
- - `collection`: The collection to hook into
757
- - `operation`: `'insert'`, `'update'`, `'remove'`, or `'fetch'`
758
- - `timing`: `'before'` or `'after'`
759
- - `fn`: Hook function (return value depends on operation and timing)
977
+ ```
978
+ UnhandledPromiseRejection: Error: Validation failed
979
+ at beforeUpdate (.../src/hooks.js:42:11)
980
+ at update (.../src/update.js:128:7)
981
+ ...
982
+ ```
760
983
 
761
- ### Protocol Management
984
+ **Right**
762
985
 
763
- #### `setProtocol(protocol)`
986
+ ```js
987
+ hook(Coll, {
988
+ async fn(doc) {
989
+ await update(/* Some other collection */); // Awaited
990
+ },
991
+ });
992
+ ```
764
993
 
765
- Set the active database protocol.
994
+ or
766
995
 
767
996
  ```js
768
- import { setProtocol, protocols } from "coll-fns";
769
-
770
- setProtocol(protocols.meteorAsync);
997
+ hook(Coll, {
998
+ fn(doc) {
999
+ return update(/* Some other collection */); // Returned promise
1000
+ },
1001
+ });
771
1002
  ```
772
1003
 
773
- #### `getProtocol()`
1004
+ ### Hook definition properties
774
1005
 
775
- Get the current active protocol.
1006
+ Each hook definition is an object with the following properties:
776
1007
 
777
1008
  ```js
778
- const currentProtocol = getProtocol();
1009
+ {
1010
+ /* Required. The function to execute.
1011
+ * Arguments depend on the hook type (see above).
1012
+ * Can be either synchronous or asynchronous. */
1013
+ fn(...args) { /* ... */ },
1014
+
1015
+ /* Optional. Fields to fetch for the documents passed to the hook.
1016
+ * Fields for multiple hooks of the same type are automatically combined.
1017
+ * If any hook of a type requests all fields with `undefined` or `true`,
1018
+ * all other similar hooks will also get the entire documents.
1019
+ * Has no effect on `beforeInsert`: the doc to be inserted is the argument. */
1020
+ fields: { name: 1, email: 1 },
1021
+
1022
+ /* Optional (`onUpdated` only). If true, fetch the document state
1023
+ * before the update with the same `fields` value.
1024
+ * Otherwise, only _id is fetched initially (they would have been
1025
+ * needed anyway to fetch their "after" versions). */
1026
+ before: true,
1027
+
1028
+ /* Optional. Synchronous predicate that prevents the hook from running if it
1029
+ * returns a truthy value. Receives the same arguments as fn. */
1030
+ unless(doc) { return doc.isBot; },
1031
+
1032
+ /* Optional. Synchronous predicate that allows the hook to run only if it
1033
+ * returns a truthy value. Receives the same arguments as fn. */
1034
+ when(doc) { return doc.status === "pending"; },
1035
+
1036
+ /* Optional handler called if the hook function throws an error.
1037
+ * A default handler that logs to console.error is defined
1038
+ * for after-hooks (onInserted, onUpdated, onRemoved)
1039
+ * to prevent an error from crashing the server. */
1040
+ onError(err, hookDef) { /* ... */ },
1041
+ }
779
1042
  ```
780
1043
 
781
- #### `updateProtocol(updates)`
1044
+ ### Examples
782
1045
 
783
- Update specific methods of the current protocol.
1046
+ <details>
1047
+ <summary>Data validation</summary>
784
1048
 
785
1049
  ```js
786
- import { updateProtocol } from "coll-fns";
1050
+ hook(Users, {
1051
+ beforeInsert: [
1052
+ {
1053
+ fn(doc) {
1054
+ if (!doc.email || !doc.email.includes("@")) {
1055
+ throw new Error("Invalid email");
1056
+ }
1057
+ },
1058
+ },
1059
+ ],
1060
+ });
1061
+ ```
1062
+
1063
+ </details>
1064
+
1065
+ <details>
1066
+ <summary>Cascading updates</summary>
787
1067
 
788
- updateProtocol({
789
- fetch: customFetchImplementation,
1068
+ ```js
1069
+ /* If user's name changed, update their posts' denormalized data */
1070
+ hook(Users, {
1071
+ onUpdated: [
1072
+ {
1073
+ fields: { name: 1 },
1074
+ /* Use `when` predicate to run the hook only on this condition.
1075
+ * Could also have used `unless` or checked condition inside `fn`. */
1076
+ when: (after, before) => after.name !== before.name,
1077
+
1078
+ /* Effect to run - uses update() which also supports hooks */
1079
+ fn(after) {
1080
+ const { _id, name } = after;
1081
+ update(Posts, { authorId: _id }, { $set: { authorName: name } });
1082
+ },
1083
+ },
1084
+ ],
790
1085
  });
791
1086
  ```
792
1087
 
793
- ### Field Projections
1088
+ </details>
794
1089
 
795
- #### Nested Fields
1090
+ <details>
1091
+ <summary>Conditional hooks with when/unless</summary>
796
1092
 
797
1093
  ```js
798
- // Nested object notation
799
- const users = await fetchList(
800
- UsersCollection,
801
- {},
802
- {
803
- fields: {
804
- name: 1,
805
- address: {
806
- street: 1,
807
- city: 1,
1094
+ hook(Posts, {
1095
+ beforeRemove: [
1096
+ {
1097
+ /* Limit fetched fields of docs to be removed */
1098
+ fields: { _id: 1 },
1099
+ /* Only run for non-admin users */
1100
+ unless() {
1101
+ return Meteor.user()?.isAdmin;
1102
+ },
1103
+ fn() {
1104
+ throw new Error("Only admins can delete posts");
808
1105
  },
809
1106
  },
810
- }
811
- );
1107
+ ],
812
1108
 
813
- // Dot notation (MongoDB-style)
814
- const users = await fetchList(
815
- UsersCollection,
816
- {},
817
- {
818
- fields: {
819
- name: 1,
820
- "address.street": 1,
821
- "address.city": 1,
1109
+ onRemoved: [
1110
+ {
1111
+ /* Only log removal of published posts */
1112
+ when(doc) {
1113
+ return doc.status === "published";
1114
+ },
1115
+ fn(doc) {
1116
+ logEvent("post_deleted", { postId: doc._id });
1117
+ },
822
1118
  },
823
- }
824
- );
1119
+ ],
1120
+ });
825
1121
  ```
826
1122
 
827
- #### Flattening Fields
1123
+ </details>
1124
+
1125
+ <details>
1126
+ <summary>Cascading removals</summary>
828
1127
 
829
1128
  ```js
830
- import { flattenFields } from "coll-fns";
1129
+ hook(Users, {
1130
+ beforeRemove: [
1131
+ {
1132
+ fn(usersToRemove) {
1133
+ const userIds = usersToRemove.map((u) => u._id);
1134
+
1135
+ /* Prevent removal if user has published posts */
1136
+ const hasPublished = exists(Posts, {
1137
+ authorId: { $in: userIds },
1138
+ status: "published",
1139
+ });
1140
+
1141
+ if (hasPublished) {
1142
+ throw new Error("Cannot delete users with published posts");
1143
+ }
1144
+ },
1145
+ },
1146
+ ],
831
1147
 
832
- const flattened = flattenFields({
833
- name: 1,
834
- address: {
835
- street: 1,
836
- city: 1,
837
- },
1148
+ onRemoved: [
1149
+ {
1150
+ fn(user) {
1151
+ /* Clean up related data after user is removed.
1152
+ * See remove() for more details on how this integrates with hooks. */
1153
+ remove(Comments, { authorId: user._id });
1154
+ },
1155
+ },
1156
+ ],
838
1157
  });
839
- // Result: { name: 1, 'address.street': 1, 'address.city': 1 }
840
1158
  ```
841
1159
 
842
- ## Available Protocols
1160
+ </details>
1161
+
1162
+ The data mutation methods below use basically the same arguments as [Meteor collection methods](https://docs.meteor.com/api/collections.html#Mongo-Collection-updateAsync).
843
1163
 
844
- All protocols are available through the `protocols` namespace:
1164
+ ## `insert(Coll, doc)`
1165
+
1166
+ Insert a document into a collection. Returns the document \_id. Runs `beforeInsert` and `onInserted` hooks if defined.
845
1167
 
846
1168
  ```js
847
- import { protocols } from "coll-fns";
1169
+ const newUser = insert(Users, {
1170
+ name: "Bob",
1171
+ email: "bob@example.com",
1172
+ });
848
1173
  ```
849
1174
 
850
- ### Node.js (MongoDB)
1175
+ **Execution flow:**
851
1176
 
852
- ```js
853
- import { setProtocol, protocols } from "coll-fns";
854
- import { MongoClient } from "mongodb";
1177
+ 1. Run `beforeInsert` hooks (can throw to prevent insertion)
1178
+ 2. Insert the document
1179
+ 3. Fire `onInserted` hooks asynchronously (without awaiting)
855
1180
 
856
- const client = new MongoClient(url);
857
- await client.connect();
858
- setProtocol(protocols.node(client));
859
- ```
1181
+ ## `update(Coll, selector, modifier, options)`
860
1182
 
861
- ### Meteor (Synchronous)
1183
+ 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).
862
1184
 
863
1185
  ```js
864
- import { setProtocol, protocols } from "coll-fns";
865
-
866
- setProtocol(protocols.meteorSync);
1186
+ update(Users, { status: "pending" }, { $set: { status: "active" } });
867
1187
  ```
868
1188
 
869
- ### Meteor (Asynchronous)
1189
+ **Execution flow:**
870
1190
 
871
- ```js
872
- import { setProtocol, protocols } from "coll-fns";
1191
+ 1. Fetch target documents with `beforeUpdate` and `onUpdated.before` fields
1192
+ 2. Run `beforeUpdate` hooks with `(docsToUpdate, modifier)` (can throw to prevent update)
1193
+ 3. Execute the update
1194
+ 4. Fetch updated documents again with `onUpdated` fields
1195
+ 5. Fire `onUpdated` hooks asynchronously with `(afterDoc, beforeDoc)` pairs
873
1196
 
874
- setProtocol(protocols.meteorAsync);
875
- ```
1197
+ **Options:**
876
1198
 
877
- ## Advanced Usage
1199
+ - `multi` (default: `true`): Update multiple documents or just the first match;
1200
+ - `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.
878
1201
 
879
- ### Custom Protocols
1202
+ ## `remove(Coll, selector)`
880
1203
 
881
- Create your own protocol by implementing the required methods:
1204
+ Remove documents matching the selector. Runs `beforeRemove` and `onRemoved` hooks if defined.
882
1205
 
883
1206
  ```js
884
- const customProtocol = {
885
- fetch: (coll, selector, options) => {
886
- /* ... */
887
- },
888
- insert: (coll, doc) => {
889
- /* ... */
890
- },
891
- update: (coll, selector, modifier, options) => {
892
- /* ... */
893
- },
894
- remove: (coll, selector) => {
895
- /* ... */
896
- },
897
- count: (coll, selector) => {
898
- /* ... */
899
- },
900
- };
901
-
902
- setProtocol(customProtocol);
1207
+ remove(Users, { inactive: true });
903
1208
  ```
904
1209
 
905
- ### Join Management
1210
+ **Execution flow:**
906
1211
 
907
- #### Setting Custom Join Prefix
1212
+ 1. Fetch documents matching the selector with `beforeRemove` and `onRemoved` fields
1213
+ 2. Run `beforeRemove` hooks with matched documents (can throw to prevent removal)
1214
+ 3. Remove the documents
1215
+ 4. Fire `onRemoved` hooks asynchronously with each removed document
908
1216
 
909
- By default, join fields are prefixed with `+`. You can customize this:
1217
+ ## `configurePool(options)`
910
1218
 
911
- ```js
912
- import { setJoinPrefix } from "coll-fns";
1219
+ After hooks can generate significant background work, especially when they trigger cascading writes and more after hooks.
1220
+
1221
+ `coll-fns` uses an internal execution pool for fire-and-forget after hooks.
1222
+ You can configure this pool at startup.
1223
+
1224
+ `configurePool` must be called **before any after hook is processed**.
1225
+
1226
+ ### Default behavior
1227
+
1228
+ By default, the pool uses:
1229
+
1230
+ - `maxConcurrent: 10`
1231
+ - `maxPending: 250`
1232
+ - `onOverflow`: drop new call and warn in console
1233
+
1234
+ This prevents unbounded growth while allowing parallel processing.
1235
+
1236
+ ### Options
913
1237
 
914
- setJoinPrefix("joins"); // Now use { joins: { author: 1 } } instead of { '+': { author: 1 } }
1238
+ ```ts
1239
+ configurePool({
1240
+ maxConcurrent?: number; // >= 1
1241
+ maxPending?: number | Infinity; // >= 0 or Infinity
1242
+ onOverflow?: "drop" | "shift" | (pendings, call) => reorderedPendings | void;
1243
+ onError?: (error, call) => void;
1244
+ });
915
1245
  ```
916
1246
 
917
- If join prefix is set to a falsy value, join fields can be declared at the document root like any native field.
1247
+ - `maxConcurrent`: maximum number of after hooks executed in parallel.
1248
+ - `maxPending`: maximum number of queued hooks waiting for execution.
1249
+ - `onOverflow`: policy to apply when pendings overflow
1250
+ - "drop": ignore the new call.
1251
+ - "shift": remove oldest pending call, enqueue the new one.
1252
+ - function: return a reordered/filtered pending list.
1253
+ - `onError`: called when a pooled call fails. If omitted, errors are logged.
1254
+
1255
+ **Example**
918
1256
 
919
1257
  ```js
920
- import { setJoinPrefix } from "coll-fns";
1258
+ import { configurePool } from "coll-fns";
1259
+
1260
+ /* Must be called BEFORE any after hook is processed. */
1261
+ configurePool({
1262
+ maxConcurrent: 20,
1263
+ maxPending: 1000,
1264
+ onOverflow: "shift",
1265
+ onError(error, call) {
1266
+ console.error("After-hook pool error:", error, call);
1267
+ },
1268
+ });
1269
+ ```
1270
+
1271
+ **Notes**
1272
+
1273
+ - This configuration is **startup-only**. Calling configurePool after processing starts throws.
1274
+ - If your workload is sensitive to ordering, keep maxConcurrent low or implement ordering constraints in your hook logic.
1275
+ - Tune maxConcurrent/maxPending based on your app’s throughput and memory profile.
921
1276
 
922
- setJoinPrefix(null); // Now use { author: 1 } instead of { '+': { author: 1 } }
1277
+ ## Hook best practices
1278
+
1279
+ <details>
1280
+ <summary><strong>Error handling</strong></summary>
1281
+
1282
+ **Before hooks** should throw errors to prevent operations:
1283
+
1284
+ ```js
1285
+ hook(Users, {
1286
+ beforeInsert: [
1287
+ {
1288
+ fn(doc) {
1289
+ if (!isValidEmail(doc.email)) {
1290
+ throw new Error("Invalid email");
1291
+ }
1292
+ },
1293
+ },
1294
+ ],
1295
+ });
923
1296
  ```
924
1297
 
925
- #### Getting Join Configuration
1298
+ **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`.
926
1299
 
927
1300
  ```js
928
- import { getJoins, getJoinPrefix } from "coll-fns";
1301
+ hook(Users, {
1302
+ onInserted: [
1303
+ {
1304
+ fn(doc) {
1305
+ /* ... */
1306
+ },
1307
+ onError(err, hookDef) {
1308
+ logToService(err, hookDef.collName);
1309
+ },
1310
+ },
1311
+ ],
1312
+ });
1313
+ ```
1314
+
1315
+ </details>
1316
+
1317
+ <details>
1318
+ <summary><strong>Field optimization</strong></summary>
929
1319
 
930
- const joins = getJoins(fields); // Extract join definitions from fields
931
- const prefix = getJoinPrefix(); // Get current join prefix (default: '+')
1320
+ Always declare which fields your hook needs with the `fields` property. This reduces database queries and improves performance:
1321
+
1322
+ ```js
1323
+ hook(Posts, {
1324
+ onUpdated: [
1325
+ {
1326
+ /* Only fetch these fields */
1327
+ fields: { authorId: 1, title: 1 },
1328
+ fn(afterPost, beforePost) {
1329
+ if (afterPost.title !== beforePost.title) {
1330
+ notifySubscribers(afterPost);
1331
+ }
1332
+ },
1333
+ },
1334
+ ],
1335
+ });
932
1336
  ```
933
1337
 
934
- ## Project Structure
1338
+ </details>
1339
+
1340
+ <details>
1341
+ <summary><strong>Conditional execution</strong></summary>
1342
+
1343
+ Use `when` and `unless` to avoid unnecessary side effects while keeping code clean and predictable:
935
1344
 
1345
+ ```js
1346
+ hook(Users, {
1347
+ onUpdated: [
1348
+ {
1349
+ fields: { status: 1 },
1350
+ /* Only run if status actually changed */
1351
+ unless(after, before) {
1352
+ return after.status === before.status;
1353
+ },
1354
+ fn(after, before) {
1355
+ sendStatusChangeEmail(after);
1356
+ },
1357
+ },
1358
+ ],
1359
+ });
936
1360
  ```
937
- src/
938
- ├── count.js - Count operation
939
- ├── fetch.js - Fetch operations (fetchList, fetchOne, fetchIds, exists)
940
- ├── fields.js - Field projection utilities
941
- ├── hook.js - Hook system for extending operations
942
- ├── index.js - Main exports
943
- ├── insert.js - Insert operation
944
- ├── join.js - Join functionality
945
- ├── protocol.js - Protocol management
946
- ├── remove.js - Remove operation
947
- ├── update.js - Update operation
948
- ├── util.js - Utility functions
949
- └── protocols/ - Database protocol implementations
950
- ├── index.js
951
- ├── meteorAsync.js
952
- ├── meteorSync.js
953
- └── node.js
1361
+
1362
+ </details>
1363
+
1364
+ <details style="margin-bottom: 1rem">
1365
+ <summary><strong>Async operations</strong></summary>
1366
+
1367
+ Hooks support both synchronous and asynchronous code. Returning a promise from a before-hook will delay the write operation:
1368
+
1369
+ ```js
1370
+ hook(Users, {
1371
+ beforeInsert: [
1372
+ {
1373
+ async fn(doc) {
1374
+ /* Wait for external service */
1375
+ doc.externalId = await createExternalUser(doc);
1376
+ },
1377
+ },
1378
+ ],
1379
+ });
954
1380
  ```
955
1381
 
956
- ## License
1382
+ </details>
1383
+
1384
+ # License
957
1385
 
958
1386
  MIT