@altertable/data-app 0.59.1 → 0.62.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 (71) hide show
  1. package/AGENTS.md +5 -3
  2. package/CONTRIBUTING.md +16 -4
  3. package/README.md +20 -14
  4. package/dist/chunks/{contract-jksbmt5q.js → contract-3bnrf5pf.js} +27 -5
  5. package/dist/chunks/contract-3bnrf5pf.js.map +10 -0
  6. package/dist/chunks/{contract-tkkc28tg.js → contract-4vrw9zk9.js} +43 -13
  7. package/dist/chunks/contract-4vrw9zk9.js.map +12 -0
  8. package/dist/chunks/{contract-14vxdcrs.js → contract-farfe948.js} +17 -21
  9. package/dist/chunks/contract-farfe948.js.map +10 -0
  10. package/dist/chunks/contract-nt819swq.js.map +1 -1
  11. package/dist/chunks/{contract-tqrf3ykr.js → contract-rrm7s5zp.js} +61 -32
  12. package/dist/chunks/contract-rrm7s5zp.js.map +12 -0
  13. package/dist/chunks/{contract-mb5nfzwg.js → contract-xjv197ck.js} +178 -31
  14. package/dist/chunks/contract-xjv197ck.js.map +13 -0
  15. package/dist/client/index.js +6 -4
  16. package/dist/client/index.js.map +1 -1
  17. package/dist/core/appearance.js +1 -1
  18. package/dist/core/contract.js +5 -3
  19. package/dist/core/contract.js.map +1 -1
  20. package/dist/embed/index.js +42 -8
  21. package/dist/embed/index.js.map +5 -4
  22. package/dist/local.js +191 -79
  23. package/dist/local.js.map +8 -7
  24. package/dist/react/embed/index.js +84 -99
  25. package/dist/react/embed/index.js.map +4 -5
  26. package/dist/react/index.css +156 -6
  27. package/dist/react/index.js +313 -152
  28. package/dist/react/index.js.map +17 -13
  29. package/dist/server.js +172 -76
  30. package/dist/server.js.map +7 -6
  31. package/dist/types/client/iframe.d.ts +14 -0
  32. package/dist/types/client/index.d.ts +19 -13
  33. package/dist/types/core/appearance.d.ts +7 -6
  34. package/dist/types/core/contract.d.ts +3 -3
  35. package/dist/types/core/messages.d.ts +9 -3
  36. package/dist/types/core/operation.d.ts +27 -0
  37. package/dist/types/core/presentation.d.ts +7 -0
  38. package/dist/types/embed/bridge.d.ts +12 -0
  39. package/dist/types/embed/host.d.ts +10 -3
  40. package/dist/types/embed/index.d.ts +7 -4
  41. package/dist/types/embed/{shell.d.ts → source.d.ts} +5 -4
  42. package/dist/types/embed/sql.d.ts +4 -0
  43. package/dist/types/react/embed/bridge.d.ts +19 -10
  44. package/dist/types/react/embed/index.d.ts +0 -2
  45. package/dist/types/react/ui/DataAppSkeleton.d.ts +8 -0
  46. package/dist/types/react/ui/SearchInput.d.ts +1 -1
  47. package/dist/types/react/ui/index.d.ts +2 -0
  48. package/dist/types/react/ui/useAppAppearance.d.ts +3 -0
  49. package/dist/types/react/ui/useDataAppPresentation.d.ts +3 -0
  50. package/dist/types/react/view-controls.d.ts +1 -1
  51. package/dist/{bootstrap.js → worker.js} +161 -29
  52. package/docs/app-authoring.md +24 -4
  53. package/docs/appearance.md +21 -8
  54. package/docs/client.md +40 -3
  55. package/docs/contract.md +9 -7
  56. package/docs/embed.md +114 -19
  57. package/docs/react-embed.md +84 -24
  58. package/docs/react.md +51 -0
  59. package/docs/releasing.md +5 -0
  60. package/docs/server-bun.md +5 -0
  61. package/docs/starter-agent-instructions.md +4 -2
  62. package/docs/worker.md +57 -0
  63. package/package.json +9 -5
  64. package/dist/chunks/contract-14vxdcrs.js.map +0 -10
  65. package/dist/chunks/contract-jksbmt5q.js.map +0 -10
  66. package/dist/chunks/contract-mb5nfzwg.js.map +0 -12
  67. package/dist/chunks/contract-tkkc28tg.js.map +0 -12
  68. package/dist/chunks/contract-tqrf3ykr.js.map +0 -11
  69. package/dist/types/embed/standalone.d.ts +0 -1
  70. package/dist/types/react/embed/shell.d.ts +0 -10
  71. package/docs/bootstrap.md +0 -76
@@ -1,4 +1,53 @@
1
- (() => {
1
+ // src/worker/trusted-parent.ts
2
+ function exactOrigin(value) {
3
+ const url = new URL(value);
4
+ if (!/^https?:$/.test(url.protocol) || url.origin !== value) {
5
+ throw new Error("Expected an exact HTTP(S) parent origin.");
6
+ }
7
+ return url;
8
+ }
9
+ function trustedParent(config, searchParams) {
10
+ if (typeof config !== "string" || !config.trim()) {
11
+ throw new Error("Missing trusted parent origins.");
12
+ }
13
+ const allowed = config.trim().split(/\s+/).map((value) => {
14
+ const wildcard = value.startsWith("https://*.");
15
+ const url = exactOrigin(wildcard ? value.replace("*.", "") : value);
16
+ if (url.hostname.includes("*") || wildcard && url.port) {
17
+ throw new Error("Invalid parent origin pattern.");
18
+ }
19
+ return { value, url, wildcard };
20
+ });
21
+ const requested = searchParams.getAll("__altertable_parent");
22
+ if (!requested.length) {
23
+ const fallback = allowed.find((origin) => !origin.wildcard);
24
+ if (!fallback)
25
+ throw new Error("An exact default parent origin is required.");
26
+ return fallback.value;
27
+ }
28
+ if (requested.length !== 1)
29
+ return null;
30
+ let url;
31
+ try {
32
+ url = exactOrigin(requested[0]);
33
+ } catch {
34
+ return null;
35
+ }
36
+ const permitted = allowed.some((origin) => {
37
+ if (!origin.wildcard)
38
+ return origin.value === requested[0];
39
+ if (url.protocol !== "https:" || url.port)
40
+ return false;
41
+ const suffix = `.${origin.url.hostname}`;
42
+ return url.hostname.endsWith(suffix) && /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(url.hostname.slice(0, -suffix.length));
43
+ });
44
+ return permitted ? requested[0] : null;
45
+ }
46
+
47
+ // src/worker/index.ts
48
+ var TOKEN_RE = /^(?=.{1,63}$)[a-z0-9]+(?:-[a-z0-9]+)+-app-[1-9][0-9]*$/;
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
+ var inlineBootstrap = `(() => {
2
51
  // src/core/bridge.ts
3
52
  var BRIDGE = "altertable:data-app";
4
53
  var MAX_PENDING = 128;
@@ -30,6 +79,9 @@
30
79
  this.name = "MessageRoutingError";
31
80
  }
32
81
  }
82
+ function defineMessageRoute(route) {
83
+ return route;
84
+ }
33
85
  function defineDataQueryRoute(operations) {
34
86
  return {
35
87
  operations,
@@ -51,7 +103,7 @@
51
103
  if (!value || typeof value !== "object")
52
104
  throw new Error("Invalid data response.");
53
105
  const response = value;
54
- if (!Number.isInteger(response.status) || response.status < 100 || response.status > 599 || !response.body || typeof response.body !== "object")
106
+ if (!Number.isInteger(response.status) || response.status < 100 || response.status > 599 || !response.body || typeof response.body !== "object" || Array.isArray(response.body))
55
107
  throw new Error("Invalid data response.");
56
108
  const body = response.body;
57
109
  if (response.status < 200 || response.status >= 300) {
@@ -73,6 +125,28 @@
73
125
  }
74
126
  };
75
127
  }
128
+ var sqlQueryRoute = defineMessageRoute({
129
+ input(value) {
130
+ if (!value || typeof value !== "object")
131
+ throw new Error("Invalid SQL query.");
132
+ const query = value;
133
+ if (typeof query.statement !== "string" || !query.statement.trim() || typeof query.limit !== "number" || !Number.isSafeInteger(query.limit) || query.limit < 1)
134
+ throw new Error("Invalid SQL query.");
135
+ return { statement: query.statement, limit: query.limit };
136
+ },
137
+ output(value, input) {
138
+ if (!value || typeof value !== "object")
139
+ throw new Error("Invalid query result.");
140
+ const result = value;
141
+ if (!Array.isArray(result.columns) || !result.columns.every((column) => column && typeof column.name === "string" && (column.type === undefined || typeof column.type === "string")) || !Array.isArray(result.rows) || result.rows.length > input.limit || !result.rows.every((row) => Array.isArray(row) && row.length === result.columns.length) || result.queryId !== undefined && typeof result.queryId !== "string")
142
+ throw new Error("Invalid query result.");
143
+ return {
144
+ columns: result.columns,
145
+ rows: result.rows,
146
+ ...result.queryId === undefined ? {} : { queryId: result.queryId }
147
+ };
148
+ }
149
+ });
76
150
 
77
151
  // src/client/messages.ts
78
152
  function createMessageClient(routes, transport) {
@@ -120,6 +194,11 @@
120
194
  }
121
195
 
122
196
  // src/client/iframe.ts
197
+ function rethrowDataMessageError(error) {
198
+ if (error instanceof MessageRoutingError)
199
+ throw new DataAppError(error.message, error.code, error.requestId);
200
+ throw error;
201
+ }
123
202
  function createIframeTransport({
124
203
  parentOrigin,
125
204
  window: frame = window,
@@ -148,20 +227,20 @@
148
227
  if (event.origin !== parentOrigin || event.source !== frame.parent || !isBridgeMessage(event.data))
149
228
  return;
150
229
  const message = event.data;
151
- if (message.type === "connect") {
230
+ if (message.type === "bridge:connect") {
152
231
  if (mode === "bundle") {
153
232
  if (!validId(message.token))
154
233
  return;
155
234
  token = message.token;
156
235
  }
157
- send({ type: "ready" });
236
+ send({ type: "bridge:ready" });
158
237
  return;
159
238
  }
160
239
  if (mode === "bundle" && (!token || message.token !== token))
161
240
  return;
162
241
  if (message.documentId !== documentId)
163
242
  return;
164
- if (message.type === "initialize" && validId(message.sessionId)) {
243
+ if (message.type === "bridge:initialize" && validId(message.sessionId)) {
165
244
  if (sessionId && sessionId !== message.sessionId) {
166
245
  for (const entry of pending.values())
167
246
  entry.reject(new DataAppError("The preview reconnected. Retry the request.", "bridge_reset"));
@@ -173,16 +252,16 @@
173
252
  entry.start();
174
253
  receiveState(message.state);
175
254
  if (mode === "url")
176
- send({ type: "runtime.ready" });
255
+ send({ type: "runtime:ready" });
177
256
  return;
178
257
  }
179
258
  if (!sessionId || message.sessionId !== sessionId)
180
259
  return;
181
- if (message.type === "state") {
260
+ if (message.type === "state:update") {
182
261
  receiveState(message.state);
183
262
  return;
184
263
  }
185
- if (message.type === "script.load" && mode === "bundle" && typeof message.javascript === "string") {
264
+ if (message.type === "script:load" && mode === "bundle" && typeof message.javascript === "string") {
186
265
  loadScript?.(message.javascript);
187
266
  return;
188
267
  }
@@ -191,9 +270,9 @@
191
270
  const entry = pending.get(message.id);
192
271
  if (!entry)
193
272
  return;
194
- if (message.type === "result" && "response" in message) {
273
+ if (message.type === "bridge:result" && "response" in message) {
195
274
  entry.resolve(message.response);
196
- } else if (message.type === "error" && typeof message.code === "string" && typeof message.message === "string") {
275
+ } else if (message.type === "bridge:error" && typeof message.code === "string" && typeof message.message === "string") {
197
276
  entry.reject(new MessageRoutingError(message.code, message.message, typeof message.requestId === "string" ? message.requestId : undefined));
198
277
  }
199
278
  }
@@ -215,13 +294,13 @@
215
294
  }
216
295
  function abort() {
217
296
  if (sent)
218
- send({ type: "cancel", id });
297
+ send({ type: "bridge:cancel", id });
219
298
  cleanup();
220
299
  reject(signal?.reason);
221
300
  }
222
301
  const timer = setTimeout(() => {
223
302
  if (sent)
224
- send({ type: "cancel", id });
303
+ send({ type: "bridge:cancel", id });
225
304
  cleanup();
226
305
  reject(new DataAppError(sessionId ? "The data request timed out." : "The preview shell is unavailable. Reload the preview.", sessionId ? "timeout" : "bridge_unavailable"));
227
306
  }, timeoutMs);
@@ -231,7 +310,7 @@
231
310
  return;
232
311
  try {
233
312
  send({
234
- type: "request",
313
+ type: "bridge:request",
235
314
  id,
236
315
  route: message.route,
237
316
  payload: message.payload
@@ -256,28 +335,22 @@
256
335
  if (sessionId)
257
336
  entry.start();
258
337
  else
259
- send({ type: "ready" });
338
+ send({ type: "bridge:ready" });
260
339
  });
261
340
  }
262
- const messages = createMessageClient({ "data.query": defineDataQueryRoute() }, requestMessage);
263
- async function queryData(operation, input, signal) {
264
- try {
265
- return await messages.request("data.query", { operation, input }, { signal });
266
- } catch (error) {
267
- if (error instanceof MessageRoutingError)
268
- throw new DataAppError(error.message, error.code, error.requestId);
269
- throw error;
270
- }
341
+ const messages = createMessageClient({ "data:query": defineDataQueryRoute(), "data:sql": sqlQueryRoute }, requestMessage);
342
+ function queryOperation(operation, input, signal) {
343
+ return messages.request("data:query", { operation, input }, { signal }).catch(rethrowDataMessageError);
271
344
  }
272
345
  function disconnect() {
273
- send({ type: "disconnect" });
346
+ send({ type: "bridge:disconnect" });
274
347
  sessionId = undefined;
275
348
  for (const entry of pending.values())
276
349
  entry.reject(new DataAppError("The preview has closed.", "bridge_closed"));
277
350
  }
278
351
  function resume(event) {
279
352
  if (event.persisted)
280
- send({ type: "ready" });
353
+ send({ type: "bridge:ready" });
281
354
  }
282
355
  function dispose() {
283
356
  if (disposed)
@@ -292,10 +365,15 @@
292
365
  frame.addEventListener("pagehide", disconnect);
293
366
  frame.addEventListener("pageshow", resume);
294
367
  if (mode === "url")
295
- send({ type: "ready" });
368
+ send({ type: "bridge:ready" });
296
369
  return {
297
370
  request: requestMessage,
298
- transport: queryData,
371
+ transport: queryOperation,
372
+ lakehouse: {
373
+ queryAll(statement, { limit, signal }) {
374
+ return messages.request("data:sql", { statement, limit }, { signal }).catch(rethrowDataMessageError);
375
+ }
376
+ },
299
377
  dispose,
300
378
  mode,
301
379
  snapshot() {
@@ -308,10 +386,10 @@
308
386
  };
309
387
  },
310
388
  ready() {
311
- send({ type: "runtime.ready" });
389
+ send({ type: "runtime:ready" });
312
390
  },
313
391
  fail() {
314
- send({ type: "runtime.error" });
392
+ send({ type: "runtime:error" });
315
393
  }
316
394
  };
317
395
  }
@@ -386,3 +464,57 @@
386
464
  throw new Error("Missing trusted parent origin.");
387
465
  startDataAppBootstrap({ parentOrigin });
388
466
  })();
467
+ `.replace(/<\/script/gi, (match) => `<\\${match.slice(1)}`);
468
+ function runtimeHtml(parentOrigin) {
469
+ const attribute = parentOrigin.replaceAll("&", "&amp;").replaceAll('"', "&quot;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll("'", "&#39;");
470
+ return `<!doctype html>
471
+ <html>
472
+ <head>
473
+ <meta charset="utf-8">
474
+ <meta name="viewport" content="width=device-width,initial-scale=1">
475
+ <meta http-equiv="Content-Security-Policy" content="${PAGE_CSP}">
476
+ </head>
477
+ <body>
478
+ <div id="root"></div>
479
+ <script data-parent-origin="${attribute}">${inlineBootstrap}</script>
480
+ </body>
481
+ </html>
482
+ `;
483
+ }
484
+ function isPreviewHost(hostname, domainName) {
485
+ const suffix = `.${domainName}`;
486
+ return hostname.endsWith(suffix) && TOKEN_RE.test(hostname.slice(0, -suffix.length));
487
+ }
488
+ var worker_default = {
489
+ fetch(request, env) {
490
+ const url = new URL(request.url);
491
+ if (!isPreviewHost(url.hostname, env.DOMAIN_NAME) || url.pathname !== "/") {
492
+ return new Response(null, { status: 404 });
493
+ }
494
+ if (request.method !== "GET" && request.method !== "HEAD") {
495
+ return new Response(null, {
496
+ status: 405,
497
+ headers: { Allow: "GET, HEAD" }
498
+ });
499
+ }
500
+ let parent;
501
+ try {
502
+ parent = trustedParent(env.PARENT_ORIGINS, url.searchParams);
503
+ } catch {
504
+ return new Response(null, { status: 503 });
505
+ }
506
+ if (!parent)
507
+ return new Response(null, { status: 403 });
508
+ return new Response(request.method === "HEAD" ? null : runtimeHtml(parent), {
509
+ headers: {
510
+ "Content-Type": "text/html; charset=utf-8",
511
+ "Content-Security-Policy": `${PAGE_CSP}; frame-ancestors ${env.PARENT_ORIGINS.trim().split(/\s+/).join(" ")}`,
512
+ "X-Content-Type-Options": "nosniff",
513
+ "Referrer-Policy": "no-referrer"
514
+ }
515
+ });
516
+ }
517
+ };
518
+ export {
519
+ worker_default as default
520
+ };
@@ -7,12 +7,12 @@ calls, and reusable React UI.
7
7
  1. Inspect the source data, time coverage, and existing definitions before choosing
8
8
  the exploration. Build findings from observed results and distinguish
9
9
  association from cause.
10
- 2. Define named, bounded [operations](contract.md) on the server. Put shared input
10
+ 2. Define named, bounded [operations](contract.md) in the execution runtime. Put shared input
11
11
  contracts in a browser-safe module and validate outputs before returning them.
12
- 3. Use a [server handler](server.md) that authorizes each request, or the
12
+ 3. For HTTP apps, use a [server handler](server.md) that authorizes each request, or the
13
13
  [Bun adapter](server-bun.md) for local development.
14
14
  4. Create a typed [client](client.md) and compose [React views](react.md). Import
15
- the operation registry with `import type` in browser code.
15
+ the operation registry with `import type` for HTTP apps, or as a value for bundle apps.
16
16
  5. Define glossary and query evidence, handle empty results, and preserve the
17
17
  displayed input while a request refreshes or fails. A measured zero and an
18
18
  unavailable value must remain distinct.
@@ -21,6 +21,26 @@ calls, and reusable React UI.
21
21
 
22
22
  Import through `@altertable/data-app/<entry>`. Installed package files are
23
23
  dependencies; customize the app's own source rather than editing `node_modules`.
24
- SQL, credentials, and viewer authorization belong on the server.
24
+ For HTTP apps, SQL, credentials, and viewer authorization belong on the server.
25
+ For bundle apps, define operations in the browser and pass them to
26
+ `createDataClient({ operations })`; SQL travels through the authorized
27
+ [SQL bridge](embed.md#sql-query-route). Credentials, viewer authorization, and
28
+ enforced query limits remain backend-owned.
29
+
30
+ ## Execution ownership
31
+
32
+ The client selects one of two paths when it is created:
33
+
34
+ | App model | Operation execution | Query delivery |
35
+ | ---------- | -------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- |
36
+ | HTTP app | The browser sends a name and input; the server authorizes the request and runs its registered operation. | A server-owned lakehouse adapter executes SQL. |
37
+ | Bundle app | The browser directly runs the registry passed to `createDataClient({ operations })`. | `bridge.lakehouse` sends SQL to the host, which authorizes each query and supplies a backend adapter. |
38
+
39
+ Both paths use the same internal operation executor for input/output validation,
40
+ query names, row bounds, deadlines, evidence, and response serialization limits.
41
+ The executor accepts a `Lakehouse` and never chooses a transport or authorizes a
42
+ viewer. Each adapter owns its boundary: HTTP owns request parsing and responses;
43
+ the bridge owns message delivery; the SQL host owns authorization and public query
44
+ errors. Backend permissions and resource limits apply independently of app policy.
25
45
 
26
46
  For agent-assisted authoring, see the [starter AGENTS.md template](starter-agent-instructions.md).
@@ -1,30 +1,43 @@
1
1
  # Appearance
2
2
 
3
3
  Import `parseAppearance`, `applyAppearance`, and `createThemeController` from
4
- `@altertable/data-app/appearance`. Parsing is safe on the server; applying tokens
5
- and controlling theme require a browser document.
4
+ `@altertable/data-app/appearance`. Parsing is safe on the server. Applying tokens requires a browser document;
5
+ viewer preferences use browser storage.
6
6
 
7
7
  ```ts
8
8
  import { parseAppearance } from '@altertable/data-app/appearance';
9
9
 
10
10
  const appearance = parseAppearance({
11
- mode: 'system',
11
+ theme: 'system',
12
12
  accentColor: '#405d47',
13
13
  density: 'comfortable',
14
14
  });
15
15
  ```
16
16
 
17
17
  `parseAppearance` fills omitted settings with defaults and rejects unknown keys
18
- or invalid values. Settings include light/dark/system mode, neutral/slate/warm
18
+ or invalid values. Settings include light/dark/system theme, neutral/slate/warm
19
19
  base colors, accent colors, a chart palette, density, corner radius, elevation,
20
20
  and body/heading typography. Colors are six-digit hexadecimal values.
21
21
 
22
22
  `applyAppearance(appearance)` installs semantic CSS tokens on the document root
23
23
  and returns a cleanup function for system-theme listening.
24
- `createThemeController(appearance)` applies the initial theme and provides
25
- `getMode`, `setMode`, and `subscribe`. Viewer mode persists in local storage
26
- independently of app-authored brand settings. Storage failures do not prevent
27
- the selection from applying to the current page.
24
+ `createThemeController(initialTheme)` manages the viewer's preference through
25
+ `getTheme`, `setTheme`, and `subscribe`. It reads and persists local storage
26
+ when available. Apply the preference with `applyAppearance` in the UI owner;
27
+ the controller itself does not modify the document.
28
28
 
29
29
  The React `DataApp` shell manages appearance for normal app usage. See
30
30
  [configuration](config.md), [React](react.md), and [styles](react-styles.md).
31
+
32
+ `Theme` is the resolved `'light' | 'dark'` theme. `ThemePreference` additionally
33
+ allows `'system'` for standalone viewers. A trusted host supplies a `theme: Theme`
34
+ through [parent presentation](embed.md#parent-presentation). React applies that
35
+ theme directly with app-owned brand tokens; standalone viewers use the preference
36
+ controller. Appearance effect cleanup releases the system preference listener.
37
+
38
+ When migrating theme controls, replace `getMode`/`setMode` with
39
+ `getTheme`/`setTheme`, and initialize the controller with a `ThemePreference`
40
+ rather than appearance settings. Subscribe to the controller and compose its
41
+ preference with `applyAppearance` to update document tokens.
42
+
43
+ Appearance configuration uses `theme` (formerly `mode`).
package/docs/client.md CHANGED
@@ -22,14 +22,51 @@ implementation. Calls POST JSON to `/api/data/:operation`; the client sends
22
22
  operation inputs rather than SQL or credentials.
23
23
 
24
24
  `DataResponse` contains `data`, the exact request `input`, `requestId`,
25
- `queriedAt`, and `queryIds`. `queries` is present only when the server permits
26
- SQL disclosure. Use the returned input when labeling stale data during a refresh.
25
+ `queriedAt`, and `queryIds`. `queries` is present when the operation exposes SQL
26
+ and the execution runtime permits disclosure. Use the returned input when labeling stale data during a refresh.
27
+ HTTP and iframe delivery validate the same success envelope: data, request ID,
28
+ query timestamp, query IDs, and optional query evidence. Malformed responses
29
+ reject with `invalid_response`.
30
+
27
31
  `DataAppError` exposes a `code` and optional `requestId`; cancellation follows
28
32
  the supplied abort signal.
29
33
 
30
34
  See [contracts](contract.md), [server handlers](server.md), and
31
35
  [React bindings](react.md).
32
36
 
37
+ ## Browser-owned operations for bundle apps
38
+
39
+ Bundle apps pass their operation registry as a value:
40
+
41
+ ```ts
42
+ import { connectionCheck } from '@altertable/data-app/contract';
43
+ import { createDataClient } from '@altertable/data-app/client';
44
+
45
+ const client = createDataClient({
46
+ operations: { connection: connectionCheck() },
47
+ });
48
+ const response = await client.query('connection', {});
49
+ ```
50
+
51
+ The client runs input parsing, operation logic, and output parsing in the browser.
52
+ It uses the same executor as the server handler for query names, row and duration
53
+ bounds, response size, and query evidence. Each query sends `{ statement, limit }`
54
+ to the installed iframe bridge's `data:sql` route; the host needs no operation
55
+ registry. SQL is visible in the browser, even when `exposeSql` is false; that flag
56
+ only controls evidence in the returned response. Credentials remain backend-owned.
57
+
58
+ The trusted bootstrap installs the bridge for bundle apps. A custom runtime must
59
+ install it before querying. An explicit `lakehouse` can supply another authorized
60
+ adapter, including `bridge.lakehouse` or a local server adapter. `operations` cannot
61
+ be combined with `transport`, `endpoint`, or `fetch`; a `lakehouse` requires
62
+ `operations`. The exported `DataClientOptions` union rejects mixed configurations
63
+ at compile time. Omitting `operations` preserves named HTTP/iframe operation delivery.
64
+
65
+ The host must implement and authorize the [SQL route](embed.md#sql-query-route).
66
+ Browser policies improve app behavior; backend access and resource limits must be
67
+ enforced independently because a frame can forge requests. Cancellation reaches
68
+ the host through the existing bridge cancellation protocol.
69
+
33
70
  ## Iframe transport
34
71
 
35
72
  `createDataClient({ transport })` accepts a `DataTransport`. Without an explicit
@@ -68,7 +105,7 @@ import {
68
105
  const bridge = getDataAppTransport();
69
106
  if (!bridge) throw new Error('An iframe transport must be installed first.');
70
107
  const messages = createMessageClient(routes, bridge.request);
71
- const result = await messages.request('echo', 'hello', { signal });
108
+ const result = await messages.request('demo:echo', 'hello', { signal });
72
109
  ```
73
110
 
74
111
  The app supplies shared `routes` and optional `signal`. Both client and host
package/docs/contract.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  Import operation definitions, parsers, and shared types from
4
4
  `@altertable/data-app/contract`. This entry is safe to import in browser and server
5
- modules. Keep SQL and operation implementations on the server; browser modules
5
+ modules. For HTTP apps, keep SQL and operation implementations on the server; browser modules
6
6
  should import their operation types using `import type`.
7
7
 
8
8
  ## Execute named queries
@@ -30,7 +30,7 @@ const activity = defineOperation({
30
30
  });
31
31
  ```
32
32
 
33
- `query` inherits the operation's limit and cancellation signal; `{ limit }` can lower a particular query's bound. Names are checked by TypeScript and at runtime. The server records the SQL and query ID when execution occurs, so evidence does not need a separate result field. Browser modules import operation types with `import type`; they never import server implementations.
33
+ `query` inherits the operation's limit and cancellation signal; `{ limit }` can lower a particular query's bound. Names are checked by TypeScript and at runtime. The executor records the SQL and query ID when execution occurs, so evidence does not need a separate result field. HTTP browser modules import operation types with `import type`. Bundle apps import their browser-owned operation registry as a value and use [browser execution](client.md#browser-owned-operations-for-bundle-apps). Never bundle credentials or server adapters.
34
34
 
35
35
  ## Shared date ranges
36
36
 
@@ -63,6 +63,8 @@ Message contracts describe the payloads allowed between an embedded app and its
63
63
  host. Share these contracts with the app; keep handlers and authorization in the
64
64
  host/server.
65
65
 
66
+ Name routes `{scope}:{action}`, for example `data:query` or `navigation:update`.
67
+
66
68
  ```ts
67
69
  import {
68
70
  createMessageRouter,
@@ -72,12 +74,12 @@ import {
72
74
  import { createNavigationHandler } from '@altertable/data-app/embed';
73
75
 
74
76
  const routes = {
75
- echo: defineMessageRoute({ input: parseString, output: parseString }),
76
- 'navigation.update': navigationUpdateRoute,
77
+ 'demo:echo': defineMessageRoute({ input: parseString, output: parseString }),
78
+ 'navigation:update': navigationUpdateRoute,
77
79
  };
78
80
  const router = createMessageRouter(routes, {
79
- echo: value => value,
80
- 'navigation.update': createNavigationHandler(),
81
+ 'demo:echo': value => value,
82
+ 'navigation:update': createNavigationHandler(),
81
83
  });
82
84
  ```
83
85
 
@@ -90,6 +92,6 @@ errors are replaced with a generic failure.
90
92
  input and output and preserves its response evidence. It infers the result type
91
93
  from the selected operation when used with `createMessageClient`. Share input and
92
94
  output parsers, not operation implementations containing SQL or credentials.
93
- `dataAppRoutes` supplies generic `data.query` and `navigation.update` contracts;
95
+ `dataAppRoutes` supplies generic `data:query` and `navigation:update` contracts;
94
96
  generic hosts must delegate operation validation and authorization to their
95
97
  server. Request handlers receive `{ signal }` for cancellation.