@abloatai/ablo 0.17.0 → 0.19.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 (64) hide show
  1. package/CHANGELOG.md +70 -0
  2. package/dist/BaseSyncedStore.d.ts +5 -4
  3. package/dist/BaseSyncedStore.js +38 -14
  4. package/dist/NetworkMonitor.js +3 -1
  5. package/dist/ObjectPool.d.ts +14 -1
  6. package/dist/ObjectPool.js +8 -1
  7. package/dist/SyncClient.js +7 -1
  8. package/dist/SyncEngineContext.js +2 -0
  9. package/dist/ai-sdk/coordination-context.d.ts +2 -1
  10. package/dist/ai-sdk/coordination-context.js +3 -3
  11. package/dist/ai-sdk/index.d.ts +3 -4
  12. package/dist/ai-sdk/index.js +2 -3
  13. package/dist/ai-sdk/wrap.d.ts +13 -18
  14. package/dist/ai-sdk/wrap.js +2 -6
  15. package/dist/cli.cjs +459 -205
  16. package/dist/client/Ablo.d.ts +116 -23
  17. package/dist/client/Ablo.js +128 -38
  18. package/dist/client/ApiClient.d.ts +2 -2
  19. package/dist/client/ApiClient.js +27 -32
  20. package/dist/client/auth.d.ts +26 -0
  21. package/dist/client/auth.js +209 -1
  22. package/dist/client/createModelProxy.d.ts +16 -11
  23. package/dist/client/createModelProxy.js +15 -15
  24. package/dist/client/sessionMint.d.ts +10 -0
  25. package/dist/client/sessionMint.js +8 -1
  26. package/dist/coordination/schema.d.ts +10 -10
  27. package/dist/coordination/schema.js +7 -8
  28. package/dist/coordination/trace.d.ts +91 -0
  29. package/dist/coordination/trace.js +147 -0
  30. package/dist/core/StoreManager.js +3 -1
  31. package/dist/errorCodes.d.ts +2 -0
  32. package/dist/errorCodes.js +2 -0
  33. package/dist/errors.d.ts +10 -2
  34. package/dist/errors.js +20 -9
  35. package/dist/index.d.ts +7 -1
  36. package/dist/index.js +12 -0
  37. package/dist/interfaces/index.d.ts +45 -2
  38. package/dist/policy/types.d.ts +33 -9
  39. package/dist/policy/types.js +42 -11
  40. package/dist/react/AbloProvider.d.ts +2 -2
  41. package/dist/schema/ddl.d.ts +36 -0
  42. package/dist/schema/ddl.js +66 -0
  43. package/dist/schema/index.d.ts +1 -1
  44. package/dist/schema/index.js +1 -1
  45. package/dist/surface.d.ts +1 -1
  46. package/dist/surface.js +2 -0
  47. package/dist/sync/HydrationCoordinator.js +7 -3
  48. package/dist/sync/SyncWebSocket.d.ts +11 -2
  49. package/dist/sync/SyncWebSocket.js +68 -4
  50. package/dist/sync/awaitClaimGrant.js +9 -2
  51. package/dist/sync/createClaimStream.d.ts +9 -2
  52. package/dist/sync/createClaimStream.js +41 -7
  53. package/dist/sync/participants.d.ts +12 -11
  54. package/dist/sync/participants.js +5 -5
  55. package/dist/transactions/TransactionQueue.d.ts +1 -0
  56. package/dist/transactions/TransactionQueue.js +40 -3
  57. package/dist/types/streams.d.ts +57 -135
  58. package/dist/wire/errorEnvelope.d.ts +13 -0
  59. package/docs/coordination.md +16 -0
  60. package/docs/debugging.md +194 -0
  61. package/docs/index.md +1 -0
  62. package/package.json +1 -1
  63. package/dist/ai-sdk/claim-broadcast.d.ts +0 -83
  64. package/dist/ai-sdk/claim-broadcast.js +0 -79
@@ -5,7 +5,7 @@
5
5
  * Consumers implement them to wire in their own logging, observability,
6
6
  * GraphQL client, session handling, and analytics.
7
7
  */
8
- import type { StaleNotification, ReadDependency } from '../coordination/schema.js';
8
+ import type { StaleNotification, ReadDependency, ParticipantKind } from '../coordination/schema.js';
9
9
  export interface SyncLogger {
10
10
  debug(message: string, ...args: unknown[]): void;
11
11
  info(message: string, ...args: unknown[]): void;
@@ -15,7 +15,7 @@ export interface SyncLogger {
15
15
  /** Breadcrumb severity levels */
16
16
  export type BreadcrumbLevel = 'debug' | 'info' | 'warning' | 'error';
17
17
  /** Breadcrumb categories for sync engine lifecycle events */
18
- export type SyncBreadcrumbCategory = 'sync.bootstrap' | 'sync.transaction' | 'sync.websocket' | 'sync.offline' | 'sync.database' | 'sync.conflict' | 'sync.groups';
18
+ export type SyncBreadcrumbCategory = 'sync.bootstrap' | 'sync.transaction' | 'sync.websocket' | 'sync.offline' | 'sync.database' | 'sync.conflict' | 'sync.coordination' | 'sync.groups';
19
19
  export interface RollbackDetails {
20
20
  transactionType: string;
21
21
  modelName: string;
@@ -71,6 +71,45 @@ export interface CommitZeroSyncIdDetails {
71
71
  export interface OfflineFlushFailureDetails {
72
72
  error: string;
73
73
  }
74
+ /**
75
+ * One thing that happened to a claim. `phase` is the past-tense state it just
76
+ * entered — the trail you follow to see WHY two participants collided on a row:
77
+ * who asked, who waited behind whom, who was turned away, whose lease lapsed.
78
+ * Each phase mirrors a `claim_*` wire frame.
79
+ */
80
+ export interface ClaimEvent {
81
+ phase: 'acquired' | 'queued' | 'granted' | 'lost' | 'rejected' | 'expired';
82
+ /** Server claim id, when the frame carries one. */
83
+ claimId?: string;
84
+ /** The claimed row + optional field scope. */
85
+ model?: string;
86
+ id?: string;
87
+ field?: string;
88
+ /** Participant that owns or blocks the lease (on `rejected`, the holder). */
89
+ actor?: string;
90
+ participantKind?: ParticipantKind;
91
+ /** FIFO position when `queued`. */
92
+ position?: number;
93
+ /** Rejection or policy reason, when the server supplied one. */
94
+ reason?: string;
95
+ }
96
+ /**
97
+ * A committed `onStale: 'notify'` write whose premise moved — the in-flight twin
98
+ * of a claim collision. The commit SUCCEEDED, but the guarded ops weren't written
99
+ * because the row changed since the caller's `readAt`; the engine handed back the
100
+ * live value so the actor can self-heal. Records WHICH rows and fields collided.
101
+ */
102
+ export interface ConflictEvent {
103
+ /** The client idempotency key whose write was notified. */
104
+ clientTxId: string;
105
+ /** The conflicted rows + the fields that collided. */
106
+ rows: ReadonlyArray<{
107
+ model: string;
108
+ id: string;
109
+ fields: readonly string[];
110
+ writtenBy?: ParticipantKind;
111
+ }>;
112
+ }
74
113
  /** Span attributes for performance monitoring */
75
114
  export interface SpanAttributes {
76
115
  [key: string]: string | number | boolean | undefined;
@@ -102,6 +141,10 @@ export interface SyncObservabilityProvider {
102
141
  captureOfflineFlushFailure(details: OfflineFlushFailureDetails): void;
103
142
  /** Capture self-healing event */
104
143
  captureSelfHealing(details: SelfHealingDetails): void;
144
+ /** Capture a claim state change (acquired / queued / granted / lost / rejected / expired) */
145
+ captureClaim(event: ClaimEvent): void;
146
+ /** Capture a notify-instead-of-abort stale-write collision */
147
+ captureConflict(event: ConflictEvent): void;
105
148
  /** Capture commit returning lastSyncId: 0 */
106
149
  captureCommitZeroSyncId(details: CommitZeroSyncIdDetails): void;
107
150
  /** Wrap a synchronous function in a performance span */
@@ -122,18 +122,40 @@ export type ConflictDecision = {
122
122
  */
123
123
  export type ConflictPolicy = (conflict: Conflict) => ConflictDecision | Promise<ConflictDecision>;
124
124
  /**
125
- * Default policy.
125
+ * Default policy — **Law 7 (the human is the principal) is the engine-wide
126
+ * default, not an opt-in.**
126
127
  *
127
- * `claim_held` conflicts always reject (a foreign claim is honored unless a
128
- * privileged policy preempts). `stale_context` conflicts honor the committer's
129
- * declared `onStale` intent:
128
+ * A `claim_held` conflict resolves by the COMMITTER's kind. This default is, by
129
+ * construction, identical to `coordination(humansOverwrite())` applied to every
130
+ * model so a developer who wants different behaviour just declares the conflict
131
+ * axis on that model (`humansReject()`, `humansNotify()`, `systemOverwrite()`, …)
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
+ * • `agent` → `reject` — an agent yields to a foreign claim (the no-bypass
138
+ * invariant; the ONLY sanctioned agent override is the
139
+ * privileged `claim.preempt` capability seam, see
140
+ * {@link capabilityPreemptPolicy})
141
+ * • `system` → `reject` — automation / backend (`sk_`) writers SERIALIZE
142
+ * through claims by default like agents do, so a server
143
+ * job can't silently steamroll a held row. Opt a model
144
+ * in with `systemOverwrite()` when that's wanted.
145
+ *
146
+ * Scope note: only `user` is allowed by default — that is the exact Law 7
147
+ * decision ("a human is never blocked"). `system` is deliberately NOT a
148
+ * free-bypass: `sk_` keys are full-access backend credentials, and a foundational
149
+ * contract (claim serialization / the retry-storm guard) depends on them
150
+ * respecting claims unless a model says otherwise.
151
+ *
152
+ * `stale_context` conflicts honor the committer's declared `onStale` intent:
130
153
  *
131
154
  * • `'notify'` → notify + hold (op withheld; the actor resolves)
132
155
  * • anything else (incl. `'reject'`, absent) → reject
133
156
  *
134
- * `'overwrite'` never reaches a policy — it's a hard opt-out resolved before
135
- * detection. This preserves the legacy always-reject default for callers that
136
- * don't opt into `notify`.
157
+ * `'overwrite'` never reaches a policy on the stale path — it's a hard opt-out
158
+ * resolved before detection.
137
159
  */
138
160
  export declare const defaultPolicy: (conflict: Conflict) => ConflictDecision;
139
161
  /**
@@ -172,8 +194,10 @@ export interface ConflictAxis {
172
194
  * synchronous (no I/O), so it runs on either side of the schema-agnostic
173
195
  * boundary. Looks up the committer's kind and maps the declared `OnStaleMode`:
174
196
  *
175
- * - undefined → the engine default (`defaultPolicy`: reject; honor
176
- * `onStale: 'notify'` on a stale write)
197
+ * - undefined → the engine default (`defaultPolicy`: Law 7 — a human (`user`)
198
+ * committer is allowed (never blocked); `agent` and `system`
199
+ * reject on a `claim_held`; on a stale write, honor
200
+ * `onStale: 'notify'`)
177
201
  * - `overwrite` → `allow` — the write wins / the committer is never blocked
178
202
  * - `reject` → `reject` — the committer yields
179
203
  * - `notify` → on `stale_context`: notify + hold (re-read & re-apply);
@@ -7,18 +7,40 @@
7
7
  * Adding new shapes is additive on the discriminated union.
8
8
  */
9
9
  /**
10
- * Default policy.
10
+ * Default policy — **Law 7 (the human is the principal) is the engine-wide
11
+ * default, not an opt-in.**
11
12
  *
12
- * `claim_held` conflicts always reject (a foreign claim is honored unless a
13
- * privileged policy preempts). `stale_context` conflicts honor the committer's
14
- * declared `onStale` intent:
13
+ * A `claim_held` conflict resolves by the COMMITTER's kind. This default is, by
14
+ * construction, identical to `coordination(humansOverwrite())` applied to every
15
+ * model so a developer who wants different behaviour just declares the conflict
16
+ * axis on that model (`humansReject()`, `humansNotify()`, `systemOverwrite()`, …)
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
+ * • `agent` → `reject` — an agent yields to a foreign claim (the no-bypass
23
+ * invariant; the ONLY sanctioned agent override is the
24
+ * privileged `claim.preempt` capability seam, see
25
+ * {@link capabilityPreemptPolicy})
26
+ * • `system` → `reject` — automation / backend (`sk_`) writers SERIALIZE
27
+ * through claims by default like agents do, so a server
28
+ * job can't silently steamroll a held row. Opt a model
29
+ * in with `systemOverwrite()` when that's wanted.
30
+ *
31
+ * Scope note: only `user` is allowed by default — that is the exact Law 7
32
+ * decision ("a human is never blocked"). `system` is deliberately NOT a
33
+ * free-bypass: `sk_` keys are full-access backend credentials, and a foundational
34
+ * contract (claim serialization / the retry-storm guard) depends on them
35
+ * respecting claims unless a model says otherwise.
36
+ *
37
+ * `stale_context` conflicts honor the committer's declared `onStale` intent:
15
38
  *
16
39
  * • `'notify'` → notify + hold (op withheld; the actor resolves)
17
40
  * • anything else (incl. `'reject'`, absent) → reject
18
41
  *
19
- * `'overwrite'` never reaches a policy — it's a hard opt-out resolved before
20
- * detection. This preserves the legacy always-reject default for callers that
21
- * don't opt into `notify`.
42
+ * `'overwrite'` never reaches a policy on the stale path — it's a hard opt-out
43
+ * resolved before detection.
22
44
  */
23
45
  // Typed by its real SYNCHRONOUS shape (via `satisfies`) rather than the
24
46
  // async-permissive `ConflictPolicy` alias, so sync callers like
@@ -26,8 +48,15 @@
26
48
  // `ConflictDecision` back (not `… | Promise<…>`). Still assignable to
27
49
  // `ConflictPolicy` everywhere it's used as a policy.
28
50
  export const defaultPolicy = ((conflict) => {
29
- if (conflict.kind !== 'stale_context') {
30
- return { action: 'reject', reason: 'claim_conflict' };
51
+ if (conflict.kind === 'claim_held') {
52
+ // Law 7 universal default: a human (`user`) is never blocked; agents AND
53
+ // system actors yield. Keeping every non-`user` kind on `reject` here is
54
+ // what makes the no-bypass invariant hold even on the registry/default
55
+ // resolution path (which, unlike the declared-axis path, has no separate
56
+ // agent-degradation guard).
57
+ return conflict.committer.kind === 'user'
58
+ ? { action: 'allow', note: 'principal:not-blocked' }
59
+ : { action: 'reject', reason: 'claim_conflict' };
31
60
  }
32
61
  return conflict.requestedMode === 'notify'
33
62
  ? { action: 'notify', reason: 'stale_notify_hold' }
@@ -53,8 +82,10 @@ export const capabilityPreemptPolicy = (conflict) => {
53
82
  * synchronous (no I/O), so it runs on either side of the schema-agnostic
54
83
  * boundary. Looks up the committer's kind and maps the declared `OnStaleMode`:
55
84
  *
56
- * - undefined → the engine default (`defaultPolicy`: reject; honor
57
- * `onStale: 'notify'` on a stale write)
85
+ * - undefined → the engine default (`defaultPolicy`: Law 7 — a human (`user`)
86
+ * committer is allowed (never blocked); `agent` and `system`
87
+ * reject on a `claim_held`; on a stale write, honor
88
+ * `onStale: 'notify'`)
58
89
  * - `overwrite` → `allow` — the write wins / the committer is never blocked
59
90
  * - `reject` → `reject` — the committer yields
60
91
  * - `notify` → on `stale_context`: notify + hold (re-read & re-apply);
@@ -1,7 +1,7 @@
1
1
  import { type ReactNode } from 'react';
2
2
  import type { SchemaRecord } from '../schema/schema.js';
3
3
  import { Ablo } from '../client/Ablo.js';
4
- import type { ActiveClaim, Peer } from '../types/streams.js';
4
+ import type { Claim, Peer } from '../types/streams.js';
5
5
  import type { EngineParticipant, ParticipantScope, ParticipantStatus } from '../sync/participants.js';
6
6
  import { type SyncStoreContract } from './context.js';
7
7
  /**
@@ -154,7 +154,7 @@ export interface UseWatchReturn {
154
154
  /** Everyone else on the engine's sync groups (`participant.presence.others`), bridged to React. */
155
155
  readonly peers: ReadonlyArray<Peer>;
156
156
  /** Active claim claims by peers (`participant.claims.others`), bridged to React. */
157
- readonly claims: ReadonlyArray<ActiveClaim>;
157
+ readonly claims: ReadonlyArray<Claim>;
158
158
  readonly status: ParticipantStatus;
159
159
  readonly error: Error | null;
160
160
  }
@@ -65,6 +65,42 @@ export declare function snakeToCamel(identifier: string): string;
65
65
  /** Quote an identifier (defense-in-depth; inputs are already slug/snake). */
66
66
  export declare function q(identifier: string): string;
67
67
  export declare function sqlType(fieldType: ModelJSON['fields'][string]['type']): string;
68
+ /**
69
+ * Detect and repair `field.json()` columns that exist in the live database as a
70
+ * NON-jsonb type, and emit a salvaging in-place `ALTER … TYPE jsonb`.
71
+ *
72
+ * Why this is needed: provisioning uses `ADD COLUMN IF NOT EXISTS` and
73
+ * `CREATE TABLE IF NOT EXISTS` (idempotent by design). When `ablo push` adopts
74
+ * a table/column that PRE-EXISTS with a different type — e.g. a `content TEXT`
75
+ * column from a legacy table — the additive DDL is a no-op and the column
76
+ * silently stays `text`. The pure schema-to-schema differ
77
+ * ({@link generateMigrationPlan}) can't see this: the schema says `json` on
78
+ * both sides, so it emits no change. The drift is only visible by INTROSPECTING
79
+ * the live database and comparing to the declared type — the drizzle-kit
80
+ * `pull` discipline. A `json` value bound to a `text` column corrupts to the
81
+ * literal `"[object Object]"`, so leaving the drift unrepaired is silent data
82
+ * loss.
83
+ *
84
+ * The cast is SALVAGING, never aborting (Postgres won't auto-cast text→jsonb;
85
+ * invalid rows would otherwise fail the whole ALTER): a row that `IS JSON`
86
+ * parses to jsonb, anything else (including already-corrupted
87
+ * `"[object Object]"`) is wrapped as a jsonb string via `to_jsonb`, and NULL
88
+ * stays NULL. The `IS JSON` predicate requires Postgres 16+ (the fleet runs
89
+ * 17); on an older engine the ALTER fails LOUD with a structured
90
+ * `migration_failed` rather than silently — acceptable, since silent corruption
91
+ * is the thing we're eliminating. See
92
+ * https://echobind.com/post/safely-alter-postgres-columns-with-using.
93
+ *
94
+ * Pure + idempotent: emits a statement ONLY for a json field whose live column
95
+ * type is present and not already `jsonb`/`json`. A correctly-provisioned schema
96
+ * yields zero statements, so this is a no-op on every push after the first
97
+ * repair. Columns absent from `liveColumnTypes` are left to the additive
98
+ * provisioner (which adds them as jsonb).
99
+ *
100
+ * @param liveColumnTypes table name → (column name → information_schema
101
+ * `data_type`), as introspected from the target schema.
102
+ */
103
+ export declare function generateJsonColumnReconciliation(schema: SchemaJSON, liveColumnTypes: ReadonlyMap<string, ReadonlyMap<string, string>>, targetSchema: string): string[];
68
104
  /**
69
105
  * Build the additive, idempotent provisioning plan for an app. Pure — no DB
70
106
  * access.
@@ -66,6 +66,72 @@ export function sqlType(fieldType) {
66
66
  }
67
67
  }
68
68
  const BASE_COLUMNS = new Set(['id', 'organization_id', 'created_by', 'created_at', 'updated_at']);
69
+ // ── JSON column drift reconciliation ─────────────────────────────────────────
70
+ /**
71
+ * Detect and repair `field.json()` columns that exist in the live database as a
72
+ * NON-jsonb type, and emit a salvaging in-place `ALTER … TYPE jsonb`.
73
+ *
74
+ * Why this is needed: provisioning uses `ADD COLUMN IF NOT EXISTS` and
75
+ * `CREATE TABLE IF NOT EXISTS` (idempotent by design). When `ablo push` adopts
76
+ * a table/column that PRE-EXISTS with a different type — e.g. a `content TEXT`
77
+ * column from a legacy table — the additive DDL is a no-op and the column
78
+ * silently stays `text`. The pure schema-to-schema differ
79
+ * ({@link generateMigrationPlan}) can't see this: the schema says `json` on
80
+ * both sides, so it emits no change. The drift is only visible by INTROSPECTING
81
+ * the live database and comparing to the declared type — the drizzle-kit
82
+ * `pull` discipline. A `json` value bound to a `text` column corrupts to the
83
+ * literal `"[object Object]"`, so leaving the drift unrepaired is silent data
84
+ * loss.
85
+ *
86
+ * The cast is SALVAGING, never aborting (Postgres won't auto-cast text→jsonb;
87
+ * invalid rows would otherwise fail the whole ALTER): a row that `IS JSON`
88
+ * parses to jsonb, anything else (including already-corrupted
89
+ * `"[object Object]"`) is wrapped as a jsonb string via `to_jsonb`, and NULL
90
+ * stays NULL. The `IS JSON` predicate requires Postgres 16+ (the fleet runs
91
+ * 17); on an older engine the ALTER fails LOUD with a structured
92
+ * `migration_failed` rather than silently — acceptable, since silent corruption
93
+ * is the thing we're eliminating. See
94
+ * https://echobind.com/post/safely-alter-postgres-columns-with-using.
95
+ *
96
+ * Pure + idempotent: emits a statement ONLY for a json field whose live column
97
+ * type is present and not already `jsonb`/`json`. A correctly-provisioned schema
98
+ * yields zero statements, so this is a no-op on every push after the first
99
+ * repair. Columns absent from `liveColumnTypes` are left to the additive
100
+ * provisioner (which adds them as jsonb).
101
+ *
102
+ * @param liveColumnTypes table name → (column name → information_schema
103
+ * `data_type`), as introspected from the target schema.
104
+ */
105
+ export function generateJsonColumnReconciliation(schema, liveColumnTypes, targetSchema) {
106
+ const qs = q(targetSchema);
107
+ const statements = [];
108
+ for (const [key, model] of Object.entries(schema.models)) {
109
+ const table = model.tableName ?? key;
110
+ const liveCols = liveColumnTypes.get(table);
111
+ if (!liveCols)
112
+ continue; // table not provisioned yet — nothing to reconcile
113
+ const qt = `${qs}.${q(table)}`;
114
+ for (const [fieldName, meta] of Object.entries(model.fields)) {
115
+ if (meta.type !== 'json')
116
+ continue;
117
+ const col = meta.column ?? camelToSnake(fieldName);
118
+ const liveType = liveCols.get(col);
119
+ if (liveType === undefined)
120
+ continue; // column absent — provisioner adds jsonb
121
+ if (liveType === 'jsonb' || liveType === 'json')
122
+ continue; // already correct
123
+ const c = q(col);
124
+ statements.push(`ALTER TABLE ${qt} ALTER COLUMN ${c} TYPE jsonb USING (\n` +
125
+ ` CASE\n` +
126
+ ` WHEN ${c} IS NULL THEN NULL\n` +
127
+ ` WHEN ${c}::text IS JSON THEN ${c}::text::jsonb\n` +
128
+ ` ELSE to_jsonb(${c}::text)\n` +
129
+ ` END\n` +
130
+ `);`);
131
+ }
132
+ }
133
+ return statements;
134
+ }
69
135
  // ── Foreign keys (relation-driven, sync-safe) ────────────────────────────────
70
136
  /**
71
137
  * A Postgres-identifier-safe constraint name ≤63 bytes. When the natural
@@ -33,7 +33,7 @@ export { mutable, readOnly, type SugarOptions } from './sugar.js';
33
33
  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';
34
34
  export { serializeSchema, parseSchema, toSchemaJSON, fromSchemaJSON, schemaHash, type SchemaJSON, type ModelJSON, type RelationJSON, } from './serialize.js';
35
35
  export { selectModels } from './select.js';
36
- export { generateProvisionPlan, generateMigrationPlan, appSchemaName, camelToSnake, snakeToCamel, q, sqlType, type ProvisionPlan, type MigrationPlan, } from './ddl.js';
36
+ export { generateProvisionPlan, generateMigrationPlan, generateJsonColumnReconciliation, appSchemaName, camelToSnake, snakeToCamel, q, sqlType, type ProvisionPlan, type MigrationPlan, } from './ddl.js';
37
37
  export { diffSchema, classifyMigration, classifyCast, isAutoApplicable, isBlockerResolved, unresolvedBlockers, type BackfillValue, type MigrationStep, type FieldChanges, type FieldColumnChange, type FieldTypeChange, type NullabilityChange, type EnumValuesChange, type IndexChange, type CastSafety, type FieldType, type RenameHints, type MigrationSignal, type MigrationClassification, type WarningCode, type BlockerCode, } from './diff.js';
38
38
  export { generateTypes } from './generate.js';
39
39
  export { query, defineQueries, type QueryDef, type QueryRecord, type Queries, type InferQueryInput, type InferQueryResult, } from './queries.js';
@@ -55,7 +55,7 @@ export { serializeSchema, parseSchema, toSchemaJSON, fromSchemaJSON, schemaHash,
55
55
  // Schema projection — derive an app's subset from one canonical schema.
56
56
  export { selectModels } from './select.js';
57
57
  // Schema → Postgres DDL (pure; shared by the hosted server and the CLI)
58
- export { generateProvisionPlan, generateMigrationPlan, appSchemaName, camelToSnake, snakeToCamel, q, sqlType, } from './ddl.js';
58
+ export { generateProvisionPlan, generateMigrationPlan, generateJsonColumnReconciliation, appSchemaName, camelToSnake, snakeToCamel, q, sqlType, } from './ddl.js';
59
59
  // Schema diff + migration planning (pure; SQL emission lowered by ddl.ts)
60
60
  export { diffSchema, classifyMigration, classifyCast, isAutoApplicable, isBlockerResolved, unresolvedBlockers, } from './diff.js';
61
61
  // Schema → TypeScript type emission (the `generate` half; pure)
package/dist/surface.d.ts CHANGED
@@ -23,7 +23,7 @@ export declare const PUBLIC_MODEL_VERBS: readonly ["retrieve", "list", "get", "g
23
23
  export declare const PUBLIC_LIST_OPTION_KEYS: readonly ["where", "filter", "orderBy", "limit", "offset", "state"];
24
24
  /** Public keys of `AbloOptions`. `schema` is required; the rest are optional
25
25
  * (the locked happy path is `Ablo({ schema, apiKey, databaseUrl, transport })`). */
26
- export declare const PUBLIC_ABLO_OPTION_KEYS: readonly ["schema", "apiKey", "databaseUrl", "persistence", "transport", "authToken", "baseURL", "fetch", "defaultHeaders", "defaultQuery", "dangerouslyAllowBrowser"];
26
+ export declare const PUBLIC_ABLO_OPTION_KEYS: readonly ["schema", "apiKey", "databaseUrl", "persistence", "transport", "debug", "logLevel", "authToken", "baseURL", "fetch", "defaultHeaders", "defaultQuery", "dangerouslyAllowBrowser"];
27
27
  export type ModelVerb = (typeof PUBLIC_MODEL_VERBS)[number];
28
28
  export type ListOptionKey = (typeof PUBLIC_LIST_OPTION_KEYS)[number];
29
29
  export type AbloOptionKey = (typeof PUBLIC_ABLO_OPTION_KEYS)[number];
package/dist/surface.js CHANGED
@@ -51,6 +51,8 @@ export const PUBLIC_ABLO_OPTION_KEYS = [
51
51
  'databaseUrl',
52
52
  'persistence',
53
53
  'transport',
54
+ 'debug',
55
+ 'logLevel',
54
56
  'authToken',
55
57
  'baseURL',
56
58
  'fetch',
@@ -171,7 +171,11 @@ export class HydrationCoordinator {
171
171
  async fetchFromNetwork(modelName, typename, clauses, options) {
172
172
  const networkRows = await this.queryNetwork(modelName, clauses, options);
173
173
  const networkModels = networkRows
174
- .map((raw) => this.hydrateOne(raw, typename))
174
+ // Strict: a row the SERVER returned whose typename this client never
175
+ // registered is a genuine schema collision (the org's pushed schema
176
+ // differs from local) — throw it here, naming the cause, rather than
177
+ // silently dropping the row and failing downstream as `entity_not_found`.
178
+ .map((raw) => this.hydrateOne(raw, typename, { strict: true }))
175
179
  .filter((m) => m !== null);
176
180
  if (networkModels.length > 0) {
177
181
  this.opts.objectPool.addBatch(networkModels, ModelScope.live);
@@ -288,7 +292,7 @@ export class HydrationCoordinator {
288
292
  getModelDef(modelName) {
289
293
  return this.opts.schema.models?.[modelName];
290
294
  }
291
- hydrateOne(raw, typename) {
295
+ hydrateOne(raw, typename, opts) {
292
296
  if (!raw || typeof raw !== 'object')
293
297
  return null;
294
298
  const obj = raw;
@@ -321,7 +325,7 @@ export class HydrationCoordinator {
321
325
  // re-populate it). The typename comes from the schema relation
322
326
  // (`'SlideLayer'`, `'SlideLayoutLayer'`, etc.) so no guessing involved.
323
327
  const stamped = this.stampTypename(obj, typename);
324
- return this.opts.objectPool.createFromData(stamped);
328
+ return this.opts.objectPool.createFromData(stamped, undefined, opts);
325
329
  }
326
330
  /**
327
331
  * Stamp `__typename` onto a row when it's known (from the schema's
@@ -179,7 +179,7 @@ export interface PresenceUpdateEvent {
179
179
  participantKind?: string;
180
180
  timestamp?: number;
181
181
  /** Server stamps every presence frame with this participant's open
182
- * claim claims so peers see them without a separate channel. Wire
182
+ * claims so peers see them without a separate channel. Wire
183
183
  * shape mirrors `apps/sync-server/src/hub/types.ts Claim`. */
184
184
  activeClaims?: Array<{
185
185
  claimId: string;
@@ -192,7 +192,7 @@ export interface PresenceUpdateEvent {
192
192
  startColumn?: number;
193
193
  endColumn?: number;
194
194
  };
195
- action: string;
195
+ reason: string;
196
196
  field?: string;
197
197
  meta?: Record<string, unknown>;
198
198
  declaredAt: number;
@@ -490,6 +490,15 @@ export declare class SyncWebSocket<TCollaboration extends EventMap<TCollaboratio
490
490
  * so a bad notification never sinks an otherwise-successful commit.
491
491
  */
492
492
  private parseNotifications;
493
+ /**
494
+ * Single instrumentation point for claim events. Every `claim_*` frame routes
495
+ * through here so a developer debugging a collision gets one consistent trace
496
+ * — a console line AND a structured capture — without each dispatch case
497
+ * re-deriving the row/holder shape. The wire payload is loosely typed
498
+ * (`Record<string, unknown>`), so this is the one place that narrows it into
499
+ * a {@link ClaimEvent}.
500
+ */
501
+ private recordClaim;
493
502
  sendCommit(operations: ReadonlyArray<MutationOperation>, clientTxId: string, timeoutMs?: number, causedByTaskId?: string | null, reads?: readonly ReadDependency[] | null): Promise<CommitAck>;
494
503
  /**
495
504
  * Send a commit frame without waiting for `mutation_result`.
@@ -11,7 +11,8 @@ import { EventEmitter } from 'events';
11
11
  import { getContext } from '../context.js';
12
12
  import { flushOfflineQueueOnce } from './OfflineFlush.js';
13
13
  import { AbloConnectionError, AbloError, CapabilityError, SyncSessionError, errorFromWire, toAbloError, } from '../errors.js';
14
- import { subscriptionAckPayloadSchema, staleNotificationSchema, } from '../coordination/schema.js';
14
+ import { subscriptionAckPayloadSchema, staleNotificationSchema, wireParticipantKindSchema, } from '../coordination/schema.js';
15
+ import { formatClaim, formatConflict } from '../coordination/trace.js';
15
16
  import { WS_BEARER_SUBPROTOCOL_PREFIX, WS_SYNC_SUBPROTOCOL, } from '../auth/credentialSource.js';
16
17
  // ---------------------------------------------------------------------------
17
18
  // Ablo-specific collaboration events moved to apps/web/src/lib/sync/collaboration-events.ts
@@ -177,7 +178,7 @@ export class SyncWebSocket extends EventEmitter {
177
178
  // which is unreliable but the only signal available; in Node it returns true
178
179
  // (assume online) so the sidecar/agent path doesn't short-circuit here.
179
180
  if (!getContext().onlineStatus.isOnline()) {
180
- getContext().logger.warn('onlineStatus reports offline, but attempting connection anyway');
181
+ getContext().logger.debug('onlineStatus reports offline, but attempting connection anyway');
181
182
  }
182
183
  this.isConnecting = true;
183
184
  this.isManualClose = false;
@@ -338,8 +339,23 @@ export class SyncWebSocket extends EventEmitter {
338
339
  // the advisory signal so an agent loop can self-heal, AND resolve
339
340
  // the receipt with it (the commit still succeeded).
340
341
  if (notifications && notifications.length > 0) {
342
+ const txId = typeof clientTxId === 'string' ? clientTxId : '';
343
+ const event = {
344
+ clientTxId: txId,
345
+ rows: notifications.map((n) => ({
346
+ model: n.model,
347
+ id: n.id,
348
+ fields: n.conflictingFields,
349
+ writtenBy: n.writtenBy?.kind,
350
+ })),
351
+ };
352
+ const message = formatConflict(event);
353
+ const ctx = getContext();
354
+ ctx.logger.warn(message);
355
+ ctx.observability.breadcrumb(message, 'sync.coordination', 'warning');
356
+ ctx.observability.captureConflict(event);
341
357
  this.emit('conflict:notified', {
342
- clientTxId: typeof clientTxId === 'string' ? clientTxId : '',
358
+ clientTxId: txId,
343
359
  notifications,
344
360
  });
345
361
  }
@@ -493,6 +509,7 @@ export class SyncWebSocket extends EventEmitter {
493
509
  // inactive server-side by the time this arrives.
494
510
  const p = message.payload ?? {};
495
511
  if (typeof p.claimId === 'string') {
512
+ this.recordClaim('expired', p);
496
513
  this.emit('claim_expired', { claimId: p.claimId });
497
514
  }
498
515
  break;
@@ -502,33 +519,40 @@ export class SyncWebSocket extends EventEmitter {
502
519
  // already claimed by another participant. Forward the
503
520
  // payload as-is — the ClaimStream consumer interprets
504
521
  // the conflict shape (peerId, target, etc.).
522
+ this.recordClaim('rejected', message.payload ?? {});
505
523
  this.emit('claim_rejected', message.payload ?? {});
506
524
  break;
507
525
  }
508
526
  case 'claim_acquired': {
509
527
  // Opt-in fair queue: the target was free, so the lease is ours
510
528
  // immediately (no waiting). Payload carries { claimId, target }.
529
+ this.recordClaim('acquired', message.payload ?? {});
511
530
  this.emit('claim_acquired', message.payload ?? {});
512
531
  break;
513
532
  }
514
533
  case 'claim_queue': {
515
- // Per-entity wait-queue snapshot for reactive `queue(id)`.
534
+ // Per-entity wait-queue snapshot for reactive `queue(id)`. Not a
535
+ // single claim's state change, so it isn't logged — the per-claim
536
+ // `queued`/`granted` events already tell that story.
516
537
  this.emit('claim_queue', message.payload ?? {});
517
538
  break;
518
539
  }
519
540
  case 'claim_queued': {
520
541
  // Opt-in fair queue: our claim is waiting in line. Payload
521
542
  // carries { claimId, target, position }.
543
+ this.recordClaim('queued', message.payload ?? {});
522
544
  this.emit('claim_queued', message.payload ?? {});
523
545
  break;
524
546
  }
525
547
  case 'claim_granted': {
526
548
  // Our queued claim reached the head — the lease is now ours.
549
+ this.recordClaim('granted', message.payload ?? {});
527
550
  this.emit('claim_granted', message.payload ?? {});
528
551
  break;
529
552
  }
530
553
  case 'claim_lost': {
531
554
  // A held/granted claim was taken from us (TTL lapse, revoke).
555
+ this.recordClaim('lost', message.payload ?? {});
532
556
  this.emit('claim_lost', message.payload ?? {});
533
557
  break;
534
558
  }
@@ -872,6 +896,46 @@ export class SyncWebSocket extends EventEmitter {
872
896
  }
873
897
  return out.length > 0 ? out : undefined;
874
898
  }
899
+ /**
900
+ * Single instrumentation point for claim events. Every `claim_*` frame routes
901
+ * through here so a developer debugging a collision gets one consistent trace
902
+ * — a console line AND a structured capture — without each dispatch case
903
+ * re-deriving the row/holder shape. The wire payload is loosely typed
904
+ * (`Record<string, unknown>`), so this is the one place that narrows it into
905
+ * a {@link ClaimEvent}.
906
+ */
907
+ recordClaim(phase, payload) {
908
+ const str = (v) => typeof v === 'string' ? v : undefined;
909
+ // Targets arrive flat ({ entityType, entityId }) or nested under `target`.
910
+ const target = payload.target && typeof payload.target === 'object'
911
+ ? payload.target
912
+ : payload;
913
+ const kind = wireParticipantKindSchema.safeParse(payload.participantKind);
914
+ const event = {
915
+ phase,
916
+ claimId: str(payload.claimId),
917
+ model: str(target.entityType) ?? str(target.model),
918
+ id: str(target.entityId) ?? str(target.id),
919
+ field: str(target.field),
920
+ actor: str(payload.actor) ?? str(payload.heldBy),
921
+ participantKind: kind.success ? kind.data : undefined,
922
+ position: typeof payload.position === 'number' ? payload.position : undefined,
923
+ reason: str(payload.policyReason) ?? str(payload.reason),
924
+ };
925
+ const message = formatClaim(event);
926
+ // A rejection or lost lease is the collision a developer is actively
927
+ // debugging → warn (shows at the default log level). The routine events
928
+ // (acquired/queued/granted/expired) are debug-only so they never drown the
929
+ // console until you opt in with `new Ablo({ debug: true })`.
930
+ const isCollision = phase === 'rejected' || phase === 'lost';
931
+ const ctx = getContext();
932
+ if (isCollision)
933
+ ctx.logger.warn(message);
934
+ else
935
+ ctx.logger.debug(message);
936
+ ctx.observability.breadcrumb(message, 'sync.coordination', isCollision ? 'warning' : 'info');
937
+ ctx.observability.captureClaim(event);
938
+ }
875
939
  sendCommit(operations, clientTxId, timeoutMs = 15_000, causedByTaskId, reads) {
876
940
  if (this.ws?.readyState !== WebSocket.OPEN) {
877
941
  return Promise.reject(this.notConnectedError('commit'));
@@ -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;