coll-fns 1.4.3 → 1.6.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
@@ -13,6 +13,7 @@ Stop repeating business logic all over your code base. Define hooks on collectio
13
13
  - [Rationale](#rationale)
14
14
  - [Installation and configuration](#installation-and-configuration)
15
15
  - [`setProtocol(protocol)`](#setprotocolprotocol)
16
+ - [Execution context (`bindEnvironment`)](#execution-context-bindenvironment)
16
17
  - [Bypassing `coll-fns`](#bypassing-coll-fns)
17
18
  - [Joins and fetch](#joins-and-fetch)
18
19
  - [Quick start examples](#quick-start-examples)
@@ -53,6 +54,18 @@ Stop repeating business logic all over your code base. Define hooks on collectio
53
54
  - [Default behavior](#default-behavior)
54
55
  - [Options](#options)
55
56
  - [Hook best practices](#hook-best-practices)
57
+ - [Nested reactive publications](#nested-reactive-publications)
58
+ - [When to use `publish`](#when-to-use-publish)
59
+ - [`publish(publication, Coll, selector, options)`](#publishpublication-coll-selector-options)
60
+ - [Publication readiness](#publication-readiness)
61
+ - [Publication context (`this` in Meteor)](#publication-context-this-in-meteor)
62
+ - [How child declarations work](#how-child-declarations-work)
63
+ - [Ancestors chain](#ancestors-chain)
64
+ - [How `deps` works](#how-deps-works)
65
+ - [Debugging](#debugging)
66
+ - [Built-in optimizations](#built-in-optimizations)
67
+ - [Using `publish` outside Meteor](#using-publish-outside-meteor)
68
+ - [Current limitations](#current-limitations)
56
69
  - [License](#license)
57
70
 
58
71
  # Rationale
@@ -122,7 +135,7 @@ And since it uses a protocol, it can be used with the **native MongoDB driver**
122
135
 
123
136
  </details>
124
137
 
125
- <details style="margin-bottom: 1rem">
138
+ <details>
126
139
  <summary><strong>Functional API</strong></summary>
127
140
 
128
141
  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.
@@ -139,6 +152,26 @@ For Meteor developers, it also means being able to enhance the `Meteor.users` co
139
152
 
140
153
  </details>
141
154
 
155
+ <details style="margin-bottom: 1rem">
156
+ <summary><strong>Nested reactive publications</strong></summary>
157
+
158
+ In Meteor in particular, publication of data over DDP that gets synced on the client in Minimongo is very convenient. It makes for really reactive applications where data doesn't get stale. Optimistic UI optimizations is built-in and doesn't require additional complex logic.
159
+
160
+ However, it can get **difficult to publish exactly the right documents**, especially when using a very useful library that allows joining collections together and keeping data much more normalized!
161
+
162
+ Although returning Meteor cursors from a publish function is still the most optimized path, it sometimes makes sense to **publish children documents based on their relationship with their parent**. But doing so is usually very difficult to implement.
163
+
164
+ Some existing community libraries that promise to do so either:
165
+
166
+ - don't actually work (might understand `added` and `removed` callbacks, but won't react to updates)
167
+ - are too simplistic (cause a lot of duplicated observers)
168
+ - come with a much more complex data layer
169
+ - add somewhat unpredictable reactivity on the server.
170
+
171
+ Since `coll-fns` is already very good at understanding relationships, a `publish` helper was introduced to allow such fine-tuned reactivity out of the box. 💪
172
+
173
+ </details>
174
+
142
175
  # Installation and configuration
143
176
 
144
177
  **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.
@@ -220,10 +253,19 @@ const customProtocol = {
220
253
  * and return the inserted _id. */
221
254
  insert(/* Coll, doc, options */) {},
222
255
 
256
+ /* Observe document changes.
257
+ * Must return a handle with a `stop()` method. */
258
+ observe(/* Coll, selector = {}, callbacks = {}, options = {} */) {},
259
+
223
260
  /* Remove documents in a collection
224
261
  * and return the number of removed documents. */
225
262
  remove(/* Coll, selector, options */) {},
226
263
 
264
+ /* Stable stringify function used for internal query keys.
265
+ * EJSON canonical stringify is used as a good default,
266
+ * but can be overridden. */
267
+ stringify(/* value */) {},
268
+
227
269
  /* Update documents in a collection
228
270
  * and return the number of modified documents. */
229
271
  update(/* Coll, selector, modifier, options */) {},
@@ -232,6 +274,33 @@ const customProtocol = {
232
274
  setProtocol(customProtocol);
233
275
  ```
234
276
 
277
+ `observe` expected shape:
278
+
279
+ ```js
280
+ observe(Coll, selector = {}, callbacks = {}, options = {}) {
281
+ const { added, changed, removed } = callbacks;
282
+
283
+ // Your implementation subscribes reactively to selector/options changes.
284
+ // Callbacks should be invoked with:
285
+ // - added(id, fields)
286
+ // - changed(id, fields)
287
+ // - removed(id)
288
+ //
289
+ // Then return an object exposing a stop() method.
290
+ return {
291
+ stop() {
292
+ // Tear down underlying observer/resources.
293
+ },
294
+ };
295
+ }
296
+ ```
297
+
298
+ Notes:
299
+
300
+ - `observe` can be sync or async (returning a Promise of the stop-handle).
301
+ - `fields` should contain changed/added fields payload expected by your transport.
302
+ - `publish()` relies on this contract to keep nested observers in sync.
303
+
235
304
  If your runtime has special callback context requirements, implement `bindEnvironment`.
236
305
 
237
306
  ```js
@@ -248,6 +317,11 @@ const customProtocol = {
248
317
 
249
318
  If your runtime has no such requirement, simply omit `bindEnvironment`.
250
319
 
320
+ If you want to use the [`publish()`](#publishpublication-args-options) composite publication helper:
321
+
322
+ - `observe` must be defined on the protocol.
323
+ - `stringify` can be defined if the default EJSON implementation is not sufficient.
324
+
251
325
  </details>
252
326
 
253
327
  ## Bypassing `coll-fns`
@@ -449,7 +523,7 @@ join(Workers, {
449
523
 
450
524
  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.
451
525
 
452
- 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:
526
+ When using function-based joins, **a `deps` property should be added** to the join definition to declare which parent fields are required for the join to work:
453
527
 
454
528
  ```js
455
529
  import { join } from "coll-fns";
@@ -472,7 +546,7 @@ join(Posts, {
472
546
  };
473
547
  },
474
548
  /* Parent fields needed in the join function */
475
- fields: {
549
+ deps: {
476
550
  _id: 1, // Optional. _id is implicit in any fetch.
477
551
  postedAt: 1,
478
552
  },
@@ -480,6 +554,8 @@ join(Posts, {
480
554
  });
481
555
  ```
482
556
 
557
+ `fields` remains accepted as a backward-compatible alias for `deps`.
558
+
483
559
  ### Recursive joins
484
560
 
485
561
  A collection can define joins on itself.
@@ -499,7 +575,7 @@ join(Users, {
499
575
 
500
576
  ### Join additional options
501
577
 
502
- 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:
578
+ Any additional properties defined on the join (other than `Coll`, `on`, `single`, `postFetch`, `deps` and legacy `fields`) will be treated as options to pass to the nested documents `fetchList`. It usually includes:
503
579
 
504
580
  - `limit`: Maximum joined documents count
505
581
  - `skip`: Documents to skip in the fetch
@@ -509,7 +585,7 @@ Any additional properties defined on the join (other than `Coll`, `on`, `single`
509
585
 
510
586
  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.
511
587
 
512
- 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.
588
+ The second argument of the function is the parent document. If some of its properties are needed, they should be declared in the join `deps` property so they are guaranteed to be fetched on the parent.
513
589
 
514
590
  ```js
515
591
  import { join } from "coll-fns";
@@ -521,8 +597,8 @@ join(Resources, {
521
597
  Coll: Tasks,
522
598
  on: ["_id", "resourceId"],
523
599
 
524
- /* Ensure `tasksOrder` will be fetched */
525
- fields: { tasksOrder: 1 },
600
+ /* Ensure `tasksOrder` will be fetched on parent docs */
601
+ deps: { tasksOrder: 1 },
526
602
 
527
603
  /* Transform the joined tasks documents based on parent resource. */
528
604
  postFetch(tasks, resource) {
@@ -1521,6 +1597,370 @@ hook(Users, {
1521
1597
 
1522
1598
  </details>
1523
1599
 
1600
+ # Nested reactive publications
1601
+
1602
+ If `coll-fns` is used in a Meteor project, using publications is a way to create fully reactive applications. `publish` function helps to publish complex hierarchical data.
1603
+
1604
+ ## When to use `publish`
1605
+
1606
+ Use `publish()` when the publication tree is dynamic and cannot be represented as a simple static list of cursors.
1607
+
1608
+ **Important caveat (Meteor):** if a publication can return plain cursor(s) directly from `Meteor.publish`, prefer that approach. Native cursor-return publications are simpler and usually more optimized by Meteor internals than any userland helper.
1609
+
1610
+ `publish()` is intended for cases where you need one or more of:
1611
+
1612
+ - nested reactive children depending on parent documents
1613
+ - selector recomputation based on changed parent fields
1614
+ - observer reuse and invalidation logic you do not want to hand-roll repeatedly
1615
+
1616
+ ## `publish(publication, Coll, selector, options)`
1617
+
1618
+ Create a reactive publication tree (Meteor-style) with support for:
1619
+
1620
+ - explicit child observers (`{ Coll, on, ... }`)
1621
+ - join shorthand children (`{ join: "joinKey", ... }`)
1622
+ - implicit join children derived from parent requested join fields
1623
+
1624
+ `publish` internally uses protocol methods:
1625
+
1626
+ - `observe` to track cursor changes
1627
+ - `getName` to emit DDP collection names
1628
+ - `stringify` to build stable query reuse keys
1629
+
1630
+ As with normal joins, child `on` values can be:
1631
+
1632
+ - static selector objects
1633
+ - functions `(parent, ...ancestors) => selector`
1634
+ - join-array selectors: `[from, to, toSelector?]`
1635
+
1636
+ Refer to the [`join()`](#joincoll-joindefinitions) section for more details.
1637
+
1638
+ ```js
1639
+ import { Meteor } from "meteor/meteor";
1640
+ import { join, publish, setJoinPrefix } from "coll-fns";
1641
+ import {
1642
+ Posts,
1643
+ Users,
1644
+ Comments,
1645
+ Tags,
1646
+ FeatureFlags,
1647
+ PostStats,
1648
+ } from "/imports/api/collections";
1649
+
1650
+ setJoinPrefix("+");
1651
+
1652
+ join(Posts, {
1653
+ author: { Coll: Users, on: ["authorId", "_id"], single: true },
1654
+ comments: { Coll: Comments, on: ["_id", "postId"], sort: { createdAt: -1 } },
1655
+ stats: { Coll: PostStats, on: ["_id", "postId"], single: true },
1656
+ });
1657
+
1658
+ join(Comments, {
1659
+ author: { Coll: Users, on: ["authorId", "_id"], single: true },
1660
+ });
1661
+
1662
+ Meteor.publish("posts.tree", function postsTree() {
1663
+ return publish(
1664
+ this,
1665
+ Posts,
1666
+ { status: "published" },
1667
+ {
1668
+ fields: {
1669
+ title: 1,
1670
+ authorId: 1,
1671
+ tagIds: 1,
1672
+ editorId: 1,
1673
+ "+": {
1674
+ stats: 1,
1675
+ comments: {
1676
+ body: 1,
1677
+ createdAt: 1,
1678
+ "+": {
1679
+ author: { displayName: 1 },
1680
+ },
1681
+ },
1682
+ },
1683
+ },
1684
+ children: [
1685
+ /* Predefined join on Posts collection */
1686
+ {
1687
+ join: "author",
1688
+ fields: { displayName: 1, avatarUrl: 1 },
1689
+ },
1690
+
1691
+ /* Array selector */
1692
+ {
1693
+ Coll: Tags,
1694
+ on: [["tagIds"], "_id"],
1695
+ fields: { label: 1, color: 1 },
1696
+ deps: undefined, // Array selector children implicitely derive deps
1697
+ },
1698
+
1699
+ /* Function selector */
1700
+ {
1701
+ Coll: Users,
1702
+ on: (post) => ({ _id: post.editorId }),
1703
+ fields: { displayName: 1 },
1704
+ deps: ["editorId"],
1705
+ },
1706
+
1707
+ /* Object selector. Always returns the same data irrespective of parent. */
1708
+ {
1709
+ Coll: FeatureFlags,
1710
+ on: { scope: "posts_publication" },
1711
+ fields: { key: 1, enabled: 1 },
1712
+ deps: false,
1713
+ },
1714
+ ],
1715
+ }
1716
+ );
1717
+ });
1718
+ ```
1719
+
1720
+ ## Publication readiness and concurrency
1721
+
1722
+ Readiness is controlled per child with `awaited` (default: `true`):
1723
+
1724
+ - `awaited: true`: wait for this child subtree before calling `ready()`
1725
+ - `awaited: false`: do not wait for this child subtree; it loads/reacts in background
1726
+
1727
+ Use `awaited: true` for data the screen must have immediately, and `awaited: false` for optional or heavy branches.
1728
+ If not specified, a child inherits `awaited` from its parent.
1729
+
1730
+ `options.maxConcurrent` controls how many child observer creations can run at
1731
+ the same time (`10` by default). This can only be defined at the publication root, not on children.
1732
+
1733
+ Why it matters:
1734
+
1735
+ - each parent `added`/`changed` can trigger many child observer creations
1736
+ - unbounded concurrency can create CPU spikes and DB pressure
1737
+ - too little concurrency can slow initial publication warm-up
1738
+
1739
+ What it controls exactly:
1740
+
1741
+ - only child observer creation tasks are throttled
1742
+ - observer reuse still applies (duplicate query keys are collapsed)
1743
+ - invalidation logic still runs; the option just smooths creation bursts
1744
+
1745
+ Practical guidance:
1746
+
1747
+ - decrease it if your publication causes heavy DB load or event-loop stalls
1748
+ - increase it if your DB/runtime handles parallelism well and warm-up is slow
1749
+ - keep in mind this is per `publish()` call, not a single global cap
1750
+
1751
+ Example:
1752
+
1753
+ ```js
1754
+ Meteor.publish("posts.tree", function postsTree() {
1755
+ return publish(
1756
+ this,
1757
+ Posts,
1758
+ { status: "published" },
1759
+ {
1760
+ maxConcurrent: 5,
1761
+ children: [
1762
+ { Coll: Users, on: ["authorId", "_id"], awaited: true },
1763
+ {
1764
+ Coll: FeatureFlags,
1765
+ on: { scope: "posts_publication" },
1766
+ awaited: false,
1767
+ },
1768
+ ],
1769
+ }
1770
+ );
1771
+ });
1772
+ ```
1773
+
1774
+ ## Publication context (`this` in Meteor)
1775
+
1776
+ `publish()` is designed first for Meteor publications. In Meteor usage, the first
1777
+ argument should be the publication session/context (`this`) received in
1778
+ `Meteor.publish(name, function () { ... })`.
1779
+
1780
+ Minimal expected shape of `publication`:
1781
+
1782
+ - `ready()` (required)
1783
+ - `added(collectionName, id, fields)` (optional but normally provided by Meteor)
1784
+ - `changed(collectionName, id, fields)` (optional but normally provided by Meteor)
1785
+ - `removed(collectionName, id)` (optional but normally provided by Meteor)
1786
+ - `onStop(fn)` (optional, used for cleanup registration)
1787
+ - `error(err)` (optional, used as error sink)
1788
+
1789
+ Example:
1790
+
1791
+ ```js
1792
+ Meteor.publish("posts.tree", function () {
1793
+ // `this` is the publication context/session.
1794
+ return publish(this, Posts, { status: "published" });
1795
+ });
1796
+ ```
1797
+
1798
+ If `ready` is missing, `publish()` throws.
1799
+
1800
+ ## How child declarations work
1801
+
1802
+ `children` entries can be objects or falsy values (`false`, `null`, `undefined`).
1803
+ Falsy entries are ignored, which allows short-circuit declarations like
1804
+ `isEnabled && { ...childArgs }`.
1805
+
1806
+ Object entries can be defined with either:
1807
+
1808
+ - explicit child args:
1809
+ - `{ Coll, on, fields?, deps?, awaited?, children?, ...cursorOptions }`
1810
+ - join shorthand:
1811
+ - `{ join: "joinKey", ...overrides }`
1812
+
1813
+ Additionally, join children can be derived implicitly from requested parent join fields.
1814
+
1815
+ Conflict rule:
1816
+
1817
+ - If the same join key is declared both as explicit child (`{ join: "..." }`) and in parent join fields (`fields["+"][joinKey]` or root join key without prefix), `publish()` throws and asks you to choose one style.
1818
+
1819
+ ## Ancestors chain
1820
+
1821
+ For function selectors and function deps, the helper passes:
1822
+
1823
+ - first argument: direct parent document
1824
+ - remaining arguments: full ancestors chain (grandparent, great-grandparent, ...)
1825
+
1826
+ This is useful when deep children must depend on context from higher levels.
1827
+
1828
+ ```js
1829
+ Meteor.publish("resources.tasks.actions", function () {
1830
+ return publish(
1831
+ this,
1832
+ Resources,
1833
+ { archived: false },
1834
+ {
1835
+ children: [
1836
+ {
1837
+ Coll: Tasks,
1838
+ on: (resource) => ({ resourceId: resource._id }),
1839
+ deps: ["_id"],
1840
+ children: [
1841
+ {
1842
+ Coll: Actions,
1843
+ on: (task, resource) => ({
1844
+ taskId: task._id,
1845
+ tenantId: resource.tenantId,
1846
+ }),
1847
+ deps(changedFields, task, resource) {
1848
+ // Re-run when task link changes or resource tenancy changes.
1849
+ if ("tenantId" in changedFields) return true;
1850
+ return ["_id", "tenantId"];
1851
+ },
1852
+ },
1853
+ ],
1854
+ },
1855
+ ],
1856
+ }
1857
+ );
1858
+ });
1859
+ ```
1860
+
1861
+ ## How `deps` works
1862
+
1863
+ `deps` controls when child observers are invalidated and recomputed after parent `changed` events.
1864
+
1865
+ Supported values:
1866
+
1867
+ - `true`: always invalidate
1868
+ - `false`: never invalidate
1869
+ - `"field"` or `["fieldA", "fieldB"]`: invalidate only when those keys are present in changed fields
1870
+ - `{ fieldA: 1, fieldB: true }`: object shorthand converted to watched keys (truthy top-level keys only)
1871
+ - function `(changedFields, parent, ...ancestors) => depsLike`: dynamic rule
1872
+ - `undefined`: special behavior
1873
+
1874
+ Special behavior when `deps` is `undefined`:
1875
+
1876
+ - static selector object child: treated as no invalidation (`[]`)
1877
+ - array/function selector child: treated as potentially dependent and will always invalidate conservatively
1878
+
1879
+ For array selectors, implicit deps are auto-derived from the `from` key.
1880
+
1881
+ `deps` matching is flat:
1882
+
1883
+ - matching is done against exact keys present in `changedFields`
1884
+ - no deep path traversal is performed by `publish()`
1885
+ - nested object deps only contribute top-level keys
1886
+
1887
+ ## Debugging
1888
+
1889
+ `publish()` supports lightweight lifecycle debugging with:
1890
+
1891
+ - `debug: true` to log all internal debug events
1892
+ - `debug: ["EVENT_NAME", ...]` to log selected events only
1893
+ - `debug: { EVENT_NAME: true, ... }` to log selected events only
1894
+
1895
+ Observer events:
1896
+
1897
+ - `CREATED`: a new observer was successfully created and registered.
1898
+ - `BYPASSED`: observer creation was skipped because selector resolved to a void selector.
1899
+ - `REUSED`: an existing observer for the same query key was reused.
1900
+ - `INVALIDATED`: child observer graph for a parent document was recomputed after relevant parent changes.
1901
+ - `UNFOLLOWED`: one follower link to a sub-observer was removed.
1902
+ - `CANCELLED`: an observer was cancelled and its local cleanup started.
1903
+
1904
+ Observer documents events:
1905
+
1906
+ - `DOC_ADDED`: a document was published through `added`.
1907
+ - `DOC_CHANGED`: a published document emitted a `changed` update.
1908
+ - `DOC_REMOVED`: a document was removed from publication (observer count for that document dropped to zero).
1909
+
1910
+ Publication events (available on root only):
1911
+
1912
+ - `READY`: emitted once when `publish()` calls `publication.ready()`.
1913
+ - `STOPPED`: emitted once when the publication stop handler runs.
1914
+
1915
+ Debug scope:
1916
+
1917
+ - Root `debug` applies to the root observer and root publication locations (`READY`, `STOPPED`).
1918
+ - Each child defines its own `debug` argument.
1919
+ - Implicit join-derived children (from parent `fields`) inherit parent `debug`.
1920
+
1921
+ ## Built-in optimizations
1922
+
1923
+ `publish()` is designed to stay controlled even with nested reactive trees.
1924
+
1925
+ In practical terms, it aims to protect you from:
1926
+
1927
+ - creating the same observer repeatedly for equivalent child queries
1928
+ - runaway bursts of child observer creation
1929
+ - stale async creations being attached after data already changed
1930
+ - leaked child observers when parent links disappear
1931
+ - duplicate add/remove churn for documents shared by multiple branches
1932
+
1933
+ Why this matters:
1934
+
1935
+ - lower risk of memory growth from forgotten/stale observers
1936
+ - fewer unnecessary observers and DB watches
1937
+ - more predictable behavior during frequent parent changes
1938
+ - safer use of nested publications in real apps, not only toy examples
1939
+
1940
+ `maxConcurrent` is part of this safety model: it prevents uncontrolled parallel
1941
+ creation bursts and lets you tune throughput vs load.
1942
+
1943
+ ## Using `publish` outside Meteor
1944
+
1945
+ The helper can be used outside Meteor only if both layers are provided:
1946
+
1947
+ - protocol layer (`setProtocol`) supporting at least:
1948
+ - `observe`
1949
+ - `getName` (optional, default implementation provided)
1950
+ - `stringify` (optional, default implementation provided)
1951
+ - publication transport/context object implementing the callbacks [listed above](#publication-context-this-in-meteor)
1952
+ (`added/changed/removed/ready/onStop/error`)
1953
+
1954
+ Protocol methods handle database reactivity.
1955
+ `publication` handles how data changes are emitted to clients.
1956
+
1957
+ ## Current limitations
1958
+
1959
+ - `publish()` is a helper, not a replacement for simple cursor-return publications.
1960
+ - Deep/dynamic trees can still be expensive if selectors are broad and highly volatile.
1961
+ - If `deps` are too broad (or omitted for dynamic selectors), invalidations may be frequent.
1962
+ - For best results, keep parent selectors selective and declare precise `deps`.
1963
+
1524
1964
  # License
1525
1965
 
1526
1966
  MIT