@abloatai/ablo 0.18.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 +37 -0
- 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 +253 -160
- package/dist/client/Ablo.d.ts +83 -22
- package/dist/client/Ablo.js +103 -30
- 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 +208 -0
- 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/errorCodes.d.ts +2 -0
- package/dist/errorCodes.js +2 -0
- package/dist/errors.d.ts +1 -2
- package/dist/errors.js +6 -9
- package/dist/index.d.ts +5 -1
- package/dist/index.js +7 -0
- package/dist/interfaces/index.d.ts +45 -2
- package/dist/policy/types.d.ts +18 -9
- package/dist/policy/types.js +26 -16
- 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/sync/HydrationCoordinator.js +7 -3
- package/dist/sync/SyncWebSocket.d.ts +11 -2
- package/dist/sync/SyncWebSocket.js +67 -3
- package/dist/sync/createClaimStream.d.ts +9 -2
- package/dist/sync/createClaimStream.js +9 -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/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
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)
|
|
@@ -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
|
|
@@ -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'));
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
*
|
|
11
11
|
* Wire contract (apps/sync-server/src/hub/types.ts):
|
|
12
12
|
* • Outbound: `{ type: 'claim_begin', payload: { claimId,
|
|
13
|
-
* entityType, entityId,
|
|
13
|
+
* entityType, entityId, reason, field?, estimatedMs? } }`
|
|
14
14
|
* • Outbound: `{ type: 'claim_abandon', payload: { claimId,
|
|
15
15
|
* entityType?, entityId? } }`
|
|
16
16
|
* • Inbound (via presence): `event.activeClaims: Claim[]`
|
|
@@ -22,12 +22,19 @@
|
|
|
22
22
|
* deletes.
|
|
23
23
|
*/
|
|
24
24
|
import type { SyncWebSocket } from './SyncWebSocket.js';
|
|
25
|
-
import type { ClaimStream } from '../types/streams.js';
|
|
25
|
+
import type { ClaimOptions, Claim, ClaimStream, PresenceTarget } from '../types/streams.js';
|
|
26
26
|
export interface ClaimStreamConfig {
|
|
27
27
|
/** Identity used to filter our own active claims out of `others`. */
|
|
28
28
|
participantId: string;
|
|
29
29
|
}
|
|
30
30
|
export interface AttachableClaimStream extends ClaimStream {
|
|
31
|
+
/**
|
|
32
|
+
* INTERNAL lease mint — sends the `claim_begin` frame and returns a held
|
|
33
|
+
* {@link Claim} (no row `data`; the model door reads the row and stamps it).
|
|
34
|
+
* Not part of the public `ClaimStream` surface: the only public way to take a
|
|
35
|
+
* claim is `ablo.<model>.claim({ id })`, which builds on this.
|
|
36
|
+
*/
|
|
37
|
+
claim(target: PresenceTarget, opts?: ClaimOptions): Claim;
|
|
31
38
|
attach(transport: SyncWebSocket): void;
|
|
32
39
|
dispose(): void;
|
|
33
40
|
}
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
*
|
|
11
11
|
* Wire contract (apps/sync-server/src/hub/types.ts):
|
|
12
12
|
* • Outbound: `{ type: 'claim_begin', payload: { claimId,
|
|
13
|
-
* entityType, entityId,
|
|
13
|
+
* entityType, entityId, reason, field?, estimatedMs? } }`
|
|
14
14
|
* • Outbound: `{ type: 'claim_abandon', payload: { claimId,
|
|
15
15
|
* entityType?, entityId? } }`
|
|
16
16
|
* • Inbound (via presence): `event.activeClaims: Claim[]`
|
|
@@ -104,7 +104,9 @@ export function createClaimStream(config, transport = null) {
|
|
|
104
104
|
continue;
|
|
105
105
|
const description = descriptionFromMeta(claim.meta);
|
|
106
106
|
activeByClaimId.set(claim.claimId, {
|
|
107
|
+
object: 'claim',
|
|
107
108
|
id: claim.claimId,
|
|
109
|
+
status: 'active',
|
|
108
110
|
heldBy: event.userId,
|
|
109
111
|
participantKind: participantKindFromWire(event.participantKind, event.isAgent),
|
|
110
112
|
target: {
|
|
@@ -115,10 +117,10 @@ export function createClaimStream(config, transport = null) {
|
|
|
115
117
|
field: claim.field,
|
|
116
118
|
meta: claim.meta,
|
|
117
119
|
},
|
|
118
|
-
reason: claim.
|
|
120
|
+
reason: claim.reason,
|
|
119
121
|
...(description ? { description } : {}),
|
|
120
122
|
ttlSeconds: Math.max(0, Math.floor((claim.expiresAt - Date.now()) / 1000)),
|
|
121
|
-
|
|
123
|
+
createdAt: claim.declaredAt,
|
|
122
124
|
expiresAt: claim.expiresAt,
|
|
123
125
|
});
|
|
124
126
|
mutated = true;
|
|
@@ -220,8 +222,7 @@ export function createClaimStream(config, transport = null) {
|
|
|
220
222
|
entityId: claim.entityId,
|
|
221
223
|
path: claim.path,
|
|
222
224
|
range: claim.range,
|
|
223
|
-
|
|
224
|
-
action: claim.reason,
|
|
225
|
+
reason: claim.reason,
|
|
225
226
|
field: claim.field,
|
|
226
227
|
meta: claim.meta,
|
|
227
228
|
estimatedMs: claim.estimatedMs,
|
|
@@ -293,10 +294,11 @@ export function createClaimStream(config, transport = null) {
|
|
|
293
294
|
};
|
|
294
295
|
return {
|
|
295
296
|
object: 'claim',
|
|
296
|
-
claimId,
|
|
297
|
+
id: claimId,
|
|
298
|
+
status: 'active',
|
|
297
299
|
reason: args.reason,
|
|
298
300
|
target: {
|
|
299
|
-
|
|
301
|
+
type: args.entityType,
|
|
300
302
|
id: args.entityId,
|
|
301
303
|
path: args.path,
|
|
302
304
|
range: args.range,
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import type { SyncWebSocket } from './SyncWebSocket.js';
|
|
2
2
|
import type { Schema, SchemaRecord } from '../schema/schema.js';
|
|
3
|
-
import type {
|
|
3
|
+
import type { Claim, Activity, ClaimTarget, ClaimStream, Peer, PresenceStream, PresenceTarget } from '../types/streams.js';
|
|
4
|
+
import type { AttachableClaimStream } from './createClaimStream.js';
|
|
4
5
|
/**
|
|
5
6
|
* Scope accepted by participant APIs. The normal SDK shape is an
|
|
6
7
|
* entity target (`{ type, id }`). Raw sync-group strings remain an
|
|
7
8
|
* advanced transport escape hatch.
|
|
8
9
|
*/
|
|
9
|
-
export type ParticipantScope =
|
|
10
|
+
export type ParticipantScope = ClaimTarget | readonly ClaimTarget[] | string | readonly string[] | {
|
|
10
11
|
readonly syncGroup: string;
|
|
11
12
|
} | {
|
|
12
13
|
readonly syncGroups: readonly string[];
|
|
@@ -44,7 +45,7 @@ export interface ParticipantJoinOptions {
|
|
|
44
45
|
}
|
|
45
46
|
export interface ScopedPresence {
|
|
46
47
|
readonly self: Peer;
|
|
47
|
-
readonly focus:
|
|
48
|
+
readonly focus: ClaimTarget | null;
|
|
48
49
|
readonly others: ReadonlyArray<Peer>;
|
|
49
50
|
update(activity: Activity): void;
|
|
50
51
|
reading(detail?: string): void;
|
|
@@ -66,15 +67,15 @@ export interface ScopedClaimOptions {
|
|
|
66
67
|
readonly ttl?: import('../types/streams.js').Duration;
|
|
67
68
|
}
|
|
68
69
|
export interface ScopedClaims {
|
|
69
|
-
readonly focus:
|
|
70
|
-
readonly others: ReadonlyArray<
|
|
70
|
+
readonly focus: ClaimTarget | null;
|
|
71
|
+
readonly others: ReadonlyArray<Claim>;
|
|
71
72
|
/**
|
|
72
73
|
* Claim an exclusive claim on the participant's focus target (or
|
|
73
74
|
* an explicit override via `opts.target`). Single verb — the old
|
|
74
75
|
* `editing / writing / announce / claim(reason, opts)` overloads
|
|
75
76
|
* collapsed into this one method.
|
|
76
77
|
*/
|
|
77
|
-
claim(opts?: ScopedClaimOptions):
|
|
78
|
+
claim(opts?: ScopedClaimOptions): Claim;
|
|
78
79
|
onRejected(listener: Parameters<ClaimStream['onRejected']>[0]): () => void;
|
|
79
80
|
onChange(listener: () => void): () => void;
|
|
80
81
|
}
|
|
@@ -84,14 +85,14 @@ export interface ParticipantFocusOptions {
|
|
|
84
85
|
}
|
|
85
86
|
export interface JoinedParticipant {
|
|
86
87
|
/** Current exact thing this participant is reading/editing. */
|
|
87
|
-
readonly target:
|
|
88
|
-
readonly focusTarget:
|
|
88
|
+
readonly target: ClaimTarget | null;
|
|
89
|
+
readonly focusTarget: ClaimTarget | null;
|
|
89
90
|
/** Transport scopes this participant is joined to for visibility/fan-out. */
|
|
90
91
|
readonly syncGroups: readonly string[];
|
|
91
92
|
readonly presence: ScopedPresence;
|
|
92
93
|
readonly claims: ScopedClaims;
|
|
93
94
|
readonly peers: ReadonlyArray<Peer>;
|
|
94
|
-
readonly activeClaims: ReadonlyArray<
|
|
95
|
+
readonly activeClaims: ReadonlyArray<Claim>;
|
|
95
96
|
focus(target: PresenceTarget, options?: ParticipantFocusOptions): JoinedParticipant;
|
|
96
97
|
leave(): void;
|
|
97
98
|
[Symbol.asyncDispose](): Promise<void>;
|
|
@@ -104,11 +105,11 @@ export interface ParticipantManagerConfig {
|
|
|
104
105
|
readonly ready: () => Promise<void>;
|
|
105
106
|
readonly getTransport: () => SyncWebSocket | null;
|
|
106
107
|
readonly presence: PresenceStream;
|
|
107
|
-
readonly claims:
|
|
108
|
+
readonly claims: AttachableClaimStream;
|
|
108
109
|
readonly schema?: Schema<SchemaRecord>;
|
|
109
110
|
}
|
|
110
111
|
export declare function createParticipantManager(config: ParticipantManagerConfig): ParticipantManager;
|
|
111
112
|
export declare function resolveParticipantSyncGroups(scope: ParticipantScope | undefined, schema?: Schema<SchemaRecord>): string[];
|
|
112
|
-
export declare function syncGroupFromEntityRef(ref:
|
|
113
|
+
export declare function syncGroupFromEntityRef(ref: ClaimTarget, schema?: Schema<SchemaRecord>): string;
|
|
113
114
|
export declare function parseParticipantTtlSeconds(value: number | string | null | undefined): number | undefined;
|
|
114
115
|
export declare function createParticipantClaimId(): string;
|
|
@@ -220,20 +220,20 @@ function createJoinedParticipant(args) {
|
|
|
220
220
|
ownHandles.add(handle);
|
|
221
221
|
return {
|
|
222
222
|
object: 'claim',
|
|
223
|
-
|
|
223
|
+
id: handle.id,
|
|
224
224
|
reason: handle.reason,
|
|
225
225
|
target: handle.target,
|
|
226
226
|
async release() {
|
|
227
227
|
ownHandles.delete(handle);
|
|
228
|
-
await handle.release();
|
|
228
|
+
await handle.release?.();
|
|
229
229
|
},
|
|
230
230
|
revoke() {
|
|
231
231
|
ownHandles.delete(handle);
|
|
232
|
-
handle.revoke();
|
|
232
|
+
handle.revoke?.();
|
|
233
233
|
},
|
|
234
234
|
[Symbol.asyncDispose]: async () => {
|
|
235
235
|
ownHandles.delete(handle);
|
|
236
|
-
await handle[Symbol.asyncDispose]();
|
|
236
|
+
await handle[Symbol.asyncDispose]?.();
|
|
237
237
|
},
|
|
238
238
|
};
|
|
239
239
|
};
|
|
@@ -262,7 +262,7 @@ function createJoinedParticipant(args) {
|
|
|
262
262
|
return;
|
|
263
263
|
left = true;
|
|
264
264
|
for (const handle of Array.from(ownHandles)) {
|
|
265
|
-
handle.revoke();
|
|
265
|
+
handle.revoke?.();
|
|
266
266
|
ownHandles.delete(handle);
|
|
267
267
|
}
|
|
268
268
|
args.presence.idle();
|
|
@@ -125,6 +125,7 @@ interface TransactionQueueConfig {
|
|
|
125
125
|
}
|
|
126
126
|
export declare class TransactionQueue extends EventEmitter {
|
|
127
127
|
private store;
|
|
128
|
+
private lastPermanentErrorSig?;
|
|
128
129
|
private _mutationExecutor;
|
|
129
130
|
private get mutationExecutor();
|
|
130
131
|
private executionQueue;
|
|
@@ -199,6 +199,12 @@ class TransactionStore {
|
|
|
199
199
|
}
|
|
200
200
|
export class TransactionQueue extends EventEmitter {
|
|
201
201
|
store = new TransactionStore();
|
|
202
|
+
// Signature of the last permanent-error we logged at `warn`. A `create`
|
|
203
|
+
// whose id already exists (`unique_violation`) is a permanent rejection
|
|
204
|
+
// that the offline queue re-drives on every reconnect/bootstrap — without
|
|
205
|
+
// this, the identical cause prints on a loop. We log the first occurrence
|
|
206
|
+
// and demote exact repeats to `debug`.
|
|
207
|
+
lastPermanentErrorSig;
|
|
202
208
|
// Per-instance executor binding. Set by `setMutationExecutor(...)` from the
|
|
203
209
|
// owning Ablo right after construction. Falls back to `getContext()` only
|
|
204
210
|
// when unset (preserves legacy tests / SDK consumers that haven't migrated).
|
|
@@ -470,6 +476,9 @@ export class TransactionQueue extends EventEmitter {
|
|
|
470
476
|
// promise always settles, regardless of which path produced the
|
|
471
477
|
// terminal state.
|
|
472
478
|
this.on('transaction:completed', (tx) => {
|
|
479
|
+
// Any successful write clears the permanent-error dedup, so a genuine
|
|
480
|
+
// recurrence after recovery warns again instead of staying demoted.
|
|
481
|
+
this.lastPermanentErrorSig = undefined;
|
|
473
482
|
const r = this.confirmationResolvers.get(tx.id);
|
|
474
483
|
if (r) {
|
|
475
484
|
this.confirmationResolvers.delete(tx.id);
|
|
@@ -1254,7 +1263,12 @@ export class TransactionQueue extends EventEmitter {
|
|
|
1254
1263
|
return undefined;
|
|
1255
1264
|
};
|
|
1256
1265
|
const diagnostics = readDiagnostics(error);
|
|
1257
|
-
|
|
1266
|
+
// Mechanic-level breadcrumb. Every batch rejection — transient
|
|
1267
|
+
// (reconnect retries it) or permanent (`handleFailure` logs the
|
|
1268
|
+
// authoritative `warn` with the same typed cause) — passes
|
|
1269
|
+
// through here. Logging it at `warn` made one rejected write
|
|
1270
|
+
// surface three identical dumps; keep it at `debug`.
|
|
1271
|
+
getContext().logger.debug('[TransactionQueue] Batch commit rejected', {
|
|
1258
1272
|
batchSize: batchOps.length,
|
|
1259
1273
|
models: batchOps.map(({ op }) => `${op.type}:${op.model}`),
|
|
1260
1274
|
errorType: abloErr?.type ?? error?.name,
|
|
@@ -1768,7 +1782,7 @@ export class TransactionQueue extends EventEmitter {
|
|
|
1768
1782
|
// expiry (AbloAuthenticationError).
|
|
1769
1783
|
try {
|
|
1770
1784
|
const abloErr = error instanceof AbloError ? error : undefined;
|
|
1771
|
-
|
|
1785
|
+
const details = {
|
|
1772
1786
|
txId: transaction.id.slice(0, 8),
|
|
1773
1787
|
type: transaction.type,
|
|
1774
1788
|
model: transaction.modelName,
|
|
@@ -1779,7 +1793,30 @@ export class TransactionQueue extends EventEmitter {
|
|
|
1779
1793
|
requestId: abloErr?.requestId,
|
|
1780
1794
|
message: error?.message,
|
|
1781
1795
|
inputKeys: transaction.data ? Object.keys(transaction.data) : undefined,
|
|
1782
|
-
}
|
|
1796
|
+
};
|
|
1797
|
+
// A `create` whose id already exists is the benign idempotency case:
|
|
1798
|
+
// "this row is already there." It's the least alarming permanent
|
|
1799
|
+
// error, so it doesn't warrant a `warn` — `info` keeps it visible
|
|
1800
|
+
// without crying wolf. Everything else (FK violation, auth expiry,
|
|
1801
|
+
// server 500) stays at `warn`.
|
|
1802
|
+
const isBenignIdempotent = transaction.type === 'create' &&
|
|
1803
|
+
(abloErr?.code === 'unique_violation' ||
|
|
1804
|
+
abloErr?.type === 'AbloIdempotencyError');
|
|
1805
|
+
// Demote exact repeats (same write rejected for the same reason on
|
|
1806
|
+
// each reconnect replay) to `debug` so the loop logs once.
|
|
1807
|
+
const sig = `${details.type}:${details.model}:${details.modelId}:${details.errorCode ?? details.errorType}`;
|
|
1808
|
+
const isRepeat = sig === this.lastPermanentErrorSig;
|
|
1809
|
+
this.lastPermanentErrorSig = sig;
|
|
1810
|
+
const logger = getContext().logger;
|
|
1811
|
+
if (isRepeat) {
|
|
1812
|
+
logger.debug('[TransactionQueue] Permanent error - rolling back (repeat)', details);
|
|
1813
|
+
}
|
|
1814
|
+
else if (isBenignIdempotent) {
|
|
1815
|
+
logger.info('[TransactionQueue] Write skipped — row already exists', details);
|
|
1816
|
+
}
|
|
1817
|
+
else {
|
|
1818
|
+
logger.warn('[TransactionQueue] Permanent error - rolling back', details);
|
|
1819
|
+
}
|
|
1783
1820
|
}
|
|
1784
1821
|
catch { }
|
|
1785
1822
|
// Mark as failed immediately and rollback
|