@abloatai/ablo 0.44.0 → 0.46.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 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,49 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.46.0
4
+
5
+ ### Pre-existing rows arrive on their own
6
+
7
+ Connecting a database that already holds data no longer leaves those rows
8
+ invisible until something touches them. Ablo snapshots the pre-existing rows
9
+ automatically, `ablo connect check` refuses to report ready until that
10
+ snapshot completes, and `ablo status --json` exposes the progress as
11
+ `initialSnapshot.status`: `loading`, `retrying` with the underlying error, or
12
+ `complete`. Keep an existing read fallback in place until the status reads
13
+ `complete`. Row-touch backfill scripts are unnecessary; the snapshot is the
14
+ engine's job.
15
+
16
+ ## 0.45.0
17
+
18
+ ### Claim admission is authoritative
19
+
20
+ A claim used to return a local handle immediately, before the server had
21
+ granted anything, so two agents on different server instances could each
22
+ believe they held the same row. Every claim now waits for the server's grant
23
+ or rejection, and the server fails closed when the shared lease store is
24
+ unavailable rather than admitting claims it cannot coordinate. A two-server
25
+ end-to-end regression pins the behavior.
26
+
27
+ ### Contention has a lifecycle you can watch
28
+
29
+ The new `contention` option names what happens when the row is already held:
30
+
31
+ ```ts
32
+ const claim = await ablo.tasks.claim({
33
+ id,
34
+ contention: {
35
+ mode: 'skip',
36
+ onStatus(event) {
37
+ // queued | granted | skipped | failed
38
+ },
39
+ },
40
+ });
41
+ ```
42
+
43
+ `{ mode: 'skip' }` resolves `null` instead of waiting, and `onStatus` reports
44
+ the attempt as it moves. `queue: true` and `queue: false` remain as
45
+ compatible spellings of the two modes.
46
+
3
47
  ## 0.44.0
4
48
 
5
49
  ### A scope denial names the wall it hit
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 line
120
- ("SQS for entity contention"). Use `{ queue: false }` for fail-fast dedup: *if
121
- someone else has this job, skip it.*
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
 
@@ -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 }` rejects with `AbloClaimedError` instead of queuing;
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` | An active claim conflicted with `{ queue: false }`, the queue was too deep, or a claim wait timed out. |
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';
@@ -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 (a declined try
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,12 @@ 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
101
+ > `claim({ id, contention: { mode: 'skip' } })`. B must receive `null`.
102
+ > Creating two clients from the same key tests re-entrancy, not contention.
97
103
 
98
104
  ---
99
105
 
@@ -317,14 +323,15 @@ four axes. `claim({ id })` alone is a complete call; each axis is opt-in.
317
323
  | `options.description` | `string` | no | Peer-visible description of the work, shown to observers (default `'editing'`). |
318
324
  | `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
325
 
320
- *How you wait* — admission to the line:
326
+ *How you handle contention*:
321
327
 
322
328
  | name | type | required | description |
323
329
  |---|---|---|---|
324
- | `options.queue` | `boolean` | no | `true` (default) queues and waits for the lease. `false` is the try-claim: if another participant holds the row it resolves `null`: an expected outcome, not an error, so claim-or-skip dedup reads `if (!claim) return` (waiting would double-process). Who holds it stays readable via `claim.state`. A *write* to a held row still rejects `entity_claimed`. |
325
- | `options.maxQueueDepth` | `number` | no | Backpressure: reject with `AbloClaimedError('queue_too_deep')` instead of joining a line already `>= maxQueueDepth` deep. Omit to wait however deep the queue is. |
326
- | `options.waitTimeoutMs` | `number` | no | Cap on how long a queued claim waits for its grant before rejecting with `AbloClaimedError('grant_timeout')`. Omit to wait as long as the line takes. Same meaning on both transports; over HTTP a timed-out wait also leaves the line. |
327
- | `options.signal` | `AbortSignal` | no | Abort a pending wait from outside: a cancelled agent task or an unmounted component takes its queued claim with it. Rejects with `AbloClaimedError('claim_wait_aborted')`; over HTTP the abort also leaves the line. Ignored once the grant has arrived, release a held lease instead. |
330
+ | `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`. |
331
+ | `options.queue` | `boolean` | no | Compatibility shorthand: `true` waits and `false` skips. Prefer `contention` when configuring more than the mode. |
332
+ | `options.maxQueueDepth` | `number` | no | Legacy flat spelling of `contention.maxDepth`. Prefer `contention` for new code. |
333
+ | `options.waitTimeoutMs` | `number` | no | Legacy flat spelling of `contention.timeoutMs`. Prefer `contention` for new code. |
334
+ | `options.signal` | `AbortSignal` | no | Legacy flat spelling of `contention.signal`. Prefer `contention` for new code. |
328
335
 
329
336
  *How long you hold* — the lease:
330
337
 
@@ -333,6 +340,53 @@ four axes. `claim({ id })` alone is a complete call; each axis is opt-in.
333
340
  | `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
341
  | `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
342
 
343
+ The request-scoped `onStatus` callback receives one discriminated event. It is
344
+ observational: an exception in UI or telemetry code never changes the claim
345
+ attempt.
346
+
347
+ | event | meaning | claim promise |
348
+ |---|---|---|
349
+ | `queued` | this request joined the wait line | remains pending |
350
+ | `granted` | this request owns the lease | resolves with the claim |
351
+ | `skipped` | `mode: 'skip'` found another participant holding the target | resolves `null` |
352
+ | `failed` | the attempt could not complete, for example timeout, cancellation, authorization, or connectivity | rejects with `event.error` |
353
+
354
+ ```ts
355
+ const claim = await ablo.tasks.claim({
356
+ id,
357
+ contention: {
358
+ mode: 'wait',
359
+ maxDepth: 3,
360
+ timeoutMs: 30_000,
361
+ onStatus(event) {
362
+ if (event.type === 'queued') {
363
+ console.log(`${event.ahead} participant(s) ahead`);
364
+ } else if (event.type === 'granted') {
365
+ console.log(event.waited ? 'your turn' : 'granted immediately');
366
+ } else {
367
+ console.warn(event.error.code, event.error.message);
368
+ }
369
+ },
370
+ },
371
+ });
372
+ ```
373
+
374
+ For claim-or-skip work, make the caller's intent explicit and keep its status
375
+ observer beside the decision:
376
+
377
+ ```ts
378
+ const claim = await ablo.tasks.claim({
379
+ id,
380
+ contention: {
381
+ mode: 'skip',
382
+ onStatus: (event) => {
383
+ if (event.type === 'skipped') metrics.increment('claim.skipped');
384
+ },
385
+ },
386
+ });
387
+ if (!claim) return;
388
+ ```
389
+
336
390
  The high-level `claim` queues by default, so on contention you either get the row
337
391
  when your turn arrives or one of the [queue errors](#errors) (`claim_lost`,
338
392
  `grant_timeout`).
@@ -447,6 +501,13 @@ ablo.<model>.claim.state({ id })
447
501
  Read who's currently working on a row, for observers and UI. Synchronous and
448
502
  reactive (it reads the local coordination snapshot). Never blocks.
449
503
 
504
+ The first call also starts row-scoped observation. Because that subscription is
505
+ asynchronous, an imperative script may briefly read `null` before the reactive
506
+ snapshot arrives; subscribe to changes or wait for the expected holder when the
507
+ observation itself is the test. Late subscriptions are backfilled across server
508
+ instances, and a skipped conflict attempt seeds its authoritative holder
509
+ summary into the same snapshot immediately.
510
+
450
511
  **You don't subscribe to anything first.** Reading or claiming a row
451
512
  automatically enrolls you in that row's sync group: reading it (including
452
513
  `retrieve`/`get`, or `claim.state` itself) gives you **read-interest**, and
@@ -552,16 +613,22 @@ decide the wait isn't worth it.
552
613
  |---|---|---|---|
553
614
  | `id` | `string` | yes | The row id. |
554
615
 
555
- **Returns** — a list envelope. `data` contains the queued
556
- [claim state objects](#the-claim-state-object) in promotion order (head first), excluding
557
- the active holder; `[]` when no one is waiting.
616
+ **Returns** — a structured queue snapshot:
617
+
618
+ - `waiting` queued [claim state objects](#the-claim-state-object) in
619
+ promotion order, excluding the active holder;
620
+ - `next` — the first waiter, or `null`;
621
+ - `size` — how many participants are waiting;
622
+ - `data` — the same array as `waiting`, retained as the standard list-envelope
623
+ member for compatibility.
558
624
 
559
625
  **Example**
560
626
 
561
627
  ```ts
562
- const { data: waiting } = ablo.weatherReports.claim.queue({ id: 'report_stockholm' });
563
- console.log(`${waiting.length} ahead of you`);
564
- console.log(waiting.map((i) => i.heldBy));
628
+ const line = ablo.weatherReports.claim.queue({ id: 'report_stockholm' });
629
+ console.log(`${line.size} waiting`);
630
+ console.log(`next: ${line.next?.heldBy ?? 'nobody'}`);
631
+ console.log(line.waiting.map((claim) => claim.heldBy));
565
632
  ```
566
633
 
567
634
  ### `claim.release`
@@ -74,6 +74,19 @@ yourself. Rotate the scoped passwords any time with `ablo connect rotate`.
74
74
  The rest of this page is what that command sets up, step by step, for when you want
75
75
  to run it by hand or review exactly what changes.
76
76
 
77
+ ### Existing rows load automatically
78
+
79
+ When Ablo creates the replication slot, it takes a consistent initial snapshot of
80
+ every mapped table in the publication before following new changes. Rows that
81
+ predate `ablo connect` therefore become available to `retrieve`, `list`, and
82
+ reactive `local.*` reads without an application backfill.
83
+
84
+ Run `ablo connect check` before removing an existing HTTP/database read fallback.
85
+ It reports the initial load as `loading` until the snapshot is complete. Do not
86
+ write a script that updates every row to make it visible: an Ablo update requires
87
+ the row to be visible already, and touching application rows is neither necessary
88
+ nor a safe bootstrap mechanism.
89
+
77
90
  ## The setup, step by step
78
91
 
79
92
  ### 1. Enable logical decoding
@@ -172,6 +185,8 @@ checklist or the precise per-item fix:
172
185
  `REPLICA IDENTITY FULL`) so `UPDATE`/`DELETE` can replicate
173
186
  - the writer role is DML-ready — scoped, non-superuser, with the idempotency
174
187
  ledger in place
188
+ - the initial snapshot is complete, so rows that existed before connecting are
189
+ available to Ablo reads
175
190
 
176
191
  Because Ablo checks from its own network, a database your own machine can't reach —
177
192
  IPv6-only, IP-allowlisted, behind a VPN — still verifies. Re-run it until every
@@ -195,8 +210,8 @@ export const ablo = Ablo({
195
210
  ```
196
211
 
197
212
  The Ablo schema describes **only your synced, collaborative models** — the rows
198
- Ablo coordinates and fans out in realtime. It is *not* your whole-database schema
199
- and does *not* replace your `schema.prisma` (or Drizzle schema). Your auth,
213
+ Ablo coordinates and fans out in realtime. It is _not_ your whole-database schema
214
+ and does _not_ replace your `schema.prisma` (or Drizzle schema). Your auth,
200
215
  billing, and other tables stay in your own ORM schema, owned by your own
201
216
  migrations. `ablo check` reflects this — it reports tables you didn't declare as
202
217
  "ignored / owned by you," which is exactly right.
@@ -226,13 +241,13 @@ state means and when to wait.
226
241
 
227
242
  This is the complete list. Nothing else.
228
243
 
229
- | Object | What it is | Owned by |
230
- |---|---|---|
231
- | `ablo_publication` | A publication naming the tables Ablo reads and confirms against. | You create it (step 2). |
232
- | `ablo_replicator` role | A `REPLICATION` + `SELECT` role Ablo reads and confirms through. | You create it (step 2). |
233
- | `ablo_writer` role | A scoped DML role Ablo writes your rows through: row DML + ledger, nothing more. | You create it (step 2). |
234
- | Replication slot | A logical slot Ablo subscribes through to track its WAL position. | Ablo's runtime creates it on first connect. |
235
- | `wal_level = logical` | A server setting that **requires a restart**. | You set it (step 1). |
244
+ | Object | What it is | Owned by |
245
+ | ---------------------- | -------------------------------------------------------------------------------- | ------------------------------------------- |
246
+ | `ablo_publication` | A publication naming the tables Ablo reads and confirms against. | You create it (step 2). |
247
+ | `ablo_replicator` role | A `REPLICATION` + `SELECT` role Ablo reads and confirms through. | You create it (step 2). |
248
+ | `ablo_writer` role | A scoped DML role Ablo writes your rows through: row DML + ledger, nothing more. | You create it (step 2). |
249
+ | Replication slot | A logical slot Ablo subscribes through to track its WAL position. | Ablo's runtime creates it on first connect. |
250
+ | `wal_level = logical` | A server setting that **requires a restart**. | You set it (step 1). |
236
251
 
237
252
  Operational reality you should know up front:
238
253
 
@@ -254,7 +269,7 @@ have.
254
269
 
255
270
  ## What Ablo stores on its side
256
271
 
257
- Your schema *definition* (model names, fields, types — pushed with `ablo push`),
272
+ Your schema _definition_ (model names, fields, types — pushed with `ablo push`),
258
273
  your hashed API keys, a safe projection of the connection registration (host,
259
274
  database, schema — the connection string itself is sealed and never echoed back),
260
275
  the replication slot position, and the ordered transaction log that drives sync and
package/docs/debugging.md CHANGED
@@ -245,8 +245,18 @@ The same stable code covers two different enforcement layers, so inspect
245
245
  - `database_row_level_security`: Ablo's capability gate allowed the operation,
246
246
  but Postgres rejected it under the customer table's RLS policy.
247
247
  `details.databaseSessionContext` shows the organization, project, branch,
248
- participant kind, user principal, and custom session-setting values applied
249
- to that transaction.
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.
250
260
 
251
261
  Every rejected live commit carries `requestId` on the thrown error and
252
262
  `request_id` in its JSON form and warning line:
@@ -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 someone already
58
- // holds the row, claim rejects with AbloClaimedError (caught below), so the
59
- // agent yields instead of waiting. Omit it, or pass queue: true, to queue
60
- // behind them. description → the label observers see while we work.
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
- // Someone already holds the row yield this run and let them finish.
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 ablo = Ablo({
32
+ const control = Ablo({
33
33
  schema,
34
34
  apiKey: process.env.ABLO_API_KEY,
35
- transport: 'http',
36
35
  });
37
36
 
38
- export async function completeTask(taskId: string) {
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
- await using claim = await ablo.tasks.claim({
54
+ const acquired = await ablo.tasks.claim({
45
55
  id: taskId,
46
- queue: false,
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
 
@@ -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 `{ queue: false }` on `claim` when work should be skipped instead of queued
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
 
@@ -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.44.0",
3
+ "version": "0.46.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.44.0",
128
- "@abloatai/transaction": "^0.44.0"
127
+ "@abloatai/humans": "^0.46.0",
128
+ "@abloatai/transaction": "^0.46.0"
129
129
  },
130
130
  "peerDependencies": {
131
131
  "ai": "^6.0.0 || ^7.0.0",