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.
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-runninggipity add recordsat 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:
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)
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,positionare always sortable/filterable. - The wrapper throws an
Errorwhen 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 patchessrc/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_atyou loaded asexpect_updated_aton 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 byrecord-write'smax_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)
// 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
selectfields. 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 withinitial: { filters: [...] }(anylistRecordsfilter works there; the dropdowns are just the generated subset). renderKanban'sgroupFieldmust be aselectfield - it throws otherwise. Columns come fromoptions.values.- Table and kanban load up to 200 records per refresh; fields with
in_list: FALSEare hidden from table columns but still edit in the form. - Inline cell editing covers scalar types;
textarea/json/relationedit 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). 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:
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-readandrecord-writetables:lists ingipity.yaml, or calls fail with a permissions error. - Shipping private data on the public default - flip
record-readtoauth: "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 inamountMicros(× 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 -
groupFieldmust beselect; 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 - functions,
gipity.yaml, backend-bearing kit rules - app-database - migrations, the
dbhelper, table permissions - app-testing - the
ctx.fn.call/callAscontract and the isolated test DB