@gonvex/client 0.1.29 → 0.1.31

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 CHANGED
@@ -99,8 +99,54 @@ const memoryOnly = new GonvexClient(url, { sync: false });
99
99
  The default global IndexedDB budget is 100 MiB. Server-declared per-collection
100
100
  row/byte budgets still apply, and least-recently-used collections are evicted
101
101
  first. Storage is isolated by runtime, project, tenant, authenticated identity,
102
- and permissions. Sync is not an offline write queue; mutations and actions
103
- retain the fail-closed policy below.
102
+ and permissions.
103
+
104
+ ## Entity-level optimistic mutations
105
+
106
+ Declare how a mutation maps its arguments to an entity row and how each live
107
+ read projects that entity:
108
+
109
+ ```go
110
+ app.Mutation(
111
+ "tasks.update",
112
+ updateTask,
113
+ gonvex.OptimisticMutation("tasks").RowIDArg("taskId").FieldsArg("updates"),
114
+ )
115
+
116
+ app.Query(
117
+ "tasks.byWorkspace",
118
+ tasksByWorkspace,
119
+ gonvex.OptimisticProjection("tasks").Key("_id").ResultPath("page"),
120
+ )
121
+ ```
122
+
123
+ Generated references carry this metadata, so a normal mutation call is enough:
124
+
125
+ ```ts
126
+ await client.mutation(api.tasks.update, {
127
+ taskId,
128
+ updates: { priority_id: priorityId },
129
+ });
130
+ ```
131
+
132
+ The client persists the pending mutation before exposing it, layers it over
133
+ every matching sync and query subscription, and notifies watchers immediately.
134
+ An RPC result marks the write accepted but does not remove it; the overlay is
135
+ retired only when every source that displayed it reports the mutation id (or a
136
+ restored committed snapshot already matches). Deterministic server errors
137
+ remove the overlay and repaint the authoritative rows. This keeps stale query
138
+ or sync frames from briefly reverting the UI.
139
+
140
+ The authoritative sync/query cache stays immutable. Durable pending state lives
141
+ in the mutation outbox and is re-applied to cached rows after reload. Outbox
142
+ rows are isolated by project, tenant, and authenticated identity; an account
143
+ switch removes the previous identity's overlay and can never replay its writes
144
+ under the new session. Unscoped rows from the pre-isolation schema are removed
145
+ during migration because their owner cannot be proven. If an opaque credential
146
+ does not expose a stable identity (and no `identity` hint is supplied), its
147
+ outbox is deliberately session-only rather than risking cross-user replay. For
148
+ a complex projection that cannot be derived from one nested fields argument,
149
+ callers can still provide explicit `optimistic` entity patches.
104
150
 
105
151
  ## Lightweight Error Tracking
106
152
 
@@ -162,12 +208,19 @@ Rejected operations throw `GonvexClientError` with `code`:
162
208
  - `closed` — client was explicitly closed
163
209
  - `auth` — authentication rejected
164
210
 
165
- ### Mutation / action fail-closed policy
211
+ ### Mutation / action disconnect policy
212
+
213
+ Actions and mutations without `{ offline: "queue" }` fail closed after a
214
+ disconnect. They reject with `code: "disconnected"` (or `timeout` / `closed`).
215
+ Optimistic mutations are persisted before transport even in fail-closed mode,
216
+ so a process reload cannot expose an older cached row while an accepted write
217
+ is still waiting for its authoritative subscription update.
166
218
 
167
- Pending mutations and actions are **never** auto-replayed after disconnect.
168
- They reject with `code: "disconnected"` (or `timeout` / `closed`). Silent
169
- re-fire of non-idempotent writes is unsafe; offline queues belong in the app
170
- (or mobile offline layer), not this client.
219
+ Pass `{ offline: "queue" }` to a mutation to durably accept a transport failure
220
+ and replay the same idempotency key after reconnect, whether or not that
221
+ mutation also declares optimistic UI metadata. Actions are never queued.
222
+ Deterministic server errors are never queued and always roll an optimistic
223
+ entity overlay back when one exists.
171
224
 
172
225
  Live queries keep last-good data at the React layer (`useQueryResult`) and
173
226
  resubscribe after reconnect. Call `client.retryQuery(ref, args)` to force a
@@ -181,6 +234,8 @@ The package exports:
181
234
  - `ConvexReactClient` compatibility alias
182
235
  - `GonvexClientError`, `ConnectionState`, timeout defaults
183
236
  - transparent persistent query caching and lower-level experimental cache helpers
237
+ - durable entity-level optimistic overlays for query and sync projections
238
+ - opt-in mutation outbox replay with stable idempotency keys
184
239
  - `subscribeSync`, `watchSync`, and normalized persistent sync storage
185
240
  - browser capability and telemetry helpers
186
241
  - `GonvexErrorReporter` and automatic operation error reporting
package/dist/index.d.ts CHANGED
@@ -2,7 +2,7 @@ import type { BrowserTelemetryInfo, JsonValue, MessageTrace, ServerCapabilities,
2
2
  import { type QueryCacheOptions, type QueryCacheStatus } from "./query-cache.js";
3
3
  import { type SyncStoreOptions } from "./sync-store.js";
4
4
  import { type ErrorReporterOptions } from "./error-reporter.js";
5
- import { OptimisticOverlay, type OptimisticPatch } from "./optimistic.js";
5
+ import { OptimisticOverlay, type OptimisticMutationDefinition, type OptimisticPatch, type OptimisticProjection } from "./optimistic.js";
6
6
  export * from "./cache.js";
7
7
  export * from "./cache-coordinator.js";
8
8
  export * from "./browser-cache.js";
@@ -34,6 +34,10 @@ type ConnectionStateHandler = (state: ConnectionState) => void;
34
34
  export type FunctionReference = {
35
35
  kind: string;
36
36
  path: string;
37
+ optimistic?: {
38
+ projection?: OptimisticProjection;
39
+ mutation?: OptimisticMutationDefinition;
40
+ };
37
41
  };
38
42
  export type GonvexClientErrorCode = "server" | "timeout" | "disconnected" | "closed" | "auth";
39
43
  /**
@@ -197,7 +201,11 @@ export declare class GonvexClient {
197
201
  private readonly mutationOutbox;
198
202
  private readonly overlay;
199
203
  private readonly optimisticMutationIds;
200
- private readonly outboxReady;
204
+ private readonly optimisticOutboxEntryIds;
205
+ private outboxReady;
206
+ private outboxScope;
207
+ private outboxScopeGeneration;
208
+ private readonly outboxEphemeralScope;
201
209
  private readonly unsubscribeOutbox;
202
210
  private readonly unsubscribeOverlay;
203
211
  private drainingOutbox;
@@ -269,7 +277,10 @@ export declare class GonvexClient {
269
277
  private scheduleSyncWatermarkPersistence;
270
278
  private emitSyncMessage;
271
279
  private materializeSyncMessage;
272
- private emitOptimisticCollection;
280
+ private materializeQueryMessage;
281
+ private emitOptimisticEntity;
282
+ private acknowledgeOptimisticSource;
283
+ private acknowledgeOptimisticQuerySnapshot;
273
284
  private markSyncSubscriptionsOutOfDate;
274
285
  private startSync;
275
286
  private sendSyncOpen;
@@ -278,16 +289,19 @@ export declare class GonvexClient {
278
289
  private unsubscribeSyncListener;
279
290
  private persistSyncSnapshot;
280
291
  private persistSyncDelta;
292
+ private activateOutboxScope;
281
293
  private restoreOutbox;
282
294
  private addOptimisticMutation;
283
295
  private settleOptimisticMutation;
284
296
  private rejectOptimisticMutation;
297
+ private ackOptimisticMutation;
285
298
  private drainOutbox;
286
299
  private scheduleOutboxDrain;
287
300
  mutation<T = JsonValue>(ref: FunctionReference, args: JsonValue, options: CallOptions & {
288
301
  offline: "queue";
289
302
  }): Promise<T | QueuedMutationOutcome>;
290
303
  mutation<T = JsonValue>(ref: FunctionReference, args?: JsonValue, options?: CallOptions): Promise<T>;
304
+ private runOptimisticMutation;
291
305
  action<T = JsonValue>(ref: FunctionReference, args?: JsonValue, options?: CallOptions): Promise<T>;
292
306
  query<T = JsonValue>(ref: FunctionReference, args?: JsonValue, options?: CallOptions): Promise<T>;
293
307
  /**
@@ -300,8 +314,9 @@ export declare class GonvexClient {
300
314
  * Flush a queue of mutations in one `mutation.callMany` frame (queue order,
301
315
  * one websocket round trip). Each entry settles independently — a failed
302
316
  * call does not reject the batch — so offline queues can apply per-row
303
- * outcomes. Falls back to sequential `mutation` calls on runtimes that do
304
- * not advertise the `mutationBatch` capability.
317
+ * outcomes. Falls back to the standard per-mutation path when the runtime
318
+ * lacks batching or when a call needs generated/explicit optimism or durable
319
+ * offline queuing, so there is never a second mutation-state implementation.
305
320
  */
306
321
  mutationMany<T = JsonValue>(calls: Array<{
307
322
  ref: FunctionReference;