@kairos-es/core 0.0.0 → 1.0.0-alpha.1
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 +24 -3
- package/dist/cjs/AppendIsOwned.js +81 -0
- package/dist/cjs/AppendIsOwned.js.map +1 -0
- package/dist/cjs/ContractStore.js +12 -2
- package/dist/cjs/ContractStore.js.map +1 -1
- package/dist/cjs/Projection.js +19 -5
- package/dist/cjs/Projection.js.map +1 -1
- package/dist/cjs/Query.js +37 -7
- package/dist/cjs/Query.js.map +1 -1
- package/dist/cjs/index.js +12 -0
- package/dist/cjs/index.js.map +1 -1
- package/dist/dts/AppendIsOwned.d.ts +78 -0
- package/dist/dts/AppendIsOwned.d.ts.map +1 -0
- package/dist/dts/ContractStore.d.ts +4 -1
- package/dist/dts/ContractStore.d.ts.map +1 -1
- package/dist/dts/Projection.d.ts +16 -0
- package/dist/dts/Projection.d.ts.map +1 -1
- package/dist/dts/Query.d.ts +21 -0
- package/dist/dts/Query.d.ts.map +1 -1
- package/dist/dts/index.d.ts +9 -4
- package/dist/dts/index.d.ts.map +1 -1
- package/dist/esm/AppendIsOwned.js +73 -0
- package/dist/esm/AppendIsOwned.js.map +1 -0
- package/dist/esm/ContractStore.js +12 -2
- package/dist/esm/ContractStore.js.map +1 -1
- package/dist/esm/Projection.js +17 -4
- package/dist/esm/Projection.js.map +1 -1
- package/dist/esm/Query.js +34 -5
- package/dist/esm/Query.js.map +1 -1
- package/dist/esm/index.js +9 -4
- package/dist/esm/index.js.map +1 -1
- package/package.json +1 -1
- package/src/AppendIsOwned.ts +92 -0
- package/src/ContractStore.ts +65 -51
- package/src/Projection.ts +21 -9
- package/src/Query.ts +38 -8
- package/src/index.ts +9 -4
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `AppendIsOwned` — the marker saying *"an append is already owned here"*, and the
|
|
3
|
+
* check that refuses a second one.
|
|
4
|
+
*
|
|
5
|
+
* ## Why a write-side concept core does not need for its own job lives in core
|
|
6
|
+
*
|
|
7
|
+
* A bracket that derives one `AppendCondition` from every read its body performed
|
|
8
|
+
* (`@kairos-es/codec`'s `transact`) owns exactly one append: the one it issues at
|
|
9
|
+
* close, under that derived condition. Any OTHER append inside that body is a
|
|
10
|
+
* wiring mistake, and the dangerous case is not the one that looks dangerous. A
|
|
11
|
+
* command runner appending mid-body usually writes into the very slice the body
|
|
12
|
+
* read, so its events match the union the bracket is about to guard on — and the
|
|
13
|
+
* bracket's own closing append is then refused by a condition its own body
|
|
14
|
+
* invalidated. The failure arrives at the wrong line, describing a conflict that
|
|
15
|
+
* was self-inflicted.
|
|
16
|
+
*
|
|
17
|
+
* The refusal therefore has to catch a RAW `store.append` and not merely a nested
|
|
18
|
+
* bracket, which is what puts it here rather than in the codec. Enforcing it one
|
|
19
|
+
* layer up would catch a nested bracket and a command runner and miss a direct
|
|
20
|
+
* store call — and a syntactic lint rule cannot close that gap either, having no
|
|
21
|
+
* call graph, so an append one function away in a helper is invisible to both.
|
|
22
|
+
* `makeContractStore` is the one seam every engine's append already passes
|
|
23
|
+
* through, so the check is written once and inherited whole, exactly as
|
|
24
|
+
* `assertServableQuery` is.
|
|
25
|
+
*
|
|
26
|
+
* The cost is real and is accepted: core learns a concept it does not need for its
|
|
27
|
+
* own job. It is affordable because core already owns the write contract, and
|
|
28
|
+
* *"an append is already owned"* is a write-side fact about that contract rather
|
|
29
|
+
* than a fact about the codec.
|
|
30
|
+
*
|
|
31
|
+
* ## Present means refused, so a sanctioned append REMOVES the marker
|
|
32
|
+
*
|
|
33
|
+
* The check reads the tag through `Effect.serviceOption`, so its ABSENCE is the
|
|
34
|
+
* normal case and costs the `append` signature nothing — `DcbEventStore.append`
|
|
35
|
+
* keeps `R = never` and no caller provides anything. Presence is what refuses.
|
|
36
|
+
*
|
|
37
|
+
* That shape decides how the two legitimate appends inside a bracket get through,
|
|
38
|
+
* and there is only one route: run with the marker STRIPPED from the context
|
|
39
|
+
* (`Effect.mapInputContext` over `Context.omit(AppendIsOwned)`). The bracket's own
|
|
40
|
+
* closing append is issued outside the scope it provided the marker in, and the
|
|
41
|
+
* codec's escape hatch strips it explicitly. Neither needs a second tag, a
|
|
42
|
+
* sanctioning flag or a mutable cell, and there is no way to be exempt by
|
|
43
|
+
* accident: an append is either lexically outside the marked region or it says in
|
|
44
|
+
* as many words that it is removing the marker.
|
|
45
|
+
*/
|
|
46
|
+
import { Context, Effect } from 'effect';
|
|
47
|
+
/**
|
|
48
|
+
* Marks that an append is already owned by an enclosing bracket, and names what
|
|
49
|
+
* owns it.
|
|
50
|
+
*
|
|
51
|
+
* `by` is a human-readable owner name and exists for the defect message alone —
|
|
52
|
+
* the refusal is decided by the tag's PRESENCE, never by this value, so nothing
|
|
53
|
+
* branches on it and no caller need agree on a vocabulary for it.
|
|
54
|
+
*/
|
|
55
|
+
export class AppendIsOwned extends /*#__PURE__*/Context.Tag('@kairos-es/core/AppendIsOwned')() {}
|
|
56
|
+
/**
|
|
57
|
+
* Die if an append is already owned here; do nothing otherwise.
|
|
58
|
+
*
|
|
59
|
+
* A DEFECT and not an error-channel value, deliberately. There is no
|
|
60
|
+
* data-dependent recovery from it: the events this append would have written
|
|
61
|
+
* belong to a bracket that is going to write its own, so no caller could sensibly
|
|
62
|
+
* catch it and carry on. It is the same class of fault as a non-servable query,
|
|
63
|
+
* and it is refused in the same place and in the same way. The remedy is one
|
|
64
|
+
* identifier away in every case — the codec's `decideOnly` for a command runner,
|
|
65
|
+
* its escape hatch for a deliberate second append — which is what makes a defect
|
|
66
|
+
* affordable here.
|
|
67
|
+
*
|
|
68
|
+
* `Effect.serviceOption` is what keeps this free: it reads the tag if present and
|
|
69
|
+
* adds NOTHING to the requirement channel, so every existing append is unchanged
|
|
70
|
+
* and unprovisioned.
|
|
71
|
+
*/
|
|
72
|
+
export const assertAppendNotOwned = /*#__PURE__*/Effect.flatMap(/*#__PURE__*/Effect.serviceOption(AppendIsOwned), owner => owner._tag === 'None' ? Effect.void : Effect.die(new Error(`makeContractStore.append: an append is already owned here (by '${owner.value.by}'), ` + 'so this one is refused. A bracket derives one append condition from every ' + 'read its body performed and issues one append under it; a second append ' + 'inside that body writes events the bracket is about to guard against, and ' + "refuses the bracket's own close. Return the events instead of appending " + "them, or say the append is deliberate with the codec's escape hatch.")));
|
|
73
|
+
//# sourceMappingURL=AppendIsOwned.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"AppendIsOwned.js","names":["Context","Effect","AppendIsOwned","Tag","assertAppendNotOwned","flatMap","serviceOption","owner","_tag","void","die","Error","value","by"],"sources":["../../src/AppendIsOwned.ts"],"sourcesContent":[null],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA6CA,SAASA,OAAO,EAAEC,MAAM,QAAQ,QAAQ;AAExC;;;;;;;;AAQA,OAAM,MAAOC,aAAc,sBAAQF,OAAO,CAACG,GAAG,CAAC,+BAA+B,CAAC,EAG5E;AAEH;;;;;;;;;;;;;;;;AAgBA,OAAO,MAAMC,oBAAoB,gBAAwBH,MAAM,CAACI,OAAO,cACrEJ,MAAM,CAACK,aAAa,CAACJ,aAAa,CAAC,EAClCK,KAAK,IACJA,KAAK,CAACC,IAAI,KAAK,MAAM,GACjBP,MAAM,CAACQ,IAAI,GACXR,MAAM,CAACS,GAAG,CACR,IAAIC,KAAK,CACP,kEAAkEJ,KAAK,CAACK,KAAK,CAACC,EAAE,MAAM,GACpF,4EAA4E,GAC5E,0EAA0E,GAC1E,4EAA4E,GAC5E,0EAA0E,GAC1E,sEAAsE,CACzE,CACF,CACR","ignoreList":[]}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Effect, Predicate, Schema, Stream } from 'effect';
|
|
2
|
+
import { assertAppendNotOwned } from "./AppendIsOwned.js";
|
|
2
3
|
import { ReadLimit } from "./Event.js";
|
|
3
4
|
import { assertServableQuery } from "./Query.js";
|
|
4
5
|
/** Decode an unknown limit through `ReadLimit`, dying on a defect-worthy value. */
|
|
@@ -17,7 +18,16 @@ export const makeContractStore = raw => ({
|
|
|
17
18
|
// Infrastructure faults are defects, not contract values.
|
|
18
19
|
return Effect.orDie(raw.read(query, options));
|
|
19
20
|
}),
|
|
20
|
-
append: (events, condition) =>
|
|
21
|
+
append: (events, condition) =>
|
|
22
|
+
// The ownership refusal runs FIRST and sits OUTSIDE the suspend, because it is
|
|
23
|
+
// the only one of append's four checks that is EFFECTFUL: it consults a
|
|
24
|
+
// `Context.Tag` rather than inspecting the arguments. `Effect.flatMap` over an
|
|
25
|
+
// already-lazy effect defers it exactly as the suspend defers the throws below,
|
|
26
|
+
// so nothing runs at the call site either way; what it buys is that the
|
|
27
|
+
// "you are inside a bracket" defect arrives before any argument is judged, which
|
|
28
|
+
// is the order a reader expects when both are wrong at once. `AppendIsOwned.ts`
|
|
29
|
+
// owns why the concept is in core at all.
|
|
30
|
+
Effect.flatMap(assertAppendNotOwned, () => Effect.suspend(() => {
|
|
21
31
|
// An EMPTY batch is a plain-data caller bypassing the
|
|
22
32
|
// `NonEmptyReadonlyArray` type: there is nothing to append. The engines
|
|
23
33
|
// diverge on it at an unchecked boundary — Postgres inserts zero rows and
|
|
@@ -59,7 +69,7 @@ export const makeContractStore = raw => ({
|
|
|
59
69
|
// narrows the raw `AppendConditionFailed | E` to the contract's single
|
|
60
70
|
// error, which is what keeps `append` at `E = AppendConditionFailed`.
|
|
61
71
|
return Effect.catchAll(raw.append(events, condition), error => Predicate.isTagged('AppendConditionFailed')(error) ? Effect.fail(error) : Effect.die(error));
|
|
62
|
-
}),
|
|
72
|
+
})),
|
|
63
73
|
subscribe: (query, after) =>
|
|
64
74
|
// `Effect.sync` runs when the stream is executed; a throw from
|
|
65
75
|
// `assertServableQuery` becomes a stream defect (identical rule to
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ContractStore.js","names":["Effect","Predicate","Schema","Stream","ReadLimit","assertServableQuery","decodeReadLimit","decodeUnknownSync","makeContractStore","raw","read","query","options","suspend","limit","undefined","orDie","append","events","condition","length","Error","failIfEventsMatch","catchAll","error","isTagged","fail","die","subscribe","after","unwrap","sync"],"sources":["../../src/ContractStore.ts"],"sourcesContent":[null],"mappings":"
|
|
1
|
+
{"version":3,"file":"ContractStore.js","names":["Effect","Predicate","Schema","Stream","assertAppendNotOwned","ReadLimit","assertServableQuery","decodeReadLimit","decodeUnknownSync","makeContractStore","raw","read","query","options","suspend","limit","undefined","orDie","append","events","condition","flatMap","length","Error","failIfEventsMatch","catchAll","error","isTagged","fail","die","subscribe","after","unwrap","sync"],"sources":["../../src/ContractStore.ts"],"sourcesContent":[null],"mappings":"AAmCA,SAASA,MAAM,EAAEC,SAAS,EAAEC,MAAM,EAAEC,MAAM,QAAQ,QAAQ;AAE1D,SAASC,oBAAoB,QAAQ,oBAAiB;AAGtD,SAASC,SAAS,QAAQ,YAAS;AAEnC,SAASC,mBAAmB,QAAoB,YAAS;AAEzD;AACA,MAAMC,eAAe,gBAAGL,MAAM,CAACM,iBAAiB,CAACH,SAAS,CAAC;AAwB3D;;;;AAIA,OAAO,MAAMI,iBAAiB,GAC5BC,GAAwB,KACG;EAC3BC,IAAI,EAAEA,CAACC,KAAK,EAAEC,OAAO,KACnBb,MAAM,CAACc,OAAO,CAAC,MAAK;IAClB;IACAR,mBAAmB,CAACM,KAAK,CAAC;IAC1B,IAAIC,OAAO,EAAEE,KAAK,KAAKC,SAAS,EAAE;MAChCT,eAAe,CAACM,OAAO,CAACE,KAAK,CAAC;IAChC;IACA;IACA,OAAOf,MAAM,CAACiB,KAAK,CAACP,GAAG,CAACC,IAAI,CAACC,KAAK,EAAEC,OAAO,CAAC,CAAC;EAC/C,CAAC,CAAC;EAEJK,MAAM,EAAEA,CAACC,MAAM,EAAEC,SAAS;EACxB;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACApB,MAAM,CAACqB,OAAO,CAACjB,oBAAoB,EAAE,MACnCJ,MAAM,CAACc,OAAO,CAAC,MAAK;IAClB;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA,IAAIK,MAAM,CAACG,MAAM,KAAK,CAAC,EAAE;MACvB,MAAM,IAAIC,KAAK,CACb,uEAAuE,GACrE,oEAAoE,GACpE,kEAAkE,CACrE;IACH;IACA;IACA,IAAIH,SAAS,KAAKJ,SAAS,EAAE;MAC3BV,mBAAmB,CAACc,SAAS,CAACI,iBAAiB,CAAC;IAClD;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA,OAAOxB,MAAM,CAACyB,QAAQ,CAACf,GAAG,CAACQ,MAAM,CAACC,MAAM,EAAEC,SAAS,CAAC,EAAGM,KAAK,IAC1DzB,SAAS,CAAC0B,QAAQ,CAAC,uBAAuB,CAAC,CAACD,KAAK,CAAC,GAC9C1B,MAAM,CAAC4B,IAAI,CAACF,KAAK,CAAC,GAClB1B,MAAM,CAAC6B,GAAG,CAACH,KAAK,CAAC,CACtB;EACH,CAAC,CAAC,CACH;EAEHI,SAAS,EAAEA,CAAClB,KAAK,EAAEmB,KAAK;EACtB;EACA;EACA;EACA;EACA5B,MAAM,CAAC6B,MAAM,CACXhC,MAAM,CAACiC,IAAI,CAAC,MAAK;IACf3B,mBAAmB,CAACM,KAAK,CAAC;IAC1B,OAAOT,MAAM,CAACc,KAAK,CAACP,GAAG,CAACoB,SAAS,CAAClB,KAAK,EAAEmB,KAAK,CAAC,CAAC;EAClD,CAAC,CAAC;CAEP,CAAC","ignoreList":[]}
|
package/dist/esm/Projection.js
CHANGED
|
@@ -27,6 +27,22 @@ const mergeQuery = (left, right) => {
|
|
|
27
27
|
items: [...left.items, ...right.items]
|
|
28
28
|
};
|
|
29
29
|
};
|
|
30
|
+
/**
|
|
31
|
+
* OR-merge a list of queries into one. The empty list is the wildcard.
|
|
32
|
+
*
|
|
33
|
+
* The same merge `composeProjections` folds its record through, reachable
|
|
34
|
+
* WITHOUT a record — which is what a caller accumulating queries rather than
|
|
35
|
+
* projections needs, and the codec's read ledger is one. Without it that caller
|
|
36
|
+
* either hand-rolls the merge, or fabricates a projection per query to reach this
|
|
37
|
+
* one; both were tried and the first is a TRAP. An empty `items` is the WILDCARD
|
|
38
|
+
* and is absorbing under OR, while spreading an empty list contributes nothing, so
|
|
39
|
+
* a hand-rolled union silently narrows a match-everything query to its siblings —
|
|
40
|
+
* in the unsafe direction, and with nothing to notice.
|
|
41
|
+
*
|
|
42
|
+
* Seeding the fold with the wildcard would swallow everything for the same reason,
|
|
43
|
+
* so the list is reduced with no seed and the empty case answers directly.
|
|
44
|
+
*/
|
|
45
|
+
export const unionQueries = queries => queries.length === 0 ? WILDCARD : queries.reduce((acc, q) => mergeQuery(acc, q));
|
|
30
46
|
/**
|
|
31
47
|
* Compose named projections into one whose state is a record of sub-states and
|
|
32
48
|
* whose query is the OR-merge of all sub-queries. Each sub-projection only
|
|
@@ -41,10 +57,7 @@ const mergeQuery = (left, right) => {
|
|
|
41
57
|
export const composeProjections = projections => {
|
|
42
58
|
const keys = Object.keys(projections);
|
|
43
59
|
const initialState = Object.fromEntries(keys.map(key => [key, projections[key].initialState]));
|
|
44
|
-
|
|
45
|
-
// treats an empty-items query as a wildcard), so fold the sub-queries
|
|
46
|
-
// directly with no seed.
|
|
47
|
-
const mergedQuery = keys.length === 0 ? WILDCARD : keys.map(key => projections[key].query).reduce((acc, q) => mergeQuery(acc, q));
|
|
60
|
+
const mergedQuery = unionQueries(keys.map(key => projections[key].query));
|
|
48
61
|
return {
|
|
49
62
|
initialState,
|
|
50
63
|
query: mergedQuery,
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"Projection.js","names":["matchesSequenced","WILDCARD","items","AnyProjection","of","projection","mergeQuery","left","right","length","composeProjections","projections","keys","Object","initialState","fromEntries","map","key","mergedQuery","query","
|
|
1
|
+
{"version":3,"file":"Projection.js","names":["matchesSequenced","WILDCARD","items","AnyProjection","of","projection","mergeQuery","left","right","length","unionQueries","queries","reduce","acc","q","composeProjections","projections","keys","Object","initialState","fromEntries","map","key","mergedQuery","query","evolve","state","decoded","next","sequenced"],"sources":["../../src/Projection.ts"],"sourcesContent":[null],"mappings":"AA4BA,SAASA,gBAAgB,QAAQ,YAAS;AAE1C;AACA;AACA;AACA;AACA;AACA;AACA,MAAMC,QAAQ,GAAU;EAAEC,KAAK,EAAE;AAAE,CAAE;AAqDrC,OAAO,MAAMC,aAAa,GAAG;EAC3B;;;;;;;EAOAC,EAAE,EAAMC,UAAyB,IAC/BA;CACM;AAaV;AACA,MAAMC,UAAU,GAAGA,CAACC,IAAW,EAAEC,KAAY,KAAW;EACtD,IAAID,IAAI,CAACL,KAAK,CAACO,MAAM,KAAK,CAAC,IAAID,KAAK,CAACN,KAAK,CAACO,MAAM,KAAK,CAAC,EAAE;IACvD,OAAOR,QAAQ;EACjB;EACA,OAAO;IAAEC,KAAK,EAAE,CAAC,GAAGK,IAAI,CAACL,KAAK,EAAE,GAAGM,KAAK,CAACN,KAAK;EAAC,CAAE;AACnD,CAAC;AAED;;;;;;;;;;;;;;;AAeA,OAAO,MAAMQ,YAAY,GAAIC,OAA6B,IACxDA,OAAO,CAACF,MAAM,KAAK,CAAC,GAChBR,QAAQ,GACRU,OAAO,CAACC,MAAM,CAAC,CAACC,GAAG,EAAEC,CAAC,KAAKR,UAAU,CAACO,GAAG,EAAEC,CAAC,CAAC,CAAC;AAEpD;;;;;;;;;;;AAWA,OAAO,MAAMC,kBAAkB,GAC7BC,WAAc,IACmB;EACjC,MAAMC,IAAI,GAAGC,MAAM,CAACD,IAAI,CAACD,WAAW,CAAoC;EAExE,MAAMG,YAAY,GAAGD,MAAM,CAACE,WAAW,CACrCH,IAAI,CAACI,GAAG,CAAEC,GAAG,IAAK,CAACA,GAAG,EAAEN,WAAW,CAACM,GAAG,CAAC,CAACH,YAAY,CAAC,CAAC,CACnC;EAEtB,MAAMI,WAAW,GAAGb,YAAY,CAACO,IAAI,CAACI,GAAG,CAAEC,GAAG,IAAKN,WAAW,CAACM,GAAG,CAAC,CAACE,KAAK,CAAC,CAAC;EAE3E,OAAO;IACLL,YAAY;IACZK,KAAK,EAAED,WAAW;IAClBE,MAAM,EAAEA,CAACC,KAAK,EAAEC,OAAO,KAAI;MACzB,MAAMC,IAAI,GAAG;QAAE,GAAGF;MAAK,CAAuC;MAC9D,KAAK,MAAMJ,GAAG,IAAIL,IAAI,EAAE;QACtB,MAAMZ,UAAU,GAAGW,WAAW,CAACM,GAAG,CAAC;QACnC,IAAI,CAACtB,gBAAgB,CAAC2B,OAAO,CAACE,SAAS,EAAExB,UAAU,CAACmB,KAAK,CAAC,EAAE;UAC1D;QACF;QACAI,IAAI,CAACN,GAAG,CAAC,GAAGjB,UAAU,CAACoB,MAAM,CAACC,KAAK,CAACJ,GAAG,CAAC,EAAEK,OAAO,CAEhD;MACH;MACA,OAAOC,IAAI;IACb;GACD;AACH,CAAC","ignoreList":[]}
|
package/dist/esm/Query.js
CHANGED
|
@@ -88,19 +88,48 @@ export const matchesSequenced = (sequenced, query) => matchesQuery(sequenced.eve
|
|
|
88
88
|
* engine" behaviour.
|
|
89
89
|
*/
|
|
90
90
|
export const assertServableQuery = query => {
|
|
91
|
+
const fault = servableQueryFault(query);
|
|
92
|
+
if (fault !== undefined) {
|
|
93
|
+
throw new Error(fault);
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
/**
|
|
97
|
+
* The servable grammar as a TOTAL function: the reason `query` cannot be served,
|
|
98
|
+
* or `undefined` when it can.
|
|
99
|
+
*
|
|
100
|
+
* The same rule as `assertServableQuery` above and NOT a second statement of it —
|
|
101
|
+
* that function is this one plus a throw, so the grammar is defined once and the
|
|
102
|
+
* two differ only in DISPOSITION. A store boundary wants the defect, because a
|
|
103
|
+
* non-servable query there is a programming error with no recovery; a caller that
|
|
104
|
+
* can still act on the answer wants a value, and the codec's read ledger is one:
|
|
105
|
+
* it asks whether a hop it is about to record would make the accumulated union
|
|
106
|
+
* unservable, and refuses on the error channel BEFORE the read is paid for.
|
|
107
|
+
*
|
|
108
|
+
* The alternative was for that caller to re-derive the rule, and it was tried:
|
|
109
|
+
* the codec's own copy tested each recorded query separately, which silently
|
|
110
|
+
* admitted a FIRST hop composed of two tagless slices — a multi-item query, and
|
|
111
|
+
* unservable, dying at the store one layer away. A grammar with two homes drifts
|
|
112
|
+
* at exactly the shapes neither author had in mind. The shape here is the read
|
|
113
|
+
* side's own (`projectionWiringFault` returns `string | undefined` for the same
|
|
114
|
+
* reason), so a fault is one exit route and one return type.
|
|
115
|
+
*/
|
|
116
|
+
export const servableQueryFault = query => {
|
|
91
117
|
const {
|
|
92
118
|
items
|
|
93
119
|
} = query;
|
|
94
120
|
// Wildcard and single-item queries are always servable.
|
|
95
121
|
if (items.length <= 1) {
|
|
96
|
-
return;
|
|
122
|
+
return undefined;
|
|
97
123
|
}
|
|
98
124
|
// A multi-item query is servable only if every item is tagged; a tagless
|
|
99
125
|
// (type-only) item mixed with tagged items cannot ride a single index.
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
126
|
+
// The `assertServableQuery:` prefix is part of the MESSAGE and not something the
|
|
127
|
+
// throw adds, so both dispositions emit byte-identical text. It names the rule in
|
|
128
|
+
// the vocabulary a store rejects in, which is what lets the read side's wiring gate
|
|
129
|
+
// report a violation in the same words a store would — an author who meets it in
|
|
130
|
+
// both places meets it once. Building it here is also what stops a second caller
|
|
131
|
+
// re-deriving the prefix to match.
|
|
132
|
+
return items.some(item => item.tags.length === 0) ? 'assertServableQuery: a multi-item query must have every item carry at ' + 'least one tag; a type-only item mixed with tagged items is not servable' : undefined;
|
|
104
133
|
};
|
|
105
134
|
/**
|
|
106
135
|
* Classify a servable `Query` into the shape that serves it.
|
package/dist/esm/Query.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"Query.js","names":["Array","Arr","Schema","EventType","Tag","QueryItem","Struct","types","tags","Query","items","matchesQueryItem","event","item","typeOk","length","includes","type","eventTags","Set","every","tag","has","matchesQuery","query","some","matchesSequenced","sequenced","assertServableQuery","
|
|
1
|
+
{"version":3,"file":"Query.js","names":["Array","Arr","Schema","EventType","Tag","QueryItem","Struct","types","tags","Query","items","matchesQueryItem","event","item","typeOk","length","includes","type","eventTags","Set","every","tag","has","matchesQuery","query","some","matchesSequenced","sequenced","assertServableQuery","fault","servableQueryFault","undefined","Error","classifyServableQuery","kind","only","isNonEmptyReadonlyArray","fields","all","or"],"sources":["../../src/Query.ts"],"sourcesContent":[null],"mappings":"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","ignoreList":[]}
|
package/dist/esm/index.js
CHANGED
|
@@ -4,11 +4,15 @@
|
|
|
4
4
|
*
|
|
5
5
|
* Exposes the domain types (`DcbEvent`, `SequencedEvent`, `Position`, `Tag`,
|
|
6
6
|
* `EventType`), the selection language (`Query`, `QueryItem`, the pure match
|
|
7
|
-
* predicate + smart constructors,
|
|
8
|
-
*
|
|
9
|
-
* `
|
|
7
|
+
* predicate + smart constructors, the servable grammar in both its dispositions —
|
|
8
|
+
* `assertServableQuery`'s throw and `servableQueryFault`'s total `string |
|
|
9
|
+
* undefined` — and the servable-shape dispatch every engine compiles its plans
|
|
10
|
+
* through), the `AppendCondition`, tagged errors, the
|
|
11
|
+
* `DcbEventStore` contract, `AppendIsOwned` and the refusal every engine's `append`
|
|
12
|
+
* inherits from it, the in-memory layer, the shared subscribe machine
|
|
10
13
|
* every engine's `subscribe` is built from (`SubscribeMachine.ts`), the pure
|
|
11
|
-
* projection primitives (`Projection`, `DecodedEvent`, `composeProjections`
|
|
14
|
+
* projection primitives (`Projection`, `DecodedEvent`, `composeProjections` and the
|
|
15
|
+
* `unionQueries` its query merge is reachable through without a record), and
|
|
12
16
|
* `retryOnConflict`.
|
|
13
17
|
*
|
|
14
18
|
* Core keeps only the *pure* fold: projections fold an already-`DecodedEvent`
|
|
@@ -23,6 +27,7 @@
|
|
|
23
27
|
* write-only consumer depend on a package it uses nothing else of (ADR-0008).
|
|
24
28
|
*/
|
|
25
29
|
export * from "./AppendCondition.js";
|
|
30
|
+
export * from "./AppendIsOwned.js";
|
|
26
31
|
export * from "./ContractStore.js";
|
|
27
32
|
export * from "./DcbEventStore.js";
|
|
28
33
|
export * from "./Event.js";
|
package/dist/esm/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","names":["DcbEventStoreInMemory"],"sources":["../../src/index.ts"],"sourcesContent":[null],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"index.js","names":["DcbEventStoreInMemory"],"sources":["../../src/index.ts"],"sourcesContent":[null],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA4BA,cAAc,sBAAmB;AACjC,cAAc,oBAAiB;AAC/B,cAAc,oBAAiB;AAC/B,cAAc,oBAAiB;AAC/B,cAAc,YAAS;AACvB,cAAc,aAAU;AACxB;AACA;AACA;AACA,SAASA,qBAAqB,QAAQ,eAAY;AAClD,cAAc,iBAAc;AAC5B,cAAc,YAAS;AACvB,cAAc,sBAAmB;AACjC,cAAc,uBAAoB","ignoreList":[]}
|
package/package.json
CHANGED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `AppendIsOwned` — the marker saying *"an append is already owned here"*, and the
|
|
3
|
+
* check that refuses a second one.
|
|
4
|
+
*
|
|
5
|
+
* ## Why a write-side concept core does not need for its own job lives in core
|
|
6
|
+
*
|
|
7
|
+
* A bracket that derives one `AppendCondition` from every read its body performed
|
|
8
|
+
* (`@kairos-es/codec`'s `transact`) owns exactly one append: the one it issues at
|
|
9
|
+
* close, under that derived condition. Any OTHER append inside that body is a
|
|
10
|
+
* wiring mistake, and the dangerous case is not the one that looks dangerous. A
|
|
11
|
+
* command runner appending mid-body usually writes into the very slice the body
|
|
12
|
+
* read, so its events match the union the bracket is about to guard on — and the
|
|
13
|
+
* bracket's own closing append is then refused by a condition its own body
|
|
14
|
+
* invalidated. The failure arrives at the wrong line, describing a conflict that
|
|
15
|
+
* was self-inflicted.
|
|
16
|
+
*
|
|
17
|
+
* The refusal therefore has to catch a RAW `store.append` and not merely a nested
|
|
18
|
+
* bracket, which is what puts it here rather than in the codec. Enforcing it one
|
|
19
|
+
* layer up would catch a nested bracket and a command runner and miss a direct
|
|
20
|
+
* store call — and a syntactic lint rule cannot close that gap either, having no
|
|
21
|
+
* call graph, so an append one function away in a helper is invisible to both.
|
|
22
|
+
* `makeContractStore` is the one seam every engine's append already passes
|
|
23
|
+
* through, so the check is written once and inherited whole, exactly as
|
|
24
|
+
* `assertServableQuery` is.
|
|
25
|
+
*
|
|
26
|
+
* The cost is real and is accepted: core learns a concept it does not need for its
|
|
27
|
+
* own job. It is affordable because core already owns the write contract, and
|
|
28
|
+
* *"an append is already owned"* is a write-side fact about that contract rather
|
|
29
|
+
* than a fact about the codec.
|
|
30
|
+
*
|
|
31
|
+
* ## Present means refused, so a sanctioned append REMOVES the marker
|
|
32
|
+
*
|
|
33
|
+
* The check reads the tag through `Effect.serviceOption`, so its ABSENCE is the
|
|
34
|
+
* normal case and costs the `append` signature nothing — `DcbEventStore.append`
|
|
35
|
+
* keeps `R = never` and no caller provides anything. Presence is what refuses.
|
|
36
|
+
*
|
|
37
|
+
* That shape decides how the two legitimate appends inside a bracket get through,
|
|
38
|
+
* and there is only one route: run with the marker STRIPPED from the context
|
|
39
|
+
* (`Effect.mapInputContext` over `Context.omit(AppendIsOwned)`). The bracket's own
|
|
40
|
+
* closing append is issued outside the scope it provided the marker in, and the
|
|
41
|
+
* codec's escape hatch strips it explicitly. Neither needs a second tag, a
|
|
42
|
+
* sanctioning flag or a mutable cell, and there is no way to be exempt by
|
|
43
|
+
* accident: an append is either lexically outside the marked region or it says in
|
|
44
|
+
* as many words that it is removing the marker.
|
|
45
|
+
*/
|
|
46
|
+
import { Context, Effect } from 'effect'
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Marks that an append is already owned by an enclosing bracket, and names what
|
|
50
|
+
* owns it.
|
|
51
|
+
*
|
|
52
|
+
* `by` is a human-readable owner name and exists for the defect message alone —
|
|
53
|
+
* the refusal is decided by the tag's PRESENCE, never by this value, so nothing
|
|
54
|
+
* branches on it and no caller need agree on a vocabulary for it.
|
|
55
|
+
*/
|
|
56
|
+
export class AppendIsOwned extends Context.Tag('@kairos-es/core/AppendIsOwned')<
|
|
57
|
+
AppendIsOwned,
|
|
58
|
+
{ readonly by: string }
|
|
59
|
+
>() {}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Die if an append is already owned here; do nothing otherwise.
|
|
63
|
+
*
|
|
64
|
+
* A DEFECT and not an error-channel value, deliberately. There is no
|
|
65
|
+
* data-dependent recovery from it: the events this append would have written
|
|
66
|
+
* belong to a bracket that is going to write its own, so no caller could sensibly
|
|
67
|
+
* catch it and carry on. It is the same class of fault as a non-servable query,
|
|
68
|
+
* and it is refused in the same place and in the same way. The remedy is one
|
|
69
|
+
* identifier away in every case — the codec's `decideOnly` for a command runner,
|
|
70
|
+
* its escape hatch for a deliberate second append — which is what makes a defect
|
|
71
|
+
* affordable here.
|
|
72
|
+
*
|
|
73
|
+
* `Effect.serviceOption` is what keeps this free: it reads the tag if present and
|
|
74
|
+
* adds NOTHING to the requirement channel, so every existing append is unchanged
|
|
75
|
+
* and unprovisioned.
|
|
76
|
+
*/
|
|
77
|
+
export const assertAppendNotOwned: Effect.Effect<void> = Effect.flatMap(
|
|
78
|
+
Effect.serviceOption(AppendIsOwned),
|
|
79
|
+
(owner) =>
|
|
80
|
+
owner._tag === 'None'
|
|
81
|
+
? Effect.void
|
|
82
|
+
: Effect.die(
|
|
83
|
+
new Error(
|
|
84
|
+
`makeContractStore.append: an append is already owned here (by '${owner.value.by}'), ` +
|
|
85
|
+
'so this one is refused. A bracket derives one append condition from every ' +
|
|
86
|
+
'read its body performed and issues one append under it; a second append ' +
|
|
87
|
+
'inside that body writes events the bracket is about to guard against, and ' +
|
|
88
|
+
"refuses the bracket's own close. Return the events instead of appending " +
|
|
89
|
+
"them, or say the append is deliberate with the codec's escape hatch.",
|
|
90
|
+
),
|
|
91
|
+
),
|
|
92
|
+
)
|
package/src/ContractStore.ts
CHANGED
|
@@ -19,7 +19,10 @@
|
|
|
19
19
|
* `E = never` on the contract, so a raw fault is a defect, not a channel value;
|
|
20
20
|
* - the `AppendConditionFailed`-else-die classification on append, matched by
|
|
21
21
|
* `_tag` and never by class identity — ONLY the contract conflict rides the
|
|
22
|
-
* error channel; every other fault is a defect
|
|
22
|
+
* error channel; every other fault is a defect;
|
|
23
|
+
* - `assertAppendNotOwned` on append — an append inside a bracket that already
|
|
24
|
+
* owns one is a DEFECT. Read `AppendIsOwned.ts` for the rule and for why a
|
|
25
|
+
* write-side concept core does not need for its own job is enforced here.
|
|
23
26
|
*
|
|
24
27
|
* The throws are deferred into the returned Effect/Stream (via `Effect.suspend`
|
|
25
28
|
* / `Effect.sync`), so a validation failure surfaces as a DEFECT WHEN THE EFFECT
|
|
@@ -32,6 +35,7 @@
|
|
|
32
35
|
import type { Array as Arr, Scope } from 'effect'
|
|
33
36
|
import { Effect, Predicate, Schema, Stream } from 'effect'
|
|
34
37
|
import type { AppendCondition } from './AppendCondition'
|
|
38
|
+
import { assertAppendNotOwned } from './AppendIsOwned'
|
|
35
39
|
import type { DcbEventStore, ReadOptions, ReadResponse } from './DcbEventStore'
|
|
36
40
|
import type { DcbEvent, Position, SequencedEvent } from './Event'
|
|
37
41
|
import { ReadLimit } from './Event'
|
|
@@ -82,57 +86,67 @@ export const makeContractStore = <E>(
|
|
|
82
86
|
}),
|
|
83
87
|
|
|
84
88
|
append: (events, condition) =>
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
89
|
+
// The ownership refusal runs FIRST and sits OUTSIDE the suspend, because it is
|
|
90
|
+
// the only one of append's four checks that is EFFECTFUL: it consults a
|
|
91
|
+
// `Context.Tag` rather than inspecting the arguments. `Effect.flatMap` over an
|
|
92
|
+
// already-lazy effect defers it exactly as the suspend defers the throws below,
|
|
93
|
+
// so nothing runs at the call site either way; what it buys is that the
|
|
94
|
+
// "you are inside a bracket" defect arrives before any argument is judged, which
|
|
95
|
+
// is the order a reader expects when both are wrong at once. `AppendIsOwned.ts`
|
|
96
|
+
// owns why the concept is in core at all.
|
|
97
|
+
Effect.flatMap(assertAppendNotOwned, () =>
|
|
98
|
+
Effect.suspend(() => {
|
|
99
|
+
// An EMPTY batch is a plain-data caller bypassing the
|
|
100
|
+
// `NonEmptyReadonlyArray` type: there is nothing to append. The engines
|
|
101
|
+
// diverge on it at an unchecked boundary — Postgres inserts zero rows and
|
|
102
|
+
// its conditional function returns zero rows, indistinguishable from a
|
|
103
|
+
// conflict, so it surfaces a PHANTOM `AppendConditionFailed`, whereas
|
|
104
|
+
// in-memory silently returns the previous head (`start - 1n`). Make it a
|
|
105
|
+
// DEFECT here, at the one choke point, so the policy is definitionally
|
|
106
|
+
// identical across engines and zero-rows stays an unambiguous conflict
|
|
107
|
+
// signal (a throw inside the suspend becomes a defect, like
|
|
108
|
+
// `assertServableQuery`).
|
|
109
|
+
if (events.length === 0) {
|
|
110
|
+
throw new Error(
|
|
111
|
+
'makeContractStore.append: the events batch is empty; append requires ' +
|
|
112
|
+
'at least one event (a NonEmptyReadonlyArray). An empty batch is a ' +
|
|
113
|
+
'plain-data caller bypassing the type — a defect, not a conflict.',
|
|
114
|
+
)
|
|
115
|
+
}
|
|
116
|
+
// A non-servable guard query is a defect in every engine.
|
|
117
|
+
if (condition !== undefined) {
|
|
118
|
+
assertServableQuery(condition.failIfEventsMatch)
|
|
119
|
+
}
|
|
120
|
+
// Only `AppendConditionFailed` belongs on the contract error channel;
|
|
121
|
+
// every other fault (a `SqlError`, a `55P03` lock-timeout abort, …) is
|
|
122
|
+
// infrastructure and surfaces OFF the shared contract as a defect.
|
|
123
|
+
//
|
|
124
|
+
// Classified by `_tag`, NEVER by `instanceof`, and this is the site where
|
|
125
|
+
// that matters most: it is the ONE gate every backend's conflicts pass
|
|
126
|
+
// through, so a class-identity check here is a single point of silent
|
|
127
|
+
// failure for the whole write side. `@kairos-es/core` ships dual ESM/CJS,
|
|
128
|
+
// so a consumer's graph can hold two distinct `AppendConditionFailed`
|
|
129
|
+
// classes for one error — an engine wired against one copy of core and
|
|
130
|
+
// wrapped by a `makeContractStore` from another. Under `instanceof` that
|
|
131
|
+
// engine's GENUINE conflicts fail the class check and are `Effect.die`d
|
|
132
|
+
// as infrastructure, which does not merely mislabel them: it takes the
|
|
133
|
+
// conflict OFF the error channel, where `retryOnConflict` (which already
|
|
134
|
+
// matches on the tag, and correctly) can never see it to retry. The tag
|
|
135
|
+
// is the error's identity across realms; `retryOnConflict.ts` makes the
|
|
136
|
+
// argument at length and this is its other end.
|
|
137
|
+
//
|
|
138
|
+
// `Predicate.isTagged` is total over `unknown` — an object check, then
|
|
139
|
+
// `_tag` equality — so a `null`, a string or a raw `SqlError` on the
|
|
140
|
+
// engine's channel simply answers false and dies as it should. It also
|
|
141
|
+
// narrows the raw `AppendConditionFailed | E` to the contract's single
|
|
142
|
+
// error, which is what keeps `append` at `E = AppendConditionFailed`.
|
|
143
|
+
return Effect.catchAll(raw.append(events, condition), (error) =>
|
|
144
|
+
Predicate.isTagged('AppendConditionFailed')(error)
|
|
145
|
+
? Effect.fail(error)
|
|
146
|
+
: Effect.die(error),
|
|
101
147
|
)
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
if (condition !== undefined) {
|
|
105
|
-
assertServableQuery(condition.failIfEventsMatch)
|
|
106
|
-
}
|
|
107
|
-
// Only `AppendConditionFailed` belongs on the contract error channel;
|
|
108
|
-
// every other fault (a `SqlError`, a `55P03` lock-timeout abort, …) is
|
|
109
|
-
// infrastructure and surfaces OFF the shared contract as a defect.
|
|
110
|
-
//
|
|
111
|
-
// Classified by `_tag`, NEVER by `instanceof`, and this is the site where
|
|
112
|
-
// that matters most: it is the ONE gate every backend's conflicts pass
|
|
113
|
-
// through, so a class-identity check here is a single point of silent
|
|
114
|
-
// failure for the whole write side. `@kairos-es/core` ships dual ESM/CJS,
|
|
115
|
-
// so a consumer's graph can hold two distinct `AppendConditionFailed`
|
|
116
|
-
// classes for one error — an engine wired against one copy of core and
|
|
117
|
-
// wrapped by a `makeContractStore` from another. Under `instanceof` that
|
|
118
|
-
// engine's GENUINE conflicts fail the class check and are `Effect.die`d
|
|
119
|
-
// as infrastructure, which does not merely mislabel them: it takes the
|
|
120
|
-
// conflict OFF the error channel, where `retryOnConflict` (which already
|
|
121
|
-
// matches on the tag, and correctly) can never see it to retry. The tag
|
|
122
|
-
// is the error's identity across realms; `retryOnConflict.ts` makes the
|
|
123
|
-
// argument at length and this is its other end.
|
|
124
|
-
//
|
|
125
|
-
// `Predicate.isTagged` is total over `unknown` — an object check, then
|
|
126
|
-
// `_tag` equality — so a `null`, a string or a raw `SqlError` on the
|
|
127
|
-
// engine's channel simply answers false and dies as it should. It also
|
|
128
|
-
// narrows the raw `AppendConditionFailed | E` to the contract's single
|
|
129
|
-
// error, which is what keeps `append` at `E = AppendConditionFailed`.
|
|
130
|
-
return Effect.catchAll(raw.append(events, condition), (error) =>
|
|
131
|
-
Predicate.isTagged('AppendConditionFailed')(error)
|
|
132
|
-
? Effect.fail(error)
|
|
133
|
-
: Effect.die(error),
|
|
134
|
-
)
|
|
135
|
-
}),
|
|
148
|
+
}),
|
|
149
|
+
),
|
|
136
150
|
|
|
137
151
|
subscribe: (query, after) =>
|
|
138
152
|
// `Effect.sync` runs when the stream is executed; a throw from
|
package/src/Projection.ts
CHANGED
|
@@ -118,6 +118,26 @@ const mergeQuery = (left: Query, right: Query): Query => {
|
|
|
118
118
|
return { items: [...left.items, ...right.items] }
|
|
119
119
|
}
|
|
120
120
|
|
|
121
|
+
/**
|
|
122
|
+
* OR-merge a list of queries into one. The empty list is the wildcard.
|
|
123
|
+
*
|
|
124
|
+
* The same merge `composeProjections` folds its record through, reachable
|
|
125
|
+
* WITHOUT a record — which is what a caller accumulating queries rather than
|
|
126
|
+
* projections needs, and the codec's read ledger is one. Without it that caller
|
|
127
|
+
* either hand-rolls the merge, or fabricates a projection per query to reach this
|
|
128
|
+
* one; both were tried and the first is a TRAP. An empty `items` is the WILDCARD
|
|
129
|
+
* and is absorbing under OR, while spreading an empty list contributes nothing, so
|
|
130
|
+
* a hand-rolled union silently narrows a match-everything query to its siblings —
|
|
131
|
+
* in the unsafe direction, and with nothing to notice.
|
|
132
|
+
*
|
|
133
|
+
* Seeding the fold with the wildcard would swallow everything for the same reason,
|
|
134
|
+
* so the list is reduced with no seed and the empty case answers directly.
|
|
135
|
+
*/
|
|
136
|
+
export const unionQueries = (queries: ReadonlyArray<Query>): Query =>
|
|
137
|
+
queries.length === 0
|
|
138
|
+
? WILDCARD
|
|
139
|
+
: queries.reduce((acc, q) => mergeQuery(acc, q))
|
|
140
|
+
|
|
121
141
|
/**
|
|
122
142
|
* Compose named projections into one whose state is a record of sub-states and
|
|
123
143
|
* whose query is the OR-merge of all sub-queries. Each sub-projection only
|
|
@@ -138,15 +158,7 @@ export const composeProjections = <T extends Record<string, AnyProjection>>(
|
|
|
138
158
|
keys.map((key) => [key, projections[key].initialState]),
|
|
139
159
|
) as CompositeState<T>
|
|
140
160
|
|
|
141
|
-
|
|
142
|
-
// treats an empty-items query as a wildcard), so fold the sub-queries
|
|
143
|
-
// directly with no seed.
|
|
144
|
-
const mergedQuery =
|
|
145
|
-
keys.length === 0
|
|
146
|
-
? WILDCARD
|
|
147
|
-
: keys
|
|
148
|
-
.map((key) => projections[key].query)
|
|
149
|
-
.reduce((acc, q) => mergeQuery(acc, q))
|
|
161
|
+
const mergedQuery = unionQueries(keys.map((key) => projections[key].query))
|
|
150
162
|
|
|
151
163
|
return {
|
|
152
164
|
initialState,
|