# Records & Views kits (structured data + generated UI)

Build 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.

```bash
gipity add records     # backend: functions + migrations + client API (needs a database template)
gipity add views       # frontend: table / kanban / form renderers (requires records)
gipity deploy dev
```

Needs `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.

## ⚠️ The read path is PUBLIC by default

`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`.

## What gets installed

- `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.
- `migrations/000-kit-records-core.sql` - registry (`kit_objects`, `kit_fields`), members (`kit_members`), event spine (`kit_events`).
- `src/packages/records/api.js` - the client wrapper.

## Declare an object (you own these files)

**1.** In your own migration (`migrations/001-myapp.sql`), create the table using the base-entity shape, then register it:

```sql
CREATE TABLE IF NOT EXISTS assets (
    id            VARCHAR(20) PRIMARY KEY,
    name          TEXT NOT NULL,
    status        VARCHAR(40) NOT NULL DEFAULT 'in_storage',
    price         JSONB,                        -- currency {amountMicros, currencyCode}
    position      REAL NOT NULL DEFAULT 0,
    created_by    JSONB NOT NULL DEFAULT '{}',  -- ACTOR {source, memberId, name}
    updated_by    JSONB NOT NULL DEFAULT '{}',
    created_at    TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at    TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    deleted_at    TIMESTAMPTZ,                  -- soft delete
    search_vector tsvector GENERATED ALWAYS AS (to_tsvector('simple', coalesce(name, ''))) STORED
);
CREATE INDEX IF NOT EXISTS idx_assets_search ON assets USING GIN (search_vector);

INSERT INTO kit_objects (name, table_name, label, label_plural, icon, app, membership, title_field)
VALUES ('asset', 'assets', 'Asset', 'Assets', '📦', 'myapp', 'open', 'name')
ON CONFLICT (name) DO UPDATE SET label = EXCLUDED.label;

INSERT INTO kit_fields (object_name, name, label, type, options, required, in_list, position) VALUES
  ('asset', 'name',   'Name',   'text',     '{}', TRUE, TRUE, 1),
  ('asset', 'status', 'Status', 'select',   '{"values":["in_use","in_storage"]}', TRUE, TRUE, 2),
  ('asset', 'price',  'Price',  'currency', '{}', FALSE, TRUE, 3)
ON CONFLICT (object_name, name) DO UPDATE SET
  label = EXCLUDED.label, type = EXCLUDED.type, options = EXCLUDED.options,
  required = EXCLUDED.required, in_list = EXCLUDED.in_list, position = EXCLUDED.position;
```

Every 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.

**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.

**3.** `gipity deploy dev`. The object is now served by the generic API - no new function needed.

## Field types

| Type | Stored as / write shape |
|---|---|
| `text`, `textarea` | string (max 10000 chars) |
| `number` | numeric |
| `boolean` | true/false |
| `date` | `'YYYY-MM-DD'` string |
| `select` | one of `options.values` (declare `{"values":[...]}`) |
| `currency` | JSONB `{amountMicros, currencyCode}`; **write a plain number of whole units** (e.g. `19.5` → 19,500,000 micros USD) |
| `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 |
| `emails` / `phones` / `links` | JSONB composites (`{primaryEmail, additionalEmails}` etc.); writing a plain string sets the primary |
| `json` | JSONB object (not array/scalar) |

Validation errors are self-correcting - they say what valid input looks like (`'status' must be one of: in_use, in_storage (got 'x')`).

## Client API (records kit)

```js
import { getSchema, listRecords, getRecord, aggregate,
         createRecord, updateRecord, deleteRecord, createMany } from '@gipity/records';

const { objects } = await getSchema('myapp');            // objects each carry .fields

const { records, total } = await listRecords('asset', {
  q: 'macbook',                                          // full-text search
  filters: [{ field: 'status', op: 'eq', value: 'in_use' }],
  sort: { field: 'price', dir: 'desc' },
  limit: 100, offset: 0,                                 // default 100, max 10000
});

const { record, events } = await getRecord('asset', id); // record + its last 50 audit events

// Server-side GROUP BY (count + optional sum) - one query, no client paging.
// sum comes back in micros for currency fields. Honors the same filters/q as list.
const { groups } = await aggregate('opportunity', { group_by: 'stage', sum: 'amount' });
// → [{ group: 'won', count: 5, sum: 1525000000000 }, …]

const { record: created } = await createRecord('asset', { name: 'MacBook', status: 'in_use' });
await updateRecord('asset', created.id, { status: 'in_storage' });
await deleteRecord('asset', created.id);                 // soft delete (sets deleted_at)

// Bulk create, auto-chunked to the object's per-call query budget. Per-row
// results in input order - one bad row doesn't sink the rest.
const asset = objects.find(o => o.name === 'asset');     // createMany takes the schema object
const results = await createMany(asset, rowsFromCsv, { source: 'IMPORT', onProgress });
// → [{ ok: true, record }, { ok: false, error }, …]
```

- 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.
- Base columns `id`, `created_at`, `updated_at`, `position` are always sortable/filterable.
- The wrapper throws an `Error` when the function returns `{ error }` - wrap calls in try/catch for UI.
- **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'`.

### Auth and membership

`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.

The 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.

### History / activity feeds

Every 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:

- `getRecord(object, id)` → `{ record, events }` - per-record timeline (last 50).
- `Gipity.fn('record-read', { action: 'events', object })` - last 100 events for one object.
- `Gipity.fn('record-read', { action: 'activity', limit })` - cross-object feed (default 50, max 100).

### Concurrency and provenance

- **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.
- **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).
- **Import provenance:** `source: 'IMPORT'` makes events read "imported" instead of "created" on the timeline.

## The one rule

**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.

## Views kit (registry-driven UI)

```js
// Bare '@gipity/views/...' works only on import-mapped pages; elsewhere use relative paths:
import { renderTable } from '../packages/views/table.js';
import { renderKanban } from '../packages/views/kanban.js';
import { openRecordForm } from '../packages/views/form.js';
import { renderDataTable } from '../packages/views/readtable.js';
import { formatValue, prettyLabel, buildWidget } from '../packages/views/fields.js';
import { getSchema } from '../packages/records/api.js';

const { objects } = await getSchema('myapp');
const object = objects.find(o => o.name === 'asset');

// Table: search box, one dropdown filter PER SELECT FIELD, click-to-sort headers,
// dense rows (loads up to 200). Returns { refresh, getState }.
const table = renderTable({
  mount: document.querySelector('#list'),
  object,
  onRowClick: rec => openDetail(rec),
  initial: {},                 // pre-apply a saved view's { q, filters, sort }
  editable: () => !!user,      // double-click a cell to edit in place; concurrent
});                            // edits come back as a clean conflict error

// Kanban: one column per option of a SELECT field; drag-to-update goes through
// record-write (so drops land on the event spine). metricField (a currency
// field) adds a per-column total next to the count.
renderKanban({ mount, object, groupField: 'status', cardFields: ['price'],
               metricField: 'price', onCardClick: rec => openDetail(rec), canEdit: () => !!user });

// Form: create/edit/delete <dialog> with type-appropriate widgets. Relation
// fields get a debounced server-side typeahead. Pass record: null to create.
openRecordForm({ object, record, onSaved: rec => table.refresh(), onDeleted: () => table.refresh() });

// Read-only table for any { columns, rows } payload (reports, introspection) - no registry needed.
renderDataTable({ mount, title: 'Report', columns: ['name', 'total'], rows });
```

Include 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.

**Known limits of the generated UI:**

- **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).
- `renderKanban`'s `groupField` **must be a `select` field** - it throws otherwise. Columns come from `options.values`.
- 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.
- Inline cell editing covers scalar types; `textarea`/`json`/`relation` edit in the form.

**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.

## Test it

Tests 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`:

```js
test('create and list assets', async (ctx) => {
    const { record } = await ctx.fn.callAs(ctx.users.alice, 'record-write',
        { action: 'create', object: 'asset', values: { name: 'MacBook', status: 'in_use' } });
    assert.ok(record.id);
    const { records, total } = await ctx.fn.call('record-read', { action: 'list', object: 'asset' });
    assert.equal(total, 1);
});
```

## Common mistakes to avoid

- **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.
- **Shipping private data on the public default** - flip `record-read` to `auth: "user"`/`"member"` first.
- **Writing record tables directly** (own function or SQL) - always go through `record-write`.
- **Editing sealed kit files** (`functions/record-*`, `functions/_lib/records/`, `migrations/000-kit-records-core.sql`) - re-adding the kit overwrites them.
- **Currency confusion**: write whole units (`19.5`); stored/aggregated values are in `amountMicros` (× 1,000,000).
- **Relation writes with a label**: send just the id string - the server denormalizes `{id, label}` itself.
- **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).
- **Bare imports off the mapped page**: on secondary pages use `'../packages/records/api.js'` / `'../packages/views/*.js'`.

## Related skills

- [app-development](app-development.md) - functions, `gipity.yaml`, backend-bearing kit rules
- [app-database](app-database.md) - migrations, the `db` helper, table permissions
- [app-testing](app-testing.md) - the `ctx.fn.call`/`callAs` contract and the isolated test DB

