关于 extension-oql
面向 Motoko 的 Object Query Layer 技能,可将 canister 集合公开为可查询实体,涵盖 Schema 派生、实体级授权、按用户限制行访问及关联遍历。
主要功能
- 01将集合映射为实体
- 02自动或手动派生 Schema
- 03按实体设置读取授权
- 04按所有者限制行并定义视图
- 05实体间外键遍历
适用场景
- 01让 canister 记录支持查询
- 02将行访问限制为数据所有者
- 03公开目录或管理私有审计数据
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ caffeinelabs/ skills --skill extension-oql安装后在 Claude Code 中输入/extension-oql确认已加载。
包含的文件
除 SKILL.md 外,安装时一起下载的参考资料和脚本。
scripts/csv.mjsencode.mjshash-segment.mjsingest.mjslib.mjspackage.json详细介绍
OQL — 对象查询层
遍历 Actor 的字段(非临时字段),并针对每个值得查询的集合,
考虑其数据如何映射到数据库中的一张表(一个实体)。每张表只
声明一个实体 — Expose mixin 会使其可查询。
后端
每个实体都有一个授权级别;默认的 .controllerOnly() 是安全的(对用户私有,但数据智能 Agent 仍可读取)。先建模实体,再为每个实体选择级别 — 参见 ## Auth。
设置
在首次导入 mo:caffeineai-oql/... 的同一写入批次中运行 mops add [email protected]。自动派生需要 moc >= 1.11(生成的应用模板已满足此要求)。
构建标志
--implicit-package=core 是可选的便利设置;每个代码片段和源文件都必须导入其使用的 mo:core 模块。如果应用使用 OQL.Table,且需要超过 4 GiB 的 Region,请添加 --max-stable-pages 1638400;依赖项自身的标志不会应用到依赖它的项目,因此必须在应用自身的构建中设置。
导入 — 每个解析器模块各导入一次
.toEntity、构建器链(.sample / .build / .public_ / …)以及记录的 _toRow 派生,都会从声明实体的文件中顶层导入的模块解析 — 解析器不会遍历子模块,因此仅导入 mo:caffeineai-oql 并不足够。请准确导入代码所用的解析器模块:
mo:caffeineai-oql/Entity— 始终需要(.sample/.build/.edge/.ownedBy/ 授权级别构建器链,以及手动模式下的.payload/.flatten)。- 每个
.toEntity/.toEntityManual接收者对应的集合模块 —MapEntity、SetEntity、ListEntity、ArrayEntity或VarArrayEntity。 - 对每个自动派生(
.toEntity)记录:RecordValue,以及每种出现的基本字段类型对应的一个<Type>Value—NatValue、TextValue、PrincipalValue、BoolValue、IntValue、FloatValue、定宽Nat/Int类型,以及BlobValue(也涵盖Storage.ExternalBlob)。可选字段(?T)无需额外导入 —Entity自带?T实例。手动模式下.payload的返回类型也需要对应的<Type>Value;仅手动实体无需RecordValue。
如果缺少集合模块,编译器会指出模块名称 — "field toEntity does not
exist … Did you mean to import mo:caffeineai-oql/MapEntity?" — 请添加所提示的导入。缺少 Entity 导入时不会出现此类提示:它会直接报错 "field payload does not exist in type Builder<…>"(M0072)。请将任何 field <builderMethod> does not exist 错误视为此列表中缺少顶层导入,而非包版本错误。
声明实体并安装
.toEntity(name, typeName, primaryKey) 会将记录集合转换为可查询实体;编译器会自动派生字段。每个实体都设置自己的授权级别(参见 ## Auth);下面的示例展示每个级别对应一张表。Expose 只会添加 OQL 查询方法(schema / execute)— 不会更改现有状态、类型和 shared 方法。
- 每条
.toEntity/Entity.manual链都必须调用.sample({...});虚拟值即可。集合为空且未提供样本 → 空架构(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(),
];
});
}
迁移链的头部:
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
授权按实体设置 — 每个构建器都会声明一个级别,且 schema() 和 execute() 都会针对当前 caller 执行检查。无需应用级配置,也无需令牌。未设置时的默认值为 #controllerOnly。
| 构建器调用 | 可读取者 | 返回行 |
|---|---|---|
.public_() | 任何人(包括匿名用户) | 全部 |
.controllerOnly() (默认) | 仅控制器 | 全部 |
.scopedPerUser() | 任何已登录的调用者 | 仅调用者自己的行 |
.controllerOrScoped() | 控制器 + 已登录的调用者 | 控制器:全部;用户:自己的行 |
选择级别
根据应由谁读取实体中的行来选择;不确定时,保留默认值。
.controllerOnly()(默认) — Agent 应当据此作答的应用私有数据,但最终用户不会直接读取(订单、指标、审计日志、配置)。Agent 以控制器身份调用,因此可以读取所有数据,同时数据对用户保持私有。.public_()— 任何人(包括已登出的访客)都可读取的数据(公开目录、已发布内容、排行榜)。.controllerOrScoped()— 每位用户只能读取自己行的用户数据,但 Agent 仍须能够回答聚合类问题(个人资料、用户订单)。需要所有者列。.scopedPerUser()— 严格私有的用户数据:每位用户只能读取自己的数据,Agent 也受范围限制,因此无法基于此表作答(私信、私人日记)。需要所有者列 — 除非 Agent 必须无法访问这些数据,否则优先使用.controllerOrScoped()。
用户可以按实体覆盖设置;如果请求暗示这是用户数据,但含义不明确,请先询问。
按用户(行级)限定范围
限定范围的级别(.scopedPerUser()、.controllerOrScoped())需要一种方式来判断哪些行属于调用者 — 所有者列或遵循主体的来源。限定范围的实体若两者都没有,.build() 会报错;如果 .public_() 实体声明了所有者,也会报错(因为检查永远不会运行)。这是防止常见数据泄露隐患的安全机制。
何时标记: Principal 字段是判断依据。
.ownedBy(field)— 该字段就是所有者;可见性由身份相等决定。.ownedByWith(field, canSee)— 自定义可见性(团队、管理员、共享)。canSee : (caller : Principal, owner : Value) -> Bool按行决定可见性;field不必是Principal,闭包也可以读取 actor 状态。
限定范围的调用者只能看到其拥有的行 — 无论是作为查询目标,还是通过联接访问 — 因此遍历永远不会泄露其他所有者的行。
// 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()
所有权决定限定范围的调用者能看到哪些行,而 .viewWith(view) 决定这些行呈现的形式 — 它会按主体进行脱敏,且只会对已通过所有权检查的行运行:
// 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()
view : (subject : Principal, row : T) -> T 会重塑类型化的行;整个查询管道(包括筛选条件)都会针对经 VIEW 处理的行执行,因此谓词绝不会探查视图隐藏的值。视图只会针对限定范围的主体运行 — 将 .viewWith 与 .public_() 配对会在 .build() 时报错(不受限的读取始终会看到原始行)。
.ownedBy(f) 等同于 .ownedByWith(f, OQL.Entity.ownerIsCaller)。所有者列最多只能有一个;它必须是真实字段,不能同时为 .edge / .hidden。对于以所有者为键的存储(Map<Principal, List<T>>),使用 OQL.Entity.newScoped(name, scopedIter, typeName, primaryKey),这样扫描的复杂度为 O(用户行数):scopedIter(?p) 只返回 p 的行,scopedIter(null) 返回所有行(用于架构初始化)。
实体构建器
根据行类型 T 选择两种模式。
自动推导 — .toEntity
对于字段全为基本类型且内置 _toRow 的记录(Nat、
Int、Float、Text、Bool、定宽的 Nat/Int、Principal):
customers.toEntity(name, typeName, primaryKey)
.sample(template) // REQUIRED — empty collection + no sample → empty schema
.edge(field, targetEntity) // tag an existing field as a foreign key
.ownedBy(field) // (or .ownedByWith(field, canSee)) per-user scoping
.scopedPerUser() // auth level: .public_ / .controllerOnly (default) / .scopedPerUser / .controllerOrScoped
.hidden(field) // opaque/sensitive — drop from schema + default projection
.build()
.toEntity是OQL.Entity.new<T>(name, func () = coll.values(), …)的语法糖; 它适用于Map、Set、List、[T]和[var T]。它只遍历值——如果某行的标识(PK 或所有者)位于 Map 键中,该标识就不是字段:通过对.entries()使用手动模式将其提升为字段,或者在其为所有者时使用OQL.Entity.newScoped。primaryKey以及任何.edge/.ownedBy字段,都必须指向行中真实存在且非.hidden的列。.edge(name, target)会将现有字段标记为外键(不会添加字段),从而支持在查询中使用点路径"name.targetField"遍历。外键/主键类型必须为Text、Nat/Int或Bool(不接受Float键),且目标的主键不能是.hidden。- 边的目标必须是已在此 canister 的
Expose中注册的实体。 指向不存在实体的边(拼写错误,或指向其他 canister 的外键)会静默地从schema()中移除整个字段——该列仍会存储并可用于筛选,但客户端无法通过 schema 发现它。跨 canister 外键应作为普通载荷字段,而非.edge。 .sample(template)用于提供 schema 发现所需的种子。务必调用;若未调用,空集合会产生空 schema(fields: [])。只有结构重要,值无关紧要。.hidden(name)会从 schema 和默认投影中移除可推导字段;它不会跳过_toRow——不支持的字段类型仍需使用手动模式或<Type>Value。
Schema 字段按字典序列出(__record 合并器的规范形式);若显示顺序很重要,请在客户端排序。
手动模式 — .toEntityManual / OQL.Entity.manual
适用于非记录类型 T、计算字段,或包含嵌套 / 变体 / 可选值 /
集合字段的记录:
// REQUIRED, top-level in this file — `.payload` / `.flatten` are Entity
// functions reached by receiver notation, not fields of the builder:
import Entity "mo:caffeineai-oql/Entity";
authors.toEntityManual<Author>("author", "Author", "id")
.sample({ id = 0; name = ""; address = { street = ""; city = "" }; tags = [] })
.payload("name", func a = a.name) // one field; extract returns a _toRow value
.flatten(func a = a.address) // splice a nested record's fields as columns
.payload("tagCount", func a = a.tags.size())
// .edge tags a declared column; .hidden drops one you already added via
// `.payload` / `.flatten` — omit fields by not adding them
.build()
field payload does not exist in type Builder<…>(M0072)表示缺少上方的 Entity 导入——这绝不是软件包版本问题。 .payload 和 .flatten 从 0.1.0 起就是 Entity 函数;通过 mops add 安装不同版本的 caffeineai-oql 无法修复此错误。仅导入 mo:caffeineai-oql 不够,通过 OQL.Entity.… 重新导出访问模块也不够:接收者记法只会解析顶层导入,因此调用 OQL.Entity.manual(...).payload(...) 的文件仍需自行添加 import Entity "mo:caffeineai-oql/Entity";。与 .toEntity 不同,此错误不会附带“是否要导入……?”提示。
.payload(name, extract)—name不得包含.。优先使用func r = r.field(让 Motoko 推断类型;避免冗余标注)。可选的?T载荷可直接使用;对于变体,请返回Text/Nat(见下文)。.flatten(extract : T -> S)—S必须是扁平类型;它的每个字段都会成为 顶层列。使用.hidden移除不需要的字段。名称冲突会添加__1、__2后缀(不会丢弃任何字段)。- 对任意行来源(自定义扁平化器、经过筛选的迭代器)使用
OQL.Entity.manual<T>(name, iter, typeName, primaryKey)。务必链接.sample(...), 并提供一个类型为T的虚拟行。限定调用通过OQL导入解析,但链接到其结果上的任何.payload/.flatten仍需要顶层Entity导入。
OQL.Value 是 { #null_; #bool; #nat; #int; #float; #text }。数值变体之间
可以比较,因此 JSON 整数阈值可以匹配 Float 值。
行类型 T | 模式 |
|---|---|
| 全基本类型记录 | .toEntity |
带 ? 字段的记录 | .toEntity(照常导入内部类型的 <Type>Value) |
| 带变体 / 嵌套字段的记录 | 提供 <Type>Value.mo 后使用 .toEntity(见下文);否则使用手动模式 |
| 带集合字段的记录 | 手动模式 — 使用 .size() 或将其通过 Text.join 转为载荷 |
| 元组 / 基本类型 / 计算值 | 手动模式 |
可选字段
每个具有实例的 ?T(?Text、?Nat、?Storage.ExternalBlob,……)都会通过 Entity 中的 ?T 实例派生:null 存储为 #null_,?v 存储为 v。无需额外导入或为每种类型单独创建文件。#null_ 单元格匹配 eq value null,不匹配有序比较,并会被聚合操作跳过。
在 .sample(...) 行中,设置每个可选字段(note = ?""、score = ?0、photo = ?("" : Storage.ExternalBlob)):架构会根据样本推断每列的类型,因此在那里留为 null 的字段会报告类型 "Null"。
转换非基本类型字段
若要让记录保留在自动派生路径上,请为每种非基本字段类型提供一个
_toRow : T -> OQL.Value:每种类型一个文件,命名为 <TypeName>Value.mo,其中包含一个 public func _toRow,并在声明实体的文件中顶层导入——与内置值模块的顶层规则相同(参见 ## Setup → Imports);解析器不会遍历子模块。父记录随后可通过 .toEntity(...) 处理,无需为每个字段单独使用 .payload。
// StatusValue.mo — variant → tag text
module { public func _toRow(self : Status) : OQL.Value =
#text(switch self { case (#draft) "draft"; case (#published) "published" }); };
// DepartmentValue.mo — nested record → child PK (then .edge the field)
module { public func _toRow(self : Department) : OQL.Value = #text(self.name); };
自定义 _toRow 始终只返回一个 Value 变体——如果它针对不同行返回不同变体,报告的架构类型就会取决于抽取了哪一行作为样本。对于一次性字段,可将相同的转换内联到 .payload 中,而不是创建模块;只有在 2 个或更多实体需要时才提取为模块。既用作实体又用作嵌套字段的记录只需提供其 <Type>Value.mo——结构化的 Row 派生类型与你的 Value 转换结果是不同的类型,可以共存。
超越每条记录一行的实体模式
同一存储可以支持多个实体——选择客户端应看到的内容:
- reshaped — 将
Map<K1, Map<K2, V>>展平为行;让展平器输出一个扁平的记录(而不是元组),以便继续自动派生,然后对提升后的键使用.edge。 - enumerated — 通过
OQL.Entity.manual,从索引键(Map<Author, …>.keys())派生实体;没有行的条目不会出现。 - synthetic — 从数组字段投影出一个联结表,使多对多查询可以从两侧进行:
OQL.Entity.manual<(Article, Text)>("articleTag", func () = flattenTags(articles), "Pair", "pair")
.sample(({ id = 0 }, ""))
.payload("article", func ((a, _)) = a.id) .edge("article", "article")
.payload("tag", func ((_, t)) = t) .edge("tag", "tag")
.build()
更大的数据集 — OQL.Table
对于预计会增长到较大的表(数万行或更多,例如事件、交易、日志、导入的数据集),请将其存储在 OQL.Table 中。它的扩展能力远超堆集合;行以追加位置作为键。
声明一个表需要做出三个决定——列、索引、行函数:
import OQL "mo:caffeineai-oql";
import Expose "mo:caffeineai-oql/Expose";
import Entity "mo:caffeineai-oql/Entity"; // the builder chain — always import
import Table "mo:caffeineai-oql/Table";
actor {
type Event = { kind : Nat; amount : Nat; note : Text };
// decisions 1 + 2 (columns, indexes) are fixed where `Table.new` runs —
// the migration chain entry below
let events : Table.Table;
// 3. Row function: your record → one (name, Value) per column, names matching.
func eventRow(e : Event) : [(Text, OQL.Value)] =
[("kind", #nat(e.kind)), ("amount", #nat(e.amount)), ("note", #text(e.note))];
// Writes: the returned position is the row's primary key.
// A modify is delete + append.
public func addEvent(e : Event) : async Nat {
Table.append(events, e, eventRow);
};
// REQUIRED: register the table's entity in the Expose mixin — schema() and
// execute() only see what Expose registers; a Table that is not in this list
// exists but is invisible to every query. Same list, same auth levels, and
// FK edges to other entities, as for any entity. `entity` defaults the type
// name to the entity name and the primary key to "id";
// `entityWith(events, "event", "Event", "id")` overrides either.
include Expose({
entities = [
Table.entity(events, "event").public_().build(),
// ... the app's other entities ...
];
});
};
Table.new 在引入该表的迁移链条目中运行:
import Table "mo:caffeineai-oql/Table";
module {
public func migration(_old : {}) : { events : Table.Table } {
{
// 1. Columns: (name, type) pairs. Order matters and the set is FIXED for
// the table's life. Types: #nat / #int / #float / #bool (64-bit cells) + #text.
// 2. Indexes: the columns queries will filter or order by —
// #hash for equality, #ordered for ranges / orderBy.
events = Table.new(
[("kind", #nat), ("amount", #nat), ("note", #text)], // columns
[("kind", #hash), ("amount", #ordered)], // indexes
);
};
};
};
重要规则:
- 数据刷新后,架构即固定——不能添加、删除或重新指定列类型;形状不同就意味着要建新表。
- 值的种类必须与列类型相同——不匹配会在
append时直接触发错误,而不会存储损坏的单元格;数值单元格为 64 位。 - 要批量加载的表声明时不得包含索引——加载后再创建索引(见下一节)。
- 只能通过
Expose中的实体查询Table——仅声明表只会存储数据,不会暴露任何内容;schema()/execute()只会看到entities列表中的内容。
除了索引所服务的查询之外,Table 还可以直接根据分段统计信息计算整列的 sum / avg——耗时不随表大小增长——并且只读取查询涉及的列。
批量上传(ImportData)——将现有数据载入 Table
当用户的数据已经存在(例如从电子表格或应用所替代的系统导出的 CSV)时,不要通过 append 慢慢写入——引入 ImportData mixin,并将数据作为预构建的 段映像 载入:加载器在链下按照已刷写段的字节布局精确排列行,canister 会验证并复制这些字节,因此每条消息的开销是 O(columns),而非 O(rows),整个过程中堆内存保持平稳。
import Expose "mo:caffeineai-oql/Expose";
import Entity "mo:caffeineai-oql/Entity"; // the builder chain — always import
import Table "mo:caffeineai-oql/Table";
import ImportData "mo:caffeineai-oql/ImportData";
actor {
// Declare the table INDEX-FREE for the load — loadSegment traps on a table
// that declares an index (a ready index missing the loaded rows would
// silently under-fetch). The index is built after the load, in the background.
// Constructed in the migration chain entry that introduces it:
// events = Table.new([("kind", #nat), ("amount", #nat), ("note", #text)], []);
let events : Table.Table;
include Expose({
entities = [
Table.entity(events, "event").public_().build(),
// ... the app's other entities ...
];
});
include ImportData([ events.importTarget("event") ]);
};
该 mixin 添加了 仅控制器可调用 的端点(layout、rows、putSegment、importFlush、buildIndex、indexStatus)——映像中的统计数据是可信的,因此加载接口应只对本就能够安装代码的主体开放。
运行加载器。 该工具随此技能一同提供,位于 scripts/ 中——使用原生 Node(≥ 20),无需安装依赖;每次调用都会通过 icp canister call 执行,因此必须先配置好 icp-cli:
node <this skill's directory>/scripts/ingest.mjs \
--canister <canister-id> --target event --file events.csv \
-e ic --index kind:hash
--target是 canister 中声明的importTarget名称;canister 的layout()是架构的权威来源——CSV 表头会按名称与列匹配,因此文件中的列顺序无关紧要(多余的 CSV 列会被忽略;缺少已声明列则会报错)。- 连接标志会传递给 icp-cli,和其他 canister 调用的方式相同:
-e <environment>(已部署的应用使用-e ic)、-n <network>、--identity <name>(默认值:当前身份)。这些端点仅控制器可调用,因此该身份必须是 canister 的 控制器。如果未指定这些标志,则使用 icp 的默认值——此时 canister NAME 会根据项目的默认环境解析,因此请从应用的项目目录运行该工具。 --index col:kind(可重复)会在行数据之后,在链下构建并上传对应列的索引(kind=hash)。对于无法在链下构建的索引(#ordered、复合索引),请改用该 mixin 的端点在链上构建:icp canister call <id> buildIndex '("event", vec {"kind"}, variant {hash})'— 构建完成前,查询会扫描数据(结果正确,只是较慢);之后由索引提供服务;indexStatus '("event")'会报告进度。- 按照客户端读取数据的方式验证加载结果:
icp canister call <id> execute '("{\"start\":\"event\",\"aggregate\":[{\"fn\":\"count\"}]}")' --query必须返回 CSV 的行数。 - CSV 单元格会根据声明的类型解码。未加引号的空字段是 null 单元格;
#text列中带引号的""表示空字符串;#bytes(w)列的字段必须是 base64,且解码后必须恰好为w个字节。 - 重新运行相同命令会从上次进度继续:表中已有的行会被跳过,且
putSegment的首行校验会在重复发送时直接触发 trap 并明确报错,而不是产生重复行。
操作规则(违反规则时会明确拒绝,而非破坏数据):
- 首次加载必须完整完成——包括数据和索引——之后应用才能开始写入。 如果加载过程中有写入,重新运行将无法完成索引;请通过
buildIndex在 canister 内重建(速度较慢,但始终有效)。 - 此后,加载和写入轮流进行。 两次加载之间,应用可以自由写入;下次运行相同命令时会将新增数据作为增量加载,无需任何准备。
- 绝不能同时进行——canister 会拒绝操作,不会返回错误答案。
- 查询在整个过程中都能正常工作,包括加载期间;索引仍在上传的列会先通过扫描提供查询服务,直至索引构建完成。
检查清单
- 在首次导入时,同一批执行
mops add [email protected] - 在顶层导入解析器模块(参见
## Setup→ Imports):Entity(始终如此——包括.payload/.flatten在内的每个构建器方法 都通过它进行解析)、集合模块(MapEntity/ …),以及 每种自动派生记录都要导入RecordValue和每种原始字段类型对应的<Type>Value - 每个实体:都存在行迭代器;使用
.toEntity(全原始类型)或.toEntityManual/OQL.Entity.manual,否则 -
.sample为每个可选(?T)字段设置非 null 虚拟值 - 每个在多个实体间复用的非原始字段都要有
<Type>Value.mo, 并在顶层导入 - 每条
.toEntity/Entity.manual链都调用.sample(template)(虚拟值即可) - FK 字段使用
.edge(name, target);自动派生的不透明/敏感字段使用.hidden(name)(手动定义时:不添加.payload即可省略,或者仅对 已添加的列使用.hidden) - 每个自定义
_toRow都返回一个Value变体 - 按用户划分的实体使用
.ownedBy/.ownedByWith并且设置作用域级别 (.scopedPerUser()/.controllerOrScoped())——绝不单独使用.controllerOnly() - 大型、主要采用追加方式的表 →
OQL.Table;将句柄保存在持久化 字段中,并在迁移中传递 - 要导入现有数据集 → 声明不含索引的
Table,添加ImportData([t.importTarget(name)]),使用scripts/ingest.mjs加载, 加载后再建立索引(--index或buildIndex)——在应用开始写入之前 完成数据和索引的准备