---
name: extension-oql
version: 1.0.0
category: 职场效率
trigger_words:
tags:
  - data
  - ai
  - agent
platform: coze
source: DeepseekModel
source_url: https://deepseekmodel.com/skill?id=caffeinelabs-skills-skills-extension-oql-skill-md
---

name extension-oql description 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. version 0.6.2 compatibility {"mops":{"caffeineai-oql":"~0.6.1"}} caffeineai-subscription ["none"] OQL — Object Query Layer 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. Backend 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 . Setup 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). Build flags --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. Imports — one per resolver module .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). the collection module for each .toEntity / .toEntityManual receiver — MapEntity , SetEntity , ListEntity , ArrayEntity , or VarArrayEntity . for each auto-derived ( .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. Declare entities and install .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. Always call .sample({...}) on every .toEntity / Entity.manual chain; dummy values are fine. Empty collection + no sample → empty schema ( fields: [] / "record { }" ). include Expose({ entities = [tasks.toEntity("task", "Task", "id").sample({ id = 0; title = "" }).public_().build()] }) import Map "mo:core/Map"; import Nat "mo:core/Nat"; import Principal "mo:core/Principal"; import OQL "mo:caffeineai-oql"; import Expose "mo:caffeineai-oql/Expose"; // Resolver modules, imported top-level (see "Imports" above). This app derives // Map entities over records of Nat / Text / Principal fields: import MapEntity "mo:caffeineai-oql/MapEntity"; import Entity "mo:caffeineai-oql/Entity"; import RecordValue "mo:caffeineai-oql/RecordValue"; import NatValue "mo:caffeineai-oql/NatValue"; import TextValue "mo:caffeineai-oql/TextValue"; import PrincipalValue "mo:caffeineai-oql/PrincipalValue"; actor { type Product = { id : Nat; name : Text; priceUsd : Nat }; type Vendor = { id : Nat; name : Text }; type AuditLog = { id : Nat; action : Text; atNs : Nat }; type Note = { id : Nat; user : Principal; body : Text }; type Document = { id : Nat; owner : Principal; title : Text; ciphertext : Text }; type User = { id : Principal; isAdmin : Bool }; let products : Map.Map<Nat, Product>; let vendors : Map.Map<Nat, Vendor>; let supplies : Map.Map<Product, Vendor>; let auditLogs : Map.Map<Nat, AuditLog>; let notes : Map.Map<Nat, Note>; let documents : Map.Map<Nat, Document>; // not all collections need to be exposed if there is no need — `users` backs // auth only, so it is intentionally never turned into an entity below let users : Map.Map<Principal, User>; transient let anyP = Principal.fromText("aaaaa-aa"); // sample owner; the value is ignored // Look up whether a caller is an admin. func isAdmin(p : Principal) : Bool = switch (users.get(p)) { case (?u) u.isAdmin; case null false }; // A custom .ownedByWith rule: admins see every document, everyone else only // their own. `owner` is the field's Value — a Principal column arrives as #text. func canSeeDocument(caller : Principal, owner : OQL.Value) : Bool = isAdmin(caller) or owner == #text(caller.toText()); include Expose({ entities = [ // #public_ — anyone, incl. anonymous, reads the whole catalogue products.toEntity("product", "Product", "id") .sample({ id = 0; name = ""; priceUsd = 0 }) .public_() .build(), vendors.toEntity("vendor", "Vendor", "id") .sample({ id = 0; name = "" }) .public_() .build(), // `supplies : Map<Product, Vendor>` — a map between two non-primitive types. // The identity lives in the key/value records, not a field, so iterate // .entries() in manual mode, promote each side's id, and .edge both — a // query can then traverse "product.name" and "vendor.name". OQL.Entity.manual<(Product, Vendor)>("supply", func () = supplies.entries(), "Supply", "key") .sample(({ id = 0; name = ""; priceUsd = 0 }, { id = 0; name = "" })) .payload("key", func ((p, v)) = p.id.toText() # ":" # v.id.toText()) .payload("product", func ((p, _)) = p.id) .edge("product", "product") .payload("vendor", func ((_, v)) = v.id) .edge("vendor", "vendor") .controllerOnly() .build(), // #controllerOnly (the default, shown explicitly) — only the platform reads auditLogs.toEntity("auditLog", "AuditLog", "id") .sample({ id = 0; action = ""; atNs = 0 }) .controllerOnly() .build(), // #scopedPerUser — each signed-in user reads only their own rows notes.toEntity("note", "Note", "id") .sample({ id = 0; user = anyP; body = "" }) .ownedBy("user") .scopedPerUser() .build(), // #controllerOrScoped — controller reads all; scoped reads use canSeeDocument. // `.hidden` — opaque column absent from schema + default projection documents.toEntity("document", "Document", "id") .sample({ id = 0; owner = anyP; title = ""; ciphertext = "" }) .hidden("ciphertext") .ownedByWith("owner", canSeeDocument) .controllerOrScoped() .build(), ]; }); } The migration chain head: import Map "mo:core/Map"; module { type Product = { id : Nat; name : Text; priceUsd : Nat }; type Vendor = { id : Nat; name : Text }; type AuditLog = { id : Nat; action : Text; atNs : Nat }; type Note = { id : Nat; user : Principal; body : Text }; type Document = { id : Nat; owner : Principal; title : Text; ciphertext : Text }; type User = { id : Principal; isAdmin : Bool }; type NewActor = { products : Map.Map<Nat, Product>; vendors : Map.Map<Nat, Vendor>; supplies : Map.Map<Product, Vendor>; auditLogs : Map.Map<Nat, AuditLog>; notes : Map.Map<Nat, Note>; documents : Map.Map<Nat, Document>; users : Map.Map<Principal, User>; }; public func migration(_old : {}) : NewActor { { products = Map.empty<Nat, Product>(); vendors = Map.empty<Nat, Vendor>(); supplies = Map.empty<Product, Vendor>(); auditLogs = Map.empty<Nat, AuditLog>(); notes = Map.empty<Nat, Note>(); documents = Map.empty<Nat, Document>(); users = Map.empty<Principal, User>(); }; }; }; Auth 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 Choosing a level 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. Per-user (row-level) scoping 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. // Per-user notes: each signed-in user reads only their own rows. notes.toEntity("note", "Note", "id") .sample({ id = 0; owner = Principal.fromText("aaaaa-aa") /* any principal */; body = "" }) .ownedBy("owner") .scopedPerUser() .build() // .ownedByWith custom rule: the owner sees their own docs, listed admins see // everyone's, and the platform controller sees all (#controllerOrScoped). // `owner` is the field's Value — a Principal column arrives as #text(principal). docs.toEntity("doc", "Doc", "id") .sample({ id = 0; owner = Principal.fromText("aaaaa-aa"); title = "" }) .ownedByWith("owner", func (caller, owner) = admins.get(caller) != null or owner == #text(caller.toText())) .controllerOrScoped() .build() 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: // Everyone sees their own bookings; exact amounts only on their own rows is // not needed here — but coarsen the contact field for non-owners of a shared // calendar, say: bookings.toEntity("booking", "Booking", "id") .sample({ id = 0; calendarId = 0; contact = "" }) .ownedByWith("calendarId", canSeeCalendar) .viewWith(func (subject, b) = if (isOwner(subject, b)) b else { b with contact = "" }) .scopedPerUser() .build()