v_chat_core 0.1.0-rc.4
v_chat_core: ^0.1.0-rc.4 copied to clipboard
Pure Dart runtime foundation for the V Chat end-user SDK.
v_chat_core #
Release candidate 0.1.0-rc.4 is the pure-Dart runtime package in the coordinated V Chat Flutter
SDK. Add it with dart pub add v_chat_core. The package supports Dart >=3.10.0-0 <4.0.0.
See the repository support, security, and privacy policies before production adoption.
What this package provides #
Pure Dart foundations for V Chat:
- app-user token lifecycle
- Dio REST transport
- WebSocket transport
- reconnect supervision
- scoped offline ports
- stable exceptions and redacted logging
- lifecycle-safe client shell
- typed current-user device registration list/upsert/revoke
- typed current-user push preferences and content-light notification routing
- typed capability-gated privacy export/delete jobs and ephemeral download metadata
- typed app-user channel create/get/update
- scoped exact channel cache, bounded local pages, and cache-then-network snapshots
- typed active current-user membership projection and scoped offline reconciliation
- typed top-level message lifecycle and confirmed exact-scope history
- typed attachment lifecycle, isolated signed-target transfers, and scoped metadata-only cache
- typed conversation/read/unread projection and confirmed exact-scope offline load
- authenticated realtime ticket, ready, close mapping, and bounded reconnect lifecycle
- acknowledged authorized channel subscriptions and connection-local handles
- typed durable channel/private events with cache-before-callback processing
- strict realtime frame/command/capacity limits and redacted pressure snapshots
- immutable client lifecycle snapshots and stable channel/conversation/thread resource facades
Installation and composition #
Install the core package directly only when you want to provide your own OfflineStore and
platform integration:
dart pub add v_chat_core
Flutter applications should normally use v_chat_flutter; pure-Dart applications can compose this
package with v_chat_persistence_drift or another implementation of the released offline ports.
final class BackendTokenProvider implements TokenProvider {
BackendTokenProvider(this.fetchToken);
final Future<AuthToken> Function({required bool forceRefresh}) fetchToken;
@override
Future<AuthToken> getToken({required bool forceRefresh}) {
return fetchToken(forceRefresh: forceRefresh);
}
}
final client = VChatClient(
options: VChatClientOptions(
appId: '00000000-0000-4000-8000-000000000001',
apiBaseUri: Uri.parse('https://api.example.com'),
),
tokenProvider: BackendTokenProvider(loadCurrentUserVChatToken),
offlineStore: offlineStore,
);
try {
await client.connectUser(userId: currentUserId);
await client.conversations.refresh();
} finally {
await client.disconnect();
await client.dispose();
}
The token provider calls your authenticated customer backend; it never contains an application
credential. Construct one client per logical app-user session. VChatClient assumes ownership of
the supplied offline store and closes it from dispose().
VChatClientOptions requires a UUID appId and HTTPS apiBaseUri. Plain HTTP is rejected unless
the caller explicitly enables insecure transport for a loopback development endpoint. Timeouts are
bounded, eligible reads retry once by default unless disabled, and mutations are never
automatically retried.
Security and package boundaries #
Push integration consumes only the released FTR-011 device operations and FTR-027 preference and content-light payload contracts. Provider configuration, native permission prompts, token discovery, presentation, and delivery execution remain host/server concerns. The SDK validates an exact six-field notification route and durably deduplicates it by connected app-user scope before publication.
Privacy lifecycle integration consumes only the four released FTR-028 current-app-user operations.
It requires the opt-in self_data_lifecycle token capability, exposes no dashboard retention
administration, persists no job or signed URL, and invalidates the SDK session after self-delete.
Client and resource facades #
The additive stable facade binds immutable resource identities while retaining the flat
VChatClient methods:
final VChatChannelHandle channel = client.channel(identity);
final VChatConversationHandle conversation = channel.conversation;
final VChatThreadHandle thread = channel.thread(rootMessageId);
final lifecycle = client.snapshots.listen(renderClientState);
final cachedConversations = client.conversations.snapshots(limit: 25).listen(
renderCachedConversations,
);
try {
await client.conversations.refresh();
final current = await channel.fetch();
final replies = await thread.fetch(limit: 25);
} finally {
await cachedConversations.cancel();
await lifecycle.cancel();
}
Resource interfaces delegate to the same released validation, authorization, cancellation, cache, and realtime code as the flat methods. Creating a handle performs no I/O. Cached conversation streams use one validated app-user scope, start no network request, and close on disconnect, disposal, or user switching.
Channels and memberships #
Channel lifecycle consumes only the released FTR-012 app-user operations:
final identity = VChatChannelIdentity(
channelTypeKey: 'messaging',
channelId: 'support-123',
);
final created = await client.createChannel(
identity: identity,
request: VChatChannelCreateRequest(
displayName: const VChatOptionalValue<String>.value('Support'),
),
);
final current = await client.getChannel(identity: identity);
final updated = await client.updateChannel(
identity: identity,
request: VChatChannelUpdateRequest(
expectedVersion: current.channel.version,
displayName: const VChatOptionalValue<String>.value('Priority support'),
),
);
final cachedPage = await client.queryCachedChannels(
channelTypeKey: 'messaging',
limit: 25,
);
await for (final snapshot in client.watchChannel(identity: identity)) {
renderChannel(snapshot);
}
watchChannel emits an optional cached snapshot and one post-response reconciliation snapshot, then
closes. A lower server version retains the existing cache source/staleness metadata. Cached pages
are local presentation state and never imply server completeness or current authorization.
The server's remote queryChannels operation requires an app-server credential and is deliberately
not exposed by this end-user package. The released channel response also has no capability field, so
the SDK does not infer one.
The membership projection uses only the released app-user conversation query:
final page = await client.queryCurrentUserMemberships(limit: 25);
final next = page.nextCursor == null
? null
: await client.queryCurrentUserMemberships(
limit: page.nextCursor!.limit,
cursor: page.nextCursor,
);
final refreshed = await client.refreshCurrentUserMemberships();
final cached = await client.queryCachedCurrentUserMemberships(limit: 25);
refreshCurrentUserMemberships traverses at most 500 rows and deletes missing cached rows only
after a terminal fresh traversal. Membership add/update/remove and roster operations remain on the
customer backend; opaque cursors are never persisted by the SDK.
Messages, replies, and attachments #
Messages use the released FTR-014 top-level operations and FTR-023 direct-thread operations:
final sent = await client.sendMessage(
channel: identity,
request: VChatMessageSendRequest(text: 'Hello'),
);
final page = await client.queryMessages(channel: identity, limit: 25);
final updated = await client.updateMessage(
channel: identity,
messageId: sent.message.messageId,
request: VChatMessageUpdateRequest(
expectedVersion: sent.message.version,
text: const VChatOptionalValue<String>.value('Updated'),
),
);
final cached = await client.queryCachedMessages(channel: identity, limit: 25);
await client.deleteMessage(
channel: identity,
messageId: updated.message.messageId,
);
final reply = await client.sendMessageReply(
channel: identity,
rootMessageId: sent.message.messageId,
request: VChatMessageSendRequest(text: 'Direct reply'),
);
final thread = await client.getMessageThread(
channel: identity,
rootMessageId: sent.message.messageId,
limit: 25,
);
final cachedThread = await client.getCachedMessageThread(
channel: identity,
rootMessageId: sent.message.messageId,
);
await client.markMessageThreadRead(
channel: identity,
rootMessageId: sent.message.messageId,
replyMessageId: reply.message.messageId,
);
Only confirmed server representations and tombstones are durable. Remote cursors, pending message content, and mutation commands are not persisted or automatically replayed. Ordinary message history contains top-level messages only; direct replies are stored under their root and become visible through a coherent completed thread snapshot. Reactions and attachments remain separate resource slices and are not inferred onto message snapshots.
Attachments use only the five released FTR-026 app-user operations:
final intent = await client.attachments.createUploadIntent(
request: VChatAttachmentUploadIntentRequest(
attachmentId: callerGeneratedUuidV4,
kind: VChatAttachmentKind.file,
fileName: 'report.pdf',
mimeType: 'application/pdf',
sizeBytes: sourceLength,
),
);
final upload = intent.upload;
if (upload == null) {
// An idempotent replay can return an already completed attachment.
renderAttachment(intent.attachment);
} else {
await client.attachments.upload(
target: upload,
source: source,
contentLength: sourceLength,
);
final completed = await client.attachments.completeUpload(
attachmentId: intent.attachment.attachmentId,
);
renderAttachment(completed.attachment);
}
The source and destination are consumed once. Signed URLs and headers use a separate unauthenticated transport and never enter cache, logs, errors, or retry state. Drift stores only validated metadata and deletion tombstones; attachment bytes and offline mutations are excluded.
Conversations and read state #
Conversation and unread state use only the released FTR-016 app-user operations:
final conversations = await client.queryCurrentUserConversations(limit: 25);
final unread = await client.getCurrentUserUnreadSummary();
if (conversations.items.isNotEmpty) {
final conversation = conversations.items.first;
final latest = conversation.latestMessage;
if (latest != null) {
await client.markChannelRead(
channel: conversation.channel.identity,
readThroughMessageId: latest.messageId,
);
await client.queryCurrentUserConversations(limit: 25);
await client.getCurrentUserUnreadSummary();
}
}
final cached = await client.queryCachedCurrentUserConversations(limit: 25);
final cachedUnread = await client.getCachedCurrentUserUnreadSummary();
The conversation method is the complete SDK-FL-005 query transport; membership callers are projected from it. Remote cursors are never persisted. Mark-read is online-only, accepts an exact observed latest-message target, and never fabricates aggregate cache deltas or automatically retries an ambiguous mutation. Realtime replacement remains SDK-FL-010.
Realtime lifecycle #
Realtime connection lifecycle uses only the released FTR-017/FTR-021 contract:
final info = await client.connectRealtime();
renderConnectionId(info.connectionId);
await client.suspendRealtimeIfConnected();
await client.resumeRealtimeIfSuspended();
await client.disconnectRealtime();
connectRealtime first requires connectUser, creates a fresh one-use ticket, offers only
vchat.realtime.v1, and returns only after a strict connection.ready envelope. Native Ping/Pong
is handled by the WebSocket runtime. Automatic reconnect uses fresh authentication and a fresh
ticket with bounded jittered backoff; protocol/policy, connection-limit, and slow-consumer closes
are terminal for that automatic cycle. No socket, ticket, connection, heartbeat, or retry state is
persisted.
Application backgrounding may suspend ticketing, connecting, recovering, connected, or
reconnecting work. If suspension wins before ready metadata can be returned, connectRealtime
fails with the stable request_cancelled lifecycle code rather than fabricating a protocol error.
Foreground resume still requires a lifecycle that was actually suspended.
Subscriptions and durable events #
Authorized channel subscriptions use only the released FTR-018/FTR-021 envelopes:
final subscription = await client.subscribeChannel(identity);
await for (final state in subscription.states) {
renderSubscriptionState(state);
}
await subscription.unsubscribe();
The handle becomes active only after a strict matching acknowledgement. Concurrent calls for one
channel share the same handle; authority changes produce only revoked; cancellation compensates
an uncertain activation; and fresh reconnect restoration rechecks authorization without claiming
missed-event continuity. Request IDs, handles, cutover sequences, and restoration intent remain
memory-only.
The five released FTR-019 event names are decoded into closed typed models. Accepted channel updates and top-level message create/update/delete events are committed to the scoped cache before global and exact-channel callbacks. Current-user conversation and unread dimensions advance independently before private callbacks. Duplicate IDs/positions are bounded in memory; stale subscription conflicts stop delivery for that handle; compatible future v1 names and invalid payloads enter bounded redacted quarantine. Register listeners before the related mutation and cancel every returned registration:
final stopGlobal = client.onRealtimeEvent(renderEvent);
final stopPrivate = client.onUserMessagingState(renderCurrentUserState);
final stopDiagnostic = client.onRealtimeProcessingDiagnostic(renderSafeIssue);
final subscription = await client.subscribeChannel(identity);
final stopExact = subscription.onEvent(renderExactChannelEvent);
// Dispose registrations before the acknowledged server subscription.
stopExact();
stopGlobal();
stopPrivate();
stopDiagnostic();
await subscription.unsubscribe();
Reconnect recovery #
FTR-020 reconnect reconciliation is opt-in and leaves the existing connection path unchanged:
final recovery = VChatRealtimeRecoveryOptions(
// Persist this non-secret ID per installation/profile to recover after reload.
clientInstanceId: 'client_abcdefghijklmnopqrstuv',
);
final stop = client.realtimeRecoveryResults.listen((result) {
renderRecoveryResult(result);
});
try {
final info = await client.connectRealtime(recovery: recovery);
renderConnectionId(info.connectionId);
} finally {
await stop.cancel();
}
Each accepted durable channel event commits its entity and
realtime.v1.<clientInstanceId>.<cid> position atomically before callbacks. A fresh socket sends
at most one 100-channel/28-KiB manifest before ordinary commands. Safe replay completes before the
public state becomes connected; unsafe positions use one acknowledged channel cutover, the
authoritative channel plus first 25 messages, and a 64-event/192-KiB live buffer. The separate
current-user repair refreshes unread state and the first 25 conversations, then drains buffered
private events. channel.subscription_revoked is terminal and neutral. Recovery never queues or
replays REST mutations, never stores tokens or event payloads in checkpoints, and can be reset
explicitly with resetRealtimeRecovery() while the configured app-user session is active.
clientInstanceId must be client_ plus 22 base64url characters. Omitting it generates an
in-memory isolated ID; supply and persist one only when cross-reload continuity is intended.
realtimeRecoveryStarts, realtimeRecoveryResults, and the recovering connection state expose
bounded lifecycle information without leaking credentials or message content.
Pressure limits, reactions, presence, and typing #
Realtime pressure is bounded by the released FTR-021 contract. Inspect only redacted counts and encoded byte totals; never use them as fleet-capacity or billing evidence:
final pressure = client.realtimePressureSnapshot;
if (pressure.isProcessing || pressure.retainedFrames > 0) {
renderRealtimePressure(
queuedFrames: pressure.queuedEventFrames,
queuedBytes: pressure.queuedEventBytes,
);
}
The production transport rejects server frames above 32 KiB and invalid ready limits. Ordinary
subscription commands stay within 4 KiB, one connection owns at most 100 active/pending channel
subscriptions and eight in-flight commands, and durable processing yields after eight frames or
32 KiB. A local processing overflow closes only that socket as terminal 4411 slow_consumer;
correct the consumer, refresh state, then reconnect explicitly.
Reply create/update/delete events update the scoped reply entity and root thread summary before
listeners run. onUserThreadState exposes only the authenticated user's private thread state after
its independent version fences and offline commit. Unknown future reply events remain forward-safe
and trigger the existing refresh diagnostic path.
addMessageReaction and removeMessageReaction mutate only the connected user's identity and are
safe for one bounded in-memory retry; neither is written to the offline command queue.
listMessageReactionUsers returns a bounded exact-type cursor page and never persists reactor
membership. Full messages expose a VChatReactionSummary whose independent version is merged
separately from the message edit version. Ordered reaction events update that summary before
listeners run; a forward summary-version gap marks the channel unsafe for authoritative refresh.
An active VChatChannelSubscription exposes connection-local presence and typing
controllers. Presence watches atomically replace 1..50 exact user IDs after acknowledgement and
expire to the client-only unknown state when their maximum 120-second freshness cannot be
proved. Typing supports the main channel and exact one-depth thread contexts, with at most four
watches and two emitted contexts per connection. keystroke sends at most once per four seconds
while a two-second local idle timer sends stop. Disconnect clears authoritative activity state
and typing emissions; recovery restores watches after durable repair but never replays typing.
No presence, typing, timer, or watch state is stored in Drift.
Request cancellation and errors #
Relevant REST methods accept VChatRequestOptions with a bounded timeout, a validated request ID,
and cooperative cancellation:
final cancellation = VChatCancellationSource();
final request = client.queryCurrentUserConversations(
limit: 25,
options: VChatRequestOptions(
requestId: 'req_conversations_001',
timeout: const Duration(seconds: 20),
cancellationToken: cancellation.token,
),
);
// For example, when the owning screen is replaced.
cancellation.cancel();
try {
await request;
} on VChatException catch (error) {
renderSafeChatError(
kind: error.kind,
code: error.code,
retriable: error.retriable,
retryAfter: error.retryAfter,
requestId: error.requestId,
);
}
The stable error hierarchy includes configuration, validation, authentication, API, transport,
protocol, storage, and lifecycle exceptions. retriable means a future attempt may be useful; it
does not authorize replaying a mutation. After an ambiguous mutation failure, refresh the exact
resource and reconcile before deliberately retrying.
The default logger is a no-op. Custom VChatLogger implementations must never record tokens,
application credentials, message text, attachment signed URLs or headers, arbitrary payloads, or
private notification content.
Further examples #
See the compile-checked Flutter
examples/v_chat_example
sources for channel, realtime, subscription, event, recovery, thread, reaction, presence, and typing
flows. They demonstrate Drift composition, customer-backend token loading, typed lifecycle, app
background/foreground binding, neutral not-found, version-conflict, offline, cancellation, and
disposal handling.