{"protocol":"clank-doc/1","frameworkVersion":"0.24.0","slug":"authorized-aggregates","title":"Authorized aggregates","description":"db.table(name).query().aggregate(options) computes typed counts, sums and scalar groups over declared application tables. It runs synchronously in the current SQLite read snapshot or write transaction. Generated backend queries supply the c","group":{"id":"full-stack","title":"Full stack"},"url":"https://docs.clank.run/docs/authorized-aggregates","source":"docs/authorized-aggregates.md","headings":["References, values and results","Admission and failure","Live updates and lifecycle"],"tableOfContents":[{"id":"references-values-and-results","title":"References, values and results","level":2},{"id":"admission-and-failure","title":"Admission and failure","level":2},{"id":"live-updates-and-lifecycle","title":"Live updates and lifecycle","level":2}],"markdown":"# Authorized aggregates\n\n`db.table(name).query().aggregate(options)` computes typed counts, sums and scalar groups over\ndeclared application tables. It runs synchronously in the current SQLite read snapshot or write\ntransaction. Generated backend queries supply the caller's ownership scope automatically.\nExplicit `openSQLite().read()` scopes remain a trusted server API: `undefined` means unscoped\nserver access and `null` means anonymous access, just as for ordinary reads.\n\nEvery source needs an explicit authorization policy, including `root`. Owned rows are selected\nwith the current owner before policies run. A denied root never follows its references. A related\nrecord must pass its own owner scope and policy before it can affect any measure or group.\n\n```ts\nimport { defineDatabase, defineTable, openSQLite, s } from \"@clank.run/framework\";\n\nconst schema = defineDatabase({\n  accounts: defineTable({ category: s.string(), enabled: s.boolean() }).owned(),\n  orders: defineTable({ account: s.id(\"accounts\"), amount: s.number(), paid: s.boolean() }).owned(),\n});\nconst database = await openSQLite(schema);\nconst scope = { userId: \"example-account\" }; // Supplied by trusted server code.\ntry {\n  database.transaction(db => {\n    const account = db.table(\"accounts\").insert({ category: \"support\", enabled: true });\n    db.table(\"orders\").insert({ account, amount: 12.5, paid: true });\n    db.table(\"orders\").insert({ account, amount: 7.5, paid: true });\n  }, scope);\n  const totals = database.read(db => db.table(\"orders\").query().where(\"paid\", true).aggregate({\n    joins: { account: { table: \"accounts\", via: \"account\" } },\n    groupBy: { source: \"account\", field: \"category\" },\n    measures: {\n      orders: { count: true },\n      revenue: { sum: { source: \"root\", field: \"amount\" } },\n    },\n    authorize: { root: order => order.paid, account: account => account.enabled },\n  }), scope);\n  console.log(totals.groups); // [{ group: \"support\", values: { orders: 2, revenue: 20 } }]\n} finally {\n  database.close();\n}\n```\n\n## References, values and results\n\n`joins` maps up to four aliases to direct root fields declared with `s.id(targetTable)`. Optional,\nnullable, defaulted and refined references are supported when every non-null alternative has the\nsame target. An alias cannot be `root`. These are inner references: missing, null, other-owner or\npolicy-denied related records exclude the root from **all** measures. Related records are loaded\nonce per distinct table/ID; authorization is cached separately for each alias. Two aliases can\ntherefore apply different policies to the same record.\n\nEach accepted root contributes once to `count`, even when many roots refer to one account.\nA sum of a related field contributes that field once **per accepted root**, rather than summing\nunique accounts. Counts accept `{ count: true }`; sums accept `{ sum: { source, field } }` for a\ndeclared numeric field. Null or absent numeric values contribute zero. There are 1–16 named\nmeasures; names and aliases are ASCII identifiers of at most 64 characters.\n\n`groupBy` accepts declared string, finite number, boolean or null fields. Optional values group\nunder null; negative zero groups with zero. Arrays, objects, unknown schemas and document\nmetadata fields cannot be projected. Numeric `1`, string `\"1\"` and boolean values stay distinct.\nSource rows use stable creation-time/ID order; `orderBy()` does not change aggregate evaluation.\nGroups use a deterministic lexical order of their JSON-encoded type/value keys. The order is\nstable across restart and is not a locale or numeric sort.\n\nThe immutable result is `{ protocol: \"clank-aggregate/1\", groups: [{ group, values }] }`. Types\npreserve each source's fields, reference target, group value and exact measure names. An empty\nungrouped selection produces one null group with zero measures; an empty grouped selection\nproduces `groups: []`. `limit()` is rejected, so a partial selection cannot appear as a complete\ntotal. Queries can use up to 32 ordinary `where()` filters, with the existing comparison semantics.\n\n## Admission and failure\n\n| Limit | Default | Maximum |\n| --- | --- | --- |\n| `maxRows` | 1,000 candidate source rows | 10,000 |\n| `maxRelated` | 1,000 distinct table/ID lookups, including misses | 10,000 |\n| `maxBytes` | 2 MiB of stored UTF-8 JSON | 8 MiB |\n| `maxGroups` | 100 output groups | 1,000 |\n| `maxOutputBytes` | 64 KiB of serialized UTF-8 output | 256 KiB |\n\nSupply smaller positive integer limits through `limits`. Native SQLite metadata admits at most\n`maxRows + 1` candidates and measures their stored UTF-8 JSON lengths **before** source JSON is\nmaterialized. Related JSON lengths are checked in the same owner scope before each first read;\ntheir bytes are counted once per distinct record. The combined byte limit includes source\ncandidates denied by policy. Other owners' rows never enter either admission or projection.\n\nCapacity failures throw `RangeError` and return no partial totals. Invalid declarations, non-boolean\npolicies or asynchronous policies throw `TypeError`; unexpected policy failures propagate. Sums\nuse JavaScript's IEEE 754 arithmetic, including ordinary decimal rounding. Non-finite sums and\nan unsafe integer intermediate when both operands are integers throw `RangeError`. Use validated\ninteger minor units within the safe integer range when exact integer accounting is required.\n\nThe limits bound materialized candidates, related lookups, stored JSON and output. They do not\nbound SQLite's internal filter/index scan time, schema parsing/default work, or arbitrary trusted\npolicy computation and additional reads. A policy-denied candidate can affect admission failure\nwithin the caller's owner scope; it cannot produce an inaccessible value, count or group in a\nsuccessful result. Do not convert capacity failures into apparently complete zero totals.\n\n## Live updates and lifecycle\n\nPolicy callbacks receive the current scoped `ReadDatabase`. Use it to read ACL records so those\ndependencies are tracked alongside precise related table/ID dependencies. Changes to a referenced\nparent or ACL re-run the live query. Changes to another owner's roots, another table or an unrelated\nparent ID stay quiet. The root dependency remains table-wide within the owner's scope, matching\nordinary queries; filters do not narrow invalidation. Session revocation still uses the backend's\ncurrent authentication checks and closes protected live streams.\n\nPolicies are trusted application code, must return a synchronous boolean and must not produce\nside effects. Top-level document fields are frozen before policy calls. The complete plan and\ncallback references are captured before any policy executes. A builder or reader retained from a\nfinished transaction cannot aggregate during a later transaction. Recreate it inside the current\nhandler. No aggregation state is persisted, no schema migration is required, and disabling a query's\naggregate call changes neither stored documents nor the live protocol.\n"}