diff --git a/content/docs/reference/eql/dates-and-times.mdx b/content/docs/reference/eql/dates-and-times.mdx
index 659dce1..c8ffebf 100644
--- a/content/docs/reference/eql/dates-and-times.mdx
+++ b/content/docs/reference/eql/dates-and-times.mdx
@@ -95,49 +95,7 @@ Blocked *operator* cells raise an `operator … is not supported` exception —
## Functions
-Every operator has a function form, for managed platforms that disallow custom operators — same typed arguments, identical resolution. The `MIN` / `MAX` aggregates only exist as functions:
-
-| Function | Equivalent | Available on |
-| --- | --- | --- |
-| `eql_v3.eq(a, b)` / `eql_v3.neq(a, b)` | `=` / `<>` | `_eq`, all `_ord` variants |
-| `eql_v3.lt` / `lte` / `gt` / `gte` | `<` `<=` `>` `>=` | all `_ord` variants |
-| `eql_v3.min(col)` / `eql_v3.max(col)` | aggregate `MIN` / `MAX` | all `_ord` variants |
-
-## Example queries
-
-### Time window
-
-```sql
-SELECT * FROM audit_events
-WHERE occurred_at BETWEEN $1::public.eql_v3_timestamp_ord AND $2::public.eql_v3_timestamp_ord;
-
-SELECT * FROM audit_events
-WHERE review_due BETWEEN $1::public.eql_v3_date_ord AND $2::public.eql_v3_date_ord;
-```
-
-### Retention cutoff
-
-```sql
-SELECT id FROM audit_events
-WHERE occurred_at < $1::public.eql_v3_timestamp_ord;
-```
-
-### Newest-first listing
-
-Write the sort key in extractor form to stream rows out of the index already ordered — at large row counts this is the difference between seconds and milliseconds (see [Sorting](/reference/eql/sorting)):
-
-```sql
-SELECT * FROM audit_events
-WHERE occurred_at >= $1::public.eql_v3_timestamp_ord
-ORDER BY eql_v3.ord_term(occurred_at) DESC
-LIMIT 10;
-```
-
-### First and last event
-
-```sql
-SELECT eql_v3.min(occurred_at), eql_v3.max(occurred_at) FROM audit_events;
-```
+content/partials/eql/functions-dates-and-times.mdx
## Where to next
diff --git a/content/docs/reference/eql/json.mdx b/content/docs/reference/eql/json.mdx
index 7253a6f..e2aea00 100644
--- a/content/docs/reference/eql/json.mdx
+++ b/content/docs/reference/eql/json.mdx
@@ -111,85 +111,129 @@ Use containment (`@>` / `<@`), field access (`->` / `->>`), or the `eql_v3.jsonb
**Operands must be typed** (`doc -> 'email'::text`, not `doc -> 'email'`) — an untyped operand resolves the native `jsonb` operator, bypassing both the encrypted operator and the blockers. See [Core concepts](/reference/eql/core-concepts).
-## Containment: `@>` and `<@`
+## Functions
-`@>` tests whether the encrypted document contains a structure; `<@` is the reverse. Build the needle with the client and cast it to `eql_v3.query_jsonb` (a typed `public.eql_v3_json` or `public.eql_v3_jsonb_entry` operand also works):
+Every JSON query addresses paths by **selector hash** — the deterministic identifier the client emits for a JSON path during encryption, not a plaintext path like `$.customer.tier`. Operands must be typed, or PostgreSQL resolves the native `jsonb` operator instead of the encrypted one.
+
+The examples below all query one encrypted `metadata` document. In plaintext:
+
+```json
+{
+ "customer": {
+ "tier": "premium",
+ "region": "apac"
+ },
+ "total": 149.95,
+ "items": ["sku-1042", "sku-2210"]
+}
+```
+
+The `*_selector` placeholders stand for the selector hash of each path: `region_selector` for `$.customer.region`, `total_selector` for `$.total`, and `items_selector` for `$.items`.
+
+### Containment [#fn-contains]
+
+`@>` tests whether the encrypted document contains a structure; `<@` is the reverse. Build the needle in the client and cast it to `eql_v3.query_jsonb`; `eql_v3.ste_vec_contains(a, b)` is the function form.
+
+
```sql
SELECT * FROM orders
WHERE metadata @> $1::eql_v3.query_jsonb;
```
-This is the encrypted equivalent of the plaintext `metadata @> '{"customer": {"tier": "premium"}}'`: containment checks that every encrypted term in the needle exists in the document's `sv` vector. `eql_v3.to_ste_vec_query(doc)` converts a stored document into the needle shape, and `eql_v3.ste_vec_contains(a, b)` is the function form backing `@>`.
+
-For large tables, back containment with a GIN index. The typed `@>` overload inlines to a native `jsonb @>` over `eql_v3.to_ste_vec_query(col)::jsonb`, so a GIN index on that same expression engages:
+For large tables, back containment with a GIN index. The typed `@>` inlines to a native `jsonb @>` over `eql_v3.to_ste_vec_query(col)::jsonb`, so a GIN index on that same expression engages:
```sql
CREATE INDEX orders_metadata_gin
ON orders USING gin (eql_v3.to_ste_vec_query(metadata)::jsonb jsonb_path_ops);
-ANALYZE orders;
```
-See [Indexes](/reference/eql/indexes) for the full recipes.
+### Field access [#fn-field-access]
-## Field access: `->` and `->>`
+`->` returns a `public.eql_v3_jsonb_entry`; `->>` serializes that entry as ciphertext text. Fields are addressed by selector hash, and array elements by 0-based index.
-Fields are addressed by **selector hash** — the deterministic identifier the client emits for a JSON path during encryption — not a plaintext path string like `$.customer.tier`.
+
```sql
--- Field access by selector (returns public.eql_v3_jsonb_entry)
-SELECT metadata -> 'selector_hash'::text FROM orders;
+SELECT metadata -> 'selector_hash'::text FROM orders; -- entry
+SELECT metadata ->> 'selector_hash'::text FROM orders; -- entry as ciphertext text
+SELECT metadata -> 0 FROM orders; -- array element by index
+```
--- The entry serialized as text (ciphertext JSON, not decrypted plaintext)
-SELECT metadata ->> 'selector_hash'::text FROM orders;
+
--- Array element by 0-based index
-SELECT metadata -> 0 FROM orders;
-```
+The extracted `public.eql_v3_jsonb_entry` is itself comparable.
-The extracted `public.eql_v3_jsonb_entry` is itself comparable:
+### eql_v3.eq_term [#fn-eq-term]
-- `=` / `<>` resolve via `eql_v3.eq_term` — works on every node type
-- `<` / `<=` / `>` / `>=` resolve via `eql_v3.ord_term` — String and Number leaves only
-- `MIN` / `MAX` over an extracted ordered leaf use the `eql_v3.min` / `eql_v3.max` aggregates
+Equality on an extracted leaf, via `eql_v3.eq_term`. Works on every node type, and drives `GROUP BY` / `DISTINCT`.
-```sql
--- Equality on an extracted leaf
-SELECT * FROM orders
-WHERE metadata -> 'email_selector'::text = $1::public.eql_v3_jsonb_entry;
+
--- Group by an extracted leaf's equality term
+```sql
SELECT eql_v3.eq_term(metadata -> 'region_selector'::text) AS region, COUNT(*)
FROM orders
GROUP BY eql_v3.eq_term(metadata -> 'region_selector'::text);
```
-A hash index on `eql_v3.eq_term(col -> ''::text)` engages the equality lookup; a btree on `eql_v3.ord_term(...)` engages range and `ORDER BY`. The OPE term is a `bytea` domain that orders under the default btree operator class, so this index needs no custom operator class. See [Indexes](/reference/eql/indexes).
+
+
+### eql_v3.ord_term [#fn-ord-term]
-## Path queries and array helpers
+Range comparisons and `ORDER BY` on an extracted **String or Number** leaf, via `eql_v3.ord_term`.
-The function forms take the same selector hashes:
+
```sql
--- All entries matching a selector
-SELECT eql_v3.jsonb_path_query(metadata, 'selector_hash') FROM orders;
+SELECT * FROM orders
+WHERE (metadata -> 'total_selector'::text) > $1::public.eql_v3_jsonb_entry;
+```
+
+
--- First match only
-SELECT eql_v3.jsonb_path_query_first(metadata, 'selector_hash') FROM orders;
+A hash index on `eql_v3.eq_term(col -> ''::text)` engages the equality lookup; a btree on `eql_v3.ord_term(...)` engages range and `ORDER BY`. See [Indexes](/reference/eql/indexes).
--- Does the selector exist in this document?
-SELECT eql_v3.jsonb_path_exists(metadata, 'selector_hash') FROM orders;
+### eql_v3.min / max [#fn-min-max]
+
+`MIN` / `MAX` over an extracted ordered leaf.
+
+
+
+```sql
+SELECT eql_v3.min(metadata -> 'total_selector'::text) FROM orders;
```
-For encrypted array nodes:
+
+
+### eql_v3.jsonb_path_query [#fn-path-query]
+
+Path queries take the same selector hashes. `jsonb_path_query` returns every matching entry, `jsonb_path_query_first` the first, and `jsonb_path_exists` a boolean.
+
+
+
+```sql
+SELECT eql_v3.jsonb_path_query(metadata, 'selector_hash') FROM orders; -- all matches
+SELECT eql_v3.jsonb_path_query_first(metadata, 'selector_hash') FROM orders; -- first match
+SELECT eql_v3.jsonb_path_exists(metadata, 'selector_hash') FROM orders; -- boolean
+```
+
+
+
+### eql_v3.jsonb_array_* [#fn-array]
+
+Helpers over an encrypted array node. `jsonb_array_elements` yields encrypted entries; `jsonb_array_elements_text` yields each element as ciphertext text.
+
+
```sql
-SELECT eql_v3.jsonb_array_length(metadata -> 'items_selector'::text) FROM orders;
-SELECT eql_v3.jsonb_array_elements(metadata -> 'items_selector'::text) FROM orders;
+SELECT eql_v3.jsonb_array_length(metadata -> 'items_selector'::text) FROM orders;
+SELECT eql_v3.jsonb_array_elements(metadata -> 'items_selector'::text) FROM orders;
SELECT eql_v3.jsonb_array_elements_text(metadata -> 'items_selector'::text) FROM orders;
```
-`jsonb_array_elements` yields encrypted entries; `jsonb_array_elements_text` yields each element as ciphertext text.
+
## Worked example
diff --git a/content/docs/reference/eql/numbers.mdx b/content/docs/reference/eql/numbers.mdx
index f0cdf81..b2f1cac 100644
--- a/content/docs/reference/eql/numbers.mdx
+++ b/content/docs/reference/eql/numbers.mdx
@@ -101,55 +101,10 @@ Blocked *operator* cells raise an `operator … is not supported` exception —
## Functions
-Every operator has a function form, for managed platforms that disallow custom operators — same typed arguments, identical resolution. The `MIN` / `MAX` aggregates only exist as functions:
-
-| Function | Equivalent | Available on |
-| --- | --- | --- |
-| `eql_v3.eq(a, b)` / `eql_v3.neq(a, b)` | `=` / `<>` | `_eq`, all `_ord` variants |
-| `eql_v3.lt` / `lte` / `gt` / `gte` | `<` `<=` `>` `>=` | all `_ord` variants |
-| `eql_v3.min(col)` / `eql_v3.max(col)` | aggregate `MIN` / `MAX` | all `_ord` variants |
+content/partials/eql/functions-numbers.mdx
**`SUM`, `AVG`, and other arithmetic aggregates are not supported** on encrypted columns — they would require homomorphic encryption. `MIN` / `MAX` work because they only need comparison; for sums and averages, decrypt at the application boundary and aggregate client-side.
-## Example queries
-
-### Range filter
-
-```sql
-SELECT * FROM employees
-WHERE salary >= $1::public.eql_v3_bigint_ord;
-
-SELECT * FROM employees
-WHERE salary BETWEEN $1::public.eql_v3_bigint_ord AND $2::public.eql_v3_bigint_ord;
-```
-
-### MIN and MAX
-
-`eql_v3.min` / `eql_v3.max` compare ordering terms — no decryption happens in the database, and the encrypted result decrypts in the client. `NULL` inputs are skipped; an all-`NULL` input set returns `NULL`:
-
-```sql
-SELECT eql_v3.min(salary) FROM employees;
-SELECT eql_v3.max(salary) FROM employees;
-```
-
-### Sorted listing
-
-Write the sort key in extractor form to stream rows out of the index already ordered (see [Sorting](/reference/eql/sorting) for why):
-
-```sql
-SELECT * FROM employees
-ORDER BY eql_v3.ord_term(salary) DESC
-LIMIT 10;
-```
-
-### Cast at the call site
-
-On a generic `jsonb` column whose payloads already carry the `op` term, cast to the right domain in the query:
-
-```sql
-SELECT eql_v3.min(salary_jsonb::public.eql_v3_bigint_ord) FROM employees;
-```
-
## Where to next
diff --git a/content/docs/reference/eql/text.mdx b/content/docs/reference/eql/text.mdx
index 810ff25..af48ec4 100644
--- a/content/docs/reference/eql/text.mdx
+++ b/content/docs/reference/eql/text.mdx
@@ -101,14 +101,7 @@ Blocked *operator* cells raise an `operator … is not supported` exception —
## Functions
-Every operator has a function form, for managed platforms that disallow custom operators — same typed arguments, identical resolution. The `MIN` / `MAX` aggregates only exist as functions:
-
-| Function | Equivalent | Available on |
-| --- | --- | --- |
-| `eql_v3.eq(a, b)` / `eql_v3.neq(a, b)` | `=` / `<>` | `text_eq`, all `text_ord` variants, all `text_search` variants |
-| `eql_v3.lt` / `lte` / `gt` / `gte` | `<` `<=` `>` `>=` | all `text_ord` variants, all `text_search` variants |
-| `eql_v3.contains(a, b)` / `eql_v3.contained_by(a, b)` | `@>` / `<@` | `text_match`, all `text_search` variants |
-| `eql_v3.min(col)` / `eql_v3.max(col)` | aggregate `MIN` / `MAX` | all `text_ord` variants, all `text_search` variants |
+content/partials/eql/functions-text.mdx
There are no `like` / `ilike` function forms — encrypted text matching is `eql_v3.contains` on a `text_match` value.
@@ -126,58 +119,6 @@ SELECT * FROM users WHERE email @> $1::public.eql_v3_text_match;
`@>` / `<@` here is **probabilistic ngram-bloom containment** — it tests whether the encrypted text contains the (encrypted) search terms. It is not JSONB containment and not `LIKE`. The client encrypts the search term into a bloom-filter query value; false positives are possible, false negatives are not. There are no `like` / `ilike` function forms either — text matching is `eql_v3.contains` on a `text_match` value.
-## Example queries
-
-### Exact lookup
-
-Equality on a `text_eq` column compares HMAC terms. `IN` desugars to `=`:
-
-```sql
-SELECT * FROM users WHERE tax_id = $1::public.eql_v3_text_eq;
-
-SELECT * FROM users
-WHERE tax_id IN ($1::public.eql_v3_text_eq, $2::public.eql_v3_text_eq);
-```
-
-### Free-text match
-
-The client encrypts the search term into the bloom-filter needle:
-
-```sql
-SELECT * FROM users WHERE name @> $1::public.eql_v3_text_match;
-
--- Function form, for platforms without custom operators
-SELECT * FROM users WHERE eql_v3.contains(name, $1::public.eql_v3_text_match);
-```
-
-### The works: `text_search`
-
-A `text_search` column answers exact lookup, free-text match, and ordering — here, all three in one query:
-
-```sql
-SELECT id, email FROM users
-WHERE email @> $1::public.eql_v3_text_match -- token containment on bf
- AND email <> $2::public.eql_v3_text_eq -- exclude an exact value via hm
-ORDER BY eql_v3.ord_term(email) -- sort on ob
-LIMIT 20;
-```
-
-### Sorting text
-
-Ordering terms are order-preserving, so `ORDER BY` sorts encrypted text correctly. Write the sort key in extractor form so a btree index can do the ordering instead of a `Sort` node — see [Sorting](/reference/eql/sorting):
-
-```sql
-SELECT * FROM users
-ORDER BY eql_v3.ord_term(email)
-LIMIT 50;
-```
-
-`MIN` / `MAX` work on any ord-capable text column too:
-
-```sql
-SELECT eql_v3.min(email) FROM users;
-```
-
## Where to next
diff --git a/content/partials/eql/functions-dates-and-times.mdx b/content/partials/eql/functions-dates-and-times.mdx
new file mode 100644
index 0000000..581efbf
--- /dev/null
+++ b/content/partials/eql/functions-dates-and-times.mdx
@@ -0,0 +1,59 @@
+{/* GENERATED — do not edit. Produced by scripts/generate-eql-api-docs.ts from the EQL manifest. Edit the generator, not this file. */}
+
+Every operator has a function form, for managed platforms that disallow custom operators — same typed arguments, identical resolution. Each lists the encrypted domains it applies to; the `MIN` / `MAX` aggregates only exist as functions.
+
+### eql_v3.eq(a, b) [#fn-eq]
+
+
+
+```sql
+SELECT * FROM events
+WHERE eql_v3.eq(occurred_at, $1::public.eql_v3_timestamp_eq);
+```
+
+
+
+### eql_v3.neq(a, b) [#fn-neq]
+
+
+
+```sql
+SELECT * FROM events
+WHERE eql_v3.neq(occurred_at, $1::public.eql_v3_timestamp_eq);
+```
+
+
+
+### eql_v3.lt / lte / gt / gte [#fn-comparison]
+
+
+
+```sql
+-- a range uses two of the four
+SELECT * FROM events
+WHERE eql_v3.gte(occurred_at, $1::public.eql_v3_timestamp_ord)
+ AND eql_v3.lt(occurred_at, $2::public.eql_v3_timestamp_ord);
+```
+
+
+
+### eql_v3.min(col) [#fn-min]
+
+
+
+```sql
+-- compares ordering terms; result decrypts client-side
+SELECT eql_v3.min(occurred_at) FROM events;
+```
+
+
+
+### eql_v3.max(col) [#fn-max]
+
+
+
+```sql
+SELECT eql_v3.max(occurred_at) FROM events;
+```
+
+
diff --git a/content/partials/eql/functions-numbers.mdx b/content/partials/eql/functions-numbers.mdx
new file mode 100644
index 0000000..357c8fa
--- /dev/null
+++ b/content/partials/eql/functions-numbers.mdx
@@ -0,0 +1,59 @@
+{/* GENERATED — do not edit. Produced by scripts/generate-eql-api-docs.ts from the EQL manifest. Edit the generator, not this file. */}
+
+Every operator has a function form, for managed platforms that disallow custom operators — same typed arguments, identical resolution. Each lists the encrypted domains it applies to; the `MIN` / `MAX` aggregates only exist as functions.
+
+### eql_v3.eq(a, b) [#fn-eq]
+
+
+
+```sql
+SELECT * FROM payments
+WHERE eql_v3.eq(amount, $1::public.eql_v3_bigint_eq);
+```
+
+
+
+### eql_v3.neq(a, b) [#fn-neq]
+
+
+
+```sql
+SELECT * FROM payments
+WHERE eql_v3.neq(amount, $1::public.eql_v3_bigint_eq);
+```
+
+
+
+### eql_v3.lt / lte / gt / gte [#fn-comparison]
+
+
+
+```sql
+-- a range uses two of the four
+SELECT * FROM payments
+WHERE eql_v3.gte(amount, $1::public.eql_v3_bigint_ord)
+ AND eql_v3.lt(amount, $2::public.eql_v3_bigint_ord);
+```
+
+
+
+### eql_v3.min(col) [#fn-min]
+
+
+
+```sql
+-- compares ordering terms; result decrypts client-side
+SELECT eql_v3.min(amount) FROM payments;
+```
+
+
+
+### eql_v3.max(col) [#fn-max]
+
+
+
+```sql
+SELECT eql_v3.max(amount) FROM payments;
+```
+
+
diff --git a/content/partials/eql/functions-text.mdx b/content/partials/eql/functions-text.mdx
new file mode 100644
index 0000000..ff415c7
--- /dev/null
+++ b/content/partials/eql/functions-text.mdx
@@ -0,0 +1,82 @@
+{/* GENERATED — do not edit. Produced by scripts/generate-eql-api-docs.ts from the EQL manifest. Edit the generator, not this file. */}
+
+Every operator has a function form, for managed platforms that disallow custom operators — same typed arguments, identical resolution. Each lists the encrypted domains it applies to; the `MIN` / `MAX` aggregates only exist as functions.
+
+### eql_v3.eq(a, b) [#fn-eq]
+
+
+
+```sql
+SELECT * FROM users
+WHERE eql_v3.eq(email, $1::public.eql_v3_text_eq);
+```
+
+
+
+### eql_v3.neq(a, b) [#fn-neq]
+
+
+
+```sql
+SELECT * FROM users
+WHERE eql_v3.neq(email, $1::public.eql_v3_text_eq);
+```
+
+
+
+### eql_v3.lt / lte / gt / gte [#fn-comparison]
+
+
+
+```sql
+-- any of the four; ordering is the usual reason to index text
+SELECT id, email FROM users
+WHERE eql_v3.gt(email, $1::public.eql_v3_text_ord)
+ORDER BY eql_v3.ord_term(email);
+```
+
+
+
+### eql_v3.contains(a, b) [#fn-contains]
+
+
+
+```sql
+-- token containment on the bloom-filter term
+SELECT * FROM users
+WHERE eql_v3.contains(email, $1::public.eql_v3_text_match);
+```
+
+
+
+### eql_v3.contained_by(a, b) [#fn-contained_by]
+
+
+
+```sql
+SELECT * FROM users
+WHERE eql_v3.contained_by(email, $1::public.eql_v3_text_match);
+```
+
+
+
+### eql_v3.min(col) [#fn-min]
+
+
+
+```sql
+-- compares ordering terms; result decrypts client-side
+SELECT eql_v3.min(email) FROM users;
+```
+
+
+
+### eql_v3.max(col) [#fn-max]
+
+
+
+```sql
+SELECT eql_v3.max(email) FROM users;
+```
+
+
diff --git a/scripts/generate-eql-api-docs.ts b/scripts/generate-eql-api-docs.ts
index 727e050..609954d 100644
--- a/scripts/generate-eql-api-docs.ts
+++ b/scripts/generate-eql-api-docs.ts
@@ -43,6 +43,12 @@ const MANIFEST_PATH =
(fs.existsSync(RELEASE_MANIFEST) ? RELEASE_MANIFEST : SAMPLE_MANIFEST);
const EQL_DIR = path.join(process.cwd(), "content/docs/reference/eql");
const OUT_FILE = path.join(EQL_DIR, "functions.mdx");
+// Per-type function fragments embedded into the hand-written type pages via
+// Fumadocs' `` directive. They live OUTSIDE the two content
+// collections (content/docs, content/stack) so they never become routes, and
+// are included cwd-relative (`content/partials/…`). Generated, so
+// the per-type "which functions apply" tables can't drift from the manifest.
+const FRAGMENT_DIR = path.join(process.cwd(), "content/partials/eql");
// Single source for the EQL version the whole reference is built against: the
// release manifest's own `version`. Written here so the banner on
// every EQL page reads the same release-derived value (no hardcoded constant).
@@ -214,6 +220,183 @@ function render(manifest: Manifest): string {
return `${body.join("\n").trimEnd()}\n`;
}
+// ── Per-type function fragments ──────────────────────────────────────────────
+// Each hand-written type page (numbers, text, dates-and-times) carries a
+// per-function reference: one card per EQL function, listing its operator
+// equivalents, the domains it applies to, and a worked example. These are
+// generated from the manifest and ``d into the page (via the ``
+// component) rather than hand-maintained, where the domain lists silently
+// drifted. The example is the one authored part — templated by capability, not
+// pulled from the manifest — and the domain list, the drift-prone part, is not.
+//
+// json and booleans are intentionally NOT here: json's surface is containment /
+// path functions (a bespoke story, not the eq/ord/min-max set), and booleans
+// are storage-only with no query functions.
+interface FragmentSpec {
+ page: string;
+ // Which manifest domain `type`s belong on this page.
+ match: (type: string) => boolean;
+ // Illustrative context for the generated examples.
+ table: string;
+ col: string;
+ // Representative concrete type the example casts to, e.g. `bigint` →
+ // `public.eql_v3_bigint_ord`. One of the page's family, for a realistic cast.
+ castType: string;
+ // On text, ranges are unusual and sorting is the point, so the comparison
+ // example demonstrates ORDER BY. Elsewhere a range filter reads best.
+ orderByExample: boolean;
+}
+
+const FRAGMENT_SPECS: FragmentSpec[] = [
+ {
+ page: "numbers",
+ match: (t) =>
+ /(^|\b)(small|big)?int|integer|numeric|decimal|real|double|float/.test(t),
+ table: "payments",
+ col: "amount",
+ castType: "bigint",
+ orderByExample: false,
+ },
+ {
+ page: "text",
+ match: (t) => t === "text",
+ table: "users",
+ col: "email",
+ castType: "text",
+ orderByExample: true,
+ },
+ {
+ page: "dates-and-times",
+ match: (t) => /date|time/.test(t),
+ table: "events",
+ col: "occurred_at",
+ castType: "timestamp",
+ orderByExample: false,
+ },
+];
+
+// The EQL function set, in reading order. `cap` is the domain capability that
+// exposes the function, so a function only renders when the page has a domain
+// with that capability. `lt`/`lte`/`gt`/`gte` are one card: same capability,
+// same domains. `id` is the deep-link anchor.
+interface FuncDef {
+ id: string;
+ name: string;
+ ops: string[];
+ cap: string;
+ agg?: boolean;
+}
+const FUNCS: FuncDef[] = [
+ { id: "fn-eq", name: "eql_v3.eq(a, b)", ops: ["="], cap: "equality" },
+ { id: "fn-neq", name: "eql_v3.neq(a, b)", ops: ["<>"], cap: "equality" },
+ {
+ id: "fn-comparison",
+ name: "eql_v3.lt / lte / gt / gte",
+ ops: ["<", "<=", ">", ">="],
+ cap: "order",
+ },
+ {
+ id: "fn-contains",
+ name: "eql_v3.contains(a, b)",
+ ops: ["@>"],
+ cap: "match",
+ },
+ {
+ id: "fn-contained_by",
+ name: "eql_v3.contained_by(a, b)",
+ ops: ["<@"],
+ cap: "match",
+ },
+ {
+ id: "fn-min",
+ name: "eql_v3.min(col)",
+ ops: ["MIN"],
+ cap: "order",
+ agg: true,
+ },
+ {
+ id: "fn-max",
+ name: "eql_v3.max(col)",
+ ops: ["MAX"],
+ cap: "order",
+ agg: true,
+ },
+];
+
+// `public.eql_v3_text_eq` → `text_eq`.
+const shortDomain = (name: string) => name.replace(/^public\.(eql_v3_)?/, "");
+
+// The example for one function, keyed by its anchor id and templated from the
+// page's illustrative context. The `::public.eql_v3__` casts use
+// real domain names, so they stay correct; the table and column are illustrative.
+function exampleFor(id: string, spec: FragmentSpec): string {
+ const { table, col, castType } = spec;
+ const dom = (variant: string) => `public.eql_v3_${castType}_${variant}`;
+ switch (id) {
+ case "fn-eq":
+ return `SELECT * FROM ${table}\nWHERE eql_v3.eq(${col}, $1::${dom("eq")});`;
+ case "fn-neq":
+ return `SELECT * FROM ${table}\nWHERE eql_v3.neq(${col}, $1::${dom("eq")});`;
+ case "fn-comparison":
+ return spec.orderByExample
+ ? `-- any of the four; ordering is the usual reason to index text\nSELECT id, ${col} FROM ${table}\nWHERE eql_v3.gt(${col}, $1::${dom("ord")})\nORDER BY eql_v3.ord_term(${col});`
+ : `-- a range uses two of the four\nSELECT * FROM ${table}\nWHERE eql_v3.gte(${col}, $1::${dom("ord")})\n AND eql_v3.lt(${col}, $2::${dom("ord")});`;
+ case "fn-contains":
+ return `-- token containment on the bloom-filter term\nSELECT * FROM ${table}\nWHERE eql_v3.contains(${col}, $1::${dom("match")});`;
+ case "fn-contained_by":
+ return `SELECT * FROM ${table}\nWHERE eql_v3.contained_by(${col}, $1::${dom("match")});`;
+ case "fn-min":
+ return `-- compares ordering terms; result decrypts client-side\nSELECT eql_v3.min(${col}) FROM ${table};`;
+ case "fn-max":
+ return `SELECT eql_v3.max(${col}) FROM ${table};`;
+ default:
+ return "";
+ }
+}
+
+function renderFragment(domains: Domain[], spec: FragmentSpec): string {
+ const scoped = domains.filter((d) => spec.match(d.type));
+ const header = `{/* GENERATED — do not edit. Produced by scripts/generate-eql-api-docs.ts from the EQL manifest. Edit the generator, not this file. */}`;
+ const intro =
+ "Every operator has a function form, for managed platforms that disallow custom operators — same typed arguments, identical resolution. Each lists the encrypted domains it applies to; the `MIN` / `MAX` aggregates only exist as functions.";
+ if (!scoped.length) {
+ return `${header}\n\n${intro}\n\n_No matching encrypted domains in this EQL manifest._\n`;
+ }
+
+ const blocks: string[] = [];
+ for (const fn of FUNCS) {
+ const applies = scoped
+ .filter((d) => d.capabilities.includes(fn.cap) && d.variant)
+ .map((d) => shortDomain(d.name));
+ if (!applies.length) continue;
+ const attrs = [
+ `ops="${fn.ops.join(",")}"`,
+ fn.agg ? "agg" : "",
+ `domains="${applies.join(",")}"`,
+ ]
+ .filter(Boolean)
+ .join(" ");
+ const example = exampleFor(fn.id, spec);
+ // The name is a real `###` heading with an explicit id, so each function
+ // gets a table-of-contents entry (nested under "Functions") and a stable
+ // deep-link anchor. The `` card renders everything below it.
+ blocks.push(
+ `### ${fn.name} [#${fn.id}]\n\n\n\n\`\`\`sql\n${example}\n\`\`\`\n\n`,
+ );
+ }
+
+ return `${header}\n\n${intro}\n\n${blocks.join("\n\n")}\n`;
+}
+
+function writeFragments(manifest: Manifest): void {
+ fs.mkdirSync(FRAGMENT_DIR, { recursive: true });
+ for (const spec of FRAGMENT_SPECS) {
+ const out = path.join(FRAGMENT_DIR, `functions-${spec.page}.mdx`);
+ fs.writeFileSync(out, renderFragment(manifest.domains ?? [], spec));
+ console.log(`✓ Generated ${path.relative(process.cwd(), out)}`);
+ }
+}
+
// ── Drift guard ──────────────────────────────────────────────────────────────
// The known surface is fully schema-qualified: domains live in `public.`,
// functions in `eql_v3.` (public) or `eql_v3_internal.` (private), and the
@@ -285,6 +468,9 @@ function main() {
fs.mkdirSync(EQL_DIR, { recursive: true });
fs.writeFileSync(OUT_FILE, render(manifest));
+ // Per-type function fragments included into the hand-written type pages.
+ writeFragments(manifest);
+
// Emit the release version for the banner (shared by every EQL
// reference page, hand-written and generated alike).
fs.mkdirSync(path.dirname(VERSION_FILE), { recursive: true });
diff --git a/src/components/eql-fn.tsx b/src/components/eql-fn.tsx
new file mode 100644
index 0000000..5af3af7
--- /dev/null
+++ b/src/components/eql-fn.tsx
@@ -0,0 +1,97 @@
+"use client";
+
+import { useState } from "react";
+
+interface EqlFnProps {
+ /**
+ * Comma-separated operator equivalents, e.g. `<,<=,>,>=`. Omit for pure
+ * functions with no operator form (e.g. `eql_v3.jsonb_path_query`).
+ */
+ ops?: string;
+ /** Comma-separated short domain names the function applies to. */
+ domains?: string;
+ /** Aggregate function (MIN/MAX): labels the row "Aggregate". */
+ agg?: boolean;
+ /** How many domains to show before the "Show all" toggle. */
+ initial?: number;
+ /** The worked example (a fenced code block). */
+ children: React.ReactNode;
+}
+
+/**
+ * The body of one entry in a generated EQL function reference (see the
+ * fragments under content/partials/eql, produced by
+ * scripts/generate-eql-api-docs.ts).
+ *
+ * The function name is a real Markdown heading in the fragment (so it appears
+ * in the page's table of contents and is deep-linkable); this component renders
+ * everything below it — the operator equivalents, the domains it applies to,
+ * and the example. The domain list is the drift-prone part, so it comes from
+ * the manifest via the `domains` prop; only the first few show, with the rest
+ * behind a reader-controlled toggle. The example is passed as children so it
+ * keeps the site's syntax highlighting and copy button.
+ */
+export function EqlFn({
+ ops,
+ domains,
+ agg,
+ initial = 2,
+ children,
+}: EqlFnProps) {
+ const opList = ops ? ops.split(",").filter(Boolean) : [];
+ const domainList = domains ? domains.split(",").filter(Boolean) : [];
+ const [expanded, setExpanded] = useState(false);
+ const hidden = Math.max(0, domainList.length - initial);
+ const visible = expanded ? domainList : domainList.slice(0, initial);
+ const label = agg ? "Aggregate" : "Operators";
+
+ return (
+