@abloatai/ablo 0.9.3 → 0.9.4

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,11 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.9.4
4
+
5
+ ### Patch Changes
6
+
7
+ - Sync-position correctness + CLI hardening. Consolidate five scattered sync cursors into one typed `syncPosition` (persisted/applied/acked with a derived `readFloor`), fixing a claim taken right after an ack-confirmed write reading stale against that write's own delta. Add transaction ack-confirmation, schema DDL-first-push, and a reworked CLI (config/dev/login/mode/drizzle-pull).
8
+
3
9
  ## 0.9.3
4
10
 
5
11
  ### Patch Changes
package/README.md CHANGED
@@ -53,7 +53,7 @@ npm install @abloatai/ablo
53
53
  npx ablo login # opens the browser: sign in (or sign up) → a sk_test_ key is saved locally
54
54
  npx ablo init # scaffolds ablo/schema.ts (offers to log in if you skipped it)
55
55
  npx ablo migrate # creates the synced tables in YOUR Postgres (reads DATABASE_URL)
56
- npx ablo dev # pushes your schema (test mode), writes ABLO_API_KEY to .env.local, watches for changes
56
+ npx ablo dev # pushes your schema (sandbox), writes ABLO_API_KEY to .env.local, watches for changes
57
57
  ```
58
58
 
59
59
  After `ablo dev`, the [Quick Start](#quick-start) below runs as-is —
@@ -356,20 +356,22 @@ Same product, same truth either way: your database is the system of record. See
356
356
 
357
357
  ## Configuration
358
358
 
359
- `Ablo({ ... })` takes one required option and a couple of transport overrides:
359
+ `Ablo({ ... })` takes three things: your schema, your key, and your database —
360
+ the last either as `databaseUrl` here or as a signed
361
+ [Data Source endpoint](./docs/data-sources.md) in your app. Every other option
362
+ has correct defaults:
360
363
 
361
364
  | Option | Type | Default | Purpose |
362
365
  | --- | --- | --- | --- |
363
366
  | `schema` | `Schema` | — (required) | Typed model proxies (`ablo.<model>.*`) |
364
367
  | `apiKey` | `string \| ApiKeySetter \| null` | `process.env.ABLO_API_KEY` | Server key — a string, or an async function for rotation |
365
368
  | `databaseUrl` | `string \| null` | `process.env.DATABASE_URL` | Your Postgres, registered as the data plane. Server runtimes only — the SDK throws if it sees this in a browser. Omit it when your app exposes a signed [Data Source endpoint](./docs/data-sources.md) instead. |
366
- | `baseURL` | `string` | `wss://api.abloatai.com` | Point at a self-hosted or private API |
367
369
 
368
370
  Keep `apiKey` in trusted server runtimes. In the browser, `<AbloProvider>`
369
371
  authenticates with the signed-in user's session; the raw-key path is gated
370
- behind `dangerouslyAllowBrowser` for server-proxy setups only. Self-hosted
371
- deployments can pass `authToken` instead of `apiKey`. Advanced hooks (custom
372
- `fetch`, logging, observability) live in [Client Behavior](./docs/client-behavior.md).
372
+ behind `dangerouslyAllowBrowser` for server-proxy setups only. Advanced hooks
373
+ (custom `fetch`, logging, observability, transport overrides) live in
374
+ [Client Behavior](./docs/client-behavior.md).
373
375
 
374
376
  ## Errors
375
377
 
@@ -272,8 +272,11 @@ export declare class BaseSyncedStore<TCollaboration extends EventMap<TCollaborat
272
272
  protected pendingDeltas: SyncDelta[];
273
273
  protected batchTimer: ReturnType<typeof setTimeout> | null;
274
274
  protected syncPromise: Promise<void> | null;
275
- protected lastAckedId: number;
276
- protected highestProcessedSyncId: number;
275
+ /** Resume/ack cursor — delegates to the shared SyncPosition (see
276
+ * sync/syncPosition.ts). Advances only after IDB persistence. */
277
+ protected get lastAckedId(): number;
278
+ /** Pool-applied cursor — delegates to the shared SyncPosition. */
279
+ protected get highestProcessedSyncId(): number;
277
280
  protected bootstrapDeltaQueue: SyncDelta[] | null;
278
281
  protected activeBootstrapCount: number;
279
282
  protected pendingDeletes: Set<string>;
@@ -181,8 +181,15 @@ export class BaseSyncedStore {
181
181
  pendingDeltas = [];
182
182
  batchTimer = null;
183
183
  syncPromise = null;
184
- lastAckedId = 0;
185
- highestProcessedSyncId = 0;
184
+ /** Resume/ack cursor — delegates to the shared SyncPosition (see
185
+ * sync/syncPosition.ts). Advances only after IDB persistence. */
186
+ get lastAckedId() {
187
+ return this.syncClient.position.persisted;
188
+ }
189
+ /** Pool-applied cursor — delegates to the shared SyncPosition. */
190
+ get highestProcessedSyncId() {
191
+ return this.syncClient.position.applied;
192
+ }
186
193
  // ── Delta queuing during bootstrap ──
187
194
  bootstrapDeltaQueue = null;
188
195
  activeBootstrapCount = 0;
@@ -957,8 +964,7 @@ export class BaseSyncedStore {
957
964
  }
958
965
  // Get sync baseline for WebSocket
959
966
  const lastSyncId = (yield this.database.getLastSyncId());
960
- this.lastAckedId = Math.max(this.lastAckedId, lastSyncId || 0);
961
- this.highestProcessedSyncId = this.lastAckedId;
967
+ this.syncClient.position.advancePersisted(lastSyncId || 0);
962
968
  try {
963
969
  const versions = (yield this.database.getVersionVector());
964
970
  if (versions && typeof versions === 'object')
@@ -1557,9 +1563,7 @@ export class BaseSyncedStore {
1557
1563
  return;
1558
1564
  }
1559
1565
  // Advance watermark
1560
- if (delta.id > this.highestProcessedSyncId) {
1561
- this.highestProcessedSyncId = delta.id;
1562
- }
1566
+ this.syncClient.position.advanceApplied(delta.id);
1563
1567
  // Sync group added — handle immediately. Supports both legacy
1564
1568
  // (addedGroups/removedGroups) and incremental (group/userId) payloads.
1565
1569
  if (delta.actionType === 'G') {
@@ -1699,8 +1703,7 @@ export class BaseSyncedStore {
1699
1703
  const persistedSyncId = batch.persistedSyncId;
1700
1704
  if (persistedSyncId > this.lastAckedId) {
1701
1705
  this.syncWebSocket?.acknowledge?.(persistedSyncId);
1702
- this.lastAckedId = persistedSyncId;
1703
- this.highestProcessedSyncId = Math.max(this.highestProcessedSyncId, persistedSyncId);
1706
+ this.syncClient.position.advancePersisted(persistedSyncId);
1704
1707
  }
1705
1708
  // Cache invalidation is automatic via SyncClient 'models:changed' event
1706
1709
  this.pendingDeltas = [];
@@ -1905,11 +1908,9 @@ export class BaseSyncedStore {
1905
1908
  }
1906
1909
  // Delegate pool writes to SyncClient (auto-invalidates cache via 'models:changed' event)
1907
1910
  this.syncClient.applyDeltaBatchToPool([dbResult], (name, data) => this.enrichRelations(name, data));
1908
- // Advance sync ID
1909
- if (delta.id > this.lastAckedId) {
1910
- this.lastAckedId = delta.id;
1911
- this.highestProcessedSyncId = Math.max(this.highestProcessedSyncId, delta.id);
1912
- }
1911
+ // This path runs after the delta was written to IDB — advance both
1912
+ // cursors through the shared position.
1913
+ this.syncClient.position.advancePersisted(delta.id);
1913
1914
  }
1914
1915
  /** Handle bootstrap_required event */
1915
1916
  handleBootstrapRequired(_hint) {
package/dist/Database.js CHANGED
@@ -8,6 +8,7 @@ import { LoadStrategy } from './types/index.js';
8
8
  import { getContext } from './context.js';
9
9
  import { AbloConnectionError, AbloValidationError } from './errors.js';
10
10
  import { InMemoryObjectStore } from './adapters/inMemoryStorage.js';
11
+ import { syncPositionSchema } from './sync/syncPosition.js';
11
12
  export class Database {
12
13
  // Core database components
13
14
  databaseManager;
@@ -252,7 +253,12 @@ export class Database {
252
253
  const instantModels = this.modelRegistry.getModelsByLoadStrategy(LoadStrategy.instant);
253
254
  const lazyModels = this.modelRegistry.getModelsByLoadStrategy(LoadStrategy.lazy);
254
255
  const modelsToLoad = [...instantModels, ...lazyModels];
255
- const metadataLastSyncId = metadata?.lastSyncId || 0;
256
+ // Gate the PERSISTED cursor through the sync-position schema field —
257
+ // the one trust boundary for resume state. IDB can hand back anything
258
+ // (a corrupted negative/float cursor would previously pass `|| 0`,
259
+ // which only catches falsy, and get sent to the server as the resume
260
+ // point). Invalid → 0 → full bootstrap, the safe degradation.
261
+ const metadataLastSyncId = syncPositionSchema.shape.persisted.safeParse(metadata?.lastSyncId).data ?? 0;
256
262
  const dataAge = metadata?.updatedAt ? Date.now() - metadata.updatedAt.getTime() : Infinity;
257
263
  // ── Zero-style cache-validity check ──────────────────────────
258
264
  //
@@ -14,6 +14,7 @@ import { TransactionQueue } from './transactions/TransactionQueue.js';
14
14
  import { type OptimisticEchoMetrics } from './transactions/OptimisticEchoTracker.js';
15
15
  import type { Database } from './Database.js';
16
16
  import type { WriteOptions } from './interfaces/index.js';
17
+ import { SyncPosition } from './sync/syncPosition.js';
17
18
  interface SyncObserver {
18
19
  onSync?: (event: SyncEvent) => void;
19
20
  }
@@ -68,6 +69,13 @@ export declare class SyncClient extends EventEmitter {
68
69
  private offlineSince?;
69
70
  private maxRetries;
70
71
  private isDisposed;
72
+ /**
73
+ * THE client's place in the global delta order — the one canonical
74
+ * instance (see `sync/syncPosition.ts`). The store advances
75
+ * `applied`/`persisted` as deltas land; the queue advances `acked` on
76
+ * commit responses; snapshots/claims read `readFloor`.
77
+ */
78
+ readonly position: SyncPosition;
71
79
  constructor(objectPool: ObjectPool, database: Database);
72
80
  /**
73
81
  * Setup network monitoring handlers
@@ -16,6 +16,7 @@ import { EventEmitter } from 'events';
16
16
  import { NetworkMonitor } from './NetworkMonitor.js';
17
17
  import { TransactionQueue } from './transactions/TransactionQueue.js';
18
18
  import { OptimisticEchoTracker, } from './transactions/OptimisticEchoTracker.js';
19
+ import { SyncPosition } from './sync/syncPosition.js';
19
20
  export class SyncClient extends EventEmitter {
20
21
  objectPool;
21
22
  database;
@@ -50,6 +51,13 @@ export class SyncClient extends EventEmitter {
50
51
  // Configuration
51
52
  maxRetries = 3;
52
53
  isDisposed = false;
54
+ /**
55
+ * THE client's place in the global delta order — the one canonical
56
+ * instance (see `sync/syncPosition.ts`). The store advances
57
+ * `applied`/`persisted` as deltas land; the queue advances `acked` on
58
+ * commit responses; snapshots/claims read `readFloor`.
59
+ */
60
+ position = new SyncPosition();
53
61
  constructor(objectPool, database) {
54
62
  super();
55
63
  this.objectPool = objectPool;
@@ -57,6 +65,7 @@ export class SyncClient extends EventEmitter {
57
65
  this.networkMonitor = new NetworkMonitor();
58
66
  // Initialize TransactionQueue with proper configuration
59
67
  this.transactionQueue = new TransactionQueue({
68
+ position: this.position,
60
69
  maxBatchSize: 50, // Increased from 10 to reduce batch count for large operations
61
70
  // Lower delay for snappier dev UX; batching still happens via coalescing
62
71
  batchDelay: 150,