aeon-ui-engine 1.9.0 → 1.10.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +13 -0
- package/README.md +33 -0
- package/package.json +1 -1
- package/packages/core/vitest.config.ts +1 -0
- 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/primitives/vitest.config.ts +1 -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
- package/packages/react/vitest.config.ts +1 -0
- package/packages/tree/vitest.config.ts +1 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,19 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [1.10.1] - 2026-10-22
|
|
6
|
+
|
|
7
|
+
### Changed
|
|
8
|
+
|
|
9
|
+
- Patch release so consumers can install a fresh `aeon-ui-engine` after 1.10.0.
|
|
10
|
+
|
|
11
|
+
## [1.10.0] - 2026-10-21
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
|
|
15
|
+
- **App store (`@aeon-ui/primitives`).** `createAeonStore({ id, slices, persist?, sync? })` keeps an app's data in one place: each entity once, by id, in its slice (`defineSlice({ idOf, load?, ttl?, tags? })`). Every change is an event: `ENTITY.UPSERT`, `ENTITY.PATCH` and `ENTITY.REMOVE`, plus optimistic `WRITE.BEGIN`, which marks entities pending until `WRITE.CONFIRM` or `WRITE.ROLLBACK` (restores the old value and marks it failed). Queries run on the chart cache (`QUERY.FETCH`, `QUERY.REFRESH`, `QUERY.INVALIDATE`, `STORE.INVALIDATE`). `select`, `query` and `entity` return the same object until their data changes. `queryAttrs` and `entityAttrs` project state onto `data-aeon-state`. Persistence saves only confirmed values through an adapter (`memoryAdapter`, `webStorageAdapter`), with `version` and `migrate`. `sync` feeds server pushes in as events, and `STORE.CLEAR` wipes memory, running loads and the saved copy. Charts read it by invoking `store.actor`. Store ids, slice names and entity fields that name secrets, keys or spend decisions are refused.
|
|
16
|
+
- **`useAeonSelector` and `useAeonQuery` (`@aeon-ui/react`).** Read the store; a component re-renders only when the value it selects changes.
|
|
17
|
+
|
|
5
18
|
## [1.9.0] - 2026-10-21
|
|
6
19
|
|
|
7
20
|
### Added
|
package/README.md
CHANGED
|
@@ -176,6 +176,39 @@ function AsyncSurface() {
|
|
|
176
176
|
|
|
177
177
|
See [docs/PRIMITIVE_COVERAGE.md](./docs/PRIMITIVE_COVERAGE.md).
|
|
178
178
|
|
|
179
|
+
## App data: one store
|
|
180
|
+
|
|
181
|
+
Each app keeps its data in one `createAeonStore`: every entity once, by id, in its slice. Components never copy it. They read through selectors and show state through `data-aeon-*` attributes. Every change is an event sent to the store.
|
|
182
|
+
|
|
183
|
+
```tsx
|
|
184
|
+
import { createAeonStore, defineSlice, entityAttrs, queryAttrs, webStorageAdapter } from '@aeon-ui/primitives'
|
|
185
|
+
import { useAeonQuery, useAeonSelector } from '@aeon-ui/react'
|
|
186
|
+
|
|
187
|
+
export const store = createAeonStore({
|
|
188
|
+
id: 'wallet-app',
|
|
189
|
+
slices: { payments: defineSlice<Payment>({ idOf: (p) => p.id, load: (query) => api.payments(query), ttl: 30_000 }) },
|
|
190
|
+
persist: { adapter: webStorageAdapter(), version: 1 },
|
|
191
|
+
sync: (send) => api.onPayment((p) => send({ type: 'ENTITY.UPSERT', slice: 'payments', entities: [p] })),
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
function Payments() {
|
|
195
|
+
const result = useAeonQuery(store, 'payments', 'recent') // loads once per key, shared by every reader
|
|
196
|
+
return <ul {...queryAttrs(result)}>{result.entities.map((p) => <PaymentRow key={p.id} id={p.id} />)}</ul>
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function PaymentRow({ id }: { id: string }) {
|
|
200
|
+
const payment = useAeonSelector(store, (s) => s.slices.payments.byId[id]) // re-renders only when this payment changes
|
|
201
|
+
const slice = useAeonSelector(store, (s) => s.slices.payments)
|
|
202
|
+
return <li {...entityAttrs(slice, 'payments', id)}>{payment?.amount}</li>
|
|
203
|
+
}
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
- **Writes:** `ENTITY.UPSERT`, `ENTITY.PATCH` and `ENTITY.REMOVE` change entities directly. `WRITE.BEGIN` applies a change at once and marks it `pending` until `WRITE.CONFIRM`, or until `WRITE.ROLLBACK` puts the old value back and marks the entity `failed`.
|
|
207
|
+
- **Queries:** `QUERY.FETCH`, `QUERY.REFRESH` and `QUERY.INVALIDATE { query | tag }` run on the chart cache's states (`idle`, `loading`, `ready`, `stale`, `refreshing`, `failed`). `STORE.INVALIDATE` reloads every watched query (for reconnect or window focus). `STORE.CLEAR` drops all data, running loads and the saved copy (for lock or account switch).
|
|
208
|
+
- **Persistence:** only confirmed values are saved, after a short quiet delay. A `version` bump runs `migrate`; without one, older saved data is dropped. Data written during start-up wins over the saved copy. Slices with `persist: false` stay in memory.
|
|
209
|
+
- **Charts:** invoke `store.actor` with `input: { select }` to get `STORE.UPDATE` whenever the selection changes. Store events sent to it are forwarded to the store.
|
|
210
|
+
- **Never stored:** store ids, slice names and entity fields at any depth that name secrets, keys, seeds, passwords or spend are refused.
|
|
211
|
+
|
|
179
212
|
## Example
|
|
180
213
|
|
|
181
214
|
```tsx
|
package/package.json
CHANGED
|
@@ -0,0 +1,493 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createChartCache,
|
|
3
|
+
isCacheableId
|
|
4
|
+
} from "./chunk-5BNAV4S2.js";
|
|
5
|
+
|
|
6
|
+
// src/machines/store.ts
|
|
7
|
+
import { stateToAttr } from "@aeon-ui/core";
|
|
8
|
+
import { assign, createActor, fromCallback, setup } from "xstate";
|
|
9
|
+
function defineSlice(options) {
|
|
10
|
+
return options;
|
|
11
|
+
}
|
|
12
|
+
var DEFAULT_TTL = 3e4;
|
|
13
|
+
var ALL_TAG = "*";
|
|
14
|
+
var EMPTY_IDS = Object.freeze([]);
|
|
15
|
+
var IDLE_QUERY = Object.freeze({ state: "idle", ids: EMPTY_IDS, error: null, loadedAt: null });
|
|
16
|
+
var EMPTY_SLICE = Object.freeze({ byId: {}, ids: EMPTY_IDS, queries: {}, pending: {}, failed: {} });
|
|
17
|
+
var defaultKeyOf = (key) => typeof key === "string" ? key : JSON.stringify(key);
|
|
18
|
+
function shallowEqual(a, b) {
|
|
19
|
+
if (Object.is(a, b)) return true;
|
|
20
|
+
if (typeof a !== "object" || typeof b !== "object" || a === null || b === null) return false;
|
|
21
|
+
if (Array.isArray(a) !== Array.isArray(b)) return false;
|
|
22
|
+
const keysA = Object.keys(a);
|
|
23
|
+
if (keysA.length !== Object.keys(b).length) return false;
|
|
24
|
+
return keysA.every((key) => Object.hasOwn(b, key) && Object.is(a[key], b[key]));
|
|
25
|
+
}
|
|
26
|
+
function isStorableName(name) {
|
|
27
|
+
return isCacheableId(name.replace(/([a-z0-9])([A-Z])/g, "$1 $2"));
|
|
28
|
+
}
|
|
29
|
+
function unstorableField(value, depth = 0) {
|
|
30
|
+
if (depth > 8 || typeof value !== "object" || value === null) return null;
|
|
31
|
+
for (const [key, inner] of Object.entries(value)) {
|
|
32
|
+
if (!Array.isArray(value) && !isStorableName(key)) return key;
|
|
33
|
+
const nested = unstorableField(inner, depth + 1);
|
|
34
|
+
if (nested !== null) return nested;
|
|
35
|
+
}
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
function queryAttrs(result) {
|
|
39
|
+
return { "data-aeon-state": stateToAttr(result.state), "data-aeon-count": String(result.ids.length) };
|
|
40
|
+
}
|
|
41
|
+
function entityAttrs(slice, sliceName, id) {
|
|
42
|
+
const state = slice.pending[id] !== void 0 ? "pending" : slice.failed[id] !== void 0 ? "failed" : "settled";
|
|
43
|
+
return { "data-aeon-entity": sliceName, "data-aeon-state": state };
|
|
44
|
+
}
|
|
45
|
+
function memoryAdapter(initial = {}) {
|
|
46
|
+
const data = new Map(Object.entries(initial));
|
|
47
|
+
return {
|
|
48
|
+
data,
|
|
49
|
+
load: (key) => data.get(key) ?? null,
|
|
50
|
+
save: (key, value) => void data.set(key, value),
|
|
51
|
+
remove: (key) => void data.delete(key)
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
function webStorageAdapter(storage = globalThis.localStorage) {
|
|
55
|
+
return {
|
|
56
|
+
load: (key) => storage.getItem(key),
|
|
57
|
+
save: (key, value) => storage.setItem(key, value),
|
|
58
|
+
remove: (key) => storage.removeItem(key)
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
function omit(record, keys) {
|
|
62
|
+
let next = null;
|
|
63
|
+
for (const key of keys) {
|
|
64
|
+
if (!Object.hasOwn(record, key)) continue;
|
|
65
|
+
next ??= { ...record };
|
|
66
|
+
delete next[key];
|
|
67
|
+
}
|
|
68
|
+
return next ?? record;
|
|
69
|
+
}
|
|
70
|
+
function withSlice(data, name, slice) {
|
|
71
|
+
return slice === data.slices[name] ? data : { ...data, slices: { ...data.slices, [name]: slice } };
|
|
72
|
+
}
|
|
73
|
+
function putEntities(r, name, slice, entities) {
|
|
74
|
+
const def = r.defs[name];
|
|
75
|
+
const equals = def.equals ?? shallowEqual;
|
|
76
|
+
let byId = null;
|
|
77
|
+
let added = null;
|
|
78
|
+
let failed = slice.failed;
|
|
79
|
+
for (const entity of entities) {
|
|
80
|
+
const id = def.idOf(entity);
|
|
81
|
+
const held = (byId ?? slice.byId)[id];
|
|
82
|
+
failed = omit(failed, [id]);
|
|
83
|
+
if (held !== void 0 && (held === entity || equals(held, entity))) continue;
|
|
84
|
+
byId ??= { ...slice.byId };
|
|
85
|
+
if (held === void 0 && !Object.hasOwn(byId, id)) (added ??= []).push(id);
|
|
86
|
+
byId[id] = entity;
|
|
87
|
+
}
|
|
88
|
+
if (!byId && failed === slice.failed) return slice;
|
|
89
|
+
return { ...slice, byId: byId ?? slice.byId, ids: added ? [...slice.ids, ...added] : slice.ids, failed };
|
|
90
|
+
}
|
|
91
|
+
function removeEntities(slice, ids) {
|
|
92
|
+
const byId = omit(slice.byId, ids);
|
|
93
|
+
if (byId === slice.byId) return slice;
|
|
94
|
+
const gone = new Set(ids);
|
|
95
|
+
return { ...slice, byId, ids: slice.ids.filter((id) => !gone.has(id)) };
|
|
96
|
+
}
|
|
97
|
+
function sameIds(a, b) {
|
|
98
|
+
return a.length === b.length && a.every((id, i) => id === b[i]);
|
|
99
|
+
}
|
|
100
|
+
function applyQueryUpdate(r, data, name, key, view) {
|
|
101
|
+
const def = r.defs[name];
|
|
102
|
+
let slice = data.slices[name] ?? EMPTY_SLICE;
|
|
103
|
+
let writes = data.writes;
|
|
104
|
+
const previous = slice.queries[key] ?? IDLE_QUERY;
|
|
105
|
+
let ids = previous.ids;
|
|
106
|
+
if (view.data !== void 0) {
|
|
107
|
+
const incoming = view.data;
|
|
108
|
+
const settled = [];
|
|
109
|
+
for (const entity of incoming) {
|
|
110
|
+
const id = def.idOf(entity);
|
|
111
|
+
const write = slice.pending[id];
|
|
112
|
+
if (write !== void 0 && writes[write]) {
|
|
113
|
+
writes = { ...writes, [write]: { ...writes[write], before: { ...writes[write].before, [id]: entity } } };
|
|
114
|
+
} else settled.push(entity);
|
|
115
|
+
}
|
|
116
|
+
slice = putEntities(r, name, slice, settled);
|
|
117
|
+
const nextIds = incoming.map(def.idOf);
|
|
118
|
+
if (!sameIds(nextIds, ids)) ids = nextIds;
|
|
119
|
+
} else if (view.state === "idle") ids = EMPTY_IDS;
|
|
120
|
+
const query = previous.state === view.state && previous.ids === ids && previous.error === view.error && previous.loadedAt === view.loadedAt ? previous : Object.freeze({ state: view.state, ids, error: view.error, loadedAt: view.loadedAt });
|
|
121
|
+
if (query !== previous) slice = { ...slice, queries: { ...slice.queries, [key]: query } };
|
|
122
|
+
const next = withSlice(data, name, slice);
|
|
123
|
+
return writes === data.writes ? next : { ...next, writes };
|
|
124
|
+
}
|
|
125
|
+
function beginWrite(r, data, event) {
|
|
126
|
+
if (data.writes[event.write]) return data;
|
|
127
|
+
const def = r.defs[event.slice];
|
|
128
|
+
let slice = data.slices[event.slice] ?? EMPTY_SLICE;
|
|
129
|
+
const before = {};
|
|
130
|
+
const after = {};
|
|
131
|
+
const ids = [];
|
|
132
|
+
for (const entity of event.upsert ?? []) {
|
|
133
|
+
const id = def.idOf(entity);
|
|
134
|
+
before[id] = slice.byId[id];
|
|
135
|
+
ids.push(id);
|
|
136
|
+
}
|
|
137
|
+
for (const id of event.remove ?? []) {
|
|
138
|
+
before[id] = slice.byId[id];
|
|
139
|
+
ids.push(id);
|
|
140
|
+
}
|
|
141
|
+
slice = putEntities(r, event.slice, slice, event.upsert ?? []);
|
|
142
|
+
slice = removeEntities(slice, event.remove ?? []);
|
|
143
|
+
for (const id of ids) after[id] = slice.byId[id];
|
|
144
|
+
const pending = { ...slice.pending };
|
|
145
|
+
for (const id of ids) pending[id] = event.write;
|
|
146
|
+
slice = { ...slice, pending, failed: omit(slice.failed, ids) };
|
|
147
|
+
return { ...withSlice(data, event.slice, slice), writes: { ...data.writes, [event.write]: { slice: event.slice, before, after } } };
|
|
148
|
+
}
|
|
149
|
+
function settleWrite(data, writeId, rollback) {
|
|
150
|
+
const write = data.writes[writeId];
|
|
151
|
+
if (!write) return data;
|
|
152
|
+
let slice = data.slices[write.slice] ?? EMPTY_SLICE;
|
|
153
|
+
const ids = Object.keys(write.after);
|
|
154
|
+
const mine = ids.filter((id) => slice.pending[id] === writeId);
|
|
155
|
+
slice = { ...slice, pending: omit(slice.pending, mine) };
|
|
156
|
+
if (rollback !== null) {
|
|
157
|
+
const failed = { ...slice.failed };
|
|
158
|
+
for (const id of ids) {
|
|
159
|
+
if (slice.byId[id] !== write.after[id]) continue;
|
|
160
|
+
const restore = write.before[id];
|
|
161
|
+
if (restore === void 0) slice = removeEntities(slice, [id]);
|
|
162
|
+
else {
|
|
163
|
+
const had = Object.hasOwn(slice.byId, id);
|
|
164
|
+
slice = { ...slice, byId: { ...slice.byId, [id]: restore }, ids: had ? slice.ids : [...slice.ids, id] };
|
|
165
|
+
}
|
|
166
|
+
failed[id] = rollback;
|
|
167
|
+
}
|
|
168
|
+
slice = { ...slice, failed };
|
|
169
|
+
}
|
|
170
|
+
const writes = { ...data.writes };
|
|
171
|
+
delete writes[writeId];
|
|
172
|
+
return { ...withSlice(data, write.slice, slice), writes };
|
|
173
|
+
}
|
|
174
|
+
function hydrate(r, data, saved) {
|
|
175
|
+
let next = { ...data, status: "ready" };
|
|
176
|
+
if (!saved) return next;
|
|
177
|
+
for (const [name, stored] of Object.entries(saved)) {
|
|
178
|
+
const def = r.defs[name];
|
|
179
|
+
if (!def || def.persist === false || !stored || typeof stored.byId !== "object") continue;
|
|
180
|
+
const slice = next.slices[name] ?? EMPTY_SLICE;
|
|
181
|
+
const order = Array.isArray(stored.ids) ? stored.ids : Object.keys(stored.byId);
|
|
182
|
+
const missing = order.map((id) => stored.byId[id]).filter((entity) => entity !== void 0 && unstorableField(entity) === null && !Object.hasOwn(slice.byId, def.idOf(entity)));
|
|
183
|
+
next = withSlice(next, name, putEntities(r, name, slice, missing));
|
|
184
|
+
}
|
|
185
|
+
return next;
|
|
186
|
+
}
|
|
187
|
+
function reduce(r, data, event) {
|
|
188
|
+
switch (event.type) {
|
|
189
|
+
case "ENTITY.UPSERT":
|
|
190
|
+
return withSlice(data, event.slice, putEntities(r, event.slice, data.slices[event.slice] ?? EMPTY_SLICE, event.entities));
|
|
191
|
+
case "ENTITY.PATCH": {
|
|
192
|
+
const slice = data.slices[event.slice] ?? EMPTY_SLICE;
|
|
193
|
+
const held = slice.byId[event.id];
|
|
194
|
+
if (held === void 0) return data;
|
|
195
|
+
return withSlice(data, event.slice, putEntities(r, event.slice, slice, [{ ...held, ...event.patch }]));
|
|
196
|
+
}
|
|
197
|
+
case "ENTITY.REMOVE": {
|
|
198
|
+
const slice = removeEntities(data.slices[event.slice] ?? EMPTY_SLICE, event.ids);
|
|
199
|
+
return withSlice(data, event.slice, { ...slice, failed: omit(slice.failed, event.ids) });
|
|
200
|
+
}
|
|
201
|
+
case "WRITE.BEGIN":
|
|
202
|
+
return beginWrite(r, data, event);
|
|
203
|
+
case "WRITE.CONFIRM":
|
|
204
|
+
return settleWrite(data, event.write, null);
|
|
205
|
+
case "WRITE.ROLLBACK":
|
|
206
|
+
return settleWrite(data, event.write, event.error ?? "write failed");
|
|
207
|
+
case "QUERY.UPDATE":
|
|
208
|
+
return applyQueryUpdate(r, data, event.slice, event.key, event.view);
|
|
209
|
+
case "STORE.HYDRATED":
|
|
210
|
+
return hydrate(r, data, event.slices);
|
|
211
|
+
case "STORE.CLEAR":
|
|
212
|
+
return { ...data, slices: Object.fromEntries(Object.keys(r.defs).map((name) => [name, EMPTY_SLICE])), writes: {} };
|
|
213
|
+
default:
|
|
214
|
+
return data;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
function createStoreMachine(defs) {
|
|
218
|
+
const r = { defs };
|
|
219
|
+
return setup({
|
|
220
|
+
types: {
|
|
221
|
+
context: {},
|
|
222
|
+
events: {},
|
|
223
|
+
input: {}
|
|
224
|
+
}
|
|
225
|
+
}).createMachine({
|
|
226
|
+
id: "aeonStore",
|
|
227
|
+
context: ({ input }) => ({
|
|
228
|
+
data: {
|
|
229
|
+
status: input.hydrate ? "hydrating" : "ready",
|
|
230
|
+
slices: Object.fromEntries(Object.keys(defs).map((name) => [name, EMPTY_SLICE])),
|
|
231
|
+
writes: {}
|
|
232
|
+
}
|
|
233
|
+
}),
|
|
234
|
+
initial: "boot",
|
|
235
|
+
states: {
|
|
236
|
+
boot: { always: [{ guard: ({ context }) => context.data.status === "hydrating", target: "hydrating" }, { target: "ready" }] },
|
|
237
|
+
hydrating: { on: { "STORE.HYDRATED": { target: "ready", actions: assign(({ context, event }) => ({ data: reduce(r, context.data, event) })) } } },
|
|
238
|
+
ready: {}
|
|
239
|
+
},
|
|
240
|
+
on: {
|
|
241
|
+
"*": { actions: assign(({ context, event }) => event.type === "STORE.HYDRATED" ? {} : { data: reduce(r, context.data, event) }) }
|
|
242
|
+
}
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
function persistable(defs, data) {
|
|
246
|
+
const out = {};
|
|
247
|
+
for (const [name, def] of Object.entries(defs)) {
|
|
248
|
+
if (def.persist === false) continue;
|
|
249
|
+
const slice = data.slices[name] ?? EMPTY_SLICE;
|
|
250
|
+
const byId = { ...slice.byId };
|
|
251
|
+
const ids = [...slice.ids];
|
|
252
|
+
for (const [id, writeId] of Object.entries(slice.pending)) {
|
|
253
|
+
const before = data.writes[writeId]?.before[id];
|
|
254
|
+
if (before === void 0) {
|
|
255
|
+
delete byId[id];
|
|
256
|
+
const at = ids.indexOf(id);
|
|
257
|
+
if (at >= 0) ids.splice(at, 1);
|
|
258
|
+
} else {
|
|
259
|
+
if (!Object.hasOwn(byId, id)) ids.push(id);
|
|
260
|
+
byId[id] = before;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
out[name] = { byId, ids };
|
|
264
|
+
}
|
|
265
|
+
return out;
|
|
266
|
+
}
|
|
267
|
+
function parseSaved(raw, options) {
|
|
268
|
+
if (!raw) return null;
|
|
269
|
+
try {
|
|
270
|
+
const saved = JSON.parse(raw);
|
|
271
|
+
if (typeof saved !== "object" || saved === null || typeof saved.slices !== "object" || saved.slices === null) return null;
|
|
272
|
+
const slices = saved.slices;
|
|
273
|
+
if (saved.version === options.version) return slices;
|
|
274
|
+
if (typeof saved.version !== "number" || !options.migrate) return null;
|
|
275
|
+
return options.migrate(slices, saved.version) ?? null;
|
|
276
|
+
} catch {
|
|
277
|
+
return null;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
function createAeonStore(options) {
|
|
281
|
+
if (!isStorableName(options.id)) throw new Error(`refusing store "${options.id}": secrets, keys and spend decisions are never stored`);
|
|
282
|
+
for (const name of Object.keys(options.slices)) {
|
|
283
|
+
if (!isStorableName(name)) throw new Error(`refusing slice "${name}" in store "${options.id}": secrets, keys and spend decisions are never stored`);
|
|
284
|
+
}
|
|
285
|
+
const defs = options.slices;
|
|
286
|
+
const persist = options.persist;
|
|
287
|
+
const storageKey = `aeon-store:${options.id}`;
|
|
288
|
+
const actor = createActor(createStoreMachine(defs), { input: { hydrate: persist !== void 0 } });
|
|
289
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
290
|
+
const caches = /* @__PURE__ */ new Map();
|
|
291
|
+
const watched = /* @__PURE__ */ new Map();
|
|
292
|
+
let data = actor.getSnapshot().context.data;
|
|
293
|
+
let stopped = false;
|
|
294
|
+
for (const [name, def] of Object.entries(defs)) {
|
|
295
|
+
if (!def.load) continue;
|
|
296
|
+
const load = def.load;
|
|
297
|
+
const userTags = def.tags;
|
|
298
|
+
caches.set(
|
|
299
|
+
name,
|
|
300
|
+
createChartCache({
|
|
301
|
+
id: `${options.id}.${name}`,
|
|
302
|
+
load: (query2, context) => load(query2, context),
|
|
303
|
+
ttl: def.ttl ?? DEFAULT_TTL,
|
|
304
|
+
tags: (query2) => [ALL_TAG, ...userTags?.(query2) ?? []],
|
|
305
|
+
keyOf: def.queryKey ?? defaultKeyOf,
|
|
306
|
+
// Watched queries are pinned; this bounds the unwatched ones.
|
|
307
|
+
maxEntries: 200,
|
|
308
|
+
now: options.now
|
|
309
|
+
})
|
|
310
|
+
);
|
|
311
|
+
}
|
|
312
|
+
let saveTimer = null;
|
|
313
|
+
const save = async () => {
|
|
314
|
+
if (saveTimer !== null) clearTimeout(saveTimer);
|
|
315
|
+
saveTimer = null;
|
|
316
|
+
if (!persist || stopped || data.status !== "ready") return;
|
|
317
|
+
await persist.adapter.save(storageKey, JSON.stringify({ version: persist.version, slices: persistable(defs, data) }));
|
|
318
|
+
};
|
|
319
|
+
const persisted = Object.keys(defs).filter((name) => defs[name].persist !== false);
|
|
320
|
+
const persistChanged = (previous, next) => persisted.some((name) => previous.slices[name]?.byId !== next.slices[name]?.byId || previous.slices[name]?.pending !== next.slices[name]?.pending);
|
|
321
|
+
const heldBeforeHydration = (previous) => persisted.some((name) => (previous.slices[name]?.ids.length ?? 0) > 0);
|
|
322
|
+
actor.subscribe((snapshot) => {
|
|
323
|
+
const next = snapshot.context.data;
|
|
324
|
+
if (next === data) return;
|
|
325
|
+
const previous = data;
|
|
326
|
+
data = next;
|
|
327
|
+
if (persist && next.status === "ready" && (previous.status === "ready" ? persistChanged(previous, next) : heldBeforeHydration(previous))) {
|
|
328
|
+
if (saveTimer !== null) clearTimeout(saveTimer);
|
|
329
|
+
saveTimer = setTimeout(() => void save().catch(() => {
|
|
330
|
+
}), persist.delayMs ?? 250);
|
|
331
|
+
}
|
|
332
|
+
for (const listener of [...listeners]) listener();
|
|
333
|
+
});
|
|
334
|
+
actor.start();
|
|
335
|
+
const keyOf = (name, query2) => (defs[name].queryKey ?? defaultKeyOf)(query2);
|
|
336
|
+
const watch = (name, query2) => {
|
|
337
|
+
const cache = caches.get(name);
|
|
338
|
+
if (!cache) throw new Error(`slice "${name}" in store "${options.id}" has no load, so it has no queries`);
|
|
339
|
+
const key = keyOf(name, query2);
|
|
340
|
+
let forSlice = watched.get(name);
|
|
341
|
+
if (!forSlice) watched.set(name, forSlice = /* @__PURE__ */ new Map());
|
|
342
|
+
if (!forSlice.has(key)) {
|
|
343
|
+
const forward = (view) => actor.send({ type: "QUERY.UPDATE", slice: name, key, view });
|
|
344
|
+
forSlice.set(key, cache.subscribe(query2, forward));
|
|
345
|
+
forward(cache.peek(query2));
|
|
346
|
+
}
|
|
347
|
+
return cache;
|
|
348
|
+
};
|
|
349
|
+
const unwatchAll = () => {
|
|
350
|
+
for (const forSlice of watched.values()) for (const stop of forSlice.values()) stop();
|
|
351
|
+
watched.clear();
|
|
352
|
+
};
|
|
353
|
+
const checkFields = (entities) => {
|
|
354
|
+
for (const entity of entities) {
|
|
355
|
+
const field = unstorableField(entity);
|
|
356
|
+
if (field !== null) throw new Error(`refusing field "${field}" in store "${options.id}": secrets, keys and spend decisions are never stored`);
|
|
357
|
+
}
|
|
358
|
+
};
|
|
359
|
+
const send = (event) => {
|
|
360
|
+
if (stopped) return;
|
|
361
|
+
const e = event;
|
|
362
|
+
if ("slice" in e && !Object.hasOwn(defs, e.slice)) throw new Error(`store "${options.id}" has no slice "${e.slice}"`);
|
|
363
|
+
switch (e.type) {
|
|
364
|
+
case "ENTITY.UPSERT":
|
|
365
|
+
checkFields(e.entities);
|
|
366
|
+
break;
|
|
367
|
+
case "ENTITY.PATCH":
|
|
368
|
+
checkFields([e.patch]);
|
|
369
|
+
break;
|
|
370
|
+
case "WRITE.BEGIN":
|
|
371
|
+
checkFields(e.upsert ?? []);
|
|
372
|
+
break;
|
|
373
|
+
case "QUERY.FETCH":
|
|
374
|
+
watch(e.slice, e.query).fetch(e.query);
|
|
375
|
+
return;
|
|
376
|
+
case "QUERY.REFRESH":
|
|
377
|
+
watch(e.slice, e.query).refresh(e.query);
|
|
378
|
+
return;
|
|
379
|
+
case "QUERY.INVALIDATE": {
|
|
380
|
+
const cache = caches.get(e.slice);
|
|
381
|
+
if (!cache) return;
|
|
382
|
+
if (e.tag !== void 0) cache.invalidate({ tag: e.tag });
|
|
383
|
+
else if (e.query !== void 0) cache.invalidate({ key: e.query });
|
|
384
|
+
else cache.invalidate({ tag: ALL_TAG });
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
case "STORE.INVALIDATE":
|
|
388
|
+
for (const cache of caches.values()) cache.invalidate({ tag: ALL_TAG });
|
|
389
|
+
return;
|
|
390
|
+
case "STORE.CLEAR":
|
|
391
|
+
unwatchAll();
|
|
392
|
+
for (const cache of caches.values()) cache.clear();
|
|
393
|
+
actor.send(e);
|
|
394
|
+
if (saveTimer !== null) clearTimeout(saveTimer);
|
|
395
|
+
saveTimer = null;
|
|
396
|
+
if (persist) void Promise.resolve(persist.adapter.remove(storageKey)).catch(() => {
|
|
397
|
+
});
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
actor.send(e);
|
|
401
|
+
};
|
|
402
|
+
const results = /* @__PURE__ */ new Map();
|
|
403
|
+
const query = (name, q) => {
|
|
404
|
+
const key = keyOf(name, q);
|
|
405
|
+
const slice = data.slices[name] ?? EMPTY_SLICE;
|
|
406
|
+
const view = slice.queries[key] ?? IDLE_QUERY;
|
|
407
|
+
const memoKey = `${name}\0${key}`;
|
|
408
|
+
const held = results.get(memoKey);
|
|
409
|
+
if (held && held.view === view && held.byId === slice.byId) return held.result;
|
|
410
|
+
const entities = view.ids.map((id) => slice.byId[id]).filter((entity) => entity !== void 0);
|
|
411
|
+
const result = held && held.view === view && sameEntities(held.result.entities, entities) ? held.result : Object.freeze({ ...view, entities: Object.freeze(entities) });
|
|
412
|
+
results.set(memoKey, { view, byId: slice.byId, result });
|
|
413
|
+
return result;
|
|
414
|
+
};
|
|
415
|
+
const select = (selector, equals = Object.is) => {
|
|
416
|
+
let from = data;
|
|
417
|
+
let value = selector(data);
|
|
418
|
+
const get = () => {
|
|
419
|
+
if (from !== data) {
|
|
420
|
+
const next = selector(data);
|
|
421
|
+
from = data;
|
|
422
|
+
if (!equals(value, next)) value = next;
|
|
423
|
+
}
|
|
424
|
+
return value;
|
|
425
|
+
};
|
|
426
|
+
return {
|
|
427
|
+
get,
|
|
428
|
+
subscribe: (listener) => {
|
|
429
|
+
let last = get();
|
|
430
|
+
return store.subscribe(() => {
|
|
431
|
+
const next = get();
|
|
432
|
+
if (next === last) return;
|
|
433
|
+
last = next;
|
|
434
|
+
listener(next);
|
|
435
|
+
});
|
|
436
|
+
}
|
|
437
|
+
};
|
|
438
|
+
};
|
|
439
|
+
let stopSync = void 0;
|
|
440
|
+
const ready = persist ? Promise.resolve().then(() => persist.adapter.load(storageKey)).then((raw) => parseSaved(raw, persist), () => null).then((slices) => {
|
|
441
|
+
if (!stopped) actor.send({ type: "STORE.HYDRATED", slices });
|
|
442
|
+
}) : Promise.resolve();
|
|
443
|
+
const store = {
|
|
444
|
+
id: options.id,
|
|
445
|
+
getSnapshot: () => data,
|
|
446
|
+
subscribe: (listener) => {
|
|
447
|
+
listeners.add(listener);
|
|
448
|
+
return () => void listeners.delete(listener);
|
|
449
|
+
},
|
|
450
|
+
send,
|
|
451
|
+
select,
|
|
452
|
+
entity: (name, id) => (data.slices[name] ?? EMPTY_SLICE).byId[id],
|
|
453
|
+
query: (name, q) => query(name, q),
|
|
454
|
+
ready,
|
|
455
|
+
flush: () => ready.then(save),
|
|
456
|
+
stop: () => {
|
|
457
|
+
if (stopped) return;
|
|
458
|
+
stopped = true;
|
|
459
|
+
if (saveTimer !== null) clearTimeout(saveTimer);
|
|
460
|
+
if (typeof stopSync === "function") stopSync();
|
|
461
|
+
unwatchAll();
|
|
462
|
+
actor.stop();
|
|
463
|
+
listeners.clear();
|
|
464
|
+
},
|
|
465
|
+
actor: fromCallback(
|
|
466
|
+
({ input, sendBack, receive }) => {
|
|
467
|
+
const selection = select(input.select, input.equals);
|
|
468
|
+
const report = (value) => sendBack({ type: "STORE.UPDATE", store: options.id, value });
|
|
469
|
+
report(selection.get());
|
|
470
|
+
receive((event) => send(event));
|
|
471
|
+
return selection.subscribe(report);
|
|
472
|
+
}
|
|
473
|
+
)
|
|
474
|
+
};
|
|
475
|
+
stopSync = options.sync?.(send);
|
|
476
|
+
return store;
|
|
477
|
+
}
|
|
478
|
+
function sameEntities(a, b) {
|
|
479
|
+
return a.length === b.length && a.every((entity, i) => entity === b[i]);
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
export {
|
|
483
|
+
defineSlice,
|
|
484
|
+
shallowEqual,
|
|
485
|
+
isStorableName,
|
|
486
|
+
unstorableField,
|
|
487
|
+
queryAttrs,
|
|
488
|
+
entityAttrs,
|
|
489
|
+
memoryAdapter,
|
|
490
|
+
webStorageAdapter,
|
|
491
|
+
createStoreMachine,
|
|
492
|
+
createAeonStore
|
|
493
|
+
};
|
|
@@ -17,4 +17,5 @@ export { STICKY_BAR_STATES, StickyBarContext, StickyBarEvent, StickyBarState, st
|
|
|
17
17
|
export { CONTENT_REGION_STATES, ContentRegionContext, ContentRegionEvent, ContentRegionState, contentRegionMachine } from './machines/content-region.js';
|
|
18
18
|
export { CLIPBOARD_STATES, ClipboardContext, ClipboardEvent, ClipboardStatus, clipboardMachine } from './machines/clipboard.js';
|
|
19
19
|
export { CACHE_STATES, CacheCommand, CacheEntryActor, CacheEntryContext, CacheEntryEvent, CacheEntryInput, CacheState, CacheUpdate, CacheView, ChartCache, ChartCacheOptions, cacheEntryMachine, createCacheEntryMachine, createChartCache, isCacheableId } from './machines/cache.js';
|
|
20
|
+
export { AeonStore, AnySlices, EntityOf, PersistedSlices, QueryOf, QueryResult, QueryView, Selection, SliceDefinition, SliceOptions, SliceState, StoreAdapter, StoreEvent, StoreOptions, StorePersistOptions, StoreState, StoreStatus, StoreUpdate, createAeonStore, createStoreMachine, defineSlice, entityAttrs, isStorableName, memoryAdapter, queryAttrs, shallowEqual, unstorableField, webStorageAdapter } from './machines/store.js';
|
|
20
21
|
import 'xstate';
|
|
@@ -2,6 +2,18 @@ import {
|
|
|
2
2
|
STICKY_BAR_STATES,
|
|
3
3
|
stickyBarMachine
|
|
4
4
|
} from "./chunk-YBPNI2LI.js";
|
|
5
|
+
import {
|
|
6
|
+
createAeonStore,
|
|
7
|
+
createStoreMachine,
|
|
8
|
+
defineSlice,
|
|
9
|
+
entityAttrs,
|
|
10
|
+
isStorableName,
|
|
11
|
+
memoryAdapter,
|
|
12
|
+
queryAttrs,
|
|
13
|
+
shallowEqual,
|
|
14
|
+
unstorableField,
|
|
15
|
+
webStorageAdapter
|
|
16
|
+
} from "./chunk-H5XJGZHT.js";
|
|
5
17
|
import {
|
|
6
18
|
tabsMachine
|
|
7
19
|
} from "./chunk-HONROWUG.js";
|
|
@@ -103,24 +115,34 @@ export {
|
|
|
103
115
|
clamp as clampSliderValue,
|
|
104
116
|
clipboardMachine,
|
|
105
117
|
contentRegionMachine,
|
|
118
|
+
createAeonStore,
|
|
106
119
|
createAsyncMachine,
|
|
107
120
|
createCacheEntryMachine,
|
|
108
121
|
createChartCache,
|
|
122
|
+
createStoreMachine,
|
|
123
|
+
defineSlice,
|
|
109
124
|
dialogMachine,
|
|
125
|
+
entityAttrs,
|
|
110
126
|
fieldMachine,
|
|
111
127
|
fileUploadMachine,
|
|
112
128
|
getFileUploadPhase,
|
|
113
129
|
isCacheableId,
|
|
114
130
|
isEmptyAsyncData,
|
|
131
|
+
isStorableName,
|
|
132
|
+
memoryAdapter,
|
|
115
133
|
normalizePair as normalizeRangeSliderValues,
|
|
116
134
|
panelMachine,
|
|
117
135
|
popoverMachine,
|
|
136
|
+
queryAttrs,
|
|
118
137
|
rangeSliderMachine,
|
|
138
|
+
shallowEqual,
|
|
119
139
|
sliderMachine,
|
|
120
140
|
snap2 as snapRangeSliderValue,
|
|
121
141
|
snap as snapSliderValue,
|
|
122
142
|
stickyBarMachine,
|
|
123
143
|
tabsMachine,
|
|
124
144
|
toastMachine,
|
|
125
|
-
toggleMachine
|
|
145
|
+
toggleMachine,
|
|
146
|
+
unstorableField,
|
|
147
|
+
webStorageAdapter
|
|
126
148
|
};
|
|
@@ -61,13 +61,13 @@ declare const appNavMachine: xstate.StateMachine<AppNavContext, {
|
|
|
61
61
|
type: "RESET";
|
|
62
62
|
section: string;
|
|
63
63
|
}, {}, never, {
|
|
64
|
-
type: "
|
|
64
|
+
type: "replace";
|
|
65
65
|
params: xstate.NonReducibleUnknown;
|
|
66
66
|
} | {
|
|
67
|
-
type: "
|
|
67
|
+
type: "pop";
|
|
68
68
|
params: xstate.NonReducibleUnknown;
|
|
69
69
|
} | {
|
|
70
|
-
type: "
|
|
70
|
+
type: "push";
|
|
71
71
|
params: xstate.NonReducibleUnknown;
|
|
72
72
|
} | {
|
|
73
73
|
type: "clear";
|