aeon-ui-engine 1.9.0 → 1.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +7 -0
- package/README.md +33 -0
- package/package.json +1 -1
- package/packages/primitives/dist/chunk-H5XJGZHT.js +493 -0
- package/packages/primitives/dist/index.d.ts +1 -0
- package/packages/primitives/dist/index.js +23 -1
- package/packages/primitives/dist/machines/app-nav.d.ts +3 -3
- package/packages/primitives/dist/machines/store.d.ts +286 -0
- package/packages/primitives/dist/machines/store.js +25 -0
- package/packages/primitives/dist/machines/tabs.d.ts +2 -2
- package/packages/primitives/src/index.ts +1 -0
- package/packages/primitives/src/machines/store.ts +712 -0
- package/packages/react/dist/index.d.ts +11 -2
- package/packages/react/dist/index.js +31 -2
- package/packages/react/src/hooks/use-aeon-store.ts +50 -0
- package/packages/react/src/index.ts +1 -0
|
@@ -0,0 +1,712 @@
|
|
|
1
|
+
import { stateToAttr } from '@aeon-ui/core'
|
|
2
|
+
import { assign, createActor, fromCallback, setup } from 'xstate'
|
|
3
|
+
import { createChartCache, isCacheableId, type CacheState, type CacheView, type ChartCache } from './cache.js'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* One store per app: every entity lives once, by id, in its slice. Charts and
|
|
7
|
+
* components read through selectors and project state onto `data-aeon-*`
|
|
8
|
+
* attributes; the only way to change anything is an event sent to the store.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export interface SliceOptions<E, Q> {
|
|
12
|
+
idOf: (entity: E) => string
|
|
13
|
+
/** Loads the entities a query names. Slices without `load` are filled by events only. */
|
|
14
|
+
load?: (query: Q, context: { signal: AbortSignal }) => Promise<readonly E[]>
|
|
15
|
+
/** Milliseconds a query result stays fresh. Default 30 seconds. */
|
|
16
|
+
ttl?: number | ((query: Q) => number)
|
|
17
|
+
tags?: (query: Q) => readonly string[]
|
|
18
|
+
/** Turns a query into the key its result is stored under. Defaults to `String` for strings, else JSON. */
|
|
19
|
+
queryKey?: (query: Q) => string
|
|
20
|
+
/** An incoming entity equal to the held one keeps the held object, so selectors stay quiet. Default: shallow equality. */
|
|
21
|
+
equals?: (a: E, b: E) => boolean
|
|
22
|
+
/** Saved by the store's persistence adapter. Default true. */
|
|
23
|
+
persist?: boolean
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface SliceDefinition<E, Q> extends SliceOptions<E, Q> {
|
|
27
|
+
readonly __types?: { entity: E; query: Q }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function defineSlice<E, Q = string>(options: SliceOptions<E, Q>): SliceDefinition<E, Q> {
|
|
31
|
+
return options
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
35
|
+
export type AnySlices = Record<string, SliceDefinition<any, any>>
|
|
36
|
+
export type EntityOf<D> = D extends { readonly __types?: { entity: infer E } } ? E : never
|
|
37
|
+
export type QueryOf<D> = D extends { readonly __types?: { query: infer Q } } ? Q : never
|
|
38
|
+
|
|
39
|
+
/** One query's result: its load state and the ids it returned, in order. */
|
|
40
|
+
export interface QueryView {
|
|
41
|
+
readonly state: CacheState
|
|
42
|
+
readonly ids: readonly string[]
|
|
43
|
+
readonly error: string | null
|
|
44
|
+
readonly loadedAt: number | null
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface SliceState<E> {
|
|
48
|
+
readonly byId: Readonly<Record<string, E>>
|
|
49
|
+
/** Every held id, in arrival order. */
|
|
50
|
+
readonly ids: readonly string[]
|
|
51
|
+
readonly queries: Readonly<Record<string, QueryView>>
|
|
52
|
+
/** Entity id → the optimistic write it waits on. */
|
|
53
|
+
readonly pending: Readonly<Record<string, string>>
|
|
54
|
+
/** Entity id → why its last write was rolled back. Cleared by the next write to it. */
|
|
55
|
+
readonly failed: Readonly<Record<string, string>>
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export type StoreStatus = 'hydrating' | 'ready'
|
|
59
|
+
|
|
60
|
+
interface PendingWrite {
|
|
61
|
+
slice: string
|
|
62
|
+
/** Values before the write, restored on rollback. */
|
|
63
|
+
before: Record<string, unknown>
|
|
64
|
+
/** Values the write put in place; rollback only restores entities still holding them. */
|
|
65
|
+
after: Record<string, unknown>
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface StoreState<S extends AnySlices> {
|
|
69
|
+
readonly status: StoreStatus
|
|
70
|
+
readonly slices: { readonly [N in keyof S]: SliceState<EntityOf<S[N]>> }
|
|
71
|
+
readonly writes: Readonly<Record<string, PendingWrite>>
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
type SliceEvent<N extends string, E, Q> =
|
|
75
|
+
| { type: 'ENTITY.UPSERT'; slice: N; entities: readonly E[] }
|
|
76
|
+
| { type: 'ENTITY.PATCH'; slice: N; id: string; patch: Partial<E> }
|
|
77
|
+
| { type: 'ENTITY.REMOVE'; slice: N; ids: readonly string[] }
|
|
78
|
+
| { type: 'QUERY.FETCH'; slice: N; query: Q }
|
|
79
|
+
| { type: 'QUERY.REFRESH'; slice: N; query: Q }
|
|
80
|
+
| { type: 'QUERY.INVALIDATE'; slice: N; query?: Q; tag?: string }
|
|
81
|
+
/** Applies at once and marks the entities pending until WRITE.CONFIRM or WRITE.ROLLBACK. */
|
|
82
|
+
| { type: 'WRITE.BEGIN'; slice: N; write: string; upsert?: readonly E[]; remove?: readonly string[] }
|
|
83
|
+
|
|
84
|
+
/** Everything a store accepts. Writes, loads and invalidations all go through here. */
|
|
85
|
+
export type StoreEvent<S extends AnySlices> =
|
|
86
|
+
| { [N in keyof S & string]: SliceEvent<N, EntityOf<S[N]>, QueryOf<S[N]>> }[keyof S & string]
|
|
87
|
+
| { type: 'WRITE.CONFIRM'; write: string }
|
|
88
|
+
| { type: 'WRITE.ROLLBACK'; write: string; error?: string }
|
|
89
|
+
/** Marks every query stale; watched ones reload (reconnect, window focus). */
|
|
90
|
+
| { type: 'STORE.INVALIDATE' }
|
|
91
|
+
/** Drops all data, running loads and saved data (lock, account or network switch). */
|
|
92
|
+
| { type: 'STORE.CLEAR' }
|
|
93
|
+
|
|
94
|
+
export interface StoreAdapter {
|
|
95
|
+
load(key: string): string | null | undefined | Promise<string | null | undefined>
|
|
96
|
+
save(key: string, value: string): void | Promise<void>
|
|
97
|
+
remove(key: string): void | Promise<void>
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export interface StorePersistOptions {
|
|
101
|
+
adapter: StoreAdapter
|
|
102
|
+
/** Bump when the saved shape changes; older data goes through `migrate` or is dropped. */
|
|
103
|
+
version: number
|
|
104
|
+
migrate?: (saved: PersistedSlices, fromVersion: number) => PersistedSlices | null | undefined
|
|
105
|
+
/** Milliseconds of quiet before a save. Default 250. */
|
|
106
|
+
delayMs?: number
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export type PersistedSlices = Record<string, { byId: Record<string, unknown>; ids: string[] }>
|
|
110
|
+
|
|
111
|
+
export interface StoreOptions<S extends AnySlices> {
|
|
112
|
+
/** Names the store and its saved data. Names of secrets, keys or spend decisions are refused. */
|
|
113
|
+
id: string
|
|
114
|
+
slices: S
|
|
115
|
+
persist?: StorePersistOptions
|
|
116
|
+
/** Starts a server or disk feed that sends store events; returns its stop function. */
|
|
117
|
+
sync?: (send: (event: StoreEvent<S>) => void) => (() => void) | void
|
|
118
|
+
now?: () => number
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** A query's view with its entities resolved. Stable until the view or one of its entities changes. */
|
|
122
|
+
export interface QueryResult<E> extends QueryView {
|
|
123
|
+
readonly entities: readonly E[]
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export interface Selection<T> {
|
|
127
|
+
get(): T
|
|
128
|
+
/** Calls back only when the selected value changes under `equals`. */
|
|
129
|
+
subscribe(listener: (value: T) => void): () => void
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** What the invoked store actor sends its parent chart whenever its selection changes. */
|
|
133
|
+
export interface StoreUpdate<T> {
|
|
134
|
+
type: 'STORE.UPDATE'
|
|
135
|
+
store: string
|
|
136
|
+
value: T
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export interface AeonStore<S extends AnySlices> {
|
|
140
|
+
readonly id: string
|
|
141
|
+
getSnapshot(): StoreState<S>
|
|
142
|
+
/** For `useSyncExternalStore`: calls back after every change. */
|
|
143
|
+
subscribe(listener: () => void): () => void
|
|
144
|
+
send(event: StoreEvent<S>): void
|
|
145
|
+
select<T>(selector: (state: StoreState<S>) => T, equals?: (a: T, b: T) => boolean): Selection<T>
|
|
146
|
+
entity<N extends keyof S & string>(slice: N, id: string): EntityOf<S[N]> | undefined
|
|
147
|
+
query<N extends keyof S & string>(slice: N, query: QueryOf<S[N]>): QueryResult<EntityOf<S[N]>>
|
|
148
|
+
/** Resolves once saved data has loaded (at once without persistence). */
|
|
149
|
+
readonly ready: Promise<void>
|
|
150
|
+
/** Saves now instead of after the quiet delay (app quit). */
|
|
151
|
+
flush(): Promise<void>
|
|
152
|
+
stop(): void
|
|
153
|
+
/**
|
|
154
|
+
* Invoke from a chart: `invoke: { id: 'store', src: store.actor, input: { select } }`.
|
|
155
|
+
* It sends `STORE.UPDATE` whenever the selection changes and forwards store
|
|
156
|
+
* events the chart sends it (`sendTo('store', …)`).
|
|
157
|
+
*/
|
|
158
|
+
readonly actor: ReturnType<typeof fromCallback<StoreEvent<S>, { select: (state: StoreState<S>) => unknown; equals?: (a: unknown, b: unknown) => boolean }>>
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const DEFAULT_TTL = 30_000
|
|
162
|
+
const ALL_TAG = '*'
|
|
163
|
+
const EMPTY_IDS: readonly string[] = Object.freeze([])
|
|
164
|
+
const IDLE_QUERY: QueryView = Object.freeze({ state: 'idle', ids: EMPTY_IDS, error: null, loadedAt: null })
|
|
165
|
+
const EMPTY_SLICE: SliceState<never> = Object.freeze({ byId: {}, ids: EMPTY_IDS, queries: {}, pending: {}, failed: {} })
|
|
166
|
+
|
|
167
|
+
const defaultKeyOf = (key: unknown) => (typeof key === 'string' ? key : JSON.stringify(key))
|
|
168
|
+
|
|
169
|
+
export function shallowEqual(a: unknown, b: unknown): boolean {
|
|
170
|
+
if (Object.is(a, b)) return true
|
|
171
|
+
if (typeof a !== 'object' || typeof b !== 'object' || a === null || b === null) return false
|
|
172
|
+
if (Array.isArray(a) !== Array.isArray(b)) return false
|
|
173
|
+
const keysA = Object.keys(a)
|
|
174
|
+
if (keysA.length !== Object.keys(b).length) return false
|
|
175
|
+
return keysA.every((key) => Object.hasOwn(b, key) && Object.is((a as Record<string, unknown>)[key], (b as Record<string, unknown>)[key]))
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Field and slice names are checked word by word, so `seedPhrase` and `privateKey` are caught. */
|
|
179
|
+
export function isStorableName(name: string): boolean {
|
|
180
|
+
return isCacheableId(name.replace(/([a-z0-9])([A-Z])/g, '$1 $2'))
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** The first field name, at any depth, that names a secret, key or spend decision. */
|
|
184
|
+
export function unstorableField(value: unknown, depth = 0): string | null {
|
|
185
|
+
if (depth > 8 || typeof value !== 'object' || value === null) return null
|
|
186
|
+
for (const [key, inner] of Object.entries(value)) {
|
|
187
|
+
if (!Array.isArray(value) && !isStorableName(key)) return key
|
|
188
|
+
const nested = unstorableField(inner, depth + 1)
|
|
189
|
+
if (nested !== null) return nested
|
|
190
|
+
}
|
|
191
|
+
return null
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** `data-aeon-*` projection of a query: its load state and how many entities it holds. */
|
|
195
|
+
export function queryAttrs(result: QueryView): Record<string, string> {
|
|
196
|
+
return { 'data-aeon-state': stateToAttr(result.state), 'data-aeon-count': String(result.ids.length) }
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** `data-aeon-*` projection of one entity: pending while a write waits on the server, failed after a rollback. */
|
|
200
|
+
export function entityAttrs<E>(slice: SliceState<E>, sliceName: string, id: string): Record<string, string> {
|
|
201
|
+
const state = slice.pending[id] !== undefined ? 'pending' : slice.failed[id] !== undefined ? 'failed' : 'settled'
|
|
202
|
+
return { 'data-aeon-entity': sliceName, 'data-aeon-state': state }
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export function memoryAdapter(initial: Record<string, string> = {}): StoreAdapter & { readonly data: Map<string, string> } {
|
|
206
|
+
const data = new Map(Object.entries(initial))
|
|
207
|
+
return {
|
|
208
|
+
data,
|
|
209
|
+
load: (key) => data.get(key) ?? null,
|
|
210
|
+
save: (key, value) => void data.set(key, value),
|
|
211
|
+
remove: (key) => void data.delete(key),
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export function webStorageAdapter(storage: Storage = globalThis.localStorage): StoreAdapter {
|
|
216
|
+
return {
|
|
217
|
+
load: (key) => storage.getItem(key),
|
|
218
|
+
save: (key, value) => storage.setItem(key, value),
|
|
219
|
+
remove: (key) => storage.removeItem(key),
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// ---- reducer -------------------------------------------------------------
|
|
224
|
+
|
|
225
|
+
type AnyEntity = unknown
|
|
226
|
+
type AnySliceState = SliceState<AnyEntity>
|
|
227
|
+
interface Data {
|
|
228
|
+
status: StoreStatus
|
|
229
|
+
slices: Record<string, AnySliceState>
|
|
230
|
+
writes: Record<string, PendingWrite>
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
type InternalEvent =
|
|
234
|
+
| StoreEvent<AnySlices>
|
|
235
|
+
| { type: 'QUERY.UPDATE'; slice: string; key: string; view: CacheView<readonly AnyEntity[]> }
|
|
236
|
+
| { type: 'STORE.HYDRATED'; slices: PersistedSlices | null }
|
|
237
|
+
|
|
238
|
+
interface Reducer {
|
|
239
|
+
defs: AnySlices
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function omit<T>(record: Readonly<Record<string, T>>, keys: Iterable<string>): Readonly<Record<string, T>> {
|
|
243
|
+
let next: Record<string, T> | null = null
|
|
244
|
+
for (const key of keys) {
|
|
245
|
+
if (!Object.hasOwn(record, key)) continue
|
|
246
|
+
next ??= { ...record }
|
|
247
|
+
delete next[key]
|
|
248
|
+
}
|
|
249
|
+
return next ?? record
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function withSlice(data: Data, name: string, slice: AnySliceState): Data {
|
|
253
|
+
return slice === data.slices[name] ? data : { ...data, slices: { ...data.slices, [name]: slice } }
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** Puts entities in place, keeping held objects that are equal to the incoming ones. */
|
|
257
|
+
function putEntities(r: Reducer, name: string, slice: AnySliceState, entities: readonly AnyEntity[]): AnySliceState {
|
|
258
|
+
const def = r.defs[name]!
|
|
259
|
+
const equals = def.equals ?? shallowEqual
|
|
260
|
+
let byId: Record<string, AnyEntity> | null = null
|
|
261
|
+
let added: string[] | null = null
|
|
262
|
+
let failed = slice.failed
|
|
263
|
+
for (const entity of entities) {
|
|
264
|
+
const id = def.idOf(entity)
|
|
265
|
+
const held = (byId ?? slice.byId)[id]
|
|
266
|
+
failed = omit(failed, [id])
|
|
267
|
+
if (held !== undefined && (held === entity || equals(held, entity))) continue
|
|
268
|
+
byId ??= { ...slice.byId }
|
|
269
|
+
if (held === undefined && !Object.hasOwn(byId, id)) (added ??= []).push(id)
|
|
270
|
+
byId[id] = entity
|
|
271
|
+
}
|
|
272
|
+
if (!byId && failed === slice.failed) return slice
|
|
273
|
+
return { ...slice, byId: byId ?? slice.byId, ids: added ? [...slice.ids, ...added] : slice.ids, failed }
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function removeEntities(slice: AnySliceState, ids: readonly string[]): AnySliceState {
|
|
277
|
+
const byId = omit(slice.byId, ids)
|
|
278
|
+
if (byId === slice.byId) return slice
|
|
279
|
+
const gone = new Set(ids)
|
|
280
|
+
return { ...slice, byId, ids: slice.ids.filter((id) => !gone.has(id)) }
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function sameIds(a: readonly string[], b: readonly string[]) {
|
|
284
|
+
return a.length === b.length && a.every((id, i) => id === b[i])
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function applyQueryUpdate(r: Reducer, data: Data, name: string, key: string, view: CacheView<readonly AnyEntity[]>): Data {
|
|
288
|
+
const def = r.defs[name]!
|
|
289
|
+
let slice = data.slices[name] ?? EMPTY_SLICE
|
|
290
|
+
let writes = data.writes
|
|
291
|
+
const previous = slice.queries[key] ?? IDLE_QUERY
|
|
292
|
+
let ids = previous.ids
|
|
293
|
+
if (view.data !== undefined) {
|
|
294
|
+
const incoming = view.data
|
|
295
|
+
const settled: AnyEntity[] = []
|
|
296
|
+
for (const entity of incoming) {
|
|
297
|
+
const id = def.idOf(entity)
|
|
298
|
+
const write = slice.pending[id]
|
|
299
|
+
// A load may predate the pending write: hold the optimistic value, refresh what rollback restores.
|
|
300
|
+
if (write !== undefined && writes[write]) {
|
|
301
|
+
writes = { ...writes, [write]: { ...writes[write]!, before: { ...writes[write]!.before, [id]: entity } } }
|
|
302
|
+
} else settled.push(entity)
|
|
303
|
+
}
|
|
304
|
+
slice = putEntities(r, name, slice, settled)
|
|
305
|
+
const nextIds = incoming.map(def.idOf)
|
|
306
|
+
if (!sameIds(nextIds, ids)) ids = nextIds
|
|
307
|
+
} else if (view.state === 'idle') ids = EMPTY_IDS
|
|
308
|
+
const query =
|
|
309
|
+
previous.state === view.state && previous.ids === ids && previous.error === view.error && previous.loadedAt === view.loadedAt
|
|
310
|
+
? previous
|
|
311
|
+
: Object.freeze({ state: view.state, ids, error: view.error, loadedAt: view.loadedAt })
|
|
312
|
+
if (query !== previous) slice = { ...slice, queries: { ...slice.queries, [key]: query } }
|
|
313
|
+
const next = withSlice(data, name, slice)
|
|
314
|
+
return writes === data.writes ? next : { ...next, writes }
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function beginWrite(r: Reducer, data: Data, event: Extract<StoreEvent<AnySlices>, { type: 'WRITE.BEGIN' }>): Data {
|
|
318
|
+
if (data.writes[event.write]) return data
|
|
319
|
+
const def = r.defs[event.slice]!
|
|
320
|
+
let slice = data.slices[event.slice] ?? EMPTY_SLICE
|
|
321
|
+
const before: Record<string, unknown> = {}
|
|
322
|
+
const after: Record<string, unknown> = {}
|
|
323
|
+
const ids: string[] = []
|
|
324
|
+
for (const entity of event.upsert ?? []) {
|
|
325
|
+
const id = def.idOf(entity)
|
|
326
|
+
before[id] = slice.byId[id]
|
|
327
|
+
ids.push(id)
|
|
328
|
+
}
|
|
329
|
+
for (const id of event.remove ?? []) {
|
|
330
|
+
before[id] = slice.byId[id]
|
|
331
|
+
ids.push(id)
|
|
332
|
+
}
|
|
333
|
+
slice = putEntities(r, event.slice, slice, event.upsert ?? [])
|
|
334
|
+
slice = removeEntities(slice, event.remove ?? [])
|
|
335
|
+
for (const id of ids) after[id] = slice.byId[id]
|
|
336
|
+
const pending = { ...slice.pending }
|
|
337
|
+
for (const id of ids) pending[id] = event.write
|
|
338
|
+
slice = { ...slice, pending, failed: omit(slice.failed, ids) }
|
|
339
|
+
return { ...withSlice(data, event.slice, slice), writes: { ...data.writes, [event.write]: { slice: event.slice, before, after } } }
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function settleWrite(data: Data, writeId: string, rollback: string | null): Data {
|
|
343
|
+
const write = data.writes[writeId]
|
|
344
|
+
if (!write) return data
|
|
345
|
+
let slice = data.slices[write.slice] ?? EMPTY_SLICE
|
|
346
|
+
const ids = Object.keys(write.after)
|
|
347
|
+
const mine = ids.filter((id) => slice.pending[id] === writeId)
|
|
348
|
+
slice = { ...slice, pending: omit(slice.pending, mine) }
|
|
349
|
+
if (rollback !== null) {
|
|
350
|
+
const failed = { ...slice.failed }
|
|
351
|
+
for (const id of ids) {
|
|
352
|
+
// A later write or a server push replaced the optimistic value: theirs stands.
|
|
353
|
+
if (slice.byId[id] !== write.after[id]) continue
|
|
354
|
+
const restore = write.before[id]
|
|
355
|
+
if (restore === undefined) slice = removeEntities(slice, [id])
|
|
356
|
+
else {
|
|
357
|
+
const had = Object.hasOwn(slice.byId, id)
|
|
358
|
+
slice = { ...slice, byId: { ...slice.byId, [id]: restore }, ids: had ? slice.ids : [...slice.ids, id] }
|
|
359
|
+
}
|
|
360
|
+
failed[id] = rollback
|
|
361
|
+
}
|
|
362
|
+
slice = { ...slice, failed }
|
|
363
|
+
}
|
|
364
|
+
const writes = { ...data.writes }
|
|
365
|
+
delete writes[writeId]
|
|
366
|
+
return { ...withSlice(data, write.slice, slice), writes }
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function hydrate(r: Reducer, data: Data, saved: PersistedSlices | null): Data {
|
|
370
|
+
let next: Data = { ...data, status: 'ready' }
|
|
371
|
+
if (!saved) return next
|
|
372
|
+
for (const [name, stored] of Object.entries(saved)) {
|
|
373
|
+
const def = r.defs[name]
|
|
374
|
+
if (!def || def.persist === false || !stored || typeof stored.byId !== 'object') continue
|
|
375
|
+
const slice = next.slices[name] ?? EMPTY_SLICE
|
|
376
|
+
const order = Array.isArray(stored.ids) ? stored.ids : Object.keys(stored.byId)
|
|
377
|
+
// Data written since start-up is newer than the saved copy.
|
|
378
|
+
const missing = order
|
|
379
|
+
.map((id) => stored.byId[id])
|
|
380
|
+
.filter((entity) => entity !== undefined && unstorableField(entity) === null && !Object.hasOwn(slice.byId, def.idOf(entity)))
|
|
381
|
+
next = withSlice(next, name, putEntities(r, name, slice, missing))
|
|
382
|
+
}
|
|
383
|
+
return next
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function reduce(r: Reducer, data: Data, event: InternalEvent): Data {
|
|
387
|
+
switch (event.type) {
|
|
388
|
+
case 'ENTITY.UPSERT':
|
|
389
|
+
return withSlice(data, event.slice, putEntities(r, event.slice, data.slices[event.slice] ?? EMPTY_SLICE, event.entities))
|
|
390
|
+
case 'ENTITY.PATCH': {
|
|
391
|
+
const slice = data.slices[event.slice] ?? EMPTY_SLICE
|
|
392
|
+
const held = slice.byId[event.id]
|
|
393
|
+
if (held === undefined) return data
|
|
394
|
+
return withSlice(data, event.slice, putEntities(r, event.slice, slice, [{ ...(held as object), ...event.patch }]))
|
|
395
|
+
}
|
|
396
|
+
case 'ENTITY.REMOVE': {
|
|
397
|
+
const slice = removeEntities(data.slices[event.slice] ?? EMPTY_SLICE, event.ids)
|
|
398
|
+
return withSlice(data, event.slice, { ...slice, failed: omit(slice.failed, event.ids) })
|
|
399
|
+
}
|
|
400
|
+
case 'WRITE.BEGIN':
|
|
401
|
+
return beginWrite(r, data, event)
|
|
402
|
+
case 'WRITE.CONFIRM':
|
|
403
|
+
return settleWrite(data, event.write, null)
|
|
404
|
+
case 'WRITE.ROLLBACK':
|
|
405
|
+
return settleWrite(data, event.write, event.error ?? 'write failed')
|
|
406
|
+
case 'QUERY.UPDATE':
|
|
407
|
+
return applyQueryUpdate(r, data, event.slice, event.key, event.view)
|
|
408
|
+
case 'STORE.HYDRATED':
|
|
409
|
+
return hydrate(r, data, event.slices)
|
|
410
|
+
case 'STORE.CLEAR':
|
|
411
|
+
return { ...data, slices: Object.fromEntries(Object.keys(r.defs).map((name) => [name, EMPTY_SLICE])), writes: {} }
|
|
412
|
+
default:
|
|
413
|
+
return data
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* The store's chart: `hydrating` until saved data has loaded, then `ready`.
|
|
419
|
+
* Every event folds into one immutable snapshot (`context.data`) that keeps
|
|
420
|
+
* its identity, and that of every untouched slice and entity, when an event
|
|
421
|
+
* changes nothing.
|
|
422
|
+
*/
|
|
423
|
+
export function createStoreMachine(defs: AnySlices) {
|
|
424
|
+
const r: Reducer = { defs }
|
|
425
|
+
return setup({
|
|
426
|
+
types: {
|
|
427
|
+
context: {} as { data: Data },
|
|
428
|
+
events: {} as InternalEvent,
|
|
429
|
+
input: {} as { hydrate: boolean },
|
|
430
|
+
},
|
|
431
|
+
}).createMachine({
|
|
432
|
+
id: 'aeonStore',
|
|
433
|
+
context: ({ input }) => ({
|
|
434
|
+
data: {
|
|
435
|
+
status: input.hydrate ? 'hydrating' : 'ready',
|
|
436
|
+
slices: Object.fromEntries(Object.keys(defs).map((name) => [name, EMPTY_SLICE])),
|
|
437
|
+
writes: {},
|
|
438
|
+
},
|
|
439
|
+
}),
|
|
440
|
+
initial: 'boot',
|
|
441
|
+
states: {
|
|
442
|
+
boot: { always: [{ guard: ({ context }) => context.data.status === 'hydrating', target: 'hydrating' }, { target: 'ready' }] },
|
|
443
|
+
hydrating: { on: { 'STORE.HYDRATED': { target: 'ready', actions: assign(({ context, event }) => ({ data: reduce(r, context.data, event) })) } } },
|
|
444
|
+
ready: {},
|
|
445
|
+
},
|
|
446
|
+
on: {
|
|
447
|
+
'*': { actions: assign(({ context, event }) => (event.type === 'STORE.HYDRATED' ? {} : { data: reduce(r, context.data, event) })) },
|
|
448
|
+
},
|
|
449
|
+
})
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// ---- store ---------------------------------------------------------------
|
|
453
|
+
|
|
454
|
+
function persistable(defs: AnySlices, data: Data): PersistedSlices {
|
|
455
|
+
const out: PersistedSlices = {}
|
|
456
|
+
for (const [name, def] of Object.entries(defs)) {
|
|
457
|
+
if (def.persist === false) continue
|
|
458
|
+
const slice = data.slices[name] ?? EMPTY_SLICE
|
|
459
|
+
const byId: Record<string, unknown> = { ...slice.byId }
|
|
460
|
+
const ids = [...slice.ids]
|
|
461
|
+
// Only confirmed values are saved: a pending write is saved as what it replaced.
|
|
462
|
+
for (const [id, writeId] of Object.entries(slice.pending)) {
|
|
463
|
+
const before = data.writes[writeId]?.before[id]
|
|
464
|
+
if (before === undefined) {
|
|
465
|
+
delete byId[id]
|
|
466
|
+
const at = ids.indexOf(id)
|
|
467
|
+
if (at >= 0) ids.splice(at, 1)
|
|
468
|
+
} else {
|
|
469
|
+
if (!Object.hasOwn(byId, id)) ids.push(id)
|
|
470
|
+
byId[id] = before
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
out[name] = { byId, ids }
|
|
474
|
+
}
|
|
475
|
+
return out
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
function parseSaved(raw: string | null | undefined, options: StorePersistOptions): PersistedSlices | null {
|
|
479
|
+
if (!raw) return null
|
|
480
|
+
try {
|
|
481
|
+
const saved = JSON.parse(raw) as { version?: unknown; slices?: unknown }
|
|
482
|
+
if (typeof saved !== 'object' || saved === null || typeof saved.slices !== 'object' || saved.slices === null) return null
|
|
483
|
+
const slices = saved.slices as PersistedSlices
|
|
484
|
+
if (saved.version === options.version) return slices
|
|
485
|
+
if (typeof saved.version !== 'number' || !options.migrate) return null
|
|
486
|
+
return options.migrate(slices, saved.version) ?? null
|
|
487
|
+
} catch {
|
|
488
|
+
return null
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
export function createAeonStore<S extends AnySlices>(options: StoreOptions<S>): AeonStore<S> {
|
|
493
|
+
if (!isStorableName(options.id)) throw new Error(`refusing store "${options.id}": secrets, keys and spend decisions are never stored`)
|
|
494
|
+
for (const name of Object.keys(options.slices)) {
|
|
495
|
+
if (!isStorableName(name)) throw new Error(`refusing slice "${name}" in store "${options.id}": secrets, keys and spend decisions are never stored`)
|
|
496
|
+
}
|
|
497
|
+
const defs = options.slices as AnySlices
|
|
498
|
+
const persist = options.persist
|
|
499
|
+
const storageKey = `aeon-store:${options.id}`
|
|
500
|
+
const actor = createActor(createStoreMachine(defs), { input: { hydrate: persist !== undefined } })
|
|
501
|
+
const listeners = new Set<() => void>()
|
|
502
|
+
const caches = new Map<string, ChartCache<readonly AnyEntity[], unknown>>()
|
|
503
|
+
const watched = new Map<string, Map<string, () => void>>()
|
|
504
|
+
let data = actor.getSnapshot().context.data
|
|
505
|
+
let stopped = false
|
|
506
|
+
|
|
507
|
+
for (const [name, def] of Object.entries(defs)) {
|
|
508
|
+
if (!def.load) continue
|
|
509
|
+
const load = def.load
|
|
510
|
+
const userTags = def.tags
|
|
511
|
+
caches.set(
|
|
512
|
+
name,
|
|
513
|
+
createChartCache<readonly AnyEntity[], unknown>({
|
|
514
|
+
id: `${options.id}.${name}`,
|
|
515
|
+
load: (query, context) => load(query, context),
|
|
516
|
+
ttl: def.ttl ?? DEFAULT_TTL,
|
|
517
|
+
tags: (query) => [ALL_TAG, ...(userTags?.(query) ?? [])],
|
|
518
|
+
keyOf: def.queryKey ?? defaultKeyOf,
|
|
519
|
+
// Watched queries are pinned; this bounds the unwatched ones.
|
|
520
|
+
maxEntries: 200,
|
|
521
|
+
now: options.now,
|
|
522
|
+
}),
|
|
523
|
+
)
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
let saveTimer: ReturnType<typeof setTimeout> | null = null
|
|
527
|
+
const save = async () => {
|
|
528
|
+
if (saveTimer !== null) clearTimeout(saveTimer)
|
|
529
|
+
saveTimer = null
|
|
530
|
+
if (!persist || stopped || data.status !== 'ready') return
|
|
531
|
+
await persist.adapter.save(storageKey, JSON.stringify({ version: persist.version, slices: persistable(defs, data) }))
|
|
532
|
+
}
|
|
533
|
+
const persisted = Object.keys(defs).filter((name) => defs[name]!.persist !== false)
|
|
534
|
+
const persistChanged = (previous: Data, next: Data) =>
|
|
535
|
+
persisted.some((name) => previous.slices[name]?.byId !== next.slices[name]?.byId || previous.slices[name]?.pending !== next.slices[name]?.pending)
|
|
536
|
+
// Writes that landed while saved data was loading are not on disk yet.
|
|
537
|
+
const heldBeforeHydration = (previous: Data) => persisted.some((name) => (previous.slices[name]?.ids.length ?? 0) > 0)
|
|
538
|
+
|
|
539
|
+
actor.subscribe((snapshot) => {
|
|
540
|
+
const next = snapshot.context.data
|
|
541
|
+
if (next === data) return
|
|
542
|
+
const previous = data
|
|
543
|
+
data = next
|
|
544
|
+
if (persist && next.status === 'ready' && (previous.status === 'ready' ? persistChanged(previous, next) : heldBeforeHydration(previous))) {
|
|
545
|
+
if (saveTimer !== null) clearTimeout(saveTimer)
|
|
546
|
+
saveTimer = setTimeout(() => void save().catch(() => {}), persist.delayMs ?? 250)
|
|
547
|
+
}
|
|
548
|
+
for (const listener of [...listeners]) listener()
|
|
549
|
+
})
|
|
550
|
+
actor.start()
|
|
551
|
+
|
|
552
|
+
const keyOf = (name: string, query: unknown) => (defs[name]!.queryKey ?? defaultKeyOf)(query)
|
|
553
|
+
|
|
554
|
+
const watch = (name: string, query: unknown) => {
|
|
555
|
+
const cache = caches.get(name)
|
|
556
|
+
if (!cache) throw new Error(`slice "${name}" in store "${options.id}" has no load, so it has no queries`)
|
|
557
|
+
const key = keyOf(name, query)
|
|
558
|
+
let forSlice = watched.get(name)
|
|
559
|
+
if (!forSlice) watched.set(name, (forSlice = new Map()))
|
|
560
|
+
if (!forSlice.has(key)) {
|
|
561
|
+
const forward = (view: CacheView<readonly AnyEntity[]>) => actor.send({ type: 'QUERY.UPDATE', slice: name, key, view })
|
|
562
|
+
forSlice.set(key, cache.subscribe(query, forward))
|
|
563
|
+
forward(cache.peek(query))
|
|
564
|
+
}
|
|
565
|
+
return cache
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
const unwatchAll = () => {
|
|
569
|
+
for (const forSlice of watched.values()) for (const stop of forSlice.values()) stop()
|
|
570
|
+
watched.clear()
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
const checkFields = (entities: readonly unknown[]) => {
|
|
574
|
+
for (const entity of entities) {
|
|
575
|
+
const field = unstorableField(entity)
|
|
576
|
+
if (field !== null) throw new Error(`refusing field "${field}" in store "${options.id}": secrets, keys and spend decisions are never stored`)
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
const send = (event: StoreEvent<S>) => {
|
|
581
|
+
if (stopped) return
|
|
582
|
+
const e = event as StoreEvent<AnySlices>
|
|
583
|
+
if ('slice' in e && !Object.hasOwn(defs, e.slice)) throw new Error(`store "${options.id}" has no slice "${e.slice}"`)
|
|
584
|
+
switch (e.type) {
|
|
585
|
+
case 'ENTITY.UPSERT':
|
|
586
|
+
checkFields(e.entities)
|
|
587
|
+
break
|
|
588
|
+
case 'ENTITY.PATCH':
|
|
589
|
+
checkFields([e.patch])
|
|
590
|
+
break
|
|
591
|
+
case 'WRITE.BEGIN':
|
|
592
|
+
checkFields(e.upsert ?? [])
|
|
593
|
+
break
|
|
594
|
+
case 'QUERY.FETCH':
|
|
595
|
+
watch(e.slice, e.query).fetch(e.query)
|
|
596
|
+
return
|
|
597
|
+
case 'QUERY.REFRESH':
|
|
598
|
+
watch(e.slice, e.query).refresh(e.query)
|
|
599
|
+
return
|
|
600
|
+
case 'QUERY.INVALIDATE': {
|
|
601
|
+
const cache = caches.get(e.slice)
|
|
602
|
+
if (!cache) return
|
|
603
|
+
if (e.tag !== undefined) cache.invalidate({ tag: e.tag })
|
|
604
|
+
else if (e.query !== undefined) cache.invalidate({ key: e.query })
|
|
605
|
+
else cache.invalidate({ tag: ALL_TAG })
|
|
606
|
+
return
|
|
607
|
+
}
|
|
608
|
+
case 'STORE.INVALIDATE':
|
|
609
|
+
for (const cache of caches.values()) cache.invalidate({ tag: ALL_TAG })
|
|
610
|
+
return
|
|
611
|
+
case 'STORE.CLEAR':
|
|
612
|
+
unwatchAll()
|
|
613
|
+
for (const cache of caches.values()) cache.clear()
|
|
614
|
+
actor.send(e)
|
|
615
|
+
if (saveTimer !== null) clearTimeout(saveTimer)
|
|
616
|
+
saveTimer = null
|
|
617
|
+
if (persist) void Promise.resolve(persist.adapter.remove(storageKey)).catch(() => {})
|
|
618
|
+
return
|
|
619
|
+
}
|
|
620
|
+
actor.send(e)
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
const results = new Map<string, { view: QueryView; byId: Readonly<Record<string, unknown>>; result: QueryResult<unknown> }>()
|
|
624
|
+
const query = (name: string, q: unknown): QueryResult<unknown> => {
|
|
625
|
+
const key = keyOf(name, q)
|
|
626
|
+
const slice = data.slices[name] ?? EMPTY_SLICE
|
|
627
|
+
const view = slice.queries[key] ?? IDLE_QUERY
|
|
628
|
+
const memoKey = `${name}\u0000${key}`
|
|
629
|
+
const held = results.get(memoKey)
|
|
630
|
+
if (held && held.view === view && held.byId === slice.byId) return held.result
|
|
631
|
+
const entities = view.ids.map((id) => slice.byId[id]).filter((entity) => entity !== undefined)
|
|
632
|
+
const result =
|
|
633
|
+
held && held.view === view && sameEntities(held.result.entities, entities) ? held.result : Object.freeze({ ...view, entities: Object.freeze(entities) })
|
|
634
|
+
results.set(memoKey, { view, byId: slice.byId, result })
|
|
635
|
+
return result
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
const select = <T>(selector: (state: StoreState<S>) => T, equals: (a: T, b: T) => boolean = Object.is): Selection<T> => {
|
|
639
|
+
let from = data
|
|
640
|
+
let value = selector(data as unknown as StoreState<S>)
|
|
641
|
+
const get = () => {
|
|
642
|
+
if (from !== data) {
|
|
643
|
+
const next = selector(data as unknown as StoreState<S>)
|
|
644
|
+
from = data
|
|
645
|
+
if (!equals(value, next)) value = next
|
|
646
|
+
}
|
|
647
|
+
return value
|
|
648
|
+
}
|
|
649
|
+
return {
|
|
650
|
+
get,
|
|
651
|
+
subscribe: (listener) => {
|
|
652
|
+
let last = get()
|
|
653
|
+
return store.subscribe(() => {
|
|
654
|
+
const next = get()
|
|
655
|
+
if (next === last) return
|
|
656
|
+
last = next
|
|
657
|
+
listener(next)
|
|
658
|
+
})
|
|
659
|
+
},
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
let stopSync: (() => void) | void = undefined
|
|
664
|
+
const ready = persist
|
|
665
|
+
? Promise.resolve()
|
|
666
|
+
.then(() => persist.adapter.load(storageKey))
|
|
667
|
+
.then((raw) => parseSaved(raw, persist), () => null)
|
|
668
|
+
.then((slices) => {
|
|
669
|
+
if (!stopped) actor.send({ type: 'STORE.HYDRATED', slices })
|
|
670
|
+
})
|
|
671
|
+
: Promise.resolve()
|
|
672
|
+
|
|
673
|
+
const store: AeonStore<S> = {
|
|
674
|
+
id: options.id,
|
|
675
|
+
getSnapshot: () => data as unknown as StoreState<S>,
|
|
676
|
+
subscribe: (listener) => {
|
|
677
|
+
listeners.add(listener)
|
|
678
|
+
return () => void listeners.delete(listener)
|
|
679
|
+
},
|
|
680
|
+
send,
|
|
681
|
+
select,
|
|
682
|
+
entity: (name, id) => (data.slices[name] ?? EMPTY_SLICE).byId[id] as EntityOf<S[typeof name]> | undefined,
|
|
683
|
+
query: (name, q) => query(name, q) as QueryResult<EntityOf<S[typeof name]>>,
|
|
684
|
+
ready,
|
|
685
|
+
flush: () => ready.then(save),
|
|
686
|
+
stop: () => {
|
|
687
|
+
if (stopped) return
|
|
688
|
+
stopped = true
|
|
689
|
+
if (saveTimer !== null) clearTimeout(saveTimer)
|
|
690
|
+
if (typeof stopSync === 'function') stopSync()
|
|
691
|
+
unwatchAll()
|
|
692
|
+
actor.stop()
|
|
693
|
+
listeners.clear()
|
|
694
|
+
},
|
|
695
|
+
actor: fromCallback<StoreEvent<S>, { select: (state: StoreState<S>) => unknown; equals?: (a: unknown, b: unknown) => boolean }>(
|
|
696
|
+
({ input, sendBack, receive }) => {
|
|
697
|
+
const selection = select(input.select, input.equals)
|
|
698
|
+
const report = (value: unknown) => sendBack({ type: 'STORE.UPDATE', store: options.id, value } satisfies StoreUpdate<unknown>)
|
|
699
|
+
report(selection.get())
|
|
700
|
+
receive((event) => send(event))
|
|
701
|
+
return selection.subscribe(report)
|
|
702
|
+
},
|
|
703
|
+
),
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
stopSync = options.sync?.(send)
|
|
707
|
+
return store
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
function sameEntities(a: readonly unknown[], b: readonly unknown[]) {
|
|
711
|
+
return a.length === b.length && a.every((entity, i) => entity === b[i])
|
|
712
|
+
}
|