@abloatai/ablo 0.56.0 → 0.58.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 +10 -4
- package/CHANGELOG.md +428 -10
- package/LICENSE +1 -1
- package/NOTICE +3 -3
- package/README.md +2 -1
- package/dist/ai-sdk.d.ts +1 -1
- package/dist/ai-sdk.d.ts.map +1 -1
- package/dist/context/evidence.d.ts +6 -8
- package/dist/context/evidence.d.ts.map +1 -1
- package/dist/context/evidence.js +6 -20
- package/dist/context/evidence.js.map +1 -1
- package/dist/context/index.d.ts +23 -0
- package/dist/context/index.d.ts.map +1 -0
- package/dist/context/index.js +26 -0
- package/dist/context/index.js.map +1 -0
- package/dist/context/onChange.d.ts +9 -0
- package/dist/context/onChange.d.ts.map +1 -0
- package/dist/context/onChange.js +37 -0
- package/dist/context/onChange.js.map +1 -0
- package/dist/source-conformance.d.ts +1 -1
- package/dist/source-conformance.d.ts.map +1 -1
- package/dist/source-conformance.js +1 -1
- package/dist/source-conformance.js.map +1 -1
- package/dist/source-drizzle.d.ts +1 -1
- package/dist/source-drizzle.d.ts.map +1 -1
- package/dist/source-drizzle.js +1 -1
- package/dist/source-drizzle.js.map +1 -1
- package/dist/source-kysely.d.ts +1 -1
- package/dist/source-kysely.d.ts.map +1 -1
- package/dist/source-kysely.js +1 -1
- package/dist/source-kysely.js.map +1 -1
- package/dist/source-next.d.ts +1 -1
- package/dist/source-next.d.ts.map +1 -1
- package/dist/source-next.js +1 -1
- package/dist/source-next.js.map +1 -1
- package/docs/agent-integration-decision-guide.md +123 -0
- package/docs/agents.md +74 -13
- package/docs/api-keys.md +6 -6
- package/docs/api.md +117 -43
- package/docs/branch-development.md +23 -4
- package/docs/cli.md +16 -9
- package/docs/client-behavior.md +21 -15
- package/docs/concurrency-convention.md +67 -77
- package/docs/context.md +56 -31
- package/docs/coordination.md +115 -36
- package/docs/customer-organizations.md +49 -31
- package/docs/data-sources.md +12 -6
- package/docs/debugging.md +1 -1
- package/docs/examples/agent-human.md +6 -18
- package/docs/examples/coordination-conformance.md +69 -0
- package/docs/examples/existing-document-pipeline.md +488 -0
- package/docs/examples/existing-python-backend.md +10 -13
- package/docs/examples/nextjs.md +49 -6
- package/docs/examples/scoped-agent.md +18 -1
- package/docs/examples/server-agent.md +2 -2
- package/docs/groups.md +19 -139
- package/docs/guarantees.md +5 -6
- package/docs/identity.md +2 -1
- package/docs/index.md +5 -0
- package/docs/integration-guide.md +46 -19
- package/docs/integrations/sandbox-runtime.md +148 -0
- package/docs/integrations.md +9 -0
- package/docs/operating-on-your-database.md +7 -0
- package/docs/quickstart.md +19 -13
- package/docs/react.md +9 -9
- package/docs/schema-contract.md +14 -13
- package/docs/session-settings.md +9 -0
- package/docs/sessions.md +1 -1
- package/examples/README.md +2 -2
- package/examples/agent-turn.ts +1 -1
- package/examples/data-source/customer-server.ts +12 -5
- package/examples/expensive-agent-turn.ts +1 -1
- package/llms.txt +72 -10
- package/package.json +6 -6
- package/dist/context/sources.d.ts +0 -21
- package/dist/context/sources.d.ts.map +0 -1
- package/dist/context/sources.js +0 -36
- package/dist/context/sources.js.map +0 -1
- package/dist/context.d.ts +0 -22
- package/dist/context.d.ts.map +0 -1
- package/dist/context.js +0 -33
- package/dist/context.js.map +0 -1
|
@@ -1,59 +1,59 @@
|
|
|
1
1
|
# Concurrency Convention
|
|
2
2
|
|
|
3
|
-
>
|
|
3
|
+
> A write either declares what it read or deliberately does not.
|
|
4
4
|
|
|
5
|
-
Ablo
|
|
6
|
-
|
|
7
|
-
participant does when it hits a conflict, and it is the policy for that model.
|
|
8
|
-
A per-write `onStale` states the disposition for one write. Ablo enforces what
|
|
9
|
-
you declared and nothing else.
|
|
5
|
+
Ablo does not put a configurable stale mode between your code and a
|
|
6
|
+
commit. The public choice is visible at the call site:
|
|
10
7
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
```
|
|
18
|
-
|
|
19
|
-
If no active claim conflicts with it, the write is last-write-wins. That is a
|
|
20
|
-
choice rather than a fallback: use it for independent assignments where the
|
|
21
|
-
latest value should win. When a model's writes are never independent, say so
|
|
22
|
-
once in its `conflict` setting instead of at every call site.
|
|
8
|
+
- `get` and `list` observe state. They do not create a write premise.
|
|
9
|
+
- `read` returns a row that can be passed to a mutation in `reads`.
|
|
10
|
+
- a mutation with `reads` rejects with `AbloStaleContextError` if any declared
|
|
11
|
+
premise changed;
|
|
12
|
+
- a mutation without `reads` is an unconditional, last-write-wins assignment
|
|
13
|
+
when no active claim applies.
|
|
23
14
|
|
|
24
15
|
## Guarded writes
|
|
25
16
|
|
|
26
|
-
|
|
17
|
+
Use `read` for every row that materially influenced a decision, then pass the
|
|
18
|
+
exact returned objects to the mutation:
|
|
27
19
|
|
|
28
20
|
```ts
|
|
29
|
-
const record = await ablo.records.
|
|
30
|
-
const
|
|
31
|
-
if (!record || !
|
|
21
|
+
const record = await ablo.records.read({ id });
|
|
22
|
+
const rules = await ablo.rules.read({ id: rulesId });
|
|
23
|
+
if (!record || !rules) throw new Error('required input is missing');
|
|
32
24
|
|
|
33
25
|
await ablo.records.update({
|
|
34
26
|
id: record.id,
|
|
35
|
-
data:
|
|
36
|
-
reads: [record,
|
|
27
|
+
data: decide(record, rules),
|
|
28
|
+
reads: [record, rules],
|
|
37
29
|
});
|
|
38
30
|
```
|
|
39
31
|
|
|
40
|
-
Ablo
|
|
41
|
-
|
|
42
|
-
|
|
32
|
+
Ablo records only the evidence needed for the check: model, id, and the
|
|
33
|
+
watermark at which the row was read. It does not retain the row's contents.
|
|
34
|
+
The exact object identity matters, so clones, fabricated rows, and rows from a
|
|
35
|
+
different client are rejected locally.
|
|
43
36
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
| `notify` | Keep the current row, return a `StaleNotification`, and let the caller reconcile. |
|
|
48
|
-
| `overwrite` | Apply the new value without enforcing the stale premise. |
|
|
37
|
+
The server validates every declared premise inside the write transaction. If
|
|
38
|
+
one is stale, the entire mutation rejects before any write applies. Re-read,
|
|
39
|
+
recompute, and submit a new mutation when that is the behavior you want.
|
|
49
40
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
`
|
|
41
|
+
## Unguarded writes
|
|
42
|
+
|
|
43
|
+
Use `get` or `list` when you only need to observe, and omit `reads` when the
|
|
44
|
+
new value should win regardless of what was previously observed:
|
|
45
|
+
|
|
46
|
+
```ts
|
|
47
|
+
const visible = await ablo.records.get({ id });
|
|
48
|
+
await ablo.records.update({ id, data: { status: 'done' } });
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
This is deliberately unconditional, not an implicit fallback. It is suitable
|
|
52
|
+
for independent assignments and inappropriate for read-modify-write decisions.
|
|
53
53
|
|
|
54
54
|
## Functional updates
|
|
55
55
|
|
|
56
|
-
For a pure
|
|
56
|
+
For a pure calculation based on one current row, use the functional form:
|
|
57
57
|
|
|
58
58
|
```ts
|
|
59
59
|
await ablo.counters.update(counterId, (current) => ({
|
|
@@ -61,59 +61,49 @@ await ablo.counters.update(counterId, (current) => ({
|
|
|
61
61
|
}));
|
|
62
62
|
```
|
|
63
63
|
|
|
64
|
-
|
|
65
|
-
|
|
64
|
+
The SDK reads, attempts a guarded write, and retries from fresh state within a
|
|
65
|
+
bounded budget. Because the updater may run more than once, do not perform
|
|
66
|
+
side effects inside it.
|
|
66
67
|
|
|
67
68
|
## Claims
|
|
68
69
|
|
|
69
|
-
A claim protects a target across a
|
|
70
|
-
|
|
71
|
-
wait
|
|
72
|
-
choose a different disposition for a participant kind.
|
|
70
|
+
A claim protects a target across a slower read → decide → write interval.
|
|
71
|
+
Foreign writers are rejected while the claim is active; contenders that ask
|
|
72
|
+
to queue wait in order. Ordinary reads stay open.
|
|
73
73
|
|
|
74
|
-
Claims and stale
|
|
74
|
+
Claims and stale reads answer different questions:
|
|
75
75
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
76
|
+
| Mechanism | Lifetime | Question |
|
|
77
|
+
|---|---|---|
|
|
78
|
+
| `reads` | One mutation | Is every input to this decision still current? |
|
|
79
|
+
| claim | Slow work interval | Who may write this target while work is underway? |
|
|
80
|
+
| database transaction | One apply | Can this physical change commit atomically? |
|
|
81
|
+
| idempotency key | Retries | Has this same mutation already been applied? |
|
|
80
82
|
|
|
81
|
-
|
|
83
|
+
Claims do not hold a Postgres transaction open while an agent thinks. The
|
|
84
|
+
database transaction remains short and owns only validation plus apply.
|
|
82
85
|
|
|
83
86
|
## Cross-row and batch premises
|
|
84
87
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
Use the high-level model methods unless you are building a custom runtime. When
|
|
91
|
-
you do use batch premises, declare only the rows or groups that materially
|
|
92
|
-
influenced the decision; overly broad premises create unnecessary contention.
|
|
88
|
+
A write may depend on rows other than its target. Put every influential row in
|
|
89
|
+
`reads`; if any one changed, Ablo rejects the whole mutation so atomicity is
|
|
90
|
+
preserved. Declare only material dependencies, because broader premises create
|
|
91
|
+
more contention.
|
|
93
92
|
|
|
94
|
-
|
|
93
|
+
Low-level runtimes can also declare row or group watermarks directly. They have
|
|
94
|
+
the same fixed result: stale rejects, fresh applies.
|
|
95
95
|
|
|
96
|
-
|
|
97
|
-
state needed to reconcile. The original write has not been applied.
|
|
96
|
+
## Live change delivery is separate
|
|
98
97
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
Give this loop a retry budget. Continuous contention should surface to the
|
|
106
|
-
caller rather than retry forever.
|
|
98
|
+
Model `onChange` is not a stale-write disposition. It streams committed changes
|
|
99
|
+
to a stateful WebSocket client. `context().onChange` has the narrower job of
|
|
100
|
+
calling once when one of that context's exact reads changes; HTTP delivers it
|
|
101
|
+
through a response held open for that listener. Neither replaces passing those
|
|
102
|
+
same `reads` to create, update, or delete.
|
|
107
103
|
|
|
108
104
|
## Boundaries
|
|
109
105
|
|
|
110
|
-
Concurrency control does not replace
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
- idempotency for safely replaying the same request;
|
|
115
|
-
- claims for exclusivity across slow, side-effecting work.
|
|
116
|
-
|
|
117
|
-
The rule is simple: the model's `conflict` setting is the policy, and each write
|
|
118
|
-
declares what it read. Plain writes are last-write-wins because declaring
|
|
119
|
-
nothing is itself a decision, so make it deliberately.
|
|
106
|
+
Concurrency control does not replace authorization, database constraints,
|
|
107
|
+
transactions, or idempotency. The rule at the SDK boundary is intentionally
|
|
108
|
+
small: `read` declares a dependency, `reads` enforces it, and omission means an
|
|
109
|
+
unconditional write.
|
package/docs/context.md
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
# Context
|
|
2
2
|
|
|
3
3
|
> Assemble the current information for an action and carry its authoritative
|
|
4
|
-
> Ablo reads into the write that follows.
|
|
4
|
+
> Ablo reads into the model write or atomic commit that follows.
|
|
5
5
|
|
|
6
6
|
`context()` is a standalone SDK function. It does not run a model, keep a
|
|
7
7
|
conversation, search documents, or create memory. The application chooses the
|
|
8
8
|
values; Ablo awaits them and identifies the exact returned rows that can guard
|
|
9
|
-
a later write.
|
|
9
|
+
a later model write or atomic commit.
|
|
10
10
|
|
|
11
11
|
## Context, model, write
|
|
12
12
|
|
|
@@ -21,7 +21,7 @@ import { generateText } from 'ai';
|
|
|
21
21
|
const ctx = await context({
|
|
22
22
|
ablo,
|
|
23
23
|
data: {
|
|
24
|
-
record: ablo.records.
|
|
24
|
+
record: ablo.records.read({ id: recordId }),
|
|
25
25
|
records: ablo.records.list({ where: { recordId } }),
|
|
26
26
|
memory: loadMemories(recordId),
|
|
27
27
|
},
|
|
@@ -42,6 +42,25 @@ await ablo.records.update({
|
|
|
42
42
|
});
|
|
43
43
|
```
|
|
44
44
|
|
|
45
|
+
The same captured rows guard an atomic batch. There is no second read format
|
|
46
|
+
and no manual conversion step:
|
|
47
|
+
|
|
48
|
+
```ts
|
|
49
|
+
await ablo.commits.create({
|
|
50
|
+
operations: [
|
|
51
|
+
{ action: 'update', model: 'records', id: recordId, data: update },
|
|
52
|
+
{ action: 'create', model: 'auditEvents', id: eventId, data: event },
|
|
53
|
+
],
|
|
54
|
+
reads: ctx.reads,
|
|
55
|
+
idempotencyKey: runId,
|
|
56
|
+
});
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Both the stateless HTTP client and the reactive WebSocket client resolve these
|
|
60
|
+
captured rows into canonical `{ model, id, readAt }` dependencies before the
|
|
61
|
+
commit reaches the transport. A claim returned by a typed model resource can
|
|
62
|
+
also be passed directly as the batch `claim`.
|
|
63
|
+
|
|
45
64
|
If an authoritative row moves during the model call, the update rejects with
|
|
46
65
|
`AbloStaleContextError`. Rebuild the context before trying again. The model is
|
|
47
66
|
not called or retried by `context()`.
|
|
@@ -54,7 +73,7 @@ protection according to the work:
|
|
|
54
73
|
| Situation | Use | Why |
|
|
55
74
|
|---|---|---|
|
|
56
75
|
| Bring several current values into one model call | `context()` | Awaits the selected values and collects their evidence. |
|
|
57
|
-
| Reject if any selected Ablo row moves | `reads: ctx.reads` | Checks those premises when
|
|
76
|
+
| Reject if any selected Ablo row moves | `reads: ctx.reads` | Checks those premises when a model write or atomic commit reaches the server. |
|
|
58
77
|
| Avoid paying for a model call while another participant owns the row | `claim()` | Waits first, then supplies fresh state. |
|
|
59
78
|
| Compute a patch from one current row without external work | Functional `update()` | Re-reads and retries the pure calculation. |
|
|
60
79
|
|
|
@@ -64,39 +83,49 @@ See [Coordination](./coordination.md) for the full choice.
|
|
|
64
83
|
|
|
65
84
|
## Result
|
|
66
85
|
|
|
67
|
-
The result has
|
|
86
|
+
The result has three members:
|
|
68
87
|
|
|
69
88
|
| Member | Meaning |
|
|
70
89
|
|---|---|
|
|
71
90
|
| `data` | The selected values, with nested promises resolved. |
|
|
72
|
-
| `reads` | Exact Ablo rows accepted by a write's `reads` option. |
|
|
73
|
-
| `
|
|
74
|
-
| `sources` | One provenance summary for each top-level value. |
|
|
91
|
+
| `reads` | Exact Ablo rows accepted by a model write or atomic commit's `reads` option. |
|
|
92
|
+
| `onChange` | Calls a listener once if any exact row in `reads` changes. Returns a function that stops listening. |
|
|
75
93
|
|
|
76
|
-
If a row in `ctx.reads` moves before
|
|
77
|
-
|
|
78
|
-
|
|
94
|
+
If a row in `ctx.reads` moves before a model write or atomic commit, the server
|
|
95
|
+
rejects the operation as stale. Plain values remain in `ctx.data`, but only
|
|
96
|
+
exact Ablo reads appear in `ctx.reads` and gain that guarantee.
|
|
79
97
|
|
|
80
|
-
|
|
81
|
-
ctx.sources;
|
|
82
|
-
// [
|
|
83
|
-
// { key: 'record', kind: 'ablo', guarantee: 'guardable', cursor: 42 },
|
|
84
|
-
// { key: 'memory', kind: 'value', guarantee: 'informational', cursor: null },
|
|
85
|
-
// ]
|
|
86
|
-
```
|
|
98
|
+
## Stop work when context changes
|
|
87
99
|
|
|
88
|
-
|
|
89
|
-
its exact Ablo rows appear in `ctx.reads`:
|
|
100
|
+
`onChange` lets long-running work stop early without changing the write rule:
|
|
90
101
|
|
|
91
102
|
```ts
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
103
|
+
const controller = new AbortController();
|
|
104
|
+
const stop = ctx.onChange((error) => controller.abort(error));
|
|
105
|
+
|
|
106
|
+
try {
|
|
107
|
+
const result = await generateText({
|
|
108
|
+
model,
|
|
109
|
+
abortSignal: controller.signal,
|
|
110
|
+
messages: [contextMessage(ctx)],
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
await ablo.records.update({
|
|
114
|
+
id: ctx.data.record.id,
|
|
115
|
+
data: parseTaskUpdate(result.text),
|
|
116
|
+
reads: ctx.reads,
|
|
117
|
+
});
|
|
118
|
+
} finally {
|
|
119
|
+
stop();
|
|
120
|
+
}
|
|
96
121
|
```
|
|
97
122
|
|
|
98
|
-
|
|
99
|
-
|
|
123
|
+
The first listener starts delivery and all listeners on that context share it.
|
|
124
|
+
The last returned `stop` closes it. A context with no reads opens nothing. The
|
|
125
|
+
first matching change calls every listener with `AbloStaleContextError`, then
|
|
126
|
+
delivery closes. The final create, update, or delete must still receive
|
|
127
|
+
`reads: ctx.reads`; that check remains authoritative if delivery races the
|
|
128
|
+
write or is disconnected.
|
|
100
129
|
|
|
101
130
|
## External context
|
|
102
131
|
|
|
@@ -108,7 +137,7 @@ Reducto, or another system behind their own interfaces.
|
|
|
108
137
|
const ctx = await context({
|
|
109
138
|
ablo,
|
|
110
139
|
data: {
|
|
111
|
-
record: ablo.records.
|
|
140
|
+
record: ablo.records.read({ id: recordId }),
|
|
112
141
|
memory: loadMemories({ query, userId }),
|
|
113
142
|
related: findRelatedChunks({ projectId, query }),
|
|
114
143
|
evidence: extractEvidence({ recordId }),
|
|
@@ -157,14 +186,10 @@ The first version deliberately has no:
|
|
|
157
186
|
|
|
158
187
|
- search or memory API;
|
|
159
188
|
- provider registry or provider-specific adapter;
|
|
160
|
-
- `since` cursor or incremental `changes` result;
|
|
161
189
|
- context session, persistence, or sharing lifecycle;
|
|
162
190
|
- token counting, trimming, summarisation, or model call;
|
|
163
191
|
- guarantee that a person or model understood the included information.
|
|
164
192
|
|
|
165
|
-
Store `ctx.cursor` in application-owned state if it is useful. Incremental
|
|
166
|
-
context is not yet derived from it.
|
|
167
|
-
|
|
168
193
|
`context` remains available as a schema model name. The helper lives at
|
|
169
194
|
`@abloatai/ablo/context`; it does not add `ablo.context()` or reserve a member
|
|
170
195
|
of the schema-backed client.
|
package/docs/coordination.md
CHANGED
|
@@ -9,7 +9,7 @@ meaning. Choose the narrowest one that matches the operation.
|
|
|
9
9
|
|---|---|---|
|
|
10
10
|
| Set an independent value | `update({ id, data })` | Last-write-wins when no claim applies. |
|
|
11
11
|
| Compute a value from the current row | `update(id, current => next)` | Re-reads and retries if the row changes concurrently. |
|
|
12
|
-
| Write only if earlier rows are still current | `reads: [record,
|
|
12
|
+
| Write only if earlier rows are still current | `reads: [record, rules]` | Rejects when an explicitly named dependency changed. |
|
|
13
13
|
| Read, call a model, then write | `claim({ id })` | Other participants cannot write the claimed target by default until your claim ends. |
|
|
14
14
|
|
|
15
15
|
**If a model call sits between the read and the write, take a claim.** A stale
|
|
@@ -25,8 +25,8 @@ does not carry a stale premise. It is intentionally last-write-wins.
|
|
|
25
25
|
Pass the exact rows that produced a decision on the write:
|
|
26
26
|
|
|
27
27
|
```ts
|
|
28
|
-
const record = await ablo.records.
|
|
29
|
-
const policy = await ablo.policies.
|
|
28
|
+
const record = await ablo.records.read({ id: recordId });
|
|
29
|
+
const policy = await ablo.policies.read({ id: policyId });
|
|
30
30
|
if (!record || !policy) throw new Error('required input is missing');
|
|
31
31
|
|
|
32
32
|
const result = await model({ record, policy });
|
|
@@ -91,7 +91,7 @@ Use explicit returned rows when application code reads first and writes later,
|
|
|
91
91
|
but does not need to reserve the row:
|
|
92
92
|
|
|
93
93
|
```ts
|
|
94
|
-
const report = await ablo.reports.
|
|
94
|
+
const report = await ablo.reports.read({ id: reportId });
|
|
95
95
|
if (!report) throw new Error('report missing');
|
|
96
96
|
|
|
97
97
|
await ablo.reports.update({
|
|
@@ -101,37 +101,13 @@ await ablo.reports.update({
|
|
|
101
101
|
});
|
|
102
102
|
```
|
|
103
103
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
| `reject` | Reject the write if its premise is stale. |
|
|
109
|
-
| `notify` | Leave the row unchanged and return the current value for reconciliation. |
|
|
110
|
-
| `overwrite` | Apply the write without a stale check. |
|
|
104
|
+
There is no stale-mode option on the write. If a declared row changed, Ablo
|
|
105
|
+
rejects the whole mutation with `AbloStaleContextError`. Re-read and recompute,
|
|
106
|
+
or use the functional update form when the computation is pure and retryable.
|
|
107
|
+
To make an unconditional assignment, omit `reads` deliberately.
|
|
111
108
|
|
|
112
109
|
See [Concurrency Convention](./concurrency-convention.md) for guarded batches
|
|
113
|
-
and
|
|
114
|
-
|
|
115
|
-
### Decide the model's conflict policy
|
|
116
|
-
|
|
117
|
-
Who yields is a design decision about the model, not something to restate on
|
|
118
|
-
every write. Declare it once, in the schema, and it travels to the server with
|
|
119
|
-
the rest of the model:
|
|
120
|
-
|
|
121
|
-
```ts
|
|
122
|
-
import { coordination, model, z } from '@abloatai/ablo/schema';
|
|
123
|
-
|
|
124
|
-
const cards = model(
|
|
125
|
-
{ title: z.string() },
|
|
126
|
-
{
|
|
127
|
-
conflict: coordination.humansOverwrite().agentsReject(),
|
|
128
|
-
},
|
|
129
|
-
);
|
|
130
|
-
```
|
|
131
|
-
|
|
132
|
-
An omitted participant kind uses the engine default, `reject`. A per-write
|
|
133
|
-
`onStale` states the disposition for that one write. Keep the policy simple, and
|
|
134
|
-
document any rule that lets a participant overwrite a held claim.
|
|
110
|
+
and the `get` / `read` boundary.
|
|
135
111
|
|
|
136
112
|
## Claims
|
|
137
113
|
|
|
@@ -155,7 +131,7 @@ await ablo.reports.update({
|
|
|
155
131
|
If another participant already holds the target, `claim` waits its turn and
|
|
156
132
|
then resolves with a fresh row in `claim.data`. Ordinary reads remain open. By
|
|
157
133
|
default, a write from a participant that does not hold the active claim is
|
|
158
|
-
rejected
|
|
134
|
+
rejected.
|
|
159
135
|
|
|
160
136
|
Bind claims with `await using` whenever possible. The claim then releases when
|
|
161
137
|
the scope exits, including when the external call or write throws. For runtimes
|
|
@@ -198,7 +174,7 @@ try {
|
|
|
198
174
|
}
|
|
199
175
|
```
|
|
200
176
|
|
|
201
|
-
To wait with limits, keep the
|
|
177
|
+
To wait with limits, keep the contention settings together:
|
|
202
178
|
|
|
203
179
|
```ts
|
|
204
180
|
const claim = await ablo.records.claim({
|
|
@@ -226,6 +202,19 @@ await using claim = await ablo.records.claim({
|
|
|
226
202
|
Claims on disjoint fields can coexist. A whole-row claim conflicts with every
|
|
227
203
|
field claim on that row.
|
|
228
204
|
|
|
205
|
+
### Relations do not create hierarchical claims
|
|
206
|
+
|
|
207
|
+
A `parent: true` relation controls ownership, access inheritance, and sync
|
|
208
|
+
routing. It does not make claims conflict across related rows. For example, a
|
|
209
|
+
claim on one document row and a claim on one of its page rows have different
|
|
210
|
+
model-and-ID targets and can coexist.
|
|
211
|
+
|
|
212
|
+
Choose the row that represents the actual unit of exclusive work. Page rows
|
|
213
|
+
allow different pages to process concurrently. If a whole-document operation
|
|
214
|
+
must exclude every page operation, enumerate the authoritative page manifest,
|
|
215
|
+
acquire page claims in one stable order, and guard the manifest against change.
|
|
216
|
+
Do not infer that exclusion from the schema relation alone.
|
|
217
|
+
|
|
229
218
|
The target options are:
|
|
230
219
|
|
|
231
220
|
| Option | Purpose |
|
|
@@ -251,12 +240,102 @@ The main methods are:
|
|
|
251
240
|
|
|
252
241
|
| Method | Purpose |
|
|
253
242
|
|---|---|
|
|
254
|
-
| `claim({ id })` |
|
|
243
|
+
| `claim({ id, ...options })` | Read and claim an existing model row; the handle includes fresh row data. |
|
|
244
|
+
| `claim(id, options)` | Claim an identifier in a registered model namespace without reading a row. |
|
|
255
245
|
| `claim.state({ id })` | Read the current holder without blocking. |
|
|
256
246
|
| `claim.queue({ id })` | Read the current wait order. |
|
|
257
247
|
| `claim.release({ id })` | Release early when you do not hold a handle. |
|
|
258
248
|
| `join({ scope })` | Observe presence for a broader scope. |
|
|
259
249
|
|
|
250
|
+
Choose the overload deliberately:
|
|
251
|
+
|
|
252
|
+
| Form | Evidence requirement | Typical use |
|
|
253
|
+
|---|---|---|
|
|
254
|
+
| `model.claim({ id })` | The row exists and the caller may read it. | Coordinate work on a synchronized row while using `handle.data`. |
|
|
255
|
+
| `model.claim(id, options)` | The model namespace is registered; no row is read. | Select one participant before calling an existing authoritative service. |
|
|
256
|
+
|
|
257
|
+
The identifier-only form is row-free, not schema-free. It does not authorize a
|
|
258
|
+
worker or test fixture to push an unrelated model into an inherited production
|
|
259
|
+
schema. Register the namespace through the application's normal schema process,
|
|
260
|
+
or select an already registered namespace whose ownership matches the operation.
|
|
261
|
+
|
|
262
|
+
## Coordinate an existing database operation
|
|
263
|
+
|
|
264
|
+
Use this pattern when an application already has a service that protects a
|
|
265
|
+
transition with a Postgres row lock or advisory lock, but slow preparation such
|
|
266
|
+
as OCR, a model call, or another tool currently happens while that database
|
|
267
|
+
lock is held.
|
|
268
|
+
|
|
269
|
+
Keep the ownership boundary explicit:
|
|
270
|
+
|
|
271
|
+
| Owner | Responsibility |
|
|
272
|
+
|---|---|
|
|
273
|
+
| Ablo claim | Select one participating worker before expensive work begins. |
|
|
274
|
+
| Application service | Authoritative re-read, transition policy, database lock, idempotency, and commit. |
|
|
275
|
+
| Postgres | Canonical row, constraints, and final integrity boundary. |
|
|
276
|
+
|
|
277
|
+
The operation runs in this order:
|
|
278
|
+
|
|
279
|
+
```text
|
|
280
|
+
claim identifier
|
|
281
|
+
-> prepare expensive result once
|
|
282
|
+
-> application service re-reads and commits under its database lock
|
|
283
|
+
-> release claim in finally
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
Model the service seam as two operations rather than moving database policy
|
|
287
|
+
into a resolver, worker, or agent tool:
|
|
288
|
+
|
|
289
|
+
```ts
|
|
290
|
+
interface ExistingOperationService<Input, Result, Row> {
|
|
291
|
+
run(
|
|
292
|
+
input: Input,
|
|
293
|
+
prepare: () => Promise<Result>,
|
|
294
|
+
): Promise<Row>;
|
|
295
|
+
|
|
296
|
+
commitPrepared(
|
|
297
|
+
input: Input,
|
|
298
|
+
prepared: Result,
|
|
299
|
+
): Promise<Row>;
|
|
300
|
+
}
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
The existing rollout path calls `run` and preserves current behavior. The
|
|
304
|
+
coordinated path wins the claim, prepares once, then calls `commitPrepared`.
|
|
305
|
+
Both methods stay under the same application-service owner and enforce the same
|
|
306
|
+
authorization and transition rules.
|
|
307
|
+
|
|
308
|
+
When the transition permits it, implement `commitPrepared` as one SQL statement
|
|
309
|
+
that acquires a transaction-level advisory lock, re-reads the row, validates
|
|
310
|
+
its current state, and updates it. The statement's implicit transaction
|
|
311
|
+
releases the advisory lock automatically. This can remove several sequential
|
|
312
|
+
client/database round trips without replacing the existing database lock.
|
|
313
|
+
|
|
314
|
+
Do not make any of these substitutions:
|
|
315
|
+
|
|
316
|
+
- Do not assume a remote Ablo request joins a local Postgres transaction.
|
|
317
|
+
- Do not remove database constraints or locks during the coordination rollout.
|
|
318
|
+
- Do not prepare expensive work speculatively before the claim resolves.
|
|
319
|
+
- Do not assume direct SQL writers obey an Ablo claim. A claim coordinates only
|
|
320
|
+
callers routed through the participating operation.
|
|
321
|
+
- Do not use a claim as durable workflow state. A lease expires; workflow
|
|
322
|
+
progress must survive independently.
|
|
323
|
+
|
|
324
|
+
Measure the old and coordinated paths with the same inputs. Record cold and
|
|
325
|
+
warm claim acquire/release latency, database round-trip latency, database-lock
|
|
326
|
+
duration, end-to-end latency, duplicate work under contention, and recovery
|
|
327
|
+
after worker exit. Keep a per-operation switch to the old path until the new
|
|
328
|
+
path preserves behavior and improves the selected race at production
|
|
329
|
+
percentiles.
|
|
330
|
+
|
|
331
|
+
For a runnable GraphQL.js implementation and PostgreSQL race/crash proof, see
|
|
332
|
+
[GraphQL.js over an existing backend](./approaches/graphql/graphql-js.md).
|
|
333
|
+
For a domain-neutral hosted lease proof, see
|
|
334
|
+
[Verify hosted coordination separately](./examples/coordination-conformance.md).
|
|
335
|
+
For the same operation boundary applied to source-versioned document
|
|
336
|
+
processing, see
|
|
337
|
+
[Process an existing document once](./examples/existing-document-pipeline.md).
|
|
338
|
+
|
|
260
339
|
## Choosing correctly
|
|
261
340
|
|
|
262
341
|
- Prefer a plain update for values that do not depend on an earlier read.
|