@playfast/reform 0.0.10 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/README.md +11 -11
  2. package/package.json +1 -1
  3. package/src/boundary/boundary.ts +16 -7
  4. package/src/calc/asyncCalc.ts +84 -37
  5. package/src/calc/asyncData.ts +8 -4
  6. package/src/calc/calc.ts +7 -3
  7. package/src/calc/calcFamily.ts +24 -10
  8. package/src/calc/compose.ts +1 -1
  9. package/src/calc/queryState.ts +8 -8
  10. package/src/channel/channel.ts +63 -37
  11. package/src/compose/composition.ts +20 -1
  12. package/src/compose/provide.ts +5 -1
  13. package/src/compose/slot.ts +4 -2
  14. package/src/compose/structure.ts +43 -19
  15. package/src/compose/ui.ts +21 -2
  16. package/src/compose/ui.typecheck.ts +4 -4
  17. package/src/definition/definition.ts +20 -7
  18. package/src/feature/feature.test.ts +4 -4
  19. package/src/feature/feature.ts +30 -16
  20. package/src/feature/feature.typecheck.ts +2 -2
  21. package/src/internal/capture.ts +1 -0
  22. package/src/internal/errors.ts +9 -4
  23. package/src/internal/inspect.ts +4 -4
  24. package/src/internal/queryDriver.ts +102 -53
  25. package/src/internal/reuse.ts +67 -30
  26. package/src/internal/scheduler.ts +35 -23
  27. package/src/internal/sources.ts +14 -8
  28. package/src/internal/stateRegistry.ts +3 -1
  29. package/src/internal/store.ts +10 -8
  30. package/src/internal/track.ts +3 -1
  31. package/src/procedure/procedure.ts +2 -2
  32. package/src/reducer/reducer.ts +17 -9
  33. package/src/remote/remoteState.test.ts +188 -1
  34. package/src/remote/remoteState.ts +112 -51
  35. package/src/remote/remoteState.typecheck.ts +4 -1
  36. package/src/runtime/bus.ts +3 -1
  37. package/src/runtime/hardening.test.ts +1 -1
  38. package/src/runtime/loop.ts +67 -46
  39. package/src/runtime/queries.ts +3 -1
  40. package/src/scene/scene.ts +16 -8
  41. package/src/state/state.ts +11 -10
  42. package/src/state/stateFamily.ts +27 -11
  43. package/src/state/stateGroup.ts +22 -9
  44. package/src/synced/syncedStore.ts +15 -9
  45. package/src/wire/tree.ts +66 -30
  46. package/src/wire/triggers.ts +9 -8
@@ -1,4 +1,4 @@
1
- import { Context, Effect, Layer, type Schema } from 'effect'
1
+ import { Context, Effect, Layer, Option, type Schema } from 'effect'
2
2
  import { type Manifest, definitionClass } from '../definition/definition'
3
3
  import { resolveScheduler, type Scheduler } from '../internal/scheduler'
4
4
  import { claimStateTag } from '../internal/stateRegistry'
@@ -31,7 +31,7 @@ export interface FamilyStore<K, V> {
31
31
  }
32
32
 
33
33
  /** Options for a family's keyed store. */
34
- export interface FamilyOptions {
34
+ export interface FamilyOptionsExternalApi {
35
35
  /**
36
36
  * Drop a key's store automatically once it has no live subscribers (checked on
37
37
  * the next microtask, so a same-commit re-subscribe — e.g. a key that just moved
@@ -44,6 +44,7 @@ export interface FamilyOptions {
44
44
  */
45
45
  readonly evictWhenUnused?: boolean
46
46
  }
47
+ export type FamilyOptions = FamilyOptionsExternalApi
47
48
 
48
49
  const makeFamilyStore = <K, V>(
49
50
  seed: (key: K) => V,
@@ -54,6 +55,8 @@ const makeFamilyStore = <K, V>(
54
55
  const evictWhenUnused = options.evictWhenUnused === true
55
56
  // Live subscriber count per key — maintained only when eviction is on.
56
57
  const subscribers = new Map<K, number>()
58
+ const liveSubscriberCount = (key: K): number =>
59
+ Option.getOrElse(Option.fromNullable(subscribers.get(key)), () => 0)
57
60
 
58
61
  // Wrap a store's `subscribe` to ref-count, evicting the key when it falls idle.
59
62
  // `get`/`set`/`getVersion` are delegated unchanged, so the loop writes and the
@@ -61,11 +64,13 @@ const makeFamilyStore = <K, V>(
61
64
  const refCounted = (key: K, store: Store<V>): Store<V> => ({
62
65
  ...store,
63
66
  subscribe: (listener) => {
64
- subscribers.set(key, (subscribers.get(key) ?? 0) + 1)
67
+ subscribers.set(key, liveSubscriberCount(key) + 1)
65
68
  const off = store.subscribe(listener)
66
69
  const released = { done: false }
67
70
  return () => {
68
- if (released.done) return
71
+ if (released.done) {
72
+ return
73
+ }
69
74
  released.done = true
70
75
  off()
71
76
  const remaining = (subscribers.get(key) ?? 1) - 1
@@ -75,7 +80,9 @@ const makeFamilyStore = <K, V>(
75
80
  }
76
81
  subscribers.delete(key)
77
82
  queueMicrotask(() => {
78
- if ((subscribers.get(key) ?? 0) === 0) entries.delete(key)
83
+ if (liveSubscriberCount(key) === 0) {
84
+ entries.delete(key)
85
+ }
79
86
  })
80
87
  }
81
88
  },
@@ -84,7 +91,9 @@ const makeFamilyStore = <K, V>(
84
91
  return {
85
92
  at: (key) => {
86
93
  const existing = entries.get(key)
87
- if (existing !== undefined) return existing
94
+ if (existing !== undefined) {
95
+ return existing
96
+ }
88
97
  const base = makeStore(seed(key), scheduler)
89
98
  const created = evictWhenUnused ? refCounted(key, base) : base
90
99
  entries.set(key, created)
@@ -102,7 +111,7 @@ const makeFamilyStore = <K, V>(
102
111
  }
103
112
  }
104
113
 
105
- export interface StateFamilyManifest<N extends string, K, V> extends Manifest {
114
+ export interface StateFamilyManifestExternalApi<N extends string, K, V> extends Manifest {
106
115
  readonly kind: 'StateFamily'
107
116
  readonly name: N
108
117
  readonly key: Schema.Schema<K, any>
@@ -110,6 +119,7 @@ export interface StateFamilyManifest<N extends string, K, V> extends Manifest {
110
119
  readonly title?: string
111
120
  readonly description?: string
112
121
  }
122
+ export type StateFamilyManifest<N extends string, K, V> = StateFamilyManifestExternalApi<N, K, V>
113
123
 
114
124
  export interface StateFamilyClass<out N extends string, in out K, in out V> {
115
125
  /** Instance carries the key phantom so `Family['Key']` resolves in `keyOf` types. */
@@ -126,11 +136,16 @@ export type FamilyValue<F> = F extends StateFamilyClass<any, any, infer V> ? V :
126
136
  * Normalized keyed state: one logical State per key, backed by a single family
127
137
  * store. Scales with the collection without re-rendering unrelated entries.
128
138
  */
139
+ export interface StateFamilyMakeOptionsExternalApi {
140
+ readonly title?: string
141
+ readonly description?: string
142
+ }
143
+
129
144
  export const make = <const N extends string, K, V>(
130
145
  name: N,
131
146
  key: Schema.Schema<K, any>,
132
- value: Schema.Schema<V, any>,
133
- options: { readonly title?: string; readonly description?: string } = {},
147
+ valueSchema: Schema.Schema<V, any>,
148
+ options: StateFamilyMakeOptionsExternalApi = {},
134
149
  ): StateFamilyClass<N, K, V> => {
135
150
  const identifier = `reform/family/${name}`
136
151
  claimStateTag(identifier)
@@ -139,7 +154,7 @@ export const make = <const N extends string, K, V>(
139
154
  kind: 'StateFamily',
140
155
  name,
141
156
  key,
142
- value,
157
+ value: valueSchema,
143
158
  ...(options.title !== undefined ? { title: options.title } : {}),
144
159
  ...(options.description !== undefined ? { description: options.description } : {}),
145
160
  }
@@ -154,7 +169,7 @@ export const read = <N extends string, K, V>(
154
169
  family: StateFamilyClass<N, K, V>,
155
170
  key: K,
156
171
  ): Effect.Effect<V, never, FamilyStore<K, V>> =>
157
- Effect.flatMap(family.store, (fs) => readTracked(fs.at(key)))
172
+ Effect.flatMap(family.store, (familyStore) => readTracked(familyStore.at(key)))
158
173
 
159
174
  /**
160
175
  * Allocate a family's keyed store — `StateFamily.live(ItemUi, seed)`. The seed
@@ -166,6 +181,7 @@ export const live = <N extends string, K, V>(
166
181
  initial: V | ((key: K) => V),
167
182
  options: FamilyOptions = {},
168
183
  ): Layer.Layer<FamilyStore<K, V>> => {
184
+ // oxlint-disable-next-line reform-rules/no-type-assertion -- value-or-factory union can't be discriminated for a generic V without a cast
169
185
  const seed: (key: K) => V = typeof initial === 'function' ? (initial as (key: K) => V) : () => initial
170
186
  return Layer.effect(
171
187
  family.store,
@@ -1,4 +1,4 @@
1
- import { Layer } from 'effect'
1
+ import { Array as Arr, Layer, Option } from 'effect'
2
2
  import { definitionClass } from '../definition/definition'
3
3
  import { DuplicateRegistration, UnknownGroupState } from '../internal/errors'
4
4
  // A group's members each carry a `store` tag, so the shared `StoresOf` (used for
@@ -77,13 +77,21 @@ export const make = <const Members extends ReadonlyArray<AnyState>>(
77
77
  ): StateGroupClass<Members> => {
78
78
  // `byName` already de-dupes, so a smaller map than the member list means two
79
79
  // members claimed one name — unaddressable, and a silent footgun on merge.
80
- const names = members.map((m) => m.manifest.name)
81
- const duplicate = names.find((name, i) => names.indexOf(name) !== i)
82
- if (duplicate !== undefined) throw new DuplicateRegistration({ kind: 'state in group', name: duplicate })
80
+ const names = members.map((member) => member.manifest.name)
81
+ const duplicate = Arr.findFirst(names, (name, index) =>
82
+ Option.exists(
83
+ Arr.findFirstIndex(names, (candidate) => candidate === name),
84
+ (firstIndex) => firstIndex !== index,
85
+ ),
86
+ )
87
+ if (Option.isSome(duplicate)) {
88
+ // oxlint-disable-next-line reform-rules/no-throw -- definition-time invariant; sync factory has no Effect context (see internal/errors.ts)
89
+ throw new DuplicateRegistration({ kind: 'state in group', name: duplicate.value })
90
+ }
83
91
  return definitionClass<StateGroupClass<Members>>({
84
92
  kind: 'StateGroup' as const,
85
93
  members,
86
- byName: new Map(members.map((m) => [m.manifest.name, m] as const)),
94
+ byName: new Map(members.map((member) => [member.manifest.name, member] as const)),
87
95
  })
88
96
  }
89
97
 
@@ -102,8 +110,11 @@ export const select = <Members extends ReadonlyArray<AnyState>, N extends StateN
102
110
  name: N,
103
111
  ): StateToken<N, ValueForName<Members, N>> => {
104
112
  const member = group.byName.get(name)
105
- if (member === undefined) throw new UnknownGroupState({ name })
106
- return new StateToken(name, member.store) as StateToken<N, ValueForName<Members, N>>
113
+ if (member === undefined) {
114
+ // oxlint-disable-next-line reform-rules/no-throw -- definition-time invariant; sync lookup has no Effect context (see internal/errors.ts)
115
+ throw new UnknownGroupState({ name })
116
+ }
117
+ return new StateToken(name, member.store)
107
118
  }
108
119
 
109
120
  /**
@@ -118,8 +129,10 @@ export const live = <Members extends ReadonlyArray<AnyState>>(
118
129
  // reflection boundary (string key into a mapped type). Each member's store
119
130
  // layer is then merged; the union of stores is exactly `StoresOf<Members>`,
120
131
  // which `reduce`'s single-layer accumulator can't express, so restate it.
132
+ // oxlint-disable-next-line reform-rules/no-type-assertion -- reflection boundary: index a mapped seed type by runtime member name
121
133
  const seedRecord = seeds as Record<string, unknown>
134
+ // oxlint-disable-next-line reform-rules/no-type-assertion -- union-of-stores Rout can't be expressed by reduce's single-layer accumulator
122
135
  return group.members
123
- .map((m) => stateLive(m, seedRecord[m.manifest.name]))
124
- .reduce((a, b) => Layer.merge(a, b)) as Layer.Layer<StoresOf<Members>>
136
+ .map((member) => stateLive(member, seedRecord[member.manifest.name]))
137
+ .reduce((accumulator, layer) => Layer.merge(accumulator, layer)) as Layer.Layer<StoresOf<Members>>
125
138
  }
@@ -1,4 +1,4 @@
1
- import { Context, Effect, Layer, type Schema, type Scope } from 'effect'
1
+ import { Context, Effect, Layer, Option, type Schema, type Scope } from 'effect'
2
2
  import { type Manifest, yieldableClass } from '../definition/definition'
3
3
  import { resolveScheduler } from '../internal/scheduler'
4
4
  import { makeDerivedStore, type Store } from '../internal/store'
@@ -31,8 +31,8 @@ export interface SyncedStoreManifest<N extends string, A> extends Manifest {
31
31
  readonly kind: 'SyncedStore'
32
32
  readonly name: N
33
33
  readonly schema: Schema.Schema<A, any>
34
- readonly title?: string
35
- readonly description?: string
34
+ readonly title: Option.Option<string>
35
+ readonly description: Option.Option<string>
36
36
  }
37
37
 
38
38
  export interface SyncedStoreClass<out N extends string, in out A>
@@ -46,6 +46,15 @@ export interface SyncedStoreClass<out N extends string, in out A>
46
46
  readonly name: N
47
47
  }
48
48
 
49
+ /**
50
+ * A minimal store-carrier — satisfied by a `SyncedStoreClass` and by any
51
+ * companion primitive (e.g. `@playfast/reform-db`'s `DbQuery`) that allocates
52
+ * its own `Store` tag, so `SyncedStore.live` is reusable beyond `SyncedStore`.
53
+ */
54
+ export interface StoreCarrier<A> {
55
+ readonly store: Context.Tag<Store<A>, Store<A>>
56
+ }
57
+
49
58
  export type AnySyncedStore = SyncedStoreClass<string, any>
50
59
  export type SyncedValue<S> = S extends SyncedStoreClass<string, infer A> ? A : never
51
60
 
@@ -65,8 +74,8 @@ export const make = <const N extends string, A>(
65
74
  kind: 'SyncedStore',
66
75
  name,
67
76
  schema,
68
- ...(options.title !== undefined ? { title: options.title } : {}),
69
- ...(options.description !== undefined ? { description: options.description } : {}),
77
+ title: Option.fromNullable(options.title),
78
+ description: Option.fromNullable(options.description),
70
79
  }
71
80
  const read = Effect.flatMap(store, readTracked)
72
81
  return yieldableClass(read, { manifest, store, name })
@@ -81,10 +90,7 @@ export const make = <const N extends string, A>(
81
90
  * the layer's scope.
82
91
  */
83
92
  export const live = <A, R>(
84
- // A minimal store-carrier — satisfied by a `SyncedStoreClass` and by any
85
- // companion primitive (e.g. `@playfast/reform-db`'s `DbQuery`) that allocates
86
- // its own `Store` tag, so the seam is reusable beyond `SyncedStore` itself.
87
- def: { readonly store: Context.Tag<Store<A>, Store<A>> },
93
+ def: StoreCarrier<A>,
88
94
  acquire: Effect.Effect<SyncedSource<A>, never, R>,
89
95
  ): Layer.Layer<Store<A>, never, Exclude<R, Scope.Scope>> =>
90
96
  Layer.scoped(
package/src/wire/tree.ts CHANGED
@@ -1,4 +1,5 @@
1
- import { Match } from 'effect'
1
+ import { Array, Match, Option, Order, Record as Rec } from 'effect'
2
+ import { sort as sortArray } from 'effect/Array'
2
3
 
3
4
  /**
4
5
  * The serializable model of a rendered UI tree, and the pure diff/apply over it.
@@ -18,8 +19,9 @@ export type WireProp =
18
19
  | { readonly _tag: 'Data'; readonly name: string; readonly value: unknown }
19
20
  | { readonly _tag: 'Event'; readonly name: string; readonly handle: string }
20
21
 
21
- /** One rendered UI contract instance, identified stably across frames. */
22
- export interface WireNode {
22
+ /** One rendered UI contract instance, identified stably across frames. The serializable
23
+ * wire-boundary shape (hence the `ExternalApi` postfix; `null` is part of the JSON contract). */
24
+ export interface WireNodeExternalApi {
23
25
  /** Stable identity across renders — the unit of diffing. */
24
26
  readonly id: string
25
27
  /** The UI contract name (`UiCapture.name`) the client looks up a presentation by. */
@@ -42,6 +44,9 @@ export interface WireNode {
42
44
  readonly props: ReadonlyArray<WireProp>
43
45
  }
44
46
 
47
+ /** Public alias preserving the established name across the codebase. */
48
+ export type WireNode = WireNodeExternalApi
49
+
45
50
  export type WireTree = ReadonlyArray<WireNode>
46
51
 
47
52
  /** A change to apply to a client's tree: upsert a node, or drop one by id. */
@@ -49,34 +54,54 @@ export type WirePatch =
49
54
  | { readonly _tag: 'Upsert'; readonly node: WireNode }
50
55
  | { readonly _tag: 'Delete'; readonly id: string }
51
56
 
52
- const arraysEqual = (a: ReadonlyArray<unknown>, b: ReadonlyArray<unknown>): boolean =>
53
- a.length === b.length && a.every((item, index) => deepEqual(item, b[index]))
57
+ interface EqualPair {
58
+ readonly left: unknown
59
+ readonly right: unknown
60
+ }
61
+
62
+ const isRecord = (candidate: unknown): candidate is Record<string, unknown> =>
63
+ typeof candidate === 'object' && candidate !== null
64
+
65
+ const arraysEqual = (left: ReadonlyArray<unknown>, right: ReadonlyArray<unknown>): boolean =>
66
+ left.length === right.length &&
67
+ left.every((element, index) => deepEqual({ left: element, right: right[index] }))
54
68
 
55
- const recordsEqual = (a: Record<string, unknown>, b: Record<string, unknown>): boolean => {
56
- const aKeys = Object.keys(a)
57
- const bKeys = Object.keys(b)
58
- return aKeys.length === bKeys.length && aKeys.every((key) => deepEqual(a[key], b[key]))
69
+ const recordsEqual = (left: Record<string, unknown>, right: Record<string, unknown>): boolean => {
70
+ const leftKeys = Rec.keys(left)
71
+ const rightKeys = Rec.keys(right)
72
+ return (
73
+ leftKeys.length === rightKeys.length &&
74
+ leftKeys.every((key) => deepEqual({ left: left[key], right: right[key] }))
75
+ )
59
76
  }
60
77
 
61
78
  /** Structural equality over serializable wire values (and the tagged props that carry them). */
62
- const deepEqual = (a: unknown, b: unknown): boolean => {
63
- if (a === b) return true
64
- if (a === null || b === null) return false
65
- if (Array.isArray(a)) return Array.isArray(b) && arraysEqual(a, b)
66
- if (Array.isArray(b)) return false
67
- if (typeof a === 'object' && typeof b === 'object') {
68
- return recordsEqual(a as Record<string, unknown>, b as Record<string, unknown>)
79
+ const deepEqual = ({ left, right }: EqualPair): boolean => {
80
+ if (left === right) {
81
+ return true
82
+ }
83
+ if (left === null || right === null) {
84
+ return false
85
+ }
86
+ if (Array.isArray(left)) {
87
+ return Array.isArray(right) && arraysEqual(left, right)
88
+ }
89
+ if (Array.isArray(right)) {
90
+ return false
91
+ }
92
+ if (isRecord(left) && isRecord(right)) {
93
+ return recordsEqual(left, right)
69
94
  }
70
95
  return false
71
96
  }
72
97
 
73
- const nodesEqual = (a: WireNode, b: WireNode): boolean =>
74
- a.name === b.name &&
75
- a.parentId === b.parentId &&
76
- a.childIndex === b.childIndex &&
77
- a.slot === b.slot &&
78
- a.key === b.key &&
79
- deepEqual(a.props, b.props)
98
+ const nodesEqual = (leftNode: WireNode, rightNode: WireNode): boolean =>
99
+ leftNode.name === rightNode.name &&
100
+ leftNode.parentId === rightNode.parentId &&
101
+ leftNode.childIndex === rightNode.childIndex &&
102
+ leftNode.slot === rightNode.slot &&
103
+ leftNode.key === rightNode.key &&
104
+ deepEqual({ left: leftNode.props, right: rightNode.props })
80
105
 
81
106
  const isUnchanged = (node: WireNode, previousById: ReadonlyMap<string, WireNode>): boolean => {
82
107
  const previous = previousById.get(node.id)
@@ -102,11 +127,14 @@ export const diff = (previous: WireTree, next: WireTree): ReadonlyArray<WirePatc
102
127
  return [...deletes, ...upserts]
103
128
  }
104
129
 
105
- const upsertNode = (state: WireTree, node: WireNode): WireTree => {
106
- const index = state.findIndex((existing) => existing.id === node.id)
107
- if (index < 0) return [...state, node]
108
- return [...state.slice(0, index), node, ...state.slice(index + 1)]
109
- }
130
+ const upsertNode = (state: WireTree, node: WireNode): WireTree =>
131
+ Option.match(
132
+ Array.findFirstIndex(state, (existing) => existing.id === node.id),
133
+ {
134
+ onNone: () => [...state, node],
135
+ onSome: (index) => [...state.slice(0, index), node, ...state.slice(index + 1)],
136
+ },
137
+ )
110
138
 
111
139
  /** Fold patches into a client's tree (the receiving side's reducer). */
112
140
  export const apply = (state: WireTree, patches: ReadonlyArray<WirePatch>): WireTree =>
@@ -120,10 +148,18 @@ export const apply = (state: WireTree, patches: ReadonlyArray<WirePatch>): WireT
120
148
  state,
121
149
  )
122
150
 
151
+ const bySiblingOrder = Order.mapInput(Order.number, (node: WireNode) => node.childIndex)
152
+
123
153
  /** The root nodes of a tree, in sibling order. */
124
154
  export const roots = (tree: WireTree): WireTree =>
125
- tree.filter((node) => node.parentId === null).sort((a, b) => a.childIndex - b.childIndex)
155
+ sortArray(
156
+ tree.filter((node) => node.parentId === null),
157
+ bySiblingOrder,
158
+ )
126
159
 
127
160
  /** The children of a node, in sibling order. */
128
161
  export const childrenOf = (tree: WireTree, parentId: string): WireTree =>
129
- tree.filter((node) => node.parentId === parentId).sort((a, b) => a.childIndex - b.childIndex)
162
+ sortArray(
163
+ tree.filter((node) => node.parentId === parentId),
164
+ bySiblingOrder,
165
+ )
@@ -73,16 +73,17 @@ export const make: Effect.Effect<TriggerRegistryApi> = Effect.gen(function* () {
73
73
  ): Effect.Effect<void> =>
74
74
  Ref.update(entries, (map) => new Map(map).set(handle, { trigger, schema }))
75
75
 
76
- const invoke = (
76
+ const invoke = Effect.fn('invoke')(function* (
77
77
  handle: TriggerHandle,
78
78
  encodedPayload: unknown,
79
- ): Effect.Effect<void, UnknownTrigger | ParseResult.ParseError> =>
80
- Effect.gen(function* () {
81
- const entry = (yield* Ref.get(entries)).get(handle)
82
- if (entry === undefined) return yield* Effect.fail(new UnknownTrigger({ handle }))
83
- const payload = yield* Schema.decodeUnknown(entry.schema)(encodedPayload)
84
- entry.trigger(payload)
85
- })
79
+ ): Effect.fn.Return<void, UnknownTrigger | ParseResult.ParseError> {
80
+ const entry = (yield* Ref.get(entries)).get(handle)
81
+ if (entry === undefined) {
82
+ return yield* Effect.fail(new UnknownTrigger({ handle }))
83
+ }
84
+ const payload = yield* Schema.decodeUnknown(entry.schema)(encodedPayload)
85
+ entry.trigger(payload)
86
+ })
86
87
 
87
88
  const revoke = (handle: TriggerHandle): Effect.Effect<void> =>
88
89
  Ref.update(entries, (map) => {