@gonvex/client 0.5.2-staging.10 → 0.5.2-staging.11

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/dist/index.js CHANGED
@@ -9,6 +9,8 @@ export * from "./error-reporter.js";
9
9
  export * from "./optimistic.js";
10
10
  export * from "./outbox.js";
11
11
  export * from "./kv-stores.js";
12
+ export * from "./client-upgrades.js";
13
+ export * from "./browser-upgrades.js";
12
14
  export * from "./signals.js";
13
15
  export * from "./external-auth.js";
14
16
  // Keep the mutable LocalReplica implementation private to GonvexClient. The
@@ -115,6 +117,16 @@ const maxReplicaBatchOpens = 256;
115
117
  // normally settle in a few milliseconds.
116
118
  export class GonvexClient {
117
119
  url;
120
+ localBinding;
121
+ localExecutor;
122
+ localStorage;
123
+ localIdentity;
124
+ localLane = Promise.resolve();
125
+ localReplayScheduled = false;
126
+ replacingLocal = false;
127
+ unsubscribeLocal;
128
+ localCollectionClosers = [];
129
+ reducerRejectionHandlers = new Set();
118
130
  socket;
119
131
  handlers = new Map();
120
132
  querySubscriptions = new Map();
@@ -185,8 +197,17 @@ export class GonvexClient {
185
197
  hasEverConnected = false;
186
198
  connectionCount = 0;
187
199
  timeouts;
200
+ clientContract;
201
+ updateRequiredHandler;
202
+ updateRequired = false;
203
+ lastOnlineAtMs = 0;
188
204
  constructor(url, options = {}) {
189
205
  this.url = url;
206
+ this.clientContract = options.clientContract ?? options.localRuntime?.clientContract;
207
+ this.updateRequiredHandler = options.onUpdateRequired;
208
+ this.localBinding = options.localRuntime;
209
+ this.localExecutor = options.localRuntime?.create();
210
+ this.localStorage = options.localReplica?.storage;
190
211
  this.auth = authFromOptions(options);
191
212
  this.telemetryEnabled = options.telemetry === true;
192
213
  this.querySubscriptionRetentionMs = normalizeQuerySubscriptionRetentionMs(options.querySubscriptionRetentionMs);
@@ -194,6 +215,8 @@ export class GonvexClient {
194
215
  this.reducerOutbox = createReducerOutbox(options.outbox);
195
216
  this.replica = new LocalReplica(options.localReplica?.storage);
196
217
  this.replicaView = createLocalReplicaView(this.replica);
218
+ if (this.localExecutor)
219
+ this.unsubscribeLocal = this.replica.subscribe(() => this.scheduleLocalReplay());
197
220
  this.unsubscribeOutbox = this.reducerOutbox.subscribe(() => {
198
221
  void this.drainOutbox();
199
222
  });
@@ -230,6 +253,182 @@ export class GonvexClient {
230
253
  transport: (type, payload) => this.sendNativeError(type, payload),
231
254
  });
232
255
  }
256
+ if (this.localBinding)
257
+ this.outboxReady = this.restoreLocalSession(initialScope, this.outboxScopeGeneration);
258
+ }
259
+ /** Authoritative sync failures arrive after an offline call returned locally. */
260
+ onReducerRejection(listener) {
261
+ this.reducerRejectionHandlers.add(listener);
262
+ return () => this.reducerRejectionHandlers.delete(listener);
263
+ }
264
+ async restoreLocalSession(scope, generation) {
265
+ if (!this.localBinding || !scope.startsWith("identity\u0000"))
266
+ return;
267
+ const saved = await this.localStorage?.loadSession?.(scope);
268
+ if (!saved || generation !== this.outboxScopeGeneration || scope !== this.outboxScope || this.hasAuthoritativeReplicaScope)
269
+ return;
270
+ this.lastOnlineAtMs = saved.lastOnlineAtMs ?? 0;
271
+ this.localIdentity = saved.identity;
272
+ await this.activateReplicaDirective(saved.directive);
273
+ this.replica.setFreshness("offline");
274
+ }
275
+ ensureLocalCollections() {
276
+ if (!this.localBinding || this.localCollectionClosers.length)
277
+ return;
278
+ for (const reference of this.localBinding.collections ?? []) {
279
+ this.localCollectionClosers.push(this.subscribeReplicaTransport(reference, {}, () => undefined));
280
+ }
281
+ }
282
+ localSnapshot(includePending) {
283
+ const snapshot = this.replica.snapshot();
284
+ const tables = {};
285
+ for (const table of this.localBinding?.tables ?? []) {
286
+ const references = (this.localBinding?.collections ?? []).filter((ref) => ref.replica?.table === table
287
+ && !Object.keys(ref.replica.equalFilters ?? {}).length && !(ref.replica.excludeWhenSet?.length));
288
+ const complete = references.some((ref) => {
289
+ const window = snapshot.liveQueries[querySubscriptionKey(ref, {})];
290
+ return window?.completeness === "complete" && window.truncated !== true;
291
+ });
292
+ tables[table] = { complete, columns: [...new Set(references.flatMap(ref => ref.replica?.columns ?? []))], rows: includePending ? this.replica.entityRows(table) : Object.values(snapshot.entities[table] ?? {}) };
293
+ }
294
+ return { scope: this.replicaScope, tables };
295
+ }
296
+ inLocalLane(run) {
297
+ const job = this.localLane.then(run);
298
+ this.localLane = job.catch(() => undefined);
299
+ return job;
300
+ }
301
+ scheduleLocalReplay() {
302
+ if (!this.localExecutor || this.replacingLocal || this.localReplayScheduled || this.manuallyClosed || !this.localIdentity)
303
+ return;
304
+ this.localReplayScheduled = true;
305
+ queueMicrotask(() => {
306
+ void (async () => {
307
+ await this.outboxReady;
308
+ await this.inLocalLane(() => this.rebaseLocalEntries());
309
+ })().catch(() => undefined).finally(() => { this.localReplayScheduled = false; });
310
+ });
311
+ }
312
+ reportLocalRejection(reducerId, path, error) {
313
+ const message = reducerErrorMessage(error);
314
+ for (const listener of this.reducerRejectionHandlers)
315
+ listener({ reducerId, path, error: message });
316
+ }
317
+ async rebaseLocalEntries() {
318
+ if (!this.localExecutor || !this.localBinding || !this.localIdentity || !this.hasAuthoritativeReplicaScope)
319
+ return;
320
+ const scope = this.outboxScope;
321
+ const replicaScope = this.replicaScope;
322
+ const entries = await this.reducerOutbox.list(scope);
323
+ const baseVersion = this.replica.version();
324
+ const snapshot = this.localSnapshot(false);
325
+ const commands = [];
326
+ for (const entry of entries) {
327
+ let patches = entry.patches ?? [];
328
+ if (entry.localExecution && entry.state !== "inflight" && entry.state !== "committed") {
329
+ const execution = { ...entry.localExecution, scope: replicaScope, identity: this.localIdentity, artifactHash: this.localBinding.artifactHash };
330
+ try {
331
+ const transaction = await this.localExecutor.execute(entry.path, entry.args, snapshot, execution);
332
+ patches = transaction.patches;
333
+ if (scope !== this.outboxScope || replicaScope !== this.replicaScope)
334
+ return;
335
+ }
336
+ catch (error) {
337
+ // Re-execution is a prediction. Only an authoritative server rejection
338
+ // removes a durable intent; missing data or local validation can make
339
+ // its predicted transaction unavailable without rejecting the intent.
340
+ patches = [];
341
+ }
342
+ if (scope !== this.outboxScope || replicaScope !== this.replicaScope)
343
+ return;
344
+ // Storage failures must abort rebasing, never masquerade as rejection.
345
+ await this.reducerOutbox.updateLocal(entry.id, patches, execution);
346
+ }
347
+ applyLocalPatches(snapshot, patches);
348
+ commands.push({ commandId: entry.idempotencyKey, patches });
349
+ }
350
+ if (scope !== this.outboxScope || replicaScope !== this.replicaScope)
351
+ return;
352
+ if (baseVersion !== this.replica.version())
353
+ return this.rebaseLocalEntries();
354
+ this.replacingLocal = true;
355
+ try {
356
+ this.replica.replaceOptimistic(commands);
357
+ }
358
+ finally {
359
+ this.replacingLocal = false;
360
+ }
361
+ this.optimisticReducerIds.clear();
362
+ this.optimisticOutboxEntryIds.clear();
363
+ for (const entry of entries) {
364
+ this.optimisticOutboxEntryIds.set(entry.idempotencyKey, entry.id);
365
+ if (commands.find((command) => command.commandId === entry.idempotencyKey)?.patches.length)
366
+ this.optimisticReducerIds.add(entry.idempotencyKey);
367
+ }
368
+ }
369
+ async runLocalReducer(ref, args) {
370
+ args = structuredClone(args);
371
+ const admittedGeneration = this.outboxScopeGeneration;
372
+ await this.outboxReady;
373
+ await this.replicaReady;
374
+ if (!this.localExecutor || !this.localBinding)
375
+ throw new Error("This reducer requires the generated Gonvex client with local execution enabled");
376
+ return this.inLocalLane(async () => {
377
+ if (admittedGeneration !== this.outboxScopeGeneration)
378
+ throw new GonvexClientError("Session changed before local reducer execution", { code: "superseded" });
379
+ if (!this.localIdentity || !this.hasAuthoritativeReplicaScope)
380
+ throw new Error("Restore an authenticated local session before editing offline");
381
+ if (this.manuallyClosed)
382
+ throw new Error("Gonvex client is closed");
383
+ if (this.updateRequired)
384
+ throw new Error("Application update required before editing");
385
+ if (this.clientContract && !this.canSendReducerNow() && (Date.now() - this.lastOnlineAtMs > this.clientContract.offlineMaxAgeMs || Date.now() < this.lastOnlineAtMs))
386
+ throw new Error("Offline editing window expired. Reconnect before making more changes.");
387
+ const scope = this.outboxScope;
388
+ const reducerId = randomID();
389
+ const execution = {
390
+ scope: this.replicaScope, commandId: reducerId, now: Date.now(),
391
+ intentEntropy: Array.from(crypto.getRandomValues(new Uint8Array(32)), byte => byte.toString(16).padStart(2, "0")).join(""),
392
+ artifactHash: this.localBinding.artifactHash, identity: structuredClone(this.localIdentity),
393
+ };
394
+ let transaction;
395
+ try {
396
+ transaction = await this.localExecutor.execute(ref.path, args, this.localSnapshot(true), execution);
397
+ }
398
+ catch (error) {
399
+ if (!(error instanceof Error) || error.name !== "IncompleteReplicaError")
400
+ throw error;
401
+ }
402
+ if (scope !== this.outboxScope || execution.scope !== this.replicaScope)
403
+ throw new GonvexClientError("Session changed during local reducer execution", { code: "superseded" });
404
+ const patches = transaction?.patches ?? [];
405
+ this.directOutboxReducerIds.add(reducerId);
406
+ try {
407
+ const entry = await this.reducerOutbox.enqueue({
408
+ scope, path: ref.path, args, idempotencyKey: reducerId, state: "pending",
409
+ // All local intents form one causal chain, including reads of rows
410
+ // written by a previous intent, not only overlapping write patches.
411
+ entityKeys: ["__gonvex_local_intents"], patches, localExecution: execution,
412
+ });
413
+ if (scope !== this.outboxScope || execution.scope !== this.replicaScope) {
414
+ throw new GonvexClientError("Session changed while persisting local reducer", { code: "superseded" });
415
+ }
416
+ this.optimisticOutboxEntryIds.set(reducerId, entry.id);
417
+ this.replacingLocal = true;
418
+ try {
419
+ this.addOptimisticReducer(reducerId, patches);
420
+ }
421
+ finally {
422
+ this.replacingLocal = false;
423
+ }
424
+ return transaction ? transaction.result : { status: "queued", reducerId };
425
+ }
426
+ finally {
427
+ this.directOutboxReducerIds.delete(reducerId);
428
+ this.scheduleLocalReplay();
429
+ void this.drainOutbox();
430
+ }
431
+ });
233
432
  }
234
433
  /** The single normalized authoritative + optimistic application data store. */
235
434
  get localReplica() {
@@ -505,6 +704,10 @@ export class GonvexClient {
505
704
  }
506
705
  return;
507
706
  }
707
+ if (message.type === "client.updateRequired") {
708
+ this.requireClientUpdate(message.reason);
709
+ return;
710
+ }
508
711
  if (message.type === "system.reload") {
509
712
  if (typeof message.artifactHash === "string") {
510
713
  this.activeArtifactHashValue = message.artifactHash;
@@ -587,6 +790,7 @@ export class GonvexClient {
587
790
  if (message.type === "auth.result") {
588
791
  const reauthenticatedSameSocket = this.authenticatedSocketGeneration === this.socketGeneration;
589
792
  this.authenticatedSocketGeneration = this.socketGeneration;
793
+ this.lastOnlineAtMs = Date.now();
590
794
  this.activeArtifactHashValue = artifactHashFromAuthResult(message.result) ?? this.activeArtifactHashValue;
591
795
  const developerSessionToken = developerSessionTokenFromAuthResult(message.result);
592
796
  if (developerSessionToken) {
@@ -596,6 +800,9 @@ export class GonvexClient {
596
800
  }
597
801
  this.authRetriedAfterError = false;
598
802
  const directive = replicaDirectiveFromAuthResult(message.result);
803
+ const localIdentity = localIdentityFromAuthResult(message.result);
804
+ if (localIdentity)
805
+ this.localIdentity = localIdentity;
599
806
  if (!directive) {
600
807
  this.authInFlight = false;
601
808
  if (!this.auth.tenant) {
@@ -615,6 +822,12 @@ export class GonvexClient {
615
822
  }
616
823
  void this.activateReplicaDirective(directive)
617
824
  .then(() => this.activateOutboxScope())
825
+ .then(async () => {
826
+ if (localIdentity && this.localBinding)
827
+ await this.localStorage?.saveSession?.(this.outboxScope, {
828
+ directive, identity: localIdentity, artifactHash: this.activeArtifactHashValue ?? this.localBinding.artifactHash, lastOnlineAtMs: this.lastOnlineAtMs,
829
+ });
830
+ })
618
831
  .then(() => {
619
832
  // The accepted server identity is not usable until its durable
620
833
  // Replica partition has been activated locally. Only now may
@@ -728,6 +941,10 @@ export class GonvexClient {
728
941
  }
729
942
  close() {
730
943
  this.manuallyClosed = true;
944
+ this.unsubscribeLocal?.();
945
+ for (const close of this.localCollectionClosers.splice(0))
946
+ close();
947
+ this.localExecutor?.close();
731
948
  this.cancelManagedAuthAttempt("Gonvex client was closed during authentication.");
732
949
  if (isEphemeralOutboxScope(this.outboxScope)) {
733
950
  void this.reducerOutbox.clear(this.outboxScope);
@@ -1777,7 +1994,7 @@ export class GonvexClient {
1777
1994
  this.outboxScope = scope;
1778
1995
  const ready = this.hasAuthoritativeReplicaScope
1779
1996
  ? this.restoreOutbox(scope, generation)
1780
- : Promise.resolve();
1997
+ : this.restoreLocalSession(scope, generation);
1781
1998
  this.outboxReady = ready;
1782
1999
  return ready;
1783
2000
  }
@@ -1812,8 +2029,10 @@ export class GonvexClient {
1812
2029
  // and sends a second time with the same command ID.
1813
2030
  this.outboxReady = this.restoreOutbox(this.outboxScope, generation);
1814
2031
  await this.outboxReady;
2032
+ this.ensureLocalCollections();
1815
2033
  }
1816
2034
  quarantineReplicaScope() {
2035
+ this.localIdentity = undefined;
1817
2036
  // Keep the durable prior identity scope intact for an authorized future
1818
2037
  // login, but make every synchronous selector fail closed immediately.
1819
2038
  // The random suffix prevents a denied scope from ever restoring rows.
@@ -1844,6 +2063,17 @@ export class GonvexClient {
1844
2063
  || scope !== this.outboxScope)
1845
2064
  return;
1846
2065
  for (const entry of entries) {
2066
+ // markCommitted follows the durable replica watermark barrier. Its
2067
+ // transaction is already in the restored base; never predict it twice.
2068
+ if (entry.localExecution && entry.state === "committed")
2069
+ await this.reducerOutbox.ack(entry.id);
2070
+ }
2071
+ if (entries.some((entry) => entry.localExecution) && this.localExecutor) {
2072
+ await this.inLocalLane(() => this.rebaseLocalEntries());
2073
+ }
2074
+ for (const entry of entries) {
2075
+ if (entry.localExecution && this.localExecutor)
2076
+ continue;
1847
2077
  if (entry.state === "committed" && (entry.patches?.length ?? 0) === 0) {
1848
2078
  await this.reducerOutbox.ack(entry.id);
1849
2079
  continue;
@@ -1894,7 +2124,12 @@ export class GonvexClient {
1894
2124
  try {
1895
2125
  while (!this.manuallyClosed && this.socket?.readyState === WebSocket.OPEN) {
1896
2126
  const scope = this.outboxScope;
1897
- const entry = await this.reducerOutbox.nextReady(scope, Date.now());
2127
+ const entry = await this.inLocalLane(async () => {
2128
+ const next = await this.reducerOutbox.nextReady(scope, Date.now());
2129
+ if (next)
2130
+ await this.reducerOutbox.markInflight(next.id);
2131
+ return next;
2132
+ });
1898
2133
  if (!entry)
1899
2134
  return;
1900
2135
  if (scope !== this.outboxScope)
@@ -1920,9 +2155,16 @@ export class GonvexClient {
1920
2155
  return;
1921
2156
  }
1922
2157
  try {
1923
- await this.call("reducer", { kind: "reducer", path: entry.path }, entry.args, this.timeouts.reducerTimeoutMs, entry.idempotencyKey, entry.idempotencyKey);
2158
+ await this.call("reducer", { kind: "reducer", path: entry.path, receiptPath: entry.receiptPath, ...(entry.localExecution ? { artifactHash: entry.localExecution.artifactHash, intentEntropy: entry.localExecution.intentEntropy } : {}) }, entry.args, this.timeouts.reducerTimeoutMs, entry.idempotencyKey, entry.idempotencyKey);
1924
2159
  await this.reducerOutbox.markCommitted(entry.id);
1925
- if ((entry.patches?.length ?? 0) > 0) {
2160
+ if (entry.localExecution && this.localExecutor) {
2161
+ await this.inLocalLane(async () => {
2162
+ await this.reducerOutbox.ack(entry.id);
2163
+ if (scope === this.outboxScope)
2164
+ await this.rebaseLocalEntries();
2165
+ });
2166
+ }
2167
+ else if ((entry.patches?.length ?? 0) > 0) {
1926
2168
  await this.settleOptimisticReducer(entry.idempotencyKey);
1927
2169
  }
1928
2170
  else {
@@ -1930,8 +2172,23 @@ export class GonvexClient {
1930
2172
  }
1931
2173
  }
1932
2174
  catch (error) {
2175
+ if (error instanceof Error && /STALE_REDUCER_ARTIFACT|CLIENT_UPDATE_REQUIRED/.test(error.message)) {
2176
+ await this.reducerOutbox.markPending(entry.id);
2177
+ this.requireClientUpdate(error.message);
2178
+ return;
2179
+ }
1933
2180
  if (error instanceof GonvexClientError && error.code === "server") {
1934
- await this.rejectOptimisticReducer(entry.idempotencyKey, entry.id);
2181
+ if (entry.localExecution && this.localExecutor) {
2182
+ await this.inLocalLane(async () => {
2183
+ await this.reducerOutbox.ack(entry.id);
2184
+ if (scope === this.outboxScope) {
2185
+ await this.rebaseLocalEntries();
2186
+ this.reportLocalRejection(entry.idempotencyKey, entry.path, error);
2187
+ }
2188
+ });
2189
+ }
2190
+ else
2191
+ await this.rejectOptimisticReducer(entry.idempotencyKey, entry.id);
1935
2192
  continue;
1936
2193
  }
1937
2194
  await this.reducerOutbox.fail(entry.id, reducerErrorMessage(error));
@@ -1958,6 +2215,8 @@ export class GonvexClient {
1958
2215
  }, delay);
1959
2216
  }
1960
2217
  reducer(ref, args = {}, options = {}) {
2218
+ if (ref.localExecution === 1)
2219
+ return this.runLocalReducer(ref, args);
1961
2220
  if (options.offline === "queue" && ref.offline?.mode !== "allowed") {
1962
2221
  return Promise.reject(new GonvexClientError(`Reducer ${ref.path} does not allow offline queueing.`, { code: "disconnected", path: ref.path, operation: "reducer" }));
1963
2222
  }
@@ -2044,7 +2303,17 @@ export class GonvexClient {
2044
2303
  void this.drainOutbox();
2045
2304
  }
2046
2305
  }
2306
+ requireClientUpdate(reason) {
2307
+ if (this.updateRequired)
2308
+ return;
2309
+ this.updateRequired = true;
2310
+ // Keep durable intents intact. Closing suspends subscriptions and future sends.
2311
+ this.close();
2312
+ this.updateRequiredHandler?.(reason);
2313
+ }
2047
2314
  canSendReducerNow() {
2315
+ if (this.updateRequired)
2316
+ return false;
2048
2317
  if (globalThis.navigator?.onLine === false)
2049
2318
  return false;
2050
2319
  const socket = this.socket;
@@ -2189,6 +2458,10 @@ export class GonvexClient {
2189
2458
  ...(ref.scope === "control" ? { scope: "control" } : {}),
2190
2459
  trace: { clientSentAtMs: entry.clientSentAtMs },
2191
2460
  ...(effectiveIdempotencyKey ? { idempotencyKey: effectiveIdempotencyKey } : {}),
2461
+ ...(ref.artifactHash ? { artifactHash: ref.artifactHash } : {}),
2462
+ ...(this.clientContract ? { clientContract: this.clientContract.version } : {}),
2463
+ ...(ref.receiptPath ? { receiptPath: ref.receiptPath } : {}),
2464
+ ...(ref.intentEntropy ? { intentEntropy: ref.intentEntropy } : {}),
2192
2465
  });
2193
2466
  }
2194
2467
  else {
@@ -2491,6 +2764,9 @@ export class GonvexClient {
2491
2764
  this.send(message);
2492
2765
  }
2493
2766
  hasControlPlaneWork() {
2767
+ for (const query of this.querySubscriptions.values())
2768
+ if (query.executionScope === "control")
2769
+ return true;
2494
2770
  for (const query of this.oneShotQueries.values())
2495
2771
  if (query.scope === "control")
2496
2772
  return true;
@@ -2684,6 +2960,7 @@ export class GonvexClient {
2684
2960
  this.managedAuthAttempt?.ids.add(id);
2685
2961
  this.sendNow({
2686
2962
  type: "auth",
2963
+ ...(this.clientContract ? { clientContract: this.clientContract.version } : {}),
2687
2964
  id,
2688
2965
  token: this.auth.token,
2689
2966
  project: this.auth.project,
@@ -3059,6 +3336,31 @@ function queryPatchRowKey(value) {
3059
3336
  const candidate = value._id ?? value.id;
3060
3337
  return typeof candidate === "string" || typeof candidate === "number" ? String(candidate) : "";
3061
3338
  }
3339
+ function applyLocalPatches(snapshot, patches) {
3340
+ for (const patch of patches) {
3341
+ const entity = patch.entity ?? patch.collection;
3342
+ if (!entity)
3343
+ continue;
3344
+ const table = snapshot.tables[entity] ??= { complete: false, rows: [] };
3345
+ const index = table.rows.findIndex((row) => String(row._id ?? row.id) === patch.rowId);
3346
+ if (patch.op === "delete") {
3347
+ if (index >= 0)
3348
+ table.rows.splice(index, 1);
3349
+ }
3350
+ else if (index >= 0)
3351
+ table.rows[index] = { ...table.rows[index], ...structuredClone(patch.fields) };
3352
+ else if (patch.op !== "patch")
3353
+ table.rows.push(structuredClone(patch.fields));
3354
+ }
3355
+ }
3356
+ function localIdentityFromAuthResult(result) {
3357
+ if (!result || typeof result !== "object" || Array.isArray(result))
3358
+ return;
3359
+ const identity = result.localIdentity;
3360
+ if (!identity?.auth?.account?.id || !identity.tenant?.id || !identity.member?.id)
3361
+ return;
3362
+ return structuredClone(identity);
3363
+ }
3062
3364
  function authFromOptions(options) {
3063
3365
  return {
3064
3366
  project: options.project,