@hypen-space/core 0.4.45 → 0.4.81

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
@@ -8,7 +8,7 @@ Hypen lets you write UI templates in a clean, declarative syntax:
8
8
 
9
9
  ```hypen
10
10
  Column {
11
- Text("Hello, ${state.user.name}!")
11
+ Text("Hello, @{state.user.name}!")
12
12
  Button(onClick: @actions.logout) {
13
13
  Text("Sign Out")
14
14
  }
@@ -18,7 +18,7 @@ Column {
18
18
  The `@hypen-space/core` package provides the engine that:
19
19
 
20
20
  1. **Parses** your Hypen templates
21
- 2. **Tracks** reactive state bindings (like `${state.user.name}`)
21
+ 2. **Tracks** reactive state bindings (like `@{state.user.name}`)
22
22
  3. **Generates patches** when state changes (instead of re-rendering everything)
23
23
  4. **Dispatches actions** triggered from the UI (like `@actions.logout`)
24
24
 
@@ -44,11 +44,11 @@ Hypen templates describe your UI structure. Components can have:
44
44
 
45
45
  ### State Bindings
46
46
 
47
- Use `${state.path}` to bind template values to your module's state:
47
+ Use `@{state.path}` to bind template values to your module's state:
48
48
 
49
49
  ```hypen
50
- Text("Count: ${state.count}")
51
- Text("User: ${state.user.name}")
50
+ Text("Count: @{state.count}")
51
+ Text("User: @{state.user.name}")
52
52
  ```
53
53
 
54
54
  When state changes, only the affected parts of the UI update.
@@ -101,7 +101,7 @@ engine.setModule("counter", counter.actions, counter.stateKeys, counter.initialS
101
101
 
102
102
  engine.renderSource(`
103
103
  Column {
104
- Text("Count: \${state.count}")
104
+ Text("Count: @{state.count}")
105
105
  Row {
106
106
  Button(onClick: @actions.decrement) { Text("-") }
107
107
  Button(onClick: @actions.increment) { Text("+") }
@@ -358,8 +358,8 @@ export default app
358
358
  .defineState({ items: ["A", "B", "C"] })
359
359
  .ui(hypen`
360
360
  Column {
361
- ForEach(items: ${state.items}) {
362
- Text("${index}: ${item}")
361
+ ForEach(items: @{state.items}) {
362
+ Text("@{index}: @{item}")
363
363
  }
364
364
  }
365
365
  `);
@@ -526,7 +526,6 @@ events.emit("userLogin", { userId: "123" });
526
526
  | `@hypen-space/core/components` | Built-in Router, Route, Link |
527
527
  | `@hypen-space/core/result` | Result type for error handling |
528
528
  | `@hypen-space/core/disposable` | Disposable pattern utilities |
529
- | `@hypen-space/core/retry` | Retry with backoff |
530
529
  | `@hypen-space/core/logger` | Structured logging |
531
530
  | `@hypen-space/core/events` | Typed event emitter |
532
531
  | `@hypen-space/server` | Node.js WASM engine, loader, discovery, RemoteServer |
package/dist/app.d.ts CHANGED
@@ -6,10 +6,21 @@ import type { Action } from "./types.js";
6
6
  import type { Session } from "./remote/types.js";
7
7
  import { HypenError } from "./result.js";
8
8
  import { type DataSourcePlugin } from "./datasource.js";
9
+ import type { StateStore } from "./persistence.js";
9
10
  export interface IEngine {
10
11
  setModule(name: string, actions: string[], stateKeys: string[], initialState: unknown): void;
12
+ registerModule(name: string, actions: string[], stateKeys: string[], initialState: unknown): void;
11
13
  onAction(actionName: string, handler: (action: Action) => void | Promise<void>): void;
12
- notifyStateChange(paths: string[], changedValues: Record<string, unknown>): void;
14
+ /**
15
+ * Apply a sparse state update.
16
+ *
17
+ * @param scope Lowercase module name to target a named module registered
18
+ * via `registerModule`. Pass `null` (or empty string) to
19
+ * target the primary module set via `setModule`.
20
+ * @param paths Changed state paths (relative to the targeted module).
21
+ * @param values Map of `path -> new value`.
22
+ */
23
+ updateStateSparse(scope: string | null, paths: string[], values: Record<string, unknown>): void;
13
24
  }
14
25
  import type { HypenRouter } from "./router.js";
15
26
  import type { HypenGlobalContext, ModuleReference } from "./context.js";
@@ -137,6 +148,11 @@ export interface HypenModuleDefinition<T = unknown> {
137
148
  plugin: DataSourcePlugin;
138
149
  config: unknown;
139
150
  }>;
151
+ /**
152
+ * Pluggable state store for persistence across sessions.
153
+ * Set via the `.persist(store)` builder method.
154
+ */
155
+ stateStore?: StateStore<T>;
140
156
  handlers: {
141
157
  onCreated?: LifecycleHandler<T>;
142
158
  onAction: Map<string, ActionHandler<T, any>>;
@@ -171,6 +187,7 @@ export declare class HypenAppBuilder<T> {
171
187
  private template?;
172
188
  private _registry?;
173
189
  private dataSourceEntries;
190
+ private _stateStore?;
174
191
  constructor(initialState: T, options?: {
175
192
  persist?: boolean;
176
193
  version?: number;
@@ -248,6 +265,21 @@ export declare class HypenAppBuilder<T> {
248
265
  * @returns Builder for chaining
249
266
  */
250
267
  onError(fn: ErrorHandler<T>): this;
268
+ /**
269
+ * Attach a persistent state store.
270
+ * The store handles save/restore lifecycle automatically.
271
+ *
272
+ * @example
273
+ * ```typescript
274
+ * import { durableObjectStore, withKey } from "@hypen-space/cf";
275
+ *
276
+ * app
277
+ * .defineState({ user: null, todos: [] }, { name: "TodoList" })
278
+ * .persist(durableObjectStore(withKey(state => state.user?.id)))
279
+ * .build();
280
+ * ```
281
+ */
282
+ persist(store: StateStore<T>): this;
251
283
  /**
252
284
  * Register a data source plugin for live database subscriptions.
253
285
  *
@@ -269,10 +301,10 @@ export declare class HypenAppBuilder<T> {
269
301
  * .build();
270
302
  * ```
271
303
  *
272
- * In Hypen DSL, bind to data source tables with `$provider.table`:
304
+ * In Hypen DSL, bind to data source tables with `@provider.table`:
273
305
  * ```hypen
274
- * ForEach(items: $spacetime.message, key: "id") {
275
- * Text("${item.text}")
306
+ * ForEach(items: @spacetime.message, key: "id") {
307
+ * Text("@{item.text}")
276
308
  * }
277
309
  * ```
278
310
  */
@@ -293,7 +325,7 @@ export declare class HypenAppBuilder<T> {
293
325
  * })
294
326
  * .ui(hypen`
295
327
  * Column {
296
- * Text("Count: ${state.count}")
328
+ * Text("Count: @{state.count}")
297
329
  * Button { Text("+") }
298
330
  * .onClick("@actions.increment")
299
331
  * }
@@ -407,7 +439,16 @@ export declare class HypenModuleInstance<T extends object = any> {
407
439
  private stateChangeCallbacks;
408
440
  private dataSourceManager?;
409
441
  private dataSourceAccessor;
410
- constructor(engine: IEngine, definition: HypenModuleDefinition<T>, router?: HypenRouter | null, globalContext?: HypenGlobalContext);
442
+ private stateStore?;
443
+ private currentPersistKey;
444
+ private persistDebounceTimer;
445
+ private sessionId;
446
+ /**
447
+ * Lowercase name used to address this module in the engine. Empty string
448
+ * for anonymous modules (which back the engine's primary module slot).
449
+ */
450
+ private moduleKey;
451
+ constructor(engine: IEngine, definition: HypenModuleDefinition<T>, router?: HypenRouter | null, globalContext?: HypenGlobalContext, sessionId?: string);
411
452
  /** Promise that resolves when onCreated handler has completed */
412
453
  private _readyPromise;
413
454
  /**
@@ -430,6 +471,15 @@ export declare class HypenModuleInstance<T extends object = any> {
430
471
  * @returns true if the error should be rethrown
431
472
  */
432
473
  private handleError;
474
+ /**
475
+ * Re-evaluate the persistence key and debounce saves on state mutation.
476
+ * Handles key transitions (null->value, value->null, value->different value).
477
+ */
478
+ private persistIfNeeded;
479
+ /**
480
+ * Activate persistence for the given key: load stored state and merge.
481
+ */
482
+ private activatePersistence;
433
483
  /**
434
484
  * Call the onCreated handler and connect data source plugins
435
485
  */
package/dist/app.js CHANGED
@@ -723,6 +723,7 @@ class HypenAppBuilder {
723
723
  template;
724
724
  _registry;
725
725
  dataSourceEntries = [];
726
+ _stateStore;
726
727
  constructor(initialState, options, registry) {
727
728
  this.initialState = initialState;
728
729
  this.options = options || {};
@@ -756,6 +757,10 @@ class HypenAppBuilder {
756
757
  this.errorHandler = fn;
757
758
  return this;
758
759
  }
760
+ persist(store) {
761
+ this._stateStore = store;
762
+ return this;
763
+ }
759
764
  useDataSource(plugin, config2) {
760
765
  this.dataSourceEntries.push({ plugin, config: config2 });
761
766
  return this;
@@ -780,6 +785,7 @@ class HypenAppBuilder {
780
785
  initialState: this.initialState,
781
786
  template: this.template,
782
787
  dataSources: this.dataSourceEntries.length > 0 ? this.dataSourceEntries : undefined,
788
+ stateStore: this._stateStore,
783
789
  handlers: {
784
790
  onCreated: this.createdHandler,
785
791
  onAction: this.actionHandlers,
@@ -844,22 +850,33 @@ class HypenModuleInstance {
844
850
  stateChangeCallbacks = [];
845
851
  dataSourceManager;
846
852
  dataSourceAccessor = {};
847
- constructor(engine, definition, router, globalContext) {
853
+ stateStore;
854
+ currentPersistKey = null;
855
+ persistDebounceTimer;
856
+ sessionId;
857
+ moduleKey = "";
858
+ constructor(engine, definition, router, globalContext, sessionId) {
848
859
  this.engine = engine;
849
860
  this.definition = definition;
850
861
  this.router = router ?? null;
851
862
  this.globalContext = globalContext;
852
- const statePrefix = (definition.name || "").toLowerCase();
863
+ this.sessionId = sessionId ?? crypto.randomUUID();
864
+ this.stateStore = definition.stateStore;
865
+ const moduleKey = (definition.name || "").toLowerCase();
866
+ this.moduleKey = moduleKey;
853
867
  this.state = createObservableState(definition.initialState, {
854
868
  onChange: (change) => {
855
- this.engine.notifyStateChange(change.paths, change.newValues);
869
+ this.engine.updateStateSparse(moduleKey || null, change.paths, change.newValues);
856
870
  this.stateChangeCallbacks.forEach((cb) => cb());
857
- },
858
- pathPrefix: statePrefix || undefined
871
+ this.persistIfNeeded();
872
+ }
859
873
  });
860
874
  const snapshot = getStateSnapshot(this.state);
861
- const prefixedState = statePrefix ? { [statePrefix]: snapshot } : snapshot;
862
- this.engine.setModule(definition.name || "AnonymousModule", definition.actions, definition.stateKeys, prefixedState);
875
+ if (moduleKey) {
876
+ this.engine.registerModule(moduleKey, definition.actions, definition.stateKeys, snapshot);
877
+ } else {
878
+ this.engine.setModule("AnonymousModule", definition.actions, definition.stateKeys, snapshot);
879
+ }
863
880
  for (const [actionName, handler] of definition.handlers.onAction) {
864
881
  log2.debug(`Registering action handler: ${actionName} for module ${definition.name}`);
865
882
  this.engine.onAction(actionName, async (action) => {
@@ -969,7 +986,54 @@ class HypenModuleInstance {
969
986
  log2.error(`${context.actionName ? `Action "${context.actionName}"` : `Lifecycle "${context.lifecycle}"`} error:`, error);
970
987
  return false;
971
988
  }
989
+ persistIfNeeded() {
990
+ if (!this.stateStore)
991
+ return;
992
+ const store = this.stateStore;
993
+ const newKey = store.resolveKey(this.state, this.definition.name || "AnonymousModule", this.sessionId);
994
+ if (newKey && !this.currentPersistKey) {
995
+ this.activatePersistence(newKey);
996
+ return;
997
+ }
998
+ if (!newKey && this.currentPersistKey) {
999
+ this.currentPersistKey = null;
1000
+ return;
1001
+ }
1002
+ if (newKey && newKey !== this.currentPersistKey) {
1003
+ this.activatePersistence(newKey);
1004
+ return;
1005
+ }
1006
+ if (!this.currentPersistKey)
1007
+ return;
1008
+ clearTimeout(this.persistDebounceTimer);
1009
+ this.persistDebounceTimer = setTimeout(() => {
1010
+ const snapshot = getStateSnapshot(this.state);
1011
+ store.save(this.currentPersistKey, snapshot);
1012
+ }, 50);
1013
+ }
1014
+ async activatePersistence(key) {
1015
+ this.currentPersistKey = key;
1016
+ const stored = await this.stateStore.load(key);
1017
+ if (stored) {
1018
+ const merged = { ...this.definition.initialState, ...stored };
1019
+ Object.assign(this.state, merged);
1020
+ } else {
1021
+ const snapshot = getStateSnapshot(this.state);
1022
+ await this.stateStore.save(key, snapshot);
1023
+ }
1024
+ }
972
1025
  async callCreatedHandler() {
1026
+ if (this.stateStore) {
1027
+ const key = this.stateStore.resolveKey(this.state, this.definition.name || "AnonymousModule", this.sessionId);
1028
+ if (key) {
1029
+ this.currentPersistKey = key;
1030
+ const stored = await this.stateStore.load(key);
1031
+ if (stored) {
1032
+ const merged = { ...this.definition.initialState, ...stored };
1033
+ Object.assign(this.state, merged);
1034
+ }
1035
+ }
1036
+ }
973
1037
  if (this.definition.dataSources?.length) {
974
1038
  const dsEngine = this.engine;
975
1039
  this.dataSourceManager = new DataSourceManager(dsEngine);
@@ -1008,6 +1072,11 @@ class HypenModuleInstance {
1008
1072
  async destroy() {
1009
1073
  if (this.isDestroyed)
1010
1074
  return;
1075
+ if (this.currentPersistKey && this.stateStore) {
1076
+ clearTimeout(this.persistDebounceTimer);
1077
+ const snapshot = getStateSnapshot(this.state);
1078
+ await this.stateStore.save(this.currentPersistKey, snapshot);
1079
+ }
1011
1080
  if (this.dataSourceManager) {
1012
1081
  try {
1013
1082
  await this.dataSourceManager.disconnectAll();
@@ -1047,4 +1116,4 @@ export {
1047
1116
  HypenApp
1048
1117
  };
1049
1118
 
1050
- //# debugId=955468B608A15BFE64756E2164756E21
1119
+ //# debugId=B8AA1FCB3E096F2264756E2164756E21