@hypen-space/core 0.4.46 → 0.4.82

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/src/app.ts CHANGED
@@ -7,12 +7,27 @@ import type { Action } from "./types.js";
7
7
  import type { Session } from "./remote/types.js";
8
8
  import { type Result, Ok, Err, fromPromise, ActionError, HypenError } from "./result.js";
9
9
  import { DataSourceManager, type DataSourcePlugin, type IDataSourceEngine } from "./datasource.js";
10
+ import type { StateStore } from "./persistence.js";
10
11
 
11
12
  // Interface for engine compatibility (works with both engine.js and engine.browser.js)
12
13
  export interface IEngine {
13
14
  setModule(name: string, actions: string[], stateKeys: string[], initialState: unknown): void;
15
+ registerModule(name: string, actions: string[], stateKeys: string[], initialState: unknown): void;
14
16
  onAction(actionName: string, handler: (action: Action) => void | Promise<void>): void;
15
- notifyStateChange(paths: string[], changedValues: Record<string, unknown>): void;
17
+ /**
18
+ * Apply a sparse state update.
19
+ *
20
+ * @param scope Lowercase module name to target a named module registered
21
+ * via `registerModule`. Pass `null` (or empty string) to
22
+ * target the primary module set via `setModule`.
23
+ * @param paths Changed state paths (relative to the targeted module).
24
+ * @param values Map of `path -> new value`.
25
+ */
26
+ updateStateSparse(
27
+ scope: string | null,
28
+ paths: string[],
29
+ values: Record<string, unknown>
30
+ ): void;
16
31
  }
17
32
 
18
33
  import { createObservableState, type StateChange, getStateSnapshot } from "./state.js";
@@ -159,6 +174,11 @@ export interface HypenModuleDefinition<T = unknown> {
159
174
  * Each entry contains the plugin and its configuration.
160
175
  */
161
176
  dataSources?: Array<{ plugin: DataSourcePlugin; config: unknown }>;
177
+ /**
178
+ * Pluggable state store for persistence across sessions.
179
+ * Set via the `.persist(store)` builder method.
180
+ */
181
+ stateStore?: StateStore<T>;
162
182
  handlers: {
163
183
  onCreated?: LifecycleHandler<T>;
164
184
  onAction: Map<string, ActionHandler<T, any>>;
@@ -195,6 +215,7 @@ export class HypenAppBuilder<T> {
195
215
  private template?: string;
196
216
  private _registry?: Map<string, HypenModuleDefinition>;
197
217
  private dataSourceEntries: Array<{ plugin: DataSourcePlugin; config: unknown }> = [];
218
+ private _stateStore?: StateStore<T>;
198
219
 
199
220
  constructor(
200
221
  initialState: T,
@@ -306,6 +327,25 @@ export class HypenAppBuilder<T> {
306
327
  return this;
307
328
  }
308
329
 
330
+ /**
331
+ * Attach a persistent state store.
332
+ * The store handles save/restore lifecycle automatically.
333
+ *
334
+ * @example
335
+ * ```typescript
336
+ * import { durableObjectStore, withKey } from "@hypen-space/cf";
337
+ *
338
+ * app
339
+ * .defineState({ user: null, todos: [] }, { name: "TodoList" })
340
+ * .persist(durableObjectStore(withKey(state => state.user?.id)))
341
+ * .build();
342
+ * ```
343
+ */
344
+ persist(store: StateStore<T>): this {
345
+ this._stateStore = store;
346
+ return this;
347
+ }
348
+
309
349
  /**
310
350
  * Register a data source plugin for live database subscriptions.
311
351
  *
@@ -327,10 +367,10 @@ export class HypenAppBuilder<T> {
327
367
  * .build();
328
368
  * ```
329
369
  *
330
- * In Hypen DSL, bind to data source tables with `$provider.table`:
370
+ * In Hypen DSL, bind to data source tables with `@provider.table`:
331
371
  * ```hypen
332
- * ForEach(items: $spacetime.message, key: "id") {
333
- * Text("${item.text}")
372
+ * ForEach(items: @spacetime.message, key: "id") {
373
+ * Text("@{item.text}")
334
374
  * }
335
375
  * ```
336
376
  */
@@ -355,7 +395,7 @@ export class HypenAppBuilder<T> {
355
395
  * })
356
396
  * .ui(hypen`
357
397
  * Column {
358
- * Text("Count: ${state.count}")
398
+ * Text("Count: @{state.count}")
359
399
  * Button { Text("+") }
360
400
  * .onClick("@actions.increment")
361
401
  * }
@@ -410,6 +450,7 @@ export class HypenAppBuilder<T> {
410
450
  initialState: this.initialState,
411
451
  template: this.template,
412
452
  dataSources: this.dataSourceEntries.length > 0 ? this.dataSourceEntries : undefined,
453
+ stateStore: this._stateStore,
413
454
  handlers: {
414
455
  onCreated: this.createdHandler,
415
456
  onAction: this.actionHandlers,
@@ -546,47 +587,70 @@ export class HypenModuleInstance<T extends object = any> {
546
587
  private stateChangeCallbacks: Array<() => void> = [];
547
588
  private dataSourceManager?: DataSourceManager;
548
589
  private dataSourceAccessor: DataSourceAccessor = {};
590
+ private stateStore?: StateStore<T>;
591
+ private currentPersistKey: string | null = null;
592
+ private persistDebounceTimer: any;
593
+ private sessionId: string;
594
+ /**
595
+ * Lowercase name used to address this module in the engine. Empty string
596
+ * for anonymous modules (which back the engine's primary module slot).
597
+ */
598
+ private moduleKey: string = "";
549
599
 
550
600
  constructor(
551
601
  engine: IEngine,
552
602
  definition: HypenModuleDefinition<T>,
553
603
  router?: HypenRouter | null,
554
- globalContext?: HypenGlobalContext
604
+ globalContext?: HypenGlobalContext,
605
+ sessionId?: string
555
606
  ) {
556
607
  this.engine = engine;
557
608
  this.definition = definition;
558
609
  this.router = router ?? null;
559
610
  this.globalContext = globalContext;
560
-
561
- // Named state prefix — all lowercase per convention
562
- const statePrefix = (definition.name || "").toLowerCase();
563
-
564
- // Create observable state that sends only changed paths and values to engine
565
- // When a prefix is set, all paths are namespaced (e.g., "homepage.count")
611
+ this.sessionId = sessionId ?? crypto.randomUUID();
612
+ this.stateStore = definition.stateStore;
613
+
614
+ // Lowercase module name — used as the engine-side scope key. Empty for
615
+ // anonymous modules (which become the engine's primary module).
616
+ const moduleKey = (definition.name || "").toLowerCase();
617
+ this.moduleKey = moduleKey;
618
+
619
+ // Create observable state that forwards changed paths to the engine.
620
+ // Paths are passed through raw — the engine handles module scoping via
621
+ // the IR's `module_scope` field derived from `module <Name> { ... }` in
622
+ // the DSL.
566
623
  this.state = createObservableState<T>(definition.initialState as T & object, {
567
624
  onChange: (change: StateChange) => {
568
- // Send only the changed paths and their new values (not the whole state)
569
- this.engine.notifyStateChange(change.paths, change.newValues);
570
- // Notify all registered callbacks
625
+ this.engine.updateStateSparse(
626
+ moduleKey || null,
627
+ change.paths,
628
+ change.newValues
629
+ );
571
630
  this.stateChangeCallbacks.forEach(cb => cb());
631
+ this.persistIfNeeded();
572
632
  },
573
- pathPrefix: statePrefix || undefined,
574
633
  });
575
634
 
576
- // Register with engine (initial state registration)
577
- // When prefixed, nest the state under the module name so the engine sees
578
- // e.g., { homepage: { count: 0 } } instead of { count: 0 }
635
+ // Register the module with the engine. Named modules go into the
636
+ // secondary `engine.modules` slot under their lowercase name; anonymous
637
+ // modules become the engine's primary module.
579
638
  const snapshot = getStateSnapshot(this.state);
580
- const prefixedState = statePrefix
581
- ? { [statePrefix]: snapshot }
582
- : snapshot;
583
-
584
- this.engine.setModule(
585
- definition.name || "AnonymousModule",
586
- definition.actions,
587
- definition.stateKeys,
588
- prefixedState
589
- );
639
+ if (moduleKey) {
640
+ this.engine.registerModule(
641
+ moduleKey,
642
+ definition.actions,
643
+ definition.stateKeys,
644
+ snapshot
645
+ );
646
+ } else {
647
+ this.engine.setModule(
648
+ "AnonymousModule",
649
+ definition.actions,
650
+ definition.stateKeys,
651
+ snapshot
652
+ );
653
+ }
590
654
 
591
655
  // Register action handlers with flexible parameter support
592
656
  for (const [actionName, handler] of definition.handlers.onAction) {
@@ -766,10 +830,87 @@ export class HypenModuleInstance<T extends object = any> {
766
830
  return false;
767
831
  }
768
832
 
833
+ /**
834
+ * Re-evaluate the persistence key and debounce saves on state mutation.
835
+ * Handles key transitions (null->value, value->null, value->different value).
836
+ */
837
+ private persistIfNeeded(): void {
838
+ if (!this.stateStore) return;
839
+
840
+ const store = this.stateStore;
841
+ const newKey = store.resolveKey(
842
+ this.state,
843
+ this.definition.name || "AnonymousModule",
844
+ this.sessionId
845
+ );
846
+
847
+ // Key transition: null -> value (e.g. login)
848
+ if (newKey && !this.currentPersistKey) {
849
+ this.activatePersistence(newKey);
850
+ return;
851
+ }
852
+
853
+ // Key transition: value -> null (e.g. logout)
854
+ if (!newKey && this.currentPersistKey) {
855
+ this.currentPersistKey = null;
856
+ return; // stop persisting, but don't delete stored data
857
+ }
858
+
859
+ // Key transition: value -> different value (e.g. account switch)
860
+ if (newKey && newKey !== this.currentPersistKey) {
861
+ this.activatePersistence(newKey);
862
+ return;
863
+ }
864
+
865
+ // No key = no persistence
866
+ if (!this.currentPersistKey) return;
867
+
868
+ // Same key — debounced save
869
+ clearTimeout(this.persistDebounceTimer);
870
+ this.persistDebounceTimer = setTimeout(() => {
871
+ const snapshot = getStateSnapshot(this.state);
872
+ store.save(this.currentPersistKey!, snapshot);
873
+ }, 50);
874
+ }
875
+
876
+ /**
877
+ * Activate persistence for the given key: load stored state and merge.
878
+ */
879
+ private async activatePersistence(key: string): Promise<void> {
880
+ this.currentPersistKey = key;
881
+ const stored = await this.stateStore!.load(key);
882
+ if (stored) {
883
+ // Merge stored over initialState, then apply to live state
884
+ const merged = { ...this.definition.initialState, ...stored };
885
+ Object.assign(this.state, merged); // triggers re-render via proxy
886
+ } else {
887
+ // First time with this key — save current state
888
+ const snapshot = getStateSnapshot(this.state);
889
+ await this.stateStore!.save(key, snapshot);
890
+ }
891
+ }
892
+
769
893
  /**
770
894
  * Call the onCreated handler and connect data source plugins
771
895
  */
772
896
  private async callCreatedHandler(): Promise<void> {
897
+ // Load persisted state before anything else
898
+ if (this.stateStore) {
899
+ const key = this.stateStore.resolveKey(
900
+ this.state,
901
+ this.definition.name || "AnonymousModule",
902
+ this.sessionId
903
+ );
904
+ if (key) {
905
+ this.currentPersistKey = key;
906
+ const stored = await this.stateStore.load(key);
907
+ if (stored) {
908
+ const merged = { ...this.definition.initialState, ...stored };
909
+ Object.assign(this.state, merged);
910
+ }
911
+ }
912
+ }
913
+
773
914
  // Initialize data source plugins if any are registered
774
915
  if (this.definition.dataSources?.length) {
775
916
  // The engine must implement IDataSourceEngine methods
@@ -822,6 +963,13 @@ export class HypenModuleInstance<T extends object = any> {
822
963
  async destroy(): Promise<void> {
823
964
  if (this.isDestroyed) return;
824
965
 
966
+ // Flush any pending persistence writes before shutdown
967
+ if (this.currentPersistKey && this.stateStore) {
968
+ clearTimeout(this.persistDebounceTimer);
969
+ const snapshot = getStateSnapshot(this.state);
970
+ await this.stateStore.save(this.currentPersistKey, snapshot);
971
+ }
972
+
825
973
  // Disconnect all data source plugins
826
974
  if (this.dataSourceManager) {
827
975
  try {
package/src/datasource.ts CHANGED
@@ -4,10 +4,10 @@
4
4
  * Enables live subscription databases (SpacetimeDB, Firebase, Convex, Electric SQL)
5
5
  * to push data into the Hypen reactive engine via a unified plugin interface.
6
6
  *
7
- * UI bindings use the `$provider.path` syntax:
8
- * - `$spacetime.messages` → SpacetimeDB messages table
9
- * - `$firebase.user.profile` → Firebase user profile
10
- * - `$convex.tasks` → Convex tasks table
7
+ * UI bindings use the `@provider.path` syntax:
8
+ * - `@spacetime.messages` → SpacetimeDB messages table
9
+ * - `@firebase.user.profile` → Firebase user profile
10
+ * - `@convex.tasks` → Convex tasks table
11
11
  *
12
12
  * @module
13
13
  */
@@ -0,0 +1,358 @@
1
+ /**
2
+ * Abstract base class for Hypen WASM engine wrappers.
3
+ *
4
+ * The Hypen engine is distributed as a wasm-bindgen module — two builds:
5
+ *
6
+ * - `wasm-node` (bundler target) — used by `@hypen-space/server` on Node/Bun
7
+ * - `wasm-browser` (web target) — used by `@hypen-space/web-engine` in browsers
8
+ *
9
+ * Both builds expose an identical `WasmEngine` class; the only genuine
10
+ * platform differences are:
11
+ *
12
+ * 1. How the WASM module is *instantiated*. Node can synchronously `new
13
+ * WasmEngine()` (the bundler target auto-initializes); the browser
14
+ * target needs an async dynamic import of the JS glue code and an
15
+ * explicit `wasm_init(url)` call before any `new WasmEngine()`.
16
+ *
17
+ * 2. How host state is *unwrapped* before it crosses the WASM boundary.
18
+ * Node can use `structuredClone` (with a `__getSnapshot` fast path for
19
+ * Hypen's proxy-backed state); the browser path does a
20
+ * `JSON.parse(JSON.stringify())` round-trip plus a Map-to-Object
21
+ * conversion for the native Map values that the wasm-bindgen browser
22
+ * target returns in action payloads.
23
+ *
24
+ * Everything else — method wrappers, error classification, callback wiring,
25
+ * revision plumbing — is identical between the two. That shared surface
26
+ * lives here.
27
+ *
28
+ * Subclasses implement two abstract hooks:
29
+ *
30
+ * - `init(options?): Promise<void>` — platform-specific WASM setup. Must
31
+ * assign `this.wasmEngine` and set `this.initialized = true` on success.
32
+ *
33
+ * - `unwrapForWasm<T>(value: T): T` — platform-specific proxy-to-plain
34
+ * conversion. Called before any value crosses into WASM.
35
+ */
36
+
37
+ import { frameworkLoggers } from "./logger.js";
38
+ import { classifyEngineError } from "./result.js";
39
+ import type {
40
+ Patch,
41
+ Action,
42
+ RenderCallback,
43
+ ActionHandler,
44
+ ComponentResolver,
45
+ } from "./types.js";
46
+
47
+ const log = frameworkLoggers.engine;
48
+
49
+ /**
50
+ * Shared base wrapping a wasm-bindgen `WasmEngine`. `any` here is load-
51
+ * bearing: the concrete type is `WasmEngine` from either the `wasm-node`
52
+ * or `wasm-browser` build, and those are separate generated `.d.ts`
53
+ * files that core cannot (and should not) depend on. Subclasses keep
54
+ * their own typed reference if they want stricter checking locally.
55
+ */
56
+ export abstract class BaseEngine {
57
+ protected wasmEngine: any = null;
58
+ protected initialized = false;
59
+
60
+ /**
61
+ * Initialize the WASM module. Platform-specific.
62
+ *
63
+ * Must assign `this.wasmEngine` and set `this.initialized = true`.
64
+ * Idempotent: must early-return if already initialized.
65
+ */
66
+ abstract init(options?: unknown): Promise<void>;
67
+
68
+ /**
69
+ * Convert a host value (possibly a Proxy, possibly containing Maps,
70
+ * possibly wrapped in Hypen's observable state) into a plain-object
71
+ * form safe to pass across the WASM boundary.
72
+ *
73
+ * Called on every `updateState` / `updateStateSparse` / `renderInto`
74
+ * / `setContext` / `registerModule` entry point.
75
+ */
76
+ protected abstract unwrapForWasm<T>(value: T): T;
77
+
78
+ /**
79
+ * Guard that throws if the engine hasn't been initialized.
80
+ * Returns the underlying WASM engine for chained access.
81
+ */
82
+ protected ensureInitialized(): any {
83
+ if (!this.wasmEngine) {
84
+ throw new Error("Engine not initialized. Call init() first.");
85
+ }
86
+ return this.wasmEngine;
87
+ }
88
+
89
+ /**
90
+ * Set the render callback that receives patches.
91
+ */
92
+ setRenderCallback(callback: RenderCallback): void {
93
+ const engine = this.ensureInitialized();
94
+ engine.setRenderCallback((patches: Patch[]) => {
95
+ callback(patches);
96
+ });
97
+ }
98
+
99
+ /**
100
+ * Set the component resolver for dynamic component composition.
101
+ */
102
+ setComponentResolver(resolver: ComponentResolver): void {
103
+ const engine = this.ensureInitialized();
104
+ engine.setComponentResolver(
105
+ (componentName: string, contextPath: string | null) => {
106
+ return resolver(componentName, contextPath);
107
+ },
108
+ );
109
+ }
110
+
111
+ /**
112
+ * Parse and render Hypen DSL source code.
113
+ * @throws {ParseError} if the source fails to parse
114
+ * @throws {RenderError} if rendering fails
115
+ */
116
+ renderSource(source: string): void {
117
+ const engine = this.ensureInitialized();
118
+ try {
119
+ engine.renderSource(source);
120
+ } catch (err) {
121
+ throw classifyEngineError(err);
122
+ }
123
+ }
124
+
125
+ /**
126
+ * Render a lazy component (for lazy route loading).
127
+ */
128
+ renderLazyComponent(source: string): void {
129
+ const engine = this.ensureInitialized();
130
+ engine.renderLazyComponent(source);
131
+ }
132
+
133
+ /**
134
+ * Render a component into a specific parent node (subtree rendering).
135
+ * @throws {ParseError} if the source fails to parse
136
+ * @throws {RenderError} if the parent node is not found or rendering fails
137
+ */
138
+ renderInto(
139
+ source: string,
140
+ parentNodeId: string,
141
+ state: Record<string, any>,
142
+ ): void {
143
+ const engine = this.ensureInitialized();
144
+ try {
145
+ engine.renderInto(source, parentNodeId, this.unwrapForWasm(state));
146
+ } catch (err) {
147
+ throw classifyEngineError(err);
148
+ }
149
+ }
150
+
151
+ /**
152
+ * Apply a sparse state update.
153
+ *
154
+ * @param scope Lowercase module name to target a named module registered
155
+ * via `registerModule`. Pass `null` (or empty string) to
156
+ * target the primary module set via `setModule`.
157
+ * @param paths Changed state paths (relative to the targeted module).
158
+ * @param values Map of `path -> new value`.
159
+ * @throws {StateError} if the state patch is invalid
160
+ */
161
+ updateStateSparse(
162
+ scope: string | null,
163
+ paths: string[],
164
+ values: Record<string, any>,
165
+ ): void {
166
+ const engine = this.ensureInitialized();
167
+
168
+ if (paths.length === 0) {
169
+ return;
170
+ }
171
+
172
+ try {
173
+ engine.updateStateSparse(scope ?? "", paths, this.unwrapForWasm(values));
174
+ } catch (err) {
175
+ throw classifyEngineError(err);
176
+ }
177
+ log.debug("State changed (sparse):", { scope, paths });
178
+ }
179
+
180
+ /**
181
+ * Apply a full-state patch. See [updateStateSparse] for `scope` semantics.
182
+ * Prefer the sparse form when only a few paths changed.
183
+ */
184
+ updateState(scope: string | null, statePatch: Record<string, any>): void {
185
+ const engine = this.ensureInitialized();
186
+ engine.updateState(scope ?? "", this.unwrapForWasm(statePatch));
187
+ }
188
+
189
+ /**
190
+ * Dispatch an action.
191
+ * @throws {HypenError} if the action dispatch fails
192
+ */
193
+ dispatchAction(name: string, payload?: any): void {
194
+ const engine = this.ensureInitialized();
195
+ try {
196
+ engine.dispatchAction(name, payload ?? null);
197
+ } catch (err) {
198
+ throw classifyEngineError(err);
199
+ }
200
+ }
201
+
202
+ /**
203
+ * Register an action handler. Errors thrown synchronously or via rejected
204
+ * promises are caught and logged at the framework level; caller-side error
205
+ * recovery should happen inside `handler`.
206
+ *
207
+ * Action payloads may contain platform-specific native values (e.g. Maps
208
+ * in the browser target). Subclasses can override `normalizeAction` to
209
+ * pre-process the action before calling the user handler.
210
+ */
211
+ onAction(actionName: string, handler: ActionHandler): void {
212
+ const engine = this.ensureInitialized();
213
+ engine.onAction(actionName, (action: Action) => {
214
+ const normalized = this.normalizeAction(action);
215
+ Promise.resolve(handler(normalized)).catch((err) => {
216
+ log.error("Action handler error:", err);
217
+ });
218
+ });
219
+ }
220
+
221
+ /**
222
+ * Platform hook: normalize an incoming action before the user handler
223
+ * sees it. Default identity; subclasses can override to unwrap native
224
+ * values (e.g. browser Maps in action payloads).
225
+ */
226
+ protected normalizeAction(action: Action): Action {
227
+ return action;
228
+ }
229
+
230
+ /**
231
+ * Initialize the primary module.
232
+ */
233
+ setModule(
234
+ name: string,
235
+ actions: string[],
236
+ stateKeys: string[],
237
+ initialState: Record<string, any>,
238
+ ): void {
239
+ const engine = this.ensureInitialized();
240
+ engine.setModule(name, actions, stateKeys, initialState);
241
+ }
242
+
243
+ /**
244
+ * Register a named module for multi-module apps.
245
+ *
246
+ * Unlike setModule which sets the primary module, this registers an
247
+ * additional module whose state is scoped to `module <name> { ... }`
248
+ * blocks in the DSL.
249
+ */
250
+ registerModule(
251
+ name: string,
252
+ actions: string[],
253
+ stateKeys: string[],
254
+ initialState: Record<string, any>,
255
+ ): void {
256
+ const engine = this.ensureInitialized();
257
+ engine.registerModule(
258
+ name,
259
+ actions,
260
+ stateKeys,
261
+ this.unwrapForWasm(initialState),
262
+ );
263
+ }
264
+
265
+ /**
266
+ * Get the current revision number.
267
+ *
268
+ * Note: the underlying WASM builder returns `bigint` in some targets
269
+ * and `number` in others; this normalizes to `number` for consumer
270
+ * ergonomics (revision counts don't exceed 2^53).
271
+ */
272
+ getRevision(): number {
273
+ const engine = this.ensureInitialized();
274
+ return Number(engine.getRevision());
275
+ }
276
+
277
+ /**
278
+ * Clear resolved components and caches, preserving primitives and resolver.
279
+ * Call before renderSource() during hot-reload so components are re-resolved
280
+ * from fresh source files.
281
+ */
282
+ clearResolvedComponents(): void {
283
+ const engine = this.ensureInitialized();
284
+ engine.clearResolvedComponents();
285
+ }
286
+
287
+ /**
288
+ * Clear the engine tree.
289
+ */
290
+ clearTree(): void {
291
+ const engine = this.ensureInitialized();
292
+ engine.clearTree();
293
+ }
294
+
295
+ /**
296
+ * Full reset: clears tree, module, component registry, dependencies,
297
+ * and revision. Render callback and component resolver are preserved.
298
+ */
299
+ reset(): void {
300
+ const engine = this.ensureInitialized();
301
+ engine.reset();
302
+ }
303
+
304
+ /**
305
+ * Debug method to inspect parsed components.
306
+ */
307
+ debugParseComponent(source: string): string {
308
+ const engine = this.ensureInitialized();
309
+ return engine.debugParseComponent(source);
310
+ }
311
+
312
+ // ── Data Source Context ────────────────────────────────────────
313
+
314
+ /**
315
+ * Set (or replace) a named data source context.
316
+ *
317
+ * Registers the provider in the dependency graph, stores the data,
318
+ * and re-renders every node bound to `$name.*`.
319
+ *
320
+ * @param name - Provider name (e.g., "spacetime", "firebase")
321
+ * @param data - Full state object for this provider
322
+ */
323
+ setContext(name: string, data: Record<string, unknown>): void {
324
+ const engine = this.ensureInitialized();
325
+ try {
326
+ engine.setContext(name, this.unwrapForWasm(data));
327
+ } catch (err) {
328
+ throw classifyEngineError(err);
329
+ }
330
+ log.debug(`Data source "${name}" context set`);
331
+ }
332
+
333
+ /**
334
+ * Remove a data source context entirely.
335
+ * Drops the provider's state and re-renders bound nodes (they resolve to null).
336
+ */
337
+ removeContext(name: string): void {
338
+ const engine = this.ensureInitialized();
339
+ engine.removeContext(name);
340
+ }
341
+
342
+ // ── Resource System ─────────────────────────────────────────────────
343
+
344
+ /**
345
+ * Register resources (name → raw SVG string) with the engine.
346
+ *
347
+ * Each SVG is parsed by the WASM engine. When the engine encounters
348
+ * `Icon(@resources.heart)` in the DSL, it resolves the name from registered
349
+ * resources and injects SVG path data into the Create patch props.
350
+ *
351
+ * @param map - Flat map of resource name to raw SVG string
352
+ */
353
+ registerResources(map: Record<string, string>): void {
354
+ const engine = this.ensureInitialized();
355
+ engine.registerResources(map);
356
+ log.debug(`Resources registered (${Object.keys(map).length} entries)`);
357
+ }
358
+ }