@abloatai/ablo 0.17.0 → 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,38 @@
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
+
3
36
  ## 0.17.0
4
37
 
5
38
  ### Minor 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);
@@ -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
@@ -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
  };
package/dist/cli.cjs CHANGED
@@ -276700,7 +276700,7 @@ var Y2 = ({ indicator: t = "dots" } = {}) => {
276700
276700
  };
276701
276701
 
276702
276702
  // src/cli/index.ts
276703
- var import_picocolors18 = __toESM(require_picocolors(), 1);
276703
+ var import_picocolors19 = __toESM(require_picocolors(), 1);
276704
276704
  var import_fs12 = require("fs");
276705
276705
  var import_path7 = require("path");
276706
276706
  var import_child_process2 = require("child_process");
@@ -277009,6 +277009,18 @@ var ERROR_CODES = {
277009
277009
  invalid_schema: wire("validation", 400, false, "The submitted schema could not be parsed."),
277010
277010
  incompatible_change: wire("conflict", 409, false, "The schema change is incompatible with the current schema.")
277011
277011
  };
277012
+ function errorCodeSpec(code) {
277013
+ return ERROR_CODES[code];
277014
+ }
277015
+ function classifyRecovery(code) {
277016
+ const spec = errorCodeSpec(code);
277017
+ if (!spec) return "none";
277018
+ if (spec.recovery) return spec.recovery;
277019
+ if (spec.retryable) return "transient";
277020
+ if (spec.httpStatus === 403) return "permission";
277021
+ if (spec.category === "auth") return "auth_blocked";
277022
+ return "none";
277023
+ }
277012
277024
 
277013
277025
  // src/coordination/schema.ts
277014
277026
  init_cjs_shims();
@@ -277331,6 +277343,20 @@ var AbloError = class extends Error {
277331
277343
  ...this.details ?? {}
277332
277344
  };
277333
277345
  }
277346
+ /**
277347
+ * A single, leak-proof line for logs and `String(err)` / template
277348
+ * interpolation: `AbloValidationError [code]: message (see docs) [request_id: …]`.
277349
+ *
277350
+ * Deliberately does NOT dump `details`, `cause`, or the stack — the thing that
277351
+ * makes `console.error(richError)` an unreadable wall of text. The structured
277352
+ * payload stays available via {@link toJSON}; this is the human one-liner.
277353
+ */
277354
+ toString() {
277355
+ const code = this.code ? ` [${this.code}]` : "";
277356
+ const docs = this.docUrl ? ` (see ${this.docUrl})` : "";
277357
+ const req = this.requestId ? ` [request_id: ${this.requestId}]` : "";
277358
+ return `${this.name}${code}: ${this.message}${docs}${req}`;
277359
+ }
277334
277360
  };
277335
277361
  function docUrlForCode(code) {
277336
277362
  return `https://docs.abloatai.com/errors#${code}`;
@@ -279530,6 +279556,16 @@ function readProjectDatabaseUrl(cwd = process.cwd()) {
279530
279556
  }
279531
279557
  return null;
279532
279558
  }
279559
+ function readProjectApiKey(cwd = process.cwd()) {
279560
+ if (process.env.ABLO_API_KEY) return { key: process.env.ABLO_API_KEY, source: "env" };
279561
+ for (const name of [".env.local", ".env"]) {
279562
+ const path = (0, import_path.resolve)(cwd, name);
279563
+ if (!(0, import_fs2.existsSync)(path)) continue;
279564
+ const match = (0, import_fs2.readFileSync)(path, "utf8").match(/^ABLO_API_KEY=(.+)$/m);
279565
+ if (match?.[1]) return { key: match[1].trim().replace(/^["']|["']$/g, ""), source: name };
279566
+ }
279567
+ return null;
279568
+ }
279533
279569
 
279534
279570
  // src/cli/migrate.ts
279535
279571
  var import_schema3 = require("@abloatai/ablo/schema");
@@ -279886,6 +279922,21 @@ async function loadSchema(schemaPath, exportName) {
279886
279922
  }
279887
279923
  return schema;
279888
279924
  }
279925
+ function maskKey(key) {
279926
+ return key ? `${key.slice(0, 12)}\u2026` : "(none)";
279927
+ }
279928
+ function describeKeySource(source) {
279929
+ switch (source) {
279930
+ case "env":
279931
+ return "ABLO_API_KEY (environment)";
279932
+ case ".env.local":
279933
+ return ".env.local";
279934
+ case ".env":
279935
+ return ".env";
279936
+ case "login":
279937
+ return "`ablo login` (stored sandbox config)";
279938
+ }
279939
+ }
279889
279940
  async function push(argv) {
279890
279941
  let args;
279891
279942
  try {
@@ -279894,7 +279945,17 @@ async function push(argv) {
279894
279945
  console.error(import_picocolors2.default.red(` ${err instanceof Error ? err.message : String(err)}`));
279895
279946
  process.exit(1);
279896
279947
  }
279897
- if (!args.apiKey) args.apiKey = resolveApiKey();
279948
+ let keySource = "env";
279949
+ if (!args.apiKey) {
279950
+ const fromProject = readProjectApiKey();
279951
+ if (fromProject) {
279952
+ args.apiKey = fromProject.key;
279953
+ keySource = fromProject.source;
279954
+ } else {
279955
+ args.apiKey = resolveApiKey();
279956
+ keySource = "login";
279957
+ }
279958
+ }
279898
279959
  if (!args.apiKey) {
279899
279960
  console.error(
279900
279961
  import_picocolors2.default.red(` No API key.`) + import_picocolors2.default.dim(
@@ -279953,6 +280014,7 @@ async function push(argv) {
279953
280014
  const code = body.code ?? body.reason;
279954
280015
  const serverMsg = body.message ?? body.reason;
279955
280016
  console.error(import_picocolors2.default.red(` Forbidden${code ? ` [${code}]` : ""}: ${serverMsg ?? "permission denied"}`));
280017
+ console.error(import_picocolors2.default.dim(` Push used ${import_picocolors2.default.bold(maskKey(args.apiKey))} from ${describeKeySource(keySource)}.`));
279956
280018
  if (code === "database_role_cannot_enforce_rls") {
279957
280019
  console.error(
279958
280020
  import_picocolors2.default.dim(
@@ -279971,6 +280033,12 @@ async function push(argv) {
279971
280033
  ` Schema pushes need a SECRET key: ${import_picocolors2.default.bold("sk_test_")} (sandbox dev loop) or a dashboard ${import_picocolors2.default.bold("sk_live_")} (production deploy: ${import_picocolors2.default.bold("ABLO_API_KEY=sk_live_\u2026 npx ablo push")}).`
279972
280034
  )
279973
280035
  );
280036
+ } else {
280037
+ console.error(
280038
+ import_picocolors2.default.dim(
280039
+ ` This key isn't authorized to push schema (needs ${import_picocolors2.default.bold("schema:push")}). ` + (keySource === "login" ? `It's your stored ${import_picocolors2.default.bold("ablo login")} sandbox key \u2014 a key in ${import_picocolors2.default.bold(".env.local")} or ${import_picocolors2.default.bold("ABLO_API_KEY")} takes precedence, so put a schema:push key there (sandbox ${import_picocolors2.default.bold("sk_test_")} or production ${import_picocolors2.default.bold("sk_live_")}) and re-push. ` : `Use a schema:push key \u2014 a sandbox ${import_picocolors2.default.bold("sk_test_")} or production ${import_picocolors2.default.bold("sk_live_")}. `) + `Manage keys at https://abloatai.com`
280040
+ )
280041
+ );
279974
280042
  }
279975
280043
  } else {
279976
280044
  console.error(import_picocolors2.default.red(` Push failed (${status2}): ${body.message ?? body.reason ?? bodyText}`));
@@ -282500,9 +282568,86 @@ async function drizzlePull(argv) {
282500
282568
  );
282501
282569
  }
282502
282570
 
282571
+ // src/cli/renderError.ts
282572
+ init_cjs_shims();
282573
+ var import_picocolors18 = __toESM(require_picocolors(), 1);
282574
+ var RECOVERY_HINT = {
282575
+ transient: "This looks transient \u2014 retry in a moment.",
282576
+ permission: "Your key isn't allowed to do this \u2014 check its scopes or role.",
282577
+ session_expiry: "Your session expired \u2014 sign in again.",
282578
+ access_credential_expiry: "Your access credential expired \u2014 refresh it and retry.",
282579
+ auth_blocked: "Authentication was blocked."
282580
+ };
282581
+ function titleForType(type) {
282582
+ const core = type.replace(/^Ablo/, "").replace(/Error$/, "");
282583
+ const spaced = core.replace(/([a-z0-9])([A-Z])/g, "$1 $2").trim();
282584
+ if (!spaced) return "Error";
282585
+ return /error$/i.test(spaced) ? spaced : `${spaced} error`;
282586
+ }
282587
+ function isStringArray(v) {
282588
+ return Array.isArray(v) && v.every((x2) => typeof x2 === "string");
282589
+ }
282590
+ function renderKnownDetails(details, line) {
282591
+ if (!details) return;
282592
+ const { retryAfterSeconds, missingIds, requiredCapability, unexecutable, errors } = details;
282593
+ if (typeof retryAfterSeconds === "number") line(` ${import_picocolors18.default.dim("retry")} after ${retryAfterSeconds}s`);
282594
+ if (isStringArray(missingIds) && missingIds.length > 0) {
282595
+ const shown = missingIds.slice(0, 5).join(", ");
282596
+ const more = missingIds.length > 5 ? ` (+${missingIds.length - 5} more)` : "";
282597
+ line(` ${import_picocolors18.default.dim("missing")} ${shown}${more}`);
282598
+ }
282599
+ if (typeof requiredCapability === "string") line(` ${import_picocolors18.default.dim("needs")} ${requiredCapability}`);
282600
+ if (Array.isArray(unexecutable) && unexecutable.length > 0) {
282601
+ line(` ${import_picocolors18.default.dim("blocked")} ${unexecutable.length} change(s) can't be applied \u2014 see \`unexecutable\` (--verbose)`);
282602
+ }
282603
+ if (Array.isArray(errors)) {
282604
+ for (const e2 of errors.slice(0, 8)) {
282605
+ if (e2 && typeof e2 === "object") {
282606
+ const rec = e2;
282607
+ const where = typeof rec.param === "string" ? `${rec.param}: ` : "";
282608
+ const msg = typeof rec.message === "string" ? rec.message : "";
282609
+ if (msg) line(` ${import_picocolors18.default.dim("\xB7")} ${where}${msg}`);
282610
+ }
282611
+ }
282612
+ }
282613
+ }
282614
+ function renderCliError(err, opts = {}) {
282615
+ const line = opts.write ?? ((l2) => console.error(l2));
282616
+ const verbose = opts.verbose ?? (process.argv.includes("--verbose") || process.env.ABLO_VERBOSE === "1");
282617
+ if (err instanceof AbloError) {
282618
+ const codeTag = err.code ? ` ${import_picocolors18.default.dim(`[${err.code}]`)}` : "";
282619
+ line("");
282620
+ line(` ${brand("ablo")} ${import_picocolors18.default.red("\u2717")} ${import_picocolors18.default.bold(titleForType(err.type))}${codeTag}`);
282621
+ line("");
282622
+ line(` ${err.message}`);
282623
+ if (err.param) line(` ${import_picocolors18.default.dim("field")} ${err.param}`);
282624
+ renderKnownDetails(err.details, line);
282625
+ const hint = err.code ? RECOVERY_HINT[classifyRecovery(err.code)] : void 0;
282626
+ if (hint) line(` ${import_picocolors18.default.dim(hint)}`);
282627
+ if (err.docUrl) line(` ${import_picocolors18.default.dim("docs")} ${err.docUrl}`);
282628
+ if (err.requestId) line(` ${import_picocolors18.default.dim("ref")} ${err.requestId}`);
282629
+ if (verbose) {
282630
+ if (err.details && Object.keys(err.details).length > 0) {
282631
+ line(` ${import_picocolors18.default.dim("details")} ${JSON.stringify(err.details)}`);
282632
+ }
282633
+ if (err.stack) line(import_picocolors18.default.dim(err.stack));
282634
+ }
282635
+ line("");
282636
+ process.exitCode = 1;
282637
+ return;
282638
+ }
282639
+ const message = err instanceof Error ? err.message : String(err);
282640
+ line("");
282641
+ line(` ${brand("ablo")} ${import_picocolors18.default.red("\u2717")} ${message}`);
282642
+ if (verbose && err instanceof Error && err.stack) line(import_picocolors18.default.dim(err.stack));
282643
+ else line(` ${import_picocolors18.default.dim("Run with --verbose for the full error.")}`);
282644
+ line("");
282645
+ process.exitCode = 1;
282646
+ }
282647
+
282503
282648
  // src/cli/index.ts
282504
282649
  var LOGO = `
282505
- ${brand("ablo")} ${import_picocolors18.default.dim("sync engine")}
282650
+ ${brand("ablo")} ${import_picocolors19.default.dim("sync engine")}
282506
282651
  `;
282507
282652
  var SUBCOMMAND_USAGE = {
282508
282653
  connect: CONNECT_USAGE,
@@ -282537,7 +282682,7 @@ async function main() {
282537
282682
  const devArgs = process.argv.slice(3);
282538
282683
  const oneShot = devArgs.includes("--no-watch");
282539
282684
  console.log(
282540
- import_picocolors18.default.dim(
282685
+ import_picocolors19.default.dim(
282541
282686
  oneShot ? " `ablo dev --no-watch` is `ablo push` (push once, no watcher) \u2014 running that." : " `ablo dev` is now `ablo push --watch` \u2014 running that."
282542
282687
  )
282543
282688
  );
@@ -282564,14 +282709,14 @@ async function main() {
282564
282709
  const guard = guardActiveProjectKey();
282565
282710
  if (!guard.ok && guard.available.length > 0 && !rest.includes("--url")) {
282566
282711
  console.error(
282567
- ` ${import_picocolors18.default.yellow("\u26A0")} active project ${import_picocolors18.default.bold(guard.activeProfile)} has no stored key ${import_picocolors18.default.dim(
282712
+ ` ${import_picocolors19.default.yellow("\u26A0")} active project ${import_picocolors19.default.bold(guard.activeProfile)} has no stored key ${import_picocolors19.default.dim(
282568
282713
  `(you have keys for: ${guard.available.join(", ")})`
282569
282714
  )}`
282570
282715
  );
282571
282716
  const loginCmd = guard.activeProfile === "default" ? "ablo login" : `ablo login --project ${guard.activeProfile}`;
282572
282717
  console.error(
282573
- import_picocolors18.default.dim(
282574
- ` Mint one with ${import_picocolors18.default.bold(loginCmd)}, or switch with ${import_picocolors18.default.bold("ablo projects use <slug>")}.`
282718
+ import_picocolors19.default.dim(
282719
+ ` Mint one with ${import_picocolors19.default.bold(loginCmd)}, or switch with ${import_picocolors19.default.bold("ablo projects use <slug>")}.`
282575
282720
  )
282576
282721
  );
282577
282722
  process.exitCode = 1;
@@ -282589,13 +282734,13 @@ async function main() {
282589
282734
  await generate(process.argv.slice(3));
282590
282735
  } else if (command === "schema") {
282591
282736
  console.error(
282592
- ` ${import_picocolors18.default.red("\u2717")} \`ablo schema push\` was renamed to \`${brand("ablo push")}\`.`
282737
+ ` ${import_picocolors19.default.red("\u2717")} \`ablo schema push\` was renamed to \`${brand("ablo push")}\`.`
282593
282738
  );
282594
282739
  console.error(` Run \`ablo push${process.argv.slice(4).join(" ") ? " " + process.argv.slice(4).join(" ") : ""}\` instead.`);
282595
282740
  process.exitCode = 1;
282596
282741
  } else {
282597
282742
  console.log(LOGO);
282598
- console.log(` ${import_picocolors18.default.bold("Usage:")}`);
282743
+ console.log(` ${import_picocolors19.default.bold("Usage:")}`);
282599
282744
  console.log(` npx ablo init Scaffold ablo/ directory + starter schema`);
282600
282745
  console.log(` npx ablo init --yes [--framework nextjs] Non-interactive (agents/CI): no prompts, flag-driven`);
282601
282746
  console.log(` [--auth apikey] [--storage direct|endpoint] [--project <slug>] [--no-project]`);
@@ -282630,10 +282775,10 @@ async function main() {
282630
282775
  console.log(` npx ablo generate Emit TypeScript types from your schema`);
282631
282776
  console.log(` npx ablo generate --out path.ts Write generated types to a path`);
282632
282777
  console.log();
282633
- console.log(` ${import_picocolors18.default.bold("Schema workflow:")}`);
282778
+ console.log(` ${import_picocolors19.default.bold("Schema workflow:")}`);
282634
282779
  console.log(` The server holds its own copy of your schema \u2014 edit ${brand("ablo/schema.ts")}, then`);
282635
282780
  console.log(` run ${brand("ablo push")} (or keep ${brand("ablo dev")} running) before the server will accept`);
282636
- console.log(` writes to new or changed models. Skip it and writes fail with ${import_picocolors18.default.yellow("server_execute_unknown_model")}.`);
282781
+ console.log(` writes to new or changed models. Skip it and writes fail with ${import_picocolors19.default.yellow("server_execute_unknown_model")}.`);
282637
282782
  console.log();
282638
282783
  }
282639
282784
  }
@@ -282684,7 +282829,7 @@ async function ensureInitProject(opts) {
282684
282829
  const ensured = await ensureProject(slug);
282685
282830
  if (ensured) {
282686
282831
  console.log(
282687
- ` ${import_picocolors18.default.green("\u2713")} ${ensured.created ? "Created" : "Using"} project ${import_picocolors18.default.bold(ensured.slug)} ${import_picocolors18.default.dim(`(${ensured.id})`)} \u2014 keys you mint for it are isolated from the org's other apps.`
282832
+ ` ${import_picocolors19.default.green("\u2713")} ${ensured.created ? "Created" : "Using"} project ${import_picocolors19.default.bold(ensured.slug)} ${import_picocolors19.default.dim(`(${ensured.id})`)} \u2014 keys you mint for it are isolated from the org's other apps.`
282688
282833
  );
282689
282834
  }
282690
282835
  }
@@ -282726,7 +282871,7 @@ async function chooseBool(flagValue, fallback, interactive, prompt) {
282726
282871
  async function init(args = []) {
282727
282872
  const opts = parseInitArgs(args);
282728
282873
  const interactive = Boolean(process.stdin.isTTY) && !opts.yes && !process.env.CI;
282729
- Ie(`${brand("ablo")} ${import_picocolors18.default.dim("sync engine")}`);
282874
+ Ie(`${brand("ablo")} ${import_picocolors19.default.dim("sync engine")}`);
282730
282875
  if (!(0, import_fs12.existsSync)("package.json")) {
282731
282876
  xe("No package.json found. Run this from your project root.");
282732
282877
  process.exit(1);
@@ -282811,7 +282956,7 @@ async function init(args = []) {
282811
282956
  if (pullExisting) {
282812
282957
  const dbUrl = process.env.DATABASE_URL ?? process.env.ABLO_DATABASE_URL;
282813
282958
  if (!dbUrl) {
282814
- schemaNote = import_picocolors18.default.dim(" (no DATABASE_URL \u2014 wrote starter; run `ablo pull` later)");
282959
+ schemaNote = import_picocolors19.default.dim(" (no DATABASE_URL \u2014 wrote starter; run `ablo pull` later)");
282815
282960
  } else {
282816
282961
  try {
282817
282962
  const pulled = await buildSchemaSourceFromDb({
@@ -282821,12 +282966,12 @@ async function init(args = []) {
282821
282966
  });
282822
282967
  if (pulled.models.length > 0) {
282823
282968
  schemaSource = pulled.source;
282824
- schemaNote = import_picocolors18.default.dim(` (pulled ${pulled.models.length} models)`);
282969
+ schemaNote = import_picocolors19.default.dim(` (pulled ${pulled.models.length} models)`);
282825
282970
  } else {
282826
- schemaNote = import_picocolors18.default.dim(" (no adoptable tables \u2014 wrote starter)");
282971
+ schemaNote = import_picocolors19.default.dim(" (no adoptable tables \u2014 wrote starter)");
282827
282972
  }
282828
282973
  } catch {
282829
- schemaNote = import_picocolors18.default.dim(" (pull failed \u2014 wrote starter)");
282974
+ schemaNote = import_picocolors19.default.dim(" (pull failed \u2014 wrote starter)");
282830
282975
  }
282831
282976
  }
282832
282977
  }
@@ -282852,14 +282997,14 @@ async function init(args = []) {
282852
282997
  const existing = (0, import_fs12.readFileSync)(envFile, "utf-8");
282853
282998
  if (!existing.includes("ABLO_")) {
282854
282999
  (0, import_fs12.writeFileSync)(envFile, existing + "\n" + envBody);
282855
- created.push(`${envFile} ${import_picocolors18.default.dim("(appended)")}`);
283000
+ created.push(`${envFile} ${import_picocolors19.default.dim("(appended)")}`);
282856
283001
  } else {
282857
- created.push(`${envFile} ${import_picocolors18.default.dim("(already configured)")}`);
283002
+ created.push(`${envFile} ${import_picocolors19.default.dim("(already configured)")}`);
282858
283003
  }
282859
283004
  }
282860
283005
  if (wireRealKey && resolvedKey) {
282861
283006
  wireEnvLocal(resolvedKey);
282862
- created.push(`.env.local ${import_picocolors18.default.dim("(ABLO_API_KEY set from your login)")}`);
283007
+ created.push(`.env.local ${import_picocolors19.default.dim("(ABLO_API_KEY set from your login)")}`);
282863
283008
  }
282864
283009
  if (agent) {
282865
283010
  (0, import_fs12.writeFileSync)((0, import_path7.join)(abloDir, "agent.ts"), generateAgent());
@@ -282874,17 +283019,17 @@ async function init(args = []) {
282874
283019
  }
282875
283020
  const providersPath = (0, import_path7.join)(layout.appBase, "providers.tsx");
282876
283021
  (0, import_fs12.writeFileSync)(providersPath, generateProviders());
282877
- created.push(`${providersPath} ${import_picocolors18.default.dim(`(wrap ${(0, import_path7.join)(layout.appBase, "layout.tsx")} in <Providers>)`)}`);
283022
+ created.push(`${providersPath} ${import_picocolors19.default.dim(`(wrap ${(0, import_path7.join)(layout.appBase, "layout.tsx")} in <Providers>)`)}`);
282878
283023
  const sessionDir = (0, import_path7.join)(layout.appBase, "api", "ablo-session");
282879
283024
  (0, import_fs12.mkdirSync)(sessionDir, { recursive: true });
282880
283025
  (0, import_fs12.writeFileSync)((0, import_path7.join)(sessionDir, "route.ts"), generateSessionRoute());
282881
- created.push(`${(0, import_path7.join)(sessionDir, "route.ts")} ${import_picocolors18.default.dim("(wire your auth)")}`);
283026
+ created.push(`${(0, import_path7.join)(sessionDir, "route.ts")} ${import_picocolors19.default.dim("(wire your auth)")}`);
282882
283027
  }
282883
283028
  if (framework !== "vanilla") {
282884
283029
  (0, import_fs12.writeFileSync)((0, import_path7.join)(abloDir, "TaskList.tsx"), generateComponent());
282885
283030
  created.push(`${abloDir}/TaskList.tsx`);
282886
283031
  }
282887
- Me(created.map((f) => `${import_picocolors18.default.green("\u2713")} ${f}`).join("\n"), "Created");
283032
+ Me(created.map((f) => `${import_picocolors19.default.green("\u2713")} ${f}`).join("\n"), "Created");
282888
283033
  const pm = detectPackageManager();
282889
283034
  if (opts.install) {
282890
283035
  const s = Y2();
@@ -282893,45 +283038,45 @@ async function init(args = []) {
282893
283038
  (0, import_child_process2.execSync)(`${pm} add @abloatai/ablo`, { stdio: "ignore" });
282894
283039
  s.stop("Installed @abloatai/ablo");
282895
283040
  } catch {
282896
- s.stop(`${import_picocolors18.default.yellow("!")} Couldn't auto-install \u2014 run ${import_picocolors18.default.bold(`${pm} install @abloatai/ablo`)}`);
283041
+ s.stop(`${import_picocolors19.default.yellow("!")} Couldn't auto-install \u2014 run ${import_picocolors19.default.bold(`${pm} install @abloatai/ablo`)}`);
282897
283042
  }
282898
283043
  }
282899
283044
  const steps = [
282900
- `Get a ${import_picocolors18.default.bold("sk_test_")} key at ${import_picocolors18.default.cyan("https://abloatai.com")}`,
282901
- `Run ${import_picocolors18.default.bold("npx ablo login")} (or add ${import_picocolors18.default.bold("ABLO_API_KEY")} to ${import_picocolors18.default.bold(envFile)})`,
282902
- `Set ${import_picocolors18.default.bold("DATABASE_URL")} in ${import_picocolors18.default.bold(envFile)} \u2014 your Postgres is the system of record; rows live there, never with Ablo`,
282903
- `Run ${import_picocolors18.default.bold("npx ablo dev")} \u2014 pushes your schema definition and watches for changes`,
283045
+ `Get a ${import_picocolors19.default.bold("sk_test_")} key at ${import_picocolors19.default.cyan("https://abloatai.com")}`,
283046
+ `Run ${import_picocolors19.default.bold("npx ablo login")} (or add ${import_picocolors19.default.bold("ABLO_API_KEY")} to ${import_picocolors19.default.bold(envFile)})`,
283047
+ `Set ${import_picocolors19.default.bold("DATABASE_URL")} in ${import_picocolors19.default.bold(envFile)} \u2014 your Postgres is the system of record; rows live there, never with Ablo`,
283048
+ `Run ${import_picocolors19.default.bold("npx ablo dev")} \u2014 pushes your schema definition and watches for changes`,
282904
283049
  ...storage === "direct" ? [
282905
- `Provision your DB: ${import_picocolors18.default.bold("npx ablo migrate")} (creates your synced-model tables with row-level security; keep your own migrations for everything else)`
283050
+ `Provision your DB: ${import_picocolors19.default.bold("npx ablo migrate")} (creates your synced-model tables with row-level security; keep your own migrations for everything else)`
282906
283051
  ] : [
282907
- `Provision your DB: ${import_picocolors18.default.bold("npx ablo migrate")} (creates your Ablo-model tables + the adapter tables; keep your own migrations for everything else), then mount ${import_picocolors18.default.bold(`${abloDir}/data-source.ts`)} at ${import_picocolors18.default.bold("/api/ablo/source")}`
283052
+ `Provision your DB: ${import_picocolors19.default.bold("npx ablo migrate")} (creates your Ablo-model tables + the adapter tables; keep your own migrations for everything else), then mount ${import_picocolors19.default.bold(`${abloDir}/data-source.ts`)} at ${import_picocolors19.default.bold("/api/ablo/source")}`
282908
283053
  ],
282909
283054
  ...framework === "nextjs" ? [
282910
- `Wrap ${import_picocolors18.default.bold((0, import_path7.join)(layout.appBase, "layout.tsx"))} in ${import_picocolors18.default.bold("<Providers>")} (${(0, import_path7.join)(layout.appBase, "providers.tsx")}) and add your auth to ${import_picocolors18.default.bold((0, import_path7.join)(layout.appBase, "api", "ablo-session", "route.ts"))}`
283055
+ `Wrap ${import_picocolors19.default.bold((0, import_path7.join)(layout.appBase, "layout.tsx"))} in ${import_picocolors19.default.bold("<Providers>")} (${(0, import_path7.join)(layout.appBase, "providers.tsx")}) and add your auth to ${import_picocolors19.default.bold((0, import_path7.join)(layout.appBase, "api", "ablo-session", "route.ts"))}`
282911
283056
  ] : [],
282912
- `Run ${import_picocolors18.default.bold(`${pm} run dev`)} and open two browser tabs \u2014 changes sync in real-time`,
283057
+ `Run ${import_picocolors19.default.bold(`${pm} run dev`)} and open two browser tabs \u2014 changes sync in real-time`,
282913
283058
  ...agent ? [
282914
- `Run ${import_picocolors18.default.bold(`npx tsx ${abloDir}/agent.ts`)} \u2014 an AI teammate edits the same tasks`,
282915
- `Run ${import_picocolors18.default.bold("npx ablo logs")} to watch human + agent commits stream by`
283059
+ `Run ${import_picocolors19.default.bold(`npx tsx ${abloDir}/agent.ts`)} \u2014 an AI teammate edits the same tasks`,
283060
+ `Run ${import_picocolors19.default.bold("npx ablo logs")} to watch human + agent commits stream by`
282916
283061
  ] : []
282917
283062
  ];
282918
283063
  Me(steps.map((s, i) => `${i + 1}. ${s}`).join("\n"), "Next steps");
282919
283064
  const existingKey = resolveApiKey("sandbox");
282920
283065
  if (existingKey) {
282921
283066
  await ensureInitProject(opts);
282922
- Se(`Already authorized ${import_picocolors18.default.dim(`(${existingKey.slice(0, 11)}\u2026)`)} \u2014 run ${import_picocolors18.default.bold("npx ablo push")} next. ${import_picocolors18.default.dim("Docs:")} https://abloatai.com/docs`);
283067
+ Se(`Already authorized ${import_picocolors19.default.dim(`(${existingKey.slice(0, 11)}\u2026)`)} \u2014 run ${import_picocolors19.default.bold("npx ablo push")} next. ${import_picocolors19.default.dim("Docs:")} https://abloatai.com/docs`);
282923
283068
  return;
282924
283069
  }
282925
283070
  if (interactive && opts.login) {
282926
283071
  const loginNow = await ye({ message: "Log in now? (opens your browser)", initialValue: true });
282927
283072
  if (!pD(loginNow) && loginNow) {
282928
- Se(`${import_picocolors18.default.dim("Docs:")} https://abloatai.com/docs`);
283073
+ Se(`${import_picocolors19.default.dim("Docs:")} https://abloatai.com/docs`);
282929
283074
  await login();
282930
283075
  await ensureInitProject(opts);
282931
283076
  return;
282932
283077
  }
282933
283078
  }
282934
- Se(`Run ${import_picocolors18.default.bold("npx ablo login")} when ready. ${import_picocolors18.default.dim("Docs:")} https://abloatai.com/docs`);
283079
+ Se(`Run ${import_picocolors19.default.bold("npx ablo login")} when ready. ${import_picocolors19.default.dim("Docs:")} https://abloatai.com/docs`);
282935
283080
  }
282936
283081
  function generateSchema() {
282937
283082
  return `import { defineSchema, model, relation, z } from '@abloatai/ablo/schema';
@@ -283209,7 +283354,9 @@ async function main() {
283209
283354
  }
283210
283355
 
283211
283356
  main().catch((err) => {
283212
- console.error(err);
283357
+ // Ablo errors stringify to one clean line (code + message + docs link),
283358
+ // never a stack/object dump \u2014 see AbloError.toString().
283359
+ console.error(String(err));
283213
283360
  process.exit(1);
283214
283361
  });
283215
283362
  `;
@@ -283296,22 +283443,33 @@ export function Providers({ children }: { children: React.ReactNode }) {
283296
283443
  `;
283297
283444
  }
283298
283445
  function generateSessionRoute() {
283299
- return `import { sync } from '@/ablo';
283446
+ return `import { headers } from 'next/headers';
283447
+ import { sync } from '@/ablo';
283448
+ import { auth } from '@/lib/auth'; // your server-side betterAuth({ ... }) instance
283300
283449
 
283301
283450
  // Mints the browser's session token. The browser never sees your sk_ key \u2014 it
283302
- // only gets this token, which already names your org + the user you assert here.
283303
- // Replace getCurrentUser() with your real auth (NextAuth / Clerk / Better Auth / \u2026).
283451
+ // only gets this short-lived token, already scoped to your org + the user you
283452
+ // assert here. The browser fetches THIS route at a relative URL
283453
+ // ('/api/ablo-session') \u2014 that's correct + best practice (same-origin, cookies
283454
+ // flow automatically); the SDK refreshes it in the browser only.
283304
283455
  export async function POST(): Promise<Response> {
283305
- const user = await getCurrentUser(); // \u2190 your auth: who is making this request?
283456
+ const user = await getCurrentUser();
283306
283457
  if (!user) return new Response('Unauthorized', { status: 401 });
283307
283458
 
283308
283459
  const { token } = await sync.sessions.create({ user: { id: user.id } });
283309
283460
  return Response.json({ token });
283310
283461
  }
283311
283462
 
283312
- // TODO: replace with your auth provider's "current user" lookup.
283463
+ // Validate the signed-in user SERVER-SIDE. This ships wired for Better Auth;
283464
+ // \`getSession\` is the server method \u2014 pass the request headers (async in current
283465
+ // Next.js), it does NOT read cookies implicitly. Using a different provider?
283466
+ // Replace the body:
283467
+ // \u2022 NextAuth: const s = await auth(); return s?.user ? { id: s.user.id } : null;
283468
+ // \u2022 Clerk: const { userId } = await auth(); return userId ? { id: userId } : null;
283469
+ // \u2022 Supabase: const { data } = await supabase.auth.getUser(); return data.user ? { id: data.user.id } : null;
283313
283470
  async function getCurrentUser(): Promise<{ id: string } | null> {
283314
- return null;
283471
+ const session = await auth.api.getSession({ headers: await headers() });
283472
+ return session?.user ? { id: session.user.id } : null;
283315
283473
  }
283316
283474
  `;
283317
283475
  }
@@ -283321,7 +283479,10 @@ function detectPackageManager() {
283321
283479
  if ((0, import_fs12.existsSync)("bun.lockb")) return "bun";
283322
283480
  return "npm";
283323
283481
  }
283324
- main().catch(console.error);
283482
+ main().catch((err) => {
283483
+ renderCliError(err);
283484
+ process.exit(process.exitCode ?? 1);
283485
+ });
283325
283486
  /*! Bundled license information:
283326
283487
 
283327
283488
  @ts-morph/common/dist/typescript.js:
@@ -133,6 +133,22 @@ export interface AbloOptions<S extends SchemaRecord = SchemaRecord> {
133
133
  * @default 'websocket'
134
134
  */
135
135
  transport?: 'websocket' | 'http' | undefined;
136
+ /**
137
+ * Turn Ablo's diagnostic logging on/off. `true` surfaces the `[Ablo]`
138
+ * coordination trace — claims requested / queued / granted / released, agent
139
+ * handovers, connection state — so you can SEE the human+agent coordination
140
+ * you built while debugging. Omitted/`false` keeps the quiet default (only
141
+ * warnings + errors). For a middle ground use {@link logLevel}. Env override:
142
+ * `ABLO_LOG_LEVEL`. Ignored if a custom logger is supplied.
143
+ */
144
+ debug?: boolean | undefined;
145
+ /**
146
+ * Log threshold for the default `[Ablo]` logger (takes precedence over
147
+ * {@link debug}). `'info'` = coordination + connection events without the
148
+ * per-model registration firehose; `'debug'` = everything; `'warn'` (default)
149
+ * = warnings + errors only; `'silent'` = nothing. Env override: `ABLO_LOG_LEVEL`.
150
+ */
151
+ logLevel?: 'debug' | 'info' | 'warn' | 'error' | 'silent' | undefined;
136
152
  /**
137
153
  * Bearer auth token. Hosted-cloud consumers pass `apiKey`; self-hosted
138
154
  * deployments may pass a bearer token minted by their own auth layer.
@@ -242,8 +258,24 @@ export interface InternalAbloOptions<S extends SchemaRecord = SchemaRecord> {
242
258
  * `apiKey` only; the server handles capability issuance.
243
259
  */
244
260
  capabilityToken?: string;
245
- /** Custom logger (default: console) */
261
+ /** Custom logger (default: console). Supplying one bypasses {@link debug}/{@link logLevel}. */
246
262
  logger?: SyncLogger;
263
+ /**
264
+ * Turn Ablo's diagnostic logging on/off. `true` surfaces the `[Ablo]`
265
+ * coordination trace — claims acquired / queued / granted / released, agent
266
+ * handovers, connection state — plus internal lifecycle, so you can SEE the
267
+ * human+agent coordination you built. Omitted/`false` keeps the quiet default
268
+ * (only warnings + errors). For a middle ground use {@link logLevel}.
269
+ * Env override: `ABLO_LOG_LEVEL`. Ignored if a custom {@link logger} is passed.
270
+ */
271
+ debug?: boolean;
272
+ /**
273
+ * Log threshold for the default `[Ablo]` logger (takes precedence over
274
+ * {@link debug}). `'info'` = coordination + connection events without the
275
+ * per-model registration firehose; `'debug'` = everything; `'warn'` (default)
276
+ * = warnings + errors only; `'silent'` = nothing. Env override: `ABLO_LOG_LEVEL`.
277
+ */
278
+ logLevel?: 'debug' | 'info' | 'warn' | 'error' | 'silent';
247
279
  /** ObjectPool size limit (default: 10000) */
248
280
  maxPoolSize?: number;
249
281
  /**
@@ -519,7 +519,17 @@ function createDynamicModelClass(modelName, jsonSubFields, fieldNames, computed,
519
519
  return ModelClass;
520
520
  }
521
521
  const LOG_LEVEL_RANK = { debug: 10, info: 20, warn: 30, error: 40, silent: 99 };
522
- function resolveLogLevel() {
522
+ /**
523
+ * Resolve the effective level. Precedence: explicit `logLevel` option →
524
+ * `debug: true` (⇒ debug) → `ABLO_LOG_LEVEL` env → default `warn`. `debug: false`
525
+ * / omitted just means "don't raise the level" — it falls through to env/default
526
+ * rather than force-silencing an ops-set env override.
527
+ */
528
+ function resolveLogLevel(opts) {
529
+ if (opts?.logLevel && opts.logLevel in LOG_LEVEL_RANK)
530
+ return opts.logLevel;
531
+ if (opts?.debug === true)
532
+ return 'debug';
523
533
  // `globalThis.process` guard keeps this safe in browser/edge runtimes that
524
534
  // have no `process` binding — there we fall through to the default.
525
535
  const raw = globalThis.process?.env?.ABLO_LOG_LEVEL;
@@ -528,12 +538,16 @@ function resolveLogLevel() {
528
538
  return normalized;
529
539
  return 'warn';
530
540
  }
531
- const consoleLogger = (() => {
532
- const threshold = LOG_LEVEL_RANK[resolveLogLevel()];
533
- const emit = (level, fn, args) => {
534
- if (typeof console === 'undefined' || LOG_LEVEL_RANK[level] < threshold)
541
+ /**
542
+ * Build the default logger, gated at `level` and prefixed `[Ablo]` so a creator
543
+ * with a console full of other tools' logs can see at a glance what's ours.
544
+ */
545
+ function createConsoleLogger(level) {
546
+ const threshold = LOG_LEVEL_RANK[level];
547
+ const emit = (lvl, fn, args) => {
548
+ if (typeof console === 'undefined' || LOG_LEVEL_RANK[lvl] < threshold)
535
549
  return;
536
- fn('[sync]', ...args);
550
+ fn('[Ablo]', ...args);
537
551
  };
538
552
  return {
539
553
  debug: (...args) => emit('debug', console.debug, args),
@@ -541,7 +555,7 @@ const consoleLogger = (() => {
541
555
  warn: (...args) => emit('warn', console.warn, args),
542
556
  error: (...args) => emit('error', console.error, args),
543
557
  };
544
- })();
558
+ }
545
559
  // `readProcessEnv` lives in `./auth` alongside the other resolvers
546
560
  // that read it. Re-exported there for use elsewhere in the file.
547
561
  // ── Default mutation executor (wire: `commit` frame over WebSocket) ──────
@@ -725,7 +739,10 @@ export function Ablo(options) {
725
739
  databaseUrl: configuredDatabaseUrl,
726
740
  dangerouslyAllowBrowser: options.dangerouslyAllowBrowser,
727
741
  });
728
- const { logger = consoleLogger } = internalOptions;
742
+ // Custom logger wins; otherwise build the default `[Ablo]` logger at the level
743
+ // resolved from `debug`/`logLevel`/`ABLO_LOG_LEVEL` (default `warn`).
744
+ const logger = internalOptions.logger ??
745
+ createConsoleLogger(resolveLogLevel({ debug: options.debug, logLevel: options.logLevel }));
729
746
  // Nudge (once) if a stray DATABASE_URL is in the env but `databaseUrl` wasn't
730
747
  // passed — the env value is no longer auto-adopted (see resolveDatabaseUrl).
731
748
  warnIfDatabaseUrlEnvIgnored(authInput, (m) => logger.warn(m));
@@ -83,7 +83,7 @@ export function warnIfDatabaseUrlEnvIgnored(input, warn) {
83
83
  if (warn)
84
84
  warn(message);
85
85
  else if (typeof console !== 'undefined')
86
- console.warn('[sync]', message);
86
+ console.warn('[Ablo]', message);
87
87
  }
88
88
  export const ABLO_HOSTED_API_DOMAIN = 'api.abloatai.com';
89
89
  export const ABLO_HOSTED_HTTP_BASE_URL = `https://${ABLO_HOSTED_API_DOMAIN}`;
@@ -274,7 +274,9 @@ export class StoreManager {
274
274
  * Clear all stores
275
275
  */
276
276
  async clearAllStores() {
277
- getContext().logger.warn('Clearing all stores');
277
+ // Lifecycle chatter (logout / identity switch / reset) NOT a warning.
278
+ // `debug` so it's silent under the default `warn` threshold.
279
+ getContext().logger.debug('Clearing all stores');
278
280
  const promises = Array.from(this.stores.values()).map((store) => store.clear());
279
281
  await Promise.all(promises);
280
282
  getContext().logger.info('All stores cleared');
package/dist/errors.d.ts CHANGED
@@ -77,6 +77,15 @@ export declare class AbloError extends Error {
77
77
  request_id?: string;
78
78
  [key: string]: unknown;
79
79
  };
80
+ /**
81
+ * A single, leak-proof line for logs and `String(err)` / template
82
+ * interpolation: `AbloValidationError [code]: message (see docs) [request_id: …]`.
83
+ *
84
+ * Deliberately does NOT dump `details`, `cause`, or the stack — the thing that
85
+ * makes `console.error(richError)` an unreadable wall of text. The structured
86
+ * payload stays available via {@link toJSON}; this is the human one-liner.
87
+ */
88
+ toString(): string;
80
89
  }
81
90
  /**
82
91
  * Map a stable error `code` to its docs URL — the one place the convention
package/dist/errors.js CHANGED
@@ -91,6 +91,20 @@ export class AbloError extends Error {
91
91
  ...(this.details ?? {}),
92
92
  };
93
93
  }
94
+ /**
95
+ * A single, leak-proof line for logs and `String(err)` / template
96
+ * interpolation: `AbloValidationError [code]: message (see docs) [request_id: …]`.
97
+ *
98
+ * Deliberately does NOT dump `details`, `cause`, or the stack — the thing that
99
+ * makes `console.error(richError)` an unreadable wall of text. The structured
100
+ * payload stays available via {@link toJSON}; this is the human one-liner.
101
+ */
102
+ toString() {
103
+ const code = this.code ? ` [${this.code}]` : '';
104
+ const docs = this.docUrl ? ` (see ${this.docUrl})` : '';
105
+ const req = this.requestId ? ` [request_id: ${this.requestId}]` : '';
106
+ return `${this.name}${code}: ${this.message}${docs}${req}`;
107
+ }
94
108
  }
95
109
  /**
96
110
  * Map a stable error `code` to its docs URL — the one place the convention
package/dist/index.d.ts CHANGED
@@ -62,6 +62,8 @@ export { defaultPolicy, capabilityPreemptPolicy, interpretConflictAxis } from '.
62
62
  export { SyncSessionError, AbloError, AbloAuthenticationError, AbloPermissionError, AbloRateLimitError, AbloIdempotencyError, AbloConnectionError, AbloValidationError, AbloNotFoundError, AbloServerError, AbloStaleContextError, AbloClaimedError, CapabilityError, translateHttpError, hasWireCode, errorFromWire, toAbloError, ERROR_CODES, ERROR_CONTRACT_VERSION, errorCodeSpec, isRetryableCode, classifyRecovery, recoveryClassSchema, RECOVERY_CLASSES, } from './errors.js';
63
63
  export type { CommitReceipt, RequiredCapability } from './errors.js';
64
64
  export type { ErrorCode, WireErrorCode, ErrorCategory, ErrorCodeSpec, RecoveryClass } from './errors.js';
65
+ export { errorEnvelope, statusForType } from './wire/errorEnvelope.js';
66
+ export type { ErrorEnvelope } from './wire/errorEnvelope.js';
65
67
  export { WS_BEARER_SUBPROTOCOL_PREFIX, WS_SYNC_SUBPROTOCOL } from './auth/credentialSource.js';
66
68
  export { ENVIRONMENTS, environmentSchema, normalizeEnvironment, environmentFromKeyPrefix, environmentToKeyPrefix, isSandboxEnvironment, } from './environment.js';
67
69
  export type { Environment, KeyPrefixEnvironment } from './environment.js';
package/dist/index.js CHANGED
@@ -85,6 +85,11 @@ export { defaultPolicy, capabilityPreemptPolicy, interpretConflictAxis } from '.
85
85
  // consumers need to discriminate failures (`e instanceof AbloX` or
86
86
  // `e.type === 'AbloX'`) plus the HTTP-response translator.
87
87
  export { SyncSessionError, AbloError, AbloAuthenticationError, AbloPermissionError, AbloRateLimitError, AbloIdempotencyError, AbloConnectionError, AbloValidationError, AbloNotFoundError, AbloServerError, AbloStaleContextError, AbloClaimedError, CapabilityError, translateHttpError, hasWireCode, errorFromWire, toAbloError, ERROR_CODES, ERROR_CONTRACT_VERSION, errorCodeSpec, isRetryableCode, classifyRecovery, recoveryClassSchema, RECOVERY_CLASSES, } from './errors.js';
88
+ // Canonical wire-egress contract (dependency-free): the error envelope shape +
89
+ // the AbloError-subclass→HTTP-status table. Re-exported so server consumers
90
+ // (e.g. apps/sync-server, which keeps its own self-contained copy) can assert
91
+ // against the ONE source instead of silently drifting. See wire/errorEnvelope.ts.
92
+ export { errorEnvelope, statusForType } from './wire/errorEnvelope.js';
88
93
  export { WS_BEARER_SUBPROTOCOL_PREFIX, WS_SYNC_SUBPROTOCOL } from './auth/credentialSource.js';
89
94
  export { ENVIRONMENTS, environmentSchema, normalizeEnvironment, environmentFromKeyPrefix, environmentToKeyPrefix, isSandboxEnvironment, } from './environment.js';
90
95
  // THE write-options contract — the one Zod schema for the option bag every
@@ -122,18 +122,32 @@ export type ConflictDecision = {
122
122
  */
123
123
  export type ConflictPolicy = (conflict: Conflict) => ConflictDecision | Promise<ConflictDecision>;
124
124
  /**
125
- * Default policy.
125
+ * Default policy — **Law 7 (the human is the principal) is the engine-wide
126
+ * default, not an opt-in.**
126
127
  *
127
- * `claim_held` conflicts always reject (a foreign claim is honored unless a
128
- * privileged policy preempts). `stale_context` conflicts honor the committer's
129
- * declared `onStale` intent:
128
+ * A `claim_held` conflict resolves by the COMMITTER's kind. This default is, by
129
+ * construction, identical to `coordination(humansOverwrite(), agentsReject())`
130
+ * applied to every model — so a developer who wants different behaviour just
131
+ * declares the conflict axis on that model (`humansReject()`, `humansNotify()`,
132
+ * …) and it wins. The default and the override speak the same vocabulary; the
133
+ * default is the one you get for free.
134
+ *
135
+ * • `user` → `allow` — a human is never blocked by a claim (a claim is a
136
+ * coordination hint among agents, not a lock on people)
137
+ * • `system` → `allow` — automation principals at the root of a trust chain
138
+ * behave like humans here (matches `mayBypassClaims`)
139
+ * • `agent` → `reject` — an agent yields to a foreign claim (the no-bypass
140
+ * invariant; the ONLY sanctioned agent override is the
141
+ * privileged `claim.preempt` capability seam, see
142
+ * {@link capabilityPreemptPolicy})
143
+ *
144
+ * `stale_context` conflicts honor the committer's declared `onStale` intent:
130
145
  *
131
146
  * • `'notify'` → notify + hold (op withheld; the actor resolves)
132
147
  * • anything else (incl. `'reject'`, absent) → reject
133
148
  *
134
- * `'overwrite'` never reaches a policy — it's a hard opt-out resolved before
135
- * detection. This preserves the legacy always-reject default for callers that
136
- * don't opt into `notify`.
149
+ * `'overwrite'` never reaches a policy on the stale path — it's a hard opt-out
150
+ * resolved before detection.
137
151
  */
138
152
  export declare const defaultPolicy: (conflict: Conflict) => ConflictDecision;
139
153
  /**
@@ -172,8 +186,9 @@ export interface ConflictAxis {
172
186
  * synchronous (no I/O), so it runs on either side of the schema-agnostic
173
187
  * boundary. Looks up the committer's kind and maps the declared `OnStaleMode`:
174
188
  *
175
- * - undefined → the engine default (`defaultPolicy`: reject; honor
176
- * `onStale: 'notify'` on a stale write)
189
+ * - undefined → the engine default (`defaultPolicy`: Law 7 — a human/system
190
+ * committer is allowed (never blocked), an agent rejects on a
191
+ * `claim_held`; on a stale write, honor `onStale: 'notify'`)
177
192
  * - `overwrite` → `allow` — the write wins / the committer is never blocked
178
193
  * - `reject` → `reject` — the committer yields
179
194
  * - `notify` → on `stale_context`: notify + hold (re-read & re-apply);
@@ -7,18 +7,32 @@
7
7
  * Adding new shapes is additive on the discriminated union.
8
8
  */
9
9
  /**
10
- * Default policy.
10
+ * Default policy — **Law 7 (the human is the principal) is the engine-wide
11
+ * default, not an opt-in.**
11
12
  *
12
- * `claim_held` conflicts always reject (a foreign claim is honored unless a
13
- * privileged policy preempts). `stale_context` conflicts honor the committer's
14
- * declared `onStale` intent:
13
+ * A `claim_held` conflict resolves by the COMMITTER's kind. This default is, by
14
+ * construction, identical to `coordination(humansOverwrite(), agentsReject())`
15
+ * applied to every model — so a developer who wants different behaviour just
16
+ * declares the conflict axis on that model (`humansReject()`, `humansNotify()`,
17
+ * …) and it wins. The default and the override speak the same vocabulary; the
18
+ * default is the one you get for free.
19
+ *
20
+ * • `user` → `allow` — a human is never blocked by a claim (a claim is a
21
+ * coordination hint among agents, not a lock on people)
22
+ * • `system` → `allow` — automation principals at the root of a trust chain
23
+ * behave like humans here (matches `mayBypassClaims`)
24
+ * • `agent` → `reject` — an agent yields to a foreign claim (the no-bypass
25
+ * invariant; the ONLY sanctioned agent override is the
26
+ * privileged `claim.preempt` capability seam, see
27
+ * {@link capabilityPreemptPolicy})
28
+ *
29
+ * `stale_context` conflicts honor the committer's declared `onStale` intent:
15
30
  *
16
31
  * • `'notify'` → notify + hold (op withheld; the actor resolves)
17
32
  * • anything else (incl. `'reject'`, absent) → reject
18
33
  *
19
- * `'overwrite'` never reaches a policy — it's a hard opt-out resolved before
20
- * detection. This preserves the legacy always-reject default for callers that
21
- * don't opt into `notify`.
34
+ * `'overwrite'` never reaches a policy on the stale path — it's a hard opt-out
35
+ * resolved before detection.
22
36
  */
23
37
  // Typed by its real SYNCHRONOUS shape (via `satisfies`) rather than the
24
38
  // async-permissive `ConflictPolicy` alias, so sync callers like
@@ -26,8 +40,14 @@
26
40
  // `ConflictDecision` back (not `… | Promise<…>`). Still assignable to
27
41
  // `ConflictPolicy` everywhere it's used as a policy.
28
42
  export const defaultPolicy = ((conflict) => {
29
- if (conflict.kind !== 'stale_context') {
30
- return { action: 'reject', reason: 'claim_conflict' };
43
+ if (conflict.kind === 'claim_held') {
44
+ // Law 7 universal default: principals (human / system) are never blocked;
45
+ // agents yield. Keeping `agent → reject` here is what makes the no-bypass
46
+ // invariant hold even on the registry/default resolution path (which, unlike
47
+ // the declared-axis path, has no separate agent-degradation guard).
48
+ return conflict.committer.kind === 'agent'
49
+ ? { action: 'reject', reason: 'claim_conflict' }
50
+ : { action: 'allow', note: 'principal:not-blocked' };
31
51
  }
32
52
  return conflict.requestedMode === 'notify'
33
53
  ? { action: 'notify', reason: 'stale_notify_hold' }
@@ -53,8 +73,9 @@ export const capabilityPreemptPolicy = (conflict) => {
53
73
  * synchronous (no I/O), so it runs on either side of the schema-agnostic
54
74
  * boundary. Looks up the committer's kind and maps the declared `OnStaleMode`:
55
75
  *
56
- * - undefined → the engine default (`defaultPolicy`: reject; honor
57
- * `onStale: 'notify'` on a stale write)
76
+ * - undefined → the engine default (`defaultPolicy`: Law 7 — a human/system
77
+ * committer is allowed (never blocked), an agent rejects on a
78
+ * `claim_held`; on a stale write, honor `onStale: 'notify'`)
58
79
  * - `overwrite` → `allow` — the write wins / the committer is never blocked
59
80
  * - `reject` → `reject` — the committer yields
60
81
  * - `notify` → on `stale_context`: notify + hold (re-read & re-apply);
package/dist/surface.d.ts CHANGED
@@ -23,7 +23,7 @@ export declare const PUBLIC_MODEL_VERBS: readonly ["retrieve", "list", "get", "g
23
23
  export declare const PUBLIC_LIST_OPTION_KEYS: readonly ["where", "filter", "orderBy", "limit", "offset", "state"];
24
24
  /** Public keys of `AbloOptions`. `schema` is required; the rest are optional
25
25
  * (the locked happy path is `Ablo({ schema, apiKey, databaseUrl, transport })`). */
26
- export declare const PUBLIC_ABLO_OPTION_KEYS: readonly ["schema", "apiKey", "databaseUrl", "persistence", "transport", "authToken", "baseURL", "fetch", "defaultHeaders", "defaultQuery", "dangerouslyAllowBrowser"];
26
+ export declare const PUBLIC_ABLO_OPTION_KEYS: readonly ["schema", "apiKey", "databaseUrl", "persistence", "transport", "debug", "logLevel", "authToken", "baseURL", "fetch", "defaultHeaders", "defaultQuery", "dangerouslyAllowBrowser"];
27
27
  export type ModelVerb = (typeof PUBLIC_MODEL_VERBS)[number];
28
28
  export type ListOptionKey = (typeof PUBLIC_LIST_OPTION_KEYS)[number];
29
29
  export type AbloOptionKey = (typeof PUBLIC_ABLO_OPTION_KEYS)[number];
package/dist/surface.js CHANGED
@@ -51,6 +51,8 @@ export const PUBLIC_ABLO_OPTION_KEYS = [
51
51
  'databaseUrl',
52
52
  'persistence',
53
53
  'transport',
54
+ 'debug',
55
+ 'logLevel',
54
56
  'authToken',
55
57
  'baseURL',
56
58
  'fetch',
@@ -177,7 +177,7 @@ export class SyncWebSocket extends EventEmitter {
177
177
  // which is unreliable but the only signal available; in Node it returns true
178
178
  // (assume online) so the sidecar/agent path doesn't short-circuit here.
179
179
  if (!getContext().onlineStatus.isOnline()) {
180
- getContext().logger.warn('onlineStatus reports offline, but attempting connection anyway');
180
+ getContext().logger.debug('onlineStatus reports offline, but attempting connection anyway');
181
181
  }
182
182
  this.isConnecting = true;
183
183
  this.isManualClose = false;
@@ -13,6 +13,7 @@
13
13
  * fake; `SyncWebSocket` satisfies it structurally.
14
14
  */
15
15
  import { AbloClaimedError, formatClaimedErrorMessage, claimTargetLabel, } from '../errors.js';
16
+ import { getContext } from '../context.js';
16
17
  export function awaitClaimGrant(transport, claimId, options) {
17
18
  return new Promise((resolve, reject) => {
18
19
  const unsubs = [];
@@ -28,12 +29,18 @@ export function awaitClaimGrant(transport, claimId, options) {
28
29
  // we waited in line, and reached the head → `claim_granted`. Either frame
29
30
  // means the lease is now ours; `waited` records which path it was.
30
31
  unsubs.push(transport.subscribe('claim_acquired', (p) => {
31
- if (p?.claimId === claimId)
32
+ if (p?.claimId === claimId) {
33
+ getContext().logger.debug(`claim: acquired ${claimId} (target was free)`);
32
34
  settle(() => resolve({ waited: false }));
35
+ }
33
36
  }));
34
37
  unsubs.push(transport.subscribe('claim_granted', (p) => {
35
- if (p?.claimId === claimId)
38
+ if (p?.claimId === claimId) {
39
+ // Promoted to the head of the line — the creator's "it's the agent's
40
+ // turn now" moment after waiting behind a holder.
41
+ getContext().logger.info(`claim: granted ${claimId} — your turn (waited in queue)`);
36
42
  settle(() => resolve({ waited: true }));
43
+ }
37
44
  }));
38
45
  if (options?.maxQueueDepth !== undefined) {
39
46
  const max = options.maxQueueDepth;
@@ -24,6 +24,11 @@
24
24
  import { asyncIteratorFrom } from '../utils/asyncIterator.js';
25
25
  import { toMs } from '../utils/duration.js';
26
26
  import { descriptionFromMeta, participantKindFromWire, } from '../coordination/schema.js';
27
+ import { getContext } from '../context.js';
28
+ /** Readable target for the coordination trace: `documents:abc` / `documents:abc.title`. */
29
+ function claimLabel(type, id, field) {
30
+ return field ? `${type}:${id}.${field}` : `${type}:${id}`;
31
+ }
27
32
  export function createClaimStream(config, transport = null) {
28
33
  const { participantId } = config;
29
34
  // ── State: others' open claims, keyed by claimId ───────────────
@@ -37,6 +42,9 @@ export function createClaimStream(config, transport = null) {
37
42
  const queueByEntity = new Map();
38
43
  const entityKey = (type, id) => `${type}:${id}`;
39
44
  const EMPTY_QUEUE = Object.freeze([]);
45
+ // Last queue position we logged per own-claim, so advancing in line is traced
46
+ // once per change (not re-logged on every server re-fan of the same line).
47
+ const lastLoggedQueuePos = new Map();
40
48
  // ── Subscribers ──────────────────────────────────────────────────
41
49
  const listeners = new Set();
42
50
  const rejectionListeners = new Set();
@@ -122,6 +130,12 @@ export function createClaimStream(config, transport = null) {
122
130
  unsubs.push(t.subscribe('claim_rejected', (rejection) => {
123
131
  if (!rejection.claimId)
124
132
  return;
133
+ if (ownClaims.has(rejection.claimId)) {
134
+ const tgt = rejection.target
135
+ ? claimLabel(rejection.target.entityType, rejection.target.entityId, rejection.target.field)
136
+ : rejection.claimId;
137
+ getContext().logger.info(`claim: rejected ${tgt}${rejection.heldBy ? ` — held by ${rejection.heldBy}` : ''}`, { claimId: rejection.claimId, reason: rejection.reason });
138
+ }
125
139
  // Drop the rejected own-claim so reconnect doesn't re-announce
126
140
  // a claim the server already rejected (would just spam both
127
141
  // sides with conflicts).
@@ -141,6 +155,10 @@ export function createClaimStream(config, transport = null) {
141
155
  const lost = payload;
142
156
  if (!lost.claimId)
143
157
  return;
158
+ if (ownClaims.has(lost.claimId)) {
159
+ const c = ownClaims.get(lost.claimId);
160
+ getContext().logger.info(`claim: lost ${c ? claimLabel(c.entityType, c.entityId, c.field) : lost.claimId} (preempted or expired)`, { claimId: lost.claimId });
161
+ }
144
162
  // Drop the lost own-claim so reconnect doesn't re-announce a lease we
145
163
  // no longer hold.
146
164
  ownClaims.delete(lost.claimId);
@@ -166,6 +184,16 @@ export function createClaimStream(config, transport = null) {
166
184
  queueByEntity.delete(key);
167
185
  else
168
186
  queueByEntity.set(key, Object.freeze([...line]));
187
+ // If WE are in this line, trace our position (the "agent queued behind a
188
+ // claim" moment) — once per position change, so advancing is visible.
189
+ const ourIndex = line.findIndex((c) => ownClaims.has(c.id));
190
+ if (ourIndex >= 0) {
191
+ const ourId = line[ourIndex].id;
192
+ if (lastLoggedQueuePos.get(ourId) !== ourIndex) {
193
+ lastLoggedQueuePos.set(ourId, ourIndex);
194
+ getContext().logger.info(`claim: queued for ${claimLabel(p.target.type, p.target.id)} — position ${ourIndex + 1} of ${line.length}, waiting`, { claimId: ourId });
195
+ }
196
+ }
169
197
  notifyListeners();
170
198
  }));
171
199
  // (3) On reconnect, re-announce every open self-claim — the
@@ -251,6 +279,9 @@ export function createClaimStream(config, transport = null) {
251
279
  };
252
280
  ownClaims.set(claimId, claim);
253
281
  sendBegin(claimId, claim);
282
+ // Coordination trace (info): the creator can SEE their human/agent claims.
283
+ getContext().logger.info(`claim: requesting ${claimLabel(claim.entityType, claim.entityId, claim.field)} for "${claim.reason}"` +
284
+ (claim.queue ? ' (will queue if contended)' : ''), { claimId });
254
285
  let revoked = false;
255
286
  const revoke = () => {
256
287
  if (revoked)
@@ -258,6 +289,7 @@ export function createClaimStream(config, transport = null) {
258
289
  revoked = true;
259
290
  ownClaims.delete(claimId);
260
291
  sendAbandon(claimId, claim);
292
+ getContext().logger.info(`claim: released ${claimLabel(claim.entityType, claim.entityId, claim.field)}`, { claimId });
261
293
  };
262
294
  return {
263
295
  object: 'claim',
@@ -8,6 +8,19 @@ export interface ErrorEnvelope {
8
8
  readonly message: string;
9
9
  readonly doc_url?: string;
10
10
  readonly request_id?: string;
11
+ /** Aggregate field-level failures — so one 4xx can report EVERY invalid input
12
+ * at once (schema push, batch commit, CLI-arg validation) instead of failing
13
+ * on the first. `param` stays the single-field convenience case. (RFC 9457
14
+ * `errors[]` / JSON:API `errors[]` / Google `BadRequest.fieldViolations[]`.) */
15
+ readonly errors?: ReadonlyArray<{
16
+ readonly code?: string;
17
+ readonly message: string;
18
+ readonly param?: string;
19
+ }>;
20
+ /** Typed-details slot: `AbloError.toJSON()` spreads its `details` (e.g.
21
+ * `missingIds`, `conflicts`, `retryAfterSeconds`) as top-level members.
22
+ * Consumers MUST ignore members they don't recognize (forward-compat). */
23
+ readonly [key: string]: unknown;
11
24
  }
12
25
  /** {@link AbloError} subclass → default HTTP status. The subclass is chosen to
13
26
  * match status semantics (a validation error is a 400, a permission error a
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@abloatai/ablo",
3
- "version": "0.17.0",
3
+ "version": "0.18.0",
4
4
  "description": "The Collaboration Layer For AI Agents",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",