@oxy-hq/cli 0.2.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -32,7 +32,7 @@ import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprot
32
32
  * this package was published.
33
33
  *
34
34
  * Auth, target resolution and placeholder substitution are the CLI's — the
35
- * same `Context`, so a token cached by `oxyc login` (or `oxy login`) works
35
+ * same `Context`, so a token cached by `oxyc login` works
36
36
  * here with no separate setup, and `{org}` / `{workspace}` resolve the same
37
37
  * way.
38
38
  */
@@ -313,4 +313,4 @@ async function runMcp(ctx) {
313
313
 
314
314
  //#endregion
315
315
  export { runMcp };
316
- //# sourceMappingURL=mcp-VdontkUr.mjs.map
316
+ //# sourceMappingURL=mcp-CIsyezzU.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"mcp-CIsyezzU.mjs","names":[],"sources":["../src/commands/mcp.ts"],"sourcesContent":["/**\n * `oxyc mcp` — the Oxy API as MCP tools, over stdio.\n *\n * NOT THE SAME THING AS `oxy mcp`. That one is workspace tooling: it takes a\n * local checkout and exposes the semantic model, automations and `.sql` files\n * in it. This one takes a TOKEN and exposes the deployment's HTTP API — the\n * same surface `oxyc api` reaches, for an agent that would rather call a tool\n * than shell out. Different input, different audience, no overlap.\n *\n * FOUR TOOLS, NOT SIX HUNDRED — the design decision that makes this affordable.\n *\n * The obvious shape is one tool per endpoint, and it is the wrong one: the API\n * has ~670 routes, an agent runtime ships every tool's JSON schema in every\n * request, and that is tens of kilobytes of context spent on each turn before\n * a single question is asked. It would also go stale on every deploy, since\n * the tool list would be baked into this package rather than read from the\n * deployment.\n *\n * So discovery stays a QUESTION the agent asks (`routes`, `schema`) rather\n * than a payload it carries, exactly as the CLI does it. Four tools cost a few\n * hundred bytes per turn and reach every endpoint, including ones added after\n * this package was published.\n *\n * Auth, target resolution and placeholder substitution are the CLI's — the\n * same `Context`, so a token cached by `oxyc login` works\n * here with no separate setup, and `{org}` / `{workspace}` resolve the same\n * way.\n */\n\nimport { existsSync, readFileSync } from \"node:fs\";\nimport { dirname, resolve } from \"node:path\";\nimport { fileURLToPath } from \"node:url\";\nimport { Server } from \"@modelcontextprotocol/sdk/server/index.js\";\nimport { StdioServerTransport } from \"@modelcontextprotocol/sdk/server/stdio.js\";\nimport { CallToolRequestSchema, ListToolsRequestSchema } from \"@modelcontextprotocol/sdk/types.js\";\nimport { loadCatalog, loadOpenApi, searchRoutes } from \"../api/catalog.js\";\nimport { paramsToQuery, parseFields } from \"../api/fields.js\";\nimport { runJq } from \"../api/output.js\";\nimport { isExternalSurface, normalizePath, substitutePlaceholders } from \"../api/paths.js\";\nimport { parseJson, request } from \"../api/request.js\";\nimport type { Context } from \"../context/resolve.js\";\nimport { CliError, ExitCode } from \"../util/errors.js\";\nimport { comparablePath } from \"./discover.js\";\n\n/**\n * Package version, reported in the MCP handshake.\n *\n * Read from `package.json` rather than restated, so a release bump cannot\n * leave the server announcing a version nobody shipped.\n */\nconst VERSION: string = (() => {\n try {\n const here = dirname(fileURLToPath(import.meta.url));\n for (const candidate of [\n resolve(here, \"..\", \"package.json\"),\n resolve(here, \"..\", \"..\", \"package.json\")\n ]) {\n if (existsSync(candidate)) {\n return (\n (JSON.parse(readFileSync(candidate, \"utf8\")) as { version?: string }).version ?? \"0.0.0\"\n );\n }\n }\n } catch {\n // A packaging shape we did not anticipate. The handshake needs a string,\n // not an accurate one.\n }\n return \"0.0.0\";\n})();\n\n/**\n * A tool result. MCP wants content blocks; everything here is text, because\n * the caller is a language model and the payloads are JSON or markdown.\n */\nfunction text(body: string, isError = false) {\n return { content: [{ type: \"text\" as const, text: body }], isError };\n}\n\nconst TOOLS = [\n {\n name: \"oxy_routes\",\n description:\n \"List the API endpoints this Oxy deployment mounts, with what each one does. \" +\n \"ALWAYS call this before oxy_request rather than guessing a path. \" +\n \"Pass a filter (matched against method, path, surface and description) to narrow it — \" +\n \"unfiltered is ~670 routes. Above 60 matches the descriptions are dropped and only \" +\n \"method, path and credential come back, so narrow enough to stay under that when you \" +\n \"need to know what an endpoint does; past ~400 it is refused outright.\",\n inputSchema: {\n type: \"object\",\n properties: {\n filter: {\n type: \"string\",\n description: \"Substring to narrow by, e.g. 'threads', 'sql', 'admin', 'semantic'.\"\n },\n all: {\n type: \"boolean\",\n description:\n \"Include ide-only and worker-only routes. Off by default: those are mounted on one \" +\n \"instance of the fleet, so a caller hitting the load balancer cannot reach them directly.\"\n }\n }\n }\n },\n {\n name: \"oxy_schema\",\n description:\n \"The request and response schema for ONE endpoint, so a body can be built correctly. \" +\n \"Covers the data plane (SQL, semantic query, and the org/workspace lookups); a blank \" +\n \"result means undocumented, not nonexistent — use oxy_routes to confirm the endpoint exists.\",\n inputSchema: {\n type: \"object\",\n properties: {\n path: { type: \"string\", description: \"Endpoint path, e.g. '{workspace}/sql/query'.\" },\n method: { type: \"string\", description: \"Narrow to one HTTP method.\" }\n },\n required: [\"path\"]\n }\n },\n {\n name: \"oxy_request\",\n description:\n \"Make an authenticated request to the Oxy API. The credential is picked from the path \" +\n \"(/api/** takes a bearer, /external/api/** an API key). Placeholders {org}, {workspace}, \" +\n \"{project}, {customer} and {me} are substituted from context. \" +\n \"Read freely; ask the human before any mutating request against production.\",\n inputSchema: {\n type: \"object\",\n properties: {\n path: { type: \"string\", description: \"Path relative to /api, e.g. '{org}/workspaces'.\" },\n method: {\n type: \"string\",\n description: \"HTTP method. Defaults to GET, or POST with fields.\"\n },\n fields: {\n type: \"object\",\n description:\n \"Request parameters. Sent as a JSON body on POST/PUT/PATCH, or as query parameters \" +\n \"on GET/HEAD/DELETE. Values keep their JSON type.\"\n },\n jq: {\n type: \"string\",\n description:\n \"A jq program to reduce the response before it is returned. Use it — a full list \" +\n \"response is far more context than the two fields you need.\"\n }\n },\n required: [\"path\"]\n }\n },\n {\n name: \"oxy_whoami\",\n description:\n \"Which deployment this is pointed at and who the token belongs to. \" +\n \"Use it when a call returns 401/403, or a 200 with a null body — on this API an expired \" +\n \"session answers 200 null rather than 401, and this tells the two apart.\",\n inputSchema: { type: \"object\", properties: {} }\n }\n];\n\n/**\n * Above this many matches, `oxy_routes` drops the descriptions.\n *\n * DEGRADES, IT DOES NOT REFUSE — the same shape as `oxyc routes`, which picks\n * `renderDetailed` under 40 matches and the compact method+path table above it\n * (`discover.ts`). The expensive field is `description`, not the row: a trimmed\n * row is a few tens of bytes, so several hundred still fit in a result an agent\n * can read, while the descriptions are what turn a broad match into hundreds of\n * KB of context spent before a question is asked.\n *\n * The threshold is on the COUNT, not on whether a `filter` was supplied — a\n * filter is not evidence of narrowing. `searchRoutes` matches a substring of\n * method, path, surface or description, so `\"admin\"`, `\"workspace\"` and\n * `\"query\"` are all filters a model reaches for in good faith that clear this.\n */\nconst MAX_DESCRIBED_ROUTES = 60;\n\n/**\n * And above THIS many, there is nothing useful to return at all.\n *\n * Deliberately far above the threshold that drops descriptions: refusing is the\n * answer only when even the trimmed listing would be a context dump, which for\n * a deployment of ~670 routes means \"you asked for effectively all of them\".\n */\nconst MAX_ROUTES_PER_RESULT = 400;\n\n/** `{ a: 1 }` → the `-f`/`-F` pairs `parseFields` understands. */\nfunction fieldPairs(fields: Record<string, unknown> | undefined): string[] {\n if (!fields) return [];\n return Object.entries(fields).map(([k, v]) => `${k}=${JSON.stringify(v)}`);\n}\n\n/**\n * Check the arguments the schema says are required.\n *\n * `inputSchema.required` is ADVISORY on the low-level `Server` — it is handed\n * to the model, not enforced by the transport. Without this, `oxy_schema` with\n * no `path` matched every path and returned the whole OpenAPI document, and\n * `oxy_request` with none built a request to `/api/`.\n */\nfunction requireArgs(name: string, args: Record<string, unknown>, required: string[]): void {\n const missing = required.filter((k) => {\n const v = args[k];\n return v === undefined || v === null || (typeof v === \"string\" && v.trim() === \"\");\n });\n if (missing.length > 0) {\n throw new CliError(`${name} needs ${missing.join(\", \")}`, { code: ExitCode.USAGE });\n }\n}\n\nasync function callTool(ctx: Context, name: string, args: Record<string, unknown>) {\n switch (name) {\n case \"oxy_routes\": {\n const catalog = await loadCatalog({ target: ctx.target(), bearer: ctx.maybeBearer() });\n const filter = (args.filter as string | undefined)?.trim();\n let matches = searchRoutes(catalog, filter || undefined);\n if (!args.all) matches = matches.filter((r) => r.role === \"fleet-ok\");\n\n // A cache stale enough to be wrong is worth saying INSIDE the result:\n // `loadCatalog` warns on stderr, which an MCP client never shows anyone.\n const staleNote = catalog.stale\n ? \"NOTE: this route table came from a stale local cache; the deployment was unreachable.\\n\\n\"\n : \"\";\n\n const asked = filter ? ` ${JSON.stringify(filter)}` : \"\";\n\n // Only a match set too large even to LIST is refused. Below that the\n // result degrades instead, because a filter like \"admin\" or \"query\" can\n // clear the description threshold in good faith and \"guess something\n // narrower\" is a dead end for a model with no way to see what it hit.\n if (matches.length > MAX_ROUTES_PER_RESULT) {\n return text(\n `${staleNote}${matches.length} routes match${asked}, which is effectively the whole ` +\n `deployment. Call oxy_routes again with a narrower \\`filter\\` (matched against ` +\n `method, path, surface and description), e.g. \"threads\", \"sql\", \"semantic\", \"admin\".`,\n true\n );\n }\n\n if (matches.length === 0) {\n // An empty array reads as \"this deployment has no such endpoint\",\n // which is usually false — the filter was wrong, or the route is\n // ide-only and hidden by default.\n return text(\n `${staleNote}No route matches ${JSON.stringify(filter ?? \"\")}. Try a broader filter, ` +\n `or pass all=true to include ide-only and worker-only mounts.`,\n true\n );\n }\n // Trimmed to the fields that help a caller build a request. The full\n // record carries the handler path and mount notes, which are for a\n // maintainer reading source, not for an agent composing a call.\n const described = matches.length <= MAX_DESCRIBED_ROUTES;\n // `credential` SURVIVES THE TRIM. It is what separates the bearer mount\n // from the `/external/api` API-key one, so a caller composing a request\n // needs it in either form — and `renderCompact`, the CLI shape this\n // mirrors, keeps it as a per-surface heading. It is also not the\n // expensive field: ~25 bytes a row, ~10 KB across the whole ceiling,\n // against descriptions that run to hundreds of KB.\n const trimmed = matches.map((r) =>\n described\n ? { method: r.method, path: r.path, credential: r.credential, description: r.description }\n : { method: r.method, path: r.path, credential: r.credential }\n );\n // The note says the listing is trimmed AND how to get the descriptions\n // back. Without it a model reads a description-less row as an endpoint\n // that has no documentation, rather than one it did not ask narrowly\n // enough to see.\n const trimNote = described\n ? \"\"\n : `NOTE: ${matches.length} routes match${asked} — too many to describe, so each row is ` +\n `method, path and credential WITHOUT its description. Narrow the \\`filter\\` to ` +\n `${MAX_DESCRIBED_ROUTES} or fewer matches to get the descriptions back.\\n\\n`;\n return text(`${staleNote}${trimNote}${JSON.stringify(trimmed)}`);\n }\n\n case \"oxy_schema\": {\n requireArgs(\"oxy_schema\", args, [\"path\"]);\n const doc = (await loadOpenApi({ target: ctx.target(), bearer: ctx.maybeBearer() })) as {\n paths?: Record<string, Record<string, unknown>>;\n components?: unknown;\n };\n const wanted = String(args.path ?? \"\");\n const method = args.method as string | undefined;\n // `comparablePath` is imported from `discover.ts` rather than copied:\n // a second reduction of the same two spellings is a second thing to keep\n // in step, and the copy here was untested. Exact first, then substring —\n // the order `runSchema` uses, so the two commands answer alike.\n const paths = Object.entries(doc.paths ?? {});\n const exact = paths.filter(([p]) => comparablePath(p) === comparablePath(wanted));\n const hit =\n exact.length > 0\n ? exact\n : paths.filter(([p]) => comparablePath(p).includes(comparablePath(wanted)));\n if (hit.length === 0) {\n return text(\n `No documented schema for ${wanted}. The OpenAPI document covers the data plane only — ` +\n `call oxy_routes with a filter to confirm the endpoint exists.`,\n true\n );\n }\n const selected = Object.fromEntries(\n hit.map(([p, ops]) => [\n p,\n method\n ? Object.fromEntries(\n Object.entries(ops).filter(([verb]) => verb.toLowerCase() === method.toLowerCase())\n )\n : ops\n ])\n );\n return text(JSON.stringify({ paths: selected, components: doc.components }, null, 2));\n }\n\n case \"oxy_request\": {\n requireArgs(\"oxy_request\", args, [\"path\"]);\n const raw = String(args.path ?? \"\");\n const method = String(args.method ?? \"\").toUpperCase() || undefined;\n const fields = parseFields([], fieldPairs(args.fields as Record<string, unknown>));\n const verb = method ?? (fields.present ? \"POST\" : \"GET\");\n const carriesBody = ![\"GET\", \"HEAD\", \"DELETE\"].includes(verb);\n\n let path = substitutePlaceholders(normalizePath(raw), ctx.placeholders());\n let body: string | undefined;\n if (fields.present) {\n if (carriesBody) body = JSON.stringify(fields.params);\n else {\n const query = paramsToQuery(fields.params);\n if (query) path += (path.includes(\"?\") ? \"&\" : \"?\") + query;\n }\n }\n\n const apiKey = ctx.apiKey();\n const external = isExternalSurface(path);\n const response = await request({\n target: ctx.target(),\n path,\n method: verb,\n body,\n bearer: external && apiKey ? ctx.maybeBearer() : ctx.bearer(),\n apiKey\n });\n\n if (response.status < 200 || response.status >= 300) {\n // The STATUS and the body, not a thrown error: the model can act on\n // \"403, here is what the server said\" and cannot act on a transport\n // exception. It is marked `isError` so the runtime shows it as one.\n return text(`HTTP ${response.status} ${response.statusText}\\n${response.body}`, true);\n }\n\n if (response.body.trim() === \"null\") {\n return text(\n \"null\\n\\n(NOTE: a 200 with a null body can mean an expired session on this API, \" +\n \"not 'no such thing'. Call oxy_whoami to tell them apart.)\"\n );\n }\n\n const jq = args.jq as string | undefined;\n if (jq) return text(runJq(response.body, jq));\n return text(response.body);\n }\n\n case \"oxy_whoami\": {\n const target = ctx.target();\n const response = await request({\n target,\n path: \"/api/user\",\n method: \"GET\",\n bearer: ctx.bearer()\n });\n const payload = parseJson(response.body);\n if (payload === null || payload === undefined) {\n return text(\n `target: ${target}\\nThe token no longer resolves to a user — the session has expired. ` +\n `Ask the human to run \\`oxyc login\\`.`,\n true\n );\n }\n return text(JSON.stringify({ target, user: payload }, null, 2));\n }\n\n default:\n return text(`unknown tool: ${name}`, true);\n }\n}\n\n/**\n * Serve MCP on stdio until the client disconnects.\n *\n * STDIO IS THE WHOLE TRANSPORT, deliberately. An HTTP/SSE server would need a\n * port, a lifetime and an auth story of its own; stdio is what every agent\n * runtime already launches, and the process inherits the caller's environment\n * — which is where the token and the target come from.\n *\n * NOTHING MAY BE WRITTEN TO STDOUT except protocol frames. `log.info` and\n * friends already go to stderr, which is why this can share the CLI's own\n * plumbing without corrupting the stream.\n */\nexport async function runMcp(ctx: Context): Promise<void> {\n const server = new Server({ name: \"oxyc\", version: VERSION }, { capabilities: { tools: {} } });\n\n server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }));\n\n server.setRequestHandler(CallToolRequestSchema, async (req) => {\n try {\n return await callTool(\n ctx,\n req.params.name,\n (req.params.arguments ?? {}) as Record<string, unknown>\n );\n } catch (cause) {\n // A throw here would kill the session. The model can act on a message;\n // it cannot act on a dead transport.\n const message = (cause as Error).message ?? String(cause);\n // BOTH CHANNELS. A model gets one string, so there is no marker to\n // distinguish them — but reading `hint` alone drops the one line that\n // says what to DO, which is the half a model can act on.\n const { hint, remedy } = cause as { hint?: string; remedy?: string };\n // Blank line between them, the way every other renderer of these two\n // separates them — a model given two consecutive imperatives with no\n // boundary loses the distinction the second field exists to make.\n // `!== undefined` for the narrowing TS gives it, and a length check\n // because an empty string would open the block with a blank line —\n // `filter(Boolean)` covered that and cost the narrowing, so both.\n const extra = [hint, remedy].filter((line) => line !== undefined && line !== \"\").join(\"\\n\\n\");\n return text(extra ? `${message}\\n\\n${extra}` : message, true);\n }\n });\n\n await server.connect(new StdioServerTransport());\n\n // `connect` returns once the transport is wired; the process must stay up\n // until the client closes stdin.\n await new Promise<void>((resolve) => {\n process.stdin.on(\"close\", resolve);\n server.onclose = resolve;\n });\n}\n"],"mappings":"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"}
@@ -2,10 +2,6 @@
2
2
  "$schema": "http://json-schema.org/draft-07/schema#",
3
3
  "title": "Config",
4
4
  "type": "object",
5
- "required": [
6
- "databases",
7
- "models"
8
- ],
9
5
  "properties": {
10
6
  "base_branch": {
11
7
  "description": "Branch that new worktrees fork from when saving on a protected branch. Defaults to the currently checked-out branch (usually the default branch) when not set. Use this when Oxy serves from a \"deployment\" branch but new work should fork from an \"integration\" branch (e.g. serve from `deploy`, fork new work from `main`).\n\nExample config.yml: base_branch: main",
@@ -25,6 +21,7 @@
25
21
  ]
26
22
  },
27
23
  "databases": {
24
+ "default": [],
28
25
  "type": "array",
29
26
  "items": {
30
27
  "$ref": "#/definitions/Database"
@@ -70,6 +67,7 @@
70
67
  ]
71
68
  },
72
69
  "models": {
70
+ "default": [],
73
71
  "type": "array",
74
72
  "items": {
75
73
  "$ref": "#/definitions/Model"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxy-hq/cli",
3
- "version": "0.2.0",
3
+ "version": "0.5.0",
4
4
  "description": "oxyc — the Oxy CLI: a gh-api-style client for the Oxy HTTP API, plus the customer-account tooling that scopes a session to one customer.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -44,20 +44,20 @@
44
44
  ],
45
45
  "license": "MIT",
46
46
  "dependencies": {
47
- "@clack/prompts": "^0.11.0",
47
+ "@clack/prompts": "^1.8.1",
48
48
  "@modelcontextprotocol/sdk": "^1.30.0",
49
49
  "ajv": "^8.20.0",
50
50
  "ajv-formats": "^3.0.1",
51
- "commander": "^14.0.2",
51
+ "commander": "^15.0.0",
52
52
  "picocolors": "^1.1.1",
53
- "yaml": "^2.8.1",
54
- "zod": "^3.25.28 || ^4"
53
+ "yaml": "^2.9.1",
54
+ "zod": "^4.6.5"
55
55
  },
56
56
  "devDependencies": {
57
- "@types/node": "^26.2.0",
58
- "tsdown": "^0.22.14",
57
+ "@types/node": "^26.6.2",
58
+ "tsdown": "^0.23.0",
59
59
  "typescript": "^7.0.2",
60
- "vitest": "^4.1.11"
60
+ "vitest": "^5.0.1"
61
61
  },
62
62
  "engines": {
63
63
  "node": ">=20"
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: oxy-custom-apps
3
- description: Use when building, debugging, reviewing, or shipping an Oxy custom-app bundle (a code-first React/Vite app served by oxy), including writing Oxy Functions and driving them from the UI. Encodes the contract rules, pitfalls, performance guardrails, and the self-serve `oxy publish` ship flow.
3
+ description: Use when building, debugging, reviewing, or shipping an Oxy custom-app bundle (a code-first React/Vite app served by oxy), including writing Oxy Functions and driving them from the UI. Encodes the contract rules, pitfalls, performance guardrails, and the self-serve `oxyc publish` ship flow.
4
4
  ---
5
5
 
6
6
  # Customer-app development
@@ -109,9 +109,15 @@ qualified.)
109
109
  ```
110
110
 
111
111
  `ctx` bridges to the data plane: `ctx.query` / `ctx.queryStream`,
112
- `ctx.semantic.query`, `ctx.warehouse.{insert,exec,upsert}`,
113
- `ctx.airway.run`, `ctx.fetch` (SSRF-allowlisted), `ctx.env`,
114
- `ctx.user`, `ctx.log`. `oxy publish` bundles `functions/*.ts` with
112
+ `ctx.semantic.query`, `ctx.warehouse.query`,
113
+ `ctx.warehouse.{insert,exec,upsert}` (to Airhouse, or to a customer
114
+ warehouse the function names in `customerWarehouseWrites`), `ctx.airway.run`,
115
+ `ctx.fetch` (SSRF-allowlisted), `ctx.env`, `ctx.user`, `ctx.log`.
116
+ What the app STORES picks its surface by shape — facts through
117
+ `ctx.airhouse`, records through `ctx.oltp`, files through
118
+ `ctx.storage` — and customer warehouses are read-only. See
119
+ **Where data goes** below before writing anything.
120
+ `oxyc publish` bundles `functions/*.ts` with
115
121
  esbuild — no separate backend to stand up. Worked example +
116
122
  author-facing `ctx` types: `oxy-hq/customer-apps:
117
123
  examples/hello-oxy/functions/`.
@@ -119,11 +125,70 @@ qualified.)
119
125
  3. **`OXY_APP_BASE_PATH` must match the path the bundle is linked
120
126
  under.** `@oxy-hq/vite-plugin` resolves it from (in order):
121
127
  `OXY_APP_BASE_PATH` env → `orgSlug + slug` in `oxy-app.json` →
122
- `/`. `oxy publish` sets that env to `/customer-apps/<org>/<slug>/`
128
+ `/`. `oxyc publish` sets that env to `/customer-apps/<org>/<slug>/`
123
129
  for the build automatically, and the vite-plugin derives the same
124
130
  from the manifest for `pnpm dev` / `pnpm build`. Mismatch → asset
125
131
  404s → blank dashboard.
126
132
 
133
+ ## Where data goes (the shape of the data picks the store)
134
+
135
+ | The data | Store | In a function | In `oxy-app.json` |
136
+ | --- | --- | --- | --- |
137
+ | **Facts** — something happened and won't change: an order, a completed checklist, a delivery, a reading | The workspace's Airhouse, in your schema `app_<writer>` | `ctx.airhouse.append(table, rows)` · `ctx.airhouse.query(sql)` · `ctx.airhouse.exec(sql)` | function: `"airhouse": { "enabled": true }` · app: `"airhouseMigrations": { "dir": "airhouse-migrations" }` |
138
+ | **Records** your app edits — bookings, assignments, templates, counts | The org's OLTP Postgres, in your schema `app_<writer>` | `ctx.oltp.query/exec(sql, params)` · `ctx.oltp.tx(async (tx) => …)` | function: `"oltp": { "enabled": true }` · app: `"migrations": { "dir": "migrations" }` |
139
+ | **Files** — photos, PDFs, exports | Your app's storage silo | `ctx.storage` | function: `"storage": { "read": true, "write": true }` |
140
+ | **The customer's warehouse** — ClickHouse, Snowflake, BigQuery, their Postgres | Theirs | `ctx.query` · `ctx.warehouse.query(db, sql)` | read-only: a write is refused unless the function declares `"customerWarehouseWrites": { "<db>": "<why>" }` |
141
+
142
+ **Availability.** `ctx.airhouse`, `ctx.oltp.tx`, `airhouseMigrations`, the
143
+ read-only rule for customer warehouses and `oxyc validate`'s data-placement
144
+ and manifest checks arrive with the Oxy release carrying oxygen-internal#3187,
145
+ together with the `@oxy-hq/sdk` and `@oxy-hq/cli` published from it. Against
146
+ an older Oxy, `ctx.airhouse` is `undefined` and the new manifest fields are
147
+ ignored; vendored `oxy.d.ts` copies (`examples/hello-oxy/functions/oxy.d.ts`)
148
+ gain the types when they are re-synced from that SDK.
149
+
150
+ Three questions, in order: **Is it bytes?** → storage (keep the key in a record
151
+ or fact). **Does it change after it's written?** No → a fact; yes → a record;
152
+ both when you need history (the record says what is true now, the fact says
153
+ what changed). **Is it already in the customer's warehouse?** → read it there.
154
+
155
+ `<writer>` is your slug with `-` → `_` (`store-ops` → `app_store_ops`); a slug
156
+ with `_` can't back a schema. Both stores are written **as the app**, whoever
157
+ invoked the function, so a scheduled run writes like a click.
158
+
159
+ **Facts, in practice.** Airhouse is DuckLake: no `PRIMARY KEY`, `UNIQUE`,
160
+ indexes or foreign keys — publish refuses a migration carrying one. So:
161
+
162
+ - Give every fact the id its source assigned and a `recorded_at`. A retried
163
+ function appends twice; readers keep one row per id:
164
+ `QUALIFY row_number() OVER (PARTITION BY completion_id ORDER BY recorded_at DESC) = 1`.
165
+ - A correction is a new fact (`voided`, `amended`), not an `UPDATE`.
166
+ - Name tables `app_<writer>.<table>` in migrations and in `exec`/`query`
167
+ (`ctx.airhouse.schema` has the name). `append` takes the bare table name.
168
+ - One statement per call, nothing bound: request data goes through `append`,
169
+ never into `exec` SQL. `exec` runs no DDL — tables come from migrations.
170
+
171
+ **Records, in practice.** Bound parameters (`$1`, `$2`) always. Several
172
+ statements that must land together go in one `ctx.oltp.tx`. The store is
173
+ provisioned per org by an operator; until then calls fail naming that step.
174
+
175
+ **Not a store:** `ctx.secrets` holds credentials you rotate. A cursor, a
176
+ counter or a JSON blob is a record. `oxyc validate` warns about it, about
177
+ keys in Airhouse migrations, and lists every customer-warehouse write exception.
178
+
179
+ ```ts
180
+ // A checklist is finished: the record changes, and a fact is appended.
181
+ async function completeChecklist(ctx, runId: string, storeGuid: string) {
182
+ await ctx.oltp.tx(async (tx) => {
183
+ await tx.exec("UPDATE checklist_runs SET status = 'done', completed_at = now() WHERE id = $1", [runId]);
184
+ await tx.exec("DELETE FROM open_reminders WHERE run_id = $1", [runId]);
185
+ });
186
+ await ctx.airhouse.append("checklist_completions", [
187
+ { completion_id: runId, store_guid: storeGuid, completed_by: ctx.user.id, recorded_at: new Date().toISOString() },
188
+ ]);
189
+ }
190
+ ```
191
+
127
192
  ## Four pitfalls
128
193
 
129
194
  - **CORS errors are usually wrong-URL errors in disguise.** The SDK's
@@ -270,16 +335,17 @@ asset base baked in.
270
335
 
271
336
  ## Publishing
272
337
 
273
- Ship with `oxy publish` — **no CI, no S3 sync, no folder picker**. From
338
+ Ship with `oxyc publish` — **no CI, no S3 sync, no folder picker**. From
274
339
  the app directory (`oxy-hq/customer-apps: examples/hello-oxy/` is the worked
275
340
  shell; note its manifest deliberately carries no `orgSlug`, leaving the org to
276
341
  be resolved at publish time):
277
342
 
278
343
  ```bash
279
- oxy login --env production # once; browser flow, caches a token,
280
- # and prints whether you're an app-admin
281
- oxy publish --env production # build → tar → upload (draft channel)
282
- oxy publish --env production --promote # …straight to live
344
+ oxyc login --env production # once; browser flow, caches a token,
345
+ # and prints whether you're an app-admin
346
+ oxyc validate # data-placement and manifest checks first
347
+ oxyc publish --env production # build → tar → upload (draft channel)
348
+ oxyc publish --env production --promote # …straight to live
283
349
  ```
284
350
 
285
351
  - **Identity** is the manifest's `slug` + `orgSlug` (override with
@@ -301,7 +367,7 @@ Full hands-on guide: `oxy-hq/customer-apps: docs/local-development.md`.
301
367
  ## Reading data while vibe-coding
302
368
 
303
369
  You don't need an `X-API-Key` to poke at the data API by hand — your
304
- `oxy login` token authenticates as a bearer. From an app directory:
370
+ `oxyc login` token authenticates as a bearer. From an app directory:
305
371
 
306
372
  ```bash
307
373
  oxyc login --env local # once; caches a token per host
@@ -319,7 +385,7 @@ curl -H "Authorization: Bearer $(oxyc token --env local)" \
319
385
  `oxyc api <path>` takes the path relative to `/api/`; `-X` sets the method
320
386
  (POST when a body is given), `-f`/`-F` set the body (string / JSON-typed),
321
387
  `--input` sends a raw one, `-i` includes response headers. `--env` resolves the
322
- same targets as `oxy publish` (`local` → the Vite dev server `:5173`, which
388
+ same targets as `oxyc publish` (`local` → the Vite dev server `:5173`, which
323
389
  proxies `/api` → oxy `:3000`).
324
390
 
325
391
  Do not guess a path: `oxyc routes <filter>` lists what exists and
@@ -3,8 +3,8 @@
3
3
  #
4
4
  # OFF by default, and the default is the whole point. `customer-apps` — the
5
5
  # monorepo Oxy ships its own custom apps from — deleted its publish CI outright
6
- # and its engineers publish from their machines with `oxy login` and
7
- # `oxy publish`; its own docs say "there is no GitHub Actions / CI". A repo
6
+ # and its engineers publish from their machines with `oxyc login` and
7
+ # `oxyc publish`; its own docs say "there is no GitHub Actions / CI". A repo
8
8
  # scaffolded from this template therefore behaves the same way unless somebody
9
9
  # asks for CI publishing, rather than arriving demanding a long-lived token the
10
10
  # rest of the org has already decided it does not want.
@@ -64,7 +64,7 @@ case "$value" in
64
64
  # misreading of a switch that costs something.
65
65
  ''|false|no|off|0)
66
66
  printf 'off\n'
67
- printf '::notice::CI publishing is off in this repo, which is the default. This run built the app bundles and published nothing. Publishing here is self-serve: from an app directory under apps/, run "oxy login --env dev" once and then "oxy publish --env dev" (or --env production). To publish from CI instead, add a repository VARIABLE named OXY_CI_PUBLISH with the value true — Settings, then Secrets and variables, then Actions, then the Variables tab — and give each GitHub environment its own OXY_TOKEN.\n' >&2
67
+ printf '::notice::CI publishing is off in this repo, which is the default. This run built the app bundles and published nothing. Publishing here is self-serve: from an app directory under apps/, run "oxyc login --env dev" once and then "oxyc publish --env dev" (or --env production). To publish from CI instead, add a repository VARIABLE named OXY_CI_PUBLISH with the value true — Settings, then Secrets and variables, then Actions, then the Variables tab — and give each GitHub environment its own OXY_TOKEN.\n' >&2
68
68
  ;;
69
69
  true|yes|on|1)
70
70
  printf 'on\n'
@@ -101,20 +101,16 @@ while IFS= read -r manifest; do
101
101
  app_dir="${manifest%/oxy-app.json}"
102
102
 
103
103
  if ! jq -e 'type == "object"' "$root/$manifest" >/dev/null 2>&1; then
104
- printf '::error file=%s::%s is not a JSON object. oxy publish reads this file for the app identity and will not get past it.\n' \
104
+ printf '::error file=%s::%s is not a JSON object. oxyc publish reads this file for the app identity and will not get past it.\n' \
105
105
  "$manifest" "$manifest" >&2
106
106
  bad=1
107
107
  continue
108
108
  fi
109
109
 
110
- # `oxy publish` resolves the app slug as `--app`, then OXY_APP, then the
111
- # manifest, then the `<app>` segment of an `apps/<org>/<app>/` path — that
112
- # is the IMPLEMENTATION's order (publish.rs:779-785). Note that
113
- # `oxy publish --help` states a different one, manifest before OXY_APP; the
114
- # code is what runs, and its own comment agrees with the code. It makes no
115
- # difference here either way: this workflow sets neither the flag nor the
116
- # env var, and both readings end `manifest, then path` — the only two links
117
- # this check depends on.
110
+ # `oxyc publish` resolves the app slug as `--app`, then OXY_APP, then the
111
+ # manifest, then the `<app>` segment of an `apps/<org>/<app>/` path — its
112
+ # `--help` says the same. This workflow sets neither the flag nor the env
113
+ # var, so `manifest, then path` are the only two links this check depends on.
118
114
  slug="$(jq -r '.slug // ""' "$root/$manifest")"
119
115
  if [ -z "$slug" ] || [ "$slug" = "null" ]; then
120
116
  printf '::error file=%s::%s has no "slug". That is the app'"'"'s identity on the platform and there is nowhere else to get it.\n' \
@@ -123,11 +119,11 @@ while IFS= read -r manifest; do
123
119
  continue
124
120
  fi
125
121
 
126
- # Same resolution chain for the org (publish.rs:773-778), with one
122
+ # Same resolution chain for the org, with one
127
123
  # difference that matters: the path fallback only fires for the nested
128
124
  # `apps/<org>/<app>/` layout. In the flat `apps/<app>/` layout there is no
129
125
  # `<org>` segment, so a manifest with no `orgSlug` leaves the org
130
- # unresolvable — and `oxy publish` needs it to look the project up on the
126
+ # unresolvable — and `oxyc publish` needs it to look the project up on the
131
127
  # build-config endpoint.
132
128
  org="$(jq -r '.orgSlug // ""' "$root/$manifest")"
133
129
  nested=0
@@ -135,13 +131,13 @@ while IFS= read -r manifest; do
135
131
  apps/*/*) nested=1 ;;
136
132
  esac
137
133
  if { [ -z "$org" ] || [ "$org" = "null" ]; } && [ "$nested" -eq 0 ]; then
138
- printf '::error file=%s::%s has no "orgSlug", and %s is not an apps/<org>/<app>/ path for oxy publish to infer one from. Add "orgSlug" to the manifest.\n' \
134
+ printf '::error file=%s::%s has no "orgSlug", and %s is not an apps/<org>/<app>/ path for oxyc publish to infer one from. Add "orgSlug" to the manifest.\n' \
139
135
  "$manifest" "$manifest" "$app_dir" >&2
140
136
  bad=1
141
137
  continue
142
138
  fi
143
139
 
144
- # `build.outDir`, defaulting to `out` — the same default `oxy publish`
140
+ # `build.outDir`, defaulting to `out` — the same default `oxyc publish`
145
141
  # applies. Guarded with a type check because a manifest whose `build` is a
146
142
  # string (or a number) would make plain `.build.outDir` a jq error, and a jq
147
143
  # error under `set -e` would abort the whole scan on one malformed file
@@ -8,7 +8,7 @@
8
8
  # unexecuted.
9
9
  #
10
10
  # It also has exactly one caller and one answer ON PURPOSE. The name printed
11
- # here is used TWICE by publish.yaml — as `oxy publish --env <name>` and as the
11
+ # here is used TWICE by publish.yaml — as `oxyc publish --env <name>` and as the
12
12
  # GitHub Actions `environment:` whose secrets that publish authenticates with —
13
13
  # and those two must never disagree. Two ternaries in the YAML would have been
14
14
  # shorter and would have drifted the first time one of them was edited; one
@@ -21,7 +21,7 @@
21
21
  # exit 0 only for a trigger this repo has a mapping for.
22
22
  # exit 1 for anything else, INCLUDING an empty trigger.
23
23
  #
24
- # The refusal is the point. `oxy publish` defaults `--env` to **production**,
24
+ # The refusal is the point. `oxyc publish` defaults `--env` to **production**,
25
25
  # so a workflow that omits the flag ships a customer's live environment while
26
26
  # looking like it did nothing in particular — which is exactly what this repo's
27
27
  # CI did before this file existed. Every publish now names its environment out
@@ -48,7 +48,7 @@ case "$trigger" in
48
48
  exit 1
49
49
  ;;
50
50
  *)
51
- printf '::error::this repo publishes on "push" and "workflow_dispatch" only, and has no oxy environment mapped for "%s". Add one to .github/scripts/publish-env.sh — deliberately, because the alternative is a trigger nobody reviewed publishing to production, which is what "oxy publish" does when nothing names an environment.\n' \
51
+ printf '::error::this repo publishes on "push" and "workflow_dispatch" only, and has no oxy environment mapped for "%s". Add one to .github/scripts/publish-env.sh — deliberately, because the alternative is a trigger nobody reviewed publishing to production, which is what "oxyc publish" does when nothing names an environment.\n' \
52
52
  "$trigger" >&2
53
53
  exit 1
54
54
  ;;