{
  "name": "app-records",
  "title": "Records & Views kits (structured data + generated UI)",
  "description": "Structured records for apps - declare objects/fields as data and get generic CRUD with validation, full-text search, soft delete, and an audit event spine (records kit), plus registry-driven tables, kanban boards, and forms (views kit). For CRM/admin/tracker/directory-style apps.",
  "guid": "sk_kit_recs",
  "category": "Kits",
  "requiredTools": [
    "add",
    "file_write",
    "project_deploy"
  ],
  "content": "# Records & Views kits (structured data + generated UI)\n\nBuild a records-backed app (CRM, asset tracker, admin panel, directory) without writing per-object CRUD. You declare objects and fields **as data** (rows in a registry, inserted by your migration); the **`records` kit** serves them through two generic functions with validation, full-text search, soft delete, optimistic concurrency, and an **event spine** - every mutation writes the row and an audit event in one transaction. The **`views` kit** renders tables, kanban boards, and create/edit forms straight from that registry - zero per-object UI code.\n\n```bash\ngipity add records     # backend: functions + migrations + client API (needs a database template)\ngipity add views       # frontend: table / kanban / form renderers (requires records)\ngipity deploy dev\n```\n\nNeeds `web-fullstack` or `api`. The `agent-api` kit adds named API keys so scripts/agents can write through the same path with AGENT/API attribution.\n\n## ⚠️ The read path is PUBLIC by default\n\n`record-read` ships at `auth: \"public\"`; `record-write` is `auth: \"user\"`. So by default **anyone with the app's id can read every row of every object** - schema, PII composite fields, and the audit log with actor names - with no sign-in. Fine for a public directory or demo; wrong for anything private. Before shipping private data, change `record-read`'s `auth` to `\"user\"` or `\"member\"` in `gipity.yaml`.\n\n## What gets installed\n\n- `functions/record-read/`, `functions/record-write/`, `functions/_lib/records/` - **sealed kit code**: never edit; re-running `gipity add records` at a newer version overwrites them (that's the upgrade path). Put app logic in your own functions.\n- `migrations/000-kit-records-core.sql` - registry (`kit_objects`, `kit_fields`), members (`kit_members`), event spine (`kit_events`).\n- `src/packages/records/api.js` - the client wrapper.\n\n## Declare an object (you own these files)\n\n**1.** In your own migration (`migrations/001-myapp.sql`), create the table using the base-entity shape, then register it:\n\n```sql\nCREATE TABLE IF NOT EXISTS assets (\n    id            VARCHAR(20) PRIMARY KEY,\n    name          TEXT NOT NULL,\n    status        VARCHAR(40) NOT NULL DEFAULT 'in_storage',\n    price         JSONB,                        -- currency {amountMicros, currencyCode}\n    position      REAL NOT NULL DEFAULT 0,\n    created_by    JSONB NOT NULL DEFAULT '{}',  -- ACTOR {source, memberId, name}\n    updated_by    JSONB NOT NULL DEFAULT '{}',\n    created_at    TIMESTAMPTZ NOT NULL DEFAULT NOW(),\n    updated_at    TIMESTAMPTZ NOT NULL DEFAULT NOW(),\n    deleted_at    TIMESTAMPTZ,                  -- soft delete\n    search_vector tsvector GENERATED ALWAYS AS (to_tsvector('simple', coalesce(name, ''))) STORED\n);\nCREATE INDEX IF NOT EXISTS idx_assets_search ON assets USING GIN (search_vector);\n\nINSERT INTO kit_objects (name, table_name, label, label_plural, icon, app, membership, title_field)\nVALUES ('asset', 'assets', 'Asset', 'Assets', '📦', 'myapp', 'open', 'name')\nON CONFLICT (name) DO UPDATE SET label = EXCLUDED.label;\n\nINSERT INTO kit_fields (object_name, name, label, type, options, required, in_list, position) VALUES\n  ('asset', 'name',   'Name',   'text',     '{}', TRUE, TRUE, 1),\n  ('asset', 'status', 'Status', 'select',   '{\"values\":[\"in_use\",\"in_storage\"]}', TRUE, TRUE, 2),\n  ('asset', 'price',  'Price',  'currency', '{}', FALSE, TRUE, 3)\nON CONFLICT (object_name, name) DO UPDATE SET\n  label = EXCLUDED.label, type = EXCLUDED.type, options = EXCLUDED.options,\n  required = EXCLUDED.required, in_list = EXCLUDED.in_list, position = EXCLUDED.position;\n```\n\nEvery column in the table must be registered as a field (plus the base-entity columns above). `title_field` names the field used as the record's display title in labels, events, and cards.\n\n**2.** Add your table to **BOTH** functions' `tables:` lists in `gipity.yaml` - `record-read` AND `record-write` (the kit ships declaring only its own core tables). Your edits survive a kit re-install.\n\n**3.** `gipity deploy dev`. The object is now served by the generic API - no new function needed.\n\n## Field types\n\n| Type | Stored as / write shape |\n|---|---|\n| `text`, `textarea` | string (max 10000 chars) |\n| `number` | numeric |\n| `boolean` | true/false |\n| `date` | `'YYYY-MM-DD'` string |\n| `select` | one of `options.values` (declare `{\"values\":[...]}`) |\n| `currency` | JSONB `{amountMicros, currencyCode}`; **write a plain number of whole units** (e.g. `19.5` → 19,500,000 micros USD) |\n| `relation` | JSONB `{id, label}`; **write the target record's id string**; declare `{\"object\":\"company\",\"labelField\":\"name\"}`. Existence is checked and the label denormalized in the write transaction |\n| `emails` / `phones` / `links` | JSONB composites (`{primaryEmail, additionalEmails}` etc.); writing a plain string sets the primary |\n| `json` | JSONB object (not array/scalar) |\n\nValidation errors are self-correcting - they say what valid input looks like (`'status' must be one of: in_use, in_storage (got 'x')`).\n\n## Client API (records kit)\n\n```js\nimport { getSchema, listRecords, getRecord, aggregate,\n         createRecord, updateRecord, deleteRecord, createMany } from '@gipity/records';\n\nconst { objects } = await getSchema('myapp');            // objects each carry .fields\n\nconst { records, total } = await listRecords('asset', {\n  q: 'macbook',                                          // full-text search\n  filters: [{ field: 'status', op: 'eq', value: 'in_use' }],\n  sort: { field: 'price', dir: 'desc' },\n  limit: 100, offset: 0,                                 // default 100, max 10000\n});\n\nconst { record, events } = await getRecord('asset', id); // record + its last 50 audit events\n\n// Server-side GROUP BY (count + optional sum) - one query, no client paging.\n// sum comes back in micros for currency fields. Honors the same filters/q as list.\nconst { groups } = await aggregate('opportunity', { group_by: 'stage', sum: 'amount' });\n// → [{ group: 'won', count: 5, sum: 1525000000000 }, …]\n\nconst { record: created } = await createRecord('asset', { name: 'MacBook', status: 'in_use' });\nawait updateRecord('asset', created.id, { status: 'in_storage' });\nawait deleteRecord('asset', created.id);                 // soft delete (sets deleted_at)\n\n// Bulk create, auto-chunked to the object's per-call query budget. Per-row\n// results in input order - one bad row doesn't sink the rest.\nconst asset = objects.find(o => o.name === 'asset');     // createMany takes the schema object\nconst results = await createMany(asset, rowsFromCsv, { source: 'IMPORT', onProgress });\n// → [{ ok: true, record }, { ok: false, error }, …]\n```\n\n- Filter ops: `eq, neq, gt, gte, lt, lte, contains, in, isnull`. Relations filter on the target **id**: `{ field: 'company', op: 'eq', value: '<company id>' }`; other composites match their primary value.\n- Base columns `id`, `created_at`, `updated_at`, `position` are always sortable/filterable.\n- The wrapper throws an `Error` when the function returns `{ error }` - wrap calls in try/catch for UI.\n- **Import path:** bare `'@gipity/records'` resolves only on pages whose HTML has the import map (the installer patches `src/index.html`). On other pages of a multi-page app, import `'../packages/records/api.js'`.\n\n### Auth and membership\n\n`record-write` requires a signed-in Gipity user. On first **successful** write, `kit_members` maps the identity to an app role: with `membership: 'open'` the first user becomes `owner`, later ones `member`; `readonly` members are rejected. `membership: 'invite'` blocks unknown users until an owner adds a member row.\n\nThe membership row is written on the write's own transaction, so a call that gets rejected - failed validation, unknown action, a record that doesn't exist - claims nothing. That means you can safely probe `record-write` with a deliberately invalid payload (the `{\"values\":{}}` smoke-test) without taking ownership of the app. Note the flip side: **any** successful write by a signed-in user on an `open` app makes them a member, and the very first one makes them `owner`. Switch to `membership: 'invite'` before you share the URL if that isn't what you want.\n\n### History / activity feeds\n\nEvery create/update/delete lands in `kit_events` (`action`, `actor`, `changes` as `{field: {from, to}}`, and a prompt-ready plain-English `summary` like `Steve updated asset \"MacBook\" (status: in_use → in_storage)`). Render history from what you already get:\n\n- `getRecord(object, id)` → `{ record, events }` - per-record timeline (last 50).\n- `Gipity.fn('record-read', { action: 'events', object })` - last 100 events for one object.\n- `Gipity.fn('record-read', { action: 'activity', limit })` - cross-object feed (default 50, max 100).\n\n### Concurrency and provenance\n\n- **Optimistic concurrency:** pass the `updated_at` you loaded as `expect_updated_at` on update - `updateRecord('asset', id, values, { expect_updated_at: rec.updated_at })`. A concurrent change returns a clean \"changed since you loaded it (last updated by …)\" error instead of silent last-write-wins.\n- **Label refresh:** renaming a record (its `title_field`) refreshes the denormalized `{id, label}` on every relation pointing at it, in the same transaction - bounded by `record-write`'s `max_rows_affected` (1,000 rows per referencing table).\n- **Import provenance:** `source: 'IMPORT'` makes events read \"imported\" instead of \"created\" on the timeline.\n\n## The one rule\n\n**Never write record tables from anywhere but `record-write`** (or the `agent-api` kit's `agent-write`). A direct `UPDATE` - from your own function or `gipity db query` - bypasses validation and poisons the event spine. Read-only access from your own functions is fine.\n\n## Views kit (registry-driven UI)\n\n```js\n// Bare '@gipity/views/...' works only on import-mapped pages; elsewhere use relative paths:\nimport { renderTable } from '../packages/views/table.js';\nimport { renderKanban } from '../packages/views/kanban.js';\nimport { openRecordForm } from '../packages/views/form.js';\nimport { renderDataTable } from '../packages/views/readtable.js';\nimport { formatValue, prettyLabel, buildWidget } from '../packages/views/fields.js';\nimport { getSchema } from '../packages/records/api.js';\n\nconst { objects } = await getSchema('myapp');\nconst object = objects.find(o => o.name === 'asset');\n\n// Table: search box, one dropdown filter PER SELECT FIELD, click-to-sort headers,\n// dense rows (loads up to 200). Returns { refresh, getState }.\nconst table = renderTable({\n  mount: document.querySelector('#list'),\n  object,\n  onRowClick: rec => openDetail(rec),\n  initial: {},                 // pre-apply a saved view's { q, filters, sort }\n  editable: () => !!user,      // double-click a cell to edit in place; concurrent\n});                            // edits come back as a clean conflict error\n\n// Kanban: one column per option of a SELECT field; drag-to-update goes through\n// record-write (so drops land on the event spine). metricField (a currency\n// field) adds a per-column total next to the count.\nrenderKanban({ mount, object, groupField: 'status', cardFields: ['price'],\n               metricField: 'price', onCardClick: rec => openDetail(rec), canEdit: () => !!user });\n\n// Form: create/edit/delete <dialog> with type-appropriate widgets. Relation\n// fields get a debounced server-side typeahead. Pass record: null to create.\nopenRecordForm({ object, record, onSaved: rec => table.refresh(), onDeleted: () => table.refresh() });\n\n// Read-only table for any { columns, rows } payload (reports, introspection) - no registry needed.\nrenderDataTable({ mount, title: 'Report', columns: ['name', 'total'], rows });\n```\n\nInclude the stylesheet once per page: `<link rel=\"stylesheet\" href=\"./packages/views/views.css\">`. It's deliberately minimal - brand by overriding `.kit-table`, `.kit-card`, `.kit-column`, etc. in your own CSS.\n\n**Known limits of the generated UI:**\n\n- **Toolbar dropdown filters are generated ONLY for `select` fields.** There is no generated filter widget for relation, date-range, number, or text fields - the table's free-text search covers text. For anything richer, build your own controls and re-render with `initial: { filters: [...] }` (any `listRecords` filter works there; the dropdowns are just the generated subset).\n- `renderKanban`'s `groupField` **must be a `select` field** - it throws otherwise. Columns come from `options.values`.\n- Table and kanban load up to 200 records per refresh; fields with `in_list: FALSE` are hidden from table columns but still edit in the form.\n- Inline cell editing covers scalar types; `textarea`/`json`/`relation` edit in the form.\n\n**Saved views:** persist `table.getState()` (returns `{ q, filters, sort }`) as a record of your own (e.g. a `saved_view` object with a `json` config field), and pass it back as `initial` to restore.\n\n## Test it\n\nTests hit the deployed functions against an isolated auto-reset test database ([app-testing](app-testing.md)). The kit lists `kit_objects`/`kit_fields` under `test.preserve` in gipity.yaml at install, so your migration-inserted registry rows survive the reset. `record-read` is callable anonymously; `record-write` is `auth: user`, so use `callAs`:\n\n```js\ntest('create and list assets', async (ctx) => {\n    const { record } = await ctx.fn.callAs(ctx.users.alice, 'record-write',\n        { action: 'create', object: 'asset', values: { name: 'MacBook', status: 'in_use' } });\n    assert.ok(record.id);\n    const { records, total } = await ctx.fn.call('record-read', { action: 'list', object: 'asset' });\n    assert.equal(total, 1);\n});\n```\n\n## Common mistakes to avoid\n\n- **Forgetting step 2**: your table must be in BOTH `record-read` and `record-write` `tables:` lists in `gipity.yaml`, or calls fail with a permissions error.\n- **Shipping private data on the public default** - flip `record-read` to `auth: \"user\"`/`\"member\"` first.\n- **Writing record tables directly** (own function or SQL) - always go through `record-write`.\n- **Editing sealed kit files** (`functions/record-*`, `functions/_lib/records/`, `migrations/000-kit-records-core.sql`) - re-adding the kit overwrites them.\n- **Currency confusion**: write whole units (`19.5`); stored/aggregated values are in `amountMicros` (× 1,000,000).\n- **Relation writes with a label**: send just the id string - the server denormalizes `{id, label}` itself.\n- **Kanban on a non-select field** - `groupField` must be `select`; expecting generated dropdown filters for non-select fields - they don't exist (see limits above).\n- **Bare imports off the mapped page**: on secondary pages use `'../packages/records/api.js'` / `'../packages/views/*.js'`.\n\n## Related skills\n\n- [app-development](app-development.md) - functions, `gipity.yaml`, backend-bearing kit rules\n- [app-database](app-database.md) - migrations, the `db` helper, table permissions\n- [app-testing](app-testing.md) - the `ctx.fn.call`/`callAs` contract and the isolated test DB\n"
}
