@rindle/optimistic 0.2.0 → 0.3.1

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/src/client.ts CHANGED
@@ -20,7 +20,7 @@ import { initWasm } from "@rindle/wasm";
20
20
 
21
21
  import type { OptimisticBackend } from "./backend.ts";
22
22
  import type { ClientRegistry, MutationTx } from "./backend.ts";
23
- import { stableClientID } from "./client-id.ts";
23
+ import { resetStableClientID, stableClientID } from "./client-id.ts";
24
24
  import { createOptimisticStore, type MutateFn } from "./index.ts";
25
25
  import type { Store } from "@rindle/client";
26
26
 
@@ -58,6 +58,13 @@ export interface RindleClientOptions<S extends ColsMap, R extends ClientRegistry
58
58
  /** A policy rejection's reason (the prediction's snap-back rides the lmid release). */
59
59
  onRejected?: (envelope: MutationEnvelope, reason: string) => void;
60
60
  queue?: { maxBatch?: number; retryDelayMs?: (attempt: number) => number };
61
+ /** Development-only recovery knobs. Keep off in production: a mutation gap means state loss
62
+ * or two writers sharing a clientID, and should be investigated. */
63
+ dev?: {
64
+ /** On a mutation-gap response, clear the persisted clientID and hard reload the page. This
65
+ * recovers from dev DB wipes while making the reset visible to the developer. */
66
+ resetOnMutationGap?: boolean;
67
+ };
61
68
  }
62
69
 
63
70
  export interface RindleClient<S extends ColsMap, R extends ClientRegistry> {
@@ -90,8 +97,9 @@ export async function createRindleClient<S extends ColsMap, R extends ClientRegi
90
97
  headers: { "content-type": "application/json", ...extra },
91
98
  body: JSON.stringify(payload),
92
99
  });
93
- if (!res.ok) throw new Error(`rindle api ${path} failed: ${res.status} ${await res.text()}`);
94
- return res.json();
100
+ const text = await res.text();
101
+ if (!res.ok) throw new RindleApiHttpError(path, res.status, text);
102
+ return text ? JSON.parse(text) : undefined;
95
103
  };
96
104
 
97
105
  // Reads-leg connection: a fixed transport (tests/in-process) or a replaceable connection built
@@ -116,8 +124,15 @@ export async function createRindleClient<S extends ColsMap, R extends ClientRegi
116
124
  retryDelayMs: opts.queue?.retryDelayMs,
117
125
  onRejected: opts.onRejected,
118
126
  send: async (envelopes) => {
119
- const outcomes = (await post(routes.mutate, { envelopes })) as Array<{ accepted: boolean; reason?: string }>;
120
- return outcomes.map((o): PushOutcome => ({ accepted: o.accepted, reason: o.reason }));
127
+ try {
128
+ const outcomes = (await post(routes.mutate, { envelopes })) as Array<{ accepted: boolean; reason?: string }>;
129
+ return outcomes.map((o): PushOutcome => ({ accepted: o.accepted, reason: o.reason }));
130
+ } catch (err) {
131
+ if (opts.dev?.resetOnMutationGap && reloadAfterMutationGap(err)) {
132
+ return new Promise<never>(() => {});
133
+ }
134
+ throw err;
135
+ }
121
136
  },
122
137
  }),
123
138
  });
@@ -154,3 +169,41 @@ export async function createRindleClient<S extends ColsMap, R extends ClientRegi
154
169
 
155
170
  export type { ClientRegistry, MutationTx };
156
171
  export type { MutateFn } from "./index.ts";
172
+
173
+ class RindleApiHttpError extends Error {
174
+ readonly path: string;
175
+ readonly status: number;
176
+ readonly body: string;
177
+
178
+ constructor(path: string, status: number, body: string) {
179
+ super(`rindle api ${path} failed: ${status}${body ? ` ${body}` : ""}`);
180
+ this.name = "RindleApiHttpError";
181
+ this.path = path;
182
+ this.status = status;
183
+ this.body = body;
184
+ }
185
+ }
186
+
187
+ const MUTATION_GAP_RE = /\bmutation(?: id)? gap\b/i;
188
+
189
+ function reloadAfterMutationGap(err: unknown): boolean {
190
+ const text =
191
+ err instanceof RindleApiHttpError
192
+ ? `${err.status} ${err.body} ${err.message}`
193
+ : String((err as Error)?.message ?? err);
194
+ if (!MUTATION_GAP_RE.test(text)) return false;
195
+
196
+ resetStableClientID();
197
+ console.error(
198
+ "[rindle] mutation gap detected; cleared the dev client id and reloading so this tab starts a fresh mutation stream.",
199
+ err,
200
+ );
201
+ const loc = (globalThis as unknown as { location?: { reload?: () => void } }).location;
202
+ if (typeof loc?.reload !== "function") return false;
203
+ try {
204
+ loc.reload();
205
+ return true;
206
+ } catch {
207
+ return false;
208
+ }
209
+ }
package/src/index.ts CHANGED
@@ -34,6 +34,8 @@ export type {
34
34
  OptimisticBackendOptions,
35
35
  OptimisticInspect,
36
36
  PendingInspect,
37
+ QueryArg,
38
+ QueryResultRow,
37
39
  ResultType,
38
40
  } from "./backend.ts";
39
41
  export type { MutationEnvelope, OptimisticSource, ProgressFrame } from "@rindle/client";
@@ -41,7 +43,7 @@ export type { MutationEnvelope, OptimisticSource, ProgressFrame } from "@rindle/
41
43
  // The one-call client for the daemon/serverless topology (API server + daemon ws).
42
44
  export { createRindleClient } from "./client.ts";
43
45
  export type { RindleClient, RindleClientOptions } from "./client.ts";
44
- export { stableClientID } from "./client-id.ts";
46
+ export { resetStableClientID, stableClientID } from "./client-id.ts";
45
47
 
46
48
  /** A {@link Store} over an {@link OptimisticBackend}, plus the named-mutator entry
47
49
  * (`mutate.createIssue(args)` — the §9 dream shape) and the §6 lifecycle surface. */