coll-fns 1.1.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,958 +1,1277 @@
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
+ - [Hook best practices](#hook-best-practices)
51
+ - [License](#license)
12
52
 
13
- ## Key Features
53
+ # Rationale
14
54
 
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
55
+ Click on any element to unfold it and better understand the rationale behind this library!
22
56
 
23
- ## Installation
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.
24
140
 
25
141
  ```bash
26
142
  npm install coll-fns
27
143
  ```
28
144
 
29
- ## Quick Start
145
+ ## `setProtocol(protocol)`
146
+
147
+ You will have to **define which protocol to use** before using any of the library's functionality.
30
148
 
31
149
  ```js
32
- import {
33
- setProtocol,
34
- fetchList,
35
- insert,
36
- update,
37
- remove,
38
- count,
39
- join,
40
- protocols,
41
- } from "coll-fns";
150
+ import { setProtocol, protocols } from "coll-fns";
42
151
 
43
- // Set up your protocol
44
- 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
+ ```
45
160
 
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" });
161
+ In a Meteor project, you should probably define a **different protocol on client** (synchronous) **and server** (asynchronous).
52
162
 
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
- });
163
+ ```js
164
+ import { setProtocol, protocols } from "coll-fns";
61
165
 
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
166
+ const protocol = Meteor.isServer ? protocols.meteorAsync : protocols.meteorSync;
167
+ setProtocol(protocol);
168
+ ```
169
+
170
+ There's also a **native NodeJS MongoDB driver** protocol built-in (`protocols.node`).
171
+
172
+ <details style="margin-bottom: 1rem">
173
+ <summary><strong>Custom protocol</strong></summary>
174
+
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 🤓!).
176
+
177
+ ```js
178
+ import { setProtocol } from "coll-fns";
179
+
180
+ const customProtocol = {
181
+ /* Return a documents count */
182
+ count(/* Coll, selector = {}, options = {} */) {},
183
+
184
+ /* Return a list of documents. */
185
+ findList(/* Coll, selector = {}, options = {} */) {},
186
+
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 */) {},
193
+
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);
75
208
  ```
76
209
 
77
- ## Joins: The Power Feature
210
+ </details>
78
211
 
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.
212
+ ## Bypassing `coll-fns`
80
213
 
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.
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! 😉
82
215
 
83
- ### Defining and Using Joins
216
+ # Joins and fetch
84
217
 
85
- Register joins once (typically during initialization) and reference them in fetch calls:
218
+ ## Quick start examples
86
219
 
87
220
  ```js
88
- import { join, fetchList } from "coll-fns";
221
+ import { fetchList, join } from "coll-fns";
222
+ import { Comments, Posts, Users } from "/collections";
89
223
 
90
- // Define joins globally for a collection (usually in initialization code)
91
- join(PostsCollection, {
224
+ /* Define joins on Posts collection */
225
+ join(Posts, {
226
+ /* One-to-one join */
92
227
  author: {
93
- Coll: UsersCollection,
228
+ Coll: Users,
94
229
  on: ["authorId", "_id"],
95
230
  single: true,
96
231
  },
232
+
233
+ /* One-to-many join */
97
234
  comments: {
98
- Coll: CommentsCollection,
235
+ Coll: Comments,
99
236
  on: ["_id", "postId"],
237
+ /* `single` defaults to false,
238
+ * so joined docs are returned as an array */
100
239
  },
101
240
  });
102
241
 
103
- // Now use fetch without re-specifying the join definitions
104
- const posts = await fetchList(
105
- PostsCollection,
106
- { status: "published" },
242
+ /* Fetch data with nested joined documents in the requested shape. */
243
+ fetchList(
244
+ Posts,
245
+ {},
107
246
  {
108
247
  fields: {
109
- title: 1,
110
- content: 1,
111
- "+": { author: 1, comments: 1 }, // Reference pre-defined joins
248
+ title: 1, // <= Own
249
+ author: { birthdate: 0 }, // <= Falsy = anything but these fields
250
+ comments: { text: 1 },
112
251
  },
113
252
  }
114
253
  );
115
-
116
- // Result: Each post includes author and comments as defined
117
254
  ```
118
255
 
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
- {},
256
+ ```jsonc
257
+ [
127
258
  {
128
- fields: {
129
- title: 1,
130
- content: 1,
131
- "+": { author: 1 }, // Use '+' prefix to include join fields
259
+ "title": "Blabla",
260
+ "authorId": "foo", // <= Included by join definition
261
+ "author": {
262
+ "name": "Foo Bar",
263
+ "genre": "non-fiction",
132
264
  },
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: { ... } }))
265
+ /* Comments is a one-to-many join, so is returned as a list */
266
+ "comments": [{ "text": "Nice!" }, { "text": "Great!" }],
267
+ },
268
+ ]
138
269
  ```
139
270
 
140
- ### One-to-Many Joins
271
+ ## `join(Coll, joinDefinitions)`
272
+
273
+ Collections can be joined together with **globally pre-registered joins** to greatly simplify optimized data fetching.
274
+
275
+ Joins are **not symmetrical by default**. Each collection should define its own relationships.
141
276
 
142
277
  ```js
143
- import { join, fetchList } from "coll-fns";
278
+ import { join } from "coll-fns";
144
279
 
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
- });
280
+ join(
281
+ /* Parent collection */
282
+ Coll,
154
283
 
155
- // Use in fetch
156
- const posts = await fetchList(
157
- PostsCollection,
158
- {},
284
+ /* Map of joins on children collections.
285
+ * Each key is the name of the field
286
+ * where joined docs will be placed. */
159
287
  {
160
- fields: {
161
- title: 1,
162
- "+": { comments: 1 },
288
+ joinProp1: {
289
+ /* joinDefinition */
290
+ },
291
+
292
+ joinProp2: {
293
+ /* joinDefinition */
163
294
  },
164
295
  }
165
296
  );
166
-
167
- // Result: Each post includes a 'comments' array
168
297
  ```
169
298
 
170
- ### Nested Joins
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.
302
+
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.
171
304
 
172
- Joins can be nested to fetch deeply related data. Register all joins upfront:
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.
173
310
 
174
311
  ```js
175
- import { join, fetchList } from "coll-fns";
312
+ import { join } from "coll-fns";
313
+ import { Comments, Posts, Users } from "/collections";
176
314
 
177
- // Pre-register joins for PostsCollection
178
- join(PostsCollection, {
315
+ join(Posts, {
179
316
  author: {
180
- Coll: UsersCollection,
317
+ Coll: Users,
318
+ /* `post.authorId === user._id` */
181
319
  on: ["authorId", "_id"],
320
+ /* Single doc instead of a list */
182
321
  single: true,
183
- fields: { name: 1, avatar: 1 },
184
322
  },
323
+
185
324
  comments: {
186
- Coll: CommentsCollection,
325
+ Coll: Comments,
326
+ /* `post._id === comment.postId` */
187
327
  on: ["_id", "postId"],
188
- fields: { text: 1, "+": { user: 1 } },
189
328
  },
190
329
  });
191
330
 
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 },
331
+ /* Reversed join from user to posts */
332
+ join(Users, {
333
+ posts: {
334
+ Coll: Posts,
335
+ on: ["_id", "authorId"],
199
336
  },
200
337
  });
338
+ ```
201
339
 
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
- );
340
+ ### Sub-array joins
214
341
 
215
- // Result: Posts with author details and comments, each comment with user details
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.
343
+
344
+ ```js
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
+ });
356
+
357
+ /* The reverse join will flip the property names. */
358
+ join(Resources, {
359
+ actions: {
360
+ Coll: Actions,
361
+ on: [["actionIds"], "_id"],
362
+ },
363
+ });
216
364
  ```
217
365
 
218
- ### Recursive Join Depth Control
366
+ ### Filtered array-joins
219
367
 
220
- Control the depth of recursive joins to prevent infinite loops:
368
+ Some joins should target only specific documents in the foreign collection. A complementary selector can be passed to the third `on` array argument.
221
369
 
222
370
  ```js
223
- import { join, fetchList } from "coll-fns";
371
+ import { join } from "coll-fns";
372
+ import { Actions, Resources } from "/collections";
224
373
 
225
- // Pre-register recursive join
226
- join(UsersCollection, {
227
- friends: {
228
- Coll: UsersCollection,
229
- on: ["friendIds", "_id"],
374
+ join(Resources, {
375
+ /* Only active tasks (third array element is a selector) */
376
+ activeTasks: {
377
+ Coll: Tasks,
378
+ on: ["_id", "resourceId", { active: true }],
230
379
  },
231
- });
232
380
 
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
- );
381
+ /* All tasks associated with a resource */
382
+ tasks: {
383
+ Coll: Tasks,
384
+ on: ["_id", "resourceId"],
385
+ },
386
+ });
244
387
  ```
245
388
 
246
- ### Function-Based Joins with `fields`
389
+ ### Object joins
247
390
 
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:
391
+ The `on` join definition property can be an object representing a selector. It will always retrieve the same linked documents.
249
392
 
250
393
  ```js
251
- import { join, fetchList } from "coll-fns";
394
+ import { join } from "coll-fns";
395
+ import { Factory, Workers } from "../collections";
252
396
 
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 },
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,
261
403
  },
262
404
  });
405
+ ```
263
406
 
264
- // Use in fetch
265
- const posts = await fetchList(
266
- PostsCollection,
267
- {},
268
- {
407
+ ### Function joins
408
+
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.
410
+
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 */
269
434
  fields: {
270
- title: 1,
271
- "+": { userComments: 1 },
435
+ _id: 1, // Optional. _id is implicit in any fetch.
436
+ postedAt: 1,
272
437
  },
273
- }
274
- );
438
+ },
439
+ });
275
440
  ```
276
441
 
277
- ## Hooks: Extensibility Made Easy
442
+ ### Recursive joins
278
443
 
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
444
+ A collection can define joins on itself.
282
445
 
283
446
  ```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
- });
447
+ import { join } from "coll-fns";
448
+ import { Users } from "/collections";
296
449
 
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);
450
+ join(Users, {
451
+ friends: {
452
+ /* Use the same collection in the join definition */
453
+ Coll: Users,
454
+ on: [["friendIds"], "_id"],
455
+ },
302
456
  });
303
457
  ```
304
458
 
305
- ### Available Hook Types
459
+ ### Join additional options
306
460
 
307
- Hooks can be attached to any CRUD operation:
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:
308
462
 
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)
463
+ - `limit`: Maximum joined documents count
464
+ - `skip`: Documents to skip in the fetch
465
+ - `sort`: Sort order of joined documents
313
466
 
314
- ### Hook Examples
467
+ ### `postFetch`
315
468
 
316
- #### Validation and Authorization
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.
317
472
 
318
473
  ```js
319
- // Prevent unauthorized updates
320
- hook(PostsCollection, "update", "before", (selector, modifier, options) => {
321
- const currentUserId = getCurrentUserId();
322
- const post = fetchList(PostsCollection, selector)[0];
474
+ import { join } from "coll-fns";
475
+ import { Actions, Resources } from "/collections";
476
+ import { sortTasks } from "/lib/tasks";
323
477
 
324
- if (post.authorId !== currentUserId) {
325
- throw new Error("Unauthorized");
326
- }
478
+ join(Resources, {
479
+ tasks: {
480
+ Coll: Tasks,
481
+ on: ["_id", "resourceId"],
482
+
483
+ /* Ensure `tasksOrder` will be fetched */
484
+ fields: { tasksOrder: 1 },
327
485
 
328
- return [selector, modifier, options];
486
+ /* Transform the joined tasks documents based on parent resource. */
487
+ postFetch(tasks, resource) {
488
+ const { tasksOrder = [] } = resource;
489
+ return sortTasks(tasks, tasksOrder);
490
+ },
491
+ },
329
492
  });
330
493
  ```
331
494
 
332
- #### Automatic Timestamps
495
+ ### `getJoins`
333
496
 
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
- });
497
+ Use `getJoins(Coll)` to retrieve the complete dictionary of the collection's joins.
341
498
 
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
- ```
499
+ ## `fetchList(Coll, selector, options)`
348
500
 
349
- #### Audit Logging
501
+ Fetch documents with the ability to **use collection joins**.
350
502
 
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
- ```
503
+ **Options:**
364
504
 
365
- #### Data Denormalization
505
+ - `fields`: Field projection object
506
+ - `limit`: Maximum number of documents
507
+ - `skip`: Number of documents to skip
508
+ - `sort`: Sort specification
366
509
 
367
- ```js
368
- // Update denormalized data
369
- hook(UsersCollection, "update", "after", (result, selector, modifier) => {
370
- const userId = selector._id;
371
- const userName = modifier.$set?.name;
510
+ In its simplest form, `fetchList` can be used in much the same way as Meteor's `Coll.find(...args).fetch()`.
372
511
 
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
- );
512
+ ```js
513
+ const users = await fetchList(
514
+ Users,
515
+ { status: "active" },
516
+ {
517
+ fields: { name: 1, email: 1 },
518
+ sort: { createdAt: -1 },
519
+ limit: 10,
520
+ skip: 0,
381
521
  }
382
- });
522
+ );
383
523
  ```
384
524
 
385
- ## Error Handling
525
+ ### `fields` option and joins
526
+
527
+ Contrary to regular projection objects, they can use nested properties `{ car: { make: 1 } }` instead of dot-string ones `{ car: 1, "car.make": 1 }`.
386
528
 
387
- `coll-fns` provides multiple mechanisms for handling errors in database operations and validations.
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.
388
530
 
389
- ### Throwing Errors in Hooks
531
+ #### Examples
390
532
 
391
- Hooks can throw errors to prevent operations from completing. This is useful for validation, authorization checks, and data integrity:
533
+ <details>
534
+ <summary>Join definitions for examples</summary>
392
535
 
393
536
  ```js
394
- import { hook } from "coll-fns";
537
+ import { fetchList, join } from "coll-fns";
538
+ import { Comments, Posts, Users } from "/collections";
395
539
 
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
- });
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
+ },
403
548
 
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
- }
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
+ });
411
557
  ```
412
558
 
413
- ### Async/Await Error Handling
559
+ </details>
414
560
 
415
- All operations support both sync and async protocols. Use standard try/catch for async operations:
561
+ <details>
562
+ <summary>Undefined (all) own fields</summary>
416
563
 
417
564
  ```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`);
565
+ fetchList(Posts, {});
566
+ ```
424
567
 
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
- }
568
+ ```json
569
+ [{ "title": "Blabla", "authorId": "foo", "likes": 7 }]
437
570
  ```
438
571
 
439
- ### Join Validation Errors
572
+ </details>
440
573
 
441
- Joins are validated when registered globally. Invalid join definitions throw errors immediately:
574
+ <details>
575
+ <summary>Some own fields</summary>
442
576
 
443
577
  ```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
578
+ fetchList(
579
+ Posts,
580
+ {},
581
+ {
582
+ fields: {
583
+ title: true, // <= Own. Any truthy value works
451
584
  },
452
- });
453
- } catch (error) {
454
- console.error(error.message);
455
- // Output: Collection 'Coll' for 'author' join is required.
456
- }
585
+ }
586
+ );
587
+ ```
457
588
 
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
- }
589
+ ```json
590
+ [{ "title": "Blabla" }]
470
591
  ```
471
592
 
472
- ### Authorization in Hooks
593
+ </details>
473
594
 
474
- Use hooks to enforce authorization rules and throw errors for unauthorized operations:
595
+ <details>
596
+ <summary>Undefined (all) own fields, truthy (all) join fields</summary>
475
597
 
476
598
  ```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");
599
+ fetchList(
600
+ Posts,
601
+ {},
602
+ {
603
+ fields: {
604
+ author: 1, // <= Join
605
+ },
483
606
  }
607
+ );
608
+ ```
484
609
 
485
- if (post.authorId !== currentUserId) {
486
- throw new Error("Unauthorized: You can only edit your own posts");
487
- }
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
+ ]
623
+ ```
488
624
 
489
- return [selector, modifier, options];
490
- });
625
+ </details>
626
+
627
+ <details>
628
+ <summary>Some own fields, truthy (all) join fields</summary>
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
+ ```js
631
+ fetchList(
632
+ Posts,
633
+ {},
634
+ {
635
+ fields: {
636
+ title: 1, // <= Own
637
+ author: 1, // <= Join
638
+ },
504
639
  }
505
- }
640
+ );
641
+ ```
642
+
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
+ ]
506
655
  ```
507
656
 
508
- ### Handling Join Fetch Errors
657
+ </details>
509
658
 
510
- When joins fail during fetch operations, errors propagate through the promise chain:
659
+ <details>
660
+ <summary>Some own fields, some join fields</summary>
511
661
 
512
662
  ```js
513
- import { join, fetchList } from "coll-fns";
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
+ );
674
+ ```
514
675
 
515
- // Pre-register the join
516
- join(PostsCollection, {
517
- author: {
518
- Coll: UsersCollection,
519
- on: ["authorId", "_id"],
520
- single: true,
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!" }],
521
687
  },
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
- }
688
+ ]
537
689
  ```
538
690
 
539
- ### Protocol-Level Error Handling
691
+ </details>
692
+
693
+ ### `setJoinPrefix(prefix)`
540
694
 
541
- When a protocol method is not implemented, `coll-fns` throws a descriptive error:
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.
696
+
697
+ Setting the prefix to null or undefined allows using join fields at the document root like any normal field.
542
698
 
543
699
  ```js
544
- import { setProtocol, fetchList } from "coll-fns";
700
+ import { setJoinPrefix } from "coll-fns";
545
701
 
546
- // Using incomplete protocol
547
- setProtocol({
548
- // Missing required methods
549
- });
702
+ /* All join fields will have to be prefixed with "+" */
703
+ setJoinPrefix("+");
550
704
 
551
- try {
552
- await fetchList(SomeCollection, {});
553
- } catch (error) {
554
- console.error(error.message);
555
- // Output: 'findList' method must be defined with 'setProtocol'.
556
- }
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
+ );
557
720
  ```
558
721
 
559
- ## Meteor Integration: Isomorphic by Design
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)`.
560
725
 
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.
726
+ ### Nested Joins
562
727
 
563
- ### Server-Side (Async)
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.
564
729
 
565
730
  ```js
566
- // server/main.js
567
- import { setProtocol, protocols } from "coll-fns";
731
+ import { fetchList } from "coll-fns";
568
732
 
569
- setProtocol(protocols.meteorAsync);
733
+ const posts = fetchList(
734
+ Posts,
735
+ {},
736
+ {
737
+ fields: {
738
+ title: 1,
739
+
740
+ /* Level 1 : One-to-many join */
741
+ comments: {
742
+ text: 1,
743
+
744
+ /* Level 2 : One-to-one join */
745
+ user: {
746
+ username: 1,
747
+ },
748
+ },
749
+ },
750
+ }
751
+ );
752
+ ```
570
753
 
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
- });
754
+ ```json
755
+ {
756
+ "title": "Blabla",
757
+ "comments": [
758
+ { "text": "Nice!", "user": { "username": "foo"} },
759
+ { "text": "Great!", "user": { "username": "bar" } }
760
+ ]
580
761
  },
581
- });
582
762
  ```
583
763
 
584
- ### Client-Side (Sync)
764
+ ### Recursion levels
585
765
 
586
- ```js
587
- // client/main.js
588
- import { setProtocol, protocols, join } from "coll-fns";
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.
589
767
 
590
- setProtocol(protocols.meteorSync);
768
+ ```js
769
+ import { join } from "coll-fns";
770
+ import { Users } from "/collections";
591
771
 
592
- // Pre-register joins (same as server)
593
- join(PostsCollection, {
594
- author: {
595
- Coll: UsersCollection,
596
- on: ["authorId", "_id"],
597
- single: true,
772
+ /* Pre-register recursive join */
773
+ join(Users, {
774
+ friends: {
775
+ Coll: Users,
776
+ on: [["friendIds"], "_id"],
598
777
  },
599
778
  });
600
779
 
601
- // Same API, synchronous execution
602
- const posts = fetchList(
603
- PostsCollection,
780
+ fetchList(
781
+ Users,
604
782
  {},
605
- { fields: { title: 1, "+": { author: 1 } } }
783
+ {
784
+ fields: {
785
+ name: 1,
786
+ /* Join field. Limit to 2 levels deep, reusing parent fields */
787
+ friends: 2,
788
+ },
789
+ }
606
790
  );
607
791
  ```
608
792
 
609
- ### Shared Code
793
+ ### Documents transformation
794
+
795
+ Documents can be transformed after fetching. Collection-level transforms are automatically applied if the protocol allows it:
796
+
797
+ **Meteor:**
610
798
 
611
799
  ```js
612
- // imports/api/posts.js
613
- import { join, fetchList } from "coll-fns";
800
+ import { Mongo } from "meteor/mongo";
614
801
 
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
- },
802
+ const Users = new Mongo.Collection("users", {
803
+ transform: (doc) => ({
804
+ ...doc,
805
+ fullName: `${doc.firstName} ${doc.lastName}`,
806
+ }),
623
807
  });
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
808
  ```
640
809
 
641
- ## API Reference
642
-
643
- ### Core Functions
644
-
645
- #### `fetchList(collection, selector, options)`
646
-
647
- Fetch an array of documents from a collection.
810
+ **For a specific fetch**, pass a `transform` option:
648
811
 
649
812
  ```js
650
813
  const users = await fetchList(
651
- UsersCollection,
814
+ Users,
652
815
  { status: "active" },
653
816
  {
654
- fields: { name: 1, email: 1 },
655
- sort: { createdAt: -1 },
656
- limit: 10,
657
- skip: 0,
817
+ transform: (doc) => ({
818
+ ...doc,
819
+ fullName: `${doc.firstName} ${doc.lastName}`,
820
+ }),
658
821
  }
659
822
  );
660
823
  ```
661
824
 
662
- **Options:**
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.
663
826
 
664
- - `fields`: Field projection object
665
- - `sort`: Sort specification
666
- - `limit`: Maximum number of documents
667
- - `skip`: Number of documents to skip
827
+ ## `fetchOne(Coll, selector, options)`
668
828
 
669
- #### `fetchOne(collection, selector, options)`
670
-
671
- Fetch a single document from a collection.
829
+ Fetch a single document from a collection. Same behaviour as `fetchList`.
672
830
 
673
831
  ```js
674
- const user = await fetchOne(
675
- UsersCollection,
832
+ import { fetchOne } from "coll-fns";
833
+ import { Users } from "/collections";
834
+
835
+ const user = fetchOne(
836
+ Users,
676
837
  { _id: userId },
677
838
  {
678
- fields: { name: 1, email: 1 },
839
+ fields: {
840
+ name: 1,
841
+ friends: 1, // <= Join
842
+ },
679
843
  }
680
844
  );
681
845
  ```
682
846
 
683
- #### `fetchIds(collection, selector, options)`
847
+ ## `fetchIds(Coll, selector, options)`
684
848
 
685
- Fetch only the `_id` field of matching documents.
849
+ Fetch only the `_id` field of matching documents. `fields` option will be ignored.
686
850
 
687
851
  ```js
688
- const userIds = await fetchIds(UsersCollection, { status: "active" });
689
- // Returns: ['id1', 'id2', 'id3']
852
+ import { fetchOne } from "coll-fns";
853
+ import { Users } from "/collections";
854
+
855
+ const userIds = fetchIds(Users, { status: "active" });
690
856
  ```
691
857
 
692
- #### `exists(collection, selector)`
858
+ ## `exists(Coll, selector)`
693
859
 
694
- Check if any documents match the selector.
860
+ Check if any document matches the selector.
695
861
 
696
862
  ```js
697
- const hasActiveUsers = await exists(UsersCollection, { status: "active" });
863
+ import { fetchOne } from "coll-fns";
864
+ import { Users } from "/collections";
865
+
866
+ const hasActiveUsers = exists(Users, { status: "active" });
698
867
  // Returns: true or false
699
868
  ```
700
869
 
701
- #### `insert(collection, doc)`
870
+ ## `count(Coll, selector)`
702
871
 
703
- Insert a document into a collection.
872
+ Count documents matching the selector.
704
873
 
705
874
  ```js
706
- const newUser = await insert(UsersCollection, {
707
- name: "Bob",
708
- email: "bob@example.com",
709
- });
875
+ import { fetchOne } from "coll-fns";
876
+ import { Users } from "/collections";
877
+
878
+ const activeUsersCount = count(UsersCollection, { status: "active" });
879
+ // Returns an integer
710
880
  ```
711
881
 
712
- #### `update(collection, selector, modifier, options)`
882
+ ## `flattenFields(fields)`
713
883
 
714
- Update documents matching the selector.
884
+ Flatten a general field specifiers object (which could include nested objects) into a MongoDB-compatible one that uses dot-notation.
715
885
 
716
886
  ```js
717
- await update(
718
- UsersCollection,
719
- { status: "pending" },
720
- { $set: { status: "active" } },
721
- { multi: true }
722
- );
887
+ import { flattenFields } from "coll-fns";
888
+
889
+ const flattened = flattenFields({
890
+ name: 1,
891
+ address: {
892
+ street: 1,
893
+ city: 1,
894
+ },
895
+ });
896
+ // Result: { name: 1, 'address.street': 1, 'address.city': 1 }
723
897
  ```
724
898
 
725
- #### `remove(collection, selector)`
899
+ # Hooks and write operations
726
900
 
727
- Remove documents matching the selector.
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.
728
902
 
729
- ```js
730
- await remove(UsersCollection, { inactive: true });
731
- ```
903
+ ## `hook(Coll, hooksObj)`
732
904
 
733
- #### `count(collection, selector)`
905
+ Register hooks on a collection to run before or after specific write operations (insert, update, remove).
734
906
 
735
- Count documents matching the selector.
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**.
736
908
 
737
- ```js
738
- const activeUsers = await count(UsersCollection, { status: "active" });
739
- ```
909
+ Hooks can be **defined in multiple places** in your codebase. This allows grouping functionally related hooks together.
740
910
 
741
- ### Hook Functions
911
+ ```js
912
+ import { hook } from "coll-fns";
913
+ import { Users, Posts } from "/collections";
742
914
 
743
- #### `hook(collection, operation, timing, fn)`
915
+ hook(Users, {
916
+ beforeInsert: [
917
+ {
918
+ fn(doc) {
919
+ if (!doc.email) throw new Error("Email is required");
744
920
 
745
- Add a hook to a collection operation.
921
+ doc.createdAt = new Date();
922
+ },
923
+ },
924
+ ],
746
925
 
747
- ```js
748
- hook(UsersCollection, "insert", "before", (doc) => {
749
- // Modify or validate doc
750
- return doc;
926
+ onInserted: [
927
+ {
928
+ fn(doc) {
929
+ console.log(`New user created: ${doc._id}`);
930
+ },
931
+ },
932
+ ],
751
933
  });
752
934
  ```
753
935
 
754
- **Parameters:**
936
+ ### Before hooks
755
937
 
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)
938
+ These hooks run **before** the write operation and can **prevent the operation** by throwing an error.
760
939
 
761
- ### Protocol Management
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])`.
762
943
 
763
- #### `setProtocol(protocol)`
944
+ Although arguments can be mutated, it is not the main purpose of these hooks. Mutations are brittle and hard to debug.
764
945
 
765
- Set the active database protocol.
946
+ `beforeUpdate` and `beforeRemove` receive an **array of targeted documents**, whereas `beforeInsert` receives a **single document**.
766
947
 
767
- ```js
768
- import { setProtocol, protocols } from "coll-fns";
948
+ ### After hooks
769
949
 
770
- setProtocol(protocols.meteorAsync);
771
- ```
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)`.
772
955
 
773
- #### `getProtocol()`
956
+ ### Hook definition properties
774
957
 
775
- Get the current active protocol.
958
+ Each hook definition is an object with the following properties:
776
959
 
777
960
  ```js
778
- const currentProtocol = getProtocol();
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
+ }
779
993
  ```
780
994
 
781
- #### `updateProtocol(updates)`
995
+ ### Examples
782
996
 
783
- Update specific methods of the current protocol.
997
+ <details>
998
+ <summary>Data validation</summary>
784
999
 
785
1000
  ```js
786
- import { updateProtocol } 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>
787
1015
 
788
- updateProtocol({
789
- fetch: customFetchImplementation,
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
+ ],
790
1036
  });
791
1037
  ```
792
1038
 
793
- ### Field Projections
1039
+ </details>
794
1040
 
795
- #### Nested Fields
1041
+ <details>
1042
+ <summary>Conditional hooks with when/unless</summary>
796
1043
 
797
1044
  ```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,
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");
808
1056
  },
809
1057
  },
810
- }
811
- );
1058
+ ],
812
1059
 
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,
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
+ },
822
1069
  },
823
- }
824
- );
1070
+ ],
1071
+ });
825
1072
  ```
826
1073
 
827
- #### Flattening Fields
1074
+ </details>
1075
+
1076
+ <details>
1077
+ <summary>Cascading removals</summary>
828
1078
 
829
1079
  ```js
830
- import { flattenFields } from "coll-fns";
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
+ ],
831
1098
 
832
- const flattened = flattenFields({
833
- name: 1,
834
- address: {
835
- street: 1,
836
- city: 1,
837
- },
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
+ ],
838
1108
  });
839
- // Result: { name: 1, 'address.street': 1, 'address.city': 1 }
840
1109
  ```
841
1110
 
842
- ## Available Protocols
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).
1114
+
1115
+ ## `insert(Coll, doc)`
843
1116
 
844
- All protocols are available through the `protocols` namespace:
1117
+ Insert a document into a collection. Returns the document \_id. Runs `beforeInsert` and `onInserted` hooks if defined.
845
1118
 
846
1119
  ```js
847
- import { protocols } from "coll-fns";
1120
+ const newUser = insert(Users, {
1121
+ name: "Bob",
1122
+ email: "bob@example.com",
1123
+ });
848
1124
  ```
849
1125
 
850
- ### Node.js (MongoDB)
1126
+ **Execution flow:**
851
1127
 
852
- ```js
853
- import { setProtocol, protocols } from "coll-fns";
854
- import { MongoClient } from "mongodb";
1128
+ 1. Run `beforeInsert` hooks (can throw to prevent insertion)
1129
+ 2. Insert the document
1130
+ 3. Fire `onInserted` hooks asynchronously (without awaiting)
855
1131
 
856
- const client = new MongoClient(url);
857
- await client.connect();
858
- setProtocol(protocols.node(client));
859
- ```
1132
+ ## `update(Coll, selector, modifier, options)`
860
1133
 
861
- ### Meteor (Synchronous)
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).
862
1135
 
863
1136
  ```js
864
- import { setProtocol, protocols } from "coll-fns";
865
-
866
- setProtocol(protocols.meteorSync);
1137
+ update(Users, { status: "pending" }, { $set: { status: "active" } });
867
1138
  ```
868
1139
 
869
- ### Meteor (Asynchronous)
1140
+ **Execution flow:**
870
1141
 
871
- ```js
872
- import { setProtocol, protocols } from "coll-fns";
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
873
1147
 
874
- setProtocol(protocols.meteorAsync);
875
- ```
1148
+ **Options:**
876
1149
 
877
- ## Advanced Usage
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.
878
1152
 
879
- ### Custom Protocols
1153
+ ## `remove(Coll, selector)`
880
1154
 
881
- Create your own protocol by implementing the required methods:
1155
+ Remove documents matching the selector. Runs `beforeRemove` and `onRemoved` hooks if defined.
882
1156
 
883
1157
  ```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);
1158
+ remove(Users, { inactive: true });
903
1159
  ```
904
1160
 
905
- ### Join Management
1161
+ **Execution flow:**
906
1162
 
907
- #### Setting Custom Join Prefix
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
908
1167
 
909
- By default, join fields are prefixed with `+`. You can customize this:
1168
+ ## Hook best practices
910
1169
 
911
- ```js
912
- import { setJoinPrefix } from "coll-fns";
1170
+ <details>
1171
+ <summary><strong>Error handling</strong></summary>
913
1172
 
914
- setJoinPrefix("joins"); // Now use { joins: { author: 1 } } instead of { '+': { author: 1 } }
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
+ });
915
1187
  ```
916
1188
 
917
- If join prefix is set to a falsy value, join fields can be declared at the document root like any native field.
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`.
918
1190
 
919
1191
  ```js
920
- import { setJoinPrefix } from "coll-fns";
921
-
922
- setJoinPrefix(null); // Now use { author: 1 } instead of { '+': { author: 1 } }
1192
+ hook(Users, {
1193
+ onInserted: [
1194
+ {
1195
+ fn(doc) {
1196
+ /* ... */
1197
+ },
1198
+ onError(err, hookDef) {
1199
+ logToService(err, hookDef.collName);
1200
+ },
1201
+ },
1202
+ ],
1203
+ });
923
1204
  ```
924
1205
 
925
- #### Getting Join Configuration
1206
+ </details>
926
1207
 
927
- ```js
928
- import { getJoins, getJoinPrefix } from "coll-fns";
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:
929
1212
 
930
- const joins = getJoins(fields); // Extract join definitions from fields
931
- 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
+ });
932
1227
  ```
933
1228
 
934
- ## Project Structure
1229
+ </details>
1230
+
1231
+ <details>
1232
+ <summary><strong>Conditional execution</strong></summary>
935
1233
 
1234
+ Use `when` and `unless` to avoid unnecessary side effects while keeping code clean and predictable:
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
+ });
936
1251
  ```
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
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
+ });
954
1271
  ```
955
1272
 
956
- ## License
1273
+ </details>
1274
+
1275
+ # License
957
1276
 
958
1277
  MIT