@abloatai/ablo 0.52.0 → 0.53.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/CHANGELOG.md CHANGED
@@ -1,5 +1,129 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.53.0
4
+
5
+ ### A collection read says where the collection ends
6
+
7
+ `list` returns a page. The result is still an array, so it maps, spreads, and
8
+ iterates exactly as before, and it now carries `hasMore` and `nextCursor` beside
9
+ the rows. Pass `nextCursor` back as `cursor`, keeping `where` and `orderBy` the
10
+ same, to walk the rest:
11
+
12
+ ```ts
13
+ let cursor: string | null = null;
14
+ const open = [];
15
+ do {
16
+ const page = await ablo.weatherReports.list({
17
+ where: { status: ['draft', 'review'] },
18
+ orderBy: { createdAt: 'asc' },
19
+ limit: 100,
20
+ ...(cursor ? { cursor } : {}),
21
+ });
22
+ open.push(...page);
23
+ cursor = page.hasMore ? page.nextCursor : null;
24
+ } while (cursor);
25
+ ```
26
+
27
+ A list read has always been a page: the server applies a default size and caps
28
+ the largest one. Until now that page state was dropped on arrival, so a read
29
+ that returned twenty of five hundred matching rows looked exactly like a
30
+ complete one. Check `hasMore` before treating a result as the whole set.
31
+
32
+ The live client keeps a local graph and loads a working set rather than pages, so
33
+ it rejects `cursor` instead of returning the first page again. Narrow the
34
+ `where`, or construct the client with `transport: 'http'` to page. On the live
35
+ client `hasMore` reports whether a `limit` cut the working set short, and
36
+ `nextCursor` is `null`.
37
+
38
+ `GET /v1/projects` returns the same list envelope as every other collection,
39
+ with `has_more` and `next_cursor` beside `data`.
40
+
41
+ ### The page cursor is called `cursor`
42
+
43
+ The parameter that resumes a collection is `cursor`, in the SDK and on every
44
+ HTTP collection route. It was `starting_after`, a spelling whose established
45
+ meaning elsewhere is a row id, while this value has always been an opaque token
46
+ tied to the sort it was issued for. A caller who read the familiar name and
47
+ passed a row id was refused, so the name promised something it never did.
48
+
49
+ `starting_after` is still accepted on the wire and is removed in a later
50
+ release. Requests that send it keep working; new code should send `cursor`.
51
+ Sending both uses `cursor`. The MCP `list_records` tool and the OpenAPI
52
+ description take `cursor`, and the spec marks the old name deprecated.
53
+
54
+ ### A filter reaches the server intact
55
+
56
+ `where` accepts operators as well as equality. An array value is an `IN`, and
57
+ tuple form spells the rest out:
58
+
59
+ ```ts
60
+ const storms = await ablo.weatherReports.list({
61
+ where: [
62
+ ['title', 'ILIKE', '%storm%'],
63
+ ['createdAt', '>=', cutoff],
64
+ ['status', 'IN', ['draft', 'review']],
65
+ ],
66
+ });
67
+ ```
68
+
69
+ Clauses combine with AND. For OR, run two reads and union the results.
70
+
71
+ On the stateless client (`transport: 'http'`) an `IN` filter and every
72
+ tuple-form clause were previously discarded before the request left, and the
73
+ read came back unfiltered. An agent or worker that filtered a collection over
74
+ HTTP was reading more rows than it asked for, with nothing to indicate it. Every
75
+ transport now encodes a filter the same way.
76
+
77
+ A filter on a boolean field could also match the opposite rows rather than fail,
78
+ when its value arrived as the database's own text spelling. Boolean values are
79
+ coerced before binding, and read back the same way.
80
+
81
+ ### A number field reads back as a number
82
+
83
+ A field declared as a number arrives as one whatever integer width its column
84
+ uses. A wide column previously came back as a decimal string while its narrower
85
+ neighbour came back as a number, so the type a caller received depended on a
86
+ database detail the schema had already settled.
87
+
88
+ A stored value beyond the range a JavaScript number represents exactly now fails
89
+ with `column_value_out_of_range` rather than arriving quietly rounded. Declare
90
+ such a field as text to read those values digit for digit.
91
+
92
+ ### A reconnect cannot roll back a confirmed write
93
+
94
+ Each row in the live client records the log position it reflects. A bootstrap or
95
+ an on-demand read from an earlier position is left unapplied, so a snapshot that
96
+ arrives late no longer overwrites a row the client already knows to be newer.
97
+ The ordered change stream continues to carry every other writer's edits. A
98
+ plugin receives that position as `syncId` on `AppliedChange`.
99
+
100
+ ### The base URL is checked where the credential travels
101
+
102
+ `baseURL` accepts an HTTPS origin, preserving a path prefix for a deployment
103
+ mounted under one, and plain HTTP for localhost. A URL that embeds its own
104
+ credentials, or carries a query or a fragment, is refused when the client is
105
+ constructed rather than failing later as an opaque request error. Every request
106
+ attaches the resolved key against this origin, so the rule lives beside the
107
+ option rather than in each application that sets it.
108
+
109
+ `normalizeAbloHostedBaseUrl` is now `normalizeAbloBaseUrl`. The old name
110
+ resolves to the same function and is removed in 0.54.0.
111
+
112
+ ### Two error codes added
113
+
114
+ `organization_disabled` is returned when an operator has disabled an
115
+ organization, and `query_relation_expansion_too_large` when a requested relation
116
+ expansion exceeds the nested-row budget. The error contract version is
117
+ `2026-08-15`.
118
+
119
+ ### CLI
120
+
121
+ Where a command sends a management key is resolved and checked in one place: an
122
+ explicit `--url` on the commands that take one, then `ABLO_API_URL`, then the
123
+ hosted default. A host given without a scheme becomes absolute, and a
124
+ destination that would put the key on the wire in clear, or one carrying its own
125
+ credentials, is refused before the request is made.
126
+
3
127
  ## 0.52.0
4
128
 
5
129
  ### Models carry only `id`
package/docs/api.md CHANGED
@@ -67,7 +67,7 @@ fallback removed — nothing to await, so they return a value.
67
67
  | Method | Returns | Use when |
68
68
  |---|---|---|
69
69
  | `get({ id })` | `Promise<T \| undefined>` | You need one row, hydrating from local store and server. |
70
- | `list({ where })` | `Promise<T[]>` | You need to hydrate a collection from local store and server. |
70
+ | `list({ where })` | `Promise<ModelList<T>>` | You need to hydrate a collection from local store and server. |
71
71
  | `local.get(id)` | `T \| undefined` | You want a synchronous snapshot of one local row. |
72
72
  | `local.list(options?)` | `T[]` | You want a synchronous snapshot of a local collection. |
73
73
  | `local.count(options?)` | `number` | You want a synchronous count of local rows. |
@@ -79,6 +79,33 @@ fallback removed — nothing to await, so they return a value.
79
79
  through the server. The `local` reads work off the rows a session has already
80
80
  synced, so a cheap re-read needs no round-trip.
81
81
 
82
+ ### Paging a collection
83
+
84
+ `list` returns a page. The result is an array, so it maps and iterates as
85
+ before, and it carries `hasMore` and `nextCursor` alongside the rows:
86
+
87
+ ```ts
88
+ let cursor: string | null = null;
89
+ const open = [];
90
+ do {
91
+ const page = await ablo.weatherReports.list({
92
+ where: { status: ['draft', 'review'] },
93
+ orderBy: { createdAt: 'asc' },
94
+ limit: 100,
95
+ ...(cursor ? { cursor } : {}),
96
+ });
97
+ open.push(...page);
98
+ cursor = page.hasMore ? page.nextCursor : null;
99
+ } while (cursor);
100
+ ```
101
+
102
+ Keep `where` and `orderBy` the same across pages: the cursor encodes the sort
103
+ position it was issued for, and a read that changes either starts a new walk.
104
+
105
+ `where` accepts operators as well as equality, and both travel to the server:
106
+ `{ status: ['draft', 'review'] }` is an `IN`, and tuple form spells the rest
107
+ out, as in `[['title', 'ILIKE', '%storm%'], ['createdAt', '>=', cutoff]]`.
108
+
82
109
  ## Protected Writes
83
110
 
84
111
  Use `snapshot` when a write should reject if the row changed mid-flight:
@@ -193,7 +220,9 @@ receipt; the typed SDK turns single-model writes into their application result
193
220
  (the created or updated row, or nothing for delete). A rejected write carries an
194
221
  error `code` (e.g. `stale_context`, `intent_conflict`) to act on.
195
222
  `GET /api/v1/models/{model}` is cursor-paginated (`limit`, `order`, `order_by`,
196
- `starting_after`) and returns `{ data, has_more, next_cursor }`.
223
+ `cursor`) and returns `{ data, has_more, next_cursor }`. The `starting_after`
224
+ spelling this parameter used through 0.52.0 is still honoured, and is removed in
225
+ a later release.
197
226
 
198
227
  `POST /api/v1/commits` remains the path for **atomic multi-op** writes (several
199
228
  operations across rows/models that must commit together) — the per-model routes
@@ -31,7 +31,7 @@ Common options:
31
31
  |---|---|
32
32
  | `schema` | Required for typed model clients. |
33
33
  | `apiKey` | Bearer credential for trusted server runtimes. Defaults to `ABLO_API_KEY` when available. |
34
- | `baseURL` | Override the hosted sync endpoint for staging or private deployments. |
34
+ | `baseURL` | Override the hosted sync endpoint for staging or private deployments. An HTTPS origin, optionally with a path prefix; plain HTTP is accepted for localhost. Your key travels here, so a URL carrying its own credentials, a query, or a fragment is refused at construction. |
35
35
  | `persistence` | `memory` by default. Use `indexeddb` for a durable browser cache that survives reloads. |
36
36
  | `durableWrites` | Optional crash recovery for unacknowledged agent/worker writes. Independent of the default memory cache; accepts `{ store, namespace? }`. |
37
37
  | `transport` | `'websocket'` (default) is the live, stateful client: a persistent socket, a local synced pool, and `onChange` subscriptions. `'http'` returns the **stateless** client for server-side actors (agents, workers, serverless): the same `ablo.<model>` read/write/claim surface, but each call is one HTTP round-trip with no socket. Under `'http'` the return type narrows to `AbloHttpClient`, so stateful-only methods (the `local` reads, `onChange`, `join`) are compile errors rather than runtime gaps. |
@@ -30,8 +30,8 @@ try {
30
30
  reads: [record],
31
31
  idempotencyKey: commitId,
32
32
  });
33
- const record = await ablo.commits.get({ id: commitId });
34
- console.log({ identity: ablo.identity, commit: record });
33
+ const commit = await ablo.commits.get({ id: commitId });
34
+ console.log({ identity: ablo.identity, commit });
35
35
  } finally {
36
36
  await ablo.dispose();
37
37
  }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * A lease outlives the process that took it.
3
+ *
4
+ * Run in two terminals against your own project. The `holder` takes a claim
5
+ * and is killed without releasing it, exactly as a sandbox that is torn down
6
+ * mid-turn would be. The `successor`, already queued, is granted the claim when
7
+ * the lease lapses and reads the row as it stands then.
8
+ *
9
+ * Terminal 1: ABLO_API_KEY=sk_... JOB_ID=job_... npx tsx examples/lease-outlives-the-machine.ts holder
10
+ * Terminal 2: ABLO_API_KEY=sk_... JOB_ID=job_... npx tsx examples/lease-outlives-the-machine.ts successor
11
+ *
12
+ * Start the successor first, then the holder, so the queue is populated before
13
+ * the lease lapses.
14
+ */
15
+ import { Ablo } from '@abloatai/ablo';
16
+ import { defineSchema, model, z } from '@abloatai/ablo/schema';
17
+
18
+ const schema = defineSchema({
19
+ jobs: model({
20
+ prompt: z.string(),
21
+ status: z.enum(['pending', 'complete']),
22
+ answer: z.string().optional(),
23
+ }),
24
+ });
25
+
26
+ const role = process.argv[2];
27
+ if (role !== 'holder' && role !== 'successor') {
28
+ throw new Error('Pass "holder" or "successor" as the first argument');
29
+ }
30
+
31
+ const jobId = process.env.JOB_ID;
32
+ if (!jobId) throw new Error('JOB_ID is required');
33
+
34
+ const ablo = Ablo({ schema, apiKey: process.env.ABLO_API_KEY });
35
+ await ablo.ready();
36
+
37
+ if (role === 'holder') {
38
+ // A short TTL and no heartbeat: this process takes the lease and then stops
39
+ // proving it is alive, which is what a machine that disappears looks like
40
+ // from the server's side.
41
+ const claim = await ablo.jobs.claim({
42
+ id: jobId,
43
+ description: 'drafting the summary',
44
+ ttl: '10s',
45
+ });
46
+ console.log('holder: lease taken, status is', claim.data.status);
47
+ console.log('holder: exiting without releasing it');
48
+ // Deliberately skip release and skip dispose. `process.exit` runs no
49
+ // cleanup, so the server never hears from this participant again.
50
+ process.exit(0);
51
+ }
52
+
53
+ console.log('successor: queueing behind whoever holds the lease');
54
+ const started = Date.now();
55
+ await using claim = await ablo.jobs.claim({
56
+ id: jobId,
57
+ description: 'taking over the draft',
58
+ ttl: '30s',
59
+ heartbeat: { every: '10s' },
60
+ });
61
+ console.log(`successor: granted after ${Math.round((Date.now() - started) / 1000)}s`);
62
+ console.log('successor: read the row as it stands now —', {
63
+ status: claim.data.status,
64
+ answer: claim.data.answer,
65
+ });
66
+ await ablo.dispose();
@@ -1,16 +1,10 @@
1
1
  {
2
+ "extends": "../tsconfig.json",
2
3
  "compilerOptions": {
3
- "target": "ES2022",
4
- "module": "ESNext",
5
- "moduleResolution": "bundler",
6
- "lib": ["ES2022", "DOM"],
7
- "strict": true,
8
4
  "noEmit": true,
9
- "esModuleInterop": true,
10
- "skipLibCheck": true,
11
- "forceConsistentCasingInFileNames": true,
12
- "types": ["node"]
5
+ "rootDir": "..",
6
+ "lib": ["ES2022", "ESNext.Disposable"]
13
7
  },
14
8
  "include": ["**/*.ts"],
15
- "exclude": ["node_modules"]
9
+ "exclude": ["node_modules", "dist"]
16
10
  }
package/llms.txt CHANGED
@@ -85,6 +85,17 @@ second verb to learn — `local.` is the only difference. The query reads accept
85
85
  and `state`; state defaults to `'live'`, with `'archived'` and `'all'` to include
86
86
  retired rows.
87
87
 
88
+ `where` takes operators, not only equality: an array value is an `IN`
89
+ (`{ status: ['draft', 'review'] }`), and tuple form spells the rest out
90
+ (`[['title', 'ILIKE', '%storm%'], ['createdAt', '>=', cutoff]]`). Clauses combine
91
+ with AND; for OR, run two reads and union them.
92
+
93
+ `list` returns a page, not always the whole collection. The result is an array,
94
+ so it maps and iterates as usual, and it carries `hasMore` and `nextCursor`
95
+ beside the rows. Pass `nextCursor` back as `cursor`, keeping `where` and
96
+ `orderBy` the same, to walk the rest. Check `hasMore` before treating a result
97
+ as complete.
98
+
88
99
  Workers import the same app schema and select `transport: 'http'`. The transport
89
100
  changes; the typed `ablo.<model>` contract does not. There is no public
90
101
  schema-less or string-keyed model client.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@abloatai/ablo",
3
- "version": "0.52.0",
3
+ "version": "0.53.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",
@@ -112,7 +112,7 @@
112
112
  "prepack": "npm run build && node scripts/strip-source-condition.mjs",
113
113
  "postpack": "node scripts/restore-source-condition.mjs",
114
114
  "pack:check": "node scripts/pack-check.mjs",
115
- "typecheck": "tsc --noEmit && tsc -p typetests/tsconfig.json",
115
+ "typecheck": "tsc --noEmit && tsc -p typetests/tsconfig.json && tsc -p examples/tsconfig.json",
116
116
  "test": "vitest run",
117
117
  "generate:errors": "tsx scripts/generate-error-docs.mts",
118
118
  "lint:errors": "tsx scripts/check-error-docs.mts",
@@ -137,8 +137,8 @@
137
137
  "directory": "packages/ablo"
138
138
  },
139
139
  "dependencies": {
140
- "@abloatai/humans": "^0.52.0",
141
- "@abloatai/transaction": "^0.52.0",
140
+ "@abloatai/humans": "^0.53.0",
141
+ "@abloatai/transaction": "^0.53.0",
142
142
  "zod": "^4.4.3"
143
143
  },
144
144
  "peerDependencies": {