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.
@@ -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
+ }