{
  "schema": "leumas.docs.page/1",
  "id": "skill:leumas-lmx#lmq",
  "slug": "skills/leumas-lmx/lmq",
  "kind": "tools",
  "bucket": "skill",
  "title": "lmq — the collection query language",
  "name": "lmq",
  "eyebrow": "the collection query language",
  "chip": null,
  "summary": "Read stored data from a script. Safe mode, read-only by construction, and it needs the host's db bridge — which means POST /api/lmx/run or Studio's LMX Playground, not the CLI.",
  "keywords": [
    "leumas-lmx",
    "isn sql",
    "construction",
    "bounds query",
    "playground",
    "otherwise bite",
    "read-only",
    "script"
  ],
  "audience": "both",
  "funnel": {
    "product": null,
    "cta": null
  },
  "body": "# `lmq` — the collection query language\n\nRead stored data from a script. **Safe** mode, **read-only by construction**, and it needs the host's\n`db` bridge — which means `POST /api/lmx/run` or Studio's LMX Playground, not the CLI.\n\n```lmq\nFROM orders\n  WHERE status IN (\"paid\", \"shipped\") AND createdAt > days_ago(30)\n  SELECT id, total, buyer.city\n  ORDER BY total DESC\n  LIMIT 20\n```\n\n---\n\n## Why it isn't SQL\n\nThree reasons, and none of them is taste.\n\n1. **Both storage backends are collection-oriented.** `LibsqlAdapter` and `JsonFileAdapter` behind the\n   dynamic connector both expose `read(collection, query)`. A dialect written for one would not run on\n   the other.\n2. **Every gate in the tree is keyed by collection.** `createDynamicCrudRouter` takes\n   `protectedCollections`, `adminOnlyCollections` and `staffOnlyCollections`. `lmq` reuses exactly\n   those lists. A SQL surface would be gated by table, and the two would drift.\n3. **There is no raw-SQL path against `leumas.db` today.** Adding one for a scripting language would\n   have made the *first* one a side door around all of the above.\n\nSo `lmq` reads the same rows `/db` reads, through the same adapter, under the same rules.\n\n---\n\n## Statements\n\nA script is a sequence of statements. **A statement starts at a line beginning with `FROM` or\n`COLLECTIONS`** and runs until the next one, so clauses may be indented on their own lines or written\ninline — both parse identically. `#` starts a comment, except inside a string.\n\nEach statement prints its result. **The script's return value is the last statement's rows**, the way\nevery query console behaves.\n\n### `COLLECTIONS`\n\n```lmq\nCOLLECTIONS\n```\n\nLists what you may query. Denied collections are not listed — a script cannot even learn they exist.\n\n### `FROM … `\n\n| Clause | Form | Notes |\n|---|---|---|\n| `FROM` | `FROM <collection>` | starts the statement |\n| `WHERE` | expression | see below |\n| `SELECT` | `SELECT a, b, nested.c` | omitted → the whole row |\n| `ORDER BY` | `ORDER BY <path> [ASC\\|DESC]` | `ASC` is the default |\n| `LIMIT` / `OFFSET` | integers | `LIMIT` is clamped to **1000** |\n| `COUNT` | `COUNT` or `COUNT BY <path>` | |\n| `SUM` `AVG` `MIN` `MAX` | `<agg> <path> [BY <path>]` | non-numeric values are skipped |\n\nA statement is either a `SELECT` **or** an aggregate, never both.\n\n---\n\n## `WHERE`\n\n```lmq\nFROM orders\n  WHERE (status = \"paid\" OR status = \"shipped\")\n    AND total >= 100\n    AND NOT plan = \"free\"\n    AND buyer.city LIKE \"lis%\"\n    AND email EXISTS\n```\n\n`AND` binds tighter than `OR`. `NOT` prefixes, parentheses group.\n\n### Operators\n\n| Operator | Meaning |\n|---|---|\n| `=` `!=` `<>` `>` `>=` `<` `<=` | comparison |\n| `IN (a, b)` / `NOT IN (a, b)` | membership |\n| `LIKE \"a%b\"` | case-insensitive match; **`%` is the only wildcard**, everything else is literal |\n| `MATCHES \"^re\"` | a real regular expression |\n| `<path> EXISTS` | the field is present (postfix) |\n\n### Values and functions\n\nStrings are quoted. Numbers, `true`, `false` and `null` are bare. Anything else bare is an error —\n`status = paid` will not silently become the string `\"paid\"`, it will tell you to quote it.\n\n| Function | Returns |\n|---|---|\n| `now()` | epoch ms, at **run** time |\n| `days_ago(n)` `hours_ago(n)` `minutes_ago(n)` | epoch ms, n units ago |\n| `lower(x)` `upper(x)` | case-folded |\n\n### Field paths\n\nDotted: `buyer.city`, `meta.source.campaign`. Dynamic-collection rows hydrate their JSON `data` column\ninto the row, so nested access is just property access. A missing field never matches a comparison and\nis not an error — a schema-less collection legitimately has rows that lack a key.\n\n---\n\n## Two things that would otherwise bite\n\n### Timestamps are epoch milliseconds\n\nThe dynamic connector writes `createdAt`/`updatedAt` as `Date.now()` — **numbers, not ISO strings**.\n`days_ago(30)` returns epoch ms to match. A hand-written document field is usually an ISO string, so\ncomparison bridges the mixed case and compares them as *time*; without that, `\"2024-01-01\"` against\n`1700000000000` would order by ASCII and quietly give the wrong answer.\n\n### Pushdown is an optimisation, never a filter\n\n`lmq` hands the storage adapter the `=` and `IN` terms from the top-level `AND` spine so it can fetch\nfewer rows. Everything else — `>`, `LIKE`, `MATCHES`, anything under an `OR`, anything with a dotted\npath or a function call — is evaluated in the interpreter. **The full expression is re-evaluated\neither way**, so pushdown can only ever cost rows, never invent them.\n\n---\n\n## What bounds a query\n\n| Gate | Effect |\n|---|---|\n| **Deny list** | The union of `/db`'s protected, admin-only, staff-only and platform collections, plus `provider_keys`. Refused by `FROM`, hidden from `COLLECTIONS`. |\n| **Per-row `canRead`** | `isAdmin \\|\\| canRead(row, uid)` — the identical filter at `connector/router.js:234`. You see your rows and public rows. |\n| **Row cap** | 5000 readable rows per statement. Over it, the statement **fails**: a silently short answer is worse than none, because nothing about it says it was cut. |\n| **`LIMIT` clamp** | 1…1000, mirroring `clampLimit` in `@leumas/connectors`. A larger `LIMIT` is clamped with a printed note, not an error. |\n\n---\n\n## What it deliberately cannot do\n\n- **Write anything.** The bridge object has no `create`, `update` or `delete` method. This is not a\n  check that could be bypassed; there is nothing to call.\n- **Join.** One collection per statement. Run two statements.\n- **Raw SQL.** See above.\n- **Run from the CLI.** No bridge there, so it refuses with a message naming `/api/lmx/run` rather\n  than returning an empty result that reads like \"nothing matched\".\n\n---\n\n## Running it\n\n```bash\ncurl -s -X POST localhost:3000/api/lmx/run \\\n  -H 'content-type: application/json' \\\n  -d '{\"mode\":\"lmq\",\"source\":\"COLLECTIONS\"}'\n```\n\nBundled examples: `shared/engines/lmx/src/scripts/demo.lmq`, `shared/engines/lmx/src/scripts/report-users.lmq`.\nTests: `shared/engines/lmx/src/test/lmq.test.js`. Guard: `pnpm smoke:lmx`.\n",
  "source": {
    "path": ".claude/skills/leumas-lmx/reference/lmq.md",
    "blobSha": "",
    "commit": "",
    "committedAt": "",
    "provenance": "no-git",
    "bytes": 6196,
    "hash": "2ea1b46d7cc20cbeb47033ba128d7c3bfd0c2df8"
  },
  "urls": {
    "html": "/p/skills/leumas-lmx/lmq",
    "json": "/docs/skills/leumas-lmx/lmq.json",
    "md": "/docs/skills/leumas-lmx/lmq.md"
  },
  "links": {
    "composes": [],
    "usedBy": [],
    "product": [],
    "howTo": [],
    "skills": []
  }
}
