@abloatai/ablo 0.9.14 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,25 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.10.0
4
+
5
+ ### Minor Changes
6
+
7
+ - Rename environment enum values to `production` and `sandbox` while preserving the existing `*_live_`/`*_test_` key prefix format.
8
+
9
+ ### Patch Changes
10
+
11
+ - Stateless HTTP transport for server-side actors, and a canonical environment vocabulary.
12
+ - **`Ablo({ transport: 'http' })`** returns a stateless `AbloHttpClient` for agents, workers, and serverless — the same `ablo.<model>` surface and coordination plane with no websocket: each call is one HTTP round-trip and identity rides the Bearer credential. The return type narrows so stateful-only APIs (`get`/`getAll`/`onChange`) are compile errors instead of latent runtime gaps.
13
+ - **Canonical `production` / `sandbox` environments** (new `environment.ts`, exported from the root): `sk_test_` / `sk_live_` remain the wire-level key prefixes but now map to `production` / `sandbox` everywhere — key parsing, source `mode`, and the CLI (which drops the legacy test/live config migration).
14
+ - **Source-mode commit scoping**: `commit` now forwards `projectId`, `accountScope`, and `environment` to customer storage resolvers, so per-project and sandbox/production traffic can be routed to distinct stores.
15
+ - **Fixes**: the WebSocket bearer credential is sent in the `ablo.bearer.<token>` subprotocol (never in the URL or proxy logs); `Model` no longer fabricates an `updatedAt` of "now" for records that arrive with only `createdAt`.
16
+
17
+ ## 0.9.15
18
+
19
+ ### Patch Changes
20
+
21
+ - Package metadata: set the npm description to "The Collaboration Layer For AI Agents" (matching the GitHub repo About) so it stops reverting to the old "State control API…" text on publish.
22
+
3
23
  ## 0.9.14
4
24
 
5
25
  ### Patch Changes
@@ -125,8 +125,9 @@ export interface UserContext {
125
125
  * `kind=agent` and the server applies capability-token auth. */
126
126
  kind?: 'user' | 'agent' | 'system';
127
127
  /** Restricted (`rk_`) API key for `kind: 'agent'` — the agent's
128
- * bearer credential. Sent as `?authorization=Bearer <token>` on the
129
- * WS upgrade. (Field name predates the Biscuit→opaque-key migration.) */
128
+ * bearer credential. Sent in the `ablo.bearer.<token>` WebSocket
129
+ * subprotocol, never in the URL. (Field name predates the
130
+ * Biscuit→opaque-key migration.) */
130
131
  capabilityToken?: string;
131
132
  /** Server-authoritative sync groups, supplied by auth/capability
132
133
  * exchange. The SDK does not invent org/user/default groups; app
package/dist/Model.js CHANGED
@@ -78,11 +78,19 @@ export class Model {
78
78
  ? data.createdAt
79
79
  : new Date(data.createdAt)
80
80
  : new Date();
81
+ // A record that arrives WITH `createdAt` but WITHOUT `updatedAt` is
82
+ // server/IDB data whose update timestamp didn't survive the wire —
83
+ // falling back to "now" here fabricated an edit time for every such
84
+ // record on every bootstrap (the decks gallery sorted everything to
85
+ // "edited just now"). Fall back to createdAt instead; only a genuinely
86
+ // new local model (no dates at all) stamps the current time.
81
87
  this.updatedAt = data.updatedAt
82
88
  ? data.updatedAt instanceof Date
83
89
  ? data.updatedAt
84
90
  : new Date(data.updatedAt)
85
- : new Date();
91
+ : data.createdAt
92
+ ? new Date(this.createdAt)
93
+ : new Date();
86
94
  this.syncStatus = data.syncStatus || 'pending';
87
95
  }
88
96
  /**
package/dist/cli.cjs CHANGED
@@ -279453,13 +279453,12 @@ function asActiveProject(value) {
279453
279453
  return void 0;
279454
279454
  }
279455
279455
  function normalizeStoredMode(value) {
279456
- if (value === "sandbox" || value === "test") return "sandbox";
279457
- if (value === "production" || value === "live") return "production";
279456
+ if (value === "sandbox" || value === "production") return value;
279458
279457
  return void 0;
279459
279458
  }
279460
279459
  function extractEntries(obj) {
279461
- const sandbox = asKeyEntry(obj.sandbox) ?? asKeyEntry(obj.test);
279462
- const production = asKeyEntry(obj.production) ?? asKeyEntry(obj.live);
279460
+ const sandbox = asKeyEntry(obj.sandbox);
279461
+ const production = asKeyEntry(obj.production);
279463
279462
  if (sandbox || production) {
279464
279463
  return { ...sandbox ? { sandbox } : {}, ...production ? { production } : {} };
279465
279464
  }
@@ -29,6 +29,7 @@ import type { IntentStream, IntentWaitOptions, PresenceStream, Snapshot } from '
29
29
  import type { ParticipantManager } from '../sync/participants.js';
30
30
  import type { ActiveIntent, Duration, Intent, TargetRange } from '../types/streams.js';
31
31
  import { type AbloApi, type AbloApiClientOptions, type AbloApiIntents } from './ApiClient.js';
32
+ import { type AbloHttpClient, type AbloHttpClientOptions } from './httpClient.js';
32
33
  /**
33
34
  * Async function that resolves an apiKey at request time. Use for
34
35
  * credential rotation — rotate from a vault, refresh from session
@@ -125,6 +126,19 @@ export interface AbloOptions<S extends SchemaRecord = SchemaRecord> {
125
126
  * @default 'memory'
126
127
  */
127
128
  persistence?: AbloPersistence;
129
+ /**
130
+ * Transport selector. `'websocket'` (default) is the live client —
131
+ * persistent socket, local synced pool, `onChange` subscriptions. `'http'`
132
+ * returns the STATELESS client for server-side actors (agents, workers,
133
+ * serverless): same `ablo.<model>` surface and coordination plane, but each
134
+ * call is one HTTP round-trip, identity rides the Bearer credential, and no
135
+ * socket is ever opened. With `'http'` the return type narrows to
136
+ * `AbloHttpClient<S>`, so stateful-only capabilities (`get`/`getAll`,
137
+ * `onChange`) are compile errors rather than latent runtime gaps.
138
+ *
139
+ * @default 'websocket'
140
+ */
141
+ transport?: 'websocket' | 'http' | undefined;
128
142
  /**
129
143
  * Bearer auth token. Hosted-cloud consumers pass `apiKey`; self-hosted
130
144
  * deployments may pass a bearer token minted by their own auth layer.
@@ -944,7 +958,18 @@ export declare function computeFKDepthPriority(schema: Schema): ReadonlyMap<stri
944
958
  * const reports = sync.weatherReports.list({ where: { status: 'pending' } });
945
959
  * await sync.weatherReports.create({ location: 'Stockholm', status: 'pending' });
946
960
  * ```
961
+ *
962
+ * Pass `transport: 'http'` for the stateless server-side client (agents,
963
+ * workers, serverless) — same `ablo.<model>` surface, no socket:
964
+ *
965
+ * ```ts
966
+ * const ablo = Ablo({ schema, apiKey: process.env.ABLO_API_KEY, transport: 'http' });
967
+ * await ablo.tasks.update({ id, data: { status: 'done' } });
968
+ * ```
947
969
  */
970
+ export declare function Ablo<const S extends SchemaRecord>(options: AbloHttpClientOptions<S> & {
971
+ transport: 'http';
972
+ }): AbloHttpClient<S>;
948
973
  export declare function Ablo<const S extends SchemaRecord>(options: AbloOptions<S>): Ablo<S>;
949
974
  export declare function Ablo(options: AbloApiClientOptions): AbloApi;
950
975
  import type * as _Streams from '../types/streams.js';
@@ -37,6 +37,9 @@ import { awaitIntentGrant } from '../sync/awaitIntentGrant.js';
37
37
  import { createSnapshot } from '../sync/createSnapshot.js';
38
38
  import { createParticipantManager } from '../sync/participants.js';
39
39
  import { createProtocolClient, } from './ApiClient.js';
40
+ // Value import is cycle-safe: httpClient.js only value-imports ApiClient.js,
41
+ // which imports this module type-only.
42
+ import { createAbloHttpClient, } from './httpClient.js';
40
43
  import { assertBrowserSafety, readProcessEnv, resolveApiKey, resolveApiKeyValue, resolveAuthToken, resolveBaseURL, resolveBootstrapBaseUrl, resolveDatabaseUrl, } from './auth.js';
41
44
  import { registerDataSource } from './registerDataSource.js';
42
45
  import { shouldUseInMemoryPersistence, } from './persistence.js';
@@ -684,8 +687,13 @@ function resolveCredentialResolver(options) {
684
687
  }
685
688
  export function Ablo(options) {
686
689
  if (options.schema == null) {
690
+ // The protocol client IS the stateless HTTP plane (string-keyed models),
691
+ // so `transport: 'http'` needs no special-casing here.
687
692
  return createProtocolClient(options);
688
693
  }
694
+ if (options.transport === 'http') {
695
+ return createAbloHttpClient(options);
696
+ }
689
697
  const internalOptions = options;
690
698
  const env = readProcessEnv();
691
699
  const authInput = { options, env };
@@ -0,0 +1,12 @@
1
+ import { z } from 'zod';
2
+ export declare const ENVIRONMENTS: readonly ["production", "sandbox"];
3
+ export type KeyPrefixEnvironment = 'live' | 'test';
4
+ export declare const environmentSchema: z.ZodEnum<{
5
+ production: "production";
6
+ sandbox: "sandbox";
7
+ }>;
8
+ export type Environment = z.infer<typeof environmentSchema>;
9
+ export declare function normalizeEnvironment(value: unknown, fallback?: Environment): Environment;
10
+ export declare function environmentFromKeyPrefix(value: KeyPrefixEnvironment): Environment;
11
+ export declare function environmentToKeyPrefix(value: Environment): KeyPrefixEnvironment;
12
+ export declare function isSandboxEnvironment(value: Environment): boolean;
@@ -0,0 +1,16 @@
1
+ import { z } from 'zod';
2
+ export const ENVIRONMENTS = ['production', 'sandbox'];
3
+ export const environmentSchema = z.enum(ENVIRONMENTS);
4
+ export function normalizeEnvironment(value, fallback = 'production') {
5
+ const parsed = environmentSchema.safeParse(value);
6
+ return parsed.success ? parsed.data : fallback;
7
+ }
8
+ export function environmentFromKeyPrefix(value) {
9
+ return value === 'test' ? 'sandbox' : 'production';
10
+ }
11
+ export function environmentToKeyPrefix(value) {
12
+ return value === 'sandbox' ? 'test' : 'live';
13
+ }
14
+ export function isSandboxEnvironment(value) {
15
+ return value === 'sandbox';
16
+ }
package/dist/index.d.ts CHANGED
@@ -64,6 +64,8 @@ export { SyncSessionError, AbloError, AbloAuthenticationError, AbloPermissionErr
64
64
  export type { CommitReceipt, RequiredCapability } from './errors.js';
65
65
  export type { ErrorCode, WireErrorCode, ErrorCategory, ErrorCodeSpec, RecoveryClass } from './errors.js';
66
66
  export { WS_BEARER_SUBPROTOCOL_PREFIX, WS_SYNC_SUBPROTOCOL } from './auth/credentialSource.js';
67
+ export { ENVIRONMENTS, environmentSchema, normalizeEnvironment, environmentFromKeyPrefix, environmentToKeyPrefix, isSandboxEnvironment, } from './environment.js';
68
+ export type { Environment, KeyPrefixEnvironment } from './environment.js';
67
69
  export { writeOptionsSchema, onStaleModeSchema, assertWriteOptions, } from './client/writeOptionsSchema.js';
68
70
  export type { WriteOptionsInput } from './client/writeOptionsSchema.js';
69
71
  export type { WriteOptions, MutationOptions } from './interfaces/index.js';
package/dist/index.js CHANGED
@@ -89,6 +89,7 @@ export { defaultPolicy, capabilityPreemptPolicy } from './policy/index.js';
89
89
  // `e.type === 'AbloX'`) plus the HTTP-response translator.
90
90
  export { SyncSessionError, AbloError, AbloAuthenticationError, AbloPermissionError, AbloRateLimitError, AbloIdempotencyError, AbloConnectionError, AbloValidationError, AbloServerError, AbloStaleContextError, AbloClaimedError, CapabilityError, translateHttpError, hasWireCode, errorFromWire, toAbloError, ERROR_CODES, ERROR_CONTRACT_VERSION, errorCodeSpec, isRetryableCode, classifyRecovery, recoveryClassSchema, RECOVERY_CLASSES, } from './errors.js';
91
91
  export { WS_BEARER_SUBPROTOCOL_PREFIX, WS_SYNC_SUBPROTOCOL } from './auth/credentialSource.js';
92
+ export { ENVIRONMENTS, environmentSchema, normalizeEnvironment, environmentFromKeyPrefix, environmentToKeyPrefix, isSandboxEnvironment, } from './environment.js';
92
93
  // THE write-options contract — the one Zod schema for the option bag every
93
94
  // write door accepts (`ablo.<model>.create/update/delete`, `commits.create`,
94
95
  // the HTTP model routes). The SDK validates against it at each boundary;
@@ -10,16 +10,19 @@
10
10
  * client bundle never pulls in `node:crypto`.
11
11
  *
12
12
  * Format (GitHub-style): `<sk|rk|ek>_<live|test>_<30 base62 body><6-char
13
- * base62 CRC32 checksum>`. The identifiable prefix + CRC32 checksum let
13
+ * base62 CRC32 checksum>`. The environment segment is the stable key-prefix
14
+ * contract; parsed values are immediately mapped to `production` / `sandbox`.
15
+ * The identifiable prefix + CRC32 checksum let
14
16
  * secret scanners detect leaks and let us reject typo'd/forged keys OFFLINE
15
17
  * (no DB round-trip). Legacy keys (a ~43-char base64url body, no checksum)
16
18
  * still validate by hash — they parse here as `checksummed: false`.
17
19
  */
18
20
  import { z } from 'zod';
21
+ import { type Environment } from '../environment.js';
19
22
  export declare const API_KEY_KINDS: readonly ["secret", "restricted", "ephemeral", "publishable"];
20
23
  export type ApiKeyKind = (typeof API_KEY_KINDS)[number];
21
- export declare const API_KEY_ENVS: readonly ["live", "test"];
22
- export type ApiKeyEnv = (typeof API_KEY_ENVS)[number];
24
+ export declare const API_KEY_ENVS: readonly ["production", "sandbox"];
25
+ export type ApiKeyEnv = Environment;
23
26
  /** A structurally-valid Ablo API key, parsed into its parts. */
24
27
  export interface ParsedApiKey {
25
28
  /** The original plaintext. */
@@ -10,13 +10,16 @@
10
10
  * client bundle never pulls in `node:crypto`.
11
11
  *
12
12
  * Format (GitHub-style): `<sk|rk|ek>_<live|test>_<30 base62 body><6-char
13
- * base62 CRC32 checksum>`. The identifiable prefix + CRC32 checksum let
13
+ * base62 CRC32 checksum>`. The environment segment is the stable key-prefix
14
+ * contract; parsed values are immediately mapped to `production` / `sandbox`.
15
+ * The identifiable prefix + CRC32 checksum let
14
16
  * secret scanners detect leaks and let us reject typo'd/forged keys OFFLINE
15
17
  * (no DB round-trip). Legacy keys (a ~43-char base64url body, no checksum)
16
18
  * still validate by hash — they parse here as `checksummed: false`.
17
19
  */
18
20
  import { createHash, randomBytes } from 'node:crypto';
19
21
  import { z } from 'zod';
22
+ import { ENVIRONMENTS, environmentFromKeyPrefix, environmentToKeyPrefix, } from '../environment.js';
20
23
  // ── Vocabulary ──────────────────────────────────────────────────────────
21
24
  // The Stripe-style key model:
22
25
  // secret (sk_) — backend / server-to-server / agents. Full authority. Never in a browser.
@@ -29,7 +32,7 @@ import { z } from 'zod';
29
32
  // it; it grants read access to the org's data plane and cannot write
30
33
  // or reach any control-plane operation.
31
34
  export const API_KEY_KINDS = ['secret', 'restricted', 'ephemeral', 'publishable'];
32
- export const API_KEY_ENVS = ['live', 'test'];
35
+ export const API_KEY_ENVS = ENVIRONMENTS;
33
36
  const PREFIX_BY_KIND = {
34
37
  secret: 'sk',
35
38
  restricted: 'rk',
@@ -115,7 +118,13 @@ export const apiKeySchema = z.string().transform((raw, ctx) => {
115
118
  ctx.addIssue({ code: 'custom', message: 'API key checksum mismatch' });
116
119
  return z.NEVER;
117
120
  }
118
- return { raw, kind: KIND_BY_PREFIX[prefix], env: env, body, checksummed };
121
+ return {
122
+ raw,
123
+ kind: KIND_BY_PREFIX[prefix],
124
+ env: environmentFromKeyPrefix(env),
125
+ body,
126
+ checksummed,
127
+ };
119
128
  });
120
129
  // ── Derived validators (thin wrappers over the same spec) ───────────────
121
130
  /** Parse + fully validate (incl. checksum). Returns null when invalid. */
@@ -140,9 +149,9 @@ export function keyChecksumMatches(raw) {
140
149
  * Mint a key: `<prefix>_<env>_<body><checksum>`. Returns the plaintext (shown
141
150
  * once), its SHA-256 hash (persisted), and the 12-char display prefix.
142
151
  */
143
- export function generateApiKey(env = 'live', kind = 'secret') {
152
+ export function generateApiKey(env = 'production', kind = 'secret') {
144
153
  const body = randomBase62(KEY_BODY_LEN);
145
- const payload = `${PREFIX_BY_KIND[kind]}_${env}_${body}`;
154
+ const payload = `${PREFIX_BY_KIND[kind]}_${environmentToKeyPrefix(env)}_${body}`;
146
155
  const plaintext = `${payload}${checksum6(payload)}`;
147
156
  return { plaintext, hash: hashApiKey(plaintext), prefix: plaintext.slice(0, 12) };
148
157
  }
@@ -124,7 +124,7 @@ export type WarningCode = 'drop_model' | 'drop_field' | 'risky_cast' | 'lossy_re
124
124
  /** A model disappears from what this plane's READERS resolve, without any
125
125
  * table being dropped. Emitted by the server's push gate (not
126
126
  * `classifyMigration`) when a first sandbox push shadows the production
127
- * artifact that sandbox readers were served via the registry's testlive
127
+ * artifact that sandbox readers were served via the registry's sandboxproduction
128
128
  * fallback. The data plane is untouched — the loss is visibility. */
129
129
  | 'remove_model';
130
130
  export type BlockerCode = 'required_field_added' | 'made_required';
@@ -15,6 +15,7 @@
15
15
  */
16
16
  import type { ParticipantKind, ConfirmationState } from '../schema/sync-delta-row.js';
17
17
  import type { ParticipantRef } from '../schema/sync-delta-wire.js';
18
+ import type { Environment } from '../environment.js';
18
19
  export interface CommitContext {
19
20
  participantId: string;
20
21
  /**
@@ -24,6 +25,19 @@ export interface CommitContext {
24
25
  */
25
26
  participantKind: ParticipantKind;
26
27
  organizationId: string;
28
+ /**
29
+ * Product/project scope for routing source-mode storage. Omitted means the
30
+ * org-default project (the legacy behavior).
31
+ */
32
+ projectId?: string;
33
+ /** Optional external account scope forwarded to storage resolvers. */
34
+ accountScope?: string;
35
+ /**
36
+ * Canonical environment for this commit. Source-mode adapters forward this to
37
+ * customer handlers so sandbox and production traffic can hit distinct
38
+ * customer-owned stores.
39
+ */
40
+ environment?: Environment;
27
41
  /**
28
42
  * The participant's own subscribed sync groups (from the WS upgrade or
29
43
  * capability token). Appended to every delta's `sync_groups` so writes fan
@@ -1,4 +1,5 @@
1
1
  import type { Schema, SchemaRecord, InferCreate } from '../schema/schema.js';
2
+ import type { Environment } from '../environment.js';
2
3
  import type { DataSourceAdapter } from './adapter.js';
3
4
  export type SourcePrimitive = string | number | boolean | null;
4
5
  export type SourceWhere = readonly [field: string, value: SourcePrimitive] | readonly [
@@ -47,21 +48,21 @@ export interface SourceRequestContext {
47
48
  readonly organizationId?: string;
48
49
  readonly requiredSyncGroups?: readonly string[];
49
50
  /**
50
- * Test/live mode for this request. Customers branch their source
51
- * handlers on this (`if (mode === 'test') db = testDb`) so test
51
+ * Production/sandbox mode for this request. Customers branch their source
52
+ * handlers on this (`if (mode === 'sandbox') db = sandboxDb`) so sandbox
52
53
  * traffic exercises the same code path against an isolated store.
53
54
  *
54
- * Mirrors Stripe's `sk_test_` / `sk_live_` distinction: same wire
55
+ * Mirrors Stripe's `sk_test_` / `sk_live_` prefixes: same wire
55
56
  * shape, same handler code, different namespace. Ablo's server-side
56
57
  * fan-out does not yet partition deltas by mode — that lands when
57
58
  * `sync_deltas.mode` ships. Until then, isolation is enforced
58
59
  * customer-side via this field, which is the right boundary anyway
59
60
  * (the customer's database is where the canonical data lives).
60
61
  *
61
- * Defaults to `'live'` when omitted so callers that don't opt in
62
+ * Defaults to `'production'` when omitted so callers that don't opt in
62
63
  * keep the existing behavior.
63
64
  */
64
- readonly mode?: 'test' | 'live';
65
+ readonly mode?: Environment;
65
66
  }
66
67
  export interface SourceOperation {
67
68
  readonly type: 'CREATE' | 'UPDATE' | 'DELETE' | 'ARCHIVE' | 'UNARCHIVE';
@@ -92,11 +92,10 @@ export interface SyncWebSocketOptions {
92
92
  kind?: 'user' | 'agent' | 'system';
93
93
  /**
94
94
  * The agent's bearer credential — a restricted (`rk_`) API key. When
95
- * set, sent as `?authorization=Bearer+<token>` on the WS upgrade —
96
- * query-param form so it works in both Node (no header support) and
97
- * browsers. The server's auth path accepts either form. Required for
98
- * `kind: 'agent'`; ignored for `kind: 'user'`. (Field name predates
99
- * the Biscuit→opaque-key migration.)
95
+ * set, sent in the `ablo.bearer.<token>` WebSocket subprotocol so the
96
+ * credential stays out of URLs and proxy logs. Required for `kind: 'agent'`;
97
+ * ignored for `kind: 'user'`. (Field name predates the Biscuit→opaque-key
98
+ * migration.)
100
99
  */
101
100
  capabilityToken?: string;
102
101
  /**
@@ -949,6 +949,12 @@ export class TransactionQueue extends EventEmitter {
949
949
  // Ensure derived fields exist (covers restored/persisted transactions)
950
950
  this.ensureDerivedFields(a);
951
951
  this.ensureDerivedFields(b);
952
+ if (a.modelName === b.modelName && a.modelId === b.modelId && a.type !== b.type) {
953
+ if (a.type === 'create')
954
+ return -1;
955
+ if (b.type === 'create')
956
+ return 1;
957
+ }
952
958
  return a.priorityScore - b.priorityScore;
953
959
  });
954
960
  // Get batch (now guaranteed to have parent entities before children)
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@abloatai/ablo",
3
- "version": "0.9.14",
4
- "description": "State control API for AI agents and collaborative apps.",
3
+ "version": "0.10.0",
4
+ "description": "The Collaboration Layer For AI Agents",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
7
7
  "engines": {
@@ -83,6 +83,11 @@
83
83
  "import": "./dist/keys/index.js",
84
84
  "default": "./dist/keys/index.js"
85
85
  },
86
+ "./environment": {
87
+ "types": "./dist/environment.d.ts",
88
+ "import": "./dist/environment.js",
89
+ "default": "./dist/environment.js"
90
+ },
86
91
  "./wire": {
87
92
  "types": "./dist/wire/index.d.ts",
88
93
  "import": "./dist/wire/index.js",