@adia-ai/a2ui-compose 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,218 @@
1
+ /**
2
+ * Context Store — Cross-surface shared state (A008 §8).
3
+ *
4
+ * Manages named contexts that multiple surfaces can read from and write to.
5
+ * When the first participating surface mounts, the context is populated.
6
+ * When the last participating surface unmounts, the context is eligible
7
+ * for garbage collection.
8
+ *
9
+ * This is the horizontal equivalent of A007's provider (vertical, parent→child).
10
+ * Contexts live outside any single surface's lifecycle.
11
+ */
12
+
13
+ /**
14
+ * @typedef {object} ContextEntry
15
+ * @property {string} name
16
+ * @property {object} shape — Schema of the context data
17
+ * @property {object} data — Current data
18
+ * @property {Set<string>} participants — Surface IDs consuming this context
19
+ * @property {object} source — How to populate { uri, refresh, params }
20
+ * @property {boolean} populated — Whether data has been fetched
21
+ * @property {Set<(data: object) => void>} listeners — Change listeners
22
+ */
23
+
24
+ export class ContextStore {
25
+ /** @type {Map<string, ContextEntry>} */
26
+ #contexts = new Map();
27
+
28
+ /**
29
+ * Define a shared context (usually from a surface manifest).
30
+ *
31
+ * @param {string} name — Context name
32
+ * @param {object} config
33
+ * @param {object} config.shape — Data shape schema
34
+ * @param {object} [config.source] — { uri, refresh, params }
35
+ */
36
+ define(name, config) {
37
+ if (this.#contexts.has(name)) return; // Already defined
38
+
39
+ this.#contexts.set(name, {
40
+ name,
41
+ shape: config.shape || {},
42
+ data: {},
43
+ participants: new Set(),
44
+ source: config.source || null,
45
+ populated: false,
46
+ listeners: new Set(),
47
+ });
48
+ }
49
+
50
+ /**
51
+ * Register a surface as a participant in a context.
52
+ * If this is the first participant and a source is defined, triggers population.
53
+ *
54
+ * @param {string} contextName
55
+ * @param {string} surfaceId
56
+ * @param {(uri: string, params: Record<string, string>) => Promise<unknown>} [fetcher] — Data fetcher
57
+ * @param {Record<string, string>} [params]
58
+ */
59
+ async join(contextName, surfaceId, fetcher, params = {}) {
60
+ let ctx = this.#contexts.get(contextName);
61
+ if (!ctx) {
62
+ // Auto-define if not pre-defined
63
+ ctx = { name: contextName, shape: {}, data: {}, participants: new Set(), source: null, populated: false, listeners: new Set() };
64
+ this.#contexts.set(contextName, ctx);
65
+ }
66
+
67
+ ctx.participants.add(surfaceId);
68
+
69
+ // Populate on first join (if source exists and not already populated)
70
+ if (!ctx.populated && ctx.source && fetcher) {
71
+ try {
72
+ let uri = ctx.source.uri;
73
+ const resolvedParams = { ...ctx.source.params, ...params };
74
+ for (const [key, spec] of Object.entries(resolvedParams)) {
75
+ const value = typeof spec === 'string' ? spec : spec.value || '';
76
+ uri = uri.replace(`{${key}}`, encodeURIComponent(value));
77
+ }
78
+ ctx.data = await fetcher(uri, resolvedParams);
79
+ ctx.populated = true;
80
+ this.#notify(contextName);
81
+ } catch (err) {
82
+ console.warn(`ContextStore: failed to populate "${contextName}":`, err.message);
83
+ }
84
+ }
85
+
86
+ return ctx.data;
87
+ }
88
+
89
+ /**
90
+ * Unregister a surface from a context.
91
+ * If no participants remain, mark for potential cleanup.
92
+ *
93
+ * @param {string} contextName
94
+ * @param {string} surfaceId
95
+ */
96
+ leave(contextName, surfaceId) {
97
+ const ctx = this.#contexts.get(contextName);
98
+ if (!ctx) return;
99
+
100
+ ctx.participants.delete(surfaceId);
101
+
102
+ // Eligible for GC when no participants
103
+ if (ctx.participants.size === 0) {
104
+ ctx.populated = false;
105
+ ctx.data = {};
106
+ }
107
+ }
108
+
109
+ /**
110
+ * Get the current data for a context.
111
+ *
112
+ * @param {string} contextName
113
+ * @returns {object | null}
114
+ */
115
+ get(contextName) {
116
+ return this.#contexts.get(contextName)?.data ?? null;
117
+ }
118
+
119
+ /**
120
+ * Read a specific key from a context.
121
+ *
122
+ * @param {string} contextName
123
+ * @param {string} key — Dot-separated path (e.g., "patient.name")
124
+ * @returns {unknown}
125
+ */
126
+ read(contextName, key) {
127
+ const data = this.get(contextName);
128
+ if (!data || !key) return data;
129
+
130
+ const segments = key.split('.');
131
+ let current = data;
132
+ for (const seg of segments) {
133
+ if (current == null || typeof current !== 'object') return undefined;
134
+ current = current[seg];
135
+ }
136
+ return current;
137
+ }
138
+
139
+ /**
140
+ * Update data in a context. Notifies all listeners.
141
+ *
142
+ * @param {string} contextName
143
+ * @param {object} data — Partial or full data update (merged)
144
+ */
145
+ update(contextName, data) {
146
+ const ctx = this.#contexts.get(contextName);
147
+ if (!ctx) return;
148
+
149
+ ctx.data = { ...ctx.data, ...data };
150
+ ctx.populated = true;
151
+ this.#notify(contextName);
152
+ }
153
+
154
+ /**
155
+ * Subscribe to context changes.
156
+ *
157
+ * @param {string} contextName
158
+ * @param {(data: object) => void} listener
159
+ * @returns {() => void} — Unsubscribe function
160
+ */
161
+ subscribe(contextName, listener) {
162
+ const ctx = this.#contexts.get(contextName);
163
+ if (!ctx) return () => {};
164
+
165
+ ctx.listeners.add(listener);
166
+ return () => ctx.listeners.delete(listener);
167
+ }
168
+
169
+ /**
170
+ * Check if a context is populated with data.
171
+ * @param {string} contextName
172
+ * @returns {boolean}
173
+ */
174
+ isPopulated(contextName) {
175
+ return this.#contexts.get(contextName)?.populated ?? false;
176
+ }
177
+
178
+ /**
179
+ * Get all defined context names.
180
+ * @returns {string[]}
181
+ */
182
+ get contextNames() {
183
+ return [...this.#contexts.keys()];
184
+ }
185
+
186
+ /**
187
+ * Get diagnostic info for a context.
188
+ * @param {string} contextName
189
+ */
190
+ inspect(contextName) {
191
+ const ctx = this.#contexts.get(contextName);
192
+ if (!ctx) return null;
193
+ return {
194
+ name: ctx.name,
195
+ populated: ctx.populated,
196
+ participantCount: ctx.participants.size,
197
+ participants: [...ctx.participants],
198
+ listenerCount: ctx.listeners.size,
199
+ dataKeys: Object.keys(ctx.data),
200
+ };
201
+ }
202
+
203
+ /** Clear all contexts. */
204
+ clear() {
205
+ this.#contexts.clear();
206
+ }
207
+
208
+ #notify(contextName) {
209
+ const ctx = this.#contexts.get(contextName);
210
+ if (!ctx) return;
211
+ for (const listener of ctx.listeners) {
212
+ try { listener(ctx.data); } catch { /* listener error */ }
213
+ }
214
+ }
215
+ }
216
+
217
+ /** Singleton shared context store for the application. */
218
+ export const sharedContextStore = new ContextStore();