@abloatai/ablo 0.12.0 → 0.14.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.
Files changed (56) hide show
  1. package/AGENTS.md +2 -2
  2. package/CHANGELOG.md +29 -0
  3. package/README.md +3 -3
  4. package/dist/BaseSyncedStore.js +39 -32
  5. package/dist/batching/index.d.ts +57 -0
  6. package/dist/batching/index.js +150 -0
  7. package/dist/cli.cjs +158 -40
  8. package/dist/client/Ablo.d.ts +16 -25
  9. package/dist/client/Ablo.js +1 -1
  10. package/dist/client/auth.js +11 -0
  11. package/dist/client/createModelProxy.d.ts +33 -8
  12. package/dist/client/createModelProxy.js +4 -4
  13. package/dist/errorCodes.d.ts +3 -1
  14. package/dist/errorCodes.js +10 -1
  15. package/dist/schema/index.d.ts +2 -2
  16. package/dist/schema/index.js +2 -2
  17. package/dist/schema/model.d.ts +38 -84
  18. package/dist/schema/model.js +12 -12
  19. package/dist/schema/roles.d.ts +49 -0
  20. package/dist/schema/roles.js +21 -0
  21. package/dist/schema/schema.d.ts +1 -1
  22. package/dist/schema/schema.js +1 -1
  23. package/dist/schema/serialize.d.ts +4 -2
  24. package/dist/schema/serialize.js +4 -2
  25. package/dist/schema/sugar.d.ts +7 -28
  26. package/dist/schema/sugar.js +2 -7
  27. package/dist/schema/sync-delta-row.d.ts +2 -0
  28. package/dist/schema/sync-delta-row.js +2 -1
  29. package/dist/schema/tenancy.d.ts +67 -28
  30. package/dist/schema/tenancy.js +93 -23
  31. package/dist/server/commit.d.ts +8 -3
  32. package/docs/api.md +7 -6
  33. package/docs/cli.md +43 -4
  34. package/docs/client-behavior.md +2 -2
  35. package/docs/coordination.md +12 -12
  36. package/docs/examples/agent-human.md +6 -6
  37. package/docs/examples/ai-sdk-tool.md +1 -1
  38. package/docs/examples/existing-python-backend.md +0 -2
  39. package/docs/examples/nextjs.md +2 -2
  40. package/docs/examples/scoped-agent.md +3 -3
  41. package/docs/examples/server-agent.md +4 -4
  42. package/docs/identity.md +27 -20
  43. package/docs/index.md +0 -1
  44. package/docs/integration-guide.md +12 -9
  45. package/docs/interaction-model.md +1 -1
  46. package/docs/mcp.md +17 -5
  47. package/docs/quickstart.md +3 -3
  48. package/docs/react.md +69 -0
  49. package/llms.txt +2 -3
  50. package/package.json +8 -2
  51. package/docs/mcp/claude-code.md +0 -35
  52. package/docs/mcp/cursor.md +0 -35
  53. package/docs/mcp/windsurf.md +0 -33
  54. package/docs/roadmap.md +0 -55
  55. package/docs/the-loop.md +0 -21
  56. package/llms-full.txt +0 -396
package/dist/cli.cjs CHANGED
@@ -276903,9 +276903,18 @@ var ERROR_CODES = {
276903
276903
  // ── quota / rate limit (429) ──────────────────────────────────────
276904
276904
  quota_exceeded: wire("rate_limit", 429, true, "The organization exceeded its configured usage quota."),
276905
276905
  connection_limit_exceeded: wire("rate_limit", 429, true, "Too many concurrent WebSocket connections for this principal or organization. Close idle connections, or retry once others drain."),
276906
+ // Per-CREDENTIAL request-rate limit — the fast (RPS/burst) axis, distinct from
276907
+ // the slow-axis `quota_exceeded` (org daily/monthly usage). Keyed per API key,
276908
+ // so one noisy key backs off without affecting the rest of the org. The
276909
+ // `Retry-After` header carries the bucket-refill delay.
276910
+ rate_limit_exceeded: wire("rate_limit", 429, true, "This API key is sending requests too quickly; slow down and retry after the indicated delay."),
276906
276911
  // ── server (5xx) ───────────────────────────────────────────────────
276907
276912
  internal_error: wire("server", 500, true, "An unexpected server error occurred."),
276908
276913
  quota_lookup_failed: wire("server", 503, true, "The quota decision could not be loaded."),
276914
+ // The per-key rate-limiter backend (Redis) was unreachable and the API is
276915
+ // configured to FAIL CLOSED on that path, so the request was rejected rather
276916
+ // than admitted unchecked. Retryable: the next attempt re-probes the backend.
276917
+ rate_limiter_unavailable: wire("server", 503, true, "The rate-limiter backend is unavailable and this endpoint is configured to fail closed; retry shortly."),
276909
276918
  turn_open_failed: wire("server", 500, true, "The agent turn failed to open."),
276910
276919
  turn_close_failed: wire("server", 500, true, "The agent turn failed to close cleanly."),
276911
276920
  // ── client-only invariants (never serialized) ──────────────────────
@@ -277023,6 +277032,7 @@ var roleSchema = import_zod2.z.object({
277023
277032
  kind: import_zod2.z.string().regex(/^[a-z][a-z0-9_]*$/, 'kind must be a lowercase identifier, e.g. "deck"'),
277024
277033
  source: roleSourceSchema
277025
277034
  });
277035
+ var entityRoleSchema = roleSchema;
277026
277036
  var scopeSchema = import_zod2.z.union([
277027
277037
  import_zod2.z.boolean(),
277028
277038
  import_zod2.z.string().regex(/^[a-z][a-z0-9_]*$/, 'scope kind must be a lowercase identifier, e.g. "dataroom"')
@@ -277031,6 +277041,11 @@ var grantsRefSchema = import_zod2.z.object({
277031
277041
  subject: import_zod2.z.string().regex(/^[a-zA-Z_][a-zA-Z0-9_]*$/, "grants.subject must name a relation"),
277032
277042
  scope: import_zod2.z.string().regex(/^[a-zA-Z_][a-zA-Z0-9_]*$/, "grants.scope must name a relation")
277033
277043
  });
277044
+ var groupsInputSchema = import_zod2.z.object({
277045
+ root: scopeSchema.optional(),
277046
+ grants: grantsRefSchema.optional(),
277047
+ roles: import_zod2.z.union([entityRoleSchema, import_zod2.z.array(entityRoleSchema)]).optional()
277048
+ });
277034
277049
 
277035
277050
  // src/coordination/schema.ts
277036
277051
  var targetRangeSchema = import_zod3.z.object({
@@ -279646,6 +279661,7 @@ init_cjs_shims();
279646
279661
  var import_os2 = require("os");
279647
279662
  var import_path2 = require("path");
279648
279663
  var import_fs3 = require("fs");
279664
+ var DEFAULT_PROFILE = "default";
279649
279665
  function configDir() {
279650
279666
  if (process.env.ABLO_CONFIG_DIR) return process.env.ABLO_CONFIG_DIR;
279651
279667
  const xdg = process.env.XDG_CONFIG_HOME;
@@ -279657,12 +279673,32 @@ function configPath() {
279657
279673
  function credentialsPath() {
279658
279674
  return (0, import_path2.join)(configDir(), "credentials.json");
279659
279675
  }
279676
+ function activeProfileName(cfg) {
279677
+ return cfg.activeProject?.slug ?? DEFAULT_PROFILE;
279678
+ }
279660
279679
  function asKeyEntry(value) {
279661
279680
  if (value && typeof value === "object" && typeof value.apiKey === "string") {
279662
279681
  return value;
279663
279682
  }
279664
279683
  return void 0;
279665
279684
  }
279685
+ function asProfileKeys(value) {
279686
+ if (!value || typeof value !== "object") return void 0;
279687
+ const v = value;
279688
+ const sandbox = asKeyEntry(v.sandbox);
279689
+ const production = asKeyEntry(v.production);
279690
+ if (!sandbox && !production) return void 0;
279691
+ return { ...sandbox ? { sandbox } : {}, ...production ? { production } : {} };
279692
+ }
279693
+ function asProfileMap(value) {
279694
+ if (!value || typeof value !== "object") return {};
279695
+ const out = {};
279696
+ for (const [name, v] of Object.entries(value)) {
279697
+ const keys = asProfileKeys(v);
279698
+ if (keys) out[name] = keys;
279699
+ }
279700
+ return out;
279701
+ }
279666
279702
  function readJson(path) {
279667
279703
  if (!(0, import_fs3.existsSync)(path)) return null;
279668
279704
  try {
@@ -279683,7 +279719,7 @@ function normalizeStoredMode(value) {
279683
279719
  if (value === "sandbox" || value === "production") return value;
279684
279720
  return void 0;
279685
279721
  }
279686
- function extractEntries(obj) {
279722
+ function extractLegacyEntries(obj) {
279687
279723
  const sandbox = asKeyEntry(obj.sandbox);
279688
279724
  const production = asKeyEntry(obj.production);
279689
279725
  if (sandbox || production) {
@@ -279692,24 +279728,33 @@ function extractEntries(obj) {
279692
279728
  const flat = asKeyEntry(obj);
279693
279729
  return flat ? { sandbox: flat } : {};
279694
279730
  }
279731
+ function hasKey(keys) {
279732
+ return !!(keys?.sandbox || keys?.production);
279733
+ }
279695
279734
  function readConfig() {
279696
279735
  const cfgObj = readJson(configPath());
279697
279736
  const credObj = readJson(credentialsPath());
279698
279737
  const mode2 = normalizeStoredMode(cfgObj?.mode) ?? normalizeStoredMode(credObj?.mode);
279699
279738
  const activeProject = asActiveProject(cfgObj?.activeProject);
279700
- const cfgEntries = cfgObj ? extractEntries(cfgObj) : {};
279701
- const entries = {
279702
- ...cfgEntries,
279703
- ...credObj ? extractEntries(credObj) : {}
279704
- // credentials file wins
279739
+ const activeName = activeProject?.slug ?? DEFAULT_PROFILE;
279740
+ const profiles = {
279741
+ ...asProfileMap(credObj?.profiles),
279742
+ ...asProfileMap(cfgObj?.profiles)
279705
279743
  };
279706
- if (!mode2 && !entries.sandbox && !entries.production) return null;
279744
+ const legacyCfg = cfgObj ? extractLegacyEntries(cfgObj) : {};
279745
+ const legacyCred = credObj ? extractLegacyEntries(credObj) : {};
279746
+ const legacy = { ...legacyCfg, ...legacyCred };
279747
+ const migratedLegacy = hasKey(legacy) && !hasKey(profiles[activeName]);
279748
+ if (migratedLegacy) profiles[activeName] = legacy;
279749
+ const anyKey = Object.values(profiles).some(hasKey);
279750
+ if (!mode2 && !anyKey) return null;
279707
279751
  const config = {
279708
279752
  mode: mode2 ?? "sandbox",
279709
279753
  ...activeProject ? { activeProject } : {},
279710
- ...entries
279754
+ profiles
279711
279755
  };
279712
- if (cfgEntries.sandbox || cfgEntries.production) writeConfig(config);
279756
+ const secretsInConfig = hasKey(legacyCfg);
279757
+ if (secretsInConfig || migratedLegacy) writeConfig(config);
279713
279758
  return config;
279714
279759
  }
279715
279760
  function writeConfig(cfg) {
@@ -279725,16 +279770,34 @@ function writeConfig(cfg) {
279725
279770
  `,
279726
279771
  { mode: 384 }
279727
279772
  );
279728
- const credentials = {
279729
- ...cfg.sandbox ? { sandbox: cfg.sandbox } : {},
279730
- ...cfg.production ? { production: cfg.production } : {}
279731
- };
279732
- (0, import_fs3.writeFileSync)(credentialsPath(), `${JSON.stringify(credentials, null, 2)}
279773
+ const profiles = {};
279774
+ for (const [name, keys] of Object.entries(cfg.profiles)) {
279775
+ if (!hasKey(keys)) continue;
279776
+ profiles[name] = {
279777
+ ...keys.sandbox ? { sandbox: keys.sandbox } : {},
279778
+ ...keys.production ? { production: keys.production } : {}
279779
+ };
279780
+ }
279781
+ (0, import_fs3.writeFileSync)(credentialsPath(), `${JSON.stringify({ profiles }, null, 2)}
279733
279782
  `, { mode: 384 });
279734
279783
  return credentialsPath();
279735
279784
  }
279785
+ function emptyConfig(mode2 = "sandbox") {
279786
+ return { mode: mode2, profiles: {} };
279787
+ }
279788
+ function setProfileKeys(profileName, keys, opts) {
279789
+ const cfg = readConfig() ?? emptyConfig(opts.mode);
279790
+ cfg.mode = opts.mode;
279791
+ cfg.profiles[profileName] = {
279792
+ ...keys.sandbox ? { sandbox: keys.sandbox } : {},
279793
+ ...keys.production ? { production: keys.production } : {}
279794
+ };
279795
+ if (opts.activeProject) cfg.activeProject = opts.activeProject;
279796
+ else delete cfg.activeProject;
279797
+ return writeConfig(cfg);
279798
+ }
279736
279799
  function setMode(mode2) {
279737
- const cfg = readConfig() ?? { mode: mode2 };
279800
+ const cfg = readConfig() ?? emptyConfig(mode2);
279738
279801
  cfg.mode = mode2;
279739
279802
  return writeConfig(cfg);
279740
279803
  }
@@ -279745,13 +279808,15 @@ function getActiveProject() {
279745
279808
  return readConfig()?.activeProject;
279746
279809
  }
279747
279810
  function setActiveProject(project) {
279748
- const cfg = readConfig() ?? { mode: "sandbox" };
279811
+ const cfg = readConfig() ?? emptyConfig("sandbox");
279749
279812
  if (project) cfg.activeProject = project;
279750
279813
  else delete cfg.activeProject;
279751
279814
  return writeConfig(cfg);
279752
279815
  }
279753
279816
  function getKeyEntry(mode2) {
279754
- return readConfig()?.[mode2];
279817
+ const cfg = readConfig();
279818
+ if (!cfg) return void 0;
279819
+ return cfg.profiles[activeProfileName(cfg)]?.[mode2];
279755
279820
  }
279756
279821
  function modeFromKey(key) {
279757
279822
  if (/^(sk|rk)_test_/.test(key)) return "sandbox";
@@ -279775,11 +279840,21 @@ function resolveApiKey(modeOverride) {
279775
279840
  if (process.env.ABLO_API_KEY) return process.env.ABLO_API_KEY;
279776
279841
  const cfg = readConfig();
279777
279842
  if (!cfg) return void 0;
279778
- const entry = cfg[modeOverride ?? cfg.mode];
279843
+ const entry = cfg.profiles[activeProfileName(cfg)]?.[modeOverride ?? cfg.mode];
279779
279844
  if (!entry) return void 0;
279780
279845
  if (entry.expiresAt && Date.parse(entry.expiresAt) <= Date.now()) return void 0;
279781
279846
  return entry.apiKey;
279782
279847
  }
279848
+ function guardActiveProjectKey() {
279849
+ if (process.env.ABLO_API_KEY) {
279850
+ return { ok: true, activeProfile: DEFAULT_PROFILE, available: [] };
279851
+ }
279852
+ const cfg = readConfig();
279853
+ const activeProfile = cfg ? activeProfileName(cfg) : DEFAULT_PROFILE;
279854
+ const profiles = cfg?.profiles ?? {};
279855
+ const available = Object.entries(profiles).filter(([, keys]) => hasKey(keys)).map(([name]) => name);
279856
+ return { ok: hasKey(profiles[activeProfile]), activeProfile, available };
279857
+ }
279783
279858
  function resolvePushPlan() {
279784
279859
  const envKey = process.env.ABLO_API_KEY;
279785
279860
  if (envKey) return { flow: modeFromKey(envKey) ?? getMode(), apiKey: envKey, source: "env" };
@@ -280425,8 +280500,19 @@ function openBrowser(url) {
280425
280500
  } catch {
280426
280501
  }
280427
280502
  }
280428
- async function deviceLogin() {
280503
+ function parseProjectFlag(argv) {
280504
+ const i = argv.indexOf("--project");
280505
+ if (i >= 0) {
280506
+ const slug = argv[i + 1];
280507
+ if (slug && !slug.startsWith("-")) return slug;
280508
+ }
280509
+ const eq = argv.find((a) => a.startsWith("--project="));
280510
+ return eq ? eq.slice("--project=".length) || void 0 : void 0;
280511
+ }
280512
+ async function deviceLogin(argv) {
280429
280513
  Ie(`${brand("ablo")} login`);
280514
+ const requested = parseProjectFlag(argv) ?? getActiveProject()?.slug;
280515
+ const targetProject = requested === DEFAULT_PROFILE ? void 0 : requested;
280430
280516
  const interactive = Boolean(process.stdout.isTTY && process.stdin.isTTY);
280431
280517
  let account = "login";
280432
280518
  if (interactive) {
@@ -280504,13 +280590,19 @@ ${import_picocolors7.default.dim(url)}`, "Approve in your browser");
280504
280590
  s.stop("Timed out waiting for approval.");
280505
280591
  process.exit(1);
280506
280592
  }
280507
- s.message("Provisioning a sandbox key\u2026");
280593
+ s.message(
280594
+ targetProject ? `Provisioning keys for ${targetProject}\u2026` : "Provisioning a sandbox key\u2026"
280595
+ );
280508
280596
  const provRes = await fetch(`${AUTH_URL}/api/cli/provision-key`, {
280509
280597
  method: "POST",
280510
280598
  headers: { authorization: `Bearer ${accessToken}`, "content-type": "application/json" },
280511
- // Pass the device_code so the server can scope the minted keys to the
280512
- // project the user picked at /cli (login project picker). Harmless if none.
280513
- body: JSON.stringify({ device_code: code.device_code })
280599
+ // Scope the minted keys to the chosen project (`--project`/active), with
280600
+ // the device_code as a legacy fallback for the /cli picker. Both harmless
280601
+ // if absent → org-default keys.
280602
+ body: JSON.stringify({
280603
+ device_code: code.device_code,
280604
+ ...targetProject ? { project_slug: targetProject } : {}
280605
+ })
280514
280606
  }).catch(() => null);
280515
280607
  if (!provRes || !provRes.ok) {
280516
280608
  s.stop("Could not provision a key.");
@@ -280528,16 +280620,23 @@ ${import_picocolors7.default.dim(url)}`, "Approve in your browser");
280528
280620
  ...prov.organizationId ? { organizationId: prov.organizationId } : {},
280529
280621
  ...k3.expiresAt ? { expiresAt: k3.expiresAt } : {}
280530
280622
  });
280531
- const path = writeConfig({
280532
- mode: "sandbox",
280533
- sandbox: entry(prov.test),
280534
- ...prov.live ? { production: entry(prov.live) } : {}
280535
- });
280623
+ const profileName = prov.project?.slug ?? DEFAULT_PROFILE;
280624
+ const path = setProfileKeys(
280625
+ profileName,
280626
+ {
280627
+ sandbox: entry(prov.test),
280628
+ ...prov.live ? { production: entry(prov.live) } : {}
280629
+ },
280630
+ { mode: "sandbox", activeProject: prov.project ?? void 0 }
280631
+ );
280536
280632
  s.stop(`Saved keys to ${path}`);
280537
- Se(`${import_picocolors7.default.green("\u2713")} Logged in ${import_picocolors7.default.dim("(sandbox)")}. Run ${import_picocolors7.default.bold("npx ablo push")} to push your schema.`);
280633
+ const where = prov.project ? ` ${import_picocolors7.default.dim(`(project ${prov.project.slug})`)}` : "";
280634
+ Se(
280635
+ `${import_picocolors7.default.green("\u2713")} Logged in ${import_picocolors7.default.dim("(sandbox)")}${where}. Run ${import_picocolors7.default.bold("npx ablo push")} to push your schema.`
280636
+ );
280538
280637
  }
280539
- async function login() {
280540
- await deviceLogin();
280638
+ async function login(argv = []) {
280639
+ await deviceLogin(argv);
280541
280640
  }
280542
280641
  function logout() {
280543
280642
  const removed = clearCredential();
@@ -280790,11 +280889,13 @@ async function projects(argv) {
280790
280889
  setActiveProject({ id: target.id, slug: target.slug });
280791
280890
  console.log(` ${import_picocolors9.default.green("\u2713")} now targeting project ${import_picocolors9.default.bold(target.slug)} ${import_picocolors9.default.dim(`(${target.id})`)}`);
280792
280891
  }
280793
- console.log(
280794
- import_picocolors9.default.dim(
280795
- " Note: a key\u2019s project scope is fixed at mint \u2014 switch keys (or mint one for this project) to act in it."
280796
- )
280797
- );
280892
+ const guard = guardActiveProjectKey();
280893
+ if (!guard.ok) {
280894
+ const loginCmd = guard.activeProfile === DEFAULT_PROFILE ? "ablo login" : `ablo login --project ${guard.activeProfile}`;
280895
+ console.log(
280896
+ import_picocolors9.default.dim(` No key stored for this project yet \u2014 run ${import_picocolors9.default.bold(loginCmd)} to mint one.`)
280897
+ );
280898
+ }
280798
280899
  return;
280799
280900
  }
280800
280901
  console.error(
@@ -281370,7 +281471,7 @@ function spreadOpts(optsArg) {
281370
281471
  }
281371
281472
  return `, ...${optsArg.getText()}`;
281372
281473
  }
281373
- function hasKey(obj, key) {
281474
+ function hasKey2(obj, key) {
281374
281475
  return obj.getProperties().some(
281375
281476
  (p2) => (import_ts_morph.Node.isPropertyAssignment(p2) || import_ts_morph.Node.isShorthandPropertyAssignment(p2)) && p2.getName() === key
281376
281477
  );
@@ -281383,7 +281484,7 @@ function verbRewrite(call, verb) {
281383
281484
  const first = args[0];
281384
281485
  const calleeText = call.getExpression().getText();
281385
281486
  if (verb === "create") {
281386
- if (import_ts_morph.Node.isObjectLiteralExpression(first) && hasKey(first, "data")) return null;
281487
+ if (import_ts_morph.Node.isObjectLiteralExpression(first) && hasKey2(first, "data")) return null;
281387
281488
  return `${calleeText}({ data: ${first.getText()}${spreadOpts(args[1])} })`;
281388
281489
  }
281389
281490
  if (import_ts_morph.Node.isObjectLiteralExpression(first)) return null;
@@ -282212,7 +282313,7 @@ async function main() {
282212
282313
  if (command === "init") {
282213
282314
  await init(process.argv.slice(3));
282214
282315
  } else if (command === "login") {
282215
- await login();
282316
+ await login(process.argv.slice(3));
282216
282317
  } else if (command === "logout") {
282217
282318
  logout();
282218
282319
  } else if (command === "mode") {
@@ -282251,6 +282352,22 @@ async function main() {
282251
282352
  const rest = process.argv.slice(3);
282252
282353
  const advanced = rest.some((a) => ["--force", "--rename", "--backfill", "--url"].includes(a));
282253
282354
  const watching = rest.includes("--watch");
282355
+ const guard = guardActiveProjectKey();
282356
+ if (!guard.ok && guard.available.length > 0 && !rest.includes("--url")) {
282357
+ console.error(
282358
+ ` ${import_picocolors18.default.yellow("\u26A0")} active project ${import_picocolors18.default.bold(guard.activeProfile)} has no stored key ${import_picocolors18.default.dim(
282359
+ `(you have keys for: ${guard.available.join(", ")})`
282360
+ )}`
282361
+ );
282362
+ const loginCmd = guard.activeProfile === "default" ? "ablo login" : `ablo login --project ${guard.activeProfile}`;
282363
+ console.error(
282364
+ import_picocolors18.default.dim(
282365
+ ` Mint one with ${import_picocolors18.default.bold(loginCmd)}, or switch with ${import_picocolors18.default.bold("ablo projects use <slug>")}.`
282366
+ )
282367
+ );
282368
+ process.exitCode = 1;
282369
+ return;
282370
+ }
282254
282371
  const plan = resolvePushPlan();
282255
282372
  if (advanced || plan.flow === "production" && !watching) {
282256
282373
  await push(rest);
@@ -282275,11 +282392,12 @@ async function main() {
282275
282392
  console.log(` [--auth apikey] [--storage direct|endpoint] [--project <slug>] [--no-project]`);
282276
282393
  console.log(` [--no-agent] [--no-pull] [--no-install] [--no-login]`);
282277
282394
  console.log(` npx ablo login Authorize in your browser (provisions sandbox + production keys)`);
282395
+ console.log(` npx ablo login --project <slug> Same, scoped to a project (mints its keys, makes it active)`);
282278
282396
  console.log(` npx ablo logout Remove the stored API key`);
282279
282397
  console.log(` npx ablo mode [sandbox|production] Switch active environment, like Stripe`);
282280
282398
  console.log(` npx ablo projects list List the org's projects (default + your own)`);
282281
282399
  console.log(` npx ablo projects create <slug> Create a project (its keys/schema/data are isolated)`);
282282
- console.log(` npx ablo projects use <slug|default> Set the active project for new key mints`);
282400
+ console.log(` npx ablo projects use <slug|default> Switch the active project (run login --project to mint its keys)`);
282283
282401
  console.log(` npx ablo status Show org, mode, keys, and server health`);
282284
282402
  console.log(` npx ablo status --json Same, machine-readable (mode, key prefix, org id, api host)`);
282285
282403
  console.log(` npx ablo logs [-n N] [--since 15m] Tail commit activity (follows; --no-follow to exit)`);
@@ -28,7 +28,7 @@ import type { SyncWebSocket } from '../sync/SyncWebSocket.js';
28
28
  import type { SyncGroupInput } from '../schema/roles.js';
29
29
  import { type SyncStatus } from '../BaseSyncedStore.js';
30
30
  import type { ClaimStream, ClaimWaitOptions, PresenceStream, Snapshot } from '../types/streams.js';
31
- import type { ClaimHandle, Duration, Claim } from '../types/streams.js';
31
+ import type { ClaimHandle, Duration } from '../types/streams.js';
32
32
  import { type AbloApi, type AbloApiClientOptions, type AbloApiClaims } from './ApiClient.js';
33
33
  import { type AbloHttpClient, type AbloHttpClientOptions } from './httpClient.js';
34
34
  /**
@@ -371,7 +371,7 @@ export interface InternalAbloOptions<S extends SchemaRecord = SchemaRecord> {
371
371
  * `claim({ id })` — durable claim handle for coordinated writes
372
372
  */
373
373
  export type { LocalCountOptions, LocalReadOptions, ModelListScope, ServerReadOptions, ModelRetrieveParams, ModelCreateParams, ModelUpdateParams, ModelDeleteParams, ClaimOptions, ClaimParams, ClaimLookupParams, ClaimReorderParams, ClaimHandle, ModelOperations, } from './createModelProxy.js';
374
- import type { ModelOperations, ClaimOptions, ClaimParams, ClaimLookupParams, ClaimReorderParams, ServerReadOptions } from './createModelProxy.js';
374
+ import type { ModelOperations, ClaimOptions, ClaimParams, ClaimReadApi, AwaitedClaimMethod, ServerReadOptions } from './createModelProxy.js';
375
375
  export type ModelOperationAction = 'create' | 'update' | 'delete' | 'archive' | 'unarchive';
376
376
  export type CommitWait = 'queued' | 'confirmed';
377
377
  export interface ModelRead<T = Record<string, unknown>> {
@@ -473,30 +473,21 @@ export interface ModelMutationOptions extends ClaimedOptions {
473
473
  * The HTTP/stateless claim surface. Normal tools usually put `claim` directly
474
474
  * on the write (`update({ id, data, claim })`) and let the SDK release it. Use
475
475
  * this namespace for multi-step handles and coordination screens.
476
+ *
477
+ * Same surface as the reactive {@link ClaimApi}, but every read is a server
478
+ * round-trip, so `state`/`queue`/`reorder` are **awaited** here (the WebSocket
479
+ * client resolves them synchronously from its local pool — which is what lets
480
+ * `useAblo((ablo) => ablo.x.claim.state({ id }))` work inside a React render; a
481
+ * stateless client has no pool to read, so the `Promise` is unavoidable).
482
+ *
483
+ * Mechanically DERIVED from `ClaimReadApi` via {@link AwaitedClaimMethod} so the
484
+ * two transports can never drift: the ONLY difference is the uniform `Promise`
485
+ * wrapper that statelessness forces. `claim({ id })` is identical (already async
486
+ * on both); `state`/`queue`/`reorder`/`release` are the awaited form.
476
487
  */
477
- export interface HttpClaimApi<T> {
478
- /** Take a manual claim handle for multi-step work. Release it when done. */
479
- (params: ClaimParams<T>): Promise<ClaimHandle<T>>;
480
- /** Release a manual claim you hold. */
481
- release(params: ClaimLookupParams<T> | ClaimHandle<T>): Promise<void>;
482
- /**
483
- * Current holder of the lease on a row, or `null` when free. For UI badges,
484
- * preflight checks, and operators.
485
- */
486
- state(params: ClaimLookupParams<T>): Promise<Claim | null>;
487
- /**
488
- * FIFO wait line behind the holder. Advanced: useful for operator UIs and
489
- * schedulers.
490
- */
491
- queue(params: ClaimLookupParams<T>): Promise<{
492
- readonly object: 'list';
493
- readonly data: readonly Claim[];
494
- }>;
495
- /**
496
- * Re-rank the wait line. Advanced and permission-gated.
497
- */
498
- reorder(params: ClaimReorderParams<T>): Promise<void>;
499
- }
488
+ export type HttpClaimApi<T = Record<string, unknown>> = ((params: ClaimParams<T>) => Promise<ClaimHandle<T>>) & {
489
+ [K in keyof ClaimReadApi<T>]: AwaitedClaimMethod<ClaimReadApi<T>[K]>;
490
+ };
500
491
  export interface ModelClient<T = Record<string, unknown>> {
501
492
  /**
502
493
  * Single-row read over HTTP. **Returns an envelope, not the bare row** — the
@@ -1325,7 +1325,7 @@ export function Ablo(options) {
1325
1325
  }),
1326
1326
  queue: (target) => publicClaims.queueFor({ type: target.model, id: target.id }),
1327
1327
  reorder: (target, order) => publicClaims.reorder({ type: target.model, id: target.id }, order),
1328
- observe: (target) => {
1328
+ state: (target) => {
1329
1329
  // The live claim stream only tracks *open* (active) claims;
1330
1330
  // terminal states (committed / expired / canceled) drop out of
1331
1331
  // the list entirely — exactly the ephemeral coordination model.
@@ -54,6 +54,14 @@ export function resolveDatabaseUrl(input) {
54
54
  * explicit option instead of flipping their mode for them. Warns once per process
55
55
  * so it never spams, and falls back to `console.warn` when no logger is supplied
56
56
  * (the `transport: 'api'` client has none).
57
+ *
58
+ * Suppressed entirely on the hosted/token path: if an `apiKey` resolves (option
59
+ * or `ABLO_API_KEY` env), the caller has chosen the hosted capability-token /
60
+ * Data Source transport, which is mutually exclusive with direct `databaseUrl`
61
+ * mode. A `DATABASE_URL` sitting in that environment is unrelated infra (Prisma,
62
+ * Drizzle, the sync-server) — never an omitted option — so nudging would be a
63
+ * false positive. This is the first-party hosted app's exact shape, where the
64
+ * stray nudge otherwise reaches end-user desktop logs.
57
65
  */
58
66
  let warnedDatabaseUrlEnvIgnored = false;
59
67
  export function warnIfDatabaseUrlEnvIgnored(input, warn) {
@@ -61,6 +69,9 @@ export function warnIfDatabaseUrlEnvIgnored(input, warn) {
61
69
  return;
62
70
  if (input.options.databaseUrl != null)
63
71
  return;
72
+ // Hosted/token path → DATABASE_URL is unrelated infra, not an omitted option.
73
+ if (resolveApiKey(input) != null)
74
+ return;
64
75
  const envUrl = input.env.DATABASE_URL;
65
76
  if (typeof envUrl !== 'string' || envUrl.length === 0)
66
77
  return;
@@ -109,8 +109,13 @@ export interface ModelCollaboration<T> {
109
109
  * `null` when the target is free. The wiring site computes it because
110
110
  * only it knows the local participant id (needed to distinguish "I
111
111
  * hold it" from "someone else holds it").
112
+ *
113
+ * Named `state` to match the public `ablo.<model>.claim.state({ id })` read —
114
+ * one verb for "who holds this" across every claim surface; the only
115
+ * difference is this internal contract takes an explicit `{ model, id }`
116
+ * target because it isn't bound to a single model.
112
117
  */
113
- observe(target: {
118
+ state(target: {
114
119
  model: string;
115
120
  id: string;
116
121
  }): Claim | null;
@@ -202,10 +207,11 @@ export interface ClaimTargetOptions<T = Record<string, unknown>> {
202
207
  * work-distribution dedup ("if someone else has this job, skip it") where
203
208
  * waiting would mean double-processing.
204
209
  *
205
- * Named `queue` to match every other claim surface (low-level
206
- * `claims.claim`, HTTP `claim.create`, and the wire). The high-level typed
207
- * claim defaults it ON because it serializes writers; the low-level lease
208
- * and HTTP default it OFF they return/resolve immediately and can't
210
+ * Named `queue` to match every other claim surface — `ablo.<model>.claim`
211
+ * on both the WS and HTTP clients (take-a-claim is the callable `claim({ id
212
+ * })` on both; the HTTP reads are just awaited) and the wire. The high-level
213
+ * typed claim defaults it ON because it serializes writers; the low-level
214
+ * lease and HTTP default it OFF — they return/resolve immediately and can't
209
215
  * transparently wait for a grant.
210
216
  */
211
217
  queue?: boolean;
@@ -276,9 +282,18 @@ export type ClaimOptions<T = Record<string, unknown>> = ClaimTargetOptions<T>;
276
282
  * handle. `state`, `queue`, and `reorder` are coordination reads/scheduler
277
283
  * controls for UI and operators.
278
284
  */
279
- export interface ClaimApi<T> {
280
- /** Take a claim and get an explicit held-work handle back. */
281
- (params: ClaimParams<T>): Promise<ClaimHandle<T>>;
285
+ /**
286
+ * Coordination reads + scheduler controls on a claim namespace, in their
287
+ * REACTIVE (synchronous) form — `state`/`queue`/`reorder` resolve against the
288
+ * local pool with no round-trip, which is what lets `useAblo((ablo) =>
289
+ * ablo.x.claim.state({ id }))` read coordination state inside a React render.
290
+ *
291
+ * This is the single source of truth for the claim read surface: the stateless
292
+ * HTTP client exposes the *awaited* projection of EXACTLY these methods
293
+ * (`HttpClaimApi` in `Ablo.ts`, derived via {@link AwaitedClaimMethod}), so the
294
+ * two transports can never drift — edit a signature here and HTTP follows.
295
+ */
296
+ export interface ClaimReadApi<T = Record<string, unknown>> {
282
297
  /**
283
298
  * Current holder for a row, or `null` when free. Use this for UI badges and
284
299
  * preflight checks, not for the normal write path.
@@ -299,6 +314,16 @@ export interface ClaimApi<T> {
299
314
  /** Release a manual claim handle early. Single-write claims auto-release. */
300
315
  release(params: ClaimLookupParams<T> | ClaimHandle<T>): Promise<void>;
301
316
  }
317
+ /**
318
+ * The awaited form of a claim method: a synchronous return becomes a `Promise`,
319
+ * an already-async one (`release`) is left untouched. Used to derive the
320
+ * stateless HTTP claim surface from the reactive {@link ClaimReadApi}.
321
+ */
322
+ export type AwaitedClaimMethod<F> = F extends (...args: infer A) => infer R ? R extends Promise<unknown> ? (...args: A) => R : (...args: A) => Promise<R> : F;
323
+ export interface ClaimApi<T> extends ClaimReadApi<T> {
324
+ /** Take a claim and get an explicit held-work handle back. */
325
+ (params: ClaimParams<T>): Promise<ClaimHandle<T>>;
326
+ }
302
327
  export interface ModelRetrieveParams extends ServerRetrieveOptions {
303
328
  readonly id: string;
304
329
  }
@@ -141,7 +141,7 @@ export function createModelProxy(schemaKey, registeredModelName, objectPool, syn
141
141
  // Is someone ELSE already on this target? Read the local coordination
142
142
  // snapshot up front — it decides whether we'll need to re-read after the
143
143
  // claim (a free / already-mine target can't have changed under us).
144
- const held = collaboration.observe({ model: wireModel, id });
144
+ const held = collaboration.state({ model: wireModel, id });
145
145
  const contended = !!held && held.heldBy !== collaboration.selfParticipantId;
146
146
  const failFast = options?.queue === false;
147
147
  // Fail-fast (`queue: false`): if another participant already holds it,
@@ -215,7 +215,7 @@ export function createModelProxy(schemaKey, registeredModelName, objectPool, syn
215
215
  const snapshot = collaboration.createSnapshot(schemaKey, id);
216
216
  const reason = options?.reason ?? 'editing';
217
217
  // The self-claim's `EntityRef` mirrors what a peer's `claim.state` would
218
- // report (`observe` maps `held.target.model` → `type`), so a holder and a
218
+ // report (`state` maps `held.target.model` → `type`), so a holder and a
219
219
  // peer see the SAME target.type for one row — the wire model token.
220
220
  const selfTarget = {
221
221
  type: wireModel,
@@ -271,7 +271,7 @@ export function createModelProxy(schemaKey, registeredModelName, objectPool, syn
271
271
  // presence. Soft + fire-and-forget — never blocks or rejects the read.
272
272
  void collaboration?.enterScope?.({ [schemaKey]: params.id });
273
273
  // Self-awareness: the server excludes a holder's OWN presence frames and
274
- // the client skips them, so `observe` returns null for a row WE hold.
274
+ // the client skips them, so `state` returns null for a row WE hold.
275
275
  // Synthesize the active claim for self from the stored lease so the
276
276
  // holder sees its own claim (the JSDoc contract on `claim.state`).
277
277
  const own = activeClaims.get(params.id);
@@ -287,7 +287,7 @@ export function createModelProxy(schemaKey, registeredModelName, objectPool, syn
287
287
  expiresAt: own.expiresAt,
288
288
  };
289
289
  }
290
- return collaboration?.observe({ model: wireModel, id: params.id }) ?? null;
290
+ return collaboration?.state({ model: wireModel, id: params.id }) ?? null;
291
291
  },
292
292
  queue(params) {
293
293
  return {
@@ -37,7 +37,7 @@ import { z } from 'zod';
37
37
  * code, a changed HTTP status, an envelope field. Emitted in `errors.json`
38
38
  * and on the `Ablo-Version` response header so a consumer can detect drift.
39
39
  */
40
- export declare const ERROR_CONTRACT_VERSION = "2026-06-13";
40
+ export declare const ERROR_CONTRACT_VERSION = "2026-06-20";
41
41
  /** Coarse grouping for metrics dashboards and docs sectioning. */
42
42
  export type ErrorCategory = 'auth' | 'permission' | 'capability' | 'claim' | 'conflict' | 'validation' | 'not_found' | 'tenant' | 'schema' | 'claim' | 'bootstrap' | 'transport' | 'rate_limit' | 'server' | 'client';
43
43
  /**
@@ -239,8 +239,10 @@ export declare const ERROR_CODES: {
239
239
  readonly ws_not_ready: ErrorCodeSpec;
240
240
  readonly quota_exceeded: ErrorCodeSpec;
241
241
  readonly connection_limit_exceeded: ErrorCodeSpec;
242
+ readonly rate_limit_exceeded: ErrorCodeSpec;
242
243
  readonly internal_error: ErrorCodeSpec;
243
244
  readonly quota_lookup_failed: ErrorCodeSpec;
245
+ readonly rate_limiter_unavailable: ErrorCodeSpec;
244
246
  readonly turn_open_failed: ErrorCodeSpec;
245
247
  readonly turn_close_failed: ErrorCodeSpec;
246
248
  readonly invalid_options: ErrorCodeSpec;
@@ -37,7 +37,7 @@ import { z } from 'zod';
37
37
  * code, a changed HTTP status, an envelope field. Emitted in `errors.json`
38
38
  * and on the `Ablo-Version` response header so a consumer can detect drift.
39
39
  */
40
- export const ERROR_CONTRACT_VERSION = '2026-06-13';
40
+ export const ERROR_CONTRACT_VERSION = '2026-06-20';
41
41
  /**
42
42
  * The closed taxonomy of *how a failure recovers* — one rung above the raw
43
43
  * `code`. Where `code` says **what** went wrong, `RecoveryClass` says **what
@@ -258,9 +258,18 @@ export const ERROR_CODES = {
258
258
  // ── quota / rate limit (429) ──────────────────────────────────────
259
259
  quota_exceeded: wire('rate_limit', 429, true, 'The organization exceeded its configured usage quota.'),
260
260
  connection_limit_exceeded: wire('rate_limit', 429, true, 'Too many concurrent WebSocket connections for this principal or organization. Close idle connections, or retry once others drain.'),
261
+ // Per-CREDENTIAL request-rate limit — the fast (RPS/burst) axis, distinct from
262
+ // the slow-axis `quota_exceeded` (org daily/monthly usage). Keyed per API key,
263
+ // so one noisy key backs off without affecting the rest of the org. The
264
+ // `Retry-After` header carries the bucket-refill delay.
265
+ rate_limit_exceeded: wire('rate_limit', 429, true, 'This API key is sending requests too quickly; slow down and retry after the indicated delay.'),
261
266
  // ── server (5xx) ───────────────────────────────────────────────────
262
267
  internal_error: wire('server', 500, true, 'An unexpected server error occurred.'),
263
268
  quota_lookup_failed: wire('server', 503, true, 'The quota decision could not be loaded.'),
269
+ // The per-key rate-limiter backend (Redis) was unreachable and the API is
270
+ // configured to FAIL CLOSED on that path, so the request was rejected rather
271
+ // than admitted unchecked. Retryable: the next attempt re-probes the backend.
272
+ rate_limiter_unavailable: wire('server', 503, true, 'The rate-limiter backend is unavailable and this endpoint is configured to fail closed; retry shortly.'),
264
273
  turn_open_failed: wire('server', 500, true, 'The agent turn failed to open.'),
265
274
  turn_close_failed: wire('server', 500, true, 'The agent turn failed to close cleanly.'),
266
275
  // ── client-only invariants (never serialized) ──────────────────────
@@ -23,13 +23,13 @@
23
23
  export { z } from 'zod';
24
24
  export { field, indexed, getFieldMeta, type FieldBuilder, type FieldMeta } from './field.js';
25
25
  export { relation, type RelationDef, type RelationType } from './relation.js';
26
- export { tenancySchema, scopedViaRefSchema, resolveTenancy, tenancyColumn, DEFAULT_ORG_COLUMN, type Tenancy, type ScopedViaRef, type TenancyInput, } from './tenancy.js';
26
+ export { tenancySchema, scopedViaRefSchema, policyInputSchema, resolvePolicy, resolveTenancy, tenancyColumn, DEFAULT_ORG_COLUMN, type Tenancy, type ScopedViaRef, type PolicyInput, } from './tenancy.js';
27
27
  export { planeSchema, DEFAULT_PLANE, type SchemaPlane } from './plane.js';
28
28
  export { syncDeltaCoreSchema, deltaAttributionSchema, deltaProvenanceSchema, syncDeltaRowSchema, participantKindSchema, confirmationStateSchema, backfillProvenanceSchema, DELTA_PLANES, type SyncDeltaCore, type DeltaAttribution, type DeltaProvenance, type SyncDeltaRow, type ParticipantKind, type ConfirmationState, type BackfillProvenance, } from './sync-delta-row.js';
29
29
  export { syncDeltaActionSchema, wireDeltaDataSchema, participantRefSchema, syncDeltaWireCoreSchema, clientSyncDeltaSchema, serverSyncDeltaSchema, type SyncDeltaAction, type WireDeltaData, type ParticipantRef, type SyncDeltaWireCore, type ClientSyncDelta, type ServerSyncDelta, } from './sync-delta-wire.js';
30
30
  export { model, scopeKindOf, type ModelDef, type ModelOptions, type LoadStrategy, type PersistOptions, type RelationRecord, type GrantsRef, } from './model.js';
31
31
  export { mutable, readOnly, type SugarOptions } from './sugar.js';
32
- export { defineSchema, composeIdentitySyncGroups, type Schema, type SchemaRecord, type Model, type InferModel, type InferCreate, type InferModelNames, type BaseModelFields, type InsertValue, type UpsertValue, type UpdateValue, type DeleteId, type DefineSchemaOptions, type Casing, type CasingConvention, type CasingFn, composeEntitySyncGroups, type IdentityRole, type IdentityContext, type IdentityRoleSource, type EntityRole, type EntityContext, type EntityRoleSource, type RoleSource, type RoleContext, type SyncGroup, type SyncGroupInput, identityRole, entityRole, extractIdentityIds, extractEntityIds, syncGroup, syncGroupSchema, syncGroupInputSchema, isSyncGroupInput, identityRoleSchema, entityRoleSchema, roleSchema, roleSourceSchema, scopeSchema, grantsRefSchema, } from './schema.js';
32
+ export { defineSchema, composeIdentitySyncGroups, type Schema, type SchemaRecord, type Model, type InferModel, type InferCreate, type InferModelNames, type BaseModelFields, type InsertValue, type UpsertValue, type UpdateValue, type DeleteId, type DefineSchemaOptions, type Casing, type CasingConvention, type CasingFn, composeEntitySyncGroups, type IdentityRole, type IdentityContext, type IdentityRoleSource, type EntityRole, type EntityContext, type EntityRoleSource, type RoleSource, type RoleContext, type SyncGroup, type SyncGroupInput, identityRole, entityRole, extractIdentityIds, extractEntityIds, syncGroup, syncGroupSchema, syncGroupInputSchema, isSyncGroupInput, identityRoleSchema, entityRoleSchema, roleSchema, roleSourceSchema, scopeSchema, grantsRefSchema, groupsInputSchema, type GroupsInput, } from './schema.js';
33
33
  export { serializeSchema, parseSchema, toSchemaJSON, fromSchemaJSON, schemaHash, type SchemaJSON, type ModelJSON, type RelationJSON, } from './serialize.js';
34
34
  export { selectModels } from './select.js';
35
35
  export { generateProvisionPlan, generateMigrationPlan, appSchemaName, camelToSnake, snakeToCamel, q, sqlType, type ProvisionPlan, type MigrationPlan, } from './ddl.js';