@abloatai/humans 0.59.1 → 0.60.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/README.md +1 -1
- package/dist/Ablo.d.ts +2 -10
- package/dist/Ablo.js +0 -1
- package/dist/client.d.ts +1 -48
- package/dist/local/BaseSyncedStore.d.ts +6 -8
- package/dist/local/BaseSyncedStore.js +4 -9
- package/dist/local/Model.js +2 -2
- package/dist/local/SyncClient.d.ts +3 -3
- package/dist/local/SyncClient.js +45 -11
- package/dist/local/client/createModelOperations.d.ts +3 -27
- package/dist/local/client/createModelOperations.js +14 -17
- package/dist/local/client/options.d.ts +14 -39
- package/dist/local/client/reactiveEngine.d.ts +3 -9
- package/dist/local/client/reactiveEngine.js +6 -151
- package/dist/local/client/storeLifecycle.js +5 -1
- package/dist/local/storeContract.d.ts +5 -5
- package/dist/local/sync/credentialLifecycle.d.ts +4 -5
- package/dist/local/sync/credentialLifecycle.js +4 -5
- package/dist/local/sync/deltaPipeline.d.ts +11 -3
- package/dist/local/sync/deltaPipeline.js +27 -80
- package/dist/local/sync/scopeGroups.d.ts +11 -0
- package/dist/local/sync/scopeGroups.js +75 -0
- package/dist/local/sync/wsFrameHandlers.d.ts +1 -1
- package/dist/local/transactions/mutations/failureHandling.js +9 -81
- package/dist/local/transactions/mutations/failureReporting.d.ts +10 -0
- package/dist/local/transactions/mutations/failureReporting.js +67 -0
- package/dist/react/AbloProvider.d.ts +11 -86
- package/dist/react/AbloProvider.js +10 -162
- package/dist/react.d.ts +1 -1
- package/dist/react.js +1 -1
- package/dist/surface.d.ts +2 -2
- package/dist/surface.js +1 -4
- package/package.json +3 -2
- package/src/Ablo.ts +5 -17
- package/src/client.ts +0 -51
- package/src/local/BaseSyncedStore.ts +11 -17
- package/src/local/Model.ts +2 -2
- package/src/local/SyncClient.ts +63 -15
- package/src/local/client/createModelOperations.ts +23 -60
- package/src/local/client/options.ts +20 -43
- package/src/local/client/reactiveEngine.ts +7 -179
- package/src/local/client/storeLifecycle.ts +6 -1
- package/src/local/storeContract.ts +5 -5
- package/src/local/sync/SyncWebSocket.ts +1 -1
- package/src/local/sync/credentialLifecycle.ts +4 -5
- package/src/local/sync/deltaPipeline.ts +26 -82
- package/src/local/sync/scopeGroups.ts +91 -0
- package/src/local/sync/wsFrameHandlers.ts +0 -1
- package/src/local/transactions/mutations/failureHandling.ts +73 -132
- package/src/local/transactions/mutations/failureReporting.ts +93 -0
- package/src/react/AbloProvider.tsx +17 -249
- package/src/react.ts +1 -5
- package/src/surface.ts +1 -4
- package/dist/local/sync/participants.d.ts +0 -132
- package/dist/local/sync/participants.js +0 -342
- package/src/local/sync/participants.ts +0 -564
|
@@ -2,8 +2,8 @@ import type { RuntimeContext } from '../../RuntimeContext.js';
|
|
|
2
2
|
import type { MutationQueueConfig } from './MutationQueue.js';
|
|
3
3
|
import type { QueuedMutation } from './commitPayload.js';
|
|
4
4
|
import type { MutationStore } from './MutationStore.js';
|
|
5
|
-
import { AbloError } from '@abloatai/transaction/errors';
|
|
6
5
|
import { extractStatusCode } from './commitPayload.js';
|
|
6
|
+
import { reportPermanentMutationFailure } from './failureReporting.js';
|
|
7
7
|
|
|
8
8
|
export interface FailureHandlingContext {
|
|
9
9
|
readonly runtime: RuntimeContext;
|
|
@@ -13,7 +13,11 @@ export interface FailureHandlingContext {
|
|
|
13
13
|
>;
|
|
14
14
|
readonly store: MutationStore;
|
|
15
15
|
readonly isPermanentError: (error: Error) => boolean;
|
|
16
|
-
readonly rollbackOptimistic: (
|
|
16
|
+
readonly rollbackOptimistic: (
|
|
17
|
+
transaction: QueuedMutation,
|
|
18
|
+
reason: string,
|
|
19
|
+
error?: Error,
|
|
20
|
+
) => Promise<void>;
|
|
17
21
|
readonly enqueue: (transaction: QueuedMutation) => void;
|
|
18
22
|
readonly getLastPermanentErrorSignature: () => string | undefined;
|
|
19
23
|
readonly setLastPermanentErrorSignature: (signature: string) => void;
|
|
@@ -35,138 +39,75 @@ export function transientRetryDelayMs(
|
|
|
35
39
|
return Math.floor(Math.random() * ceiling);
|
|
36
40
|
}
|
|
37
41
|
|
|
38
|
-
export async function handleFailure(
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
// A `create` whose id already exists is the benign idempotency case:
|
|
65
|
-
// "this row is already there." It's the least alarming permanent
|
|
66
|
-
// error, so it doesn't warrant a `warn` — `info` keeps it visible
|
|
67
|
-
// without crying wolf. Everything else (FK violation, auth expiry,
|
|
68
|
-
// server 500) stays at `warn`.
|
|
69
|
-
const isBenignIdempotent =
|
|
70
|
-
transaction.type === 'create' &&
|
|
71
|
-
(abloErr?.code === 'unique_violation' ||
|
|
72
|
-
abloErr?.type === 'AbloIdempotencyError');
|
|
73
|
-
|
|
74
|
-
// Demote exact repeats (same write rejected for the same reason on
|
|
75
|
-
// each reconnect replay) to `debug` so the loop logs once.
|
|
76
|
-
const sig = `${details.type}:${details.model}:${details.modelId}:${details.errorCode ?? details.errorType}`;
|
|
77
|
-
const isRepeat = sig === ctx.getLastPermanentErrorSignature();
|
|
78
|
-
ctx.setLastPermanentErrorSignature(sig);
|
|
79
|
-
|
|
80
|
-
const logger = ctx.runtime.logger;
|
|
81
|
-
|
|
82
|
-
// Two registers from one call site, split by log level (the default
|
|
83
|
-
// logger is gated at `warn`, so `debug` stays hidden unless
|
|
84
|
-
// ABLO_LOG_LEVEL=debug is set to inspect the engine):
|
|
85
|
-
// - the default-visible line speaks the application developer's
|
|
86
|
-
// language: their verb (such as `update`), their model, the typed
|
|
87
|
-
// error's own message, and the wire `code` for searching. It uses
|
|
88
|
-
// no engine jargon and prints no JSON dump, which would alarm
|
|
89
|
-
// without helping.
|
|
90
|
-
// - the forensic `details` ride a companion `debug` line for anyone
|
|
91
|
-
// debugging the engine internals.
|
|
92
|
-
const revertNote = ctx.config.enableOptimistic
|
|
93
|
-
? ' The local change was reverted.'
|
|
94
|
-
: '';
|
|
95
|
-
const reason = abloErr?.message ? ` — ${abloErr.message}` : '';
|
|
96
|
-
const code = abloErr?.code ? ` (code: ${abloErr.code})` : '';
|
|
97
|
-
const requestRef = abloErr?.requestId
|
|
98
|
-
? ` [request_id: ${abloErr.requestId}]`
|
|
99
|
-
: '';
|
|
100
|
-
// An optimistic write resolves before the server answers, so a later
|
|
101
|
-
// rejection has no caller left to return to and this log is the only
|
|
102
|
-
// place it appears. That reads to an application developer as their own
|
|
103
|
-
// save silently failing — the write showed, then vanished — and sends
|
|
104
|
-
// them into their editor instead of here. Name the subscription that
|
|
105
|
-
// hands them the same typed error, so the application can say what
|
|
106
|
-
// happened rather than only the console.
|
|
107
|
-
const channelNote = ctx.config.enableOptimistic
|
|
108
|
-
? ' To surface this in your app, subscribe with `ablo.onMutationFailure(…)`.'
|
|
109
|
-
: '';
|
|
110
|
-
const headline = `Your ${transaction.type} to "${transaction.modelName}" was not saved${reason}${code}${requestRef}.${revertNote}${channelNote}`;
|
|
111
|
-
|
|
112
|
-
if (isRepeat) {
|
|
113
|
-
// Same write rejected for the same reason on each reconnect replay —
|
|
114
|
-
// log the forensics once, stay quiet after.
|
|
115
|
-
logger.debug('write rejected again (same reason)', details);
|
|
116
|
-
} else if (isBenignIdempotent) {
|
|
117
|
-
// Already-exists on a `create` is expected on replay, not a problem.
|
|
118
|
-
logger.info(`Your ${transaction.type} to "${transaction.modelName}" was skipped — this row already exists.`);
|
|
119
|
-
logger.debug('idempotent skip — details', details);
|
|
120
|
-
} else {
|
|
121
|
-
logger.warn(headline);
|
|
122
|
-
logger.debug('write rejection — details', details);
|
|
123
|
-
}
|
|
124
|
-
} catch {}
|
|
125
|
-
|
|
126
|
-
// Mark as failed immediately and rollback
|
|
127
|
-
ctx.store.updateStatus(transaction.id, 'failed');
|
|
128
|
-
|
|
129
|
-
if (ctx.config.enableOptimistic) {
|
|
130
|
-
await ctx.rollbackOptimistic(transaction, 'permanent_error', error);
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
ctx.emit('transaction:failed', { transaction, error, permanent: true });
|
|
134
|
-
// The id-suffixed event is what the awaited model-write promise listens
|
|
135
|
-
// on through `waitForConfirmation` — without it a permanently
|
|
136
|
-
// rejected write left the caller's promise hanging forever.
|
|
137
|
-
ctx.emit(`transaction:failed:${transaction.id}`, { error });
|
|
138
|
-
return;
|
|
42
|
+
export async function handleFailure(
|
|
43
|
+
ctx: FailureHandlingContext,
|
|
44
|
+
transaction: QueuedMutation,
|
|
45
|
+
error: Error,
|
|
46
|
+
): Promise<void> {
|
|
47
|
+
transaction.attempts++;
|
|
48
|
+
|
|
49
|
+
// Check whether this is a permanent error that should not be retried.
|
|
50
|
+
if (ctx.isPermanentError(error)) {
|
|
51
|
+
reportPermanentMutationFailure(
|
|
52
|
+
{
|
|
53
|
+
runtime: ctx.runtime,
|
|
54
|
+
enableOptimistic: ctx.config.enableOptimistic,
|
|
55
|
+
getLastPermanentErrorSignature: ctx.getLastPermanentErrorSignature,
|
|
56
|
+
setLastPermanentErrorSignature: ctx.setLastPermanentErrorSignature,
|
|
57
|
+
},
|
|
58
|
+
transaction,
|
|
59
|
+
error,
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
// Mark as failed immediately and rollback
|
|
63
|
+
ctx.store.updateStatus(transaction.id, 'failed');
|
|
64
|
+
|
|
65
|
+
if (ctx.config.enableOptimistic) {
|
|
66
|
+
await ctx.rollbackOptimistic(transaction, 'permanent_error', error);
|
|
139
67
|
}
|
|
140
68
|
|
|
141
|
-
transaction
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
// (429/503) use a longer base than other transient errors. The re-enqueue
|
|
149
|
-
// is scheduled rather than awaited, so one backing-off transaction cannot
|
|
150
|
-
// stall unrelated commits.
|
|
151
|
-
const delay = transientRetryDelayMs(error, transaction.attempts, ctx.config.retryBackoff);
|
|
152
|
-
|
|
153
|
-
ctx.store.updateStatus(transaction.id, 'pending');
|
|
154
|
-
setTimeout(() => {
|
|
155
|
-
// The queue may have shut down or the tx may have been settled
|
|
156
|
-
// (e.g. delta-confirmed) while we backed off.
|
|
157
|
-
if (ctx.store.get(transaction.id)?.status !== 'pending') return;
|
|
158
|
-
ctx.enqueue(transaction);
|
|
159
|
-
}, delay);
|
|
160
|
-
} else {
|
|
161
|
-
// Mark as failed and rollback
|
|
162
|
-
ctx.store.updateStatus(transaction.id, 'failed');
|
|
163
|
-
|
|
164
|
-
if (ctx.config.enableOptimistic) {
|
|
165
|
-
await ctx.rollbackOptimistic(transaction, 'max_retries_exhausted', error);
|
|
166
|
-
}
|
|
69
|
+
ctx.emit('transaction:failed', { transaction, error, permanent: true });
|
|
70
|
+
// The id-suffixed event is what the awaited model-write promise listens
|
|
71
|
+
// on through `waitForConfirmation` — without it a permanently
|
|
72
|
+
// rejected write left the caller's promise hanging forever.
|
|
73
|
+
ctx.emit(`transaction:failed:${transaction.id}`, { error });
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
167
76
|
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
77
|
+
transaction.firstTransientFailureAt ??= Date.now();
|
|
78
|
+
const insideAvailabilityWindow =
|
|
79
|
+
Date.now() - transaction.firstTransientFailureAt <
|
|
80
|
+
ctx.config.availabilityRetryWindowMs;
|
|
81
|
+
|
|
82
|
+
if (transaction.attempts < ctx.config.maxRetries || insideAvailabilityWindow) {
|
|
83
|
+
// Exponential backoff with full jitter on every transient retry:
|
|
84
|
+
// `sleep = random(0, min(cap, base * 2^attempt))`. Throttling responses
|
|
85
|
+
// (429/503) use a longer base than other transient errors. The re-enqueue
|
|
86
|
+
// is scheduled rather than awaited, so one backing-off transaction cannot
|
|
87
|
+
// stall unrelated commits.
|
|
88
|
+
const delay = transientRetryDelayMs(
|
|
89
|
+
error,
|
|
90
|
+
transaction.attempts,
|
|
91
|
+
ctx.config.retryBackoff,
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
ctx.store.updateStatus(transaction.id, 'pending');
|
|
95
|
+
setTimeout(() => {
|
|
96
|
+
// The queue may have shut down or the tx may have been settled
|
|
97
|
+
// (e.g. delta-confirmed) while we backed off.
|
|
98
|
+
if (ctx.store.get(transaction.id)?.status !== 'pending') return;
|
|
99
|
+
ctx.enqueue(transaction);
|
|
100
|
+
}, delay);
|
|
101
|
+
} else {
|
|
102
|
+
// Mark as failed and rollback
|
|
103
|
+
ctx.store.updateStatus(transaction.id, 'failed');
|
|
104
|
+
|
|
105
|
+
if (ctx.config.enableOptimistic) {
|
|
106
|
+
await ctx.rollbackOptimistic(transaction, 'max_retries_exhausted', error);
|
|
171
107
|
}
|
|
108
|
+
|
|
109
|
+
ctx.emit('transaction:failed', { transaction, error });
|
|
110
|
+
// Settle `waitForConfirmation` waiters (see the permanent branch above).
|
|
111
|
+
ctx.emit(`transaction:failed:${transaction.id}`, { error });
|
|
172
112
|
}
|
|
113
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { AbloError } from '@abloatai/transaction/errors';
|
|
2
|
+
import type { RuntimeContext } from '../../RuntimeContext.js';
|
|
3
|
+
import type { QueuedMutation } from './commitPayload.js';
|
|
4
|
+
|
|
5
|
+
const EXPECTED_COORDINATION_CODES = new Set([
|
|
6
|
+
'stale_context',
|
|
7
|
+
'claim_conflict',
|
|
8
|
+
'claim_queued',
|
|
9
|
+
'claim_lost',
|
|
10
|
+
'entity_claimed',
|
|
11
|
+
'model_claimed',
|
|
12
|
+
]);
|
|
13
|
+
|
|
14
|
+
export interface PermanentFailureReportingContext {
|
|
15
|
+
readonly runtime: RuntimeContext;
|
|
16
|
+
readonly enableOptimistic: boolean;
|
|
17
|
+
readonly getLastPermanentErrorSignature: () => string | undefined;
|
|
18
|
+
readonly setLastPermanentErrorSignature: (signature: string) => void;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Report a terminal rejection at a severity that matches its meaning. */
|
|
22
|
+
export function reportPermanentMutationFailure(
|
|
23
|
+
ctx: PermanentFailureReportingContext,
|
|
24
|
+
transaction: QueuedMutation,
|
|
25
|
+
error: Error,
|
|
26
|
+
): void {
|
|
27
|
+
try {
|
|
28
|
+
const abloError = error instanceof AbloError ? error : undefined;
|
|
29
|
+
const details = {
|
|
30
|
+
txId: transaction.id.slice(0, 8),
|
|
31
|
+
type: transaction.type,
|
|
32
|
+
model: transaction.modelName,
|
|
33
|
+
modelId: transaction.modelId.slice(0, 12),
|
|
34
|
+
errorType: abloError?.type ?? error.name,
|
|
35
|
+
errorCode: abloError?.code,
|
|
36
|
+
httpStatus: abloError?.httpStatus,
|
|
37
|
+
requestId: abloError?.requestId,
|
|
38
|
+
message: error.message,
|
|
39
|
+
inputKeys: transaction.data ? Object.keys(transaction.data) : undefined,
|
|
40
|
+
};
|
|
41
|
+
const signature = `${details.type}:${details.model}:${details.modelId}:${details.errorCode ?? details.errorType}`;
|
|
42
|
+
const isRepeat = signature === ctx.getLastPermanentErrorSignature();
|
|
43
|
+
ctx.setLastPermanentErrorSignature(signature);
|
|
44
|
+
|
|
45
|
+
const logger = ctx.runtime.logger;
|
|
46
|
+
if (isRepeat) {
|
|
47
|
+
logger.debug('write rejected again (same reason)', details);
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const isBenignIdempotent =
|
|
52
|
+
transaction.type === 'create' &&
|
|
53
|
+
(abloError?.code === 'unique_violation' ||
|
|
54
|
+
abloError?.type === 'AbloIdempotencyError');
|
|
55
|
+
if (isBenignIdempotent) {
|
|
56
|
+
logger.info(
|
|
57
|
+
`Your ${transaction.type} to "${transaction.modelName}" was skipped — this row already exists.`,
|
|
58
|
+
);
|
|
59
|
+
logger.debug('idempotent skip — details', details);
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (abloError?.code && EXPECTED_COORDINATION_CODES.has(abloError.code)) {
|
|
64
|
+
const reverted = ctx.enableOptimistic
|
|
65
|
+
? ' The local edit was reverted.'
|
|
66
|
+
: '';
|
|
67
|
+
const explanation =
|
|
68
|
+
abloError.code === 'stale_context'
|
|
69
|
+
? 'it changed elsewhere before this save completed'
|
|
70
|
+
: 'another participant currently owns the conflicting work';
|
|
71
|
+
logger.info(
|
|
72
|
+
`Your ${transaction.type} to "${transaction.modelName}" was not saved because ${explanation}.${reverted}`,
|
|
73
|
+
);
|
|
74
|
+
logger.debug('coordination rejection — details', details);
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const reason = abloError?.message ? ` — ${abloError.message}` : '';
|
|
79
|
+
const code = abloError?.code ? ` (code: ${abloError.code})` : '';
|
|
80
|
+
const requestReference = abloError?.requestId
|
|
81
|
+
? ` [request_id: ${abloError.requestId}]`
|
|
82
|
+
: '';
|
|
83
|
+
const reverted = ctx.enableOptimistic
|
|
84
|
+
? ' The local change was reverted.'
|
|
85
|
+
: '';
|
|
86
|
+
logger.warn(
|
|
87
|
+
`Your ${transaction.type} to "${transaction.modelName}" was not saved${reason}${code}${requestReference}.${reverted}`,
|
|
88
|
+
);
|
|
89
|
+
logger.debug('write rejection — details', details);
|
|
90
|
+
} catch {
|
|
91
|
+
// Diagnostics must never interfere with rollback and promise settlement.
|
|
92
|
+
}
|
|
93
|
+
}
|
|
@@ -12,21 +12,9 @@ import {
|
|
|
12
12
|
} from 'react';
|
|
13
13
|
import type { Schema, SchemaRecord } from '@abloatai/transaction/schema/schema';
|
|
14
14
|
import type { AbloClient as Ablo } from '../client.js';
|
|
15
|
-
import type {
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
Peer,
|
|
19
|
-
} from '@abloatai/transaction/types/streams';
|
|
20
|
-
import type {
|
|
21
|
-
EngineParticipant,
|
|
22
|
-
ParticipantScope,
|
|
23
|
-
ParticipantStatus,
|
|
24
|
-
} from '../local/sync/participants.js';
|
|
25
|
-
import {
|
|
26
|
-
createParticipantClaimId,
|
|
27
|
-
parseParticipantTtlSeconds,
|
|
28
|
-
resolveParticipantSyncGroups,
|
|
29
|
-
} from '../local/sync/participants.js';
|
|
15
|
+
import type { Peer } from '@abloatai/transaction/types/streams';
|
|
16
|
+
import type { GroupScope } from '../local/sync/scopeGroups.js';
|
|
17
|
+
import { resolveScopeGroups } from '../local/sync/scopeGroups.js';
|
|
30
18
|
import { SyncContext, type SyncStoreContract } from './context.js';
|
|
31
19
|
import { AbloInternalContext, type AbloInternalContextValue } from './internalContext.js';
|
|
32
20
|
import { AbloValidationError } from '@abloatai/transaction/errors';
|
|
@@ -42,9 +30,8 @@ import { DefaultFallback } from './DefaultFallback.js';
|
|
|
42
30
|
*
|
|
43
31
|
* - **One component, one import.** Consumers write the provider
|
|
44
32
|
* once at the root; nothing else needs to plumb the engine.
|
|
45
|
-
* - **Multiplayer is default.** React consumers
|
|
46
|
-
*
|
|
47
|
-
* available. No opt-in prop.
|
|
33
|
+
* - **Multiplayer is default.** React consumers share the client's scoped
|
|
34
|
+
* groups, presence stream, and model surface without another join step.
|
|
48
35
|
* - **Declarative props for app glue.** `preventUnsavedChanges`,
|
|
49
36
|
* `onSessionExpired`, `postBootstrap`, `resolveUsers` — each
|
|
50
37
|
* absorbs a class of integration code that previously lived in
|
|
@@ -67,7 +54,7 @@ import { DefaultFallback } from './DefaultFallback.js';
|
|
|
67
54
|
* // Build once at module scope — a new instance per render tears down the socket.
|
|
68
55
|
* // The endpoint string points at your session-mint route (`ablo init`
|
|
69
56
|
* // scaffolds it); the SDK fetches it and keeps the token fresh.
|
|
70
|
-
* const ablo = Ablo({ schema,
|
|
57
|
+
* const ablo = Ablo({ schema, session: { endpoint: '/api/ablo-session' } });
|
|
71
58
|
*
|
|
72
59
|
* <AbloProvider client={ablo}>
|
|
73
60
|
* <App />
|
|
@@ -393,229 +380,14 @@ function BootstrapGate({
|
|
|
393
380
|
}
|
|
394
381
|
|
|
395
382
|
|
|
396
|
-
export type { EngineParticipant, ParticipantScope, ParticipantStatus };
|
|
397
|
-
|
|
398
|
-
/**
|
|
399
|
-
* Options for `useJoin`. The hook reuses the engine's single
|
|
400
|
-
* WebSocket and opens a scoped claim on it when `scope` is provided:
|
|
401
|
-
* one TCP connection, N logical sub-syncgroup participants.
|
|
402
|
-
*/
|
|
403
|
-
export interface UseJoinOptions {
|
|
404
|
-
readonly scope?: ParticipantScope;
|
|
405
|
-
/**
|
|
406
|
-
* Lease TTL for the participant claim, as a compact duration (`'5m'`) or a
|
|
407
|
-
* number of seconds. The same dial and the same spelling as
|
|
408
|
-
* `ablo.<model>.join(ids, { ttl })` and every other lease in the SDK.
|
|
409
|
-
*/
|
|
410
|
-
readonly ttl?: Duration;
|
|
411
|
-
/**
|
|
412
|
-
* @deprecated Use `ttl`. Removed in 0.37.0.
|
|
413
|
-
*
|
|
414
|
-
* The same rename as on `ParticipantJoinOptions`: one lease, and the seconds
|
|
415
|
-
* spelling was the one that misled, because it accepted duration strings too.
|
|
416
|
-
*/
|
|
417
|
-
readonly ttlSeconds?: number | string | null;
|
|
418
|
-
/** Tear down + don't re-join while true. */
|
|
419
|
-
readonly paused?: boolean;
|
|
420
|
-
/**
|
|
421
|
-
* Acquire a write-claim CLAIM on the scope, in addition to read interest.
|
|
422
|
-
*
|
|
423
|
-
* Default `false`: opening a scope subscribes the connection to its deltas
|
|
424
|
-
* (read interest, via `update_subscription`) but does NOT claim it — a
|
|
425
|
-
* viewer is not a claimant. Set `true` when the participant intends to
|
|
426
|
-
* WRITE (editing a report, an agent staking work): the claim is sent so peers
|
|
427
|
-
* observe it, and the scope is pinned so it stays subscribed (never warms)
|
|
428
|
-
* for as long as the claim is held.
|
|
429
|
-
*/
|
|
430
|
-
readonly claim?: boolean;
|
|
431
|
-
/**
|
|
432
|
-
* Backfill the scope's CURRENT state into the pool on enter, in addition to
|
|
433
|
-
* tailing live changes.
|
|
434
|
-
*
|
|
435
|
-
* Default `false`: entering a scope subscribes to its FUTURE deltas only — if
|
|
436
|
-
* the scope's rows aren't already loaded, the view is empty until something
|
|
437
|
-
* changes. Set `true` when opening an entity that may not be loaded yet (a
|
|
438
|
-
* deep-linked report, a never-opened ledger) so its current rows are fetched and
|
|
439
|
-
* injected once, then kept fresh by the live tail. The fetch is single-flight
|
|
440
|
-
* and runs once per group; a failure soft-fails (the live tail still flows).
|
|
441
|
-
*/
|
|
442
|
-
readonly hydrate?: boolean;
|
|
443
|
-
}
|
|
444
|
-
|
|
445
|
-
export interface UseJoinReturn {
|
|
446
|
-
readonly participant: EngineParticipant | null;
|
|
447
|
-
/** Everyone else on the engine's sync groups (`participant.presence.others`), bridged to React. */
|
|
448
|
-
readonly peers: readonly Peer[];
|
|
449
|
-
/** Active claim claims by peers (`participant.claims.others`), bridged to React. */
|
|
450
|
-
readonly claims: readonly Claim[];
|
|
451
|
-
readonly status: ParticipantStatus;
|
|
452
|
-
readonly error: Error | null;
|
|
453
|
-
}
|
|
454
|
-
|
|
455
383
|
const EMPTY_PRESENCE: readonly Peer[] = Object.freeze([]);
|
|
456
|
-
const EMPTY_INTENTS: readonly Claim[] = Object.freeze([]);
|
|
457
384
|
|
|
458
|
-
|
|
459
|
-
* Join multiplayer for a given scope. Returns the participant and its
|
|
460
|
-
* lifecycle status. Auto-cleans up on unmount or when `paused`
|
|
461
|
-
* flips to true.
|
|
462
|
-
*
|
|
463
|
-
* `useJoin` is the React form of `ablo.<model>.join` — scope-level
|
|
464
|
-
* read-interest + presence; returns the reactive participant facade
|
|
465
|
-
* (peers/claims/status).
|
|
466
|
-
*
|
|
467
|
-
* The returned `participant` is an `EngineParticipant` — `.presence`
|
|
468
|
-
* + `.claims` only — backed by the engine's existing socket. For
|
|
469
|
-
* headless-bot patterns (a separate identity in the same browser
|
|
470
|
-
* tab), construct a second `Ablo({ kind: 'agent', ... })` directly.
|
|
471
|
-
*/
|
|
472
|
-
export function useJoin(opts: UseJoinOptions): UseJoinReturn {
|
|
473
|
-
const ctx = useContext(AbloInternalContext);
|
|
474
|
-
const engine = ctx?.engine ?? null;
|
|
475
|
-
const { paused = false } = opts;
|
|
476
|
-
// Resolve the model-form scope ({ reports: id } / refs) THROUGH the schema, so a
|
|
477
|
-
// model's declared `scope` kind is honored (typename `Report` → `report:<id>`,
|
|
478
|
-
// not the `type:id` string fallback). Schema appears once the engine is ready;
|
|
479
|
-
// until then refs resolve by convention, then re-resolve when it arrives.
|
|
480
|
-
const scopeKey = JSON.stringify(
|
|
481
|
-
resolveParticipantSyncGroups(opts.scope, engine?.schema).sort(),
|
|
482
|
-
);
|
|
483
|
-
const scopedSyncGroups = useMemo(
|
|
484
|
-
() => JSON.parse(scopeKey) as string[],
|
|
485
|
-
[scopeKey],
|
|
486
|
-
);
|
|
487
|
-
const [claimError, setClaimError] = useState<Error | null>(null);
|
|
488
|
-
const [claimConnected, setClaimConnected] = useState(false);
|
|
489
|
-
|
|
490
|
-
// Reference-stable participant facade — same socket as entity sync,
|
|
491
|
-
// so there is no `connect()` / `disconnect()` lifecycle here. The
|
|
492
|
-
// engine manages the connection; the hook is a thin window onto its
|
|
493
|
-
// already-attached presence + claim streams.
|
|
494
|
-
const participant: EngineParticipant | null = useMemo(() => {
|
|
495
|
-
if (!engine) return null;
|
|
496
|
-
return { presence: engine.presence, claims: engine.claims };
|
|
497
|
-
}, [engine]);
|
|
498
|
-
|
|
499
|
-
// Status maps to the engine's sync state. `connecting` while the
|
|
500
|
-
// engine bootstraps; `connected` once `engine.ready()` resolves and
|
|
501
|
-
// any scoped participant claim has acked; `error` if the claim
|
|
502
|
-
// fails; `disconnected` while paused or before the engine exists.
|
|
503
|
-
const syncStatus = useSyncStatus();
|
|
504
|
-
// Only a write-claim participant waits on a claim ack. A pure reader
|
|
505
|
-
// (the default) is `connected` as soon as the engine is — its read
|
|
506
|
-
// interest is fire-and-forget `update_subscription`, not a claim.
|
|
507
|
-
const needsClaim = !!opts.claim && scopedSyncGroups.length > 0;
|
|
508
|
-
const status: ParticipantStatus = paused || !engine
|
|
509
|
-
? 'disconnected'
|
|
510
|
-
: claimError
|
|
511
|
-
? 'error'
|
|
512
|
-
: syncStatus.name === 'connected'
|
|
513
|
-
? needsClaim && !claimConnected
|
|
514
|
-
? 'connecting'
|
|
515
|
-
: 'connected'
|
|
516
|
-
: syncStatus.name === 'disconnected' || syncStatus.name === 'needs-auth'
|
|
517
|
-
? 'disconnected'
|
|
518
|
-
: 'connecting';
|
|
519
|
-
const error: Error | null = claimError;
|
|
520
|
-
|
|
521
|
-
// ── Read interest (always) ───────────────────────────────────────
|
|
522
|
-
// Subscribe the connection to the scope's sync groups while mounted +
|
|
523
|
-
// connected — the area-of-interest navigation primitive. No claim, no
|
|
524
|
-
// TTL: a viewer just receives the scope's deltas. Hysteresis (warm TTL)
|
|
525
|
-
// lives in the store's SubscriptionManager, so a quick unmount/remount
|
|
526
|
-
// (tab flip) doesn't re-bootstrap.
|
|
527
|
-
useEffect(() => {
|
|
528
|
-
const scope = opts.scope;
|
|
529
|
-
if (paused || !engine || !scope || scopedSyncGroups.length === 0) return;
|
|
530
|
-
if (syncStatus.name !== 'connected') return;
|
|
531
|
-
const store = engine._store;
|
|
532
|
-
// `hydrate` backfills the scope's current state after subscribing
|
|
533
|
-
// (store handles subscribe-first ordering + single-flight). leaveScope
|
|
534
|
-
// only moves read interest; the hydrated rows stay in the pool.
|
|
535
|
-
void store.enterScope?.(scope, { hydrate: opts.hydrate });
|
|
536
|
-
return () => {
|
|
537
|
-
void store.leaveScope?.(scope);
|
|
538
|
-
};
|
|
539
|
-
// scopeKey is the stable proxy for the resolved groups; same idiom as
|
|
540
|
-
// the claim effect below.
|
|
541
|
-
}, [engine, paused, scopeKey, syncStatus.name, opts.hydrate]);
|
|
542
|
-
|
|
543
|
-
// ── Write claim (opt-in: `claim: true`) ─────────────────────────
|
|
544
|
-
// A claim is the write-claim primitive — distinct from read interest
|
|
545
|
-
// above. Only sent when the caller opts in; it makes peers observe the
|
|
546
|
-
// claim and pins the scope so it never warms while held.
|
|
547
|
-
useEffect(() => {
|
|
548
|
-
setClaimError(null);
|
|
549
|
-
setClaimConnected(false);
|
|
550
|
-
const scope = opts.scope;
|
|
551
|
-
if (paused || !engine || !opts.claim || !scope || scopedSyncGroups.length === 0)
|
|
552
|
-
return;
|
|
553
|
-
if (syncStatus.name !== 'connected') return;
|
|
554
|
-
const ws = engine._ws;
|
|
555
|
-
const store = engine._store;
|
|
556
|
-
|
|
557
|
-
let cancelled = false;
|
|
558
|
-
const claimId = createParticipantClaimId();
|
|
559
|
-
ws.sendClaim(claimId, scopedSyncGroups, {
|
|
560
|
-
// Reading the retired spelling IS the compatibility path; it goes at 0.37.0.
|
|
561
|
-
// eslint-disable-next-line @typescript-eslint/no-deprecated
|
|
562
|
-
ttlSeconds: parseParticipantTtlSeconds(opts.ttl ?? opts.ttlSeconds),
|
|
563
|
-
})
|
|
564
|
-
.then(() => {
|
|
565
|
-
if (!cancelled) setClaimConnected(true);
|
|
566
|
-
})
|
|
567
|
-
.catch((err) => {
|
|
568
|
-
if (!cancelled) {
|
|
569
|
-
setClaimError(err instanceof Error ? err : new Error(String(err)));
|
|
570
|
-
}
|
|
571
|
-
});
|
|
572
|
-
// Prominence: hold the scope subscribed for as long as the claim lives.
|
|
573
|
-
void store.pinScope?.(scope);
|
|
574
|
-
|
|
575
|
-
return () => {
|
|
576
|
-
cancelled = true;
|
|
577
|
-
ws.sendRelease(claimId);
|
|
578
|
-
void store.unpinScope?.(scope);
|
|
579
|
-
};
|
|
580
|
-
// eslint-disable-next-line @typescript-eslint/no-deprecated -- same compatibility read as above.
|
|
581
|
-
}, [engine, paused, scopeKey, syncStatus.name, opts.ttl, opts.ttlSeconds, opts.claim]);
|
|
582
|
-
|
|
583
|
-
// Bridge the engine's presence + claims streams into React state.
|
|
584
|
-
// Plain useState + useEffect is sufficient — mid-frame tearing on a
|
|
585
|
-
// peer list is harmless (users won't notice one frame of stale
|
|
586
|
-
// presence). Queries and sync status use useSyncExternalStore
|
|
587
|
-
// because transactions CAN tear visibly; presence can't.
|
|
588
|
-
const [peers, setPeers] = useState<readonly Peer[]>(EMPTY_PRESENCE);
|
|
589
|
-
const [claims, setClaims] = useState<readonly Claim[]>(EMPTY_INTENTS);
|
|
590
|
-
|
|
591
|
-
useEffect(() => {
|
|
592
|
-
if (!participant || paused) {
|
|
593
|
-
setPeers(EMPTY_PRESENCE);
|
|
594
|
-
setClaims(EMPTY_INTENTS);
|
|
595
|
-
return;
|
|
596
|
-
}
|
|
597
|
-
setPeers(participant.presence.others);
|
|
598
|
-
setClaims(participant.claims.others);
|
|
599
|
-
const unsubPresence = participant.presence.onChange(() => {
|
|
600
|
-
setPeers(participant.presence.others);
|
|
601
|
-
});
|
|
602
|
-
const unsubClaims = participant.claims.onChange(() => {
|
|
603
|
-
setClaims(participant.claims.others);
|
|
604
|
-
});
|
|
605
|
-
return () => {
|
|
606
|
-
unsubPresence();
|
|
607
|
-
unsubClaims();
|
|
608
|
-
};
|
|
609
|
-
}, [participant, paused]);
|
|
610
|
-
|
|
611
|
-
return { participant, peers, claims, status, error };
|
|
612
|
-
}
|
|
385
|
+
export type { GroupScope };
|
|
613
386
|
|
|
614
387
|
/**
|
|
615
388
|
* Read-only presence: the OTHER participants currently visible to this
|
|
616
|
-
* connection, bridged to React.
|
|
617
|
-
*
|
|
618
|
-
* it is a pure reader of the engine's already-flowing presence stream.
|
|
389
|
+
* connection, bridged to React. This is a pure reader of the engine's
|
|
390
|
+
* already-flowing presence stream; it does not mutate connection groups.
|
|
619
391
|
*
|
|
620
392
|
* Pass `scope` to narrow to the peers on that scope's sync group(s); omit
|
|
621
393
|
* it to get everyone on the engine's groups. Membership is driven entirely
|
|
@@ -623,25 +395,22 @@ export function useJoin(opts: UseJoinOptions): UseJoinReturn {
|
|
|
623
395
|
* cursor/collaboration traffic), so reading it never affects what the
|
|
624
396
|
* connection is subscribed to and can't deadlock against a gated channel.
|
|
625
397
|
*
|
|
626
|
-
* Use this to answer "is anyone else here?"
|
|
627
|
-
* broadcasts while alone
|
|
628
|
-
* read interest (scope `leave` is not reference-counted, so a second
|
|
629
|
-
* `useJoin` on the same scope would warm-drop the owner's
|
|
630
|
-
* subscription on unmount).
|
|
398
|
+
* Use this to answer "is anyone else here?", for example to suppress
|
|
399
|
+
* live-cursor broadcasts while alone.
|
|
631
400
|
*
|
|
632
401
|
* ```ts
|
|
633
402
|
* const peers = usePeers({ reports: reportId });
|
|
634
403
|
* const alone = !peers.some((p) => p.participantKind === 'user');
|
|
635
404
|
* ```
|
|
636
405
|
*/
|
|
637
|
-
export function usePeers(scope?:
|
|
406
|
+
export function usePeers(scope?: GroupScope): readonly Peer[] {
|
|
638
407
|
const ctx = useContext(AbloInternalContext);
|
|
639
408
|
const engine = ctx?.engine ?? null;
|
|
640
409
|
|
|
641
|
-
// Resolve scope → groups through the schema
|
|
410
|
+
// Resolve scope → groups through the schema.
|
|
642
411
|
// The stringified, sorted key is the stable effect dependency.
|
|
643
412
|
const scopeKey = JSON.stringify(
|
|
644
|
-
|
|
413
|
+
resolveScopeGroups(scope, engine?.schema).sort(),
|
|
645
414
|
);
|
|
646
415
|
const groups = useMemo(() => JSON.parse(scopeKey) as string[], [scopeKey]);
|
|
647
416
|
|
|
@@ -659,13 +428,12 @@ export function usePeers(scope?: ParticipantScope): readonly Peer[] {
|
|
|
659
428
|
: presence.others.filter((p) =>
|
|
660
429
|
p.syncGroups.some((g) => groups.includes(g)),
|
|
661
430
|
);
|
|
662
|
-
// Plain useState + onChange — presence changes on
|
|
431
|
+
// Plain useState + onChange — presence changes on connect/disconnect/activity
|
|
663
432
|
// only (never on cursor traffic, a separate channel), so this fires
|
|
664
|
-
// rarely; a frame of stale presence is harmless
|
|
665
|
-
// useJoin's peers bridge).
|
|
433
|
+
// rarely; a frame of stale presence is harmless.
|
|
666
434
|
setPeers(compute());
|
|
667
435
|
return presence.onChange(() => { setPeers(compute()); });
|
|
668
|
-
}, [engine, scopeKey]);
|
|
436
|
+
}, [engine, groups, scopeKey]);
|
|
669
437
|
|
|
670
438
|
return peers;
|
|
671
439
|
}
|
package/src/react.ts
CHANGED
|
@@ -10,15 +10,11 @@ export {
|
|
|
10
10
|
|
|
11
11
|
export {
|
|
12
12
|
AbloProvider,
|
|
13
|
-
useJoin,
|
|
14
13
|
usePeers,
|
|
15
14
|
useSync,
|
|
16
15
|
useSyncStore,
|
|
17
16
|
type AbloProviderProps,
|
|
18
|
-
type
|
|
19
|
-
type ParticipantStatus,
|
|
20
|
-
type UseJoinOptions,
|
|
21
|
-
type UseJoinReturn,
|
|
17
|
+
type GroupScope,
|
|
22
18
|
} from './react/AbloProvider.js';
|
|
23
19
|
|
|
24
20
|
export {
|