@abloatai/ablo 0.16.3 → 0.18.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,72 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.18.0
4
+
5
+ ### Minor Changes
6
+
7
+ - **Client observability — `debug` / `logLevel`, off by default.** The SDK used to
8
+ emit a `debug` line per model and per property during schema registration (a
9
+ firehose). It now defaults to a quiet `warn` threshold and exposes two new
10
+ `Ablo()` options to opt back in:
11
+ - `logLevel: 'debug' | 'info' | 'warn' | 'error' | 'silent'` — `'info'` surfaces
12
+ coordination and connection events without the per-model registration noise;
13
+ `'debug'` is everything. Precedence: explicit `logLevel` → `debug: true` →
14
+ `ABLO_LOG_LEVEL` env → default `warn`. Supplying your own `logger` bypasses both.
15
+ - `debug: boolean` — shorthand for `logLevel: 'debug'`.
16
+
17
+ Coordination is now traceable at `info`: claims that are **rejected** or **lost**
18
+ (preempted/expired), and your position **advancing in a claim queue**, each log
19
+ once per change with a readable target (`documents:abc.title`) — quiet lowercase
20
+ lines, no shouty tags.
21
+
22
+ **New: canonical wire-egress contract export.** `errorEnvelope`, `statusForType`,
23
+ and the `ErrorEnvelope` type are now exported from the package root. Server
24
+ consumers (e.g. a self-hosted sync server) can assert against the one source of
25
+ truth for the error-envelope shape and the `AbloError`-subclass→HTTP-status
26
+ table instead of keeping a copy that silently drifts.
27
+
28
+ **Structured CLI error rendering.** CLI failures render as a titled block with a
29
+ reason code and per-code remediation (`--verbose` for the stack) instead of a
30
+ console wall-of-text; `AbloError.toString()` produces a leak-proof one-liner.
31
+
32
+ **`ABLO_API_KEY` resolution + sandbox key scopes.** The key is now resolved from
33
+ `.env.local` / `.env` (not just the process env), and sandbox keys are granted
34
+ `schema:push` by default so `ablo push` works out of the box in a fresh sandbox.
35
+
36
+ ## 0.17.0
37
+
38
+ ### Minor Changes
39
+
40
+ - **Bring-your-own database is now one model.** Ablo connects to your Postgres and
41
+ never operates it. There used to be two confusing BYO paths, and the
42
+ connection-string one would create roles, force row-level security, transfer
43
+ table ownership, and push you to run `ablo migrate` before anything worked. That
44
+ cascade is gone. Ablo now follows the shape every serious "sync over your own
45
+ Postgres" engine uses (ElectricSQL, PowerSync, Zero): it reads your database via
46
+ Postgres logical replication and never runs DDL, creates roles, forces RLS, or
47
+ rewrites your `DATABASE_URL`. You own your schema; Ablo reads it.
48
+ - **New: `ablo connect`.** One command prints the exact, copy-pasteable setup for
49
+ your own Postgres — enable `wal_level=logical`, create the `ablo_publication`
50
+ publication and a least-privilege `ablo_replicator` role — and
51
+ `ablo connect --check` validates readiness (wal level, publication, replication
52
+ grant, replica identity). This is the single supported way to connect a real
53
+ database.
54
+ - **`ablo migrate` left the happy path.** It no longer creates roles, transfers
55
+ ownership, or rewrites your connection string, and `ablo dev` no longer attempts
56
+ a scoped-role creation on every watch loop. `migrate` is now an optional escape
57
+ hatch for generating starter DDL (`--dry-run` prints the SQL).
58
+ - **Clearer failures.** `ablo push` permission errors lead with the server's actual
59
+ reason code and per-code remediation instead of a generic "needs `schema:push`
60
+ scope," and the schema-conflict message names which environment/version a prior
61
+ push came from and when.
62
+ - **Logical-replication runtime is in Preview.** The setup (`ablo connect`) and the
63
+ connection model are live; the server-side WAL consumer that streams your changes
64
+ is implemented and journey-tested but not yet generally available.
65
+
66
+ The previous connection-string-operate and adapter/outbox modes are demoted to a
67
+ clearly-labeled **Legacy / not recommended** section — they still work, but new
68
+ integrations should use logical replication.
69
+
3
70
  ## 0.16.3
4
71
 
5
72
  ### Patch Changes
@@ -505,10 +505,11 @@ export declare class BaseSyncedStore<TCollaboration extends EventMap<TCollaborat
505
505
  * 1. REACTIVE — register `getToken` as the re-mint hook the FSM calls when a
506
506
  * probe finds the key stale (`credential_stale`) or on a nudge.
507
507
  * 2. PROACTIVE — keep the short-lived key fresh ahead of trouble: a refresh
508
- * timer inside the TTL, plus re-mint on OS wake / network-online / tab
509
- * focus. Browser-only triggers are env-gated, so Node/agent hosts get
510
- * only the timer (a no-op there agents use a static `apiKey`, no
511
- * resolver, so this is never called for them).
508
+ * timer inside the TTL, plus re-mint on OS wake. The ENTIRE proactive
509
+ * block is browser-gated (`typeof window`): server/SSR has no socket to
510
+ * keep warm and the resolver is browser-oriented, so arming it in Node
511
+ * would fire a relative-URL fetch and throw. (Agents pass a static
512
+ * `apiKey` with no resolver, so this method is never called for them.)
512
513
  *
513
514
  * Config-driven and invisible, like Supabase's `autoRefreshToken` — consumers
514
515
  * never call a refresh method. Idempotent (a second call replaces the first);
@@ -702,7 +703,9 @@ export declare class BaseSyncedStore<TCollaboration extends EventMap<TCollaborat
702
703
  * sync-group mutations). Does NOT schedule a flush — callers decide
703
704
  * whether to debounce (live) or flush atomically (catch-up frame).
704
705
  */
705
- protected enqueueDelta(delta: SyncDelta): boolean;
706
+ protected enqueueDelta(delta: SyncDelta, options?: {
707
+ authoritative?: boolean;
708
+ }): boolean;
706
709
  /** Debounce a flush for live single-delta traffic. */
707
710
  protected scheduleDeltaFlush(): void;
708
711
  /**
@@ -739,9 +739,21 @@ export class BaseSyncedStore {
739
739
  // A throw = transient (offline / mint endpoint unreachable / 5xx). The
740
740
  // login may be perfectly valid; never sign out for this — back off and
741
741
  // retry. Mirrors the `getToken` throw-vs-null contract end-to-end.
742
- getContext().logger.warn('[BaseSyncedStore] Access-credential re-mint failed (transient)', {
743
- error: error?.message,
744
- });
742
+ const message = error?.message ?? String(error);
743
+ // A relative-URL resolver invoked server-side (Node fetch has no origin
744
+ // to resolve against) emits the opaque "Failed to parse URL" / "Only
745
+ // absolute URLs are supported". Translate it into something actionable
746
+ // instead of a mystery transient blip — the proactive refresh is now
747
+ // browser-only, so hitting this means the resolver fired from SSR/RSC or
748
+ // a server route.
749
+ if (typeof window === 'undefined' && /parse URL|absolute URLs?/i.test(message)) {
750
+ getContext().logger.warn('credential resolver ran on the server with a relative URL — Node fetch needs an absolute URL. ' +
751
+ 'Refresh the Ablo client in the browser, or build an absolute URL server-side ' +
752
+ "(e.g. new URL('/api/ablo-session', process.env.NEXT_PUBLIC_APP_URL)).", { error: message });
753
+ }
754
+ else {
755
+ getContext().logger.warn('access-credential re-mint failed (transient)', { error: message });
756
+ }
745
757
  return 'network_error';
746
758
  }
747
759
  })();
@@ -769,10 +781,11 @@ export class BaseSyncedStore {
769
781
  * 1. REACTIVE — register `getToken` as the re-mint hook the FSM calls when a
770
782
  * probe finds the key stale (`credential_stale`) or on a nudge.
771
783
  * 2. PROACTIVE — keep the short-lived key fresh ahead of trouble: a refresh
772
- * timer inside the TTL, plus re-mint on OS wake / network-online / tab
773
- * focus. Browser-only triggers are env-gated, so Node/agent hosts get
774
- * only the timer (a no-op there agents use a static `apiKey`, no
775
- * resolver, so this is never called for them).
784
+ * timer inside the TTL, plus re-mint on OS wake. The ENTIRE proactive
785
+ * block is browser-gated (`typeof window`): server/SSR has no socket to
786
+ * keep warm and the resolver is browser-oriented, so arming it in Node
787
+ * would fire a relative-URL fetch and throw. (Agents pass a static
788
+ * `apiKey` with no resolver, so this method is never called for them.)
776
789
  *
777
790
  * Config-driven and invisible, like Supabase's `autoRefreshToken` — consumers
778
791
  * never call a refresh method. Idempotent (a second call replaces the first);
@@ -807,14 +820,25 @@ export class BaseSyncedStore {
807
820
  // 'network_error' → transient (offline / mint hiccup); the next timer tick
808
821
  // or the FSM's own probe retries. Never sign out for it.
809
822
  };
810
- // Comfortably inside the 15m `ek_` TTL; a missed (background-throttled) tick
811
- // is recovered by the next, or by the reactive probe. The timer is the sole
812
- // proactive PRE-ROLL it keeps the key warm ahead of expiry even while the
813
- // socket sits healthy-`connected` (a state the FSM never probes unprompted).
814
- const REFRESH_INTERVAL_MS = 10 * 60 * 1000;
815
- const timer = setInterval(() => void refresh(), REFRESH_INTERVAL_MS);
816
- const teardowns = [() => clearInterval(timer)];
823
+ const teardowns = [];
824
+ // The ENTIRE proactive pre-roll is BROWSER-ONLY. On the server (Next.js
825
+ // SSR/RSC evaluating the `providers` module) there is no live socket to keep
826
+ // warm AND the scaffolded credential resolver is browser-oriented (a
827
+ // relative-URL `fetch('/api/ablo-session')`). Arming the timer server-side
828
+ // fires that resolver in Node, where fetch has no origin to resolve a
829
+ // relative URL against "Failed to parse URL" on every tick. Browser-only
830
+ // refresh is the unanimous vendor model (Supabase `autoRefreshToken: isBrowser()`,
831
+ // Clerk/Ably/Stripe refresh client-side). The reactive re-mint hook
832
+ // (`setCredentialRefresher` above) stays UNCONDITIONAL: it only fires on a
833
+ // real connection probe, which can't happen during a bare SSR module eval.
817
834
  if (typeof window !== 'undefined') {
835
+ // Comfortably inside the 15m `ek_` TTL; a missed (background-throttled)
836
+ // tick is recovered by the next, or by the reactive probe. The timer is
837
+ // the sole proactive PRE-ROLL — it keeps the key warm ahead of expiry even
838
+ // while the socket sits healthy-`connected` (a state the FSM never probes).
839
+ const REFRESH_INTERVAL_MS = 10 * 60 * 1000;
840
+ const timer = setInterval(() => void refresh(), REFRESH_INTERVAL_MS);
841
+ teardowns.push(() => clearInterval(timer));
818
842
  // OS-wake (desktop only): the Electron shell bridges `powerMonitor`
819
843
  // 'resume' to this DOM event. This is the ONE event-trigger the lifecycle
820
844
  // still owns, because `visibilitychange` does NOT fire on wake-from-sleep
@@ -1713,7 +1737,25 @@ export class BaseSyncedStore {
1713
1737
  applyDeltaFrame(deltas) {
1714
1738
  let enqueuedAny = false;
1715
1739
  for (const delta of deltas) {
1716
- if (this.enqueueDelta(delta))
1740
+ // A delta_batch frame is the server's AUTHORITATIVE, ordered answer to
1741
+ // "everything in my stream after cursor C" (reconnect/catch-up replay or
1742
+ // post-bootstrap drain). Apply every delta it carries — do NOT subject it
1743
+ // to the live-traffic watermark dedup (`id <= applied`).
1744
+ //
1745
+ // That watermark is only valid under in-order delivery, and reconnect
1746
+ // breaks the assumption: an in-flight LIVE broadcast for a gap delta can
1747
+ // land out of order BEFORE the catch-up fills the ids below it (e.g. the
1748
+ // server acks a write, then the test/client reconnects, then that write's
1749
+ // pending broadcast arrives on the fresh socket — id 4 live before the
1750
+ // catch-up's [2,3,4]). Applying id 4 advances `applied` to 4, and the
1751
+ // watermark would then drop 2 and 3 from the catch-up as "already seen" —
1752
+ // a poisoned gap and a cursor that lies (applied=4 with rows 2,3 absent).
1753
+ //
1754
+ // Re-applying a delta the live path already applied is safe: the
1755
+ // downstream `Database.processDeltaBatch` + `SyncClient.applyDeltaBatchToPool`
1756
+ // are idempotent (echo detection, no row resurrection, conflict
1757
+ // resolution), so the redundant id 4 is a no-op while 2 and 3 land.
1758
+ if (this.enqueueDelta(delta, { authoritative: true }))
1717
1759
  enqueuedAny = true;
1718
1760
  }
1719
1761
  if (!enqueuedAny)
@@ -1734,10 +1776,15 @@ export class BaseSyncedStore {
1734
1776
  * sync-group mutations). Does NOT schedule a flush — callers decide
1735
1777
  * whether to debounce (live) or flush atomically (catch-up frame).
1736
1778
  */
1737
- enqueueDelta(delta) {
1738
- // Dedup guard — skip already-processed deltas
1739
- if (delta.id > 0 && delta.id <= this.highestProcessedSyncId)
1779
+ enqueueDelta(delta, options = {}) {
1780
+ // Dedup guard — skip already-processed deltas. The `applied` watermark is a
1781
+ // valid skip threshold ONLY for in-order live traffic; an authoritative
1782
+ // catch-up frame bypasses it (see `applyDeltaFrame`) so an out-of-order
1783
+ // live delta that advanced the watermark can't cause the frame's lower ids
1784
+ // to be silently dropped.
1785
+ if (!options.authoritative && delta.id > 0 && delta.id <= this.highestProcessedSyncId) {
1740
1786
  return false;
1787
+ }
1741
1788
  // Confirm awaiting transactions via sync ID threshold (before batching)
1742
1789
  this.syncClient.onDeltaReceived(delta.id);
1743
1790
  // Update version vector
@@ -31,7 +31,9 @@ export class NetworkMonitor extends EventEmitter {
31
31
  const wasOnline = this.isOnline;
32
32
  this.isOnline = false;
33
33
  if (wasOnline) {
34
- getContext().logger.warn('Network connection lost');
34
+ // Symmetric with 'Network connection restored' (info) — expected,
35
+ // transient connectivity state, not an actionable warning.
36
+ getContext().logger.info('Network connection lost');
35
37
  this.emit('offline');
36
38
  }
37
39
  };