@altertable/data-app 0.63.0 → 0.65.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 (63) hide show
  1. package/AGENTS.md +3 -0
  2. package/CONTRIBUTING.md +4 -0
  3. package/README.md +1 -1
  4. package/dist/chunks/{contract-tkc552ze.js → contract-a061yj6r.js} +2 -2
  5. package/dist/chunks/contract-awa2d5b1.js +272 -0
  6. package/dist/chunks/contract-awa2d5b1.js.map +12 -0
  7. package/dist/chunks/{contract-zr3jd7mr.js → contract-g6hky7x6.js} +93 -72
  8. package/dist/chunks/contract-g6hky7x6.js.map +12 -0
  9. package/dist/chunks/{contract-8wdybxj7.js → contract-sm7j7k95.js} +110 -74
  10. package/dist/chunks/contract-sm7j7k95.js.map +14 -0
  11. package/dist/chunks/{contract-farfe948.js → contract-txjy0en2.js} +31 -31
  12. package/dist/chunks/contract-txjy0en2.js.map +10 -0
  13. package/dist/chunks/contract-wz59z8pq.js.map +2 -2
  14. package/dist/chunks/{contract-wd8qe3mt.js → contract-yxbjea23.js} +8 -6
  15. package/dist/chunks/contract-yxbjea23.js.map +12 -0
  16. package/dist/client/index.js +3 -3
  17. package/dist/core/appearance.js +4 -2
  18. package/dist/core/appearance.js.map +1 -1
  19. package/dist/core/contract.js +1 -1
  20. package/dist/embed/index.js +20 -10
  21. package/dist/embed/index.js.map +3 -3
  22. package/dist/react/embed/index.js +5 -2
  23. package/dist/react/embed/index.js.map +3 -3
  24. package/dist/react/index.js +726 -454
  25. package/dist/react/index.js.map +17 -14
  26. package/dist/types/client/iframe.d.ts +2 -0
  27. package/dist/types/client/index.d.ts +1 -0
  28. package/dist/types/client/logger.d.ts +6 -0
  29. package/dist/types/core/appearance.d.ts +12 -1
  30. package/dist/types/core/bridge-endpoint.d.ts +21 -0
  31. package/dist/types/core/bridge.d.ts +137 -16
  32. package/dist/types/core/config.d.ts +2 -1
  33. package/dist/types/core/logger.d.ts +12 -0
  34. package/dist/types/embed/host.d.ts +6 -2
  35. package/dist/types/embed/index.d.ts +1 -0
  36. package/dist/types/embed/source.d.ts +1 -1
  37. package/dist/types/react/embed/bridge.d.ts +1 -1
  38. package/dist/types/react/index.d.ts +1 -0
  39. package/dist/types/react/ui/AppToolbar.d.ts +3 -1
  40. package/dist/types/react/ui/DataApp.d.ts +6 -1
  41. package/dist/types/react/ui/ExportControl.d.ts +4 -0
  42. package/dist/types/react/ui/Toast.d.ts +8 -0
  43. package/dist/types/react/ui/csv-export.d.ts +19 -0
  44. package/dist/types/react/ui/icons.d.ts +1 -0
  45. package/dist/types/react/ui/useAppAppearance.d.ts +2 -2
  46. package/dist/worker.js +382 -79
  47. package/docs/app-authoring.md +30 -3
  48. package/docs/embed.md +1 -1
  49. package/docs/hosted-apps.md +2 -2
  50. package/docs/layout.md +31 -0
  51. package/docs/local-data-apps.md +1 -1
  52. package/docs/react.md +64 -2
  53. package/examples/starter-data-app/index.tsx +50 -32
  54. package/package.json +2 -1
  55. package/dist/chunks/contract-8q35dcyh.js +0 -9
  56. package/dist/chunks/contract-8q35dcyh.js.map +0 -10
  57. package/dist/chunks/contract-8wdybxj7.js.map +0 -13
  58. package/dist/chunks/contract-cxr9t12b.js +0 -18
  59. package/dist/chunks/contract-cxr9t12b.js.map +0 -10
  60. package/dist/chunks/contract-farfe948.js.map +0 -10
  61. package/dist/chunks/contract-wd8qe3mt.js.map +0 -11
  62. package/dist/chunks/contract-zr3jd7mr.js.map +0 -12
  63. /package/dist/chunks/{contract-tkc552ze.js.map → contract-a061yj6r.js.map} +0 -0
package/dist/worker.js CHANGED
@@ -48,19 +48,273 @@ function trustedParent(config, searchParams) {
48
48
  var TOKEN_RE = /^(?=.{1,63}$)[a-z0-9]+(?:-[a-z0-9]+)+-app-[1-9][0-9]*$/;
49
49
  var PAGE_CSP = "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src data: blob:; connect-src 'none'; form-action 'none'; base-uri 'none'";
50
50
  var inlineBootstrap = `(() => {
51
+ // src/core/logger.ts
52
+ function isLogEntry(value) {
53
+ if (!value || typeof value !== "object")
54
+ return false;
55
+ const entry = value;
56
+ return (entry.method === "log" || entry.method === "info" || entry.method === "warn" || entry.method === "error") && Array.isArray(entry.args) && entry.args.length <= 128;
57
+ }
58
+
51
59
  // src/core/bridge.ts
52
60
  var BRIDGE = "altertable:data-app";
53
61
  var MAX_PENDING = 128;
54
62
  var REQUEST_TIMEOUT_MS = 60000;
55
- function isBridgeMessage(value) {
56
- if (!value || typeof value !== "object")
57
- return false;
58
- const message = value;
59
- return message.channel === BRIDGE && message.version === 1 && typeof message.type === "string" && typeof message.documentId === "string" && message.documentId.length > 0 && message.documentId.length <= 128;
60
- }
61
63
  function validId(value) {
62
64
  return typeof value === "string" && /^[a-zA-Z0-9_-]{1,128}$/.test(value);
63
65
  }
66
+ function event({
67
+ wire,
68
+ from,
69
+ session,
70
+ parse
71
+ }) {
72
+ return { wire, from, session, parse };
73
+ }
74
+ function id(fields) {
75
+ if (!validId(fields.id))
76
+ throw new Error("Invalid request ID.");
77
+ return fields.id;
78
+ }
79
+ function string(fields, key) {
80
+ const value = fields[key];
81
+ if (typeof value !== "string")
82
+ throw new Error(\`Invalid \${key}.\`);
83
+ return value;
84
+ }
85
+ function empty() {
86
+ return {};
87
+ }
88
+ var bridgeProtocol = {
89
+ channel: BRIDGE,
90
+ version: 1,
91
+ events: {
92
+ connect: event({
93
+ wire: "bridge:connect",
94
+ from: "host",
95
+ session: "none",
96
+ parse: empty
97
+ }),
98
+ ready: event({
99
+ wire: "bridge:ready",
100
+ from: "app",
101
+ session: "none",
102
+ parse: empty
103
+ }),
104
+ initialize: event({
105
+ wire: "bridge:initialize",
106
+ from: "host",
107
+ session: "establish",
108
+ parse: (fields) => ({ state: fields.state })
109
+ }),
110
+ request: event({
111
+ wire: "bridge:request",
112
+ from: "app",
113
+ session: "current",
114
+ parse: (fields) => {
115
+ const route = string(fields, "route");
116
+ if (!route || route.length > 256)
117
+ throw new Error("Invalid message route.");
118
+ return { id: id(fields), route, payload: fields.payload };
119
+ }
120
+ }),
121
+ result: event({
122
+ wire: "bridge:result",
123
+ from: "host",
124
+ session: "current",
125
+ parse: (fields) => {
126
+ if (!Object.hasOwn(fields, "response"))
127
+ throw new Error("Missing response.");
128
+ return { id: id(fields), response: fields.response };
129
+ }
130
+ }),
131
+ error: event({
132
+ wire: "bridge:error",
133
+ from: "host",
134
+ session: "current",
135
+ parse: (fields) => {
136
+ if (fields.requestId !== undefined && typeof fields.requestId !== "string")
137
+ throw new Error("Invalid requestId.");
138
+ return {
139
+ id: id(fields),
140
+ code: string(fields, "code"),
141
+ message: string(fields, "message"),
142
+ ...fields.requestId === undefined ? {} : { requestId: fields.requestId }
143
+ };
144
+ }
145
+ }),
146
+ cancel: event({
147
+ wire: "bridge:cancel",
148
+ from: "app",
149
+ session: "current",
150
+ parse: (fields) => ({ id: id(fields) })
151
+ }),
152
+ disconnect: event({
153
+ wire: "bridge:disconnect",
154
+ from: "app",
155
+ session: "current",
156
+ parse: empty
157
+ }),
158
+ scriptLoad: event({
159
+ wire: "script:load",
160
+ from: "host",
161
+ session: "current",
162
+ parse: (fields) => ({ javascript: string(fields, "javascript") })
163
+ }),
164
+ stateUpdate: event({
165
+ wire: "state:update",
166
+ from: "host",
167
+ session: "current",
168
+ parse: (fields) => ({ state: fields.state })
169
+ }),
170
+ runtimeLog: event({
171
+ wire: "runtime:log",
172
+ from: "app",
173
+ session: "current",
174
+ parse: (fields) => {
175
+ if (!isLogEntry(fields.payload))
176
+ throw new Error("Invalid log entry.");
177
+ return { payload: fields.payload };
178
+ }
179
+ }),
180
+ runtimeReady: event({
181
+ wire: "runtime:ready",
182
+ from: "app",
183
+ session: "current",
184
+ parse: empty
185
+ }),
186
+ runtimeError: event({
187
+ wire: "runtime:error",
188
+ from: "app",
189
+ session: "current",
190
+ parse: empty
191
+ })
192
+ }
193
+ };
194
+ var byWire = new Map;
195
+ for (const name of Object.keys(bridgeProtocol.events)) {
196
+ const wire = bridgeProtocol.events[name].wire;
197
+ if (byWire.has(wire))
198
+ throw new Error(\`Duplicate bridge event: \${wire}\`);
199
+ byWire.set(wire, name);
200
+ }
201
+ function bridgeEventName(wire) {
202
+ return byWire.get(wire);
203
+ }
204
+ function parseBridgeEnvelope(value) {
205
+ if (!value || typeof value !== "object")
206
+ return;
207
+ const fields = value;
208
+ if (fields.channel !== BRIDGE || fields.version !== bridgeProtocol.version || typeof fields.type !== "string" || typeof fields.documentId !== "string" || !fields.documentId || fields.documentId.length > 128 || fields.sessionId !== undefined && !validId(fields.sessionId) || fields.token !== undefined && typeof fields.token !== "string")
209
+ return;
210
+ return fields;
211
+ }
212
+ function parseBridgePayload(envelope, definition) {
213
+ try {
214
+ return {
215
+ channel: BRIDGE,
216
+ version: bridgeProtocol.version,
217
+ documentId: envelope.documentId,
218
+ ...envelope.sessionId === undefined ? {} : { sessionId: envelope.sessionId },
219
+ ...envelope.token === undefined ? {} : { token: envelope.token },
220
+ type: definition.wire,
221
+ ...definition.parse(envelope)
222
+ };
223
+ } catch {
224
+ return;
225
+ }
226
+ }
227
+ function parseBridgeMessage(value) {
228
+ const envelope = parseBridgeEnvelope(value);
229
+ if (!envelope)
230
+ return;
231
+ const name = bridgeEventName(envelope.type);
232
+ if (!name)
233
+ return;
234
+ const definition = bridgeProtocol.events[name];
235
+ if (definition.session !== "none" && envelope.sessionId === undefined)
236
+ return;
237
+ return parseBridgePayload(envelope, definition);
238
+ }
239
+
240
+ // src/core/bridge-endpoint.ts
241
+ function createBridgeEndpoint({
242
+ role,
243
+ origin,
244
+ source,
245
+ opaque,
246
+ context,
247
+ post,
248
+ diagnostic,
249
+ invalidRequest,
250
+ handlers
251
+ }) {
252
+ let disposed = false;
253
+ function send(name, payload) {
254
+ if (disposed)
255
+ throw new Error("The bridge endpoint has closed.");
256
+ const definition = bridgeProtocol.events[name];
257
+ const current = context();
258
+ const message = parseBridgeMessage({
259
+ ...payload,
260
+ channel: bridgeProtocol.channel,
261
+ version: bridgeProtocol.version,
262
+ type: definition.wire,
263
+ documentId: name === "connect" ? "host" : current.documentId,
264
+ sessionId: current.sessionId,
265
+ token: opaque ? current.token : undefined
266
+ });
267
+ if (!message || opaque && !validId(current.token))
268
+ throw new Error("The bridge is not ready to send this event.");
269
+ diagnostic?.("send", definition.wire);
270
+ if (disposed)
271
+ return;
272
+ post(message);
273
+ }
274
+ function receive(event) {
275
+ const peer = source();
276
+ if (disposed || !peer || event.origin !== origin || event.source !== peer)
277
+ return;
278
+ const envelope = parseBridgeEnvelope(event.data);
279
+ if (!envelope)
280
+ return;
281
+ const current = context();
282
+ const connecting = role === "app" && envelope.type === bridgeProtocol.events.connect.wire;
283
+ if (opaque && (connecting ? !validId(envelope.token) : !current.token || envelope.token !== current.token))
284
+ return;
285
+ const name = bridgeEventName(envelope.type);
286
+ if (!name)
287
+ return;
288
+ const definition = bridgeProtocol.events[name];
289
+ if (definition.from === role)
290
+ return;
291
+ if (definition.session === "establish") {
292
+ if (envelope.documentId !== current.documentId || envelope.sessionId === undefined)
293
+ return;
294
+ } else if (definition.session === "current") {
295
+ if (!current.sessionId || envelope.sessionId !== current.sessionId || envelope.documentId !== current.documentId)
296
+ return;
297
+ }
298
+ const message = parseBridgePayload(envelope, definition);
299
+ if (!message) {
300
+ if (role === "host" && name === "request" && validId(envelope.id))
301
+ invalidRequest?.(envelope.id);
302
+ return;
303
+ }
304
+ diagnostic?.("receive", definition.wire);
305
+ if (disposed)
306
+ return;
307
+ const handler = handlers[name];
308
+ handler(message);
309
+ }
310
+ return {
311
+ send,
312
+ receive,
313
+ dispose() {
314
+ disposed = true;
315
+ }
316
+ };
317
+ }
64
318
 
65
319
  // src/core/messages.ts
66
320
  var errorBrand = Symbol.for("@altertable/data-app/MessageRoutingError");
@@ -193,6 +447,46 @@ var inlineBootstrap = `(() => {
193
447
  }
194
448
  }
195
449
 
450
+ // src/client/logger.ts
451
+ function createBridgeLogger(enabled, send) {
452
+ function resolve(args) {
453
+ if (args.length === 1 && typeof args[0] === "function") {
454
+ const value = args[0]();
455
+ return Array.isArray(value) ? value : [value];
456
+ }
457
+ return args.map((arg) => typeof arg === "function" ? arg() : arg);
458
+ }
459
+ function write(method, args) {
460
+ if (!enabled())
461
+ return;
462
+ try {
463
+ send({ method, args: resolve(args) });
464
+ } catch {}
465
+ }
466
+ return {
467
+ log: (...args) => write("log", args),
468
+ info: (...args) => write("info", args),
469
+ warn: (...args) => write("warn", args),
470
+ error: (...args) => write("error", args)
471
+ };
472
+ }
473
+ function logBridgeMessage(logger, message) {
474
+ if (message.type === "runtime:log")
475
+ return;
476
+ logger.log(() => {
477
+ const metadata = { type: message.type };
478
+ if ("id" in message)
479
+ metadata.id = message.id;
480
+ if (message.type === "bridge:request") {
481
+ metadata.route = message.route;
482
+ const payload = message.payload;
483
+ if (message.route === "data:query" && payload && typeof payload === "object" && "operation" in payload && typeof payload.operation === "string")
484
+ metadata.operation = payload.operation;
485
+ }
486
+ return ["Sending message to parent", metadata];
487
+ });
488
+ }
489
+
196
490
  // src/client/iframe.ts
197
491
  function rethrowDataMessageError(error) {
198
492
  if (error instanceof MessageRoutingError)
@@ -215,67 +509,61 @@ var inlineBootstrap = `(() => {
215
509
  const pending = new Map;
216
510
  let hostState;
217
511
  const stateListeners = new Set;
218
- function send(message) {
219
- frame.parent.postMessage({ channel: BRIDGE, version: 1, documentId, sessionId, token, ...message }, parentOrigin);
220
- }
512
+ const logger = createBridgeLogger(() => !disposed && !!sessionId && !!hostState && typeof hostState === "object" && ("logging" in hostState) && hostState.logging === true, (entry) => send("runtimeLog", { payload: entry }));
513
+ const endpoint = createBridgeEndpoint({
514
+ role: "app",
515
+ origin: parentOrigin,
516
+ source: () => frame.parent,
517
+ opaque: mode === "bundle",
518
+ context: () => ({ documentId, sessionId, token }),
519
+ post(message) {
520
+ frame.parent.postMessage(message, parentOrigin);
521
+ logBridgeMessage(logger, message);
522
+ },
523
+ handlers: {
524
+ connect(message) {
525
+ if (mode === "bundle")
526
+ token = message.token;
527
+ send("ready", {});
528
+ },
529
+ initialize(message) {
530
+ if (sessionId && sessionId !== message.sessionId) {
531
+ for (const entry of pending.values())
532
+ entry.reject(new DataAppError("The preview reconnected. Retry the request.", "bridge_reset"));
533
+ }
534
+ const first = !sessionId;
535
+ sessionId = message.sessionId;
536
+ receiveState(message.state);
537
+ if (first)
538
+ for (const entry of pending.values())
539
+ entry.start();
540
+ if (disposed)
541
+ return;
542
+ if (mode === "url")
543
+ send("runtimeReady", {});
544
+ },
545
+ stateUpdate(message) {
546
+ receiveState(message.state);
547
+ },
548
+ scriptLoad(message) {
549
+ if (mode === "bundle")
550
+ loadScript?.(message.javascript);
551
+ },
552
+ result(message) {
553
+ pending.get(message.id)?.resolve(message.response);
554
+ },
555
+ error(message) {
556
+ pending.get(message.id)?.reject(new MessageRoutingError(message.code, message.message, message.requestId));
557
+ }
558
+ }
559
+ });
560
+ const send = endpoint.send;
561
+ const receive = endpoint.receive;
221
562
  function receiveState(state) {
222
563
  hostState = state;
223
564
  for (const listener of stateListeners)
224
565
  listener(state);
225
566
  }
226
- function receive(event) {
227
- if (event.origin !== parentOrigin || event.source !== frame.parent || !isBridgeMessage(event.data))
228
- return;
229
- const message = event.data;
230
- if (message.type === "bridge:connect") {
231
- if (mode === "bundle") {
232
- if (!validId(message.token))
233
- return;
234
- token = message.token;
235
- }
236
- send({ type: "bridge:ready" });
237
- return;
238
- }
239
- if (mode === "bundle" && (!token || message.token !== token))
240
- return;
241
- if (message.documentId !== documentId)
242
- return;
243
- if (message.type === "bridge:initialize" && validId(message.sessionId)) {
244
- if (sessionId && sessionId !== message.sessionId) {
245
- for (const entry of pending.values())
246
- entry.reject(new DataAppError("The preview reconnected. Retry the request.", "bridge_reset"));
247
- }
248
- const first = !sessionId;
249
- sessionId = message.sessionId;
250
- if (first)
251
- for (const entry of pending.values())
252
- entry.start();
253
- receiveState(message.state);
254
- if (mode === "url")
255
- send({ type: "runtime:ready" });
256
- return;
257
- }
258
- if (!sessionId || message.sessionId !== sessionId)
259
- return;
260
- if (message.type === "state:update") {
261
- receiveState(message.state);
262
- return;
263
- }
264
- if (message.type === "script:load" && mode === "bundle" && typeof message.javascript === "string") {
265
- loadScript?.(message.javascript);
266
- return;
267
- }
268
- if (typeof message.id !== "string")
269
- return;
270
- const entry = pending.get(message.id);
271
- if (!entry)
272
- return;
273
- if (message.type === "bridge:result" && "response" in message) {
274
- entry.resolve(message.response);
275
- } else if (message.type === "bridge:error" && typeof message.code === "string" && typeof message.message === "string") {
276
- entry.reject(new MessageRoutingError(message.code, message.message, typeof message.requestId === "string" ? message.requestId : undefined));
277
- }
278
- }
279
567
  frame.addEventListener("message", receive);
280
568
  function requestMessage(message, signal) {
281
569
  return new Promise((resolve, reject) => {
@@ -294,13 +582,13 @@ var inlineBootstrap = `(() => {
294
582
  }
295
583
  function abort() {
296
584
  if (sent)
297
- send({ type: "bridge:cancel", id });
585
+ send("cancel", { id });
298
586
  cleanup();
299
587
  reject(signal?.reason);
300
588
  }
301
589
  const timer = setTimeout(() => {
302
590
  if (sent)
303
- send({ type: "bridge:cancel", id });
591
+ send("cancel", { id });
304
592
  cleanup();
305
593
  reject(new DataAppError(sessionId ? "The data request timed out." : "The preview shell is unavailable. Reload the preview.", sessionId ? "timeout" : "bridge_unavailable"));
306
594
  }, timeoutMs);
@@ -309,8 +597,7 @@ var inlineBootstrap = `(() => {
309
597
  if (sent)
310
598
  return;
311
599
  try {
312
- send({
313
- type: "bridge:request",
600
+ send("request", {
314
601
  id,
315
602
  route: message.route,
316
603
  payload: message.payload
@@ -334,8 +621,8 @@ var inlineBootstrap = `(() => {
334
621
  signal?.addEventListener("abort", abort, { once: true });
335
622
  if (sessionId)
336
623
  entry.start();
337
- else
338
- send({ type: "bridge:ready" });
624
+ else if (mode === "url" || token)
625
+ send("ready", {});
339
626
  });
340
627
  }
341
628
  const messages = createMessageClient({ "data:query": defineDataQueryRoute(), "data:sql": sqlQueryRoute }, requestMessage);
@@ -343,20 +630,22 @@ var inlineBootstrap = `(() => {
343
630
  return messages.request("data:query", { operation, input }, { signal }).catch(rethrowDataMessageError);
344
631
  }
345
632
  function disconnect() {
346
- send({ type: "bridge:disconnect" });
633
+ if (sessionId)
634
+ send("disconnect", {});
347
635
  sessionId = undefined;
348
636
  for (const entry of pending.values())
349
637
  entry.reject(new DataAppError("The preview has closed.", "bridge_closed"));
350
638
  }
351
639
  function resume(event) {
352
- if (event.persisted)
353
- send({ type: "bridge:ready" });
640
+ if (event.persisted && (mode === "url" || token))
641
+ send("ready", {});
354
642
  }
355
643
  function dispose() {
356
644
  if (disposed)
357
645
  return;
358
646
  disconnect();
359
647
  disposed = true;
648
+ endpoint.dispose();
360
649
  frame.removeEventListener("message", receive);
361
650
  frame.removeEventListener("pagehide", disconnect);
362
651
  frame.removeEventListener("pageshow", resume);
@@ -365,8 +654,9 @@ var inlineBootstrap = `(() => {
365
654
  frame.addEventListener("pagehide", disconnect);
366
655
  frame.addEventListener("pageshow", resume);
367
656
  if (mode === "url")
368
- send({ type: "bridge:ready" });
657
+ send("ready", {});
369
658
  return {
659
+ logger,
370
660
  request: requestMessage,
371
661
  transport: queryOperation,
372
662
  lakehouse: {
@@ -386,10 +676,12 @@ var inlineBootstrap = `(() => {
386
676
  };
387
677
  },
388
678
  ready() {
389
- send({ type: "runtime:ready" });
679
+ if (!disposed && sessionId)
680
+ send("runtimeReady", {});
390
681
  },
391
682
  fail() {
392
- send({ type: "runtime:error" });
683
+ if (!disposed && sessionId)
684
+ send("runtimeError", {});
393
685
  }
394
686
  };
395
687
  }
@@ -417,6 +709,7 @@ var inlineBootstrap = `(() => {
417
709
  }) {
418
710
  let loaded;
419
711
  let failed = false;
712
+ const bundleSource = \`altertable-data-app-\${crypto.randomUUID()}.js\`;
420
713
  const bridge = createIframeTransport({
421
714
  parentOrigin,
422
715
  window: frame,
@@ -435,7 +728,8 @@ var inlineBootstrap = `(() => {
435
728
  loaded = javascript;
436
729
  try {
437
730
  const script = frame.document.createElement("script");
438
- script.textContent = javascript;
731
+ script.textContent = \`\${javascript}
732
+ //# sourceURL=\${bundleSource}\`;
439
733
  frame.document.body.append(script);
440
734
  if (!failed)
441
735
  bridge.ready();
@@ -448,12 +742,20 @@ var inlineBootstrap = `(() => {
448
742
  failed = true;
449
743
  bridge.fail();
450
744
  }
451
- frame.addEventListener("error", reportError);
452
- frame.addEventListener("unhandledrejection", reportError);
745
+ function reportBundleError(event) {
746
+ if (event.filename === bundleSource)
747
+ reportError();
748
+ }
749
+ function reportBundleRejection(event) {
750
+ if (event.reason instanceof Error && event.reason.stack?.includes(bundleSource))
751
+ reportError();
752
+ }
753
+ frame.addEventListener("error", reportBundleError);
754
+ frame.addEventListener("unhandledrejection", reportBundleRejection);
453
755
  const uninstall = installDataAppTransport(bridge, frame);
454
756
  return () => {
455
- frame.removeEventListener("error", reportError);
456
- frame.removeEventListener("unhandledrejection", reportError);
757
+ frame.removeEventListener("error", reportBundleError);
758
+ frame.removeEventListener("unhandledrejection", reportBundleRejection);
457
759
  uninstall();
458
760
  };
459
761
  }
@@ -486,6 +788,7 @@ function isPreviewHost(hostname, domainName) {
486
788
  return hostname.endsWith(suffix) && TOKEN_RE.test(hostname.slice(0, -suffix.length));
487
789
  }
488
790
  var worker_default = {
791
+ runtimeHtml,
489
792
  fetch(request, env) {
490
793
  const url = new URL(request.url);
491
794
  if (!isPreviewHost(url.hostname, env.DOMAIN_NAME) || url.pathname !== "/") {
@@ -1,7 +1,9 @@
1
1
  # Author a data app
2
2
 
3
3
  Build an exploration that answers the user's question and a story that presents
4
- its strongest findings. Both use the same queries, definitions, and evidence.
4
+ its strongest findings. Include CSV export so readers can take the displayed
5
+ results into their own tools. Exploration, export, and story use the same
6
+ queries, definitions, and evidence.
5
7
 
6
8
  ## Inspect the data
7
9
 
@@ -38,10 +40,31 @@ and failure.
38
40
  | Register source names | [Source identifiers](react.md#register-source-identifiers) |
39
41
  | Choose date and field filters | [Filter variables](react.md#time-views-and-field-filters) |
40
42
  | Handle refresh and stale results | [Displayed results](react.md#preserve-displayed-results) |
43
+ | Export displayed data as CSV | [CSV export](react.md#export-displayed-data-as-csv) |
41
44
  | Bind definitions and source evidence | [Data context](react.md#bind-evidence) |
42
45
 
43
46
  Use the exported types for configuration, appearance, formatting, and component
44
- options.
47
+ options. Declare configuration with `satisfies DataAppConfig` so appearance
48
+ fields and values are checked before bundling.
49
+
50
+ ## Compose the layout
51
+
52
+ See the [layout contract](layout.md).
53
+
54
+ ## Export the displayed results
55
+
56
+ Provide `DataApp.csvExport` in every analytical app. The request-backed API
57
+ requires a callback that selects an explicit filename and named datasets with ordered columns and raw
58
+ rows from the displayed snapshot. Follow [CSV export](react.md#export-displayed-data-as-csv)
59
+ and the [starter](../examples/starter-data-app/index.tsx); use the built-in toolbar
60
+ action rather than adding a custom download button.
61
+
62
+ Export every distinct analytical dataset at its displayed grain, including relevant
63
+ dimensions and measures. Reuse one dataset for charts or metrics derived from the
64
+ same rows. One dataset downloads as CSV; multiple datasets offer individual CSVs
65
+ and **Export all** as a ZIP archive. Use the displayed input for scope labels and filenames. Export the
66
+ bounded result the app already has; do not issue a different query or mix pending
67
+ filters into the visible result. Setup and static screens may omit export.
45
68
 
46
69
  ## Present the findings
47
70
 
@@ -54,7 +77,11 @@ row or chart into a step.
54
77
 
55
78
  Verify findings against the source and the user's question. Distinguish measured
56
79
  zero, unavailable values, and empty results. Check filters, refresh, loading,
57
- empty, error, and stale states, then present the story. Inspect both experiences
80
+ empty, error, and stale states, then present the story. Download CSV from the
81
+ standalone and embedded toolbar and verify its filename, columns, raw values,
82
+ and filter scope against the displayed result. Export and Present story must be
83
+ available once analytical results are shown; initial loading, empty, and initial
84
+ errors have neither action. Inspect both experiences
58
85
  at phone and desktop widths in light and dark themes.
59
86
 
60
87
  The app owns its queries, result parsing, business definitions, configuration,
package/docs/embed.md CHANGED
@@ -73,7 +73,7 @@ app bundle; non-React apps use `createDataAppNavigation()` from `/client`.
73
73
 
74
74
  `attachDataAppBridge()` also supports connection mode for a host-owned iframe. Supply
75
75
  `connection: { type: 'origin', origin }` or `{ type: 'opaque', token }`, and an
76
- `onMessage` dispatcher. Both modes use the same transport. It returns `dispose()` and `setPresentation()` methods and owns source/origin checks, request
76
+ `onMessage` dispatcher. Both modes use the same transport. It returns `dispose()`, `setPresentation()`, and `setLogger()` methods and owns source/origin checks, request
77
77
  correlation, cancellation, bounded pending requests, and reconnection. Use source mode for bundle loading and token rotation. The opaque destination requires
78
78
  wildcard delivery, but incoming messages still require the exact iframe window,
79
79
  null origin, token, document, and session to match.
@@ -4,7 +4,7 @@ Follow the [shared authoring flow](app-authoring.md) using [index.tsx](../exampl
4
4
  one file with public package imports; omit server files, HTML, credentials, and
5
5
  relative or app-alias imports.
6
6
 
7
- Replace the sample SQL, parsers, filters, data context, story, and configuration with
7
+ Replace the sample SQL, parsers, filters, data context, CSV export, story, and configuration with
8
8
  an exploration of the source data you inspected. The starter uses two SQL
9
9
  `VALUES` rows, so it needs no production table.
10
10
 
@@ -13,7 +13,7 @@ For execution details, see [browser-owned operations](client.md#browser-owned-op
13
13
  ## Convert a local data app
14
14
 
15
15
  1. Combine the app's operations and parsers, data context, views, story,
16
- configuration, and browser entry into one `index.tsx`, following the
16
+ CSV export, configuration, and browser entry into one `index.tsx`, following the
17
17
  [single-file starter](../examples/starter-data-app/index.tsx).
18
18
  2. Replace the HTTP client with `createDataClient({ operations })`, using the
19
19
  operation registry as a value. See [browser-owned operations](client.md#browser-owned-operations-for-bundle-apps)