{
  "name": "app-development-reference",
  "title": "App Development Reference - Module Rules, Multi-file Functions & Limits",
  "description": "Function runtime reference: ES module rules, multi-file functions, shared _lib modules, kits that ship backend pieces, versioning & rollback, management commands, and rate limits",
  "guid": "sk_plat_aref",
  "category": "App development",
  "requiredTools": [
    "fn_manage",
    "db_manage",
    "db_sql",
    "project_deploy",
    "test_run"
  ],
  "content": "# App Development Reference - Module Rules, Multi-file Functions & Limits\n\nThe long tail of [app-development](app-development.md) - load that skill first for the core workflow (write a function, wire `gipity.yaml`, deploy, test). This reference covers the function runtime's module system in depth, plus operational details you reach for on demand.\n\n## ES module syntax - what you can write\n\nThe runtime compiles your code as a real ES module (since 2026-05-29). Any valid single-file ESM parses cleanly:\n\n```js\n// All of this works in one file:\nimport { z } from './schemas.js';           // relative imports (multi-file only - see below)\nexport const VERSION = 3;                   // top-level const, exported or not\nexport function validate(input) { ... }     // named exports alongside the default\nconst cfg = await loadConfig();             // top-level await\nexport default async function handler(ctx, { db }) { ... }\n```\n\nWhat does NOT work and the exact error you'll see:\n- **Bare imports** (`import _ from 'lodash'`) → *\"Bare import 'lodash' is not allowed in function code. Use a relative path like './helpers.js'.\"* No `node_modules`, no built-in Node modules (`fs`, `path`, `crypto` - for secrets use the injected `randomToken(chars)` service).\n- **No default export** → *\"Function must \\`export default\\` a callable handler.\"*\n- **Default export isn't a function** → *\"Function 'export default' must be a function (got object).\"*\n- **Genuine parse error** → *\"Function file 'index.js' did not parse: <v8 message>\"* - the file name in the message tells you which file the error is in (helpful in multi-file functions).\n\n## Multi-file functions\n\nWhen a function grows past a single file - testable helpers, shared constants, etc. - turn it into a **directory**:\n\n```\nfunctions/\n  song-render/\n    index.js          # entry - must have `export default async function ...`\n    sanitize.js       # helper - `export function sanitizeRenderOptions(...)`\n    constants.js      # `export const DEFAULT_MODE = 'preview'`\n```\n\n`index.js` is the entry; sibling files are reached via relative `import`:\n\n```js\n// functions/song-render/index.js\nimport { sanitizeRenderOptions } from './sanitize.js';\n\nexport default async function songRender(ctx, { db, jobs, guid }) {\n  const opts = sanitizeRenderOptions(ctx.body?.render_options);\n  // ...\n}\n```\n\n**Why use multi-file:**\n- **Testable helpers.** Unit-test directly from `tests/*.test.js` with `import { x } from '../functions/song-render/sanitize.js'`. Single-file helpers can only be exercised through end-to-end function calls.\n- **Cleaner separation.** Pure helpers stay pure.\n\n**Helpers don't see services.** Only the entry module receives `ctx`, `db`, `fetch`, `secrets`, `env`, `console`, `jobs`, `guid`, and the app services (`llm`, `image`, `tts`, `sound`, `music`, `video`, `transcribe`, `location`). Inside a helper, those names are `undefined`. If a helper needs the DB, the entry passes it as an argument:\n\n```js\n// functions/song-render/sanitize.js - PURE helper, no service access\nexport function sanitizeRenderOptions(raw) { /* validates raw, returns clean obj */ }\n\n// functions/song-render/persist.js - needs db, so accept it as arg\nexport async function saveRender(db, songGuid, opts) {\n  await db.query('INSERT INTO renders ...', [songGuid, opts]);\n}\n\n// functions/song-render/index.js - entry passes db down\nimport { sanitizeRenderOptions } from './sanitize.js';\nimport { saveRender } from './persist.js';\nexport default async function (ctx, { db }) {\n  const opts = sanitizeRenderOptions(ctx.body);\n  await saveRender(db, ctx.body.song_guid, opts);\n}\n```\n\n**Import rules and errors:**\n- Imports must be relative - `./helper.js` or `./utils/foo.js` (bare specifiers rejected as above)\n- Imports can't escape the function directory - `../../etc/foo.js` fails with *\"Import '../../etc/foo.js' escapes the function directory.\"* The one sanctioned exception is the shared `../_lib/` prefix (next section).\n- A missing sibling fails clearly: *\"Cannot resolve import './nope.js' → 'nope.js' - file not in function payload. Available: index.js, sanitize.js\"*\n- Circular imports work (a → b → a) - ESM cycles resolve via the module loader's link cache\n- The entry filename is always `index.js`\n\n**Limits:** up to 20 files per function (shared `_lib` files count toward this), 256 KB total source, max 4 levels of nesting.\n\nPick the shape that fits: single `functions/foo.js` for simple handlers; `functions/foo/` directory once you have a helper you'd like to unit-test.\n\n## Shared modules across functions (`functions/_lib/`)\n\nWhen two or more functions need the same code - a schema registry, validation, SQL builders - put it in `functions/_lib/` instead of copy-pasting it into each function directory (copies drift):\n\n```\nfunctions/\n  _lib/\n    registry.js       # shared - `export function describeObject(...)`\n  record-read/\n    index.js          # imports it: `import { describeObject } from '../_lib/registry.js'`\n  record-write.js     # single-file functions can import it the same way\n```\n\nAt deploy, `_lib/` files are bundled into **every** function's payload, so editing a shared module re-deploys all functions automatically. The rules:\n\n- Import as `../_lib/<file>.js` from any function file (works from single-file functions too)\n- `_lib` files may import each other (`./fmt.js`) but can't import function files - shared code stays function-agnostic\n- `_lib` files are pure like helpers: no `db`/`ctx`/services; the entry passes them as arguments\n- `_lib` is reserved: a function named `_lib` or a `_lib/` subdirectory inside a function directory is a deploy error\n- Unit-test shared modules from `tests/*.test.js` via `import { x } from '../functions/_lib/registry.js'`\n\nThe function runtime supports single-file ESM (shipped 2026-05-29), multi-file functions with relative `./helper.js` imports (2026-05-30), and shared `_lib` modules imported as `../_lib/<file>.js` (2026-06-09).\n\n## Kits that ship backend pieces (`gipity add records` …)\n\nSome kits install more than frontend code. A kit whose manifest declares\n`functions: true` / `migrations: true` copies its `functions/` and\n`migrations/` trees into the app's own `functions/` and `migrations/` (the\n`records` and `agent-api` kits are the prototypes - generic registry-driven\nCRUD with an audit event spine, and named API keys for agent writes). Rules:\n\n- **Kit-owned files are sealed.** Functions, `_lib` modules, and migrations a\n  kit installs are upgraded by re-running `gipity add <kit>` at a newer\n  version, which overwrites them. Never edit them in place; put app code in\n  your own function/migration files.\n- Kit migrations order themselves by filename (`000-kit-*.sql` sorts before\n  your `001-*.sql`). If the app has no `sql` deploy phase, the install adds one.\n- The kit's `gipity.yaml` function entries declare only the kit's own core\n  tables. **When you register your own objects/tables, append them to the\n  kit functions' `tables:` lists yourself** - e.g. the records kit's\n  `record-read`/`record-write` need your object tables listed before they can\n  serve them. Your edits survive re-install (entries are merged by name, never\n  clobbered).\n- Kits can require other kits (`requires: [\"records\"]`) - `add` errors with\n  the missing prerequisite's name.\n\nFull guide to the `records` + `views` kits (registry-driven CRUD, field\ntypes, event spine, generated tables/kanban/forms) → the\n[app-records](app-records.md) skill.\n\n## Versioning & Rollback\n\nEvery update creates an immutable version:\n```\nfn_manage rollback --name my-function --version 3\n```\n\n## Management Commands\n```\nfn_manage list                          # List all functions\nfn_manage logs --name X                 # Execution logs\nfn_manage delete --name X               # Delete\nfn_manage rollback --name X --version N # Rollback\n```\n\n## Rate Limits\n- 300 requests per 5-minute window (per IP) — **waived during `gipity test`** (all test runs share one sandbox IP, so the platform exempts a project's own test run; see \"What else is isolated\" above)\n- 500 rows / 128 KB per query result\n\n---\n\n## Related Skills\n\n- `app-development` - the core guide: function shape, `ctx`, `gipity.yaml` permissions, and the deploy/test loop\n- `app-database` - the per-app Postgres database: migrations, the `db` helper, transactions, table permissions\n- `app-testing` - testing deployed functions: `ctx.fn.call` / `callAs`, the isolated test DB, fixtures\n- `app-records` - the records + views kits: registry-driven CRUD, field types, event spine\n- `deploy` - the deploy pipeline and `gipity.yaml` manifest\n"
}
