@kumbatio/energy-system 0.2.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -30,10 +30,19 @@ This library gives applications a structured way to adapt to energy state instea
30
30
  - UI visibility
31
31
  - Notification filtering
32
32
  - Task complexity guidance
33
+ - Interaction forgiveness (undo windows, destructive-action confirmation)
34
+ - Energy-aware deferral ordering
35
+ - **Presence annotation**: declare which energy levels a component/view belongs
36
+ to (`defineEnergyPresence`, `presenceAtOrAbove`, `<EnergyGate>`, `data-energy-min`)
37
+ - **Focus sessions**: time-boxed suppression windows with auto-expiry and break nudges
38
+ - **Notification gate**: a runtime that enforces `NotificationConfig`
39
+ (threshold, batching, defer-not-drop) instead of leaving it as guidance
40
+ - **Deferral presets**: pure "not now" (`snooze`) computations with
41
+ energy-aware default ordering
33
42
  - DOM adapter (`data-energy-level` + CSS variables)
34
- - React provider, hooks, and headless render component
43
+ - React provider, hooks, and headless render components
35
44
  - Persistence adapters (`localStorage`, in-memory)
36
- - Deterministic clock support for testing/simulation
45
+ - Deterministic clock and timer support for testing/simulation
37
46
  - Optional external persistence observation (`observe`)
38
47
  - Derived metrics helper (`getEnergyMetrics`)
39
48
  - Compatibility helpers for non-native external level models
@@ -103,6 +112,133 @@ export function App() {
103
112
  }
104
113
  ```
105
114
 
115
+ ## Presence annotation (which energy states does this element belong to?)
116
+
117
+ Every component/view can declare the energy levels it participates in. The
118
+ declaration is a plain typed object — one presence (`'visible' | 'muted' |
119
+ 'hidden'`) per level — so the same annotation drives React, the engine, or
120
+ plain CSS.
121
+
122
+ ```ts
123
+ import {
124
+ defineEnergyPresence,
125
+ presenceAtOrAbove,
126
+ presenceAtOrBelow,
127
+ createPresenceStrategy,
128
+ resolveEnergyPresence,
129
+ } from '@kumbatio/energy-system'
130
+
131
+ // Explicit map: hide the AI chat at 50 and below, mute it at 75
132
+ const aiChatPresence = defineEnergyPresence({
133
+ default: 'visible',
134
+ 75: 'muted',
135
+ 50: 'hidden',
136
+ 25: 'hidden',
137
+ 0: 'hidden',
138
+ })
139
+
140
+ // Shorthands
141
+ const composerTools = presenceAtOrAbove(50) // hidden at 25 and 0
142
+ const recoveryHint = presenceAtOrBelow(25) // low-energy-only affordance
143
+
144
+ // Resolve anywhere
145
+ resolveEnergyPresence(aiChatPresence, 50) // 'hidden'
146
+
147
+ // Or lift into a strategy and resolve through the engine
148
+ const aiChat = createPresenceStrategy('ai-chat', aiChatPresence)
149
+ engine.resolve(aiChat) // 'visible' | 'muted' | 'hidden'
150
+ ```
151
+
152
+ In React, `<EnergyGate>` applies a presence declaration to a subtree:
153
+
154
+ ```tsx
155
+ import { EnergyGate } from '@kumbatio/energy-system/react'
156
+
157
+ // Shorthand: needs at least 75 energy
158
+ <EnergyGate min={75}>
159
+ <AiChatPanel />
160
+ </EnergyGate>
161
+
162
+ // Full map; function children receive the resolved presence so 'muted'
163
+ // can style itself
164
+ <EnergyGate presence={aiChatPresence} fallback={<QuietPlaceholder />}>
165
+ {(presence) => <AiChatPanel muted={presence === 'muted'} />}
166
+ </EnergyGate>
167
+ ```
168
+
169
+ CSS-only path — annotate elements with the range they belong to and the
170
+ stylesheet handles hiding as `data-energy-level` changes:
171
+
172
+ ```html
173
+ <div data-energy-min="75">AI chat — needs 75+ energy</div>
174
+ <div data-energy-max="25">Recovery hint — low energy only</div>
175
+ ```
176
+
177
+ ## Focus sessions and the notification gate
178
+
179
+ These are the runtime half of the model: strategies _describe_ behavior,
180
+ the gate and session controller _enforce_ it.
181
+
182
+ ```ts
183
+ import {
184
+ createEnergyEngine,
185
+ createNotificationGate,
186
+ createFocusSessionController,
187
+ } from '@kumbatio/energy-system'
188
+
189
+ const engine = createEnergyEngine({ initialLevel: 75 })
190
+
191
+ // The gate enforces notificationStrategy: at 50 it batches every 5 minutes
192
+ // and only lets 'high'+ through; at 0 everything is deferred, not dropped.
193
+ const gate = createNotificationGate(engine, {
194
+ onDeliver({ notifications, reason, channels }) {
195
+ if (channels.visual) showToast(notifications, reason)
196
+ },
197
+ })
198
+
199
+ gate.publish({ priority: 'high', payload: { title: 'Build finished' } })
200
+
201
+ // Focus sessions: time-boxed, auto-expiring suppression windows.
202
+ const focus = createFocusSessionController({ engine, gate })
203
+ focus.subscribe((event, session) => {
204
+ if (event === 'break') showBreakNudge()
205
+ if (event === 'end') showSessionSummary(session)
206
+ })
207
+
208
+ // Session length + break cadence default from the current energy level
209
+ // (expected productivity window / task-complexity guidance).
210
+ focus.start()
211
+ ```
212
+
213
+ Two invariants are guaranteed by construction:
214
+
215
+ 1. **Nothing is silently dropped.** A notification the current level does not
216
+ admit is deferred and released when energy rises, suppression lifts, or the
217
+ gate is disposed.
218
+ 2. **Sessions always end.** Expiry is an emitted event (never a predicate you
219
+ must poll), and suppression is lifted _before_ the end event fires, so an
220
+ end-of-session notification can never be swallowed by the session itself.
221
+
222
+ ## Deferral ("not now")
223
+
224
+ Deferring is an energy statement. Presets are pure `(now) => Date` functions;
225
+ `deferralStrategy` orders them by level so the one-tap default matches
226
+ capacity — at low energy the default is "tomorrow morning", not "in 1 hour".
227
+
228
+ ```ts
229
+ import {
230
+ createDeferralPresets,
231
+ deferralStrategy,
232
+ resolveDeferral,
233
+ DEFERRAL_PRESET_IDS,
234
+ } from '@kumbatio/energy-system'
235
+
236
+ const presets = createDeferralPresets({ morningHour: 9, eveningHour: 18 })
237
+ const { defaultPresetId, orderedPresetIds } = engine.resolve(deferralStrategy)
238
+
239
+ const resurfaceAt = resolveDeferral(presets, defaultPresetId) // epoch ms
240
+ ```
241
+
106
242
  ## Quick start (DOM)
107
243
 
108
244
  ```ts
@@ -135,6 +271,16 @@ Then use classes like:
135
271
  - `.energy-toolbar`
136
272
  - `.energy-content`
137
273
 
274
+ And presence attributes:
275
+
276
+ - `data-energy-min="75"` — element hides whenever the current level is below 75
277
+ - `data-energy-max="25"` — element hides whenever the current level is above 25
278
+ - `data-energy-presence="muted" | "hidden"` — hooks for JS-resolved presence
279
+ (`--energy-muted-opacity` controls the muted treatment)
280
+
281
+ The stylesheet honours `prefers-reduced-motion` for its own transitions; apps
282
+ animating presence changes should do the same.
283
+
138
284
  ## API map
139
285
 
140
286
  ### Core package
@@ -146,8 +292,21 @@ Then use classes like:
146
292
  - `cycleDiscreteLevel(current, levels, fallback)`
147
293
  - `mapToNearestDiscreteLevel(value, levels, fallback)`
148
294
  - `mapToNearestEnergyLevel(value)`
149
- - Strategies: `uiVisibilityStrategy`, `notificationStrategy`, `taskComplexityStrategy`
150
- - Types: `EnergyLevel`, `EnergyState`, `AdaptationStrategy`, etc.
295
+ - Strategies: `uiVisibilityStrategy`, `notificationStrategy`,
296
+ `taskComplexityStrategy`, `interactionForgivenessStrategy`, `deferralStrategy`
297
+ - Presence: `defineEnergyPresence(spec)`, `presenceAtOrAbove(min, below?)`,
298
+ `presenceAtOrBelow(max, above?)`, `resolveEnergyPresence(map, level)`,
299
+ `isPresenceVisible(presence)`, `isEnergyPresence(value)`,
300
+ `createPresenceStrategy(name, map)`
301
+ - Focus sessions: `createFocusSessionController(options?)`,
302
+ `sessionRemainingMs(session, now?)`, `isSessionExpired(session, now?)`
303
+ - Notification gate: `createNotificationGate(engine, options)`,
304
+ `resolveNotificationOutcome(config, priority, suppressed)`,
305
+ `isNotificationPriority(value)`
306
+ - Deferral: `createDeferralPresets(options?)`, `resolveDeferral(presets, id, now?)`,
307
+ `DEFERRAL_PRESET_IDS`
308
+ - Types: `EnergyLevel`, `EnergyState`, `EnergyPresence`, `EnergyPresenceMap`,
309
+ `AdaptationStrategy`, `FocusSession`, `NotificationDelivery`, etc.
151
310
 
152
311
  ### `@kumbatio/energy-system/react`
153
312
 
@@ -157,6 +316,8 @@ Then use classes like:
157
316
  - `useEnergyLevelCycler()`
158
317
  - `useStrategy(strategy)`
159
318
  - `useEnergyGate(minLevel)`
319
+ - `useEnergyPresence(presenceMap)`
320
+ - `EnergyGate` (presence-gated subtree: `presence` map or `min`/`max` shorthand)
160
321
  - `EnergyIndicator`
161
322
 
162
323
  ### `@kumbatio/energy-system/persistence`
@@ -175,7 +336,12 @@ Then use classes like:
175
336
  - `createEnergyEngine({ clock })` - inject a deterministic time source
176
337
  - `createEnergyEngine({ originId })` - inject a deterministic producer identity for tests
177
338
  - `createEnergyEngine({ onPersistenceError })` - observe failed save attempts before retry
178
- - `engine.flush()` - wait until the current state version is durably persisted
339
+ - `createEnergyEngine({ maxFutureSkewMs })` - reject hydrated/observed state stamped further
340
+ ahead of the local clock than this budget (default 5 minutes; `Number.POSITIVE_INFINITY`
341
+ accepts any finite timestamp). Guards reconciliation against contexts with bad clocks.
342
+ - `engine.flush()` - wait until the current state version is durably persisted; rejects if the
343
+ engine is disposed or an unchanged initial state cannot be safely reconciled after a hydration
344
+ read failure
179
345
  - `engine.dispose()` - release engine-owned observation/subscription resources
180
346
  - `EnergyPersistence.observe(onState)` - subscribe to external state changes
181
347
  - `getEnergyMetrics(state, now?)` - derive productivity/break/task guidance metrics
@@ -232,7 +398,8 @@ clock timestamp. Local writes advance the logical revision when the clock does n
232
398
 
233
399
  `setLevel()` updates in-memory subscribers synchronously. Persistence runs in the background with
234
400
  bounded exponential backoff. Call `await engine.flush()` when a workflow must wait for durable
235
- storage before reporting completion.
401
+ storage before reporting completion. An initial `flush()` waits for hydration before writing the
402
+ default state, and rejects rather than overwriting unread storage if that hydration read failed.
236
403
 
237
404
  ## Development
238
405
 
@@ -0,0 +1,40 @@
1
+ import type { AdaptationStrategy } from './types.js';
2
+ /** A named deferral option */
3
+ export interface DeferralPreset {
4
+ readonly id: string;
5
+ readonly label: string;
6
+ /** Compute the resurface time from a reference moment */
7
+ compute(now: Date): Date;
8
+ }
9
+ export interface DeferralPresetOptions {
10
+ /** Hour (0-23) mornings resolve to. Default 9. */
11
+ morningHour?: number;
12
+ /** Hour (0-23) evenings resolve to. Default 18. */
13
+ eveningHour?: number;
14
+ }
15
+ /** Stable preset ids, exported so configs/strategies can reference them */
16
+ export declare const DEFERRAL_PRESET_IDS: Readonly<{
17
+ readonly inOneHour: 'in-1-hour';
18
+ readonly thisEvening: 'this-evening';
19
+ readonly tomorrowMorning: 'tomorrow-morning';
20
+ readonly nextWorkday: 'next-workday';
21
+ readonly nextMonday: 'next-monday';
22
+ }>;
23
+ /**
24
+ * Build the standard deferral presets. Times are computed in local time —
25
+ * "tomorrow morning" means the user's morning.
26
+ */
27
+ export declare function createDeferralPresets(options?: DeferralPresetOptions): readonly DeferralPreset[];
28
+ /**
29
+ * Resolve a preset id to a resurface timestamp (epoch ms).
30
+ * Returns null for an unknown id — callers decide whether that is an error.
31
+ */
32
+ export declare function resolveDeferral(presets: readonly DeferralPreset[], presetId: string, now?: Date): number | null;
33
+ export interface DeferralConfig {
34
+ /** Preset ids in suggestion order for this level (first = most prominent) */
35
+ readonly orderedPresetIds: readonly string[];
36
+ /** The preset a one-tap "defer" action should use at this level */
37
+ readonly defaultPresetId: string;
38
+ }
39
+ export declare const deferralStrategy: AdaptationStrategy<DeferralConfig>;
40
+ //# sourceMappingURL=defer.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"defer.d.ts","sourceRoot":"","sources":["../src/defer.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,kBAAkB,EAAe,MAAM,YAAY,CAAA;AAejE,8BAA8B;AAC9B,MAAM,WAAW,cAAc;IAC7B,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAA;IACnB,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAA;IACtB,yDAAyD;IACzD,OAAO,CAAC,GAAG,EAAE,IAAI,GAAG,IAAI,CAAA;CACzB;AAED,MAAM,WAAW,qBAAqB;IACpC,kDAAkD;IAClD,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,mDAAmD;IACnD,WAAW,CAAC,EAAE,MAAM,CAAA;CACrB;AAED,2EAA2E;AAC3E,eAAO,MAAM,mBAAmB;wBACnB,WAAW;0BACT,cAAc;8BACV,kBAAkB;0BACtB,cAAc;yBACf,aAAa;EAChB,CAAA;AAmBX;;;GAGG;AACH,wBAAgB,qBAAqB,CACnC,OAAO,GAAE,qBAA0B,GAClC,SAAS,cAAc,EAAE,CA4D3B;AAED;;;GAGG;AACH,wBAAgB,eAAe,CAC7B,OAAO,EAAE,SAAS,cAAc,EAAE,EAClC,QAAQ,EAAE,MAAM,EAChB,GAAG,GAAE,IAAiB,GACrB,MAAM,GAAG,IAAI,CAIf;AAID,MAAM,WAAW,cAAc;IAC7B,6EAA6E;IAC7E,QAAQ,CAAC,gBAAgB,EAAE,SAAS,MAAM,EAAE,CAAA;IAC5C,mEAAmE;IACnE,QAAQ,CAAC,eAAe,EAAE,MAAM,CAAA;CACjC;AAyDD,eAAO,MAAM,gBAAgB,EAAE,kBAAkB,CAAC,cAAc,CAU/D,CAAA"}
package/dist/defer.js ADDED
@@ -0,0 +1,173 @@
1
+ import { getEnergyLevel } from './levels.js';
2
+ /**
3
+ * Deferral ("snooze") — the "not now" primitive. Deferring an item is an
4
+ * energy statement: it declares insufficient capacity for it right now and
5
+ * names when it should resurface. Presets are pure `(now) -> Date` functions;
6
+ * the energy-aware strategy orders them so the default suggestion matches
7
+ * current capacity (low energy -> longer deferrals, because items should
8
+ * resurface when capacity has plausibly recovered, not in an hour).
9
+ */
10
+ function freezeObject(value) {
11
+ return Object.freeze(value);
12
+ }
13
+ /** Stable preset ids, exported so configs/strategies can reference them */
14
+ export const DEFERRAL_PRESET_IDS = freezeObject({
15
+ inOneHour: 'in-1-hour',
16
+ thisEvening: 'this-evening',
17
+ tomorrowMorning: 'tomorrow-morning',
18
+ nextWorkday: 'next-workday',
19
+ nextMonday: 'next-monday',
20
+ });
21
+ function validateHour(name, value) {
22
+ if (!Number.isInteger(value) || value < 0 || value > 23) {
23
+ throw new Error(`Invalid ${name}: ${String(value)} (expected an integer hour 0-23)`);
24
+ }
25
+ }
26
+ function startOfDay(from) {
27
+ const day = new Date(from);
28
+ day.setHours(0, 0, 0, 0);
29
+ return day;
30
+ }
31
+ function isWeekend(day) {
32
+ const weekday = day.getDay();
33
+ return weekday === 0 || weekday === 6;
34
+ }
35
+ /**
36
+ * Build the standard deferral presets. Times are computed in local time —
37
+ * "tomorrow morning" means the user's morning.
38
+ */
39
+ export function createDeferralPresets(options = {}) {
40
+ const { morningHour = 9, eveningHour = 18 } = options;
41
+ validateHour('morningHour', morningHour);
42
+ validateHour('eveningHour', eveningHour);
43
+ return Object.freeze([
44
+ freezeObject({
45
+ id: DEFERRAL_PRESET_IDS.inOneHour,
46
+ label: 'In 1 hour',
47
+ compute(now) {
48
+ return new Date(now.getTime() + 60 * 60_000);
49
+ },
50
+ }),
51
+ freezeObject({
52
+ id: DEFERRAL_PRESET_IDS.thisEvening,
53
+ label: `This evening (${eveningHour}:00)`,
54
+ compute(now) {
55
+ const evening = startOfDay(now);
56
+ evening.setHours(eveningHour);
57
+ if (evening.getTime() <= now.getTime()) {
58
+ evening.setDate(evening.getDate() + 1);
59
+ }
60
+ return evening;
61
+ },
62
+ }),
63
+ freezeObject({
64
+ id: DEFERRAL_PRESET_IDS.tomorrowMorning,
65
+ label: `Tomorrow morning (${morningHour}:00)`,
66
+ compute(now) {
67
+ const morning = startOfDay(now);
68
+ morning.setDate(morning.getDate() + 1);
69
+ morning.setHours(morningHour);
70
+ return morning;
71
+ },
72
+ }),
73
+ freezeObject({
74
+ id: DEFERRAL_PRESET_IDS.nextWorkday,
75
+ label: `Next workday (${morningHour}:00)`,
76
+ compute(now) {
77
+ const day = startOfDay(now);
78
+ day.setDate(day.getDate() + 1);
79
+ while (isWeekend(day)) {
80
+ day.setDate(day.getDate() + 1);
81
+ }
82
+ day.setHours(morningHour);
83
+ return day;
84
+ },
85
+ }),
86
+ freezeObject({
87
+ id: DEFERRAL_PRESET_IDS.nextMonday,
88
+ label: `Next Monday (${morningHour}:00)`,
89
+ compute(now) {
90
+ const day = startOfDay(now);
91
+ const delta = (1 - day.getDay() + 7) % 7 || 7;
92
+ day.setDate(day.getDate() + delta);
93
+ day.setHours(morningHour);
94
+ return day;
95
+ },
96
+ }),
97
+ ]);
98
+ }
99
+ /**
100
+ * Resolve a preset id to a resurface timestamp (epoch ms).
101
+ * Returns null for an unknown id — callers decide whether that is an error.
102
+ */
103
+ export function resolveDeferral(presets, presetId, now = new Date()) {
104
+ const preset = presets.find((candidate) => candidate.id === presetId);
105
+ if (!preset)
106
+ return null;
107
+ return preset.compute(now).getTime();
108
+ }
109
+ const IDS = DEFERRAL_PRESET_IDS;
110
+ const DEFERRAL_CONFIGS = freezeObject({
111
+ 100: freezeObject({
112
+ orderedPresetIds: Object.freeze([
113
+ IDS.inOneHour,
114
+ IDS.thisEvening,
115
+ IDS.tomorrowMorning,
116
+ IDS.nextWorkday,
117
+ IDS.nextMonday,
118
+ ]),
119
+ defaultPresetId: IDS.inOneHour,
120
+ }),
121
+ 75: freezeObject({
122
+ orderedPresetIds: Object.freeze([
123
+ IDS.inOneHour,
124
+ IDS.thisEvening,
125
+ IDS.tomorrowMorning,
126
+ IDS.nextWorkday,
127
+ IDS.nextMonday,
128
+ ]),
129
+ defaultPresetId: IDS.inOneHour,
130
+ }),
131
+ 50: freezeObject({
132
+ orderedPresetIds: Object.freeze([
133
+ IDS.thisEvening,
134
+ IDS.tomorrowMorning,
135
+ IDS.inOneHour,
136
+ IDS.nextWorkday,
137
+ IDS.nextMonday,
138
+ ]),
139
+ defaultPresetId: IDS.thisEvening,
140
+ }),
141
+ 25: freezeObject({
142
+ orderedPresetIds: Object.freeze([
143
+ IDS.tomorrowMorning,
144
+ IDS.nextWorkday,
145
+ IDS.thisEvening,
146
+ IDS.nextMonday,
147
+ IDS.inOneHour,
148
+ ]),
149
+ defaultPresetId: IDS.tomorrowMorning,
150
+ }),
151
+ 0: freezeObject({
152
+ orderedPresetIds: Object.freeze([
153
+ IDS.tomorrowMorning,
154
+ IDS.nextMonday,
155
+ IDS.nextWorkday,
156
+ IDS.thisEvening,
157
+ IDS.inOneHour,
158
+ ]),
159
+ defaultPresetId: IDS.tomorrowMorning,
160
+ }),
161
+ });
162
+ export const deferralStrategy = {
163
+ name: 'deferral',
164
+ describe(level) {
165
+ const def = getEnergyLevel(level);
166
+ const config = DEFERRAL_CONFIGS[def.value];
167
+ return `${def.label}: default deferral is "${config.defaultPresetId}"`;
168
+ },
169
+ resolve(level) {
170
+ return DEFERRAL_CONFIGS[getEnergyLevel(level).value];
171
+ },
172
+ };
173
+ //# sourceMappingURL=defer.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"defer.js","sourceRoot":"","sources":["../src/defer.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,cAAc,EAAE,MAAM,aAAa,CAAA;AAG5C;;;;;;;GAOG;AAEH,SAAS,YAAY,CAAmB,KAAQ;IAC9C,OAAO,MAAM,CAAC,MAAM,CAAC,KAAK,CAAC,CAAA;AAC7B,CAAC;AAiBD,2EAA2E;AAC3E,MAAM,CAAC,MAAM,mBAAmB,GAAG,YAAY,CAAC;IAC9C,SAAS,EAAE,WAAW;IACtB,WAAW,EAAE,cAAc;IAC3B,eAAe,EAAE,kBAAkB;IACnC,WAAW,EAAE,cAAc;IAC3B,UAAU,EAAE,aAAa;CACjB,CAAC,CAAA;AAEX,SAAS,YAAY,CAAC,IAAY,EAAE,KAAa;IAC/C,IAAI,CAAC,MAAM,CAAC,SAAS,CAAC,KAAK,CAAC,IAAI,KAAK,GAAG,CAAC,IAAI,KAAK,GAAG,EAAE,EAAE,CAAC;QACxD,MAAM,IAAI,KAAK,CAAC,WAAW,IAAI,KAAK,MAAM,CAAC,KAAK,CAAC,kCAAkC,CAAC,CAAA;IACtF,CAAC;AACH,CAAC;AAED,SAAS,UAAU,CAAC,IAAU;IAC5B,MAAM,GAAG,GAAG,IAAI,IAAI,CAAC,IAAI,CAAC,CAAA;IAC1B,GAAG,CAAC,QAAQ,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC,EAAE,CAAC,CAAC,CAAA;IACxB,OAAO,GAAG,CAAA;AACZ,CAAC;AAED,SAAS,SAAS,CAAC,GAAS;IAC1B,MAAM,OAAO,GAAG,GAAG,CAAC,MAAM,EAAE,CAAA;IAC5B,OAAO,OAAO,KAAK,CAAC,IAAI,OAAO,KAAK,CAAC,CAAA;AACvC,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,qBAAqB,CACnC,OAAO,GAA0B,EAAE;IAEnC,MAAM,EAAE,WAAW,GAAG,CAAC,EAAE,WAAW,GAAG,EAAE,EAAE,GAAG,OAAO,CAAA;IACrD,YAAY,CAAC,aAAa,EAAE,WAAW,CAAC,CAAA;IACxC,YAAY,CAAC,aAAa,EAAE,WAAW,CAAC,CAAA;IAExC,OAAO,MAAM,CAAC,MAAM,CAAC;QACnB,YAAY,CAAC;YACX,EAAE,EAAE,mBAAmB,CAAC,SAAS;YACjC,KAAK,EAAE,WAAW;YAClB,OAAO,CAAC,GAAS;gBACf,OAAO,IAAI,IAAI,CAAC,GAAG,CAAC,OAAO,EAAE,GAAG,EAAE,GAAG,MAAM,CAAC,CAAA;YAC9C,CAAC;SACF,CAAC;QACF,YAAY,CAAC;YACX,EAAE,EAAE,mBAAmB,CAAC,WAAW;YACnC,KAAK,EAAE,iBAAiB,WAAW,MAAM;YACzC,OAAO,CAAC,GAAS;gBACf,MAAM,OAAO,GAAG,UAAU,CAAC,GAAG,CAAC,CAAA;gBAC/B,OAAO,CAAC,QAAQ,CAAC,WAAW,CAAC,CAAA;gBAC7B,IAAI,OAAO,CAAC,OAAO,EAAE,IAAI,GAAG,CAAC,OAAO,EAAE,EAAE,CAAC;oBACvC,OAAO,CAAC,OAAO,CAAC,OAAO,CAAC,OAAO,EAAE,GAAG,CAAC,CAAC,CAAA;gBACxC,CAAC;gBACD,OAAO,OAAO,CAAA;YAChB,CAAC;SACF,CAAC;QACF,YAAY,CAAC;YACX,EAAE,EAAE,mBAAmB,CAAC,eAAe;YACvC,KAAK,EAAE,qBAAqB,WAAW,MAAM;YAC7C,OAAO,CAAC,GAAS;gBACf,MAAM,OAAO,GAAG,UAAU,CAAC,GAAG,CAAC,CAAA;gBAC/B,OAAO,CAAC,OAAO,CAAC,OAAO,CAAC,OAAO,EAAE,GAAG,CAAC,CAAC,CAAA;gBACtC,OAAO,CAAC,QAAQ,CAAC,WAAW,CAAC,CAAA;gBAC7B,OAAO,OAAO,CAAA;YAChB,CAAC;SACF,CAAC;QACF,YAAY,CAAC;YACX,EAAE,EAAE,mBAAmB,CAAC,WAAW;YACnC,KAAK,EAAE,iBAAiB,WAAW,MAAM;YACzC,OAAO,CAAC,GAAS;gBACf,MAAM,GAAG,GAAG,UAAU,CAAC,GAAG,CAAC,CAAA;gBAC3B,GAAG,CAAC,OAAO,CAAC,GAAG,CAAC,OAAO,EAAE,GAAG,CAAC,CAAC,CAAA;gBAC9B,OAAO,SAAS,CAAC,GAAG,CAAC,EAAE,CAAC;oBACtB,GAAG,CAAC,OAAO,CAAC,GAAG,CAAC,OAAO,EAAE,GAAG,CAAC,CAAC,CAAA;gBAChC,CAAC;gBACD,GAAG,CAAC,QAAQ,CAAC,WAAW,CAAC,CAAA;gBACzB,OAAO,GAAG,CAAA;YACZ,CAAC;SACF,CAAC;QACF,YAAY,CAAC;YACX,EAAE,EAAE,mBAAmB,CAAC,UAAU;YAClC,KAAK,EAAE,gBAAgB,WAAW,MAAM;YACxC,OAAO,CAAC,GAAS;gBACf,MAAM,GAAG,GAAG,UAAU,CAAC,GAAG,CAAC,CAAA;gBAC3B,MAAM,KAAK,GAAG,CAAC,CAAC,GAAG,GAAG,CAAC,MAAM,EAAE,GAAG,CAAC,CAAC,GAAG,CAAC,IAAI,CAAC,CAAA;gBAC7C,GAAG,CAAC,OAAO,CAAC,GAAG,CAAC,OAAO,EAAE,GAAG,KAAK,CAAC,CAAA;gBAClC,GAAG,CAAC,QAAQ,CAAC,WAAW,CAAC,CAAA;gBACzB,OAAO,GAAG,CAAA;YACZ,CAAC;SACF,CAAC;KACH,CAAC,CAAA;AACJ,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,eAAe,CAC7B,OAAkC,EAClC,QAAgB,EAChB,GAAG,GAAS,IAAI,IAAI,EAAE;IAEtB,MAAM,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC,SAAS,EAAE,EAAE,CAAC,SAAS,CAAC,EAAE,KAAK,QAAQ,CAAC,CAAA;IACrE,IAAI,CAAC,MAAM;QAAE,OAAO,IAAI,CAAA;IACxB,OAAO,MAAM,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC,OAAO,EAAE,CAAA;AACtC,CAAC;AAWD,MAAM,GAAG,GAAG,mBAAmB,CAAA;AAE/B,MAAM,gBAAgB,GAAG,YAAY,CAAC;IACpC,GAAG,EAAE,YAAY,CAAC;QAChB,gBAAgB,EAAE,MAAM,CAAC,MAAM,CAAC;YAC9B,GAAG,CAAC,SAAS;YACb,GAAG,CAAC,WAAW;YACf,GAAG,CAAC,eAAe;YACnB,GAAG,CAAC,WAAW;YACf,GAAG,CAAC,UAAU;SACf,CAAC;QACF,eAAe,EAAE,GAAG,CAAC,SAAS;KAC/B,CAAC;IACF,EAAE,EAAE,YAAY,CAAC;QACf,gBAAgB,EAAE,MAAM,CAAC,MAAM,CAAC;YAC9B,GAAG,CAAC,SAAS;YACb,GAAG,CAAC,WAAW;YACf,GAAG,CAAC,eAAe;YACnB,GAAG,CAAC,WAAW;YACf,GAAG,CAAC,UAAU;SACf,CAAC;QACF,eAAe,EAAE,GAAG,CAAC,SAAS;KAC/B,CAAC;IACF,EAAE,EAAE,YAAY,CAAC;QACf,gBAAgB,EAAE,MAAM,CAAC,MAAM,CAAC;YAC9B,GAAG,CAAC,WAAW;YACf,GAAG,CAAC,eAAe;YACnB,GAAG,CAAC,SAAS;YACb,GAAG,CAAC,WAAW;YACf,GAAG,CAAC,UAAU;SACf,CAAC;QACF,eAAe,EAAE,GAAG,CAAC,WAAW;KACjC,CAAC;IACF,EAAE,EAAE,YAAY,CAAC;QACf,gBAAgB,EAAE,MAAM,CAAC,MAAM,CAAC;YAC9B,GAAG,CAAC,eAAe;YACnB,GAAG,CAAC,WAAW;YACf,GAAG,CAAC,WAAW;YACf,GAAG,CAAC,UAAU;YACd,GAAG,CAAC,SAAS;SACd,CAAC;QACF,eAAe,EAAE,GAAG,CAAC,eAAe;KACrC,CAAC;IACF,CAAC,EAAE,YAAY,CAAC;QACd,gBAAgB,EAAE,MAAM,CAAC,MAAM,CAAC;YAC9B,GAAG,CAAC,eAAe;YACnB,GAAG,CAAC,UAAU;YACd,GAAG,CAAC,WAAW;YACf,GAAG,CAAC,WAAW;YACf,GAAG,CAAC,SAAS;SACd,CAAC;QACF,eAAe,EAAE,GAAG,CAAC,eAAe;KACrC,CAAC;CACH,CAAmE,CAAA;AAEpE,MAAM,CAAC,MAAM,gBAAgB,GAAuC;IAClE,IAAI,EAAE,UAAU;IAChB,QAAQ,CAAC,KAAK;QACZ,MAAM,GAAG,GAAG,cAAc,CAAC,KAAK,CAAC,CAAA;QACjC,MAAM,MAAM,GAAG,gBAAgB,CAAC,GAAG,CAAC,KAAK,CAAC,CAAA;QAC1C,OAAO,GAAG,GAAG,CAAC,KAAK,0BAA0B,MAAM,CAAC,eAAe,GAAG,CAAA;IACxE,CAAC;IACD,OAAO,CAAC,KAAK;QACX,OAAO,gBAAgB,CAAC,cAAc,CAAC,KAAK,CAAC,CAAC,KAAK,CAAC,CAAA;IACtD,CAAC;CACF,CAAA"}
package/dist/engine.d.ts CHANGED
@@ -9,6 +9,14 @@ export interface EnergyEngineOptions {
9
9
  clock?: EnergyClock | (() => number);
10
10
  /** Stable producer identity for deterministic reconciliation. Primarily useful in tests. */
11
11
  originId?: string;
12
+ /**
13
+ * Maximum tolerated future clock skew (ms) for externally supplied state
14
+ * (hydration and cross-context observation). States stamped further ahead of
15
+ * the local clock are rejected so one bad clock cannot win reconciliation
16
+ * until its timestamp passes. Pass Number.POSITIVE_INFINITY to accept any
17
+ * finite timestamp. Default: 5 minutes.
18
+ */
19
+ maxFutureSkewMs?: number;
12
20
  }
13
21
  export interface EnergyEngine {
14
22
  /** Get current energy state */
@@ -23,7 +31,11 @@ export interface EnergyEngine {
23
31
  resolve<T>(strategy: AdaptationStrategy<T>): T;
24
32
  /** Load persisted state (called automatically, but can be called manually) */
25
33
  hydrate(): Promise<void>;
26
- /** Wait until the current state version is durably persisted. */
34
+ /**
35
+ * Wait until the current state version is durably persisted.
36
+ * Rejects if the engine is disposed or an unchanged initial state cannot be
37
+ * reconciled because its persistence hydration read failed.
38
+ */
27
39
  flush(): Promise<void>;
28
40
  /** Release engine-owned subscriptions/resources */
29
41
  dispose(): void;
@@ -1 +1 @@
1
- {"version":3,"file":"engine.d.ts","sourceRoot":"","sources":["../src/engine.ts"],"names":[],"mappings":"AAOA,OAAO,KAAK,EACV,kBAAkB,EAClB,WAAW,EACX,oBAAoB,EACpB,WAAW,EACX,iBAAiB,EACjB,YAAY,EACZ,WAAW,EACZ,MAAM,YAAY,CAAA;AAEnB,MAAM,WAAW,mBAAmB;IAClC,YAAY,CAAC,EAAE,WAAW,CAAA;IAC1B,WAAW,CAAC,EAAE,iBAAiB,CAAA;IAC/B,QAAQ,CAAC,EAAE,oBAAoB,CAAA;IAC/B,mFAAmF;IACnF,kBAAkB,CAAC,EAAE,CAAC,KAAK,EAAE,OAAO,EAAE,KAAK,EAAE,WAAW,KAAK,IAAI,CAAA;IACjE,sDAAsD;IACtD,KAAK,CAAC,EAAE,WAAW,GAAG,CAAC,MAAM,MAAM,CAAC,CAAA;IACpC,4FAA4F;IAC5F,QAAQ,CAAC,EAAE,MAAM,CAAA;CAClB;AAED,MAAM,WAAW,YAAY;IAC3B,+BAA+B;IAC/B,QAAQ,IAAI,WAAW,CAAA;IACvB,4CAA4C;IAC5C,QAAQ,CAAC,KAAK,EAAE,WAAW,EAAE,MAAM,CAAC,EAAE,YAAY,GAAG,IAAI,CAAA;IACzD,iCAAiC;IACjC,UAAU,IAAI,IAAI,CAAA;IAClB,gEAAgE;IAChE,SAAS,CAAC,QAAQ,EAAE,oBAAoB,GAAG,MAAM,IAAI,CAAA;IACrD,sDAAsD;IACtD,OAAO,CAAC,CAAC,EAAE,QAAQ,EAAE,kBAAkB,CAAC,CAAC,CAAC,GAAG,CAAC,CAAA;IAC9C,8EAA8E;IAC9E,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC,CAAA;IACxB,iEAAiE;IACjE,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAAA;IACtB,mDAAmD;IACnD,OAAO,IAAI,IAAI,CAAA;CAChB;AAgFD,wBAAgB,kBAAkB,CAAC,OAAO,GAAE,mBAAwB,GAAG,YAAY,CA0QlF"}
1
+ {"version":3,"file":"engine.d.ts","sourceRoot":"","sources":["../src/engine.ts"],"names":[],"mappings":"AAOA,OAAO,KAAK,EACV,kBAAkB,EAClB,WAAW,EACX,oBAAoB,EACpB,WAAW,EACX,iBAAiB,EACjB,YAAY,EACZ,WAAW,EACZ,MAAM,YAAY,CAAA;AAEnB,MAAM,WAAW,mBAAmB;IAClC,YAAY,CAAC,EAAE,WAAW,CAAA;IAC1B,WAAW,CAAC,EAAE,iBAAiB,CAAA;IAC/B,QAAQ,CAAC,EAAE,oBAAoB,CAAA;IAC/B,mFAAmF;IACnF,kBAAkB,CAAC,EAAE,CAAC,KAAK,EAAE,OAAO,EAAE,KAAK,EAAE,WAAW,KAAK,IAAI,CAAA;IACjE,sDAAsD;IACtD,KAAK,CAAC,EAAE,WAAW,GAAG,CAAC,MAAM,MAAM,CAAC,CAAA;IACpC,4FAA4F;IAC5F,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB;;;;;;OAMG;IACH,eAAe,CAAC,EAAE,MAAM,CAAA;CACzB;AAED,MAAM,WAAW,YAAY;IAC3B,+BAA+B;IAC/B,QAAQ,IAAI,WAAW,CAAA;IACvB,4CAA4C;IAC5C,QAAQ,CAAC,KAAK,EAAE,WAAW,EAAE,MAAM,CAAC,EAAE,YAAY,GAAG,IAAI,CAAA;IACzD,iCAAiC;IACjC,UAAU,IAAI,IAAI,CAAA;IAClB,gEAAgE;IAChE,SAAS,CAAC,QAAQ,EAAE,oBAAoB,GAAG,MAAM,IAAI,CAAA;IACrD,sDAAsD;IACtD,OAAO,CAAC,CAAC,EAAE,QAAQ,EAAE,kBAAkB,CAAC,CAAC,CAAC,GAAG,CAAC,CAAA;IAC9C,8EAA8E;IAC9E,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC,CAAA;IACxB;;;;OAIG;IACH,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAAA;IACtB,mDAAmD;IACnD,OAAO,IAAI,IAAI,CAAA;CAChB;AA2FD,wBAAgB,kBAAkB,CAAC,OAAO,GAAE,mBAAwB,GAAG,YAAY,CAiUlF"}
package/dist/engine.js CHANGED
@@ -4,6 +4,7 @@ function logEngineError(message, err) {
4
4
  }
5
5
  const PERSIST_RETRY_INITIAL_MS = 250;
6
6
  const PERSIST_RETRY_MAX_MS = 30_000;
7
+ const DEFAULT_MAX_FUTURE_SKEW_MS = 5 * 60_000;
7
8
  function resolveNow(clock) {
8
9
  if (typeof clock === 'function')
9
10
  return clock;
@@ -48,17 +49,25 @@ function isPreferredExternalState(candidate, current) {
48
49
  }
49
50
  return false;
50
51
  }
51
- function normalizeState(candidate) {
52
+ function normalizeState(candidate, nowMs, maxFutureSkewMs) {
52
53
  if (!isEnergyLevel(candidate.level)) {
53
54
  throw new Error(`Invalid energy level from persistence: ${String(candidate.level)}`);
54
55
  }
55
56
  if (!isEnergySource(candidate.source)) {
56
57
  throw new Error(`Invalid energy source from persistence: ${String(candidate.source)}`);
57
58
  }
59
+ if (candidate.timestamp - nowMs > maxFutureSkewMs) {
60
+ throw new Error(`Energy state timestamp ${String(candidate.timestamp)} exceeds local clock by more than ${String(maxFutureSkewMs)}ms`);
61
+ }
58
62
  return createEnergyState(candidate.level, candidate.source, candidate.timestamp, candidate.revision, candidate.origin);
59
63
  }
60
64
  export function createEnergyEngine(options = {}) {
61
- const { initialLevel = 100, persistence, onChange, onPersistenceError, clock, originId = createEnergyOrigin(), } = options;
65
+ const { initialLevel = 100, persistence, onChange, onPersistenceError, clock, originId = createEnergyOrigin(), maxFutureSkewMs = DEFAULT_MAX_FUTURE_SKEW_MS, } = options;
66
+ if (typeof maxFutureSkewMs !== 'number' ||
67
+ (!Number.isFinite(maxFutureSkewMs) && maxFutureSkewMs !== Number.POSITIVE_INFINITY) ||
68
+ maxFutureSkewMs < 0) {
69
+ throw new Error(`Invalid maxFutureSkewMs: ${String(maxFutureSkewMs)}`);
70
+ }
62
71
  const now = resolveNow(clock);
63
72
  const listeners = new Set();
64
73
  const notificationQueue = [];
@@ -66,11 +75,17 @@ export function createEnergyEngine(options = {}) {
66
75
  let disposed = false;
67
76
  let isNotifying = false;
68
77
  let state = createEnergyState(initialLevel, 'manual', now(), 0, originId);
69
- let persistedVersion = 0;
78
+ // Version 0 is the initial in-memory state, not proof that a persistence
79
+ // adapter has durably stored it. Starting below the version domain keeps
80
+ // flush() honest even before the first state transition.
81
+ let persistedVersion = -1;
70
82
  let requestedPersistVersion = 0;
71
83
  let persistTask;
72
84
  let persistRetryTimer;
73
85
  let persistRetryDelayMs = PERSIST_RETRY_INITIAL_MS;
86
+ let initialHydrationTask;
87
+ let hasCompletedPersistenceLoad = false;
88
+ let persistenceLoadError;
74
89
  const persistWaiters = [];
75
90
  function resolvePersistWaiters() {
76
91
  for (let index = persistWaiters.length - 1; index >= 0; index -= 1) {
@@ -180,12 +195,26 @@ export function createEnergyEngine(options = {}) {
180
195
  return state;
181
196
  },
182
197
  setLevel(level, source = 'manual') {
198
+ if (disposed)
199
+ return;
183
200
  const wallTime = now();
184
- const timestamp = Math.max(wallTime, state.timestamp);
185
- const revision = timestamp === state.timestamp ? state.revision + 1 : 0;
201
+ let timestamp = Math.max(wallTime, state.timestamp);
202
+ let revision = 0;
203
+ if (timestamp === state.timestamp) {
204
+ if (state.revision === Number.MAX_SAFE_INTEGER) {
205
+ // Preserve a strictly newer ordering key without producing an
206
+ // invalid revision when a deterministic/future clock cannot advance.
207
+ timestamp += 1;
208
+ }
209
+ else {
210
+ revision = state.revision + 1;
211
+ }
212
+ }
186
213
  applyState(createEnergyState(level, source, timestamp, revision, originId));
187
214
  },
188
215
  cycleLevel() {
216
+ if (disposed)
217
+ return;
189
218
  engine.setLevel(cycleEnergyLevel(state.level), 'manual');
190
219
  },
191
220
  subscribe(listener) {
@@ -207,8 +236,11 @@ export function createEnergyEngine(options = {}) {
207
236
  let stored;
208
237
  try {
209
238
  stored = await persistence.load();
239
+ hasCompletedPersistenceLoad = true;
240
+ persistenceLoadError = undefined;
210
241
  }
211
242
  catch (err) {
243
+ persistenceLoadError = err;
212
244
  logEngineError('Failed to load persisted energy state', err);
213
245
  return;
214
246
  }
@@ -216,7 +248,7 @@ export function createEnergyEngine(options = {}) {
216
248
  return;
217
249
  let normalized;
218
250
  try {
219
- normalized = normalizeState(stored);
251
+ normalized = normalizeState(stored, now(), maxFutureSkewMs);
220
252
  }
221
253
  catch (err) {
222
254
  logEngineError('Ignoring invalid persisted energy state', err);
@@ -234,6 +266,18 @@ export function createEnergyEngine(options = {}) {
234
266
  if (disposed) {
235
267
  throw new Error('Cannot flush a disposed energy engine');
236
268
  }
269
+ // Do not persist the default state over an unread stored value. Once a
270
+ // local/external transition exists, that newer intent can persist
271
+ // immediately; an unchanged initial state must wait for auto-hydration.
272
+ if (stateVersion === 0 && initialHydrationTask) {
273
+ await initialHydrationTask;
274
+ }
275
+ if (disposed) {
276
+ throw new Error('Cannot flush a disposed energy engine');
277
+ }
278
+ if (stateVersion === 0 && !hasCompletedPersistenceLoad) {
279
+ throw new Error('Cannot flush the initial energy state because persistence hydration did not complete', { cause: persistenceLoadError });
280
+ }
237
281
  const targetVersion = stateVersion;
238
282
  if (persistedVersion >= targetVersion)
239
283
  return;
@@ -248,7 +292,12 @@ export function createEnergyEngine(options = {}) {
248
292
  if (disposed)
249
293
  return;
250
294
  disposed = true;
251
- disposePersistenceObservation();
295
+ try {
296
+ disposePersistenceObservation();
297
+ }
298
+ catch (err) {
299
+ logEngineError('Failed to release persistence observation', err);
300
+ }
252
301
  if (persistRetryTimer) {
253
302
  clearTimeout(persistRetryTimer);
254
303
  persistRetryTimer = undefined;
@@ -263,7 +312,7 @@ export function createEnergyEngine(options = {}) {
263
312
  };
264
313
  // Auto-hydrate from persistence
265
314
  if (persistence) {
266
- void engine.hydrate().catch((err) => {
315
+ initialHydrationTask = engine.hydrate().catch((err) => {
267
316
  logEngineError('Unexpected hydrate failure', err);
268
317
  });
269
318
  }
@@ -274,7 +323,7 @@ export function createEnergyEngine(options = {}) {
274
323
  return;
275
324
  let normalized;
276
325
  try {
277
- normalized = normalizeState(externalState);
326
+ normalized = normalizeState(externalState, now(), maxFutureSkewMs);
278
327
  }
279
328
  catch (err) {
280
329
  logEngineError('Ignoring invalid observed energy state', err);