@omegup/msync 0.1.54 → 0.1.56

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 ADDED
@@ -0,0 +1,388 @@
1
+ # @omegup/msync
2
+
3
+ [![npm](https://img.shields.io/npm/v/@omegup/msync)](https://www.npmjs.com/package/@omegup/msync)
4
+
5
+ `msync` keeps derived MongoDB data in sync with the documents it comes from.
6
+
7
+ You describe the derivation the way you would write an aggregation: project the fields you read, join, reshape, group, and write the result back. `msync` runs that description once over the existing data, then again on every change. Joins, sums, and counts update from the delta. A removed source undoes the write that depended on it. A changed source rewrites only the documents that depend on it.
8
+
9
+ ## Install
10
+
11
+ ```bash
12
+ npm install @omegup/msync
13
+ ```
14
+
15
+ Peer runtime: MongoDB with change streams (a replica set), and TypeScript. `msync` enables change-stream pre- and post-images on every collection it watches.
16
+
17
+ ```ts
18
+ import { Machine, from, staging } from '@omegup/msync'
19
+ ```
20
+
21
+ ## The principle
22
+
23
+ A stream is a named, typed function from source documents to stored documents.
24
+
25
+ 1. **Sources are ordinary collections.** Every document `msync` reads or writes is a `Model`: it has `_id`, `touchedAt`, and `deletedAt`. `touchedAt` is the cluster time of the last write. `deletedAt: null` means the document is alive; a timestamp means it was removed. Deletes are soft, so a stream can see that a document went away and undo what it had written.
26
+
27
+ 2. **You declare the slice you read.** A view is a collection plus a projection. The projection is the only data the rest of the pipeline is allowed to see, and the types follow it.
28
+
29
+ 3. **Stages are valid as a full pass and as a delta.** `$lookup`, `$set`, `$replaceWith`, `$match`, and `$unwind` each know how to run over a snapshot and how to run over a before/after pair. You write them once.
30
+
31
+ 4. **The last stage writes.** `$simpleMerge` and `$merge` patch fields onto an existing document. `$group` and `$groupId` fold many documents into one and keep the fold incremental. `$insert` creates documents in another collection and soft-deletes them when the source disappears.
32
+
33
+ 5. **The name is the identity of the job.** Progress lives in a `__last` document keyed by that name. If the pipeline you registered under the name changes, `msync` tears the previous output down (unsets the fields it wrote, or soft-deletes the documents it inserted) and rebuilds.
34
+
35
+ 6. **Streams compose by reading each other's fields.** A field whose name starts with `_` is treated as derived. A later stream that projects `_accountName` waits until that field exists, so you can add every stream to one machine and they still line up.
36
+
37
+ ```
38
+ source collection
39
+ │ change stream (pre-image + post-image)
40
+ ▼
41
+ view ── projection, optional match
42
+ │
43
+ stages ── lookup, set, replace, match, unwind
44
+ │
45
+ sink ── merge | group | insert
46
+ │
47
+ ▼
48
+ same collection, or another one
49
+ ```
50
+
51
+ Two ways in:
52
+
53
+ | | `from` | `staging` |
54
+ |---|---|---|
55
+ | Use it when | The current document is enough, and you patch it in place | You join, group, insert, or need the previous value |
56
+ | What it stores | The resume token in `__last` | A `{collection}_{name}_snapshot` of before/after, plus `__last` |
57
+ | Sink | `$simpleMerge`, `$simpleInsert` | `$merge`, `$group`, `$groupId`, `$insert` |
58
+ | Can be the right side of `$lookup` | | Yes, after `.get()` |
59
+
60
+ `single` builds the same snapshot pipeline without the watch loop, for a one-shot read.
61
+
62
+ ## A document
63
+
64
+ ```ts
65
+ import type { Model } from '@omegup/msync'
66
+
67
+ // Model is { _id, touchedAt, deletedAt? } intersected with your fields.
68
+ type Account = {
69
+ _id: string
70
+ name: string
71
+ createdAt: Date
72
+ _cohort?: string | null // written by a stream, e.g. "2026-03"
73
+ } & Model
74
+ ```
75
+
76
+ Your application sets `touchedAt` on every write (including its own updates) and sets `deletedAt` when it removes a document. `msync` does the same on the documents it creates. Bump `touchedAt` with the cluster time; streams resume from it.
77
+
78
+ Derived fields use a leading `_`. That is how a downstream stream knows the field is produced by an upstream one and should be waited for. Pass `needs: { plainField: 1 }` to wait on a field that does not use the prefix, or `needs: { _alreadyThere: 0 }` to read a `_` field without waiting.
79
+
80
+ ## The shape of every stream
81
+
82
+ ```ts
83
+ staging(view, 'unique-stream-name')
84
+ .with($lookup({ /* join, needs the left-hand stream */ }))
85
+ .then($replaceWith(/* reshape the current document */))
86
+ .then($match(/* keep or drop */))
87
+ .get() // freeze the pipeline; also what $lookup expects as `from`
88
+ .out($merge(/* ... */)(/* write */))
89
+ ```
90
+
91
+ - `.with(fn)` wraps the stream so far. `$lookup` and `$outerLookup` are written this way, because a join has to see the left side.
92
+ - `.then(stage)` appends a stage that only sees the current document: `$set`, `$replaceWith`, `$match`, `$unwind`.
93
+ - `.get()` closes the pipeline.
94
+ - `.out(sink)` attaches the writer and returns a runner.
95
+
96
+ `from` has the same chain, and its stages are linear (`$set`, `$replaceWith`, `$match`). It has no snapshot, so it has no `$lookup`.
97
+
98
+ A projection entry is a pair, `[fieldName, 1]`, under the same name:
99
+
100
+ ```ts
101
+ projection: {
102
+ createdAt: ['createdAt', 1],
103
+ regionId: ['regionId', 1],
104
+ }
105
+ ```
106
+
107
+ A field expression is a pair too, `[fieldName, expr]`, inside `field({ ... })` and inside merge and group maps.
108
+
109
+ ```ts
110
+ root<Account>().of('createdAt').expr() // this document's field
111
+ val(' ') // a literal
112
+ concat(first, val(' '), last) // an expression
113
+ ```
114
+
115
+ `root<T>()` is the current document, typed as `T`. `.of('key')` steps into a field. `.expr()` turns the path into a value you can pass to `concat`, `$sum`, `$merge`, and the rest.
116
+
117
+ ## Tutorial
118
+
119
+ One small domain runs through every example: accounts place orders, and plans are sold per region. Each function takes the collections it touches and returns a runner. The runners are what you hand to a `Machine`.
120
+
121
+ ### 1. Derive a field from the document itself
122
+
123
+ An account has `createdAt`. List pages want the cohort (`YYYY-MM`) without parsing dates on every read. Keep `_cohort` on the account, and refresh it when `createdAt` changes.
124
+
125
+ `from` is enough: there is no join and no previous value to subtract.
126
+
127
+ ```ts
128
+ import type { Collection, Model, N, O } from '@omegup/msync'
129
+ import { $simpleMerge, from, monthPart, root } from '@omegup/msync'
130
+
131
+ type Account = O<{
132
+ _id: string
133
+ name: string
134
+ createdAt: Date
135
+ _cohort: string | N
136
+ }>
137
+
138
+ export const makeAccountCohortStream = (accounts: Collection<Account & Model>) =>
139
+ from(
140
+ {
141
+ collection: accounts,
142
+ projection: { createdAt: ['createdAt', 1] },
143
+ },
144
+ 'account-cohort',
145
+ )
146
+ .get()
147
+ .out(
148
+ $simpleMerge<O<{ _cohort: string }>>()(accounts, {
149
+ _cohort: ['_cohort', monthPart(root<Account>().of('createdAt').expr())],
150
+ }),
151
+ )
152
+ ```
153
+
154
+ `$simpleMerge` writes `_cohort` onto the same `_id`. The document is already there, so a missing target fails the merge. The next change of `createdAt` rewrites that account only.
155
+
156
+ ```
157
+ { _id: "a1", createdAt: 2026-03-02 } → { _cohort: "2026-03" }
158
+ ```
159
+
160
+ ### 2. Copy a field across a reference
161
+
162
+ An order stores `accountId`. The account stores `name`. Reading an order should not look the account up again, and renaming an account should fix every order that points at it.
163
+
164
+ This needs `staging`. `$lookup` watches both sides: the order's `accountId`, and the account's `name`.
165
+
166
+ ```ts
167
+ import type { Collection, Model, O, OPick, StrKey } from '@omegup/msync'
168
+ import { $lookup, $merge, root, staging } from '@omegup/msync'
169
+
170
+ type Account = O<{ _id: string; name: string }>
171
+ type Order = O<{ _id: string; accountId: string; total: number; _accountName?: string }>
172
+
173
+ type OrderView = OPick<Order & Model, '_id' | 'accountId'>
174
+ type AccountView = OPick<Account & Model, '_id' | 'name'>
175
+ type Joined = OrderView & { readonly account: AccountView }
176
+
177
+ export const makeOrderAccountNameStream = (
178
+ orders: Collection<Order & Model>,
179
+ accounts: Collection<Account & Model>,
180
+ ) =>
181
+ staging<Order & Model, StrKey<OrderView>>(
182
+ {
183
+ collection: orders,
184
+ projection: { accountId: ['accountId', 1] },
185
+ },
186
+ 'order-account-name',
187
+ )
188
+ .with(
189
+ $lookup({
190
+ as: 'account',
191
+ to: 'one',
192
+ from: staging(
193
+ {
194
+ collection: accounts,
195
+ projection: { name: ['name', 1] },
196
+ },
197
+ 'accounts-for-orders',
198
+ ).get(),
199
+ localField: root<OrderView>().of('accountId'),
200
+ }),
201
+ )
202
+ .get()
203
+ .out(
204
+ $merge<O<{ _accountName: string }>>()(orders, {
205
+ _accountName: ['_accountName', root<Joined>().of('account').of('name').expr()],
206
+ }),
207
+ )
208
+ ```
209
+
210
+ The inner `staging(...).get()` is a stream of its own, with its own snapshot. `$merge` patches `_accountName` back onto the order. A later stream that projects `_accountName` waits until this one has written it.
211
+
212
+ `$lookup` keeps documents that match. `$outerLookup` keeps the left document when the right side is missing, and types the joined field as `T | null`.
213
+
214
+ A lookup says where it points with `to`. `to: 'one'` means `localField` points at the right document's `_id`, so there is no `foreignField`. Each left row matches one right row, and the result keeps the left `_id`. `to: 'many'` is several matches per left row, or a join on some other field: pass `middle` and both fields. The lookup writes each row's `_id` as the left id, that string, and the right id. You do not build that id again afterwards.
215
+
216
+ ### 3. Keep a total on the parent
217
+
218
+ Each order has an `accountId` and a `total`. The account should store `_lifetimeSpend`, the sum of its orders, including orders that change or disappear.
219
+
220
+ `$groupId` folds into a document that already exists. The group key is that document's `_id`, here the account id. `$sum` is delta-aware: a new order adds its total, an edited order subtracts the old total and adds the new one, a deleted order subtracts. The account is never recomputed from scratch.
221
+
222
+ ```ts
223
+ import type { Collection, Model, O } from '@omegup/msync'
224
+ import { $groupId, $sum, notNull, root, staging } from '@omegup/msync'
225
+
226
+ type Account = O<{ _id: string; _lifetimeSpend?: number }>
227
+ type Order = O<{ _id: string; accountId: string; total: number }>
228
+
229
+ export const makeAccountSpendStream = (
230
+ orders: Collection<Order & Model>,
231
+ accounts: Collection<Account & Model>,
232
+ ) =>
233
+ staging<Order & Model, '_id' | 'accountId' | 'total'>(
234
+ {
235
+ collection: orders,
236
+ projection: {
237
+ accountId: ['accountId', 1],
238
+ total: ['total', 1],
239
+ },
240
+ match: notNull(root<Order>().of('accountId').expr()),
241
+ },
242
+ 'account-lifetime-spend',
243
+ )
244
+ .get()
245
+ .out(
246
+ $groupId<Order, O<{ _lifetimeSpend: number }>, {}, Account & Model>(
247
+ root<Order>().of('accountId').expr(),
248
+ {
249
+ _lifetimeSpend: ['_lifetimeSpend', $sum(root<Order>().of('total').expr())],
250
+ },
251
+ accounts,
252
+ {},
253
+ ),
254
+ )
255
+ ```
256
+
257
+ Read `$groupId` as four arguments:
258
+
259
+ 1. The group key, a string expression. It must equal `_id` on the target.
260
+ 2. Accumulators. Each value is `['field', accumulator]`. These fields are maintained incrementally on the target.
261
+ 3. The target collection.
262
+ 4. Extra fields, recomputed from the accumulator output on every update. `{}` when the accumulator value is already what you want to store.
263
+
264
+ `$group` is the same fold when the target document does not exist yet. It stores the key in `_grp` and inserts. Use it for a summary collection, such as revenue per month. Use `$groupId` to hang a summary on a document you already have, such as spend on an account.
265
+
266
+ When the thing you are counting is a set of strings rather than a number, use `$countDict`. It stores a map of string to count, drops keys whose count falls to zero, and pairs with `$keys` in the extra-fields argument to publish the surviving strings as an array. A product that references tag ids, joined to the tag's name and folded with `$countDict`, keeps `_tags` on the product this way. The [guide](docs/guide.md) shows that accumulator next to `$sum`.
267
+
268
+ ### 4. Materialize a collection of pairs
269
+
270
+ Plans are sold per region. Every account in a region is entitled to every plan in that region. Each pair is its own document in `entitlements`, with `_id` of `accountId-planId`, so other streams can aggregate entitlements directly. When the plan's price changes, the entitlement updates. When the account or the plan goes away, the entitlement is soft-deleted.
271
+
272
+ Several plans can share a region, so this lookup is `to: 'many'`. `middle: '-'` is required, and the lookup sets each row's `_id` to the account id, that hyphen, and the plan id. Copy `accountId` off `_id` before the join, because the composite id replaces it. `$replaceWith` keeps the id the lookup already assigned. `$insert` upserts by that `_id`, and on a deleted input it sets `deletedAt`.
273
+
274
+ ```ts
275
+ import type { Collection, ID, Model, O, OPick } from '@omegup/msync'
276
+ import { $insert, $lookup, $replaceWith, $set, field, root, staging, to } from '@omegup/msync'
277
+
278
+ type Account = O<{ _id: string; regionId: string }>
279
+ type Plan = O<{ _id: string; regionId: string; price: number }>
280
+ type Entitlement = O<{
281
+ _id: string
282
+ accountId: string
283
+ planId: string
284
+ price: number
285
+ }>
286
+
287
+ export const makeEntitlementStream = (
288
+ accounts: Collection<Account & Model>,
289
+ plans: Collection<Plan & Model>,
290
+ entitlements: Collection<Entitlement & Model>,
291
+ ) => {
292
+ type AccountView = OPick<Account & Model, keyof ID | 'regionId'>
293
+ type PlanView = OPick<Plan & Model, keyof ID | 'regionId' | 'price'>
294
+ type WithAccountId = AccountView & O<{ accountId: string }>
295
+ type Joined = WithAccountId & { readonly plan: PlanView }
296
+
297
+ return staging<Account & Model, keyof AccountView>(
298
+ {
299
+ collection: accounts,
300
+ projection: { regionId: ['regionId', 1] },
301
+ },
302
+ 'entitlements-from-accounts',
303
+ )
304
+ .then(
305
+ $set<O<{ accountId: string }>>()({
306
+ accountId: ['accountId', to(root<AccountView>().of('_id').expr())],
307
+ }),
308
+ )
309
+ .with(
310
+ $lookup({
311
+ as: 'plan',
312
+ to: 'many',
313
+ middle: '-',
314
+ from: staging(
315
+ {
316
+ collection: plans,
317
+ projection: {
318
+ regionId: ['regionId', 1],
319
+ price: ['price', 1],
320
+ },
321
+ },
322
+ 'plans-for-entitlements',
323
+ ).get(),
324
+ localField: root<WithAccountId>().of('regionId'),
325
+ foreignField: root<PlanView>().of('regionId'),
326
+ }),
327
+ )
328
+ .then(
329
+ $replaceWith(
330
+ field<Entitlement, Joined>({
331
+ _id: ['_id', root<Joined>().of('_id').expr()],
332
+ accountId: ['accountId', root<Joined>().of('accountId').expr()],
333
+ planId: ['planId', root<Joined>().of('plan').of('_id').expr()],
334
+ price: ['price', root<Joined>().of('plan').of('price').expr()],
335
+ }),
336
+ ),
337
+ )
338
+ .get()
339
+ .out($insert(entitlements))
340
+ }
341
+ ```
342
+
343
+ The same account and the same plan always produce the same `_id`, because `middle` is part of the lookup. That is how the next run finds the row to update or retire.
344
+
345
+ ## Run them together
346
+
347
+ A `Machine` merges runners and loops. Each tick reports which stream did the work.
348
+
349
+ ```ts
350
+ import { Machine, prepare } from '@omegup/msync'
351
+
352
+ const client = await prepare() // connects with MONGO_URL, or use your own MongoClient
353
+ const db = client.db(process.env.MONGO_NAME)
354
+
355
+ const accounts = db.collection('accounts')
356
+ const orders = db.collection('orders')
357
+ const plans = db.collection('plans')
358
+ const entitlements = db.collection('entitlements')
359
+
360
+ const machine = new Machine()
361
+ machine.add(makeAccountCohortStream(accounts))
362
+ machine.add(makeOrderAccountNameStream(orders, accounts))
363
+ machine.add(makeAccountSpendStream(orders, accounts))
364
+ machine.add(makeEntitlementStream(accounts, plans, entitlements))
365
+
366
+ await machine.start(info => {
367
+ console.log(new Date(), info.debug)
368
+ })
369
+ ```
370
+
371
+ `.start` resolves only when it stops. Return `true` from the callback to stop after the current tick. Add streams in an order you can read — producers of `_` fields before the streams that project them — then let the machine run them side by side. The `_` prefix (and `needs`) is what makes a consumer skip a document until the producer has filled it.
372
+
373
+ Stream names are unique inside the process. Reusing a name from two different call sites throws. Pick a name that says what the stream maintains: `'account-cohort'`, `'order-account-name'`.
374
+
375
+ Split a large sync by area. Each area builds a `Machine` and returns it. The process adds every area and starts once:
376
+
377
+ ```ts
378
+ const machine = new Machine()
379
+ machine.add(billing(client).runner())
380
+ machine.add(catalog(client).runner())
381
+ await machine.start(info => {
382
+ console.log(new Date(), info.debug)
383
+ })
384
+ ```
385
+
386
+ ## Next
387
+
388
+ [docs/guide.md](docs/guide.md) is the rest of the language: expressions, predicates, accumulators, the write modes side by side, and what the process stores while it runs.
package/index.d.ts CHANGED
@@ -544,7 +544,7 @@ type Params<As extends string, LQ extends O, RQ extends O, RE extends RQ, S exte
544
544
  as: AsLiteral<As>;
545
545
  } & ({
546
546
  to: 'one';
547
- localField: Field<LQ, string>;
547
+ localField: Field<LQ, string | N>;
548
548
  foreignField?: never;
549
549
  middle?: never;
550
550
  } | {
@@ -771,7 +771,10 @@ declare const sub: <T, D, Ctx, P extends O>(a: Expr<T, D, Ctx>, f: Field<P, D, C
771
771
  declare const eqTyped: <T1 extends Dom, T2 extends Dom, F extends HKT<Dom>, C = unknown, Dom = unknown>(a: Expr<T1 | T2, App<F, T1 | T2>, C>, b: Expr<T1, App<F, T1 | T2>, C>) => BoolExpr<App<F, T1>, App<F, T2>, C>;
772
772
  declare const ne: <T, D, C>(a: Expr<T, D, C>) => <K>(b: Expr<K, D, C>) => Expr<boolean, D, C>;
773
773
  declare const notNull: <T, D, C>(a: Expr<T, D, C>) => Expr<boolean, D, C>;
774
- declare const $ifNull: <R, D, C>(...expr: [...Expr<R | null | undefined, D, C>[], Expr<R, D, C>]) => Expr<R, D, C>;
774
+ declare const $ifNull: {
775
+ <R, D, C = unknown>(...expr: [...Expr<R | null | undefined, D, C>[], Expr<R, D, C>]): Expr<R, D, C>;
776
+ <R, D, C = unknown>(...expr: [Expr<R, D, C>, ...Expr<R | null | undefined, D, C>[]]): Expr<R, D, C>;
777
+ };
775
778
  declare const exprMapVal: <K extends string, T extends Partial<Record<K, rawItem>>, D, C>(expr: Expr<K, D, C>, map: { readonly [P in K]: Expr<T[P], D, C>; }, or?: Expr<T[K], D, C>) => Expr<T[K & keyof T], D, C>;
776
779
  declare const mapVal: <K extends string, T extends Partial<Record<K, rawItem>>, D, C>(expr: Expr<K, D, C>, map: T, or: T[K & keyof T]) => Expr<T[K & keyof T], D, C>;
777
780
  declare const setField: <K extends string, T, V, D, C>({ field, input, value, }: {
package/index.esm.js CHANGED
@@ -942,7 +942,9 @@ const getWhenMatched = (whenNotMatched) => {
942
942
  return link().with($replaceWith_(ite(same, root().of('old').expr(), root().of('merged').expr()))).stages;
943
943
  };
944
944
  const $mergeX = (out, keys, f, map, ext, update) => {
945
- const patch = mapExact(keys, v => sub(v, f));
945
+ const patch = mapExact(keys, (v) => {
946
+ return $ifNull(sub(v, f), nil);
947
+ });
946
948
  const filter = mapExactToObject(ext, v => ({ $eq: v }));
947
949
  const setDeleted = out.whenNotMatched === 'discard';
948
950
  const replacer = map(field(omitPick().backward(spread(patch, {
package/index.js CHANGED
@@ -944,7 +944,9 @@ const getWhenMatched = (whenNotMatched) => {
944
944
  return link().with($replaceWith_(ite(same, root().of('old').expr(), root().of('merged').expr()))).stages;
945
945
  };
946
946
  const $mergeX = (out, keys, f, map, ext, update) => {
947
- const patch = mapExact(keys, v => sub(v, f));
947
+ const patch = mapExact(keys, (v) => {
948
+ return $ifNull(sub(v, f), nil);
949
+ });
948
950
  const filter = mapExactToObject(ext, v => ({ $eq: v }));
949
951
  const setDeleted = out.whenNotMatched === 'discard';
950
952
  const replacer = map(field(omitPick().backward(spread(patch, {
package/package.json CHANGED
@@ -1,9 +1,18 @@
1
1
  {
2
+ "name": "@omegup/msync",
3
+ "version": "0.1.56",
4
+ "description": "Keeps derived MongoDB data in sync with the documents it comes from.",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/Omegup/msync.git"
8
+ },
9
+ "homepage": "https://github.com/Omegup/msync#readme",
10
+ "bugs": {
11
+ "url": "https://github.com/Omegup/msync/issues"
12
+ },
2
13
  "main": "index.js",
3
14
  "module": "index.esm.js",
4
15
  "typings": "index.d.ts",
5
- "name": "@omegup/msync",
6
- "version": "0.1.54",
7
16
  "dependencies": {
8
17
  "dayjs": "^1.11.9",
9
18
  "dotenv": "^16.3.1",