npx skills add ...
npx skills add caffeinelabs/skills --skill extension-oql
Make a canister's data queryable by the Caffeine Data Intelligence agent. Use whenever an app stores structured data (Maps/Lists/arrays of records) that should be answerable in natural language — "top customers", "revenue by region", "active projects". Adds a discoverable `schema()` and a JSON `execute()` query endpoint via the `caffeineai-oql` mops package's `Expose` mixin.
npx skills add caffeinelabs/skills --skill extension-oql
Go over the actor's fields (non-transient) and, for each collection worth querying,
consider how its data maps to a table in a database (an entity). You only
declare one entity per table — the Expose mixin makes them queryable.
Each entity carries an authorization level; the default .controllerOnly() is
safe (private to users, still readable by the Data Intelligence agent). Model
your entities first, then pick a level per entity — see ## Auth.
Run mops add caffeineai-oql@0.6.1 in the same write batch as your first
mo:caffeineai-oql/... import. Auto-derivation requires moc >= 1.11 (the
generated-app template already satisfies this).
--default-persistent-actors is mandatory. --implicit-package=core is
optional convenience; every snippet and source file must import the mo:core
modules it uses. If the app uses OQL.Table and needs more than 4 GiB of
Region, add --max-stable-pages 1638400 as well; a dependency's own flags are
not applied to the project that depends on it, so it has to be set in the app's
own build.
.toEntity, the builder chain (.sample / .build / .public_ / …), and record
_toRow derivation are resolved from modules imported top-level in the file
that declares entities — the resolver does not walk submodules, so importing
only mo:caffeineai-oql is not enough. Import exactly the resolver modules your
code uses:
mo:caffeineai-oql/Entity — always (the .sample / .build / .edge /
.ownedBy / auth-level builder chain, and .payload / .flatten in manual mode)..toEntity / .toEntityManual receiver —
MapEntity, SetEntity, ListEntity, ArrayEntity, or VarArrayEntity..toEntity) record: RecordValue, plus one
<Type>Value per primitive field type present — NatValue, TextValue,
PrincipalValue, BoolValue, IntValue, FloatValue, the sized Nat/Int
widths, BlobValue. Manual .payload return types need their <Type>Value
too; manual-only entities need no RecordValue.When a collection module is missing the compiler names it — "field toEntity does
not exist … Did you mean to import mo:caffeineai-oql/MapEntity?" — add the named
import. A missing Entity import gets no such hint: it surfaces as a bare
"field payload does not exist in type Builder<…>" (M0072). Treat any
field <builderMethod> does not exist error as a missing top-level import from
this list, never as a wrong package version.
.toEntity(name, typeName, primaryKey) turns a collection of records into a
queryable entity; the compiler auto-derives the fields. Each entity sets its own
authorization level (see ## Auth); the example below shows one table per level.
Expose adds only the OQL query methods (schema / execute) — your existing
state, types, and shared methods are untouched.
.sample({...}) on every .toEntity / Entity.manual chain; dummy values are fine. Empty collection + no sample → empty schema (fields: [] / "record { }").The migration chain head:
Authorization is per entity — each builder declares a level, and schema()
and execute() both run the check against the live caller. No app-wide config,
no tokens. The default when none is set is #controllerOnly.
| Builder call | Who reads | Rows returned |
|---|---|---|
.public_() | anyone (incl. anonymous) | all |
.controllerOnly() (default) | controllers only | all |
.scopedPerUser() | any signed-in caller | only the caller's own |
.controllerOrScoped() | controllers + signed-in callers | controller: all; user: own |
Pick per entity by who should read its rows — when in doubt, keep the default.
.controllerOnly() (default) — private app data the agent should answer
over, but no end user reads directly (orders, metrics, audit logs, config). The
agent calls as the controller, so it reads everything while the data stays
private to users..public_() — world-readable data, including logged-out visitors (public
catalogue, published content, leaderboards)..controllerOrScoped() — per-user data where each user reads only their own
rows, but the agent must still answer aggregate questions (profiles, a user's
orders). Requires an owner column..scopedPerUser() — strictly private per-user data: each user reads only
their own, and the agent is scoped too, so it cannot answer over this table
(DMs, private journals). Requires an owner column — prefer
.controllerOrScoped() unless the agent must be blind to it.The user may override per entity; if a request implies per-user data but is ambiguous, ask.
Scoped levels (.scopedPerUser(), .controllerOrScoped()) need a way to know
which rows belong to the caller — an owner column or a subject-honouring
source. .build() traps if a scoped entity has neither, and also traps if a
.public_() entity declares an owner (the check would never run). This is the
guardrail against the common data-leak footgun.
When to tag: a Principal field is the signal.
.ownedBy(field) — the field is the owner; visibility is identity equality..ownedByWith(field, canSee) — custom visibility (teams, admins, sharing).
canSee : (caller : Principal, owner : Value) -> Bool decides per row; field
need not be a Principal, and the closure can read actor state.A scoped caller sees only its owned rows — both as the query target and through a join — so traversal can never leak another owner's rows.
Where ownership decides which rows a scoped caller sees, .viewWith(view)
decides what shape it sees them in — a per-subject redaction that runs only
on rows the ownership check already admitted:
view : (subject : Principal, row : T) -> T reshapes the typed row; the whole
query pipeline (filters included) evaluates the VIEWED row, so a predicate can
never probe a value the view hides. Views run for scoped subjects only —
pairing .viewWith with .public_() traps at .build() (unrestricted reads
always see raw rows).
.ownedBy(f) is exactly .ownedByWith(f, OQL.Entity.ownerIsCaller). At most one
owner column; it must be a real field, not also .edge / .hidden. For
owner-keyed storage (Map<Principal, List<T>>) use
OQL.Entity.newScoped(name, scopedIter, typeName, primaryKey) so the scan is
O(user rows): scopedIter(?p) returns only p's rows, scopedIter(null) all
(schema seeding).
Two modes, picked by the row type T.
.toEntityFor records whose fields are all primitives with a built-in _toRow (Nat,
Int, Float, Text, Bool, the sized Nat/Int widths, Principal):
.toEntity is sugar for OQL.Entity.new<T>(name, func () = coll.values(), …);
it exists on Map, Set, List, [T], and [var T]. It iterates values
only — if a row's identity (PK or owner) lives in the Map key, it is not a
field: promote it via manual mode over .entries(), or OQL.Entity.newScoped
when it's the owner.primaryKey, and any .edge / .ownedBy field, must name a real,
non-.hidden column of the row..edge(name, target) tags an existing field (it does not add one) as an FK,
enabling dotted-path traversal "name.targetField" in queries. FK/PK types
must be Text, Nat/Int, or Bool (Float keys are rejected), and the
target's primary key must not be .hidden.Expose.
An edge to an absent entity (a typo, or an FK into another canister) silently
drops the whole field from schema() — the column still stores and filters,
but no schema-driven client can discover it. A cross-canister FK belongs as a
plain payload field, not an .edge..sample(template) seeds schema discovery. Always call it; without it an
empty collection yields an empty schema (fields: []). Only the shape
matters, not the values..hidden(name) drops a derivable field from schema + default projection; it
does not skip _toRow — unsupported field types still need manual mode or
a <Type>Value.Schema fields are listed in lexicographic order (the __record combiner's
canonical form); sort client-side if display order matters.
.toEntityManual / OQL.Entity.manualFor non-record T, computed fields, or records with nested / variant / option /
collection fields:
field payload does not exist in type Builder<…> (M0072) means the Entity
import above is missing — it is never a package-version problem. .payload
and .flatten have been Entity functions since 0.1.0; mops add-ing a
different caffeineai-oql version will not fix this error. Importing only
mo:caffeineai-oql is not enough, and neither is reaching the module through
the OQL.Entity.… re-export: receiver notation resolves against top-level
imports only, so a file that calls OQL.Entity.manual(...).payload(...)
still needs its own import Entity "mo:caffeineai-oql/Entity";. Unlike
.toEntity, this failure carries no "Did you mean to import …?" hint.
.payload(name, extract) — name must not contain .. Prefer
func r = r.field (let Motoko infer; avoid redundant annotations). For
options/variants, return Text/Nat with a sentinel (see below)..flatten(extract : T -> S) — S must be flat; each of its fields becomes a
top-level column. Drop unwanted ones with .hidden. Name collisions get
__1, __2 suffixes (nothing is dropped).OQL.Entity.manual<T>(name, iter, typeName, primaryKey) for arbitrary row
sources (custom flatteners, filtered iterators). Always chain .sample(...)
with one dummy row of type T. The qualified call resolves through the
OQL import, but any .payload / .flatten chained onto its result still
needs the top-level Entity import.OQL.Value is { #null_; #bool; #nat; #int; #float; #text }. Numeric variants
compare across each other, so a JSON integer threshold matches a Float value.
Row type T | Mode |
|---|---|
| All-primitive record | .toEntity |
Record with ? / variant / nested field | .toEntity once you ship <Type>Value.mo (below); else manual |
| Record with a collection field | manual — .size() or Text.join into a payload |
| Tuple / primitive / computed | manual |
To keep a record on the auto-derive path, give each non-primitive field type a
_toRow : T -> OQL.Value: one file per type named <TypeName>Value.mo, a single
public func _toRow, imported top-level in the file that declares entities —
the same top-level rule as the built-in value modules (see ## Setup → Imports);
the resolver does not walk submodules. Parent records then ride .toEntity(...)
with no per-field .payload.
Always return ONE Value variant, even for null (sentinel "" / 0 /
false) — a _toRow that sometimes returns #null_ makes the reported schema
type flip-flop by row order. Sentinels keep the field queryable (eq value ""
matches the nulls). For a one-off field, inline the same conversion in a
.payload instead of a module; lift to a module only when 2+ entities need it.
A record used both as an entity and as a nested field just ships its
<Type>Value.mo — the structural Row derivation and your Value collapse are
distinct types and coexist.
The same storage can back several entities — pick what the client should see:
Map<K1, Map<K2, V>> into rows; have the flattener emit
a flat record (not a tuple) so it still auto-derives, then .edge the
promoted keys.Map<Author, …>.keys()) via
OQL.Entity.manual; entries with no rows simply don't appear.OQL.TableFor a table expected to grow large (tens of thousands of rows and up — events,
transactions, logs, imported datasets), store it in an OQL.Table. It scales
far past what heap collections hold; rows are keyed by their append position.
Declaring one is three decisions — columns, indexes, row function:
Table.new runs in the migration chain entry that introduces the table:
Rules that matter:
append rather than storing corrupt cells; numeric cells are 64-bit.Table is queryable only through its entity in Expose — declaring
the table alone stores data but exposes nothing; schema() / execute()
see exactly the entities list.Beyond what the index serves, a Table answers whole-column sum / avg
straight from segment stats — flat in table size — and reads only the columns
a query touches.
ImportData) — load existing data into a TableWhen the user's data already exists (a CSV export from a spreadsheet or the
system the app replaces), don't trickle it through append — include the
ImportData mixin and load it as pre-built segment images: the loader lays
rows out off-chain in exactly a flushed segment's byte layout and the canister
validates and copies the bytes, so one message costs O(columns) instead of
O(rows), and the heap stays flat throughout.
The mixin adds controller-only endpoints (layout, rows, putSegment,
importFlush, buildIndex, indexStatus) — the image's stats are trusted
answers, so the load surface belongs to the principal that could install code
anyway.
Running the loader. The tool ships with this skill in scripts/ — plain
Node (≥ 20) with no dependencies to install; every call goes through
icp canister call, so icp-cli must be set up:
--target is the importTarget name declared in the canister; the canister's
layout() is the schema authority — the CSV header is matched to the columns
by name, so file column order never matters (extra CSV columns are
ignored; a declared column missing from the file is an error).-e <environment> (-e ic for the deployed app), -n <network>,
--identity <name> (default: your current one). The endpoints are
controller-only, so the identity must be a controller of the canister.
With none of them, your icp defaults apply — a canister NAME then resolves
against the project's default environment, so run the tool from the app's
project directory.--index col:kind (repeatable, kind = hash) builds and uploads that
column's index off-chain after the rows. For anything it can't build
(#ordered, composites), build on-chain instead through the mixin's
endpoint: icp canister call <id> buildIndex '("event", vec {"kind"}, variant {hash})' — queries scan (correctly, just slower) until the build
completes, then the index serves; indexStatus '("event")' reports progress.icp canister call <id> execute '("{\"start\":\"event\",\"aggregate\":[{\"fn\":\"count\"}]}")' --query
must return the CSV's row count."" in a #text column is the empty string; a #bytes(w)
column's field is base64 and must decode to exactly w bytes.putSegment's expect-first-row guard turns any double-send into
a loud trap instead of duplicated rows.Operating rules (violations refuse loudly rather than corrupt):
buildIndex (slower, always works).mops add caffeineai-oql@0.6.1 in the same batch as the first import## Setup → Imports): Entity
(always — every builder method including .payload / .flatten
resolves through it), the collection module(s) (MapEntity / …), and
RecordValue + a <Type>Value per primitive field type of each
auto-derived record.toEntity (all-primitive) or
.toEntityManual / OQL.Entity.manual otherwise<Type>Value.mo for every non-primitive field reused across entities,
imported top-level.sample(template) on every .toEntity / Entity.manual chain (dummy values are fine).edge(name, target); opaque/sensitive auto-derived fields
.hidden(name) (manual: omit via no .payload, or .hidden only columns
you did add)Value variant.ownedBy / .ownedByWith and a scoped level
(.scopedPerUser() / .controllerOrScoped()) — never bare
.controllerOnly()OQL.Table; keep the handle in a persistent
field and thread it through migrationsTable index-free, add
ImportData([t.importTarget(name)]), load with scripts/ingest.mjs,
index after the load (--index or buildIndex) — data and indexes done
before the app starts writing