@malloy-publisher/skills 0.1.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.
- package/LICENSE +21 -0
- package/README.md +68 -0
- package/dist/index.d.ts +22 -0
- package/dist/index.js +47 -0
- package/package.json +54 -0
- package/skills/malloy/SKILL.md +45 -0
- package/skills/malloy-analysis/SKILL.md +76 -0
- package/skills/malloy-analysis-pitfalls/SKILL.md +68 -0
- package/skills/malloy-analysis-report/SKILL.md +125 -0
- package/skills/malloy-analyze/SKILL.md +262 -0
- package/skills/malloy-charts/SKILL.md +417 -0
- package/skills/malloy-debug/SKILL.md +121 -0
- package/skills/malloy-define/SKILL.md +194 -0
- package/skills/malloy-discover/SKILL.md +182 -0
- package/skills/malloy-document/SKILL.md +165 -0
- package/skills/malloy-getting-started/SKILL.md +57 -0
- package/skills/malloy-gotchas-modeling/SKILL.md +277 -0
- package/skills/malloy-gotchas-queries/SKILL.md +132 -0
- package/skills/malloy-gotchas-rendering/SKILL.md +124 -0
- package/skills/malloy-html-data-app-embedding/SKILL.md +39 -0
- package/skills/malloy-html-data-app-runtime/SKILL.md +158 -0
- package/skills/malloy-html-data-apps/SKILL.md +165 -0
- package/skills/malloy-html-data-apps/reference/lazy-load.md +84 -0
- package/skills/malloy-html-data-apps/reference/verification-harness.md +121 -0
- package/skills/malloy-lookml-review/SKILL.md +79 -0
- package/skills/malloy-lookml-review/reference/_concepts.md +42 -0
- package/skills/malloy-lookml-review/reference/build-derived-tables.md +110 -0
- package/skills/malloy-lookml-review/reference/build-unnest.md +47 -0
- package/skills/malloy-lookml-review/reference/curate-visibility.md +66 -0
- package/skills/malloy-lookml-review/reference/discover.md +102 -0
- package/skills/malloy-lookml-review/reference/document.md +28 -0
- package/skills/malloy-lookml-review/reference/propose-fields.md +75 -0
- package/skills/malloy-lookml-review/reference/review-coverage.md +102 -0
- package/skills/malloy-materialization-tuning/SKILL.md +103 -0
- package/skills/malloy-model/SKILL.md +320 -0
- package/skills/malloy-model/reference/access-modifiers.md +21 -0
- package/skills/malloy-model/reference/analysis-to-model.md +82 -0
- package/skills/malloy-model/reference/bridge-tables.md +51 -0
- package/skills/malloy-model/reference/normalized-schemas.md +66 -0
- package/skills/malloy-model/reference/query-sources.md +57 -0
- package/skills/malloy-modeling/SKILL.md +174 -0
- package/skills/malloy-notebook-chat/SKILL.md +19 -0
- package/skills/malloy-notebooks/SKILL.md +225 -0
- package/skills/malloy-patterns/SKILL.md +117 -0
- package/skills/malloy-phrase-detection/SKILL.md +75 -0
- package/skills/malloy-publish/SKILL.md +111 -0
- package/skills/malloy-queries/SKILL.md +270 -0
- package/skills/malloy-review/SKILL.md +160 -0
- package/skills/malloy-review/reference/output-template.md +164 -0
- package/skills/malloy-review/reference/rubric-correctness.md +161 -0
- package/skills/malloy-review/reference/rubric-documentation.md +69 -0
- package/skills/malloy-review/reference/rubric-governance.md +37 -0
- package/skills/malloy-review/reference/rubric-queries.md +78 -0
- package/skills/malloy-review/reference/rubric-rendering.md +127 -0
- package/skills/malloy-review/reference/rubric-structure.md +68 -0
- package/skills/malloy-review/reference/rubric-style.md +59 -0
- package/skills/malloy-review/reference/scope-resolution.md +100 -0
- package/skills/malloy-review/reference/severity-taxonomy.md +139 -0
- package/skills/malloy-scope/SKILL.md +100 -0
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: malloy-getting-started
|
|
3
|
+
description: First steps for using a Malloy Publisher deployment through its MCP tools. Use when connecting to Publisher for the first time, when you do not yet know the available environments, packages, or models, or when a user asks what data they can explore. Covers verifying the server, discovering data with malloy_getContext, and running a first grounded query.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Getting started with Malloy Publisher
|
|
7
|
+
|
|
8
|
+
Goal: go from "connected" to a correct, grounded answer without guessing any names.
|
|
9
|
+
|
|
10
|
+
## 0. Confirm the tools are reachable
|
|
11
|
+
|
|
12
|
+
At minimum you need `malloy_getContext`, `malloy_executeQuery`, and `malloy_searchDocs`. Authoring a model also needs `malloy_compile` and `malloy_reloadPackage` (see section 4); an older Publisher may not serve those two.
|
|
13
|
+
|
|
14
|
+
If none of the tools are there, either the server is not running or your client connected before it was. Start the server (`npx @malloy-publisher/server --port 4000`, or `bun run build && bun run start` from a clone) and wait until `curl -s http://localhost:4000/api/v0/status` reports `operationalState: serving`.
|
|
15
|
+
|
|
16
|
+
If you started the server yourself in this session, the tools still will not appear: your tool list was fixed when you connected, and you cannot reconnect yourself. Tell the user the tools are missing for that reason and ask them to run `/mcp`, select `malloy`, and choose Reconnect. The panel offers `Authenticate` first and reports `Auth: not authenticated`; that is a red herring, the endpoint has no auth. Restarting Claude Code also works. Continue once the tools are there.
|
|
17
|
+
|
|
18
|
+
When a user is present, do not route around it by calling the REST API with curl. It appears to work, so the user never learns their session is missing the tools, and you lose what they are for: grounded discovery instead of guessed names, `malloy_compile` instead of throwaway queries, and `malloy_reloadPackage` instead of a restart. Say the tools are missing and let the user fix it in five seconds. Running unattended, with nobody who can reconnect you, is different: there the REST API is the supported interface, not a workaround. Discovery, query, compile, and reload all have REST equivalents (`malloy_searchDocs` and `malloy_getContext`'s plain-English ranking do not; read the bundled skills for syntax and ground from model metadata instead); the running server serves the full spec at `http://localhost:4000/api-doc.yaml`, and AGENTS.md carries the endpoint map.
|
|
19
|
+
|
|
20
|
+
## 1. Discover what exists (never guess names)
|
|
21
|
+
|
|
22
|
+
`malloy_getContext` is progressive. Call it with as much as you know:
|
|
23
|
+
|
|
24
|
+
- No arguments: the available environments, each with its package names.
|
|
25
|
+
- `environmentName` only: the packages in that environment.
|
|
26
|
+
- `environmentName` + `packageName`: that package's sources.
|
|
27
|
+
- `environmentName` + `packageName` + `query` (plain English): the sources, views, named queries, and dimension/measure fields most relevant to the question.
|
|
28
|
+
|
|
29
|
+
Use the names it returns exactly. Do not invent environments, packages, sources, or fields.
|
|
30
|
+
|
|
31
|
+
## 2. Run the query
|
|
32
|
+
|
|
33
|
+
Call `malloy_executeQuery` with the `environmentName`, `packageName`, and `modelPath` from the context results, plus either:
|
|
34
|
+
|
|
35
|
+
- a named view or query: pass its `name` as `queryName` (with `sourceName` for a view), or
|
|
36
|
+
- an ad-hoc query: pass Malloy code as `query`.
|
|
37
|
+
|
|
38
|
+
The result is JSON. Charts and dashboards defined in the model render in the Publisher UI at http://localhost:4000.
|
|
39
|
+
|
|
40
|
+
## 3. When you need Malloy syntax
|
|
41
|
+
|
|
42
|
+
Use `malloy_searchDocs` for language questions (filters, aggregates, joins, nesting, renderers).
|
|
43
|
+
|
|
44
|
+
## 4. What else you can do here
|
|
45
|
+
|
|
46
|
+
Answering questions is the start, not the whole surface. When the user asks what is possible, say so rather than offering queries alone. Switch skills for the deeper work:
|
|
47
|
+
|
|
48
|
+
- `malloy-modeling`: build or change a model. Validate the edit with `malloy_compile`, save it, then `malloy_reloadPackage` so the new sources and views run by name without restarting the server.
|
|
49
|
+
- `malloy-analysis`: explore a package and answer data questions.
|
|
50
|
+
- `malloy-html-data-apps`: build a data app, a hand-authored HTML page in the package's `public/` directory that Publisher serves, backed by the package's models and needing no build step.
|
|
51
|
+
- `malloy-review`: check Malloy for correctness.
|
|
52
|
+
|
|
53
|
+
## Contract
|
|
54
|
+
|
|
55
|
+
- Ground every query in `malloy_getContext` results. If a name is not in the results, do not use it.
|
|
56
|
+
- Start broad and narrow down: environments, then packages, then sources, then query.
|
|
57
|
+
- Confirm the environment and package before running a query.
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: malloy-gotchas-modeling
|
|
3
|
+
description: Common Malloy modeling mistakes and how to avoid them. Read BEFORE writing source definitions, dimensions, measures, or joins. Covers reserved words, NULL checks, date functions, type casts, field management (extend except/accept/rename vs include public/internal/private), and query-based source gotchas.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Modeling Gotchas
|
|
7
|
+
|
|
8
|
+
> **Read this before writing Malloy code.** These patterns cause most modeling errors.
|
|
9
|
+
|
|
10
|
+
> **Tool names** are written bare here - `get_context`, `execute_query`, `search_malloy_docs`. The exact prefixed name depends on the host surface; match each against the tools you actually have.
|
|
11
|
+
|
|
12
|
+
## Reserved Words: Backtick Them
|
|
13
|
+
|
|
14
|
+
**When in doubt, backtick it.** Unquoted reserved words cause cascading errors on unrelated lines.
|
|
15
|
+
|
|
16
|
+
```malloy
|
|
17
|
+
// WRONG // RIGHT
|
|
18
|
+
dimension: d is Date::date dimension: d is `Date`::date
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Words most likely to appear as column names:
|
|
22
|
+
```
|
|
23
|
+
date, time, day, month, year, quarter, week, hour, minute, second,
|
|
24
|
+
number, string, boolean, type, table, source, index, count, sum, avg, min, max,
|
|
25
|
+
true, false, null, is, on, with, all, from, by, in, to, for, select, order_by,
|
|
26
|
+
top, bottom, desc, asc, row, range, current, window, rank
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
- `number`: only the bare word needs backticking; `account_number` is fine
|
|
30
|
+
- `source`: reserved; use a different alias like `traffic_source`
|
|
31
|
+
|
|
32
|
+
## NULL Checks: `is not null`, NOT `!= null`
|
|
33
|
+
|
|
34
|
+
```malloy
|
|
35
|
+
// WRONG // RIGHT
|
|
36
|
+
dimension: is_sold is sold_at != null dimension: is_sold is sold_at is not null
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Date Functions vs Properties
|
|
40
|
+
|
|
41
|
+
```malloy
|
|
42
|
+
// WRONG: day_of_week is a function // RIGHT
|
|
43
|
+
dimension: dow is created_at.day_of_week dimension: dow is day_of_week(created_at)
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
**Property access:** `.month`, `.year`, `.quarter`, `.day`, `::date`
|
|
47
|
+
**Function call required:** `day_of_week()`, `week()`, `hour()`, `minute()`, `second()`
|
|
48
|
+
|
|
49
|
+
## `.date` Is a Cast, Not a Truncation
|
|
50
|
+
|
|
51
|
+
Calendar truncations are `.day`, `.week`, `.month`, `.quarter`, `.year` (plus `.hour`, `.minute`, `.second` for timestamps). `.date` is **not** among them: it's a **cast** (`::date`), not a truncation, so `created_at.date` does not compile. This bites twice: once at compile time, and again as a latent bad `#(doc)` comment that only a review pass catches ("truncated to date" is a doc smell; it should say "to day").
|
|
52
|
+
|
|
53
|
+
```malloy
|
|
54
|
+
// WRONG // RIGHT
|
|
55
|
+
created_at.date created_at.day // truncate to day
|
|
56
|
+
created_at::date // cast to a date
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Interval Functions: Only `seconds` / `minutes` / `hours` / `days`
|
|
60
|
+
|
|
61
|
+
`weeks()`, `months()`, `quarters()`, `years()` are **documented but don't work** in this build; only `seconds`, `minutes`, `hours`, `days` actually function. Compute in days and derive the larger unit: a *units conversion*, not a calendar-floored duration:
|
|
62
|
+
|
|
63
|
+
```malloy
|
|
64
|
+
// WRONG: weeks()/months() don't compile
|
|
65
|
+
dimension: weeks_open is weeks(opened_at to closed_at)
|
|
66
|
+
|
|
67
|
+
// RIGHT: measure in days, convert (documents that it's approximate)
|
|
68
|
+
dimension: days_open is days(opened_at to closed_at)
|
|
69
|
+
dimension: weeks_open is days(opened_at to closed_at) / 7 // ≈ weeks
|
|
70
|
+
dimension: months_open is days(opened_at to closed_at) / 30.44 // ≈ months
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
(Contrast: `search_malloy_docs` gets this right when asked narrowly; trust the docs on the supported units, not on the missing ones.)
|
|
74
|
+
|
|
75
|
+
## Safe Division: Always `nullif`
|
|
76
|
+
|
|
77
|
+
```malloy
|
|
78
|
+
// WRONG // RIGHT
|
|
79
|
+
a / b a / nullif(b, 0)
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## String Columns Need Casts for Aggregates
|
|
83
|
+
|
|
84
|
+
```malloy
|
|
85
|
+
// WRONG: "Can't use type string" // RIGHT
|
|
86
|
+
measure: avg_score is avg(score) measure: avg_score is avg(score::number)
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
**Dirty columns: null the sentinel before casting.** `::number` is a strict cast, so a column that carries non-numeric sentinels (`'NA'`, `'N/A'`, `''`, `'-'`, `'null'`) compiles fine but fails at query time with `Could not convert string 'NA' to DOUBLE`. Strip the sentinel with `nullif` first, then cast (aggregates skip nulls):
|
|
90
|
+
|
|
91
|
+
```malloy
|
|
92
|
+
// WRONG: throws on 'NA' at query time // RIGHT: nulls 'NA', then casts
|
|
93
|
+
measure: s is avg(score::number) measure: s is avg(nullif(score, 'NA')::number)
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Chain `nullif` for multiple sentinels: `nullif(nullif(score, 'NA'), '')::number`. Sample the column's values first (`run: source -> { group_by: score; limit: 20 }`) to see which sentinels it uses.
|
|
97
|
+
|
|
98
|
+
## Boolean Columns: No Quotes
|
|
99
|
+
|
|
100
|
+
```malloy
|
|
101
|
+
// WRONG // RIGHT
|
|
102
|
+
count() { where: complaint = 'true' } count() { where: complaint = true }
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Check schema: if `BOOL`, use `true`/`false`. If `STRING`, use `'true'`/`'false'`.
|
|
106
|
+
|
|
107
|
+
## `greatest()` / `least()` Are Null-Poisoning
|
|
108
|
+
|
|
109
|
+
Malloy's `greatest()` / `least()` return **NULL if *any* argument is null**, unlike Postgres `GREATEST`/`LEAST`, which ignore nulls. Porting a LookML/SQL expression verbatim is a silent parity bug: the number just goes null for any row with a missing input. Coalesce the result back to a non-null argument:
|
|
110
|
+
|
|
111
|
+
```malloy
|
|
112
|
+
// WRONG: one null input nulls the whole thing
|
|
113
|
+
dimension: last_touch is greatest(email_at, call_at)
|
|
114
|
+
|
|
115
|
+
// RIGHT: fall back so a null arg can't poison the result
|
|
116
|
+
dimension: last_touch is greatest(email_at, call_at) ?? email_at ?? call_at
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
## No Scalar Median; Raw-SQL Aggregates Don't Compile
|
|
120
|
+
|
|
121
|
+
**There is no scalar `median`, and `PERCENTILE_CONT` cannot be expressed as a measure in this build.** Every documented form for a custom SQL aggregate - `percentile_cont!(x, 0.5)`, `sql_number(...)`, `sql_number(...) { is_aggregate: true }`, and the `# is_aggregate` annotation - resolves as a **scalar** and fails with *"Cannot use a scalar field in a measure declaration."* The docs' own `avg_dist` example fails the same way. This is a deployed-runtime limitation, not a syntax error you can fix: **do not** burn cycles trying `!`, `sql_number`, or `is_aggregate` variations to get a median.
|
|
122
|
+
|
|
123
|
+
```malloy
|
|
124
|
+
// DOES NOT COMPILE in this build (all forms resolve as scalar):
|
|
125
|
+
measure: median_x is percentile_cont!(x, 0.5)
|
|
126
|
+
measure: median_x is sql_number("PERCENTILE_CONT(...) ...") { is_aggregate: true }
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
**Ship `avg` instead, or defer median with a documented gap** ("median deferred: no scalar median / runtime rejects raw-SQL aggregates"). Tell the user; don't silently substitute `avg` for a metric that was specified as median.
|
|
130
|
+
|
|
131
|
+
## Field Management: `extend {}` vs `include {}` Don't Compose
|
|
132
|
+
|
|
133
|
+
Malloy has two field-management mechanisms for base sources. **`include {}` is the curated default; `extend { except / accept / rename }` is the fallback when a `rename:` is unavoidable.** They have different capabilities and **do not combine**.
|
|
134
|
+
|
|
135
|
+
| Mechanism | Where it lives | Keywords | Compatible with `rename:`? | Experimental flag? |
|
|
136
|
+
|---|---|---|---|---|
|
|
137
|
+
| Access modifiers (default) | `include {}` | `public:` / `internal:` / `private:` | **No** | Yes (`##! experimental.access_modifiers`) |
|
|
138
|
+
| Field management (fallback) | `extend {}` | `accept:` / `except:` / `rename:` | Yes (same block) | No |
|
|
139
|
+
|
|
140
|
+
### Default: `include {}` for documented, curated base sources
|
|
141
|
+
|
|
142
|
+
Use `include {}` whenever the source doesn't need a `rename:`. It's the only way to attach `#(doc)` tags to raw columns, and it's the canonical way to hide empty/garbage/duplicate columns (`internal:`) and sensitive ones (`private:`). See `skill:malloy-model` § Access Modifiers.
|
|
143
|
+
|
|
144
|
+
```malloy
|
|
145
|
+
##! experimental.access_modifiers
|
|
146
|
+
source: orders is conn.table('orders') include {
|
|
147
|
+
public:
|
|
148
|
+
#(doc) Order identifier
|
|
149
|
+
order_id
|
|
150
|
+
|
|
151
|
+
#(doc) Customer who placed the order
|
|
152
|
+
user_id
|
|
153
|
+
|
|
154
|
+
internal:
|
|
155
|
+
raw_payload_json // empty after JSON extraction
|
|
156
|
+
legacy_status_code // superseded by status_code
|
|
157
|
+
}
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
### When `rename:` is unavoidable: fall back to `extend {}`
|
|
161
|
+
|
|
162
|
+
`include {}` does not compose with `rename:`. The combination errors with `Can't find field 'X' to set access modifier` because `rename:` runs first and leaves no `X` for `include` to attach a modifier to. There's also a collision inside `include {}` itself: a measure cannot share a name with a raw column, even one tagged `internal:` (`Cannot redefine 'X'`), and the natural fix for that is `rename:`, which then triggers the first error.
|
|
163
|
+
|
|
164
|
+
When a rename is genuinely required (most often during `conn.sql()` to `conn.table()` migration where a SQL alias matches a measure name that's already in heavy use downstream), drop `include {}` and curate the source with `extend { except: ... }` + `rename:` instead. You forfeit `#(doc)` on raw columns and the `public/internal/private` tiers, but keep column gating and the rename.
|
|
165
|
+
|
|
166
|
+
```malloy
|
|
167
|
+
// RIGHT: rename is required to free `revenue` for the measure
|
|
168
|
+
extend {
|
|
169
|
+
except: legacy_status_code // hide garbage column without include {}
|
|
170
|
+
rename: raw_revenue is revenue
|
|
171
|
+
measure: revenue is raw_revenue.sum()
|
|
172
|
+
}
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
If you can rename the measure or split the source instead, prefer that: it preserves `include {}` and the curated surface.
|
|
176
|
+
|
|
177
|
+
### `extend {}` clauses (reference)
|
|
178
|
+
|
|
179
|
+
- **`accept:`**: allow-list, keep only the named columns
|
|
180
|
+
- **`except:`**: deny-list, drop the named columns; keep everything else (mutually exclusive with `accept:`)
|
|
181
|
+
- **`rename:`**: alias a raw column to free up its original name for a measure or dimension
|
|
182
|
+
|
|
183
|
+
### Migrating `conn.sql()` to `conn.table()` + Malloy clauses
|
|
184
|
+
|
|
185
|
+
The biggest reason teams reach for `conn.sql()` is column gating, aliasing, and per-row derivation in one place. All three have native equivalents:
|
|
186
|
+
|
|
187
|
+
1. **Verify the schema**: `run: <source> -> { select: *; limit: 1 }` to discover all columns. Anything in the table but not in the SQL's `SELECT` was being intentionally hidden, so preserve that gating.
|
|
188
|
+
2. Switch to `conn.table('…')`.
|
|
189
|
+
3. Hidden columns: preferably `include { internal: ... }` (lets you also `#(doc)` the public columns). If a `rename:` is also needed in the same source, fall back to `extend { except: ... }`.
|
|
190
|
+
4. SQL aliases: `extend { rename: ... }` (forces the fallback path, since `rename:` and `include {}` don't compose). If the alias was to free up a name for a measure, use `rename: raw_X is X`, then `measure: X is raw_X.sum()`.
|
|
191
|
+
5. SQL derivations: `dimension:` definitions in `extend {}`.
|
|
192
|
+
6. SQL `WHERE`: source-level `where:`.
|
|
193
|
+
|
|
194
|
+
## Cannot Redefine Query-Based Source Columns
|
|
195
|
+
|
|
196
|
+
Columns from `table -> { group_by, aggregate }` or `conn.sql()` already exist. You cannot re-declare them.
|
|
197
|
+
|
|
198
|
+
```malloy
|
|
199
|
+
// WRONG: "Cannot redefine 'user_id'"
|
|
200
|
+
source: facts is conn.table('t') -> { group_by: user_id, aggregate: total is sum(amt) }
|
|
201
|
+
extend { dimension: user_id is user_id }
|
|
202
|
+
// RIGHT: add only NEW derived dimensions
|
|
203
|
+
source: facts is conn.table('t') -> { group_by: user_id, aggregate: total is sum(amt) }
|
|
204
|
+
extend { dimension: is_high_value is total > 1000 }
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
To add `#(doc)` tags to existing query columns, use `include {}` between the query and extend.
|
|
208
|
+
|
|
209
|
+
## Never Use `conn.sql()` When Malloy Has a Native Pattern
|
|
210
|
+
|
|
211
|
+
```malloy
|
|
212
|
+
// WRONG: raw SQL for pre-aggregation
|
|
213
|
+
source: facts is conn.sql("""SELECT user_id, SUM(amount) AS total FROM orders GROUP BY user_id""")
|
|
214
|
+
// RIGHT: Malloy query-based source
|
|
215
|
+
source: facts is conn.table('orders') -> { group_by: user_id, aggregate: total is sum(amount) }
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
**Mandatory: call `search_malloy_docs` before reaching for `conn.sql()`.** Don't argue from intuition. Most patterns that look SQL-only have a Malloy equivalent, including the ones reviewers historically said couldn't be expressed.
|
|
219
|
+
|
|
220
|
+
| Looks like it needs SQL | Malloy equivalent |
|
|
221
|
+
|---|---|
|
|
222
|
+
| Multi-CTE pipeline | Stacked query-based sources: `source: a is t -> {...}`; `source: b is a -> {...}`; `source: c is b -> {...}` |
|
|
223
|
+
| UNNEST / array column access | `array_column.each.field`: arrays auto-join as nested tables ([data types docs](https://docs.malloydata.dev/documentation/language/datatypes#array-access)) |
|
|
224
|
+
| PIVOT (conditional aggregation) | Filtered aggregates: `aggregate: a is x.sum() { where: cat = 'a' }, b is x.sum() { where: cat = 'b' }` |
|
|
225
|
+
| Window functions (any frame, including custom) | `calculate:` with `sum_cumulative`, `lag`, `lead`, `rank`, `row_number`, `avg_moving`, `first_value`, `last_value`: supports `partition_by:` and `order_by:` ([window functions docs](https://docs.malloydata.dev/documentation/language/functions#window-functions)) |
|
|
226
|
+
| `ROWS BETWEEN UNBOUNDED PRECEDING AND 1 PRECEDING` | `sum_cumulative(x) - x` (cumulative-including-current minus current = cumulative-excluding-current) |
|
|
227
|
+
| `WHERE date = (SELECT max(date) FROM …)` (latest snapshot) | `join_cross` to a one-row aggregate source, then filter on the joined `max_date` field |
|
|
228
|
+
| Multi-key joins | `join_one: x is target on a = x.a and b = x.b and c = x.c` |
|
|
229
|
+
| `greatest()` / `least()` / `CASE` chains | All native: `greatest(a, b, c)`, `least(a, b)`, `pick 'x' when cond else 'y'` |
|
|
230
|
+
| Dialect-specific scalar functions | `function_name!return_type(args)`: Malloy's raw-SQL function escape (no `conn.sql()` block needed) |
|
|
231
|
+
|
|
232
|
+
**Genuinely valid `conn.sql()` candidates (rare):**
|
|
233
|
+
|
|
234
|
+
- SQL features Malloy explicitly doesn't model (e.g., DML/DDL, specific `MERGE` patterns)
|
|
235
|
+
- Multi-stage transformations where every CTE has 3+ joins to different tables AND the result is consumed by multiple downstream sources, but in this case an intermediate table in the data warehouse is usually still better than `conn.sql()`
|
|
236
|
+
|
|
237
|
+
**Never use `conn.sql()` for:** simple column selection or renaming, `WHERE` filters, two-table joins, column type casts, latest-snapshot patterns, conditional aggregation, or window functions of any kind.
|
|
238
|
+
|
|
239
|
+
If a project's standards file specifies a stricter policy (e.g., a `search_malloy_docs` rationale comment requirement above every `conn.sql()` block), defer to that.
|
|
240
|
+
|
|
241
|
+
## Duplicate Rows: Check Before Building Measures
|
|
242
|
+
|
|
243
|
+
```malloy
|
|
244
|
+
run: source -> { group_by: pk_field, aggregate: n is count(), having: n > 1, limit: 10 }
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
Symptoms: `sum()` returns astronomical values. Causes: event tables, batch retries, merged sources.
|
|
248
|
+
|
|
249
|
+
## `except:` Removes Fields From Namespace Entirely
|
|
250
|
+
|
|
251
|
+
`except:` in `include {}` completely removes fields: dimensions and measures cannot reference excluded fields. Use `internal:` instead when derived dimensions need the raw column.
|
|
252
|
+
|
|
253
|
+
```malloy
|
|
254
|
+
// WRONG: dimension references excluded field
|
|
255
|
+
source: x is conn.table('t')
|
|
256
|
+
include { except: raw_date }
|
|
257
|
+
extend { dimension: order_date is raw_date::date } // ERROR! raw_date is gone
|
|
258
|
+
|
|
259
|
+
// RIGHT: internal fields are still available in extend
|
|
260
|
+
source: x is conn.table('t')
|
|
261
|
+
include { internal: raw_date }
|
|
262
|
+
extend { dimension: order_date is raw_date::date } // Works
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
## Source Order: Define Joined Tables First
|
|
266
|
+
|
|
267
|
+
Malloy compiles top-to-bottom. Define lookup/dimension tables before the source that joins them, or use `import` statements in multi-file projects.
|
|
268
|
+
|
|
269
|
+
## MUST Search Docs Before Using Unfamiliar Patterns
|
|
270
|
+
|
|
271
|
+
Call `search_malloy_docs` BEFORE first use of any of these. Don't guess the syntax:
|
|
272
|
+
- `pick` expressions
|
|
273
|
+
- Window functions (`calculate`)
|
|
274
|
+
- `percentile` or statistical functions: but see the hard limit above, raw-SQL aggregates (`sql_number` / `is_aggregate` / `percentile_cont!`) do **not** compile as measures in this build; there is no scalar median
|
|
275
|
+
- Time interval functions (`days()`, `seconds()`): only `seconds`/`minutes`/`hours`/`days` exist (see above)
|
|
276
|
+
- Query-based sources (`from()`)
|
|
277
|
+
- `!` operator / `sql_number()`
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: malloy-gotchas-queries
|
|
3
|
+
description: Common Malloy query and view mistakes. Read BEFORE writing views, queries, or notebooks. Covers chart constraints, aggregate filters, joined field aliasing, method syntax, and time truncation vs extraction.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Query & View Gotchas
|
|
7
|
+
|
|
8
|
+
> **Read this before writing views or queries.** These patterns cause most query errors.
|
|
9
|
+
|
|
10
|
+
## Charts: ONE Aggregate Per View
|
|
11
|
+
|
|
12
|
+
Charts render only the **first** aggregate. Use exactly one aggregate per `# bar_chart` / `# line_chart` view.
|
|
13
|
+
|
|
14
|
+
```malloy
|
|
15
|
+
// WRONG: revenue is ignored
|
|
16
|
+
# bar_chart
|
|
17
|
+
view: x is { group_by: status, aggregate: order_count, revenue }
|
|
18
|
+
// RIGHT: single aggregate
|
|
19
|
+
# bar_chart
|
|
20
|
+
view: x is { group_by: status, aggregate: revenue }
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
For multiple metrics: nest separate chart views in a `# dashboard`, or use `y=['revenue','cost']` for multi-measure series.
|
|
24
|
+
|
|
25
|
+
## Joined Fields in `order_by`: Must Alias First
|
|
26
|
+
|
|
27
|
+
```malloy
|
|
28
|
+
// WRONG: compile error
|
|
29
|
+
view: x is { group_by: races.season_year, aggregate: pts, order_by: races.season_year }
|
|
30
|
+
// RIGHT: alias then reference
|
|
31
|
+
view: x is { group_by: yr is races.season_year, aggregate: pts, order_by: yr }
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Any time you `group_by` a joined field, create an alias and use it in `order_by`.
|
|
35
|
+
|
|
36
|
+
## `having:` vs `where:`: Aggregate Filters
|
|
37
|
+
|
|
38
|
+
```malloy
|
|
39
|
+
// WRONG: "Aggregate expressions not allowed in where"
|
|
40
|
+
view: x is { group_by: cat, aggregate: n is count(), where: n > 10 }
|
|
41
|
+
// RIGHT
|
|
42
|
+
view: x is { group_by: cat, aggregate: n is count(), having: n > 10 }
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
- `where:` filters rows BEFORE aggregation (dimensions/raw columns)
|
|
46
|
+
- `having:` filters AFTER aggregation (measures)
|
|
47
|
+
|
|
48
|
+
## Aggregating Joined Fields: Method Syntax
|
|
49
|
+
|
|
50
|
+
```malloy
|
|
51
|
+
// WRONG: compile error: "Join path is required for this calculation; use 'inventory_items.item_cost.sum()'"
|
|
52
|
+
measure: cogs is sum(inventory_items.item_cost)
|
|
53
|
+
// RIGHT: method syntax
|
|
54
|
+
measure: cogs is inventory_items.item_cost.sum()
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
`sum`, `avg`, `min`, and `max` over a dotted joined path all produce that compile error; the diagnostic message even tells you the exact fix. Don't worry about catching this in code review; the compiler does it for you.
|
|
58
|
+
|
|
59
|
+
**Exception: `count(joined.field)` is correct, not a bug.** `count(joined.field)` is the **canonical Malloy idiom** for distinct-count through a join. Keep it as-is even when nearby `sum`/`avg`/`min`/`max` calls have to use method syntax. The closest method-syntax form `joined.count()` counts *rows* in the joined source (different semantics, differs from the distinct count when the joined field has duplicates within the joined table). The Malloy docs example `joined.count(field)` does NOT compile against current Malloy (error: `Expression illegal inside path.count()`); it only works for double-nested paths like `aircraft.count(aircraft_models.code)`.
|
|
60
|
+
|
|
61
|
+
## Chart Annotation Placement
|
|
62
|
+
|
|
63
|
+
Place `# bar_chart` / `# line_chart` on the **nested view definition**, not on `nest:` itself. Putting it on `nest:` causes "not a repeated record" errors.
|
|
64
|
+
|
|
65
|
+
## DRY: Define in Source, Reference in View
|
|
66
|
+
|
|
67
|
+
```malloy
|
|
68
|
+
// WRONG: inline in view
|
|
69
|
+
view: summary is { aggregate: revenue is sum(total) }
|
|
70
|
+
// RIGHT: reference existing measure
|
|
71
|
+
view: summary is { aggregate: revenue }
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## Time Truncation vs Extraction
|
|
75
|
+
|
|
76
|
+
| Syntax | What it does | Returns |
|
|
77
|
+
|--------|--------------|---------|
|
|
78
|
+
| `ts.month` | Truncates to start of month | Timestamp (`@2024-03-01`) |
|
|
79
|
+
| `month(ts)` | Extracts month number | Integer (1-12) |
|
|
80
|
+
| `ts.year` | Truncates to start of year | Timestamp (`@2024-01-01`) |
|
|
81
|
+
| `year(ts)` | Extracts year number | Integer (2024) |
|
|
82
|
+
|
|
83
|
+
Use `.month` for time series charts (proper date ordering). Use `month()` for cross-year comparison.
|
|
84
|
+
|
|
85
|
+
**Year integers render with commas.** `year(ts)` displays as `2,018`. Tag with `# number=id` to suppress commas. Same for zip codes, IDs.
|
|
86
|
+
|
|
87
|
+
## `?` Alternation: Use Commas to Combine Filters
|
|
88
|
+
|
|
89
|
+
The `?` operator is Malloy's **alternation operator**: a shorthand for "match any of these values." `party ? 'Democrat' | 'Republican'` means `party = 'Democrat' OR party = 'Republican'`. The `|` separates the alternatives.
|
|
90
|
+
|
|
91
|
+
When combining an alternation filter with other filters, **use a comma**:
|
|
92
|
+
|
|
93
|
+
```malloy
|
|
94
|
+
// CANONICAL: commas separate independent filter conditions
|
|
95
|
+
where: is_us = true, party ? 'Democrat' | 'Republican'
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
`and` works in some arrangements (when the alternation is the second operand) but produces a confusing `'logical operator' Can't use type string` compile error when the alternation comes first. The comma form is unambiguous in every position, so just use it.
|
|
99
|
+
|
|
100
|
+
## Query Clauses Are Newline-Separated
|
|
101
|
+
|
|
102
|
+
Do not use trailing commas between query clauses. Each clause goes on its own line.
|
|
103
|
+
|
|
104
|
+
```malloy
|
|
105
|
+
// WRONG: trailing comma before limit
|
|
106
|
+
run: source -> { group_by: status, aggregate: n is count(), limit: 10 }
|
|
107
|
+
// RIGHT: newline-separated
|
|
108
|
+
run: source -> {
|
|
109
|
+
group_by: status
|
|
110
|
+
aggregate: n is count()
|
|
111
|
+
limit: 10
|
|
112
|
+
}
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
Clauses: `group_by:`, `aggregate:`, `nest:`, `order_by:`, `limit:`, `where:`, `having:`, `select:`, `calculate:`
|
|
116
|
+
|
|
117
|
+
## Fields Within a Clause: Commas or Newlines, Never Semicolons
|
|
118
|
+
|
|
119
|
+
Semicolons are not a separator anywhere in Malloy. Multiple fields under one `aggregate:` / `group_by:` are separated by commas (inline) or newlines (one per line); a `;` fails with `no viable alternative at input '<next-field>'` pointing at the field right after it.
|
|
120
|
+
|
|
121
|
+
```malloy
|
|
122
|
+
// WRONG: semicolons between fields
|
|
123
|
+
run: schools -> { aggregate: total is count(); charters is count() { where: is_charter } }
|
|
124
|
+
// RIGHT: commas inline...
|
|
125
|
+
run: schools -> { aggregate: total is count(), charters is count() { where: is_charter } }
|
|
126
|
+
// ...or newlines
|
|
127
|
+
run: schools -> {
|
|
128
|
+
aggregate:
|
|
129
|
+
total is count()
|
|
130
|
+
charters is count() { where: is_charter }
|
|
131
|
+
}
|
|
132
|
+
```
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: malloy-gotchas-rendering
|
|
3
|
+
description: Common Malloy renderer annotation mistakes. Read BEFORE adding chart annotations, formatting tags, or building dashboards. Covers tag syntax, scale rules, sparkline setup, and big_value patterns.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Rendering Gotchas
|
|
7
|
+
|
|
8
|
+
> **Read this before adding renderer annotations.** These patterns cause most rendering issues.
|
|
9
|
+
|
|
10
|
+
## One Tag Per Line
|
|
11
|
+
|
|
12
|
+
Each `#` annotation must be on its own line directly above the field. Never combine tags on one line.
|
|
13
|
+
|
|
14
|
+
```malloy
|
|
15
|
+
// WRONG, will not work
|
|
16
|
+
# label="Revenue" # currency
|
|
17
|
+
revenue
|
|
18
|
+
|
|
19
|
+
// RIGHT
|
|
20
|
+
# label="Revenue"
|
|
21
|
+
# currency
|
|
22
|
+
revenue
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## No Fixed Scale on Measures
|
|
26
|
+
|
|
27
|
+
Use `# currency` (no scale) on measure definitions. The same measure renders at many granularities: `usd0m` turns $500 into `$0.0M`.
|
|
28
|
+
|
|
29
|
+
```malloy
|
|
30
|
+
// WRONG on a measure definition
|
|
31
|
+
# currency=usd0m
|
|
32
|
+
measure: revenue is sum(total)
|
|
33
|
+
|
|
34
|
+
// RIGHT, no scale on measure
|
|
35
|
+
# currency
|
|
36
|
+
measure: revenue is sum(total)
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Add scale (e.g., `# currency=usd0m`) only in views after confirming value ranges with queries.
|
|
40
|
+
|
|
41
|
+
## `# big_value` Needs `# label` on Each Measure
|
|
42
|
+
|
|
43
|
+
```malloy
|
|
44
|
+
# big_value
|
|
45
|
+
view: summary is {
|
|
46
|
+
aggregate:
|
|
47
|
+
# label="Revenue"
|
|
48
|
+
# currency
|
|
49
|
+
revenue
|
|
50
|
+
|
|
51
|
+
# label="Orders"
|
|
52
|
+
# number=auto
|
|
53
|
+
order_count
|
|
54
|
+
}
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Without `# label`, big_value cards show raw field names which are often unclear.
|
|
58
|
+
|
|
59
|
+
## Sparkline Setup
|
|
60
|
+
|
|
61
|
+
Sparklines in `# big_value` require TWO things: a `# hidden` nested view AND a `.sparkline=` reference.
|
|
62
|
+
|
|
63
|
+
```malloy
|
|
64
|
+
# big_value { sparkline=trend }
|
|
65
|
+
view: revenue_kpi is {
|
|
66
|
+
aggregate:
|
|
67
|
+
# label="Revenue"
|
|
68
|
+
# currency
|
|
69
|
+
revenue
|
|
70
|
+
nest:
|
|
71
|
+
# line_chart { size=spark }
|
|
72
|
+
# hidden
|
|
73
|
+
trend is { group_by: order_date, aggregate: revenue, order_by: order_date }
|
|
74
|
+
}
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
If the sparkline doesn't show: check that `# hidden` is on the nested view AND the view name matches `.sparkline=`.
|
|
78
|
+
|
|
79
|
+
## Comparison Deltas
|
|
80
|
+
|
|
81
|
+
```malloy
|
|
82
|
+
# big_value { comparison_field=prior_month comparison_label="vs Last Month" }
|
|
83
|
+
view: rev_delta is {
|
|
84
|
+
aggregate:
|
|
85
|
+
# label="Revenue"
|
|
86
|
+
# currency
|
|
87
|
+
revenue
|
|
88
|
+
# hidden
|
|
89
|
+
prior_month
|
|
90
|
+
}
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
Use `down_is_good=true` for metrics where decrease is positive (churn, defects).
|
|
94
|
+
|
|
95
|
+
## `# dashboard` Layout
|
|
96
|
+
|
|
97
|
+
In a `# dashboard` view, fields render by role: `group_by` -> a repeating row header, `aggregate` measures -> KPI cards, each `nest:` -> a tile. Put each tile tag on its own line above the nested view. A lone renderer tag also works on the `nest:` line, but a tile usually carries several tags (`# break`, `# colspan`, `# subtitle`, then `# bar_chart`), and only the own-line form keeps each tag on its own line.
|
|
98
|
+
|
|
99
|
+
```malloy
|
|
100
|
+
// RIGHT: tag above the nested view; the measure auto-renders as a card
|
|
101
|
+
# dashboard
|
|
102
|
+
view: overview is {
|
|
103
|
+
group_by: category
|
|
104
|
+
# currency
|
|
105
|
+
aggregate: avg_retail is retail_price.avg()
|
|
106
|
+
nest:
|
|
107
|
+
# bar_chart
|
|
108
|
+
by_brand is { group_by: brand, aggregate: avg_retail is retail_price.avg(), limit: 10 }
|
|
109
|
+
}
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
- **`# colspan` only works in columns mode.** Set `# dashboard { columns=N }` first; in flex mode `# colspan` is ignored. `# break` (new row) works in both modes.
|
|
113
|
+
- **`gap` is spacing, not a mode.** `columns=N` enters columns mode; `# dashboard { gap=24 }` only changes tile spacing.
|
|
114
|
+
- **`# dashboard` needs a row-producing view (no effect on a scalar).** A flat view of top-level `aggregate:` measures still renders KPI cards; add `nest:` for chart and table tiles.
|
|
115
|
+
- **Use `# colspan`, not the old `# span`.** Tile styling comes from the instance theme.
|
|
116
|
+
|
|
117
|
+
## Theming
|
|
118
|
+
|
|
119
|
+
- **Per-chart theme keys are nested, not flat.** In Publisher, `# theme.palette.tableHeader.dark = "#94a3b8"` works; a flat `# theme.tableHeaderColor = "#94a3b8"` is silently dropped. Publisher's annotation reader only understands the structured `palette.*` / `font.*` form (the same vocabulary as the config), not the renderer's flat `MalloyExplicitTheme` key names.
|
|
120
|
+
- **A per-chart annotation OVERRIDES the instance theme.** Precedence, highest to lowest, per key: `# theme.*` (view), then `## theme.*` (model), then the instance theme (config / Settings, then Theme editor), then built-in defaults. A `# theme.*` view tag beats a `## theme.*` model default, and both beat the instance theme for the keys they set. This is the opposite of a bare `@malloydata/render` embed, where the embedder wins.
|
|
121
|
+
- **Only seven palette keys take light/dark.** `background`, `tableHeader`, `tableHeaderBackground`, `tableBody`, `tile`, `tileTitle`, and `mapColor` each accept a `.light` and/or `.dark` variant. `palette.series`, `font.family`, and `font.size` are single values shared across modes; a `.light`/`.dark` on them does nothing.
|
|
122
|
+
- **`# theme.palette.mapColor.{light,dark}` recolors choropleths only.** It sets the saturated end of the `# shape_map` / `# segment_map` gradient (per mode). Rect-mark heatmaps keep their built-in scheme.
|
|
123
|
+
- **`defaultMode` and `allowUserToggle` are instance-only.** No per-chart annotation controls the light/dark default or the toggle lock; set them in the config `theme` block or the editor.
|
|
124
|
+
- **Environment-level theming is not applied yet.** Only the instance theme and the `# theme.*` / `## theme.*` per-chart annotations take effect today.
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: malloy-html-data-app-embedding
|
|
3
|
+
description: Embed an in-package HTML data app into a host page or another application, including auto-sizing and auth. Read when embedding a Publisher page via Publisher.embed.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Embedding an HTML Data App
|
|
7
|
+
|
|
8
|
+
> `Publisher.embed(selector, { src })` drops a package page into a host page as a sandboxed, auto-resizing iframe. Same-origin embeds authenticate with the browser's cookies; cross-origin embeds need a signed token.
|
|
9
|
+
|
|
10
|
+
## The host-page pattern
|
|
11
|
+
|
|
12
|
+
```html
|
|
13
|
+
<script src="https://your-publisher/sdk/publisher.js"></script>
|
|
14
|
+
<div id="dashboard"></div>
|
|
15
|
+
<script>
|
|
16
|
+
const handle = Publisher.embed("#dashboard", {
|
|
17
|
+
src: "https://your-publisher/environments/demo/packages/sales/index.html",
|
|
18
|
+
});
|
|
19
|
+
// handle.destroy() removes the iframe and detaches its listeners.
|
|
20
|
+
</script>
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
`embed(selector, options)` returns `{ iframe, destroy() }`. Options: `src` (required), `token` (a signed token for cross-origin auth, appended as `embed_token`), `height` (omit to auto-size; a number is treated as pixels), and `allow` (the iframe permissions policy).
|
|
24
|
+
|
|
25
|
+
## Sizing and the resize contract
|
|
26
|
+
|
|
27
|
+
Omit `height` and the frame auto-sizes. The embedded page measures its real content height and posts a `publisher:resize` message to the host, which resizes the iframe and accepts that message only from the iframe it created. You write none of this; it ships in `/sdk/publisher.js`, so the embedded page only has to load that script.
|
|
28
|
+
|
|
29
|
+
Do not rely on `body { min-height: 100vh }` to drive the frame height. The runtime deliberately measures the content's bottom edge, not the viewport, to avoid a grow-forever loop.
|
|
30
|
+
|
|
31
|
+
## Auth
|
|
32
|
+
|
|
33
|
+
- Same-origin or same-tenant: pass no token. The browser's cookies authenticate the iframe.
|
|
34
|
+
- Cross-origin: mint a short-lived signed token server-side and pass it as `options.token`. The runtime appends it to the iframe URL as `embed_token`; the embedded page must read it (from `location.search`) and call `Publisher.setToken(token)`. Because it rides in the URL, it can land in browser history, Referer headers, and server logs, so keep it short-lived and scoped to that one embed, and never put a long-lived or admin token in client HTML.
|
|
35
|
+
|
|
36
|
+
## Guardrails (v1)
|
|
37
|
+
|
|
38
|
+
- The iframe is sandboxed (`allow-scripts allow-same-origin allow-forms`). Design for that: no top-level navigation, no popups.
|
|
39
|
+
- Embedded author JavaScript runs with the viewing user's data authority, so treat everything under `public/` as strictly first-party code: do not load untrusted third-party scripts, and do not move query results off to other hosts. Tighter per-embed isolation is planned.
|