@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.
- package/CHANGELOG.md +70 -0
- package/dist/BaseSyncedStore.d.ts +5 -4
- package/dist/BaseSyncedStore.js +38 -14
- package/dist/NetworkMonitor.js +3 -1
- package/dist/ObjectPool.d.ts +14 -1
- package/dist/ObjectPool.js +8 -1
- package/dist/SyncClient.js +7 -1
- package/dist/SyncEngineContext.js +2 -0
- package/dist/ai-sdk/coordination-context.d.ts +2 -1
- package/dist/ai-sdk/coordination-context.js +3 -3
- package/dist/ai-sdk/index.d.ts +3 -4
- package/dist/ai-sdk/index.js +2 -3
- package/dist/ai-sdk/wrap.d.ts +13 -18
- package/dist/ai-sdk/wrap.js +2 -6
- package/dist/cli.cjs +459 -205
- package/dist/client/Ablo.d.ts +116 -23
- package/dist/client/Ablo.js +128 -38
- package/dist/client/ApiClient.d.ts +2 -2
- package/dist/client/ApiClient.js +27 -32
- package/dist/client/auth.d.ts +26 -0
- package/dist/client/auth.js +209 -1
- package/dist/client/createModelProxy.d.ts +16 -11
- package/dist/client/createModelProxy.js +15 -15
- package/dist/client/sessionMint.d.ts +10 -0
- package/dist/client/sessionMint.js +8 -1
- package/dist/coordination/schema.d.ts +10 -10
- package/dist/coordination/schema.js +7 -8
- package/dist/coordination/trace.d.ts +91 -0
- package/dist/coordination/trace.js +147 -0
- package/dist/core/StoreManager.js +3 -1
- package/dist/errorCodes.d.ts +2 -0
- package/dist/errorCodes.js +2 -0
- package/dist/errors.d.ts +10 -2
- package/dist/errors.js +20 -9
- package/dist/index.d.ts +7 -1
- package/dist/index.js +12 -0
- package/dist/interfaces/index.d.ts +45 -2
- package/dist/policy/types.d.ts +33 -9
- package/dist/policy/types.js +42 -11
- package/dist/react/AbloProvider.d.ts +2 -2
- package/dist/schema/ddl.d.ts +36 -0
- package/dist/schema/ddl.js +66 -0
- package/dist/schema/index.d.ts +1 -1
- package/dist/schema/index.js +1 -1
- package/dist/surface.d.ts +1 -1
- package/dist/surface.js +2 -0
- package/dist/sync/HydrationCoordinator.js +7 -3
- package/dist/sync/SyncWebSocket.d.ts +11 -2
- package/dist/sync/SyncWebSocket.js +68 -4
- package/dist/sync/awaitClaimGrant.js +9 -2
- package/dist/sync/createClaimStream.d.ts +9 -2
- package/dist/sync/createClaimStream.js +41 -7
- package/dist/sync/participants.d.ts +12 -11
- package/dist/sync/participants.js +5 -5
- package/dist/transactions/TransactionQueue.d.ts +1 -0
- package/dist/transactions/TransactionQueue.js +40 -3
- package/dist/types/streams.d.ts +57 -135
- package/dist/wire/errorEnvelope.d.ts +13 -0
- package/docs/coordination.md +16 -0
- package/docs/debugging.md +194 -0
- package/docs/index.md +1 -0
- package/package.json +1 -1
- package/dist/ai-sdk/claim-broadcast.d.ts +0 -83
- 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 */
|
package/dist/policy/types.d.ts
CHANGED
|
@@ -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`
|
|
128
|
-
*
|
|
129
|
-
*
|
|
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
|
|
135
|
-
* detection.
|
|
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`:
|
|
176
|
-
*
|
|
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);
|
package/dist/policy/types.js
CHANGED
|
@@ -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`
|
|
13
|
-
*
|
|
14
|
-
*
|
|
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
|
|
20
|
-
* detection.
|
|
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
|
|
30
|
-
|
|
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`:
|
|
57
|
-
*
|
|
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 {
|
|
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<
|
|
157
|
+
readonly claims: ReadonlyArray<Claim>;
|
|
158
158
|
readonly status: ParticipantStatus;
|
|
159
159
|
readonly error: Error | null;
|
|
160
160
|
}
|
package/dist/schema/ddl.d.ts
CHANGED
|
@@ -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.
|
package/dist/schema/ddl.js
CHANGED
|
@@ -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
|
package/dist/schema/index.d.ts
CHANGED
|
@@ -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';
|
package/dist/schema/index.js
CHANGED
|
@@ -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
|
@@ -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
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
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.
|
|
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:
|
|
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;
|