@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.
@@ -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) => Effect.suspend(() => {
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":"AAgCA,SAASA,MAAM,EAAEC,SAAS,EAAEC,MAAM,EAAEC,MAAM,QAAQ,QAAQ;AAI1D,SAASC,SAAS,QAAQ,YAAS;AAEnC,SAASC,mBAAmB,QAAoB,YAAS;AAEzD;AACA,MAAMC,eAAe,gBAAGJ,MAAM,CAACK,iBAAiB,CAACH,SAAS,CAAC;AAwB3D;;;;AAIA,OAAO,MAAMI,iBAAiB,GAC5BC,GAAwB,KACG;EAC3BC,IAAI,EAAEA,CAACC,KAAK,EAAEC,OAAO,KACnBZ,MAAM,CAACa,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,OAAOd,MAAM,CAACgB,KAAK,CAACP,GAAG,CAACC,IAAI,CAACC,KAAK,EAAEC,OAAO,CAAC,CAAC;EAC/C,CAAC,CAAC;EAEJK,MAAM,EAAEA,CAACC,MAAM,EAAEC,SAAS,KACxBnB,MAAM,CAACa,OAAO,CAAC,MAAK;IAClB;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA,IAAIK,MAAM,CAACE,MAAM,KAAK,CAAC,EAAE;MACvB,MAAM,IAAIC,KAAK,CACb,uEAAuE,GACrE,oEAAoE,GACpE,kEAAkE,CACrE;IACH;IACA;IACA,IAAIF,SAAS,KAAKJ,SAAS,EAAE;MAC3BV,mBAAmB,CAACc,SAAS,CAACG,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,OAAOtB,MAAM,CAACuB,QAAQ,CAACd,GAAG,CAACQ,MAAM,CAACC,MAAM,EAAEC,SAAS,CAAC,EAAGK,KAAK,IAC1DvB,SAAS,CAACwB,QAAQ,CAAC,uBAAuB,CAAC,CAACD,KAAK,CAAC,GAC9CxB,MAAM,CAAC0B,IAAI,CAACF,KAAK,CAAC,GAClBxB,MAAM,CAAC2B,GAAG,CAACH,KAAK,CAAC,CACtB;EACH,CAAC,CAAC;EAEJI,SAAS,EAAEA,CAACjB,KAAK,EAAEkB,KAAK;EACtB;EACA;EACA;EACA;EACA1B,MAAM,CAAC2B,MAAM,CACX9B,MAAM,CAAC+B,IAAI,CAAC,MAAK;IACf1B,mBAAmB,CAACM,KAAK,CAAC;IAC1B,OAAOR,MAAM,CAACa,KAAK,CAACP,GAAG,CAACmB,SAAS,CAACjB,KAAK,EAAEkB,KAAK,CAAC,CAAC;EAClD,CAAC,CAAC;CAEP,CAAC","ignoreList":[]}
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":[]}
@@ -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
- // Seeding the reduce with the wildcard would swallow everything (mergeQuery
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","reduce","acc","q","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;;;;;;;;;;;AAWA,OAAO,MAAMQ,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;EACA;EACA;EACA,MAAMI,WAAW,GACfN,IAAI,CAACH,MAAM,KAAK,CAAC,GACbR,QAAQ,GACRW,IAAI,CACDI,GAAG,CAAEC,GAAG,IAAKN,WAAW,CAACM,GAAG,CAAC,CAACE,KAAK,CAAC,CACpCC,MAAM,CAAC,CAACC,GAAG,EAAEC,CAAC,KAAKhB,UAAU,CAACe,GAAG,EAAEC,CAAC,CAAC,CAAC;EAE/C,OAAO;IACLR,YAAY;IACZK,KAAK,EAAED,WAAW;IAClBK,MAAM,EAAEA,CAACC,KAAK,EAAEC,OAAO,KAAI;MACzB,MAAMC,IAAI,GAAG;QAAE,GAAGF;MAAK,CAAuC;MAC9D,KAAK,MAAMP,GAAG,IAAIL,IAAI,EAAE;QACtB,MAAMP,UAAU,GAAGM,WAAW,CAACM,GAAG,CAAC;QACnC,IAAI,CAACjB,gBAAgB,CAACyB,OAAO,CAACE,SAAS,EAAEtB,UAAU,CAACc,KAAK,CAAC,EAAE;UAC1D;QACF;QACAO,IAAI,CAACT,GAAG,CAAC,GAAGZ,UAAU,CAACkB,MAAM,CAACC,KAAK,CAACP,GAAG,CAAC,EAAEQ,OAAO,CAEhD;MACH;MACA,OAAOC,IAAI;IACb;GACD;AACH,CAAC","ignoreList":[]}
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
- const hasTaglessItem = items.some(item => item.tags.length === 0);
101
- if (hasTaglessItem) {
102
- throw new Error('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');
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.
@@ -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","hasTaglessItem","Error","classifyServableQuery","kind","only","undefined","isNonEmptyReadonlyArray","fields","all","or"],"sources":["../../src/Query.ts"],"sourcesContent":[null],"mappings":"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","ignoreList":[]}
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, and the servable-shape dispatch every engine
8
- * compiles its plans through), the `AppendCondition`, tagged errors, the
9
- * `DcbEventStore` contract, the in-memory layer, the shared subscribe machine
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`), and
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";
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","names":["DcbEventStoreInMemory"],"sources":["../../src/index.ts"],"sourcesContent":[null],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;AAwBA,cAAc,sBAAmB;AACjC,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":[]}
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kairos-es/core",
3
- "version": "0.0.0",
3
+ "version": "1.0.0-alpha.1",
4
4
  "description": "DCB-first event-sourcing contract and in-memory store for Effect v3",
5
5
  "license": "BSD-3-Clause",
6
6
  "repository": {
@@ -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
+ )
@@ -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
- Effect.suspend(() => {
86
- // An EMPTY batch is a plain-data caller bypassing the
87
- // `NonEmptyReadonlyArray` type: there is nothing to append. The engines
88
- // diverge on it at an unchecked boundary — Postgres inserts zero rows and
89
- // its conditional function returns zero rows, indistinguishable from a
90
- // conflict, so it surfaces a PHANTOM `AppendConditionFailed`, whereas
91
- // in-memory silently returns the previous head (`start - 1n`). Make it a
92
- // DEFECT here, at the one choke point, so the policy is definitionally
93
- // identical across engines and zero-rows stays an unambiguous conflict
94
- // signal (a throw inside the suspend becomes a defect, like
95
- // `assertServableQuery`).
96
- if (events.length === 0) {
97
- throw new Error(
98
- 'makeContractStore.append: the events batch is empty; append requires ' +
99
- 'at least one event (a NonEmptyReadonlyArray). An empty batch is a ' +
100
- 'plain-data caller bypassing the type — a defect, not a conflict.',
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
- // A non-servable guard query is a defect in every engine.
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
- // Seeding the reduce with the wildcard would swallow everything (mergeQuery
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,