@gp2f/client-sdk 1.0.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/dist/index.js ADDED
@@ -0,0 +1,58 @@
1
+ // WebSocket client
2
+ export { Gp2fClient, applyOptimisticUpdate } from "./client";
3
+ // Reconciliation UX components
4
+ export { ReconciliationBanner } from "./ReconciliationBanner";
5
+ export { UndoButton } from "./UndoButton";
6
+ export { MergeModal } from "./MergeModal";
7
+ /**
8
+ * Lazily load the GP2F WASM policy engine.
9
+ *
10
+ * The module is fetched and instantiated on the **first call** only; subsequent
11
+ * calls return the cached instance with no additional network cost.
12
+ *
13
+ * This pattern ("lazy loading") keeps the initial JS bundle small and defers
14
+ * the WASM download until the moment the policy engine is actually needed.
15
+ *
16
+ * @example
17
+ * ```ts
18
+ * const engine = await loadPolicyEngine();
19
+ * const { result } = engine.evaluate(JSON.stringify(state), JSON.stringify(ast));
20
+ * ```
21
+ */
22
+ export async function loadPolicyEngine() {
23
+ return _policyEngineCache ?? (_policyEngineCache = await _importPolicyEngine());
24
+ }
25
+ /** Cached module instance (populated after the first successful load). */
26
+ let _policyEngineCache = null;
27
+ /**
28
+ * Perform the actual dynamic import.
29
+ *
30
+ * Replace the module path with the real WASM package once it is published.
31
+ * The `/* webpackChunkName magic comment tells bundlers (webpack / Vite)
32
+ * to emit this as a separate chunk so it is only downloaded on demand.
33
+ */
34
+ async function _importPolicyEngine() {
35
+ // Dynamic import – bundlers will split this into a separate chunk.
36
+ // We use a try/catch so that the SDK remains usable even when the optional
37
+ // @gp2f/policy-core-wasm peer package is not installed.
38
+ try {
39
+ // @ts-expect-error: @gp2f/policy-core-wasm is an optional peer package
40
+ // that may not be installed. The try/catch below handles the absence case.
41
+ const mod = await import(/* webpackChunkName: "policy-engine" */ "@gp2f/policy-core-wasm");
42
+ return mod;
43
+ }
44
+ catch {
45
+ // WASM package not installed – return a stub that always delegates to the
46
+ // server-side evaluator. Log a warning so developers know the lazy engine
47
+ // is inactive.
48
+ if (typeof console !== "undefined") {
49
+ console.warn("[gp2f] WASM policy engine not found (@gp2f/policy-core-wasm). " +
50
+ "All policy evaluation will be performed server-side.");
51
+ }
52
+ return {
53
+ evaluate(_stateJson, _astJson) {
54
+ throw new Error("WASM policy engine is not available. Install @gp2f/policy-core-wasm to enable client-side evaluation.");
55
+ },
56
+ };
57
+ }
58
+ }
package/dist/wire.d.ts ADDED
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Wire-protocol types shared between the GP2F server and this SDK.
3
+ * These mirror the Rust structs in `server/src/wire.rs`.
4
+ */
5
+ export interface ClientMessage {
6
+ opId: string;
7
+ astVersion: string;
8
+ action: string;
9
+ payload: unknown;
10
+ clientSnapshotHash: string;
11
+ tenantId?: string;
12
+ workflowId?: string;
13
+ instanceId?: string;
14
+ /** base64url HMAC-SHA256 over canonical op fields */
15
+ clientSignature?: string;
16
+ }
17
+ export interface AcceptResponse {
18
+ opId: string;
19
+ serverSnapshotHash: string;
20
+ }
21
+ export interface ThreeWayPatch {
22
+ baseSnapshot: unknown;
23
+ localDiff: unknown;
24
+ serverDiff: unknown;
25
+ conflicts: FieldConflict[];
26
+ }
27
+ export interface FieldConflict {
28
+ path: string;
29
+ strategy: "CRDT" | "LWW" | "TRANSACTIONAL";
30
+ resolvedValue: unknown;
31
+ }
32
+ export interface RejectResponse {
33
+ opId: string;
34
+ reason: string;
35
+ patch: ThreeWayPatch;
36
+ /**
37
+ * Suggested back-off interval in milliseconds (Retry-After semantics).
38
+ * Present when the rejection is caused by server-side backpressure; the
39
+ * client SHOULD pause sending new ops for at least this duration.
40
+ */
41
+ retryAfterMs?: number;
42
+ }
43
+ /**
44
+ * Sent by the server once per connection, immediately after the WebSocket
45
+ * handshake, for time-synchronisation purposes.
46
+ */
47
+ export interface HelloMessage {
48
+ /** Server wall-clock time in milliseconds since the Unix epoch. */
49
+ serverTimeMs: number;
50
+ /** Server HLC timestamp at the moment of the hello. */
51
+ serverHlcTs: number;
52
+ }
53
+ /**
54
+ * Sent by the server when the client's AST schema version is incompatible.
55
+ * The client MUST fetch a fresh policy bundle before reconnecting.
56
+ */
57
+ export interface ReloadRequiredMessage {
58
+ /** The minimum AST version the server accepts (semver). */
59
+ minRequiredVersion: string;
60
+ /** Human-readable explanation. */
61
+ reason: string;
62
+ }
63
+ export type ServerMessage = {
64
+ type: "ACCEPT";
65
+ } & AcceptResponse | {
66
+ type: "REJECT";
67
+ } & RejectResponse | {
68
+ type: "HELLO";
69
+ } & HelloMessage | {
70
+ type: "RELOAD_REQUIRED";
71
+ } & ReloadRequiredMessage;
package/dist/wire.js ADDED
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Wire-protocol types shared between the GP2F server and this SDK.
3
+ * These mirror the Rust structs in `server/src/wire.rs`.
4
+ */
5
+ export {};
package/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "@gp2f/client-sdk",
3
+ "version": "1.0.0",
4
+ "description": "GP2F client SDK – reconciliation UX components and WebSocket client",
5
+ "main": "dist/index.js",
6
+ "types": "dist/index.d.ts",
7
+ "files": [
8
+ "dist",
9
+ "src"
10
+ ],
11
+ "exports": {
12
+ ".": {
13
+ "types": "./dist/index.d.ts",
14
+ "import": "./dist/index.js",
15
+ "default": "./dist/index.js"
16
+ }
17
+ },
18
+ "scripts": {
19
+ "build": "tsc",
20
+ "test": "jest --passWithNoTests"
21
+ },
22
+ "keywords": [
23
+ "gp2f",
24
+ "crdt",
25
+ "reconciliation",
26
+ "optimistic-ui"
27
+ ],
28
+ "license": "MIT",
29
+ "peerDependencies": {
30
+ "react": ">=18.0.0",
31
+ "react-dom": ">=18.0.0"
32
+ },
33
+ "devDependencies": {
34
+ "@types/react": "^18.0.0",
35
+ "@types/react-dom": "^18.0.0",
36
+ "typescript": "^5.0.0"
37
+ }
38
+ }
@@ -0,0 +1,321 @@
1
+ import React, { useState } from "react";
2
+ import type { FieldConflict } from "./wire";
3
+
4
+ /**
5
+ * Side-by-side modal shown when a REJECT contains non-CRDT field conflicts.
6
+ *
7
+ * For each conflicting field the user can choose:
8
+ * - **Keep mine** – use the local optimistic value
9
+ * - **Use server** – accept the server's authoritative value (from `resolvedValue`)
10
+ */
11
+ export interface MergeModalProps {
12
+ conflicts: FieldConflict[];
13
+ /** JSON snapshot of the state *before* the rejected op was applied. */
14
+ baseSnapshot: unknown;
15
+ /** JSON diff representing the local (client) changes. */
16
+ localDiff: unknown;
17
+ /** Called with the user's resolution choices (path → chosen value). */
18
+ onResolve: (resolutions: Record<string, unknown>) => void;
19
+ /** Called when the user cancels without resolving. */
20
+ onCancel: () => void;
21
+ }
22
+
23
+ type Resolution = "mine" | "server";
24
+
25
+ export function MergeModal({
26
+ conflicts,
27
+ localDiff,
28
+ onResolve,
29
+ onCancel,
30
+ }: MergeModalProps): React.ReactElement {
31
+ const [choices, setChoices] = useState<Record<string, Resolution>>(
32
+ () =>
33
+ Object.fromEntries(conflicts.map((c) => [c.path, "server" as Resolution]))
34
+ );
35
+
36
+ function handleChoice(path: string, choice: Resolution) {
37
+ setChoices((prev) => ({ ...prev, [path]: choice }));
38
+ }
39
+
40
+ function handleResolve() {
41
+ const resolutions: Record<string, unknown> = {};
42
+ for (const conflict of conflicts) {
43
+ if (choices[conflict.path] === "server") {
44
+ resolutions[conflict.path] = conflict.resolvedValue;
45
+ } else {
46
+ // "mine": extract value from localDiff
47
+ resolutions[conflict.path] = getFieldValue(localDiff, conflict.path);
48
+ }
49
+ }
50
+ onResolve(resolutions);
51
+ }
52
+
53
+ return (
54
+ <div
55
+ role="dialog"
56
+ aria-modal="true"
57
+ aria-labelledby="merge-modal-title"
58
+ style={{
59
+ position: "fixed",
60
+ inset: 0,
61
+ display: "flex",
62
+ alignItems: "center",
63
+ justifyContent: "center",
64
+ backgroundColor: "rgba(0,0,0,0.45)",
65
+ zIndex: 1000,
66
+ }}
67
+ >
68
+ <div
69
+ style={{
70
+ backgroundColor: "#ffffff",
71
+ borderRadius: "0.5rem",
72
+ boxShadow: "0 20px 60px rgba(0,0,0,0.3)",
73
+ width: "min(90vw, 640px)",
74
+ maxHeight: "80vh",
75
+ display: "flex",
76
+ flexDirection: "column",
77
+ overflow: "hidden",
78
+ }}
79
+ >
80
+ {/* Header */}
81
+ <div
82
+ style={{
83
+ padding: "1rem 1.25rem",
84
+ borderBottom: "1px solid #e5e7eb",
85
+ display: "flex",
86
+ alignItems: "center",
87
+ justifyContent: "space-between",
88
+ }}
89
+ >
90
+ <h2
91
+ id="merge-modal-title"
92
+ style={{ margin: 0, fontSize: "1rem", fontWeight: 600 }}
93
+ >
94
+ Resolve Conflicts ({conflicts.length})
95
+ </h2>
96
+ <button
97
+ type="button"
98
+ aria-label="Close"
99
+ onClick={onCancel}
100
+ style={{
101
+ background: "none",
102
+ border: "none",
103
+ cursor: "pointer",
104
+ fontSize: "1.25rem",
105
+ }}
106
+ >
107
+
108
+ </button>
109
+ </div>
110
+
111
+ {/* Conflict list */}
112
+ <div style={{ overflowY: "auto", flex: 1, padding: "0.75rem 1.25rem" }}>
113
+ {conflicts.map((conflict) => (
114
+ <ConflictRow
115
+ key={conflict.path}
116
+ conflict={conflict}
117
+ localValue={getFieldValue(localDiff, conflict.path)}
118
+ choice={choices[conflict.path] ?? "server"}
119
+ onChoose={(c) => handleChoice(conflict.path, c)}
120
+ />
121
+ ))}
122
+ </div>
123
+
124
+ {/* Footer */}
125
+ <div
126
+ style={{
127
+ padding: "0.75rem 1.25rem",
128
+ borderTop: "1px solid #e5e7eb",
129
+ display: "flex",
130
+ justifyContent: "flex-end",
131
+ gap: "0.5rem",
132
+ }}
133
+ >
134
+ <button
135
+ type="button"
136
+ onClick={onCancel}
137
+ style={{
138
+ padding: "0.5rem 1rem",
139
+ borderRadius: "0.375rem",
140
+ border: "1px solid #d1d5db",
141
+ background: "#ffffff",
142
+ cursor: "pointer",
143
+ }}
144
+ >
145
+ Cancel
146
+ </button>
147
+ <button
148
+ type="button"
149
+ onClick={handleResolve}
150
+ style={{
151
+ padding: "0.5rem 1rem",
152
+ borderRadius: "0.375rem",
153
+ border: "none",
154
+ backgroundColor: "#2563eb",
155
+ color: "#ffffff",
156
+ cursor: "pointer",
157
+ fontWeight: 500,
158
+ }}
159
+ >
160
+ Apply Resolutions
161
+ </button>
162
+ </div>
163
+ </div>
164
+ </div>
165
+ );
166
+ }
167
+
168
+ // ── internal components ───────────────────────────────────────────────────────
169
+
170
+ interface ConflictRowProps {
171
+ conflict: FieldConflict;
172
+ localValue: unknown;
173
+ choice: Resolution;
174
+ onChoose: (c: Resolution) => void;
175
+ }
176
+
177
+ function ConflictRow({
178
+ conflict,
179
+ localValue,
180
+ choice,
181
+ onChoose,
182
+ }: ConflictRowProps): React.ReactElement {
183
+ return (
184
+ <div
185
+ style={{
186
+ marginBottom: "1rem",
187
+ borderRadius: "0.375rem",
188
+ border: "1px solid #e5e7eb",
189
+ overflow: "hidden",
190
+ }}
191
+ >
192
+ <div
193
+ style={{
194
+ padding: "0.5rem 0.75rem",
195
+ backgroundColor: "#f9fafb",
196
+ borderBottom: "1px solid #e5e7eb",
197
+ display: "flex",
198
+ alignItems: "center",
199
+ gap: "0.5rem",
200
+ }}
201
+ >
202
+ <code style={{ fontSize: "0.8125rem", flex: 1 }}>{conflict.path}</code>
203
+ <span
204
+ style={{
205
+ fontSize: "0.75rem",
206
+ padding: "0.125rem 0.375rem",
207
+ borderRadius: "0.25rem",
208
+ backgroundColor:
209
+ conflict.strategy === "TRANSACTIONAL" ? "#fde8e8" : "#e0f2fe",
210
+ color:
211
+ conflict.strategy === "TRANSACTIONAL" ? "#991b1b" : "#0369a1",
212
+ }}
213
+ >
214
+ {conflict.strategy}
215
+ </span>
216
+ </div>
217
+
218
+ <div
219
+ style={{
220
+ display: "grid",
221
+ gridTemplateColumns: "1fr 1fr",
222
+ gap: 0,
223
+ }}
224
+ >
225
+ {/* Mine */}
226
+ <OptionPanel
227
+ label="My change"
228
+ value={localValue}
229
+ selected={choice === "mine"}
230
+ onClick={() => onChoose("mine")}
231
+ accentColor="#2563eb"
232
+ />
233
+ {/* Server */}
234
+ <OptionPanel
235
+ label="Server (authoritative)"
236
+ value={conflict.resolvedValue}
237
+ selected={choice === "server"}
238
+ onClick={() => onChoose("server")}
239
+ accentColor="#16a34a"
240
+ bordered
241
+ />
242
+ </div>
243
+ </div>
244
+ );
245
+ }
246
+
247
+ interface OptionPanelProps {
248
+ label: string;
249
+ value: unknown;
250
+ selected: boolean;
251
+ onClick: () => void;
252
+ accentColor: string;
253
+ bordered?: boolean;
254
+ }
255
+
256
+ function OptionPanel({
257
+ label,
258
+ value,
259
+ selected,
260
+ onClick,
261
+ accentColor,
262
+ bordered,
263
+ }: OptionPanelProps): React.ReactElement {
264
+ return (
265
+ <button
266
+ type="button"
267
+ onClick={onClick}
268
+ style={{
269
+ padding: "0.75rem",
270
+ textAlign: "left",
271
+ cursor: "pointer",
272
+ border: "none",
273
+ borderLeft: bordered ? "1px solid #e5e7eb" : undefined,
274
+ backgroundColor: selected ? `${accentColor}10` : "#ffffff",
275
+ outline: selected ? `2px solid ${accentColor}` : "none",
276
+ outlineOffset: "-2px",
277
+ transition: "background-color 100ms",
278
+ width: "100%",
279
+ }}
280
+ >
281
+ <div
282
+ style={{
283
+ fontSize: "0.75rem",
284
+ fontWeight: 600,
285
+ color: selected ? accentColor : "#6b7280",
286
+ marginBottom: "0.25rem",
287
+ }}
288
+ >
289
+ {selected && "✔ "}
290
+ {label}
291
+ </div>
292
+ <pre
293
+ style={{
294
+ margin: 0,
295
+ fontSize: "0.75rem",
296
+ fontFamily: "monospace",
297
+ whiteSpace: "pre-wrap",
298
+ wordBreak: "break-all",
299
+ color: "#1f2937",
300
+ }}
301
+ >
302
+ {JSON.stringify(value, null, 2)}
303
+ </pre>
304
+ </button>
305
+ );
306
+ }
307
+
308
+ // ── helpers ───────────────────────────────────────────────────────────────────
309
+
310
+ /** Extract the value at a JSON-pointer path (e.g. `/amount`) from an object. */
311
+ function getFieldValue(obj: unknown, pointer: string): unknown {
312
+ if (typeof obj !== "object" || obj === null) return undefined;
313
+ // Strip leading `/` and split on `/`
314
+ const segments = pointer.replace(/^\//, "").split("/");
315
+ let current: unknown = obj;
316
+ for (const seg of segments) {
317
+ if (typeof current !== "object" || current === null) return undefined;
318
+ current = (current as Record<string, unknown>)[seg];
319
+ }
320
+ return current;
321
+ }
@@ -0,0 +1,87 @@
1
+ import React from "react";
2
+ import type { RejectResponse } from "./wire";
3
+ import { UndoButton } from "./UndoButton";
4
+
5
+ /**
6
+ * Displayed as a dismissible banner at the top of the form whenever the server
7
+ * REJECTs an operation. Shows the rejection reason and provides a "Undo" and
8
+ * "Resolve conflicts" action.
9
+ */
10
+ export interface ReconciliationBannerProps {
11
+ /** The full server REJECT response. */
12
+ rejection: RejectResponse;
13
+ /** Called when the user clicks "Undo". */
14
+ onUndo: () => void;
15
+ /** Called when the user clicks "Resolve conflicts". */
16
+ onResolve: () => void;
17
+ /** Called when the user dismisses the banner. */
18
+ onDismiss: () => void;
19
+ }
20
+
21
+ export function ReconciliationBanner({
22
+ rejection,
23
+ onUndo,
24
+ onResolve,
25
+ onDismiss,
26
+ }: ReconciliationBannerProps): React.ReactElement {
27
+ const hasConflicts = rejection.patch.conflicts.length > 0;
28
+
29
+ return (
30
+ <div
31
+ role="alert"
32
+ aria-live="assertive"
33
+ style={{
34
+ display: "flex",
35
+ alignItems: "center",
36
+ gap: "0.75rem",
37
+ padding: "0.75rem 1rem",
38
+ borderRadius: "0.375rem",
39
+ backgroundColor: "#fef3c7",
40
+ borderLeft: "4px solid #f59e0b",
41
+ color: "#92400e",
42
+ fontSize: "0.875rem",
43
+ }}
44
+ >
45
+ <span style={{ flex: 1 }}>
46
+ <strong>Sync conflict:</strong> {rejection.reason}
47
+ </span>
48
+
49
+ <UndoButton onUndo={onUndo} />
50
+
51
+ {hasConflicts && (
52
+ <button
53
+ type="button"
54
+ onClick={onResolve}
55
+ style={{
56
+ padding: "0.25rem 0.75rem",
57
+ borderRadius: "0.25rem",
58
+ border: "1px solid #b45309",
59
+ backgroundColor: "transparent",
60
+ color: "#92400e",
61
+ cursor: "pointer",
62
+ fontSize: "0.875rem",
63
+ }}
64
+ >
65
+ Resolve conflicts ({rejection.patch.conflicts.length})
66
+ </button>
67
+ )}
68
+
69
+ <button
70
+ type="button"
71
+ aria-label="Dismiss"
72
+ onClick={onDismiss}
73
+ style={{
74
+ background: "none",
75
+ border: "none",
76
+ cursor: "pointer",
77
+ color: "#92400e",
78
+ fontSize: "1rem",
79
+ lineHeight: 1,
80
+ padding: "0.125rem",
81
+ }}
82
+ >
83
+
84
+ </button>
85
+ </div>
86
+ );
87
+ }
@@ -0,0 +1,45 @@
1
+ import React from "react";
2
+
3
+ /**
4
+ * A small undo button shown inline (e.g. in the {@link ReconciliationBanner}).
5
+ * Reverts the optimistic local change that was rejected by the server.
6
+ */
7
+ export interface UndoButtonProps {
8
+ /** Callback invoked when the user clicks the undo button. */
9
+ onUndo: () => void;
10
+ /** Optional label (defaults to "Undo"). */
11
+ label?: string;
12
+ /** Whether the undo action is currently available. */
13
+ disabled?: boolean;
14
+ }
15
+
16
+ export function UndoButton({
17
+ onUndo,
18
+ label = "Undo",
19
+ disabled = false,
20
+ }: UndoButtonProps): React.ReactElement {
21
+ return (
22
+ <button
23
+ type="button"
24
+ onClick={onUndo}
25
+ disabled={disabled}
26
+ aria-label="Undo last change"
27
+ style={{
28
+ display: "inline-flex",
29
+ alignItems: "center",
30
+ gap: "0.25rem",
31
+ padding: "0.25rem 0.75rem",
32
+ borderRadius: "0.25rem",
33
+ border: "1px solid #b45309",
34
+ backgroundColor: "#ffffff",
35
+ color: "#92400e",
36
+ cursor: disabled ? "not-allowed" : "pointer",
37
+ fontSize: "0.875rem",
38
+ opacity: disabled ? 0.5 : 1,
39
+ transition: "background-color 150ms",
40
+ }}
41
+ >
42
+ ↩ {label}
43
+ </button>
44
+ );
45
+ }