@uistate/core 4.1.2 → 5.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (150) hide show
  1. package/LICENSE-eventTest.md +26 -0
  2. package/README.md +409 -42
  3. package/cssState.js +32 -1
  4. package/eventState.js +58 -49
  5. package/eventTest.js +196 -0
  6. package/examples/001-counter/README.md +44 -0
  7. package/examples/001-counter/eventState.js +86 -0
  8. package/examples/001-counter/index.html +30 -0
  9. package/examples/002-counter-improved/README.md +44 -0
  10. package/examples/002-counter-improved/eventState.js +86 -0
  11. package/examples/002-counter-improved/index.html +44 -0
  12. package/examples/003-input-reactive/README.md +44 -0
  13. package/examples/003-input-reactive/eventState.js +86 -0
  14. package/examples/003-input-reactive/index.html +30 -0
  15. package/examples/004-computed-state/README.md +45 -0
  16. package/examples/004-computed-state/eventState.js +86 -0
  17. package/examples/004-computed-state/index.html +62 -0
  18. package/examples/005-conditional-rendering/README.md +42 -0
  19. package/examples/005-conditional-rendering/eventState.js +86 -0
  20. package/examples/005-conditional-rendering/index.html +36 -0
  21. package/examples/006-list-rendering/README.md +49 -0
  22. package/examples/006-list-rendering/eventState.js +86 -0
  23. package/examples/006-list-rendering/index.html +60 -0
  24. package/examples/007-form-validation/README.md +52 -0
  25. package/examples/007-form-validation/eventState.js +86 -0
  26. package/examples/007-form-validation/index.html +99 -0
  27. package/examples/008-undo-redo/README.md +70 -0
  28. package/examples/008-undo-redo/eventState.js +86 -0
  29. package/examples/008-undo-redo/index.html +105 -0
  30. package/examples/009-localStorage-side-effects/README.md +72 -0
  31. package/examples/009-localStorage-side-effects/eventState.js +86 -0
  32. package/examples/009-localStorage-side-effects/index.html +54 -0
  33. package/examples/010-decoupled-components/README.md +74 -0
  34. package/examples/010-decoupled-components/eventState.js +86 -0
  35. package/examples/010-decoupled-components/index.html +90 -0
  36. package/examples/011-async-patterns/README.md +98 -0
  37. package/examples/011-async-patterns/eventState.js +86 -0
  38. package/examples/011-async-patterns/index.html +129 -0
  39. package/examples/028-counter-improved-eventTest/LICENSE +55 -0
  40. package/examples/028-counter-improved-eventTest/README.md +131 -0
  41. package/examples/028-counter-improved-eventTest/app/store.js +9 -0
  42. package/examples/028-counter-improved-eventTest/index.html +49 -0
  43. package/examples/028-counter-improved-eventTest/runtime/core/behaviors.runtime.js +282 -0
  44. package/examples/028-counter-improved-eventTest/runtime/core/eventState.js +100 -0
  45. package/examples/028-counter-improved-eventTest/runtime/core/helpers.js +212 -0
  46. package/examples/028-counter-improved-eventTest/runtime/core/router.js +271 -0
  47. package/examples/028-counter-improved-eventTest/store.d.ts +8 -0
  48. package/examples/028-counter-improved-eventTest/style.css +170 -0
  49. package/examples/028-counter-improved-eventTest/tests/README.md +208 -0
  50. package/examples/028-counter-improved-eventTest/tests/counter.test.js +116 -0
  51. package/examples/028-counter-improved-eventTest/tests/eventTest.js +176 -0
  52. package/examples/028-counter-improved-eventTest/tests/generateTypes.js +168 -0
  53. package/examples/028-counter-improved-eventTest/tests/run.js +20 -0
  54. package/examples/030-todo-app-with-eventTest/LICENSE +55 -0
  55. package/examples/030-todo-app-with-eventTest/README.md +121 -0
  56. package/examples/030-todo-app-with-eventTest/app/router.js +25 -0
  57. package/examples/030-todo-app-with-eventTest/app/store.js +16 -0
  58. package/examples/030-todo-app-with-eventTest/app/views/home.js +11 -0
  59. package/examples/030-todo-app-with-eventTest/app/views/todoDemo.js +88 -0
  60. package/examples/030-todo-app-with-eventTest/index.html +65 -0
  61. package/examples/030-todo-app-with-eventTest/runtime/core/behaviors.runtime.js +282 -0
  62. package/examples/030-todo-app-with-eventTest/runtime/core/eventState.js +100 -0
  63. package/examples/030-todo-app-with-eventTest/runtime/core/helpers.js +212 -0
  64. package/examples/030-todo-app-with-eventTest/runtime/core/router.js +271 -0
  65. package/examples/030-todo-app-with-eventTest/store.d.ts +18 -0
  66. package/examples/030-todo-app-with-eventTest/style.css +170 -0
  67. package/examples/030-todo-app-with-eventTest/tests/README.md +208 -0
  68. package/examples/030-todo-app-with-eventTest/tests/eventTest.js +176 -0
  69. package/examples/030-todo-app-with-eventTest/tests/generateTypes.js +189 -0
  70. package/examples/030-todo-app-with-eventTest/tests/run.js +20 -0
  71. package/examples/030-todo-app-with-eventTest/tests/todos.test.js +167 -0
  72. package/examples/031-todo-app-with-eventTest/LICENSE +55 -0
  73. package/examples/031-todo-app-with-eventTest/README.md +54 -0
  74. package/examples/031-todo-app-with-eventTest/TUTORIAL.md +390 -0
  75. package/examples/031-todo-app-with-eventTest/WHY_EVENTSTATE.md +777 -0
  76. package/examples/031-todo-app-with-eventTest/app/bridges.js +113 -0
  77. package/examples/031-todo-app-with-eventTest/app/router.js +26 -0
  78. package/examples/031-todo-app-with-eventTest/app/store.js +15 -0
  79. package/examples/031-todo-app-with-eventTest/app/views/home.js +46 -0
  80. package/examples/031-todo-app-with-eventTest/app/views/todoDemo.js +69 -0
  81. package/examples/031-todo-app-with-eventTest/devtools/dock.js +41 -0
  82. package/examples/031-todo-app-with-eventTest/devtools/stateTracker.dock.js +10 -0
  83. package/examples/031-todo-app-with-eventTest/devtools/stateTracker.js +246 -0
  84. package/examples/031-todo-app-with-eventTest/devtools/telemetry.js +104 -0
  85. package/examples/031-todo-app-with-eventTest/devtools/typeGenerator.js +339 -0
  86. package/examples/031-todo-app-with-eventTest/index.html +103 -0
  87. package/examples/031-todo-app-with-eventTest/package-lock.json +2184 -0
  88. package/examples/031-todo-app-with-eventTest/package.json +24 -0
  89. package/examples/031-todo-app-with-eventTest/runtime/core/behaviors.runtime.js +282 -0
  90. package/examples/031-todo-app-with-eventTest/runtime/core/eventState.js +100 -0
  91. package/examples/031-todo-app-with-eventTest/runtime/core/helpers.js +212 -0
  92. package/examples/031-todo-app-with-eventTest/runtime/core/router.js +271 -0
  93. package/examples/031-todo-app-with-eventTest/runtime/extensions/boundary.js +36 -0
  94. package/examples/031-todo-app-with-eventTest/runtime/extensions/converge.js +63 -0
  95. package/examples/031-todo-app-with-eventTest/runtime/extensions/eventState.plus.js +210 -0
  96. package/examples/031-todo-app-with-eventTest/runtime/extensions/hydrate.js +157 -0
  97. package/examples/031-todo-app-with-eventTest/runtime/extensions/queryBinding.js +69 -0
  98. package/examples/031-todo-app-with-eventTest/runtime/forms/computed.js +78 -0
  99. package/examples/031-todo-app-with-eventTest/runtime/forms/meta.js +51 -0
  100. package/examples/031-todo-app-with-eventTest/runtime/forms/submitWithBoundary.js +28 -0
  101. package/examples/031-todo-app-with-eventTest/runtime/forms/validators.js +55 -0
  102. package/examples/031-todo-app-with-eventTest/store.d.ts +23 -0
  103. package/examples/031-todo-app-with-eventTest/style.css +170 -0
  104. package/examples/031-todo-app-with-eventTest/tests/README.md +208 -0
  105. package/examples/031-todo-app-with-eventTest/tests/eventTest.js +176 -0
  106. package/examples/031-todo-app-with-eventTest/tests/generateTypes.js +191 -0
  107. package/examples/031-todo-app-with-eventTest/tests/run.js +20 -0
  108. package/examples/031-todo-app-with-eventTest/tests/todos.test.js +192 -0
  109. package/examples/032-todo-app-with-eventTest/LICENSE +55 -0
  110. package/examples/032-todo-app-with-eventTest/README.md +54 -0
  111. package/examples/032-todo-app-with-eventTest/TUTORIAL.md +390 -0
  112. package/examples/032-todo-app-with-eventTest/WHY_EVENTSTATE.md +777 -0
  113. package/examples/032-todo-app-with-eventTest/app/actions/index.js +153 -0
  114. package/examples/032-todo-app-with-eventTest/app/bridges.js +113 -0
  115. package/examples/032-todo-app-with-eventTest/app/router.js +26 -0
  116. package/examples/032-todo-app-with-eventTest/app/store.js +15 -0
  117. package/examples/032-todo-app-with-eventTest/app/views/home.js +46 -0
  118. package/examples/032-todo-app-with-eventTest/app/views/todoDemo.js +69 -0
  119. package/examples/032-todo-app-with-eventTest/devtools/dock.js +41 -0
  120. package/examples/032-todo-app-with-eventTest/devtools/stateTracker.dock.js +10 -0
  121. package/examples/032-todo-app-with-eventTest/devtools/stateTracker.js +246 -0
  122. package/examples/032-todo-app-with-eventTest/devtools/telemetry.js +104 -0
  123. package/examples/032-todo-app-with-eventTest/devtools/typeGenerator.js +339 -0
  124. package/examples/032-todo-app-with-eventTest/index.html +87 -0
  125. package/examples/032-todo-app-with-eventTest/package-lock.json +2184 -0
  126. package/examples/032-todo-app-with-eventTest/package.json +24 -0
  127. package/examples/032-todo-app-with-eventTest/runtime/core/behaviors.runtime.js +282 -0
  128. package/examples/032-todo-app-with-eventTest/runtime/core/eventState.js +100 -0
  129. package/examples/032-todo-app-with-eventTest/runtime/core/helpers.js +212 -0
  130. package/examples/032-todo-app-with-eventTest/runtime/core/router.js +271 -0
  131. package/examples/032-todo-app-with-eventTest/runtime/extensions/boundary.js +36 -0
  132. package/examples/032-todo-app-with-eventTest/runtime/extensions/converge.js +63 -0
  133. package/examples/032-todo-app-with-eventTest/runtime/extensions/eventState.plus.js +210 -0
  134. package/examples/032-todo-app-with-eventTest/runtime/extensions/hydrate.js +157 -0
  135. package/examples/032-todo-app-with-eventTest/runtime/extensions/queryBinding.js +69 -0
  136. package/examples/032-todo-app-with-eventTest/runtime/forms/computed.js +78 -0
  137. package/examples/032-todo-app-with-eventTest/runtime/forms/meta.js +51 -0
  138. package/examples/032-todo-app-with-eventTest/runtime/forms/submitWithBoundary.js +28 -0
  139. package/examples/032-todo-app-with-eventTest/runtime/forms/validators.js +55 -0
  140. package/examples/032-todo-app-with-eventTest/store.d.ts +23 -0
  141. package/examples/032-todo-app-with-eventTest/style.css +170 -0
  142. package/examples/032-todo-app-with-eventTest/tests/README.md +208 -0
  143. package/examples/032-todo-app-with-eventTest/tests/eventTest.js +176 -0
  144. package/examples/032-todo-app-with-eventTest/tests/generateTypes.js +191 -0
  145. package/examples/032-todo-app-with-eventTest/tests/run.js +20 -0
  146. package/examples/032-todo-app-with-eventTest/tests/todos.test.js +192 -0
  147. package/index.js +14 -3
  148. package/package.json +16 -7
  149. package/stateSerializer.js +99 -4
  150. package/templateManager.js +50 -2
@@ -0,0 +1,271 @@
1
+ // runtime/core/router.js — Generic SPA router factory for eventState stores
2
+ // Usage:
3
+ // const router = createRouter({
4
+ // routes: [{ path: '/', view: 'home', component: HomeView }],
5
+ // store,
6
+ // rootSelector: '[data-route-root]',
7
+ // debug: true
8
+ // });
9
+ // router.start();
10
+
11
+ export function createRouter(config) {
12
+ const {
13
+ routes = [],
14
+ store,
15
+ rootSelector = '[data-route-root]',
16
+ fallback = null,
17
+ debug = false,
18
+ linkSelector = 'a[data-link]',
19
+ navSelector = 'nav a[data-link]',
20
+ } = config;
21
+
22
+ // Detect base path from <base href> if present
23
+ const BASE_PATH = (() => {
24
+ const b = document.querySelector('base[href]');
25
+ if (!b) return '';
26
+ try {
27
+ const u = new URL(b.getAttribute('href'), location.href);
28
+ let p = u.pathname;
29
+ if (p.length > 1 && p.endsWith('/')) p = p.slice(0, -1);
30
+ return p;
31
+ } catch { return ''; }
32
+ })();
33
+
34
+ function stripBase(pathname) {
35
+ if (BASE_PATH && pathname.startsWith(BASE_PATH)) {
36
+ const rest = pathname.slice(BASE_PATH.length) || '/';
37
+ return rest.startsWith('/') ? rest : ('/' + rest);
38
+ }
39
+ return pathname;
40
+ }
41
+
42
+ function withBase(pathname) {
43
+ if (!BASE_PATH) return pathname;
44
+ if (pathname === '/') return BASE_PATH || '/';
45
+ return (BASE_PATH + (pathname.startsWith('/') ? '' : '/') + pathname);
46
+ }
47
+
48
+ function normalizePath(p) {
49
+ if (!p) return '/';
50
+ try {
51
+ if (p[0] !== '/') p = '/' + p;
52
+ if (p === '/index.html') return '/';
53
+ if (p.length > 1 && p.endsWith('/')) p = p.slice(0, -1);
54
+ return p;
55
+ } catch { return '/'; }
56
+ }
57
+
58
+ function resolve(pathname) {
59
+ const p = normalizePath(pathname);
60
+ const r = routes.find(r => r.path === p);
61
+ if (r) return { ...r, params: {} };
62
+ if (fallback) return { ...fallback, params: {} };
63
+ return null;
64
+ }
65
+
66
+ function getRoot() {
67
+ const el = document.querySelector(rootSelector);
68
+ if (!el) throw new Error('Route root not found: ' + rootSelector);
69
+ return el;
70
+ }
71
+
72
+ function setActiveNav(pathname) {
73
+ document.querySelectorAll(navSelector).forEach(a => {
74
+ const url = new URL(a.getAttribute('href'), location.href);
75
+ const linkPath = normalizePath(stripBase(url.pathname));
76
+ const here = normalizePath(pathname);
77
+ const isExact = linkPath === here;
78
+ const isParent = !isExact && linkPath !== '/' && here.startsWith(linkPath);
79
+ const active = isExact || isParent;
80
+ a.classList.toggle('active', active);
81
+ if (isExact) a.setAttribute('aria-current', 'page');
82
+ else a.removeAttribute('aria-current');
83
+ });
84
+ }
85
+
86
+ // State
87
+ let current = { viewKey: null, unboot: null, path: null, search: '' };
88
+ let navController = null;
89
+ const scrollPositions = new Map();
90
+ history.scrollRestoration = 'manual';
91
+
92
+ // Core navigate function
93
+ async function navigate(pathname, { replace = false, search = '', restoreScroll = false } = {}) {
94
+ const root = getRoot();
95
+ const appPath = normalizePath(stripBase(pathname));
96
+ const resolved = resolve(appPath);
97
+
98
+ if (!resolved) {
99
+ if (debug) console.warn('[router] No route found for:', appPath);
100
+ return;
101
+ }
102
+
103
+ const viewKey = resolved.view;
104
+ const component = resolved.component;
105
+ const html = document.documentElement;
106
+ const prevViewKey = current.viewKey;
107
+
108
+ const searchStr = search && search.startsWith('?') ? search : (search ? ('?' + search) : '');
109
+
110
+ // Always log navigation for telemetry
111
+ console.log('[nav] navigate', { from: current.path, to: appPath, view: viewKey });
112
+
113
+ if (debug) {
114
+ console.debug('[router] navigate', { pathname, appPath, searchStr, view: viewKey, params: resolved.params });
115
+ }
116
+
117
+ // Same-route no-op guard
118
+ if (current.path === appPath && current.search === searchStr) {
119
+ return;
120
+ }
121
+
122
+ // Abort in-flight boot
123
+ if (navController) navController.abort();
124
+ navController = new AbortController();
125
+ const { signal } = navController;
126
+
127
+ // Transition start
128
+ if (store) {
129
+ try { store.set('ui.route.transitioning', true); } catch {}
130
+ }
131
+ html.setAttribute('data-transitioning', 'on');
132
+
133
+ // Save scroll position
134
+ if (current.path) {
135
+ scrollPositions.set(current.path, { x: scrollX, y: scrollY });
136
+ }
137
+
138
+ // Unboot previous view
139
+ if (typeof current.unboot === 'function') {
140
+ try { await current.unboot(); } catch (_e) {}
141
+ }
142
+
143
+ // Clear DOM
144
+ root.replaceChildren();
145
+
146
+ // Boot new view
147
+ const unboot = await (component.boot?.({ store, el: root, signal }) || Promise.resolve(() => {}));
148
+ current = { viewKey, unboot, path: appPath, search: searchStr };
149
+
150
+ // Update nav active state
151
+ setActiveNav(appPath);
152
+
153
+ // Parse query params
154
+ const urlForQuery = new URL(location.origin + withBase(appPath) + searchStr);
155
+ const q = {};
156
+ urlForQuery.searchParams.forEach((v, k) => { q[k] = v; });
157
+
158
+ // Update store
159
+ if (store) {
160
+ try {
161
+ if (store.setMany) {
162
+ store.setMany({
163
+ 'ui.route.view': viewKey,
164
+ 'ui.route.path': appPath,
165
+ 'ui.route.params': resolved.params || {},
166
+ 'ui.route.query': q,
167
+ });
168
+ } else {
169
+ store.set('ui.route.view', viewKey);
170
+ store.set('ui.route.path', appPath);
171
+ store.set('ui.route.params', resolved.params || {});
172
+ store.set('ui.route.query', q);
173
+ }
174
+ } catch {}
175
+ }
176
+
177
+ // Update browser history
178
+ const useReplace = replace || (prevViewKey === viewKey);
179
+ if (useReplace) history.replaceState({}, '', withBase(appPath) + searchStr);
180
+ else history.pushState({}, '', withBase(appPath) + searchStr);
181
+
182
+ // Set view attribute
183
+ html.setAttribute('data-view', viewKey);
184
+
185
+ if (debug) {
186
+ console.debug('[router] view', { viewKey, path: appPath, query: q });
187
+ }
188
+
189
+ // Transition end
190
+ if (store) {
191
+ try { store.set('ui.route.transitioning', false); } catch {}
192
+ }
193
+ html.setAttribute('data-transitioning', 'off');
194
+
195
+ // Focus management
196
+ if (!root.hasAttribute('tabindex')) root.setAttribute('tabindex', '-1');
197
+ try { root.focus({ preventScroll: true }); } catch {}
198
+
199
+ // Scroll restoration
200
+ if (restoreScroll) {
201
+ const pos = scrollPositions.get(appPath);
202
+ if (pos) scrollTo(pos.x, pos.y);
203
+ } else {
204
+ scrollTo(0, 0);
205
+ }
206
+ }
207
+
208
+ function navigateQuery(patch = {}, { replace = true } = {}) {
209
+ const params = new URLSearchParams(current.search || '');
210
+ for (const [k, v] of Object.entries(patch)) {
211
+ if (v === null || v === undefined || v === '') params.delete(k);
212
+ else params.set(k, String(v));
213
+ }
214
+ const searchStr = params.toString();
215
+ const prefixed = searchStr ? ('?' + searchStr) : '';
216
+ const path = current.path || normalizePath(stripBase(location.pathname));
217
+ return navigate(path, { search: prefixed, replace });
218
+ }
219
+
220
+ function navigatePath(path, { replace = true } = {}) {
221
+ const appPath = normalizePath(stripBase(path));
222
+ const searchStr = current.search || '';
223
+ return navigate(appPath, { search: searchStr, replace });
224
+ }
225
+
226
+ function onClick(e) {
227
+ const a = e.target.closest(linkSelector);
228
+ if (!a) return;
229
+ if (e.defaultPrevented) return;
230
+ if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey || e.button !== 0) return;
231
+ const url = new URL(a.getAttribute('href'), location.href);
232
+ if (url.origin !== location.origin) return;
233
+ e.preventDefault();
234
+
235
+ // Log navigation click
236
+ console.log('[nav] click', { href: a.getAttribute('href'), text: a.textContent.trim() });
237
+
238
+ navigate(url.pathname, { search: url.search }).catch(() => {});
239
+ }
240
+
241
+ function onPop() {
242
+ navigate(location.pathname, { replace: true, search: location.search, restoreScroll: true }).catch(() => {});
243
+ }
244
+
245
+ // Public API
246
+ return {
247
+ navigate,
248
+ navigateQuery,
249
+ navigatePath,
250
+
251
+ start() {
252
+ window.addEventListener('click', onClick);
253
+ window.addEventListener('popstate', onPop);
254
+ navigate(location.pathname, { replace: true, search: location.search, restoreScroll: true });
255
+ return this;
256
+ },
257
+
258
+ stop() {
259
+ window.removeEventListener('click', onClick);
260
+ window.removeEventListener('popstate', onPop);
261
+ if (typeof current.unboot === 'function') {
262
+ try { current.unboot(); } catch {}
263
+ }
264
+ return this;
265
+ },
266
+
267
+ getCurrent() {
268
+ return { ...current };
269
+ }
270
+ };
271
+ }
@@ -0,0 +1,36 @@
1
+ // boundary.js — Minimal boundary helper for UIstate
2
+ // Design goals:
3
+ // - Never throw; always resolve to a value (or undefined) so callers can remain declarative
4
+ // - Toggle loading flags via provided setLoading(boolean)
5
+ // - Report errors via onError(error)
6
+ // - Optional mapError(error) to convert errors into return values
7
+ // - Optional finally() callback invoked regardless of outcome
8
+
9
+ /**
10
+ * @template T
11
+ * @param {() => Promise<T>} fn async function to execute within the boundary
12
+ * @param {{
13
+ * setLoading?: (v:boolean)=>void,
14
+ * onError?: (error:any)=>void,
15
+ * mapError?: (error:any)=>any,
16
+ * finally?: ()=>void,
17
+ * }} opts
18
+ * @returns {Promise<T|any|undefined>}
19
+ */
20
+ export async function runWithBoundary(fn, opts = {}){
21
+ const { setLoading, onError, mapError, finally: onFinally } = opts;
22
+ try {
23
+ if (setLoading) try { setLoading(true); } catch {}
24
+ const out = await fn();
25
+ return out;
26
+ } catch (err) {
27
+ try { onError && onError(err); } catch {}
28
+ if (typeof mapError === 'function') {
29
+ try { return mapError(err); } catch { return undefined; }
30
+ }
31
+ return undefined;
32
+ } finally {
33
+ if (setLoading) try { setLoading(false); } catch {}
34
+ if (onFinally) try { onFinally(); } catch {}
35
+ }
36
+ }
@@ -0,0 +1,63 @@
1
+ // converge.js — last-write-wins (or last-local-wins) convergence helpers
2
+ // Expects a store with get/set APIs.
3
+
4
+ function pathKey(path) {
5
+ return String(path || '');
6
+ }
7
+
8
+ /**
9
+ * Apply an inbound change if its clock is newer than the local clock.
10
+ * @param {any} store
11
+ * @param {{ path: string, value: any, clock: number }} change
12
+ * @param {{ clocksPath?: string, arrayStrategy?: 'replace'|'keyedMerge', keyField?: string }} opts
13
+ */
14
+ export function applyConverge(store, change, opts = {}) {
15
+ const { path, value, clock } = change || {};
16
+ if (!path) return false;
17
+ const { clocksPath = 'clocks', arrayStrategy = 'replace', keyField = 'id' } = opts;
18
+
19
+ const ck = pathKey(path);
20
+ const localClock = store.get(`${clocksPath}.${ck}`) || 0;
21
+ if (!(clock > localClock)) return false; // reject stale or equal
22
+
23
+ // Apply the value with optional array strategy
24
+ if (Array.isArray(value)) {
25
+ if (arrayStrategy === 'replace') {
26
+ store.set(path, value);
27
+ } else {
28
+ const local = store.get(path) || [];
29
+ const result = [...local];
30
+ for (const inc of value) {
31
+ const k = inc && typeof inc === 'object' ? inc[keyField] : undefined;
32
+ if (k == null) { result.push(inc); continue; }
33
+ const sk = String(k);
34
+ const idx = result.findIndex((x) => x && x[keyField] != null && String(x[keyField]) === sk);
35
+ if (idx >= 0) {
36
+ const old = result[idx];
37
+ result[idx] = (old && typeof old === 'object' && inc && typeof inc === 'object')
38
+ ? { ...old, ...inc }
39
+ : inc;
40
+ } else {
41
+ result.push(inc);
42
+ }
43
+ }
44
+ store.set(path, result);
45
+ }
46
+ } else {
47
+ store.set(path, value);
48
+ }
49
+
50
+ // Update clock
51
+ store.set(`${clocksPath}.${ck}`, clock);
52
+ return true;
53
+ }
54
+
55
+ /**
56
+ * Build an outbound change with clock and origin-id (for crossTabSync).
57
+ */
58
+ export function publishChange(store, path, value, { clocksPath = 'clocks', now = () => Date.now(), origin } = {}) {
59
+ const clock = Number(now());
60
+ store.set(path, value);
61
+ store.set(`${clocksPath}.${pathKey(path)}`, clock);
62
+ return { path, value, clock, origin };
63
+ }
@@ -0,0 +1,210 @@
1
+ // eventState.plus.js — Open/Closed extension over eventState.js without modifying core
2
+ // Provides: safety guards, stricter validation, unsubscribe helper, and a light batch API
3
+ // NOTE: This module composes the existing './eventState.js' implementation and returns
4
+ // an enhanced facade. The original fine-grained semantics (per-path events) remain intact.
5
+
6
+ import createEventStateBase from '../core/eventState.js';
7
+
8
+ /**
9
+ * Create an enhanced EventState store while preserving the original semantics.
10
+ * - Safety: destroyed-guard to prevent use-after-destroy
11
+ * - Validation: strict argument checks for subscribe/off
12
+ * - Ergonomics: off(unsub) helper
13
+ * - Batch: coalesce multiple set() calls by path within a batch() section
14
+ *
15
+ * Important: batching here deduplicates per-path updates within the batch,
16
+ * but still dispatches one notification per unique path at flush time.
17
+ * This preserves fine-grained observability while reducing churn.
18
+ */
19
+ export function createEventStatePlus(initial = {}, options = {}){
20
+ const base = createEventStateBase(initial);
21
+ let destroyed = false;
22
+
23
+ // Track subscriptions we create (optional, for destroy hygiene)
24
+ const _subscriptions = new Set();
25
+
26
+ function assertNotDestroyed(){
27
+ if (destroyed) throw new Error('EventState store has been destroyed');
28
+ }
29
+
30
+ // Batching support: collect last value per path and flush at end
31
+ let batching = false;
32
+ let buffer = new Map(); // path -> value (last write wins)
33
+
34
+ function flushBuffer(){
35
+ if (buffer.size === 0) return;
36
+ const entries = Array.from(buffer.entries());
37
+ buffer.clear();
38
+ for (const [path, value] of entries){
39
+ base.set(path, value);
40
+ }
41
+ }
42
+
43
+ function batch(fn){
44
+ assertNotDestroyed();
45
+ const wasBatching = batching;
46
+ batching = true;
47
+ try {
48
+ fn();
49
+ } finally {
50
+ batching = wasBatching; // support nested batches: only flush on outermost
51
+ if (!batching) flushBuffer();
52
+ }
53
+ }
54
+
55
+ // Facade methods
56
+ function get(path){
57
+ return base.get(path);
58
+ }
59
+
60
+ function set(path, value){
61
+ assertNotDestroyed();
62
+ if (!path) return value;
63
+ if (batching){
64
+ buffer.set(path, value);
65
+ return value;
66
+ }
67
+ return base.set(path, value);
68
+ }
69
+
70
+ function subscribe(path, handler){
71
+ assertNotDestroyed();
72
+ if (typeof path !== 'string' || typeof handler !== 'function'){
73
+ throw new TypeError('subscribe(path, handler) requires a string path and function handler');
74
+ }
75
+ // eventState.js invokes callback(detail, path). We adapt the signature to (detail, meta)
76
+ // where meta mimics an event-like shape with type=path for ergonomics.
77
+ const wrapped = (detail /* from base */, subscribedPath /* string */) => {
78
+ handler(detail, { type: subscribedPath, detail });
79
+ };
80
+ const unsubscribe = base.subscribe(path, wrapped);
81
+ _subscriptions.add(unsubscribe);
82
+ return function off(){
83
+ _subscriptions.delete(unsubscribe);
84
+ return unsubscribe();
85
+ };
86
+ }
87
+
88
+ function off(unsubscribe){
89
+ if (typeof unsubscribe !== 'function'){
90
+ throw new TypeError('off(unsubscribe) requires a function returned by subscribe');
91
+ }
92
+ return unsubscribe();
93
+ }
94
+
95
+ function setMany(entries){
96
+ assertNotDestroyed();
97
+ if (!entries) return;
98
+ // Accept Array<[path,value]>, Map, or plain object
99
+ batch(() => {
100
+ if (Array.isArray(entries)){
101
+ for (const [p, v] of entries) set(p, v);
102
+ } else if (entries instanceof Map){
103
+ for (const [p, v] of entries.entries()) set(p, v);
104
+ } else if (typeof entries === 'object'){
105
+ for (const p of Object.keys(entries)) set(p, entries[p]);
106
+ }
107
+ });
108
+ }
109
+
110
+ function destroy(){
111
+ if (destroyed) return;
112
+ // Best-effort unsubscribe of known subs created via this facade
113
+ for (const unsub of Array.from(_subscriptions)){
114
+ try { unsub(); } catch {}
115
+ _subscriptions.delete(unsub);
116
+ }
117
+ // Forward to base.destroy if present
118
+ if (typeof base.destroy === 'function'){
119
+ try { base.destroy(); } catch {}
120
+ }
121
+ destroyed = true;
122
+ // Drop buffered writes (safer than flushing after destroy)
123
+ buffer.clear();
124
+ }
125
+
126
+ return {
127
+ // Core parity
128
+ get,
129
+ set,
130
+ subscribe,
131
+ // Added ergonomics
132
+ off,
133
+ destroy,
134
+ // Batching utilities
135
+ batch,
136
+ setMany,
137
+ // Introspection
138
+ get destroyed(){ return destroyed; },
139
+ };
140
+ }
141
+
142
+ /**
143
+ * Upgrade an existing base store into a Plus facade, without duplicating state.
144
+ * Accepts any object implementing { get, set, subscribe, destroy? }.
145
+ */
146
+ export function upgradeEventState(base){
147
+ // Wrap an existing store with the same facade used above. This avoids creating a new base.
148
+ // Reuse the createEventStatePlus mechanics but without constructing a new base store.
149
+ // Implementation mirrors createEventStatePlus, substituting `base` for the newly created one.
150
+
151
+ let destroyed = false;
152
+ const _subscriptions = new Set();
153
+ const assertNotDestroyed = () => { if (destroyed) throw new Error('EventState store has been destroyed'); };
154
+
155
+ let batching = false;
156
+ let buffer = new Map();
157
+ const flushBuffer = () => {
158
+ if (buffer.size === 0) return;
159
+ const entries = Array.from(buffer.entries());
160
+ buffer.clear();
161
+ for (const [path, value] of entries){ base.set(path, value); }
162
+ };
163
+ const batch = (fn) => {
164
+ assertNotDestroyed();
165
+ const wasBatching = batching;
166
+ batching = true;
167
+ try { fn(); } finally { batching = wasBatching; if (!batching) flushBuffer(); }
168
+ };
169
+
170
+ const get = (path) => base.get(path);
171
+ const set = (path, value) => { assertNotDestroyed(); if (!path) return value; if (batching){ buffer.set(path, value); return value; } return base.set(path, value); };
172
+ const subscribe = (path, handler) => {
173
+ assertNotDestroyed();
174
+ if (typeof path !== 'string' || typeof handler !== 'function') throw new TypeError('subscribe(path, handler) requires a string path and function handler');
175
+ const wrapped = (detail, subscribedPath) => { handler(detail, { type: subscribedPath, detail }); };
176
+ const unsubscribe = base.subscribe(path, wrapped);
177
+ _subscriptions.add(unsubscribe);
178
+ return function off(){ _subscriptions.delete(unsubscribe); return unsubscribe(); };
179
+ };
180
+ const off = (unsubscribe) => {
181
+ if (typeof unsubscribe !== 'function') throw new TypeError('off(unsubscribe) requires a function returned by subscribe');
182
+ return unsubscribe();
183
+ };
184
+ const setMany = (entries) => {
185
+ assertNotDestroyed();
186
+ if (!entries) return;
187
+ batch(() => {
188
+ if (Array.isArray(entries)) for (const [p,v] of entries) set(p,v);
189
+ else if (entries instanceof Map) for (const [p,v] of entries.entries()) set(p,v);
190
+ else if (typeof entries === 'object') for (const p of Object.keys(entries)) set(p, entries[p]);
191
+ });
192
+ };
193
+ const destroy = () => {
194
+ if (destroyed) return;
195
+ for (const unsub of Array.from(_subscriptions)){
196
+ try { unsub(); } catch {}
197
+ _subscriptions.delete(unsub);
198
+ }
199
+ if (typeof base.destroy === 'function') { try { base.destroy(); } catch {} }
200
+ destroyed = true;
201
+ buffer.clear();
202
+ };
203
+
204
+ return {
205
+ get, set, subscribe, off, destroy, batch, setMany,
206
+ get destroyed(){ return destroyed; },
207
+ };
208
+ }
209
+
210
+ export default createEventStatePlus;