@abloatai/ablo 0.50.0 → 0.52.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.
@@ -0,0 +1,215 @@
1
+ # Customer Organizations
2
+
3
+ > Serve many customer organizations from one backend and one shared schema without weakening tenant isolation.
4
+
5
+ An application serving many customers has two independent choices: where
6
+ customer data is isolated and where the schema is authored. Ablo lets every
7
+ customer keep a hard organization boundary while all of them use the schema
8
+ pushed once by the owning project.
9
+
10
+ ## Choose the customer boundary first
11
+
12
+ | Customer model | Isolation guarantee | Choose it when |
13
+ |---|---|---|
14
+ | One Ablo organization with customer scope roots | Whatever read boundary every model declares in `policy` | Cross-customer reads are intentional, or every model is explicitly and continuously policy-partitioned |
15
+ | One Ablo organization per customer | Structural organization filtering and RLS on every row | Customers must stay isolated even when a model has no customer-specific policy |
16
+
17
+ Sync-groups decide which changes are delivered. They do not, by themselves,
18
+ authorize HTTP reads. If you cannot audit matching policies across every model,
19
+ use one Ablo organization per customer.
20
+
21
+ The rest of this guide uses organization-per-customer: one trusted backend
22
+ holds one dedicated mint key, every customer has an `organizationId`, and each
23
+ user session names that customer organization.
24
+
25
+ ## What you need
26
+
27
+ - An owning project containing the schema every customer uses.
28
+ - The schema pushed to that project's production root.
29
+ - A server-side `sk_` carrying only `ephemeral:mint-any-org`.
30
+ - A customer `organizationId` resolved from your authenticated application
31
+ membership, never accepted unchecked from the browser.
32
+ - A model-by-model `can` grant for the UI being opened.
33
+
34
+ The cross-organization key is a minting credential, not a tenant-data
35
+ credential. Keep it in a secret manager and expose only your own authenticated
36
+ session endpoint.
37
+
38
+ ## Mint on your backend
39
+
40
+ Create the Ablo client once in server-only code:
41
+
42
+ ```ts
43
+ import Ablo from '@abloatai/ablo';
44
+ import { schema } from '@/ablo/schema';
45
+
46
+ export const customerSessions = Ablo({
47
+ schema,
48
+ apiKey: process.env.ABLO_API_KEY,
49
+ });
50
+ ```
51
+
52
+ After your application authenticates the user, resolve the customer from that
53
+ trusted membership and mint the session:
54
+
55
+ ```ts
56
+ import { credentialEndpointSuccessSchema } from '@abloatai/ablo/auth';
57
+ import { customerSessions } from '@/ablo/customer-sessions';
58
+
59
+ export async function POST() {
60
+ const member = await requireSignedInCustomerMember();
61
+
62
+ const session = await customerSessions.sessions.create({
63
+ user: { id: member.userId },
64
+ organizationId: member.abloOrganizationId,
65
+ can: {
66
+ projects: ['read'],
67
+ records: ['read', 'create', 'update'],
68
+ },
69
+ });
70
+
71
+ return Response.json(
72
+ credentialEndpointSuccessSchema.parse({
73
+ token: session.token,
74
+ expiresAt: session.expiresAt,
75
+ credentialKind: 'ephemeral',
76
+ }),
77
+ { headers: { 'Cache-Control': 'no-store' } },
78
+ );
79
+ }
80
+ ```
81
+
82
+ Do not take `organizationId` directly from request JSON. A signed-in user could
83
+ replace it with another customer's id. Derive it from the server-side membership
84
+ you just authenticated.
85
+
86
+ ## Connect the browser
87
+
88
+ The browser knows only your session endpoint. It never receives the
89
+ cross-organization key:
90
+
91
+ ```tsx
92
+ 'use client';
93
+
94
+ import Ablo from '@abloatai/ablo';
95
+ import { AbloProvider } from '@abloatai/ablo/react';
96
+ import { schema } from '@/ablo/schema';
97
+
98
+ const ablo = Ablo({
99
+ schema,
100
+ authEndpoint: '/api/ablo-session',
101
+ });
102
+
103
+ export function Providers({ children }: { children: React.ReactNode }) {
104
+ return <AbloProvider client={ablo}>{children}</AbloProvider>;
105
+ }
106
+ ```
107
+
108
+ The client re-mints before expiry. Your endpoint should return
109
+ `session_expired` only when the application's own login is gone; network and
110
+ server failures are transient and must not sign the user out.
111
+
112
+ ## Schema and data stay on different axes
113
+
114
+ For a cross-organization mint, Ablo derives the schema binding from the owning
115
+ key automatically:
116
+
117
+ ```text
118
+ owning key organization/project -> schema shape
119
+ customer organization -> rows, RLS, database, sync groups
120
+ session can -> allowed model operations
121
+ model policy -> allowed reads inside that organization
122
+ ```
123
+
124
+ Most applications should not pass a schema option. An explicit override exists for
125
+ migrations or advanced routing:
126
+
127
+ ```ts
128
+ await customerSessions.sessions.create({
129
+ user: { id: member.userId },
130
+ organizationId: member.abloOrganizationId,
131
+ schemaProject: {
132
+ organizationId: schemaOwnerOrganizationId,
133
+ projectId: migratingSchemaProjectId,
134
+ },
135
+ can: { records: ['read', 'update'] },
136
+ });
137
+ ```
138
+
139
+ Both schema coordinates move together; customer data does not move with them.
140
+
141
+ ## Customer lifecycle checklist
142
+
143
+ When onboarding a customer:
144
+
145
+ 1. Provision or resolve its Ablo organization and store that immutable id on
146
+ your customer record.
147
+ 2. Connect that organization's production data source if it owns a separate
148
+ database.
149
+ 3. Keep the shared schema in the owning project; do not copy it into every
150
+ customer organization.
151
+ 4. Mint a test user session through the same backend route production uses.
152
+ 5. Verify one known read and write in the customer organization before enabling
153
+ the integration.
154
+
155
+ When offboarding, stop minting immediately, revoke active sessions when an
156
+ immediate cutoff is required, and retire the customer's data-source access
157
+ through the control-plane process you use for provisioning.
158
+
159
+ ## Security checklist
160
+
161
+ - Store the cross-organization key only in the backend secret manager.
162
+ - Give it only `ephemeral:mint-any-org`; do not combine minting with schema or
163
+ data authority.
164
+ - Resolve `organizationId` from authenticated membership server-side.
165
+ - Keep `can` to the smallest model/verb set the UI needs.
166
+ - Use organization-per-customer when a missing model policy must not expose
167
+ another customer's rows.
168
+ - Rotate the cross-organization key on a schedule and after personnel or
169
+ infrastructure changes.
170
+ - Record target organization, user, session id, and request id without logging
171
+ plaintext credentials.
172
+
173
+ ## Troubleshooting
174
+
175
+ ### The mint is forbidden
176
+
177
+ The presenting credential must be a secret `sk_` with
178
+ `ephemeral:mint-any-org`. A normal project key can mint users into its own
179
+ organization but cannot name another one. Run `npx ablo whoami --json` in the
180
+ backend environment to confirm which project and branch the configured key
181
+ actually belongs to; the command never prints the full secret.
182
+
183
+ ### The session mints but bootstrap is empty
184
+
185
+ Confirm that the cross-organization key belongs to the project where the schema
186
+ was pushed, and that the customer's organization has its data source/root
187
+ branch ready. Do not copy the schema into the customer organization. If you
188
+ supplied `schemaProject`, remove it unless you intentionally override the
189
+ owning-key default.
190
+
191
+ ### The mint reports an unknown model
192
+
193
+ Every model named by `can` must exist in the active shared schema. Check the
194
+ model key spelling against the schema used to construct `customerSessions`, then
195
+ push that schema to the key's branch deliberately.
196
+
197
+ ### Data appears under the wrong customer
198
+
199
+ Inspect the membership-to-`organizationId` lookup in your backend route first.
200
+ The schema project never selects the data tenant. The `organizationId` passed to
201
+ `sessions.create` does, and it must come from trusted server-side membership.
202
+
203
+ ### Realtime looks isolated but an HTTP read is too broad
204
+
205
+ Sync-groups route changes; they are not a read policy. Add or correct the
206
+ model's `policy`, or move customers to separate Ablo organizations when the
207
+ boundary must hold structurally across every model.
208
+
209
+ ## Related guides
210
+
211
+ - [Sessions](./sessions.md) — session lifecycle, refresh, revocation, and grants.
212
+ - [API Keys](./api-keys.md) — credential classes, scopes, inspection, and rotation.
213
+ - [Identity & Sync Groups](./identity.md) — participant delivery groups versus model read policy.
214
+ - [Connect Your Database](./data-sources.md) — customer-owned database setup.
215
+ - [Deployment](./deployment.md) — production schema and database rollout order.
@@ -149,8 +149,8 @@ own schema:
149
149
  ABLO_API_KEY="$MAIL_KEY" DATABASE_URL="$PRODUCTION_URL" \
150
150
  npx ablo connect apply --schema mail --yes
151
151
 
152
- ABLO_API_KEY="$SLIDES_KEY" DATABASE_URL="$PRODUCTION_URL" \
153
- npx ablo connect apply --schema slides --yes
152
+ ABLO_API_KEY="$ENTRIES_KEY" DATABASE_URL="$PRODUCTION_URL" \
153
+ npx ablo connect apply --schema entries --yes
154
154
  ```
155
155
 
156
156
  For a Neon or Supabase preview branch, use that branch's direct URL and keep the
@@ -380,7 +380,7 @@ export const ablo = Ablo({
380
380
  The API key still selects the project and branch; during `ready()` Ablo asks the
381
381
  server what the key actually targets and refuses startup when either coordinate
382
382
  differs. `ablo dev` writes all three values together, so accidentally exporting
383
- a slides key into the mail app—or a mail development key into production—fails
383
+ a entries key into the mail app—or a mail development key into production—fails
384
384
  before any read, write, or subscription begins.
385
385
 
386
386
  The Ablo schema describes **only your synced, collaborative models** — the rows
package/docs/debugging.md CHANGED
@@ -72,12 +72,12 @@ Precedence: an explicit `logLevel` wins, then `debug: true` (⇒ `debug`), then
72
72
  These lines (all at `info`) let you watch the handover you built:
73
73
 
74
74
  ```
75
- [Ablo] claim: requesting documents:doc_42 for "editing" (will queue if contended)
76
- [Ablo] claim: queued for documents:doc_42 — position 2 of 3, waiting
75
+ [Ablo] claim: requesting records:doc_42 for "editing" (will queue if contended)
76
+ [Ablo] claim: queued for records:doc_42 — position 2 of 3, waiting
77
77
  [Ablo] claim: granted 7f3c… — your turn (waited in queue)
78
- [Ablo] claim: rejected documents:doc_42 — held by agent_writer
79
- [Ablo] claim: lost documents:doc_42 (preempted or expired)
80
- [Ablo] claim: released documents:doc_42
78
+ [Ablo] claim: rejected records:doc_42 — held by agent_writer
79
+ [Ablo] claim: lost records:doc_42 (preempted or expired)
80
+ [Ablo] claim: released records:doc_42
81
81
  ```
82
82
 
83
83
  Read it as the lifecycle of one claim:
@@ -189,7 +189,7 @@ function ActivityFeed({ log }: { log: ClaimLog }) {
189
189
  For **"who holds *this* row right now"** (a badge, not a feed), don't use `ClaimLog` — read the reactive claim state directly. It re-renders on change with no extra wiring:
190
190
 
191
191
  ```tsx
192
- const holder = useAblo((ablo) => ablo.documents.claim.state({ id })); // Claim | null
192
+ const holder = useAblo((ablo) => ablo.records.claim.state({ id })); // Claim | null
193
193
  ```
194
194
 
195
195
  See [React](./react.md) and [Coordination](./coordination.md) for the claim-read APIs.
@@ -265,7 +265,7 @@ Every rejected live commit carries `requestId` on the thrown error and
265
265
  import { AbloError } from '@abloatai/ablo';
266
266
 
267
267
  try {
268
- await ablo.documents.create({
268
+ await ablo.records.create({
269
269
  data,
270
270
  });
271
271
  } catch (error) {
@@ -289,7 +289,7 @@ evidence that the replication source has no history.
289
289
  Use:
290
290
 
291
291
  ```ts
292
- await ablo.documents.list({ type: 'complete' });
292
+ await ablo.records.list({ type: 'complete' });
293
293
  ```
294
294
 
295
295
  `type: 'complete'` waits for a server round trip and returns the confirmed
@@ -2,16 +2,16 @@
2
2
 
3
3
  > An agent that yields the row when a person is already holding it.
4
4
 
5
- A task-writing agent that yields when a person is editing the same task.
5
+ A record-writing agent that yields when a person is editing the same record.
6
6
 
7
7
  ## Scenario
8
8
 
9
- The same tasks are edited by agents and by the people watching them. They must
9
+ The same records are edited by agents and by the people watching them. They must
10
10
  not collide:
11
11
 
12
12
  - If a person already holds the row, the agent yields instead of fighting for it.
13
13
  - While the agent is updating, the UI can show who is active.
14
- - If the task changes mid-run, the commit is rejected instead of overwriting the
14
+ - If the record changes mid-run, the commit is rejected instead of overwriting the
15
15
  newer edit.
16
16
 
17
17
  A **claim** does both jobs. Claims don't lock — if another writer holds the row,
@@ -24,9 +24,9 @@ a typed error if the row moved underneath you while the agent was busy.
24
24
 
25
25
  ## Schema-Backed Worker
26
26
 
27
- The worker uses the same schema client the app uses. It reads the task from the
27
+ The worker uses the same schema client the app uses. It reads the record from the
28
28
  server with `get({ id })`, claims the row, and writes through
29
- `ablo.tasks.update(...)` with a stale-check so a concurrent edit can't be
29
+ `ablo.records.update(...)` with a stale-check so a concurrent edit can't be
30
30
  overwritten.
31
31
 
32
32
  ```ts
@@ -34,7 +34,7 @@ import Ablo, { AbloClaimedError, AbloStaleContextError } from '@abloatai/ablo';
34
34
  import { defineSchema, model, z } from '@abloatai/ablo/schema';
35
35
 
36
36
  const schema = defineSchema({
37
- tasks: model({
37
+ records: model({
38
38
  title: z.string(),
39
39
  status: z.enum(['todo', 'doing', 'done']),
40
40
  }),
@@ -46,19 +46,19 @@ const ablo = Ablo({
46
46
  transport: 'http',
47
47
  });
48
48
 
49
- export async function markDone(taskId: string) {
49
+ export async function markDone(recordId: string) {
50
50
  await ablo.ready();
51
51
 
52
52
  // get({ id }) is an async server read — await it.
53
- const task = await ablo.tasks.get({ id: taskId });
54
- if (!task) return { status: 'not_found' };
53
+ const record = await ablo.records.get({ id: recordId });
54
+ if (!record) return { status: 'not_found' };
55
55
 
56
56
  try {
57
57
  // queue: false → don't queue behind a current holder. If another
58
58
  // participant holds the row, claim resolves null, so the agent yields
59
59
  // instead of waiting. Omit it, or pass queue: true, to queue behind them.
60
- const acquired = await ablo.tasks.claim({
61
- id: taskId,
60
+ const acquired = await ablo.records.claim({
61
+ id: recordId,
62
62
  queue: false,
63
63
  description: 'marking_done',
64
64
  });
@@ -72,7 +72,7 @@ export async function markDone(taskId: string) {
72
72
  // `onStale: 'reject'`. The write below is therefore equivalent to passing
73
73
  // those options yourself:
74
74
  //
75
- // ablo.tasks.update({
75
+ // ablo.records.update({
76
76
  // id: claim.data.id,
77
77
  // data: { status: 'done' },
78
78
  // readAt: <claim snapshot version>,
@@ -82,12 +82,12 @@ export async function markDone(taskId: string) {
82
82
  // If a newer version landed mid-run, the row no longer matches `readAt`, so
83
83
  // the server rejects this commit with AbloStaleContextError (caught below)
84
84
  // instead of clobbering that edit.
85
- const updated = await ablo.tasks.update({
85
+ const updated = await ablo.records.update({
86
86
  id: claim.data.id,
87
87
  data: { status: 'done' },
88
88
  });
89
89
 
90
- return { status: 'done', task: updated };
90
+ return { status: 'done', record: updated };
91
91
  } catch (err) {
92
92
  // The lease was lost or a foreign holder rejected the write.
93
93
  if (err instanceof AbloClaimedError) return { status: 'yielded' };
@@ -108,9 +108,9 @@ Keep workers on the same schema-backed client as the app.
108
108
 
109
109
  import { useAblo } from '@abloatai/ablo/react';
110
110
 
111
- export function TaskRow({ task: serverTask }: Props) {
112
- const data = useAblo((ablo) => ablo.tasks.local.get(serverTask.id)) ?? serverTask;
113
- const holder = useAblo((ablo) => ablo.tasks.claim.state({ id: serverTask.id }));
111
+ export function RecordRow({ record: serverTask }: Props) {
112
+ const data = useAblo((ablo) => ablo.records.local.get(serverTask.id)) ?? serverTask;
113
+ const holder = useAblo((ablo) => ablo.records.claim.state({ id: serverTask.id }));
114
114
  const agentActive = holder?.participantKind === 'agent';
115
115
 
116
116
  return (
@@ -27,7 +27,7 @@ import { z } from 'zod';
27
27
  export const runtime = 'nodejs';
28
28
 
29
29
  const schema = defineSchema({
30
- tasks: model({
30
+ records: model({
31
31
  title: schemaZ.string(),
32
32
  status: schemaZ.enum(['todo', 'doing', 'done']),
33
33
  summary: schemaZ.string().optional(),
@@ -40,15 +40,15 @@ const ablo = Ablo({
40
40
  transport: 'http',
41
41
  });
42
42
 
43
- const updateTask = updateTool(ablo.tasks, {
44
- title: 'Update task',
45
- description: 'Update a task without overwriting concurrent work.',
43
+ const updateTask = updateTool(ablo.records, {
44
+ title: 'Update record',
45
+ description: 'Update a record without overwriting concurrent work.',
46
46
  inputSchema: z.object({
47
- taskId: z.string(),
47
+ recordId: z.string(),
48
48
  status: z.enum(['todo', 'doing', 'done']).optional(),
49
49
  summary: z.string().optional(),
50
50
  }),
51
- id: ({ taskId }) => taskId,
51
+ id: ({ recordId }) => recordId,
52
52
  apply: (current, { status, summary }) => ({
53
53
  status: status ?? current.status,
54
54
  summary: summary ?? current.summary,
@@ -95,7 +95,7 @@ export async function POST() {
95
95
  const userId = await currentUserId(); // your auth
96
96
  const { token, expiresAt } = await ablo.sessions.create({
97
97
  user: { id: userId },
98
- can: { tasks: ['read', 'update'] },
98
+ can: { records: ['read', 'update'] },
99
99
  });
100
100
  return Response.json(
101
101
  credentialEndpointSuccessSchema.parse({
@@ -21,11 +21,11 @@ app/
21
21
  api/
22
22
  ablo-session/
23
23
  route.ts # mints a per-user ek_ token for the browser
24
- tasks/
24
+ records/
25
25
  [id]/
26
26
  page.tsx # RSC: get + render
27
27
  actions.ts # Server Action: claim, then write
28
- TaskEditor.tsx # Client: live updates
28
+ RecordEditor.tsx # Client: live updates
29
29
  lib/
30
30
  ablo.ts # Server Ablo client (holds ABLO_API_KEY)
31
31
  ablo.schema.ts # shared schema
@@ -82,7 +82,7 @@ export async function POST() {
82
82
 
83
83
  const { token, expiresAt } = await ablo.sessions.create({
84
84
  user: { id: user.id },
85
- can: { tasks: ['read', 'create', 'update'] },
85
+ can: { records: ['read', 'create', 'update'] },
86
86
  });
87
87
  return Response.json(
88
88
  credentialEndpointSuccessSchema.parse({
@@ -137,40 +137,40 @@ export default function RootLayout({ children }: { children: React.ReactNode })
137
137
  ## RSC Initial Render
138
138
 
139
139
  ```tsx
140
- // app/tasks/[id]/page.tsx
140
+ // app/records/[id]/page.tsx
141
141
  import { ablo } from '@/lib/ablo';
142
142
 
143
- export default async function TaskPage({
143
+ export default async function RecordPage({
144
144
  params,
145
145
  }: { params: Promise<{ id: string }> }) {
146
146
  const { id } = await params;
147
147
  await ablo.ready();
148
- const task = await ablo.tasks.get({ id });
149
- if (!task) return null;
148
+ const record = await ablo.records.get({ id });
149
+ if (!record) return null;
150
150
 
151
- return <TaskEditor task={task} />;
151
+ return <RecordEditor record={record} />;
152
152
  }
153
153
  ```
154
154
 
155
155
  ## Server Action Commit
156
156
 
157
157
  ```ts
158
- // app/tasks/[id]/actions.ts
158
+ // app/records/[id]/actions.ts
159
159
  'use server';
160
160
 
161
161
  import { ablo } from '@/lib/ablo';
162
162
 
163
163
  export async function markDone(id: string) {
164
164
  // Claim grants exclusive, ordered access and hands back the fresh row.
165
- await using claim = await ablo.tasks.claim({ id });
165
+ await using claim = await ablo.records.claim({ id });
166
166
 
167
- const task = await ablo.tasks.update({
167
+ const record = await ablo.records.update({
168
168
  id,
169
169
  data: { status: 'done' },
170
170
  claim,
171
171
  });
172
172
 
173
- return { status: 'done', task };
173
+ return { status: 'done', record };
174
174
  // claim auto-releases as the action returns
175
175
  }
176
176
  ```
@@ -186,9 +186,9 @@ you — re-fetch and retry.
186
186
 
187
187
  import { useAblo } from '@abloatai/ablo/react';
188
188
 
189
- export function TaskEditor({ task: serverTask }: Props) {
190
- const data = useAblo((ablo) => ablo.tasks.local.get(serverTask.id)) ?? serverTask;
191
- const holder = useAblo((ablo) => ablo.tasks.claim.state({ id: serverTask.id }));
189
+ export function RecordEditor({ record: serverTask }: Props) {
190
+ const data = useAblo((ablo) => ablo.records.local.get(serverTask.id)) ?? serverTask;
191
+ const holder = useAblo((ablo) => ablo.records.claim.state({ id: serverTask.id }));
192
192
  const busy = Boolean(holder);
193
193
 
194
194
  return (
@@ -5,8 +5,8 @@
5
5
  You want an agent that edits **one workspace** and pushes realtime updates to the
6
6
  participants on **that workspace only** — not a broadcast to the whole org. The
7
7
  catch most people hit: which write reaches whom is decided by how the rows
8
- *relate*, not by which columns the write touched. So a task edit that never sets
9
- `workspaceId` still reaches everyone watching the workspace, because the task already
8
+ *relate*, not by which columns the write touched. So a record edit that never sets
9
+ `workspaceId` still reaches everyone watching the workspace, because the record already
10
10
  belongs to it. You get this by declaring the relationship once, then narrowing the
11
11
  agent to the workspace id — you never assemble a `workspace:<id>` audience string by
12
12
  hand.
@@ -27,9 +27,9 @@ export const schema = defineSchema(
27
27
  { title: z.string() },
28
28
  { groups: { root: 'workspace' } },
29
29
  ),
30
- // A task has no group of its own. It inherits its workspace's group via the
31
- // `parent` edge, so a task write reaches everyone watching the workspace.
32
- tasks: model(
30
+ // A record has no group of its own. It inherits its workspace's group via the
31
+ // `parent` edge, so a record write reaches everyone watching the workspace.
32
+ records: model(
33
33
  { workspaceId: z.string(), title: z.string() },
34
34
  { relations: { workspace: relation.belongsTo('workspaces', 'workspaceId', { parent: true }) } },
35
35
  ),
@@ -67,7 +67,7 @@ const server = Ablo({ schema, apiKey: process.env.ABLO_API_KEY });
67
67
  export async function mintProjectAgentSession(workspaceId: string, agentId: string) {
68
68
  const { token } = await server.sessions.create({
69
69
  agent: { id: agentId },
70
- can: { tasks: ['read', 'update'] }, // operation allowlist for this run
70
+ can: { records: ['read', 'update'] }, // operation allowlist for this run
71
71
  syncGroups: [syncGroup('workspace', workspaceId)], // narrowed to just this workspace
72
72
  });
73
73
  return token;
@@ -106,13 +106,13 @@ const ablo = useAblo();
106
106
  // Other participants subscribed to workspace:<workspaceId> — a reviewer agent, a
107
107
  // person watching in the UI — receive this delta in realtime. Participants on
108
108
  // other workspaces never see it.
109
- await ablo.tasks.update({ id: taskId, data: { title: 'Ship the Q4 report' } });
109
+ await ablo.records.update({ id: recordId, data: { title: 'Ship the Q4 report' } });
110
110
  ```
111
111
 
112
- The task's delta is stamped `workspace:<workspaceId>`, derived server-side from the
113
- task → workspace `parent` edge — not from `workspaceId` appearing in this particular
112
+ The record's delta is stamped `workspace:<workspaceId>`, derived server-side from the
113
+ record → workspace `parent` edge — not from `workspaceId` appearing in this particular
114
114
  write, and not from whatever the agent happened to subscribe to. The routing is
115
- decided by the data: a task belongs to its workspace, so its writes go to the
115
+ decided by the data: a record belongs to its workspace, so its writes go to the
116
116
  workspace's group, full stop.
117
117
 
118
118
  ## See also
@@ -2,13 +2,13 @@
2
2
 
3
3
  > A stateless schema-backed worker: wake, claim, commit, go idle.
4
4
 
5
- A server agent is backend code — a cron job, a queue worker, an AI task — that
5
+ A server agent is backend code — a cron job, a queue worker, an AI record — that
6
6
  reads and writes your app's records outside the browser. The hard part is doing
7
- it without racing whatever else is working: if two workers pick up the same task
7
+ it without racing whatever else is working: if two workers pick up the same record
8
8
  at once, one write clobbers the other. This is what `claim()` is for.
9
9
 
10
10
  Agents hold no socket, so pass `transport: 'http'` and import the same schema the
11
- rest of the app uses. Below, a worker finishes a task by claiming it, writing the
11
+ rest of the app uses. Below, a worker finishes a record by claiming it, writing the
12
12
  result, and releasing it automatically when the claim goes out of scope.
13
13
 
14
14
  `claim({ id })` takes the record for your worker and returns a disposable handle:
@@ -22,7 +22,7 @@ import Ablo from '@abloatai/ablo';
22
22
  import { defineSchema, model, z } from '@abloatai/ablo/schema';
23
23
 
24
24
  const schema = defineSchema({
25
- tasks: model({
25
+ records: model({
26
26
  title: z.string(),
27
27
  status: z.enum(['todo', 'doing', 'done']),
28
28
  summary: z.string().optional(),
@@ -37,27 +37,27 @@ const control = Ablo({
37
37
  async function clientForWorker(workerId: string) {
38
38
  const { token } = await control.sessions.create({
39
39
  agent: { id: workerId },
40
- can: { tasks: ['read', 'update'] },
40
+ can: { records: ['read', 'update'] },
41
41
  });
42
42
  return Ablo({ schema, apiKey: token, transport: 'http' });
43
43
  }
44
44
 
45
- export async function completeTask(taskId: string, workerId: string) {
45
+ export async function completeTask(recordId: string, workerId: string) {
46
46
  // Participant identity comes from this worker-specific session. Two clients
47
47
  // made directly from the same root key are re-entrant, not contenders.
48
48
  const ablo = await clientForWorker(workerId);
49
49
  await ablo.ready();
50
50
 
51
- const task = await ablo.tasks.get({ id: taskId });
52
- if (!task) return { status: 'not_found' };
51
+ const record = await ablo.records.get({ id: recordId });
52
+ if (!record) return { status: 'not_found' };
53
53
 
54
- const acquired = await ablo.tasks.claim({
55
- id: taskId,
54
+ const acquired = await ablo.records.claim({
55
+ id: recordId,
56
56
  contention: {
57
57
  mode: 'skip',
58
58
  onStatus(event) {
59
59
  if (event.type === 'skipped') {
60
- console.info('task already owned', event.error.code);
60
+ console.info('record already owned', event.error.code);
61
61
  }
62
62
  },
63
63
  },
@@ -66,12 +66,12 @@ export async function completeTask(taskId: string, workerId: string) {
66
66
  if (!acquired) return { status: 'already_claimed' };
67
67
 
68
68
  await using claim = acquired;
69
- const updated = await ablo.tasks.update({
69
+ const updated = await ablo.records.update({
70
70
  id: claim.data.id,
71
71
  data: { status: 'done' },
72
72
  });
73
73
 
74
- return { status: 'done', task: updated };
74
+ return { status: 'done', record: updated };
75
75
  // claim auto-releases as the function returns
76
76
  }
77
77
  ```
@@ -97,7 +97,7 @@ schema-backed client:
97
97
  ```ts
98
98
  await ablo.commits.create({
99
99
  operations: [
100
- { action: 'update', model: 'tasks', id: 'task_123', data: { status: 'done' } },
100
+ { action: 'update', model: 'records', id: 'record_123', data: { status: 'done' } },
101
101
  ],
102
102
  wait: 'confirmed',
103
103
  });