@abloatai/ablo 0.12.0 → 0.13.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 (45) hide show
  1. package/AGENTS.md +2 -2
  2. package/CHANGELOG.md +19 -0
  3. package/README.md +3 -3
  4. package/dist/cli.cjs +149 -40
  5. package/dist/schema/index.d.ts +2 -2
  6. package/dist/schema/index.js +2 -2
  7. package/dist/schema/model.d.ts +38 -84
  8. package/dist/schema/model.js +12 -12
  9. package/dist/schema/roles.d.ts +49 -0
  10. package/dist/schema/roles.js +21 -0
  11. package/dist/schema/schema.d.ts +1 -1
  12. package/dist/schema/schema.js +1 -1
  13. package/dist/schema/serialize.d.ts +4 -2
  14. package/dist/schema/serialize.js +4 -2
  15. package/dist/schema/sugar.d.ts +7 -28
  16. package/dist/schema/sugar.js +2 -7
  17. package/dist/schema/sync-delta-row.d.ts +2 -0
  18. package/dist/schema/sync-delta-row.js +2 -1
  19. package/dist/schema/tenancy.d.ts +67 -28
  20. package/dist/schema/tenancy.js +93 -23
  21. package/dist/server/commit.d.ts +8 -3
  22. package/docs/api.md +1 -1
  23. package/docs/cli.md +43 -4
  24. package/docs/client-behavior.md +2 -2
  25. package/docs/coordination.md +1 -1
  26. package/docs/examples/agent-human.md +6 -6
  27. package/docs/examples/ai-sdk-tool.md +1 -1
  28. package/docs/examples/existing-python-backend.md +0 -2
  29. package/docs/examples/nextjs.md +2 -2
  30. package/docs/examples/scoped-agent.md +3 -3
  31. package/docs/examples/server-agent.md +4 -4
  32. package/docs/identity.md +27 -20
  33. package/docs/index.md +0 -1
  34. package/docs/integration-guide.md +12 -9
  35. package/docs/interaction-model.md +1 -1
  36. package/docs/mcp.md +17 -5
  37. package/docs/quickstart.md +3 -3
  38. package/llms.txt +2 -3
  39. package/package.json +3 -2
  40. package/docs/mcp/claude-code.md +0 -35
  41. package/docs/mcp/cursor.md +0 -35
  42. package/docs/mcp/windsurf.md +0 -33
  43. package/docs/roadmap.md +0 -55
  44. package/docs/the-loop.md +0 -21
  45. package/llms-full.txt +0 -396
package/AGENTS.md CHANGED
@@ -31,7 +31,7 @@ Every model verb takes ONE options object. The common loop:
31
31
 
32
32
  1. **Read** the row — `await ablo.<model>.retrieve({ id })` (async; from the server) or `await ablo.<model>.list({ where })` for many. In React render, read synchronously with `useAblo((a) => a.<model>.get(id))`.
33
33
  2. **See who's active** (optional) — `ablo.<model>.claim.state({ id })` (synchronous; never blocks).
34
- 3. **Claim** the row before changing it — `await using claim = await ablo.<model>.claim({ id, action?, ttl? })`. If someone else holds it, this waits for them, then gives you the fresh row on `claim.data`. The claim auto-releases when it goes out of scope (`await using`).
34
+ 3. **Claim** the row before changing it — `await using claim = await ablo.<model>.claim({ id, reason?, ttl? })`. If someone else holds it, this waits for them, then gives you the fresh row on `claim.data`. The claim auto-releases when it goes out of scope (`await using`).
35
35
  4. **Write** — `await ablo.<model>.update({ id: claim.data.id, data })`. Because you hold the claim, the write is rejected if the row changed underneath you.
36
36
 
37
37
  Keep coding assistants on this schema-backed path.
@@ -59,7 +59,7 @@ if (!report) throw new Error('Report not found');
59
59
  // row before resolving. Auto-released at the end of this scope (`await using`).
60
60
  await using claim = await ablo.weatherReports.claim({
61
61
  id: 'report_stockholm',
62
- action: 'forecasting',
62
+ reason: 'forecasting',
63
63
  ttl: '2m',
64
64
  });
65
65
  const claimed = claim.data;
package/CHANGELOG.md CHANGED
@@ -1,5 +1,24 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.13.0
4
+
5
+ ### Minor Changes
6
+
7
+ - Schema authoring: split model routing into two orthogonal axes — `policy` (row access) and `groups` (sync-group routing).
8
+
9
+ **Breaking (schema authoring).** The flat, collision-prone model options are replaced by two namespaced ones:
10
+ - **`policy`** — row-access / tenant isolation (named after Postgres/Supabase RLS policies: the rule that scopes which rows a tenant may read). A discriminated union on `by` replaces the old `orgScoped` / `scopedVia` / `orgColumn` trio:
11
+ - `{ by: 'column' }` — row-local tenancy column (the default when omitted; column name still overridable).
12
+ - `{ by: 'parent', fk, parent }` — inherit tenancy through a foreign key when the table has no tenancy column of its own (e.g. `slide_layers` → `slides`).
13
+ - Type `TenancyInput` is renamed `PolicyInput`; `policyInputSchema` / `resolvePolicy` are now exported.
14
+ - **`groups: { root, grants, roles }`** — which delta channels a row fans into (orthogonal to `policy`, which governs read access). One namespaced object replaces the old flat `scope` / `grants` / `entityRoles`:
15
+ - `root` (was `scope`) — mark a model a scope root; its records form the group `<kind>:<id>`. Renamed so it no longer collides with the old `scopedVia` tenancy sugar or the inner `grants.scope` relation name.
16
+ - `grants` — a membership edge granting an identity access to a scope root.
17
+ - `roles` (was `entityRoles`) — explicit non-relational record→group roles; accepts one role or an array.
18
+ - `groupsInputSchema` / `GroupsInput` are now exported.
19
+
20
+ **CLI.** `config.json` now stores per-project profile key pairs (`profiles: Record<string, ProfileKeys>`) instead of a single top-level pair; older flat layouts are folded into the active profile automatically on read, so existing logins keep working. `login` / `projects` updated to the profile model.
21
+
3
22
  ## 0.12.0
4
23
 
5
24
  ### Minor Changes
package/README.md CHANGED
@@ -274,7 +274,7 @@ ablo.weatherReports.claim.state({ id: 'report_stockholm' });
274
274
  ablo.weatherReports.claim.queue({ id: 'report_stockholm' });
275
275
 
276
276
  {
277
- await using claim = await ablo.weatherReports.claim({ id, wait: false });
277
+ await using claim = await ablo.weatherReports.claim({ id, queue: false });
278
278
  /* do the held work */
279
279
  }
280
280
 
@@ -285,11 +285,11 @@ ablo.weatherReports.claim.queue({ id: 'report_stockholm' });
285
285
  ```
286
286
 
287
287
  `claim.state` returns the holder (or `null`); `claim.queue` returns the line waiting
288
- behind it. `wait: false` skips rather than waiting when the row is held;
288
+ behind it. `queue: false` skips rather than waiting when the row is held;
289
289
  `maxQueueDepth: 2` bails when two or more are already ahead.
290
290
 
291
291
  Default reads keep working while a row is claimed. Server reads that need claimed
292
- semantics can opt in with `ifClaimed: 'return' | 'wait' | 'fail'`.
292
+ semantics can opt in with `ifClaimed: 'return' | 'fail'`.
293
293
 
294
294
  Even an unclaimed write can't land on stale reasoning — the commit is guarded:
295
295
 
package/dist/cli.cjs CHANGED
@@ -277023,6 +277023,7 @@ var roleSchema = import_zod2.z.object({
277023
277023
  kind: import_zod2.z.string().regex(/^[a-z][a-z0-9_]*$/, 'kind must be a lowercase identifier, e.g. "deck"'),
277024
277024
  source: roleSourceSchema
277025
277025
  });
277026
+ var entityRoleSchema = roleSchema;
277026
277027
  var scopeSchema = import_zod2.z.union([
277027
277028
  import_zod2.z.boolean(),
277028
277029
  import_zod2.z.string().regex(/^[a-z][a-z0-9_]*$/, 'scope kind must be a lowercase identifier, e.g. "dataroom"')
@@ -277031,6 +277032,11 @@ var grantsRefSchema = import_zod2.z.object({
277031
277032
  subject: import_zod2.z.string().regex(/^[a-zA-Z_][a-zA-Z0-9_]*$/, "grants.subject must name a relation"),
277032
277033
  scope: import_zod2.z.string().regex(/^[a-zA-Z_][a-zA-Z0-9_]*$/, "grants.scope must name a relation")
277033
277034
  });
277035
+ var groupsInputSchema = import_zod2.z.object({
277036
+ root: scopeSchema.optional(),
277037
+ grants: grantsRefSchema.optional(),
277038
+ roles: import_zod2.z.union([entityRoleSchema, import_zod2.z.array(entityRoleSchema)]).optional()
277039
+ });
277034
277040
 
277035
277041
  // src/coordination/schema.ts
277036
277042
  var targetRangeSchema = import_zod3.z.object({
@@ -279646,6 +279652,7 @@ init_cjs_shims();
279646
279652
  var import_os2 = require("os");
279647
279653
  var import_path2 = require("path");
279648
279654
  var import_fs3 = require("fs");
279655
+ var DEFAULT_PROFILE = "default";
279649
279656
  function configDir() {
279650
279657
  if (process.env.ABLO_CONFIG_DIR) return process.env.ABLO_CONFIG_DIR;
279651
279658
  const xdg = process.env.XDG_CONFIG_HOME;
@@ -279657,12 +279664,32 @@ function configPath() {
279657
279664
  function credentialsPath() {
279658
279665
  return (0, import_path2.join)(configDir(), "credentials.json");
279659
279666
  }
279667
+ function activeProfileName(cfg) {
279668
+ return cfg.activeProject?.slug ?? DEFAULT_PROFILE;
279669
+ }
279660
279670
  function asKeyEntry(value) {
279661
279671
  if (value && typeof value === "object" && typeof value.apiKey === "string") {
279662
279672
  return value;
279663
279673
  }
279664
279674
  return void 0;
279665
279675
  }
279676
+ function asProfileKeys(value) {
279677
+ if (!value || typeof value !== "object") return void 0;
279678
+ const v = value;
279679
+ const sandbox = asKeyEntry(v.sandbox);
279680
+ const production = asKeyEntry(v.production);
279681
+ if (!sandbox && !production) return void 0;
279682
+ return { ...sandbox ? { sandbox } : {}, ...production ? { production } : {} };
279683
+ }
279684
+ function asProfileMap(value) {
279685
+ if (!value || typeof value !== "object") return {};
279686
+ const out = {};
279687
+ for (const [name, v] of Object.entries(value)) {
279688
+ const keys = asProfileKeys(v);
279689
+ if (keys) out[name] = keys;
279690
+ }
279691
+ return out;
279692
+ }
279666
279693
  function readJson(path) {
279667
279694
  if (!(0, import_fs3.existsSync)(path)) return null;
279668
279695
  try {
@@ -279683,7 +279710,7 @@ function normalizeStoredMode(value) {
279683
279710
  if (value === "sandbox" || value === "production") return value;
279684
279711
  return void 0;
279685
279712
  }
279686
- function extractEntries(obj) {
279713
+ function extractLegacyEntries(obj) {
279687
279714
  const sandbox = asKeyEntry(obj.sandbox);
279688
279715
  const production = asKeyEntry(obj.production);
279689
279716
  if (sandbox || production) {
@@ -279692,24 +279719,33 @@ function extractEntries(obj) {
279692
279719
  const flat = asKeyEntry(obj);
279693
279720
  return flat ? { sandbox: flat } : {};
279694
279721
  }
279722
+ function hasKey(keys) {
279723
+ return !!(keys?.sandbox || keys?.production);
279724
+ }
279695
279725
  function readConfig() {
279696
279726
  const cfgObj = readJson(configPath());
279697
279727
  const credObj = readJson(credentialsPath());
279698
279728
  const mode2 = normalizeStoredMode(cfgObj?.mode) ?? normalizeStoredMode(credObj?.mode);
279699
279729
  const activeProject = asActiveProject(cfgObj?.activeProject);
279700
- const cfgEntries = cfgObj ? extractEntries(cfgObj) : {};
279701
- const entries = {
279702
- ...cfgEntries,
279703
- ...credObj ? extractEntries(credObj) : {}
279704
- // credentials file wins
279730
+ const activeName = activeProject?.slug ?? DEFAULT_PROFILE;
279731
+ const profiles = {
279732
+ ...asProfileMap(credObj?.profiles),
279733
+ ...asProfileMap(cfgObj?.profiles)
279705
279734
  };
279706
- if (!mode2 && !entries.sandbox && !entries.production) return null;
279735
+ const legacyCfg = cfgObj ? extractLegacyEntries(cfgObj) : {};
279736
+ const legacyCred = credObj ? extractLegacyEntries(credObj) : {};
279737
+ const legacy = { ...legacyCfg, ...legacyCred };
279738
+ const migratedLegacy = hasKey(legacy) && !hasKey(profiles[activeName]);
279739
+ if (migratedLegacy) profiles[activeName] = legacy;
279740
+ const anyKey = Object.values(profiles).some(hasKey);
279741
+ if (!mode2 && !anyKey) return null;
279707
279742
  const config = {
279708
279743
  mode: mode2 ?? "sandbox",
279709
279744
  ...activeProject ? { activeProject } : {},
279710
- ...entries
279745
+ profiles
279711
279746
  };
279712
- if (cfgEntries.sandbox || cfgEntries.production) writeConfig(config);
279747
+ const secretsInConfig = hasKey(legacyCfg);
279748
+ if (secretsInConfig || migratedLegacy) writeConfig(config);
279713
279749
  return config;
279714
279750
  }
279715
279751
  function writeConfig(cfg) {
@@ -279725,16 +279761,34 @@ function writeConfig(cfg) {
279725
279761
  `,
279726
279762
  { mode: 384 }
279727
279763
  );
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)}
279764
+ const profiles = {};
279765
+ for (const [name, keys] of Object.entries(cfg.profiles)) {
279766
+ if (!hasKey(keys)) continue;
279767
+ profiles[name] = {
279768
+ ...keys.sandbox ? { sandbox: keys.sandbox } : {},
279769
+ ...keys.production ? { production: keys.production } : {}
279770
+ };
279771
+ }
279772
+ (0, import_fs3.writeFileSync)(credentialsPath(), `${JSON.stringify({ profiles }, null, 2)}
279733
279773
  `, { mode: 384 });
279734
279774
  return credentialsPath();
279735
279775
  }
279776
+ function emptyConfig(mode2 = "sandbox") {
279777
+ return { mode: mode2, profiles: {} };
279778
+ }
279779
+ function setProfileKeys(profileName, keys, opts) {
279780
+ const cfg = readConfig() ?? emptyConfig(opts.mode);
279781
+ cfg.mode = opts.mode;
279782
+ cfg.profiles[profileName] = {
279783
+ ...keys.sandbox ? { sandbox: keys.sandbox } : {},
279784
+ ...keys.production ? { production: keys.production } : {}
279785
+ };
279786
+ if (opts.activeProject) cfg.activeProject = opts.activeProject;
279787
+ else delete cfg.activeProject;
279788
+ return writeConfig(cfg);
279789
+ }
279736
279790
  function setMode(mode2) {
279737
- const cfg = readConfig() ?? { mode: mode2 };
279791
+ const cfg = readConfig() ?? emptyConfig(mode2);
279738
279792
  cfg.mode = mode2;
279739
279793
  return writeConfig(cfg);
279740
279794
  }
@@ -279745,13 +279799,15 @@ function getActiveProject() {
279745
279799
  return readConfig()?.activeProject;
279746
279800
  }
279747
279801
  function setActiveProject(project) {
279748
- const cfg = readConfig() ?? { mode: "sandbox" };
279802
+ const cfg = readConfig() ?? emptyConfig("sandbox");
279749
279803
  if (project) cfg.activeProject = project;
279750
279804
  else delete cfg.activeProject;
279751
279805
  return writeConfig(cfg);
279752
279806
  }
279753
279807
  function getKeyEntry(mode2) {
279754
- return readConfig()?.[mode2];
279808
+ const cfg = readConfig();
279809
+ if (!cfg) return void 0;
279810
+ return cfg.profiles[activeProfileName(cfg)]?.[mode2];
279755
279811
  }
279756
279812
  function modeFromKey(key) {
279757
279813
  if (/^(sk|rk)_test_/.test(key)) return "sandbox";
@@ -279775,11 +279831,21 @@ function resolveApiKey(modeOverride) {
279775
279831
  if (process.env.ABLO_API_KEY) return process.env.ABLO_API_KEY;
279776
279832
  const cfg = readConfig();
279777
279833
  if (!cfg) return void 0;
279778
- const entry = cfg[modeOverride ?? cfg.mode];
279834
+ const entry = cfg.profiles[activeProfileName(cfg)]?.[modeOverride ?? cfg.mode];
279779
279835
  if (!entry) return void 0;
279780
279836
  if (entry.expiresAt && Date.parse(entry.expiresAt) <= Date.now()) return void 0;
279781
279837
  return entry.apiKey;
279782
279838
  }
279839
+ function guardActiveProjectKey() {
279840
+ if (process.env.ABLO_API_KEY) {
279841
+ return { ok: true, activeProfile: DEFAULT_PROFILE, available: [] };
279842
+ }
279843
+ const cfg = readConfig();
279844
+ const activeProfile = cfg ? activeProfileName(cfg) : DEFAULT_PROFILE;
279845
+ const profiles = cfg?.profiles ?? {};
279846
+ const available = Object.entries(profiles).filter(([, keys]) => hasKey(keys)).map(([name]) => name);
279847
+ return { ok: hasKey(profiles[activeProfile]), activeProfile, available };
279848
+ }
279783
279849
  function resolvePushPlan() {
279784
279850
  const envKey = process.env.ABLO_API_KEY;
279785
279851
  if (envKey) return { flow: modeFromKey(envKey) ?? getMode(), apiKey: envKey, source: "env" };
@@ -280425,8 +280491,19 @@ function openBrowser(url) {
280425
280491
  } catch {
280426
280492
  }
280427
280493
  }
280428
- async function deviceLogin() {
280494
+ function parseProjectFlag(argv) {
280495
+ const i = argv.indexOf("--project");
280496
+ if (i >= 0) {
280497
+ const slug = argv[i + 1];
280498
+ if (slug && !slug.startsWith("-")) return slug;
280499
+ }
280500
+ const eq = argv.find((a) => a.startsWith("--project="));
280501
+ return eq ? eq.slice("--project=".length) || void 0 : void 0;
280502
+ }
280503
+ async function deviceLogin(argv) {
280429
280504
  Ie(`${brand("ablo")} login`);
280505
+ const requested = parseProjectFlag(argv) ?? getActiveProject()?.slug;
280506
+ const targetProject = requested === DEFAULT_PROFILE ? void 0 : requested;
280430
280507
  const interactive = Boolean(process.stdout.isTTY && process.stdin.isTTY);
280431
280508
  let account = "login";
280432
280509
  if (interactive) {
@@ -280504,13 +280581,19 @@ ${import_picocolors7.default.dim(url)}`, "Approve in your browser");
280504
280581
  s.stop("Timed out waiting for approval.");
280505
280582
  process.exit(1);
280506
280583
  }
280507
- s.message("Provisioning a sandbox key\u2026");
280584
+ s.message(
280585
+ targetProject ? `Provisioning keys for ${targetProject}\u2026` : "Provisioning a sandbox key\u2026"
280586
+ );
280508
280587
  const provRes = await fetch(`${AUTH_URL}/api/cli/provision-key`, {
280509
280588
  method: "POST",
280510
280589
  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 })
280590
+ // Scope the minted keys to the chosen project (`--project`/active), with
280591
+ // the device_code as a legacy fallback for the /cli picker. Both harmless
280592
+ // if absent → org-default keys.
280593
+ body: JSON.stringify({
280594
+ device_code: code.device_code,
280595
+ ...targetProject ? { project_slug: targetProject } : {}
280596
+ })
280514
280597
  }).catch(() => null);
280515
280598
  if (!provRes || !provRes.ok) {
280516
280599
  s.stop("Could not provision a key.");
@@ -280528,16 +280611,23 @@ ${import_picocolors7.default.dim(url)}`, "Approve in your browser");
280528
280611
  ...prov.organizationId ? { organizationId: prov.organizationId } : {},
280529
280612
  ...k3.expiresAt ? { expiresAt: k3.expiresAt } : {}
280530
280613
  });
280531
- const path = writeConfig({
280532
- mode: "sandbox",
280533
- sandbox: entry(prov.test),
280534
- ...prov.live ? { production: entry(prov.live) } : {}
280535
- });
280614
+ const profileName = prov.project?.slug ?? DEFAULT_PROFILE;
280615
+ const path = setProfileKeys(
280616
+ profileName,
280617
+ {
280618
+ sandbox: entry(prov.test),
280619
+ ...prov.live ? { production: entry(prov.live) } : {}
280620
+ },
280621
+ { mode: "sandbox", activeProject: prov.project ?? void 0 }
280622
+ );
280536
280623
  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.`);
280624
+ const where = prov.project ? ` ${import_picocolors7.default.dim(`(project ${prov.project.slug})`)}` : "";
280625
+ Se(
280626
+ `${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.`
280627
+ );
280538
280628
  }
280539
- async function login() {
280540
- await deviceLogin();
280629
+ async function login(argv = []) {
280630
+ await deviceLogin(argv);
280541
280631
  }
280542
280632
  function logout() {
280543
280633
  const removed = clearCredential();
@@ -280790,11 +280880,13 @@ async function projects(argv) {
280790
280880
  setActiveProject({ id: target.id, slug: target.slug });
280791
280881
  console.log(` ${import_picocolors9.default.green("\u2713")} now targeting project ${import_picocolors9.default.bold(target.slug)} ${import_picocolors9.default.dim(`(${target.id})`)}`);
280792
280882
  }
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
- );
280883
+ const guard = guardActiveProjectKey();
280884
+ if (!guard.ok) {
280885
+ const loginCmd = guard.activeProfile === DEFAULT_PROFILE ? "ablo login" : `ablo login --project ${guard.activeProfile}`;
280886
+ console.log(
280887
+ import_picocolors9.default.dim(` No key stored for this project yet \u2014 run ${import_picocolors9.default.bold(loginCmd)} to mint one.`)
280888
+ );
280889
+ }
280798
280890
  return;
280799
280891
  }
280800
280892
  console.error(
@@ -281370,7 +281462,7 @@ function spreadOpts(optsArg) {
281370
281462
  }
281371
281463
  return `, ...${optsArg.getText()}`;
281372
281464
  }
281373
- function hasKey(obj, key) {
281465
+ function hasKey2(obj, key) {
281374
281466
  return obj.getProperties().some(
281375
281467
  (p2) => (import_ts_morph.Node.isPropertyAssignment(p2) || import_ts_morph.Node.isShorthandPropertyAssignment(p2)) && p2.getName() === key
281376
281468
  );
@@ -281383,7 +281475,7 @@ function verbRewrite(call, verb) {
281383
281475
  const first = args[0];
281384
281476
  const calleeText = call.getExpression().getText();
281385
281477
  if (verb === "create") {
281386
- if (import_ts_morph.Node.isObjectLiteralExpression(first) && hasKey(first, "data")) return null;
281478
+ if (import_ts_morph.Node.isObjectLiteralExpression(first) && hasKey2(first, "data")) return null;
281387
281479
  return `${calleeText}({ data: ${first.getText()}${spreadOpts(args[1])} })`;
281388
281480
  }
281389
281481
  if (import_ts_morph.Node.isObjectLiteralExpression(first)) return null;
@@ -282212,7 +282304,7 @@ async function main() {
282212
282304
  if (command === "init") {
282213
282305
  await init(process.argv.slice(3));
282214
282306
  } else if (command === "login") {
282215
- await login();
282307
+ await login(process.argv.slice(3));
282216
282308
  } else if (command === "logout") {
282217
282309
  logout();
282218
282310
  } else if (command === "mode") {
@@ -282251,6 +282343,22 @@ async function main() {
282251
282343
  const rest = process.argv.slice(3);
282252
282344
  const advanced = rest.some((a) => ["--force", "--rename", "--backfill", "--url"].includes(a));
282253
282345
  const watching = rest.includes("--watch");
282346
+ const guard = guardActiveProjectKey();
282347
+ if (!guard.ok && guard.available.length > 0 && !rest.includes("--url")) {
282348
+ console.error(
282349
+ ` ${import_picocolors18.default.yellow("\u26A0")} active project ${import_picocolors18.default.bold(guard.activeProfile)} has no stored key ${import_picocolors18.default.dim(
282350
+ `(you have keys for: ${guard.available.join(", ")})`
282351
+ )}`
282352
+ );
282353
+ const loginCmd = guard.activeProfile === "default" ? "ablo login" : `ablo login --project ${guard.activeProfile}`;
282354
+ console.error(
282355
+ import_picocolors18.default.dim(
282356
+ ` Mint one with ${import_picocolors18.default.bold(loginCmd)}, or switch with ${import_picocolors18.default.bold("ablo projects use <slug>")}.`
282357
+ )
282358
+ );
282359
+ process.exitCode = 1;
282360
+ return;
282361
+ }
282254
282362
  const plan = resolvePushPlan();
282255
282363
  if (advanced || plan.flow === "production" && !watching) {
282256
282364
  await push(rest);
@@ -282275,11 +282383,12 @@ async function main() {
282275
282383
  console.log(` [--auth apikey] [--storage direct|endpoint] [--project <slug>] [--no-project]`);
282276
282384
  console.log(` [--no-agent] [--no-pull] [--no-install] [--no-login]`);
282277
282385
  console.log(` npx ablo login Authorize in your browser (provisions sandbox + production keys)`);
282386
+ console.log(` npx ablo login --project <slug> Same, scoped to a project (mints its keys, makes it active)`);
282278
282387
  console.log(` npx ablo logout Remove the stored API key`);
282279
282388
  console.log(` npx ablo mode [sandbox|production] Switch active environment, like Stripe`);
282280
282389
  console.log(` npx ablo projects list List the org's projects (default + your own)`);
282281
282390
  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`);
282391
+ console.log(` npx ablo projects use <slug|default> Switch the active project (run login --project to mint its keys)`);
282283
282392
  console.log(` npx ablo status Show org, mode, keys, and server health`);
282284
282393
  console.log(` npx ablo status --json Same, machine-readable (mode, key prefix, org id, api host)`);
282285
282394
  console.log(` npx ablo logs [-n N] [--since 15m] Tail commit activity (follows; --no-follow to exit)`);
@@ -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';
@@ -27,7 +27,7 @@ export { field, indexed, getFieldMeta } from './field.js';
27
27
  // Relation builders
28
28
  export { relation } from './relation.js';
29
29
  // Tenancy — the single source of truth for how a model's rows are tenant-scoped.
30
- export { tenancySchema, scopedViaRefSchema, resolveTenancy, tenancyColumn, DEFAULT_ORG_COLUMN, } from './tenancy.js';
30
+ export { tenancySchema, scopedViaRefSchema, policyInputSchema, resolvePolicy, resolveTenancy, tenancyColumn, DEFAULT_ORG_COLUMN, } from './tenancy.js';
31
31
  // Database plane — which DB a model's rows live in (`tenant` portable to a BYO
32
32
  // customer DB, `control` = Ablo's own). Sibling axis to `tenancy`.
33
33
  export { planeSchema, DEFAULT_PLANE } from './plane.js';
@@ -46,7 +46,7 @@ export { model, scopeKindOf, } from './model.js';
46
46
  // falls back to sensible defaults. See sugar.ts for the full pattern.
47
47
  export { mutable, readOnly } from './sugar.js';
48
48
  // Schema definition + type inference
49
- export { defineSchema, composeIdentitySyncGroups, composeEntitySyncGroups, identityRole, entityRole, extractIdentityIds, extractEntityIds, syncGroup, syncGroupSchema, syncGroupInputSchema, isSyncGroupInput, identityRoleSchema, entityRoleSchema, roleSchema, roleSourceSchema, scopeSchema, grantsRefSchema, } from './schema.js';
49
+ export { defineSchema, composeIdentitySyncGroups, composeEntitySyncGroups, identityRole, entityRole, extractIdentityIds, extractEntityIds, syncGroup, syncGroupSchema, syncGroupInputSchema, isSyncGroupInput, identityRoleSchema, entityRoleSchema, roleSchema, roleSourceSchema, scopeSchema, grantsRefSchema, groupsInputSchema, } from './schema.js';
50
50
  // Schema ⇄ JSON (control-plane transport for hosted multi-tenant)
51
51
  export { serializeSchema, parseSchema, toSchemaJSON, fromSchemaJSON, schemaHash, } from './serialize.js';
52
52
  // Schema projection — derive an app's subset from one canonical schema.
@@ -18,10 +18,10 @@
18
18
  */
19
19
  import { z } from 'zod';
20
20
  import type { RelationDef } from './relation.js';
21
- import type { EntityRole } from './roles.js';
21
+ import type { EntityRole, GroupsInput } from './roles.js';
22
22
  import { type FieldMeta } from './field.js';
23
- import { type Tenancy, type ScopedViaRef } from './tenancy.js';
24
- export type { ScopedViaRef, Tenancy } from './tenancy.js';
23
+ import { type Tenancy, type PolicyInput } from './tenancy.js';
24
+ export type { ScopedViaRef, Tenancy, PolicyInput } from './tenancy.js';
25
25
  import { type SchemaPlane } from './plane.js';
26
26
  /**
27
27
  * Controls when model data is loaded from the server.
@@ -95,49 +95,27 @@ export interface ModelOptions {
95
95
  */
96
96
  tableName?: string;
97
97
  /**
98
- * Whether this model's table has an `organization_id` column. Default: true.
99
- * When false, the bootstrap/read query omits the `WHERE organization_id = $1`
100
- * tenant filter for this model.
98
+ * **Axis 1 row-access policy (tenant isolation / RLS).** Decides who may
99
+ * *read* a row at all. Named after Postgres/Supabase, where a `policy` is the
100
+ * rule that scopes which rows a tenant sees. A discriminated union on `by` —
101
+ * one option replacing the old `orgScoped`/`scopedVia`/`orgColumn` trio:
101
102
  *
102
- * SECURITY — `orgScoped: false` makes the table GLOBALLY READABLE: every
103
- * client of every tenant sees every row. It is ONLY correct for genuinely
104
- * tenant-less tables (the `organizations` table itself, global lookups). If
105
- * rows belong to a tenant through a foreign key but this table has no
106
- * `organization_id` of its own, use {@link scopedVia} INSTEAD reaching for
107
- * `orgScoped: false` there silently exposes the entire table cross-tenant.
108
- */
109
- orgScoped?: boolean;
110
- /**
111
- * Scope rows via a parent table when THIS table has no
112
- * `organization_id` column of its own, but rows still belong to a
113
- * tenant via a foreign key (e.g. `memberRoles.member_id member.id`,
114
- * `teamMember.team_id → team.id`, `users.id ← member.user_id`).
115
- *
116
- * Emits, in place of the missing `organization_id = $1` clause:
117
- *
118
- * WHERE <table>.<localKey> IN
119
- * (SELECT <parentKey> FROM <parentTable> WHERE <parentOrgColumn> = $1)
120
- *
121
- * Use this INSTEAD of `orgScoped: false` for any `load: 'instant'`
122
- * model whose rows would otherwise leak cross-tenant on bootstrap —
123
- * dropping the filter entirely exposes the entire DB to every client.
103
+ * - `{ by: 'column' }` row-local tenancy column (the DEFAULT when omitted).
104
+ * `column` overrides the name (default `organization_id`).
105
+ * - `{ by: 'parent', fk, parent }` inherit tenancy through a foreign key
106
+ * when THIS table has no tenancy column of its own (e.g. `slide_layers`
107
+ * slide deck org). Emits, in place of `organization_id = $1`:
108
+ * `WHERE <table>.<fk> IN (SELECT <parentKey> FROM <parent> WHERE
109
+ * <parentTenantColumn> = $1)`. Use this for any `load: 'instant'` child
110
+ * table that would otherwise leak cross-tenant on bootstrap.
111
+ * - `{ by: 'none' }` — genuinely global / reference data (the `organizations`
112
+ * table itself, global lookups). Makes the whole table readable
113
+ * cross-tenant only correct for tenant-less tables. Because it's an
114
+ * explicit, named branch (not a falsy flag) it can't be reached by accident.
124
115
  *
125
- * Identifiers must match the regular `[a-zA-Z_][a-zA-Z0-9_]*` shape;
126
- * the SQL compiler validates them to keep this away from injection
127
- * paths.
116
+ * Normalized into the canonical {@link Tenancy} by `resolvePolicy` at build.
128
117
  */
129
- scopedVia?: ScopedViaRef;
130
- /**
131
- * Override the physical tenancy column name (default `organization_id`).
132
- * Authoring sugar — normalized into the canonical {@link tenancy} descriptor.
133
- */
134
- orgColumn?: string;
135
- /**
136
- * Canonical tenancy descriptor. You normally don't set this — `orgScoped`,
137
- * `scopedVia`, and `orgColumn` are normalized into it at build time. Set it
138
- * directly only to author the union form explicitly.
139
- */
140
- tenancy?: Tenancy;
118
+ policy?: PolicyInput;
141
119
  /**
142
120
  * Which database plane this model's rows live in. `tenant` (default) =
143
121
  * the tenant data plane, emitted into a customer's BYO/dedicated DB by
@@ -146,53 +124,29 @@ export interface ModelOptions {
146
124
  */
147
125
  plane?: SchemaPlane;
148
126
  /**
149
- * Marks this model as a **scope root** a model that forms a sync group of
150
- * its own. A scope-root record lives in the group `<kind>:<id>`, where `kind`
151
- * defaults to the lowercased `typename` (so a `Deck` → `deck:<id>`). Pass a
152
- * string to override the kind explicitly (`scope: 'matter'`).
153
- *
154
- * Replaces the old `syncGroupFormat` template string: there is no `{id}`
155
- * placeholder to author — the engine mints the branded {@link SyncGroup} from
156
- * `(kind, id)`. Child models inherit a root's group via their `belongsTo`
157
- * relations (a `document` with `dataroomId` fans into `dataroom:<id>`), so
158
- * only the root declares anything.
159
- */
160
- scope?: boolean | string;
161
- /**
162
- * Declares this model as a **membership edge** that grants an identity access
163
- * to a scope root — the relation-driven equivalent of "this user can see that
164
- * dataroom." Both values are *relation names* already declared on this model:
165
- * `subject` is the `belongsTo` to the identity (e.g. a `user`), `scope` is the
166
- * `belongsTo` to the scope-root entity (e.g. a `dataroom`).
127
+ * **Axis 2 sync-group routing.** Decides which delta *channels* a row fans
128
+ * into. Orthogonal to {@link policy} (read access). One namespaced object
129
+ * replacing the old flat `scope`/`grants`/`entityRoles`:
167
130
  *
168
- * The server's membership resolver reads this at connect time for identity
169
- * `U`, it queries `WHERE <subject FK> = U` and adds `<scopeKind>:<scope FK>`
170
- * to the identity's subscribed groups (Linear's `/sync/user_sync_groups`).
131
+ * - `root` mark this model a scope root; its records form the group
132
+ * `<kind>:<id>` (kind defaults from the lowercased typename, e.g. `Deck`
133
+ * `deck:<id>`; pass a string to override, `root: 'matter'`). Child models
134
+ * inherit a root's group via their `belongsTo` relations. Was `scope` —
135
+ * renamed so it no longer collides with the old `scopedVia` tenancy sugar.
136
+ * - `grants` — a membership edge granting an identity access to a scope root.
137
+ * Both values name `belongsTo` relations on this model (`subject` → identity,
138
+ * `scope` → scope root). Only needed for sub-org sharing.
139
+ * - `roles` — explicit non-relational record→group roles (the inbox-fan-out
140
+ * escape hatch, keyed on a plain field). Was `entityRoles`. One or many.
171
141
  *
172
142
  * ```ts
173
143
  * // dataroomMember: { userId, dataroomId }
174
- * grants: { subject: 'user', scope: 'dataroom' } // both are relation names
175
- * ```
176
- *
177
- * Not needed for org-level access — a `dataroom.organizationId` already routes
178
- * via the `org:<id>` group. `grants` is only for sub-org membership.
179
- */
180
- grants?: GrantsRef;
181
- /**
182
- * Explicit record→group roles for routing that isn't relational — a group
183
- * keyed on a plain field rather than the record's own id or a `belongsTo`
184
- * scope root. The escape hatch for cases like per-recipient inbox fan-out:
185
- *
186
- * ```ts
187
- * // a message routes to its addressee's inbox, keyed on `toId`
188
- * entityRoles: [entityRole({ kind: 'inbox', source: 'toId' })]
144
+ * groups: { grants: { subject: 'user', scope: 'dataroom' } }
145
+ * // a message → its addressee's inbox, keyed on `toId`
146
+ * groups: { roles: [entityRole({ kind: 'inbox', source: 'toId' })] }
189
147
  * ```
190
- *
191
- * Prefer {@link scope} (self group) + `belongsTo` relations (parent groups)
192
- * when the routing follows the relation graph — reach for `entityRoles` only
193
- * when it genuinely doesn't.
194
148
  */
195
- entityRoles?: EntityRole | readonly EntityRole[];
149
+ groups?: GroupsInput;
196
150
  /**
197
151
  * Whether clients may issue CREATE/UPDATE/DELETE mutations for this
198
152
  * model via the `commit` wire protocol. Default: **true** — declaring a