@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 +33 -0
- package/dist/BaseSyncedStore.d.ts +5 -4
- package/dist/BaseSyncedStore.js +38 -14
- package/dist/NetworkMonitor.js +3 -1
- package/dist/cli.cjs +206 -45
- package/dist/client/Ablo.d.ts +33 -1
- package/dist/client/Ablo.js +25 -8
- package/dist/client/auth.js +1 -1
- package/dist/core/StoreManager.js +3 -1
- package/dist/errors.d.ts +9 -0
- package/dist/errors.js +14 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +5 -0
- package/dist/policy/types.d.ts +24 -9
- package/dist/policy/types.js +32 -11
- package/dist/surface.d.ts +1 -1
- package/dist/surface.js +2 -0
- package/dist/sync/SyncWebSocket.js +1 -1
- package/dist/sync/awaitClaimGrant.js +9 -2
- package/dist/sync/createClaimStream.js +32 -0
- package/dist/wire/errorEnvelope.d.ts +13 -0
- package/package.json +1 -1
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
|
|
509
|
-
*
|
|
510
|
-
*
|
|
511
|
-
*
|
|
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);
|
package/dist/BaseSyncedStore.js
CHANGED
|
@@ -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
|
-
|
|
743
|
-
|
|
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
|
|
773
|
-
*
|
|
774
|
-
*
|
|
775
|
-
*
|
|
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
|
-
|
|
811
|
-
//
|
|
812
|
-
//
|
|
813
|
-
//
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
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
|
package/dist/NetworkMonitor.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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")} ${
|
|
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
|
-
|
|
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
|
-
` ${
|
|
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
|
-
|
|
282574
|
-
` Mint one with ${
|
|
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
|
-
` ${
|
|
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(` ${
|
|
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(` ${
|
|
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 ${
|
|
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
|
-
` ${
|
|
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")} ${
|
|
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 =
|
|
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 =
|
|
282969
|
+
schemaNote = import_picocolors19.default.dim(` (pulled ${pulled.models.length} models)`);
|
|
282825
282970
|
} else {
|
|
282826
|
-
schemaNote =
|
|
282971
|
+
schemaNote = import_picocolors19.default.dim(" (no adoptable tables \u2014 wrote starter)");
|
|
282827
282972
|
}
|
|
282828
282973
|
} catch {
|
|
282829
|
-
schemaNote =
|
|
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} ${
|
|
283000
|
+
created.push(`${envFile} ${import_picocolors19.default.dim("(appended)")}`);
|
|
282856
283001
|
} else {
|
|
282857
|
-
created.push(`${envFile} ${
|
|
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 ${
|
|
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} ${
|
|
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")} ${
|
|
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) => `${
|
|
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(`${
|
|
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 ${
|
|
282901
|
-
`Run ${
|
|
282902
|
-
`Set ${
|
|
282903
|
-
`Run ${
|
|
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: ${
|
|
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: ${
|
|
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 ${
|
|
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 ${
|
|
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 ${
|
|
282915
|
-
`Run ${
|
|
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 ${
|
|
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(`${
|
|
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 ${
|
|
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
|
-
|
|
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 {
|
|
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,
|
|
283303
|
-
//
|
|
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();
|
|
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
|
-
//
|
|
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
|
-
|
|
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(
|
|
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:
|
package/dist/client/Ablo.d.ts
CHANGED
|
@@ -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
|
/**
|
package/dist/client/Ablo.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
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('[
|
|
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
|
-
|
|
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));
|
package/dist/client/auth.js
CHANGED
|
@@ -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('[
|
|
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
|
-
|
|
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
|
package/dist/policy/types.d.ts
CHANGED
|
@@ -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`
|
|
128
|
-
*
|
|
129
|
-
*
|
|
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
|
|
135
|
-
* detection.
|
|
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`:
|
|
176
|
-
*
|
|
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);
|
package/dist/policy/types.js
CHANGED
|
@@ -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`
|
|
13
|
-
*
|
|
14
|
-
*
|
|
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
|
|
20
|
-
* detection.
|
|
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
|
|
30
|
-
|
|
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`:
|
|
57
|
-
*
|
|
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
|
@@ -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.
|
|
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
|