@abloatai/ablo 0.43.0 → 0.45.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.
- package/AGENTS.md +19 -0
- package/CHANGELOG.md +58 -0
- package/docs/agents.md +3 -3
- package/docs/client-behavior.md +4 -2
- package/docs/coordination.md +73 -12
- package/docs/debugging.md +71 -6
- package/docs/deployment.md +2 -2
- package/docs/examples/agent-human.md +8 -6
- package/docs/examples/server-agent.md +25 -6
- package/docs/quickstart.md +3 -2
- package/docs/session-settings.md +8 -0
- package/package.json +3 -3
package/AGENTS.md
CHANGED
|
@@ -87,4 +87,23 @@ Claims live on a callable namespace beside `create` / `update` / `retrieve`. Eve
|
|
|
87
87
|
- `ablo.<model>.claim.release({ id })` — release a claim early.
|
|
88
88
|
- `ablo.<model>.claim.reorder({ id, order })` — reorder the waiting queue.
|
|
89
89
|
|
|
90
|
+
Keep admission behavior together for anything beyond the default wait:
|
|
91
|
+
|
|
92
|
+
```ts
|
|
93
|
+
const claim = await ablo.tasks.claim({
|
|
94
|
+
id,
|
|
95
|
+
contention: {
|
|
96
|
+
mode: 'skip', // use 'wait' with maxDepth / timeoutMs when waiting is useful
|
|
97
|
+
onStatus(event) {
|
|
98
|
+
if (event.type === 'skipped') console.warn(event.error.message);
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
});
|
|
102
|
+
if (!claim) return; // another participant already owns the work
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
`onStatus` receives typed `queued`, `granted`, `skipped`, and `failed` events.
|
|
106
|
+
It is request-scoped; use `claim.state` / `claim.queue` for the shared reactive
|
|
107
|
+
view.
|
|
108
|
+
|
|
90
109
|
Most users declare a schema and write through `ablo.<model>.update({ id, data })`.
|
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,63 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.45.0
|
|
4
|
+
|
|
5
|
+
### Claim admission is authoritative
|
|
6
|
+
|
|
7
|
+
A claim used to return a local handle immediately, before the server had
|
|
8
|
+
granted anything, so two agents on different server instances could each
|
|
9
|
+
believe they held the same row. Every claim now waits for the server's grant
|
|
10
|
+
or rejection, and the server fails closed when the shared lease store is
|
|
11
|
+
unavailable rather than admitting claims it cannot coordinate. A two-server
|
|
12
|
+
end-to-end regression pins the behavior.
|
|
13
|
+
|
|
14
|
+
### Contention has a lifecycle you can watch
|
|
15
|
+
|
|
16
|
+
The new `contention` option names what happens when the row is already held:
|
|
17
|
+
|
|
18
|
+
```ts
|
|
19
|
+
const claim = await ablo.tasks.claim({
|
|
20
|
+
id,
|
|
21
|
+
contention: {
|
|
22
|
+
mode: 'skip',
|
|
23
|
+
onStatus(event) {
|
|
24
|
+
// queued | granted | skipped | failed
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
});
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
`{ mode: 'skip' }` resolves `null` instead of waiting, and `onStatus` reports
|
|
31
|
+
the attempt as it moves. `queue: true` and `queue: false` remain as
|
|
32
|
+
compatible spellings of the two modes.
|
|
33
|
+
|
|
34
|
+
## 0.44.0
|
|
35
|
+
|
|
36
|
+
### A scope denial names the wall it hit
|
|
37
|
+
|
|
38
|
+
`capability_scope_denied` now distinguishes the Ablo capability allowlist
|
|
39
|
+
from the customer database's row-level security. The error carries the
|
|
40
|
+
required capability, the resolved operations, the participant and user
|
|
41
|
+
principal, the branch, the organization and project, and any applied session
|
|
42
|
+
settings, so "permission denied" is a diagnosis instead of a dead end: you
|
|
43
|
+
can see whether your grant was missing a verb or whether your own database's
|
|
44
|
+
row policy rejected the session context Ablo applied.
|
|
45
|
+
|
|
46
|
+
### Write failures carry their request id
|
|
47
|
+
|
|
48
|
+
A WebSocket write failure now carries the `requestId` the server logged it
|
|
49
|
+
under, and a `wait: 'confirmed'` write rejects with the complete typed error
|
|
50
|
+
rather than a bare failure, so the error you catch is the error the server
|
|
51
|
+
recorded.
|
|
52
|
+
|
|
53
|
+
### `doctor` reports readiness, not destiny
|
|
54
|
+
|
|
55
|
+
`doctor` now says infrastructure is ready rather than promising a write will
|
|
56
|
+
succeed, because database constraints and row-level security still apply at
|
|
57
|
+
write time. The debugging guide explains how to read the new diagnostics,
|
|
58
|
+
and documents that `list()` may answer from the local pool while
|
|
59
|
+
`list({ type: 'complete' })` waits for the server round trip.
|
|
60
|
+
|
|
3
61
|
## 0.43.0
|
|
4
62
|
|
|
5
63
|
### Keys are branch-first
|
package/docs/agents.md
CHANGED
|
@@ -116,9 +116,9 @@ await ablo.tasks.claim.queue({ id: taskId }); // the FIFO wait-line behind the
|
|
|
116
116
|
await ablo.tasks.claim.reorder({ id: taskId, order: line }); // re-rank the line (privileged)
|
|
117
117
|
```
|
|
118
118
|
|
|
119
|
-
Think of it as a queue per row — a durable, inspectable, reorderable lease
|
|
120
|
-
|
|
121
|
-
|
|
119
|
+
Think of it as a queue per row — a durable, inspectable, reorderable lease
|
|
120
|
+
line. Use `contention: { mode: 'skip' }` for fail-fast dedup: *if someone else
|
|
121
|
+
has this job, skip it.*
|
|
122
122
|
|
|
123
123
|
## Messaging between agents
|
|
124
124
|
|
package/docs/client-behavior.md
CHANGED
|
@@ -152,7 +152,9 @@ the latest row, then hands you the fresh row — so you can't overwrite a change
|
|
|
152
152
|
see. Options on the claim:
|
|
153
153
|
|
|
154
154
|
- default `claim` waits in the fair queue and re-reads before handing you the row;
|
|
155
|
-
- `{ queue: false }`
|
|
155
|
+
- `{ queue: false }` resolves `null` when another participant already holds the
|
|
156
|
+
target; two clients with the same participant identity are re-entrant, not
|
|
157
|
+
contenders;
|
|
156
158
|
- `{ maxQueueDepth }` rejects if the wait line is already too deep.
|
|
157
159
|
|
|
158
160
|
While waiting, schema clients learn when the claim clears from the live claim
|
|
@@ -172,7 +174,7 @@ All SDK errors extend `AbloError` and carry a stable `type`.
|
|
|
172
174
|
| `AbloValidationError` | Invalid input or unsupported request shape. |
|
|
173
175
|
| `AbloServerError` | Server-side 5xx. Retry with backoff if the operation is idempotent. |
|
|
174
176
|
| `AbloStaleContextError` | Write was based on stale `readAt` state. Re-read and retry. |
|
|
175
|
-
| `AbloClaimedError` |
|
|
177
|
+
| `AbloClaimedError` | A write conflicted with another participant's active claim, the queue was too deep, or a claim wait timed out. |
|
|
176
178
|
|
|
177
179
|
```ts
|
|
178
180
|
import { AbloClaimedError } from '@abloatai/ablo';
|
package/docs/coordination.md
CHANGED
|
@@ -57,7 +57,7 @@ See [claiming part of a row](#claiming-part-of-a-row).
|
|
|
57
57
|
> from outside with `signal` (an `AbortSignal`; rejects
|
|
58
58
|
> `claim_wait_aborted`), bound the line you'll join with `maxQueueDepth`
|
|
59
59
|
> (`queue_too_deep`), or skip waiting entirely with `queue: false` — the
|
|
60
|
-
> try-claim, which resolves `null` when the target is held (
|
|
60
|
+
> try-claim, which resolves `null` when the target is held (skipped work
|
|
61
61
|
> is not an error) and takes no place in line. For
|
|
62
62
|
> callers that manage the wait themselves, the ticket surface remains:
|
|
63
63
|
> `ablo.claims.get({ claimId })` polls a ticket to its grant,
|
|
@@ -94,6 +94,13 @@ claim](#writing-under-a-claim)), and the [errors](#errors) you can catch.
|
|
|
94
94
|
>
|
|
95
95
|
> Now `agent-0` holds while `agent-1`/`agent-2` queue in FIFO order and drain in
|
|
96
96
|
> turn. See [sessions](./sessions.md#agent-sessions-rk_) for the minting flow.
|
|
97
|
+
>
|
|
98
|
+
> **Testing exclusion:** mint two sessions with different `agent.id` values,
|
|
99
|
+
> assert those values differ, let A acquire the row, and inspect
|
|
100
|
+
> `claim.state({ id })` before B calls `claim({ id, queue: false })`. B must
|
|
101
|
+
> receive `null`. Creating two clients from the same key tests re-entrancy, not
|
|
102
|
+
> contention. An empty `claim.state` is an observation/subscription issue; it
|
|
103
|
+
> does not relax the authoritative lease check.
|
|
97
104
|
|
|
98
105
|
---
|
|
99
106
|
|
|
@@ -317,14 +324,15 @@ four axes. `claim({ id })` alone is a complete call; each axis is opt-in.
|
|
|
317
324
|
| `options.description` | `string` | no | Peer-visible description of the work, shown to observers (default `'editing'`). |
|
|
318
325
|
| `options.meta` | `object` | no | App-defined structured metadata, carried verbatim to every participant observing the claim. Declare its shape once on `Register`'s `ClaimMeta` slot. |
|
|
319
326
|
|
|
320
|
-
*How you
|
|
327
|
+
*How you handle contention*:
|
|
321
328
|
|
|
322
329
|
| name | type | required | description |
|
|
323
330
|
|---|---|---|---|
|
|
324
|
-
| `options.
|
|
325
|
-
| `options.
|
|
326
|
-
| `options.
|
|
327
|
-
| `options.
|
|
331
|
+
| `options.contention` | `Claim.ContentionOptions` | no | Keeps the response to a busy target together: `{ mode: 'wait', maxDepth?, timeoutMs?, signal?, onStatus? }`. Use `mode: 'skip'` for claim-or-skip dedup; a foreign holder resolves the claim as `null`. |
|
|
332
|
+
| `options.queue` | `boolean` | no | Compatibility shorthand: `true` waits and `false` skips. Prefer `contention` when configuring more than the mode. |
|
|
333
|
+
| `options.maxQueueDepth` | `number` | no | Legacy flat spelling of `contention.maxDepth`. Prefer `contention` for new code. |
|
|
334
|
+
| `options.waitTimeoutMs` | `number` | no | Legacy flat spelling of `contention.timeoutMs`. Prefer `contention` for new code. |
|
|
335
|
+
| `options.signal` | `AbortSignal` | no | Legacy flat spelling of `contention.signal`. Prefer `contention` for new code. |
|
|
328
336
|
|
|
329
337
|
*How long you hold* — the lease:
|
|
330
338
|
|
|
@@ -333,6 +341,53 @@ four axes. `claim({ id })` alone is a complete call; each axis is opt-in.
|
|
|
333
341
|
| `options.ttl` | `Duration` | no | Crash-cleanup floor. Rarely set: the lease renews while your connection is alive, so it only matters once you go silent. |
|
|
334
342
|
| `options.heartbeat` | `true \| Duration \| { every?, onBeat?, onLost? }` | no | Keep the lease alive for work that outlives the TTL: `true` beats every third of the TTL, a duration sets the cadence, and the structured form carries the cadence and both callbacks in one place: `onBeat` fires after every successful beat (chiefly `queueDepth`, the pressure signal), `onLost` once if a beat learns the lease is gone. The loop stops on release. |
|
|
335
343
|
|
|
344
|
+
The request-scoped `onStatus` callback receives one discriminated event. It is
|
|
345
|
+
observational: an exception in UI or telemetry code never changes the claim
|
|
346
|
+
attempt.
|
|
347
|
+
|
|
348
|
+
| event | meaning | claim promise |
|
|
349
|
+
|---|---|---|
|
|
350
|
+
| `queued` | this request joined the wait line | remains pending |
|
|
351
|
+
| `granted` | this request owns the lease | resolves with the claim |
|
|
352
|
+
| `skipped` | `mode: 'skip'` found another participant holding the target | resolves `null` |
|
|
353
|
+
| `failed` | the attempt could not complete, for example timeout, cancellation, authorization, or connectivity | rejects with `event.error` |
|
|
354
|
+
|
|
355
|
+
```ts
|
|
356
|
+
const claim = await ablo.tasks.claim({
|
|
357
|
+
id,
|
|
358
|
+
contention: {
|
|
359
|
+
mode: 'wait',
|
|
360
|
+
maxDepth: 3,
|
|
361
|
+
timeoutMs: 30_000,
|
|
362
|
+
onStatus(event) {
|
|
363
|
+
if (event.type === 'queued') {
|
|
364
|
+
console.log(`${event.ahead} participant(s) ahead`);
|
|
365
|
+
} else if (event.type === 'granted') {
|
|
366
|
+
console.log(event.waited ? 'your turn' : 'granted immediately');
|
|
367
|
+
} else {
|
|
368
|
+
console.warn(event.error.code, event.error.message);
|
|
369
|
+
}
|
|
370
|
+
},
|
|
371
|
+
},
|
|
372
|
+
});
|
|
373
|
+
```
|
|
374
|
+
|
|
375
|
+
For claim-or-skip work, make the caller's intent explicit and keep its status
|
|
376
|
+
observer beside the decision:
|
|
377
|
+
|
|
378
|
+
```ts
|
|
379
|
+
const claim = await ablo.tasks.claim({
|
|
380
|
+
id,
|
|
381
|
+
contention: {
|
|
382
|
+
mode: 'skip',
|
|
383
|
+
onStatus: (event) => {
|
|
384
|
+
if (event.type === 'skipped') metrics.increment('claim.skipped');
|
|
385
|
+
},
|
|
386
|
+
},
|
|
387
|
+
});
|
|
388
|
+
if (!claim) return;
|
|
389
|
+
```
|
|
390
|
+
|
|
336
391
|
The high-level `claim` queues by default, so on contention you either get the row
|
|
337
392
|
when your turn arrives or one of the [queue errors](#errors) (`claim_lost`,
|
|
338
393
|
`grant_timeout`).
|
|
@@ -552,16 +607,22 @@ decide the wait isn't worth it.
|
|
|
552
607
|
|---|---|---|---|
|
|
553
608
|
| `id` | `string` | yes | The row id. |
|
|
554
609
|
|
|
555
|
-
**Returns** — a
|
|
556
|
-
|
|
557
|
-
|
|
610
|
+
**Returns** — a structured queue snapshot:
|
|
611
|
+
|
|
612
|
+
- `waiting` — queued [claim state objects](#the-claim-state-object) in
|
|
613
|
+
promotion order, excluding the active holder;
|
|
614
|
+
- `next` — the first waiter, or `null`;
|
|
615
|
+
- `size` — how many participants are waiting;
|
|
616
|
+
- `data` — the same array as `waiting`, retained as the standard list-envelope
|
|
617
|
+
member for compatibility.
|
|
558
618
|
|
|
559
619
|
**Example**
|
|
560
620
|
|
|
561
621
|
```ts
|
|
562
|
-
const
|
|
563
|
-
console.log(`${
|
|
564
|
-
console.log(
|
|
622
|
+
const line = ablo.weatherReports.claim.queue({ id: 'report_stockholm' });
|
|
623
|
+
console.log(`${line.size} waiting`);
|
|
624
|
+
console.log(`next: ${line.next?.heldBy ?? 'nobody'}`);
|
|
625
|
+
console.log(line.waiting.map((claim) => claim.heldBy));
|
|
565
626
|
```
|
|
566
627
|
|
|
567
628
|
### `claim.release`
|
package/docs/debugging.md
CHANGED
|
@@ -15,12 +15,13 @@ const ablo = Ablo({ schema, apiKey: process.env.ABLO_API_KEY, debug: true });
|
|
|
15
15
|
|
|
16
16
|
## CLI environment and target
|
|
17
17
|
|
|
18
|
-
`
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
18
|
+
Read-only diagnostics (`status`, `whoami`, `logs`, and `connect locate/check`)
|
|
19
|
+
may inspect the application-facing chain: exported `ABLO_API_KEY`,
|
|
20
|
+
`.env.local`, `.env`, then the stored credential. An exported value wins over
|
|
21
|
+
project files. Mutations (`push` and `connect apply/rotate/register/deregister`)
|
|
22
|
+
are intentionally stricter: they read the process environment, an explicit
|
|
23
|
+
`--env-file`, or a stored compatibility credential. An ambient file cannot
|
|
24
|
+
silently choose the branch a mutation acts on.
|
|
24
25
|
|
|
25
26
|
Use the two diagnostics according to the question:
|
|
26
27
|
|
|
@@ -232,3 +233,67 @@ AbloValidationError [model_required_field_missing]: A required field was absent.
|
|
|
232
233
|
```
|
|
233
234
|
|
|
234
235
|
Branch on `err.code` (stable) — never on the message (rewordable). See [Client Behavior](./client-behavior.md) for the full error model and which codes are safe to retry.
|
|
236
|
+
|
|
237
|
+
### Diagnosing `capability_scope_denied`
|
|
238
|
+
|
|
239
|
+
The same stable code covers two different enforcement layers, so inspect
|
|
240
|
+
`error.details.origin`:
|
|
241
|
+
|
|
242
|
+
- `capability_allowlist`: the branch/session credential did not grant the
|
|
243
|
+
operation. `requiredCapability.scope` names the missing `model.verb`, and
|
|
244
|
+
`details.resolvedOperations` shows the grants the server actually resolved.
|
|
245
|
+
- `database_row_level_security`: Ablo's capability gate allowed the operation,
|
|
246
|
+
but Postgres rejected it under the customer table's RLS policy.
|
|
247
|
+
`details.databaseSessionContext` shows the organization, project, branch,
|
|
248
|
+
participant kind, user principal, and the complete built-in-plus-custom
|
|
249
|
+
session-setting values configured for that transaction.
|
|
250
|
+
`customSessionSettings` isolates only the mappings declared by the schema;
|
|
251
|
+
an empty object there does not mean built-in settings such as
|
|
252
|
+
`app.current_org_id` were absent.
|
|
253
|
+
|
|
254
|
+
Do not respond by changing a tenant policy to `USING (true)` or granting
|
|
255
|
+
`BYPASSRLS`. Compare the row's tenant value with
|
|
256
|
+
`databaseSessionContext.sessionSettings.app.current_org_id`. On CREATE, Ablo
|
|
257
|
+
server-stamps the authenticated organization into the model's row-local tenancy
|
|
258
|
+
field; a missing value is a server/version fault to report with `requestId`, not
|
|
259
|
+
a requirement to make the column nullable or open the policy.
|
|
260
|
+
|
|
261
|
+
Every rejected live commit carries `requestId` on the thrown error and
|
|
262
|
+
`request_id` in its JSON form and warning line:
|
|
263
|
+
|
|
264
|
+
```ts
|
|
265
|
+
import { AbloError } from '@abloatai/ablo';
|
|
266
|
+
|
|
267
|
+
try {
|
|
268
|
+
await ablo.documents.create({
|
|
269
|
+
data,
|
|
270
|
+
wait: 'confirmed',
|
|
271
|
+
});
|
|
272
|
+
} catch (error) {
|
|
273
|
+
if (error instanceof AbloError) {
|
|
274
|
+
console.error(error.code, error.requestId, error.requiredCapability, error.details);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
With `wait: 'confirmed'`, the awaited call rejects with that complete typed
|
|
280
|
+
error. `onMutationFailure` remains the notification channel for optimistic
|
|
281
|
+
writes that return before the server answers; it is not required to recover
|
|
282
|
+
details from a confirmed write.
|
|
283
|
+
|
|
284
|
+
### Local reads versus a confirmed server read
|
|
285
|
+
|
|
286
|
+
`list()` without a completeness option may return the current local pool
|
|
287
|
+
immediately. That is why it can be empty while Postgres contains rows: it is not
|
|
288
|
+
evidence that the replication source has no history.
|
|
289
|
+
|
|
290
|
+
Use:
|
|
291
|
+
|
|
292
|
+
```ts
|
|
293
|
+
await ablo.documents.list({ type: 'complete' });
|
|
294
|
+
```
|
|
295
|
+
|
|
296
|
+
`type: 'complete'` waits for a server round trip and returns the confirmed
|
|
297
|
+
result. `type: 'unknown'` returns the local result immediately and refreshes it
|
|
298
|
+
in the background. The distinction is freshness/completeness, not claimed
|
|
299
|
+
versus unclaimed data.
|
package/docs/deployment.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
> What production takes: a database Ablo can reach, a key minted for the plane you mean, and a schema push in the deploy.
|
|
4
4
|
|
|
5
|
-
One command answers the
|
|
5
|
+
One command answers whether the infrastructure needed for a write is ready
|
|
6
6
|
right now, and if not, why:
|
|
7
7
|
|
|
8
8
|
```bash
|
|
@@ -25,7 +25,7 @@ ABLO_API_KEY=sk_… npx ablo status
|
|
|
25
25
|
• fulfilments typename=fulfilments
|
|
26
26
|
• reviews typename=reviews
|
|
27
27
|
|
|
28
|
-
✓ ready —
|
|
28
|
+
✓ write infrastructure is ready — database constraints and row-level policies still apply
|
|
29
29
|
```
|
|
30
30
|
|
|
31
31
|
`status` asks the routing authority rather than sampling a read, because reads
|
|
@@ -54,15 +54,17 @@ export async function markDone(taskId: string) {
|
|
|
54
54
|
if (!task) return { status: 'not_found' };
|
|
55
55
|
|
|
56
56
|
try {
|
|
57
|
-
// queue: false → don't queue behind a current holder. If
|
|
58
|
-
// holds the row, claim
|
|
59
|
-
//
|
|
60
|
-
|
|
61
|
-
await using claim = await ablo.tasks.claim({
|
|
57
|
+
// queue: false → don't queue behind a current holder. If another
|
|
58
|
+
// participant holds the row, claim resolves null, so the agent yields
|
|
59
|
+
// instead of waiting. Omit it, or pass queue: true, to queue behind them.
|
|
60
|
+
const acquired = await ablo.tasks.claim({
|
|
62
61
|
id: taskId,
|
|
63
62
|
queue: false,
|
|
64
63
|
description: 'marking_done',
|
|
65
64
|
});
|
|
65
|
+
if (!acquired) return { status: 'yielded' };
|
|
66
|
+
|
|
67
|
+
await using claim = acquired;
|
|
66
68
|
if (claim.data.status === 'done') return { status: 'noop' };
|
|
67
69
|
|
|
68
70
|
// Inside an active claim, `update` is stale-checked automatically: the SDK
|
|
@@ -89,7 +91,7 @@ export async function markDone(taskId: string) {
|
|
|
89
91
|
|
|
90
92
|
return { status: 'done', task: updated };
|
|
91
93
|
} catch (err) {
|
|
92
|
-
//
|
|
94
|
+
// The lease was lost or a foreign holder rejected the write.
|
|
93
95
|
if (err instanceof AbloClaimedError) return { status: 'yielded' };
|
|
94
96
|
// A newer version was saved while we held the claim. The stale-check
|
|
95
97
|
// rejected our commit, so nothing was overwritten — re-run on fresh data.
|
|
@@ -29,24 +29,43 @@ const schema = defineSchema({
|
|
|
29
29
|
}),
|
|
30
30
|
});
|
|
31
31
|
|
|
32
|
-
const
|
|
32
|
+
const control = Ablo({
|
|
33
33
|
schema,
|
|
34
34
|
apiKey: process.env.ABLO_API_KEY,
|
|
35
|
-
transport: 'http',
|
|
36
35
|
});
|
|
37
36
|
|
|
38
|
-
|
|
37
|
+
async function clientForWorker(workerId: string) {
|
|
38
|
+
const { token } = await control.sessions.create({
|
|
39
|
+
agent: { id: workerId },
|
|
40
|
+
can: { tasks: ['read', 'update'] },
|
|
41
|
+
});
|
|
42
|
+
return Ablo({ schema, apiKey: token, transport: 'http' });
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export async function completeTask(taskId: string, workerId: string) {
|
|
46
|
+
// Participant identity comes from this worker-specific session. Two clients
|
|
47
|
+
// made directly from the same root key are re-entrant, not contenders.
|
|
48
|
+
const ablo = await clientForWorker(workerId);
|
|
39
49
|
await ablo.ready();
|
|
40
50
|
|
|
41
51
|
const task = await ablo.tasks.get({ id: taskId });
|
|
42
52
|
if (!task) return { status: 'not_found' };
|
|
43
53
|
|
|
44
|
-
|
|
54
|
+
const acquired = await ablo.tasks.claim({
|
|
45
55
|
id: taskId,
|
|
46
|
-
|
|
56
|
+
contention: {
|
|
57
|
+
mode: 'skip',
|
|
58
|
+
onStatus(event) {
|
|
59
|
+
if (event.type === 'skipped') {
|
|
60
|
+
console.info('task already owned', event.error.code);
|
|
61
|
+
}
|
|
62
|
+
},
|
|
63
|
+
},
|
|
47
64
|
description: 'completing',
|
|
48
65
|
});
|
|
66
|
+
if (!acquired) return { status: 'already_claimed' };
|
|
49
67
|
|
|
68
|
+
await using claim = acquired;
|
|
50
69
|
const updated = await ablo.tasks.update({
|
|
51
70
|
id: claim.data.id,
|
|
52
71
|
data: { status: 'done' },
|
|
@@ -67,7 +86,7 @@ The two options on the claim:
|
|
|
67
86
|
|
|
68
87
|
- `queue: false` — skip this record if another claim is already in progress,
|
|
69
88
|
rather than queueing behind it. Fail-fast dedup: *if someone else has this job,
|
|
70
|
-
skip it.* (The default queues.)
|
|
89
|
+
skip it.* It resolves `null`; it does not throw. (The default queues.)
|
|
71
90
|
- `description: 'completing'` — a readable label for what your worker is doing,
|
|
72
91
|
visible to anyone reading `claim.state({ id })`.
|
|
73
92
|
|
package/docs/quickstart.md
CHANGED
|
@@ -304,8 +304,9 @@ await using handle = await ablo.weatherReports.claim({ id: 'weather_stockholm' }
|
|
|
304
304
|
await ablo.weatherReports.update({ id: handle.data.id, data: { status: 'ready' } });
|
|
305
305
|
```
|
|
306
306
|
|
|
307
|
-
Use `{
|
|
308
|
-
behind an active holder.
|
|
307
|
+
Use `contention: { mode: 'skip' }` when work should be skipped instead of
|
|
308
|
+
queued behind an active holder. Add `onStatus` inside that object when the
|
|
309
|
+
attempt should also update telemetry or UI.
|
|
309
310
|
|
|
310
311
|
## Next steps
|
|
311
312
|
|
package/docs/session-settings.md
CHANGED
|
@@ -54,6 +54,14 @@ context, whether or not you map anything:
|
|
|
54
54
|
If your policies read these names directly, you need no mapping at all — this
|
|
55
55
|
page is for the case where they read different ones.
|
|
56
56
|
|
|
57
|
+
For a mutable model with row-local tenancy, Ablo also puts the authenticated
|
|
58
|
+
organization into the CREATE operation as `organizationId`; the source adapter
|
|
59
|
+
maps that field to the model's tenancy column (normally `organization_id`).
|
|
60
|
+
Callers do not supply it, and the column does not need a SQL default. A default
|
|
61
|
+
from `app.current_org_id` can be useful as defense in depth, but it is not a
|
|
62
|
+
substitute for the tenant policy and you should never loosen RLS to make an
|
|
63
|
+
Ablo write pass.
|
|
64
|
+
|
|
57
65
|
`app.current_user_id` is worth reading twice, because it has three states rather
|
|
58
66
|
than two. It carries a person's id when a person is behind the write. It carries
|
|
59
67
|
`*` when a backend credential is acting as the organization itself, which is the
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@abloatai/ablo",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.45.0",
|
|
4
4
|
"description": "The public Ablo SDK for coordinated reads, commits, claims, observation, and reactive applications.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -124,8 +124,8 @@
|
|
|
124
124
|
"directory": "packages/ablo"
|
|
125
125
|
},
|
|
126
126
|
"dependencies": {
|
|
127
|
-
"@abloatai/humans": "^0.
|
|
128
|
-
"@abloatai/transaction": "^0.
|
|
127
|
+
"@abloatai/humans": "^0.45.0",
|
|
128
|
+
"@abloatai/transaction": "^0.45.0"
|
|
129
129
|
},
|
|
130
130
|
"peerDependencies": {
|
|
131
131
|
"ai": "^6.0.0 || ^7.0.0",
|