@farm.js/sync 0.1.0-beta.1 → 0.1.0-beta.2

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/client.d.ts CHANGED
@@ -35,6 +35,18 @@ export declare function startSyncRuntime(config: SyncRuntimeConfig, options?: Sy
35
35
  export declare function getSyncStore(model: string): SyncModelStore;
36
36
  /** Fetch rows for a model, incrementally when the schema supports a cursor. */
37
37
  export declare function loadModel(model: string): Promise<void>;
38
+ /**
39
+ * Run a server-defined action with the mutation lifecycle: an optimistic
40
+ * layer when the effect is predictable, the same offline pause, commit of
41
+ * whatever rows the server returns, rollback plus a failures entry on
42
+ * refusal.
43
+ *
44
+ * The optimistic patch resolves in tiers: an explicit patch wins; otherwise
45
+ * input fields that are schema columns form one (when the input carries the
46
+ * model's key); otherwise the action runs with no preview and the screen
47
+ * updates when the server's rows land.
48
+ */
49
+ export declare function runSyncAction(model: string, action: string, invoke: (input: unknown) => Promise<unknown>, input: unknown, explicitPatch?: SyncRow): SyncMutationHandle;
38
50
  export type SyncModelClient = {
39
51
  insert(input: SyncRow): SyncMutationHandle;
40
52
  update(input: SyncRow): SyncMutationHandle;
@@ -48,6 +60,21 @@ export type SyncModelClient = {
48
60
  /** @internal */
49
61
  readonly __farmSyncModel: string;
50
62
  };
63
+ /**
64
+ * Model row types, filled in by the generated `farm.d.ts` through declaration
65
+ * merging. Empty here so an app without generated types still compiles with
66
+ * plain `SyncRow` rows and any model name.
67
+ */
68
+ export interface SyncModels {
69
+ }
70
+ /** Insert shapes per model: key, defaulted, and nullable columns optional. */
71
+ export interface SyncModelInputs {
72
+ }
73
+ export type SyncModelName = [keyof SyncModels] extends [never] ? string : keyof SyncModels & string;
74
+ export type SyncRowOf<K> = K extends keyof SyncModels ? SyncModels[K] : SyncRow;
75
+ export type SyncInsertOf<K> = K extends keyof SyncModelInputs ? SyncModelInputs[K] : SyncRow;
76
+ /** The per-model client, created lazily. The `db` proxy delegates here. */
77
+ export declare function getSyncModelClient(model: string): SyncModelClient;
51
78
  /** Typed per-model client accessor, resolved lazily so imports stay cheap. */
52
79
  export declare const db: Record<string, SyncModelClient>;
53
80
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../src/client.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../src/client.ts"],"names":[],"mappings":"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"}
package/dist/client.js CHANGED
@@ -159,6 +159,12 @@ function runMutation(model, operation, input, optimistic) {
159
159
  catch (error) {
160
160
  store.removeLayer(layer);
161
161
  handleState = "failed";
162
+ store.recordFailure({
163
+ operation,
164
+ input,
165
+ error: error instanceof Error ? error : new Error(String(error)),
166
+ retry: () => runMutation(model, operation, input, optimistic),
167
+ });
162
168
  throw error;
163
169
  }
164
170
  finally {
@@ -181,7 +187,112 @@ function runMutation(model, operation, input, optimistic) {
181
187
  finally: (onFinally) => isPersisted.finally(onFinally),
182
188
  };
183
189
  }
190
+ /**
191
+ * Run a server-defined action with the mutation lifecycle: an optimistic
192
+ * layer when the effect is predictable, the same offline pause, commit of
193
+ * whatever rows the server returns, rollback plus a failures entry on
194
+ * refusal.
195
+ *
196
+ * The optimistic patch resolves in tiers: an explicit patch wins; otherwise
197
+ * input fields that are schema columns form one (when the input carries the
198
+ * model's key); otherwise the action runs with no preview and the screen
199
+ * updates when the server's rows land.
200
+ */
201
+ export function runSyncAction(model, action, invoke, input, explicitPatch) {
202
+ const state = requireRuntime();
203
+ const store = getSyncStore(model);
204
+ const keyField = store.descriptor.key;
205
+ const record = input && typeof input === "object" ? input : undefined;
206
+ const key = record?.[keyField];
207
+ let patch = explicitPatch;
208
+ if (!patch && record && key !== undefined) {
209
+ const columns = new Set(store.descriptor.fields ?? []);
210
+ const derived = Object.fromEntries(Object.entries(record).filter(([field]) => field !== keyField && columns.has(field)));
211
+ if (Object.keys(derived).length > 0)
212
+ patch = derived;
213
+ }
214
+ const layer = patch && key !== undefined
215
+ ? { type: "upsert", key, row: store.mergeRow(key, patch) }
216
+ : null;
217
+ if (layer)
218
+ store.addLayer(layer);
219
+ store.trackPending(1);
220
+ let handleState = "pending";
221
+ const isPersisted = (async () => {
222
+ try {
223
+ if ((state.options.networkMode ?? "online") === "online" && !isOnline()) {
224
+ store.trackPaused(1);
225
+ try {
226
+ await waitForOnline();
227
+ }
228
+ finally {
229
+ store.trackPaused(-1);
230
+ }
231
+ }
232
+ const result = await invoke(input);
233
+ const rows = Array.isArray(result) ? result : [result];
234
+ let committed = false;
235
+ for (const row of rows) {
236
+ if (row && typeof row === "object" && row[keyField] !== undefined) {
237
+ // Commit through the layer once so the prediction is replaced, then
238
+ // fold any further returned rows straight into the confirmed set.
239
+ store.commitLayer(!committed && layer
240
+ ? layer
241
+ : { type: "upsert", key: row[keyField], row: row }, row);
242
+ committed = true;
243
+ }
244
+ }
245
+ if (!committed) {
246
+ // The action changed something this client cannot address by key;
247
+ // drop the prediction and converge on the server's truth instead.
248
+ if (layer)
249
+ store.removeLayer(layer);
250
+ void loadModel(model);
251
+ }
252
+ handleState = "completed";
253
+ persistModel(model);
254
+ return (rows[0] ?? null);
255
+ }
256
+ catch (error) {
257
+ if (layer)
258
+ store.removeLayer(layer);
259
+ handleState = "failed";
260
+ store.recordFailure({
261
+ operation: action,
262
+ input,
263
+ error: error instanceof Error ? error : new Error(String(error)),
264
+ retry: () => runSyncAction(model, action, invoke, input, explicitPatch),
265
+ });
266
+ throw error;
267
+ }
268
+ finally {
269
+ store.trackPending(-1);
270
+ }
271
+ })();
272
+ void isPersisted.catch(() => undefined);
273
+ return {
274
+ key,
275
+ get state() {
276
+ return handleState;
277
+ },
278
+ get persisted() {
279
+ return isPersisted;
280
+ },
281
+ then: (onFulfilled, onRejected) => isPersisted.then(onFulfilled, onRejected),
282
+ catch: (onRejected) => isPersisted.catch(onRejected),
283
+ finally: (onFinally) => isPersisted.finally(onFinally),
284
+ };
285
+ }
184
286
  const clientCache = new Map();
287
+ /** The per-model client, created lazily. The `db` proxy delegates here. */
288
+ export function getSyncModelClient(model) {
289
+ let client = clientCache.get(model);
290
+ if (!client) {
291
+ client = createModelClient(model);
292
+ clientCache.set(model, client);
293
+ }
294
+ return client;
295
+ }
185
296
  function createModelClient(model) {
186
297
  const client = {
187
298
  insert(input) {
@@ -243,12 +354,7 @@ export const db = new Proxy({}, {
243
354
  get(_target, property) {
244
355
  if (typeof property !== "string")
245
356
  return undefined;
246
- let client = clientCache.get(property);
247
- if (!client) {
248
- client = createModelClient(property);
249
- clientCache.set(property, client);
250
- }
251
- return client;
357
+ return getSyncModelClient(property);
252
358
  },
253
359
  });
254
360
  // ── persistence ────────────────────────────────────────────────────────────
package/dist/index.d.ts CHANGED
@@ -32,6 +32,13 @@ export type SyncPluginOptions = {
32
32
  * cannot be detected from its shape.
33
33
  */
34
34
  dialect?: FarmSqlDialect;
35
+ /**
36
+ * Extra origins allowed to call the sync endpoint, using the
37
+ * `serverActions.allowedOrigins` pattern syntax. Sync writes ride the
38
+ * caller's cookies, so cross-site requests are rejected unless the origin is
39
+ * listed here.
40
+ */
41
+ allowedOrigins?: readonly string[];
35
42
  };
36
43
  export type SyncMiddlewareContext = {
37
44
  request: Request;
@@ -48,6 +55,7 @@ export declare function sync(options: SyncPluginOptions): import("@farm.js/core"
48
55
  access: import("./types.js").SyncAccess;
49
56
  persist: boolean;
50
57
  cursor: string | null;
58
+ fields: string[];
51
59
  };
52
60
  };
53
61
  }, unknown, false, import("@farm.js/core").PluginRoutes>;
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,UAAU,EAAE,cAAc,EAAE,MAAM,eAAe,CAAC;AAChE,OAAO,EAAE,oBAAoB,EAAE,kBAAkB,EAAsB,MAAM,aAAa,CAAC;AAC3F,OAAO,EACL,iBAAiB,EACjB,KAAK,iBAAiB,EACtB,KAAK,gBAAgB,EAErB,KAAK,SAAS,EACf,MAAM,YAAY,CAAC;AAEpB,MAAM,MAAM,iBAAiB,GAAG;IAC9B,wEAAwE;IACxE,MAAM,EAAE,UAAU,CAAC;IACnB;;;;OAIG;IACH,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB;;;;;OAKG;IACH,MAAM,CAAC,EAAE,OAAO,GAAG,CAAC,MAAM,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC,CAAC;IACtD,+DAA+D;IAC/D,KAAK,CAAC,EAAE,SAAS,GAAG,KAAK,CAAC;IAC1B,mFAAmF;IACnF,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,gBAAgB,CAAC,CAAC;IACzC,kEAAkE;IAClE,UAAU,CAAC,EAAE,SAAS,cAAc,EAAE,CAAC;IACvC,kCAAkC;IAClC,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,gEAAgE;IAChE,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB;;;OAGG;IACH,OAAO,CAAC,EAAE,cAAc,CAAC;CAC1B,CAAC;AAEF,MAAM,MAAM,qBAAqB,GAAG;IAClC,OAAO,EAAE,OAAO,CAAC;IACjB,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CAClC,CAAC;AAEF,MAAM,MAAM,cAAc,GAAG,CAC3B,GAAG,EAAE,qBAAqB,KACvB,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,GAAG,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAAC,CAAC;AAE9E,YAAY,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC;AACjD,YAAY,EACV,UAAU,EACV,eAAe,EACf,gBAAgB,EAChB,aAAa,EACb,SAAS,EACT,gBAAgB,GACjB,MAAM,YAAY,CAAC;AAIpB,wBAAgB,IAAI,CAAC,OAAO,EAAE,iBAAiB;;;;;;;;;;yDAuH9C;AAiHD,mCAAmC;AACnC,OAAO,EAAE,iBAAiB,EAAE,oBAAoB,EAAE,kBAAkB,EAAE,CAAC;AACvE,YAAY,EAAE,iBAAiB,EAAE,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAOA,OAAO,KAAK,EAAE,UAAU,EAAE,cAAc,EAAE,MAAM,eAAe,CAAC;AAChE,OAAO,EAAE,oBAAoB,EAAE,kBAAkB,EAAsB,MAAM,aAAa,CAAC;AAC3F,OAAO,EACL,iBAAiB,EACjB,KAAK,iBAAiB,EACtB,KAAK,gBAAgB,EAErB,KAAK,SAAS,EACf,MAAM,YAAY,CAAC;AAEpB,MAAM,MAAM,iBAAiB,GAAG;IAC9B,wEAAwE;IACxE,MAAM,EAAE,UAAU,CAAC;IACnB;;;;OAIG;IACH,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB;;;;;OAKG;IACH,MAAM,CAAC,EAAE,OAAO,GAAG,CAAC,MAAM,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC,CAAC;IACtD,+DAA+D;IAC/D,KAAK,CAAC,EAAE,SAAS,GAAG,KAAK,CAAC;IAC1B,mFAAmF;IACnF,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,gBAAgB,CAAC,CAAC;IACzC,kEAAkE;IAClE,UAAU,CAAC,EAAE,SAAS,cAAc,EAAE,CAAC;IACvC,kCAAkC;IAClC,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,gEAAgE;IAChE,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB;;;OAGG;IACH,OAAO,CAAC,EAAE,cAAc,CAAC;IACzB;;;;;OAKG;IACH,cAAc,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;CACpC,CAAC;AAEF,MAAM,MAAM,qBAAqB,GAAG;IAClC,OAAO,EAAE,OAAO,CAAC;IACjB,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CAClC,CAAC;AAEF,MAAM,MAAM,cAAc,GAAG,CAC3B,GAAG,EAAE,qBAAqB,KACvB,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,GAAG,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAAC,CAAC;AAE9E,YAAY,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC;AACjD,YAAY,EACV,UAAU,EACV,eAAe,EACf,gBAAgB,EAChB,aAAa,EACb,SAAS,EACT,gBAAgB,GACjB,MAAM,YAAY,CAAC;AAIpB,wBAAgB,IAAI,CAAC,OAAO,EAAE,iBAAiB;;;;;;;;;;;yDAgJ9C;AAuLD,mCAAmC;AACnC,OAAO,EAAE,iBAAiB,EAAE,oBAAoB,EAAE,kBAAkB,EAAE,CAAC;AACvE,YAAY,EAAE,iBAAiB,EAAE,CAAC"}
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { declareSchemaTables, definePlugin } from "@farm.js/core";
1
+ import { declareSchemaTables, definePlugin, describeIntegrationOriginRejection, resolveIntegrationAllowedOrigins, validateIntegrationRequestOrigin, } from "@farm.js/core";
2
2
  import { executeSyncOperation, SyncOperationError } from "./server.js";
3
3
  import { resolveSyncModels, } from "./types.js";
4
4
  const DEFAULT_PATH = "/_farm/sync";
@@ -11,6 +11,8 @@ export function sync(options) {
11
11
  if (models.size === 0) {
12
12
  throw new Error("sync(): no models are exposed. Add at least one entry to `models`.");
13
13
  }
14
+ // Resolved once so an invalid pattern fails at startup, not per request.
15
+ const allowedOrigins = resolveIntegrationAllowedOrigins(options.allowedOrigins, "sync().allowedOrigins");
14
16
  let ormPromise;
15
17
  const resolveOrm = () => {
16
18
  ormPromise ??= resolveSyncClient(options, models);
@@ -29,6 +31,7 @@ export function sync(options) {
29
31
  access: model.access,
30
32
  persist: model.persist,
31
33
  cursor: model.cursorField ?? null,
34
+ fields: Object.keys(model.fields),
32
35
  },
33
36
  ])),
34
37
  },
@@ -39,12 +42,26 @@ export function sync(options) {
39
42
  scope.__FARM_SYNC__?.start?.(config);
40
43
  },
41
44
  },
45
+ // The production runtime only has a Web Request/Response boundary. Keep
46
+ // the endpoint on the runtime hook so it is handled by both Nitro and the
47
+ // development server instead of relying solely on the legacy Node hook.
48
+ runtime: {
49
+ async before({ request }) {
50
+ return handleSyncRequest(request, undefined, {
51
+ path,
52
+ models,
53
+ middleware: options.middleware,
54
+ resolveOrm,
55
+ allowedOrigins,
56
+ });
57
+ },
58
+ },
42
59
  async beforeRequest(req, res) {
43
60
  const pathname = (req.url ?? "").split("?")[0];
44
61
  if (pathname !== path)
45
62
  return;
46
63
  if (req.method !== "POST") {
47
- sendJson(res, 405, { error: { code: "invalid_input", message: "Sync uses POST." } });
64
+ await sendNodeResponse(res, jsonResponse(405, { error: { code: "invalid_input", message: "Sync uses POST." } }));
48
65
  return;
49
66
  }
50
67
  let body;
@@ -52,36 +69,31 @@ export function sync(options) {
52
69
  body = await readJsonBody(req);
53
70
  }
54
71
  catch {
55
- sendJson(res, 400, {
72
+ await sendNodeResponse(res, jsonResponse(400, {
56
73
  error: { code: "invalid_input", message: "Request body must be JSON." },
57
- });
74
+ }));
58
75
  return;
59
76
  }
60
77
  if (!body || typeof body.model !== "string" || typeof body.operation !== "string") {
61
- sendJson(res, 400, {
62
- error: { code: "invalid_input", message: "Request body needs `model` and `operation`." },
63
- });
78
+ await sendNodeResponse(res, jsonResponse(400, {
79
+ error: {
80
+ code: "invalid_input",
81
+ message: "Request body needs `model` and `operation`.",
82
+ },
83
+ }));
64
84
  return;
65
85
  }
66
86
  // The operation layer works in web-standard terms, so adapt once here.
67
87
  const request = toWebRequest(req, path);
68
- try {
69
- const context = await runMiddleware(options.middleware, request);
70
- const orm = await resolveOrm();
71
- const result = await executeSyncOperation({ body, models, orm, request, context });
72
- sendJson(res, 200, result);
73
- }
74
- catch (error) {
75
- if (error instanceof SyncOperationError) {
76
- sendJson(res, error.status, { error: { code: error.code, message: error.message } });
77
- return;
78
- }
79
- // Never leak an internal message to the browser.
80
- console.error("[farm:sync] operation failed", error);
81
- sendJson(res, 500, {
82
- error: { code: "server_error", message: "The sync operation failed." },
83
- });
84
- }
88
+ const response = await handleSyncRequest(request, body, {
89
+ path,
90
+ models,
91
+ middleware: options.middleware,
92
+ resolveOrm,
93
+ allowedOrigins,
94
+ });
95
+ if (response)
96
+ await sendNodeResponse(res, response);
85
97
  },
86
98
  });
87
99
  // Declare the tables sync owns, so `farm sync migrate` can create them
@@ -106,6 +118,75 @@ export function sync(options) {
106
118
  },
107
119
  });
108
120
  }
121
+ async function handleSyncRequest(request, bodyOverride, options) {
122
+ const pathname = new URL(request.url).pathname;
123
+ if (pathname !== options.path)
124
+ return undefined;
125
+ if (request.method !== "POST") {
126
+ return jsonResponse(405, { error: { code: "invalid_input", message: "Sync uses POST." } });
127
+ }
128
+ // Sync operations ride the caller's cookies, and a cross-site page can send
129
+ // this POST as a simple request with no preflight. Reject foreign origins
130
+ // before touching the body, mirroring the server-action origin contract.
131
+ const origin = validateIntegrationRequestOrigin(request, {
132
+ allowedOrigins: options.allowedOrigins,
133
+ requireOriginMetadata: true,
134
+ });
135
+ if (!origin.ok) {
136
+ return jsonResponse(403, {
137
+ error: { code: "unauthorized", message: describeIntegrationOriginRejection(origin.reason) },
138
+ });
139
+ }
140
+ let body;
141
+ try {
142
+ body = bodyOverride ?? (await request.clone().json());
143
+ }
144
+ catch {
145
+ return jsonResponse(400, {
146
+ error: { code: "invalid_input", message: "Request body must be JSON." },
147
+ });
148
+ }
149
+ if (!body || typeof body.model !== "string" || typeof body.operation !== "string") {
150
+ return jsonResponse(400, {
151
+ error: { code: "invalid_input", message: "Request body needs `model` and `operation`." },
152
+ });
153
+ }
154
+ try {
155
+ const context = await runMiddleware(options.middleware, request);
156
+ const orm = await options.resolveOrm();
157
+ const result = await executeSyncOperation({
158
+ body,
159
+ models: options.models,
160
+ orm,
161
+ request,
162
+ context,
163
+ });
164
+ return jsonResponse(200, result);
165
+ }
166
+ catch (error) {
167
+ if (error instanceof SyncOperationError) {
168
+ return jsonResponse(error.status, {
169
+ error: { code: error.code, message: error.message },
170
+ });
171
+ }
172
+ // Never leak an internal message to the browser.
173
+ console.error("[farm:sync] operation failed", error);
174
+ return jsonResponse(500, {
175
+ error: { code: "server_error", message: "The sync operation failed." },
176
+ });
177
+ }
178
+ }
179
+ function jsonResponse(status, payload) {
180
+ return new Response(JSON.stringify(payload), {
181
+ status,
182
+ headers: { "content-type": "application/json; charset=utf-8" },
183
+ });
184
+ }
185
+ async function sendNodeResponse(res, response) {
186
+ res.statusCode = response.status;
187
+ response.headers.forEach((value, name) => res.setHeader?.(name, value));
188
+ res.end(Buffer.from(await response.arrayBuffer()));
189
+ }
109
190
  async function runMiddleware(middleware, request) {
110
191
  let context = {};
111
192
  for (const entry of middleware ?? []) {
@@ -142,20 +223,6 @@ async function buildOrm(schema, client) {
142
223
  const { createIntegrationOrm } = await import("@farm.js/core");
143
224
  return (await createIntegrationOrm({ schema, client }));
144
225
  }
145
- /**
146
- * Write a JSON response using the Node primitives every Farm runtime provides.
147
- * The richer `res.status().json()` helpers are not present on every path.
148
- */
149
- function sendJson(res, status, payload) {
150
- if (typeof res.writeHead === "function") {
151
- res.writeHead(status, { "content-type": "application/json; charset=utf-8" });
152
- res.end(JSON.stringify(payload));
153
- return;
154
- }
155
- res.statusCode = status;
156
- res.setHeader?.("content-type", "application/json; charset=utf-8");
157
- res.end(JSON.stringify(payload));
158
- }
159
226
  /** True when every exposed model is already reachable with the methods sync calls. */
160
227
  function isModelClient(value, models) {
161
228
  if (!value || typeof value !== "object")
package/dist/react.d.ts CHANGED
@@ -1,5 +1,6 @@
1
- import { type SyncModelClient } from "./client.js";
2
- import type { SyncRow, SyncStoreStatus } from "./store.js";
1
+ import { type SyncInsertOf, type SyncModelClient, type SyncModelName, type SyncMutationHandle, type SyncRowOf } from "./client.js";
2
+ import type { SyncRow, SyncStoreStatus, SyncWriteFailure } from "./store.js";
3
+ export type { SyncModelInputs, SyncModels } from "./client.js";
3
4
  export type LiveQueryOptions<TRow> = {
4
5
  orderBy?: (row: TRow) => string | number | Date;
5
6
  direction?: "asc" | "desc";
@@ -7,7 +8,7 @@ export type LiveQueryOptions<TRow> = {
7
8
  offset?: number;
8
9
  enabled?: boolean;
9
10
  };
10
- export type LiveQueryResult<TRow> = {
11
+ export type LiveQueryResult<TRow, TInsert = TRow> = {
11
12
  rows: TRow[];
12
13
  status: SyncStoreStatus;
13
14
  error: Error | null;
@@ -15,8 +16,20 @@ export type LiveQueryResult<TRow> = {
15
16
  pending: number;
16
17
  /** Writes waiting for the connection to return. */
17
18
  paused: number;
19
+ /** Same as `paused`, named for what the user sees: queued until reconnect. */
20
+ queued: number;
18
21
  isEmpty: boolean;
19
22
  refresh(): Promise<void>;
23
+ /** @deprecated A query result is for reading; write through `useSyncAction(model).insert`. */
24
+ insert(input: TInsert): SyncMutationHandle<TRow>;
25
+ /** @deprecated Write through `useSyncAction(model).update`. */
26
+ update(key: unknown, patch: Partial<TRow>): SyncMutationHandle<TRow>;
27
+ /** @deprecated Write through `useSyncAction(model).delete`. */
28
+ delete(key: unknown): SyncMutationHandle<TRow>;
29
+ /** True once the server confirmed this row; false while it is optimistic. */
30
+ isPersisted(row: TRow): boolean;
31
+ /** Rolled-back writes awaiting the user, each with retry() and dismiss(). */
32
+ failures: readonly SyncWriteFailure[];
20
33
  };
21
34
  /**
22
35
  * Subscribe to rows of a model, filtered in the browser.
@@ -25,7 +38,50 @@ export type LiveQueryResult<TRow> = {
25
38
  * the browser, so any expression is fair game. Which rows reach the device is
26
39
  * decided separately by the server's row filter.
27
40
  */
41
+ export declare function useLiveQuery<K extends SyncModelName>(model: K, predicate?: (row: SyncRowOf<K>) => boolean, options?: LiveQueryOptions<SyncRowOf<K>>): LiveQueryResult<SyncRowOf<K>, SyncInsertOf<K>>;
28
42
  export declare function useLiveQuery<TRow extends SyncRow = SyncRow>(model: SyncModelClient, predicate?: (row: TRow) => boolean, options?: LiveQueryOptions<TRow>): LiveQueryResult<TRow>;
29
43
  /** Subscribe to a single row by key. */
44
+ export declare function useRow<K extends SyncModelName>(model: K, key: unknown): SyncRowOf<K> | undefined;
30
45
  export declare function useRow<TRow extends SyncRow = SyncRow>(model: SyncModelClient, key: unknown): TRow | undefined;
46
+ export type SyncActionOptions<K extends SyncModelName, TInput> = {
47
+ /**
48
+ * What to show while the server decides, for transitions the input does not
49
+ * spell out. Left out, input fields that are schema columns become the
50
+ * patch; failing that the screen updates when the server's rows arrive.
51
+ */
52
+ optimistic?: Partial<SyncRowOf<K>> | ((input: TInput) => Partial<SyncRowOf<K>>);
53
+ /** Name shown in the failures queue; defaults to the function's name. */
54
+ name?: string;
55
+ };
56
+ export type SyncAction<TInput, TRow> = ((input: TInput) => SyncMutationHandle<TRow>) & {
57
+ /** Calls persisting right now. */
58
+ inFlight: number;
59
+ /** Calls waiting for the connection to return. */
60
+ queued: number;
61
+ /** Rolled-back calls awaiting the user, shared with the model's store. */
62
+ failures: readonly SyncWriteFailure[];
63
+ };
64
+ /** The plain write surface of a model, with the shared durability counters. */
65
+ export type SyncModelActions<TRow, TInsert = TRow> = {
66
+ insert(input: TInsert): SyncMutationHandle<TRow>;
67
+ update(key: unknown, patch: Partial<TRow>): SyncMutationHandle<TRow>;
68
+ delete(key: unknown): SyncMutationHandle<TRow>;
69
+ /** Writes persisting right now. */
70
+ inFlight: number;
71
+ /** Writes waiting for the connection to return. */
72
+ queued: number;
73
+ /** Rolled-back writes awaiting the user, shared with the model's store. */
74
+ failures: readonly SyncWriteFailure[];
75
+ };
76
+ /**
77
+ * The mutation side of a model. `useLiveQuery` reads; this writes.
78
+ *
79
+ * The model-name form is the plain CRUD surface. The function form binds a
80
+ * server-defined action so calling it behaves like every other sync write:
81
+ * an optimistic layer when the effect is predictable, the returned rows
82
+ * committed into the store every live query reads, rollback into the
83
+ * failures queue when the server refuses.
84
+ */
85
+ export declare function useSyncAction<K extends SyncModelName>(model: K): SyncModelActions<SyncRowOf<K>, SyncInsertOf<K>>;
86
+ export declare function useSyncAction<K extends SyncModelName, TInput>(fn: (input: TInput) => Promise<unknown>, model: K, options?: SyncActionOptions<K, TInput>): SyncAction<TInput, SyncRowOf<K>>;
31
87
  //# sourceMappingURL=react.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"react.d.ts","sourceRoot":"","sources":["../src/react.ts"],"names":[],"mappings":"AAGA,OAAO,EAA8B,KAAK,eAAe,EAAE,MAAM,aAAa,CAAC;AAC/E,OAAO,KAAK,EAAE,OAAO,EAAE,eAAe,EAAE,MAAM,YAAY,CAAC;AAI3D,MAAM,MAAM,gBAAgB,CAAC,IAAI,IAAI;IACnC,OAAO,CAAC,EAAE,CAAC,GAAG,EAAE,IAAI,KAAK,MAAM,GAAG,MAAM,GAAG,IAAI,CAAC;IAChD,SAAS,CAAC,EAAE,KAAK,GAAG,MAAM,CAAC;IAC3B,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,OAAO,CAAC,EAAE,OAAO,CAAC;CACnB,CAAC;AAEF,MAAM,MAAM,eAAe,CAAC,IAAI,IAAI;IAClC,IAAI,EAAE,IAAI,EAAE,CAAC;IACb,MAAM,EAAE,eAAe,CAAC;IACxB,KAAK,EAAE,KAAK,GAAG,IAAI,CAAC;IACpB,2CAA2C;IAC3C,OAAO,EAAE,MAAM,CAAC;IAChB,mDAAmD;IACnD,MAAM,EAAE,MAAM,CAAC;IACf,OAAO,EAAE,OAAO,CAAC;IACjB,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;CAC1B,CAAC;AAEF;;;;;;GAMG;AACH,wBAAgB,YAAY,CAAC,IAAI,SAAS,OAAO,GAAG,OAAO,EACzD,KAAK,EAAE,eAAe,EACtB,SAAS,CAAC,EAAE,CAAC,GAAG,EAAE,IAAI,KAAK,OAAO,EAClC,OAAO,GAAE,gBAAgB,CAAC,IAAI,CAAM,GACnC,eAAe,CAAC,IAAI,CAAC,CA6EvB;AAED,wCAAwC;AACxC,wBAAgB,MAAM,CAAC,IAAI,SAAS,OAAO,GAAG,OAAO,EACnD,KAAK,EAAE,eAAe,EACtB,GAAG,EAAE,OAAO,GACX,IAAI,GAAG,SAAS,CAYlB"}
1
+ {"version":3,"file":"react.d.ts","sourceRoot":"","sources":["../src/react.ts"],"names":[],"mappings":"AAGA,OAAO,EAKL,KAAK,YAAY,EACjB,KAAK,eAAe,EACpB,KAAK,aAAa,EAClB,KAAK,kBAAkB,EACvB,KAAK,SAAS,EACf,MAAM,aAAa,CAAC;AACrB,OAAO,KAAK,EAAE,OAAO,EAAE,eAAe,EAAE,gBAAgB,EAAE,MAAM,YAAY,CAAC;AAE7E,YAAY,EAAE,eAAe,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAY/D,MAAM,MAAM,gBAAgB,CAAC,IAAI,IAAI;IACnC,OAAO,CAAC,EAAE,CAAC,GAAG,EAAE,IAAI,KAAK,MAAM,GAAG,MAAM,GAAG,IAAI,CAAC;IAChD,SAAS,CAAC,EAAE,KAAK,GAAG,MAAM,CAAC;IAC3B,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,OAAO,CAAC,EAAE,OAAO,CAAC;CACnB,CAAC;AAEF,MAAM,MAAM,eAAe,CAAC,IAAI,EAAE,OAAO,GAAG,IAAI,IAAI;IAClD,IAAI,EAAE,IAAI,EAAE,CAAC;IACb,MAAM,EAAE,eAAe,CAAC;IACxB,KAAK,EAAE,KAAK,GAAG,IAAI,CAAC;IACpB,2CAA2C;IAC3C,OAAO,EAAE,MAAM,CAAC;IAChB,mDAAmD;IACnD,MAAM,EAAE,MAAM,CAAC;IACf,8EAA8E;IAC9E,MAAM,EAAE,MAAM,CAAC;IACf,OAAO,EAAE,OAAO,CAAC;IACjB,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;IAEzB,8FAA8F;IAC9F,MAAM,CAAC,KAAK,EAAE,OAAO,GAAG,kBAAkB,CAAC,IAAI,CAAC,CAAC;IACjD,+DAA+D;IAC/D,MAAM,CAAC,GAAG,EAAE,OAAO,EAAE,KAAK,EAAE,OAAO,CAAC,IAAI,CAAC,GAAG,kBAAkB,CAAC,IAAI,CAAC,CAAC;IACrE,+DAA+D;IAC/D,MAAM,CAAC,GAAG,EAAE,OAAO,GAAG,kBAAkB,CAAC,IAAI,CAAC,CAAC;IAE/C,6EAA6E;IAC7E,WAAW,CAAC,GAAG,EAAE,IAAI,GAAG,OAAO,CAAC;IAChC,6EAA6E;IAC7E,QAAQ,EAAE,SAAS,gBAAgB,EAAE,CAAC;CACvC,CAAC;AAEF;;;;;;GAMG;AACH,wBAAgB,YAAY,CAAC,CAAC,SAAS,aAAa,EAClD,KAAK,EAAE,CAAC,EACR,SAAS,CAAC,EAAE,CAAC,GAAG,EAAE,SAAS,CAAC,CAAC,CAAC,KAAK,OAAO,EAC1C,OAAO,CAAC,EAAE,gBAAgB,CAAC,SAAS,CAAC,CAAC,CAAC,CAAC,GACvC,eAAe,CAAC,SAAS,CAAC,CAAC,CAAC,EAAE,YAAY,CAAC,CAAC,CAAC,CAAC,CAAC;AAClD,wBAAgB,YAAY,CAAC,IAAI,SAAS,OAAO,GAAG,OAAO,EACzD,KAAK,EAAE,eAAe,EACtB,SAAS,CAAC,EAAE,CAAC,GAAG,EAAE,IAAI,KAAK,OAAO,EAClC,OAAO,CAAC,EAAE,gBAAgB,CAAC,IAAI,CAAC,GAC/B,eAAe,CAAC,IAAI,CAAC,CAAC;AAsGzB,wCAAwC;AACxC,wBAAgB,MAAM,CAAC,CAAC,SAAS,aAAa,EAAE,KAAK,EAAE,CAAC,EAAE,GAAG,EAAE,OAAO,GAAG,SAAS,CAAC,CAAC,CAAC,GAAG,SAAS,CAAC;AAClG,wBAAgB,MAAM,CAAC,IAAI,SAAS,OAAO,GAAG,OAAO,EACnD,KAAK,EAAE,eAAe,EACtB,GAAG,EAAE,OAAO,GACX,IAAI,GAAG,SAAS,CAAC;AAkBpB,MAAM,MAAM,iBAAiB,CAAC,CAAC,SAAS,aAAa,EAAE,MAAM,IAAI;IAC/D;;;;OAIG;IACH,UAAU,CAAC,EAAE,OAAO,CAAC,SAAS,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,KAAK,EAAE,MAAM,KAAK,OAAO,CAAC,SAAS,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;IAChF,yEAAyE;IACzE,IAAI,CAAC,EAAE,MAAM,CAAC;CACf,CAAC;AAEF,MAAM,MAAM,UAAU,CAAC,MAAM,EAAE,IAAI,IAAI,CAAC,CAAC,KAAK,EAAE,MAAM,KAAK,kBAAkB,CAAC,IAAI,CAAC,CAAC,GAAG;IACrF,kCAAkC;IAClC,QAAQ,EAAE,MAAM,CAAC;IACjB,kDAAkD;IAClD,MAAM,EAAE,MAAM,CAAC;IACf,0EAA0E;IAC1E,QAAQ,EAAE,SAAS,gBAAgB,EAAE,CAAC;CACvC,CAAC;AAEF,+EAA+E;AAC/E,MAAM,MAAM,gBAAgB,CAAC,IAAI,EAAE,OAAO,GAAG,IAAI,IAAI;IACnD,MAAM,CAAC,KAAK,EAAE,OAAO,GAAG,kBAAkB,CAAC,IAAI,CAAC,CAAC;IACjD,MAAM,CAAC,GAAG,EAAE,OAAO,EAAE,KAAK,EAAE,OAAO,CAAC,IAAI,CAAC,GAAG,kBAAkB,CAAC,IAAI,CAAC,CAAC;IACrE,MAAM,CAAC,GAAG,EAAE,OAAO,GAAG,kBAAkB,CAAC,IAAI,CAAC,CAAC;IAC/C,mCAAmC;IACnC,QAAQ,EAAE,MAAM,CAAC;IACjB,mDAAmD;IACnD,MAAM,EAAE,MAAM,CAAC;IACf,2EAA2E;IAC3E,QAAQ,EAAE,SAAS,gBAAgB,EAAE,CAAC;CACvC,CAAC;AAEF;;;;;;;;GAQG;AACH,wBAAgB,aAAa,CAAC,CAAC,SAAS,aAAa,EACnD,KAAK,EAAE,CAAC,GACP,gBAAgB,CAAC,SAAS,CAAC,CAAC,CAAC,EAAE,YAAY,CAAC,CAAC,CAAC,CAAC,CAAC;AACnD,wBAAgB,aAAa,CAAC,CAAC,SAAS,aAAa,EAAE,MAAM,EAC3D,EAAE,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,OAAO,CAAC,OAAO,CAAC,EAIvC,KAAK,EAAE,CAAC,EACR,OAAO,CAAC,EAAE,iBAAiB,CAAC,CAAC,EAAE,MAAM,CAAC,GACrC,UAAU,CAAC,MAAM,EAAE,SAAS,CAAC,CAAC,CAAC,CAAC,CAAC"}
package/dist/react.js CHANGED
@@ -1,16 +1,13 @@
1
1
  "use client";
2
2
  import { useCallback, useEffect, useMemo, useRef, useSyncExternalStore } from "react";
3
- import { getSyncStore, hydrateModel } from "./client.js";
3
+ import { getSyncModelClient, getSyncStore, hydrateModel, runSyncAction, } from "./client.js";
4
4
  const EMPTY_ROWS = [];
5
- /**
6
- * Subscribe to rows of a model, filtered in the browser.
7
- *
8
- * The predicate runs over the model's rows on every change; it never leaves
9
- * the browser, so any expression is fair game. Which rows reach the device is
10
- * decided separately by the server's row filter.
11
- */
5
+ const EMPTY_FAILURES = [];
6
+ function resolveModelName(model) {
7
+ return typeof model === "string" ? model : model.__farmSyncModel;
8
+ }
12
9
  export function useLiveQuery(model, predicate, options = {}) {
13
- const name = model.__farmSyncModel;
10
+ const name = resolveModelName(model);
14
11
  const store = getSyncStore(name);
15
12
  const predicateRef = useRef(predicate);
16
13
  predicateRef.current = predicate;
@@ -25,6 +22,7 @@ export function useLiveQuery(model, predicate, options = {}) {
25
22
  const error = useSyncExternalStore(subscribe, () => store.error, () => null);
26
23
  const pending = useSyncExternalStore(subscribe, () => store.pending, () => 0);
27
24
  const paused = useSyncExternalStore(subscribe, () => store.paused, () => 0);
25
+ const failures = useSyncExternalStore(subscribe, () => store.failures, () => EMPTY_FAILURES);
28
26
  useEffect(() => {
29
27
  if (options.enabled === false)
30
28
  return;
@@ -52,20 +50,63 @@ export function useLiveQuery(model, predicate, options = {}) {
52
50
  }
53
51
  return result;
54
52
  }, [rows]);
53
+ const client = useMemo(() => getSyncModelClient(name), [name]);
55
54
  return {
56
55
  rows: visible,
57
56
  status,
58
57
  error,
59
58
  pending,
60
59
  paused,
60
+ queued: paused,
61
61
  isEmpty: visible.length === 0,
62
- refresh: () => model.refresh(),
62
+ refresh: () => client.refresh(),
63
+ insert: (input) => client.insert(input),
64
+ update: (key, patch) => client.update({
65
+ ...patch,
66
+ [store.descriptor.key]: key,
67
+ }),
68
+ delete: (key) => client.delete({ [store.descriptor.key]: key }),
69
+ isPersisted: (row) => store.isPersisted(row),
70
+ failures,
63
71
  };
64
72
  }
65
- /** Subscribe to a single row by key. */
66
73
  export function useRow(model, key) {
67
- const store = getSyncStore(model.__farmSyncModel);
74
+ const store = getSyncStore(resolveModelName(model));
68
75
  const subscribe = useCallback((listener) => store.subscribe(listener), [store]);
69
76
  const rows = useSyncExternalStore(subscribe, () => store.getRows(), () => store.getRows());
70
77
  return useMemo(() => rows.find((row) => String(row[store.descriptor.key]) === String(key)), [rows, key, store.descriptor.key]);
71
78
  }
79
+ export function useSyncAction(fnOrModel, maybeModel, options = {}) {
80
+ const fn = typeof fnOrModel === "function" ? fnOrModel : undefined;
81
+ const model = (typeof fnOrModel === "string" ? fnOrModel : maybeModel);
82
+ const store = getSyncStore(model);
83
+ const subscribe = useCallback((listener) => store.subscribe(listener), [store]);
84
+ const failures = useSyncExternalStore(subscribe, () => store.failures, () => EMPTY_FAILURES);
85
+ const inFlight = useSyncExternalStore(subscribe, () => store.pending, () => 0);
86
+ const queued = useSyncExternalStore(subscribe, () => store.paused, () => 0);
87
+ const optionsRef = useRef(options);
88
+ optionsRef.current = options;
89
+ const fnRef = useRef(fn);
90
+ fnRef.current = fn;
91
+ if (!fn) {
92
+ const client = getSyncModelClient(model);
93
+ const crud = {
94
+ insert: (input) => client.insert(input),
95
+ update: (key, patch) => client.update({ ...patch, [store.descriptor.key]: key }),
96
+ delete: (key) => client.delete({ [store.descriptor.key]: key }),
97
+ inFlight,
98
+ queued,
99
+ failures,
100
+ };
101
+ return crud;
102
+ }
103
+ const call = (input) => {
104
+ const current = optionsRef.current;
105
+ const bound = fnRef.current;
106
+ return runSyncAction(model, current.name || bound.name || "action", (value) => bound(value), input, (typeof current.optimistic === "function" ? current.optimistic(input) : current.optimistic));
107
+ };
108
+ call.inFlight = inFlight;
109
+ call.queued = queued;
110
+ call.failures = failures;
111
+ return call;
112
+ }
package/dist/server.js CHANGED
@@ -95,6 +95,9 @@ async function insertRow(client, resolved, scope, input) {
95
95
  }
96
96
  data[resolved.key] = generated;
97
97
  }
98
+ else {
99
+ requireScalarKey(resolved, data[resolved.key]);
100
+ }
98
101
  // Scope and cursor columns are server-owned: client-supplied values are
99
102
  // discarded so a caller cannot widen its scope or stall everyone's
100
103
  // incremental sync by writing a stale timestamp.
@@ -103,8 +106,15 @@ async function insertRow(client, resolved, scope, input) {
103
106
  async function updateRow(client, resolved, scope, input) {
104
107
  const data = sanitizeInput(resolved, input, { allowKey: false });
105
108
  const key = requireKey(resolved, input);
106
- const where = { ...scope, [resolved.key]: key };
107
- const updated = await client.update({ where, data: { ...data, ...cursorStamp(resolved) } });
109
+ // Scope columns are server-owned on update just like on insert: a client
110
+ // sending one must not move the row into another caller's scope.
111
+ for (const field of Object.keys(scope)) {
112
+ delete data[field];
113
+ }
114
+ const updated = await client.update({
115
+ where: scopedKeyFilter(resolved, scope, key),
116
+ data: { ...data, ...cursorStamp(resolved) },
117
+ });
108
118
  if (!updated) {
109
119
  throw new SyncOperationError("not_found", `No ${resolved.name} row matched ${String(key)} within the current scope.`, 404);
110
120
  }
@@ -112,7 +122,7 @@ async function updateRow(client, resolved, scope, input) {
112
122
  }
113
123
  async function deleteRow(client, resolved, scope, input) {
114
124
  const key = requireKey(resolved, input);
115
- const deleted = await client.deleteMany({ where: { ...scope, [resolved.key]: key } });
125
+ const deleted = await client.deleteMany({ where: scopedKeyFilter(resolved, scope, key) });
116
126
  if (deleted === 0) {
117
127
  throw new SyncOperationError("not_found", `No ${resolved.name} row matched ${String(key)} within the current scope.`, 404);
118
128
  }
@@ -123,8 +133,29 @@ function requireKey(resolved, input) {
123
133
  if (key === undefined || key === null || key === "") {
124
134
  throw new SyncOperationError("invalid_input", `${resolved.name}.${resolved.key} is required for this operation.`);
125
135
  }
136
+ return requireScalarKey(resolved, key);
137
+ }
138
+ /**
139
+ * A key must address exactly one row. Objects are rejected because the orm
140
+ * reads them as operator filters — `{ not: null }` as a delete key would
141
+ * otherwise match every row in scope.
142
+ */
143
+ function requireScalarKey(resolved, key) {
144
+ if (typeof key !== "string" && typeof key !== "number" && typeof key !== "boolean") {
145
+ throw new SyncOperationError("invalid_input", `${resolved.name}.${resolved.key} must be a single value.`);
146
+ }
126
147
  return key;
127
148
  }
149
+ /**
150
+ * Combine the row filter with the client's key without letting one overwrite
151
+ * the other: when the filter itself scopes by the key column, a plain merge
152
+ * would let the client's key replace the scope entirely.
153
+ */
154
+ function scopedKeyFilter(resolved, scope, key) {
155
+ if (Object.keys(scope).length === 0)
156
+ return { [resolved.key]: key };
157
+ return { AND: [scope, { [resolved.key]: key }] };
158
+ }
128
159
  /** Drop unknown columns so a client cannot write outside the declared schema. */
129
160
  function sanitizeInput(resolved, input, options) {
130
161
  if (input !== undefined &&
package/dist/store.d.ts CHANGED
@@ -4,12 +4,27 @@ export type SyncModelDescriptor = {
4
4
  access: "read" | "write";
5
5
  persist: boolean;
6
6
  cursor: string | null;
7
+ /** Schema column names, for deriving an action's optimistic patch from its
8
+ * input. Absent on configs emitted before this field existed. */
9
+ fields?: readonly string[];
7
10
  };
8
11
  export type SyncRuntimeConfig = {
9
12
  path: string;
10
13
  models: Record<string, SyncModelDescriptor>;
11
14
  };
12
15
  export type SyncStoreStatus = "idle" | "loading" | "ready" | "error";
16
+ /** A write the server refused, kept until the user retries or dismisses it. */
17
+ export type SyncWriteFailure = {
18
+ id: number;
19
+ /** "insert" | "update" | "delete", or a server action name. */
20
+ operation: string;
21
+ input: unknown;
22
+ error: Error;
23
+ at: number;
24
+ /** Re-apply the optimistic state and send the write again. */
25
+ retry: () => unknown;
26
+ dismiss: () => void;
27
+ };
13
28
  type OptimisticLayer = {
14
29
  type: "upsert";
15
30
  key: unknown;
@@ -36,6 +51,10 @@ export declare class SyncModelStore {
36
51
  cursor: string | null;
37
52
  pending: number;
38
53
  paused: number;
54
+ /** Rolled-back writes awaiting the user. Replaced, never mutated, so a
55
+ * useSyncExternalStore snapshot of it stays referentially stable. */
56
+ failures: readonly SyncWriteFailure[];
57
+ private nextFailureId;
39
58
  constructor(name: string, descriptor: SyncModelDescriptor);
40
59
  subscribe(listener: () => void): () => void;
41
60
  /** Stable array identity between changes so `useSyncExternalStore` stays quiet. */
@@ -55,6 +74,15 @@ export declare class SyncModelStore {
55
74
  /** Rows worth persisting: confirmed only, never optimistic guesses. */
56
75
  confirmedRows(): SyncRow[];
57
76
  hydrate(rows: SyncRow[], cursor: string | null): void;
77
+ /** True when no optimistic layer still shadows this row's key. */
78
+ isPersisted(row: SyncRow): boolean;
79
+ recordFailure(entry: {
80
+ operation: string;
81
+ input: unknown;
82
+ error: Error;
83
+ retry: () => unknown;
84
+ }): void;
85
+ dismissFailure(id: number): void;
58
86
  trackPending(delta: number): void;
59
87
  trackPaused(delta: number): void;
60
88
  private invalidate;
@@ -1 +1 @@
1
- {"version":3,"file":"store.d.ts","sourceRoot":"","sources":["../src/store.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,OAAO,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;AAE9C,MAAM,MAAM,mBAAmB,GAAG;IAChC,GAAG,EAAE,MAAM,CAAC;IACZ,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC;IACzB,OAAO,EAAE,OAAO,CAAC;IACjB,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;CACvB,CAAC;AAEF,MAAM,MAAM,iBAAiB,GAAG;IAC9B,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,mBAAmB,CAAC,CAAC;CAC7C,CAAC;AAEF,MAAM,MAAM,eAAe,GAAG,MAAM,GAAG,SAAS,GAAG,OAAO,GAAG,OAAO,CAAC;AAErE,KAAK,eAAe,GAChB;IAAE,IAAI,EAAE,QAAQ,CAAC;IAAC,GAAG,EAAE,OAAO,CAAC;IAAC,GAAG,EAAE,OAAO,CAAA;CAAE,GAC9C;IAAE,IAAI,EAAE,QAAQ,CAAC;IAAC,GAAG,EAAE,OAAO,CAAA;CAAE,CAAC;AAErC;;;;GAIG;AACH,qBAAa,cAAc;IAcvB,QAAQ,CAAC,IAAI,EAAE,MAAM;IACrB,QAAQ,CAAC,UAAU,EAAE,mBAAmB;IAd1C,OAAO,CAAC,SAAS,CAA8B;IAC/C,OAAO,CAAC,MAAM,CAAyB;IACvC,OAAO,CAAC,SAAS,CAAyB;IAC1C,OAAO,CAAC,QAAQ,CAAiB;IACjC,OAAO,CAAC,aAAa,CAAQ;IAE7B,MAAM,EAAE,eAAe,CAAU;IACjC,KAAK,EAAE,KAAK,GAAG,IAAI,CAAQ;IAC3B,MAAM,EAAE,MAAM,GAAG,IAAI,CAAQ;IAC7B,OAAO,SAAK;IACZ,MAAM,SAAK;gBAGA,IAAI,EAAE,MAAM,EACZ,UAAU,EAAE,mBAAmB;IAG1C,SAAS,CAAC,QAAQ,EAAE,MAAM,IAAI,GAAG,MAAM,IAAI;IAO3C,mFAAmF;IACnF,OAAO,IAAI,OAAO,EAAE;IAepB,GAAG,CAAC,GAAG,EAAE,OAAO,GAAG,OAAO,GAAG,SAAS;IAItC,SAAS,CAAC,MAAM,EAAE,eAAe,EAAE,KAAK,GAAE,KAAK,GAAG,IAAW,GAAG,IAAI;IAMpE,+EAA+E;IAC/E,eAAe,CAAC,IAAI,EAAE,OAAO,EAAE,EAAE,OAAO,EAAE;QAAE,IAAI,EAAE,OAAO,CAAC;QAAC,MAAM,EAAE,MAAM,GAAG,IAAI,CAAA;KAAE,GAAG,IAAI;IAWzF,QAAQ,CAAC,KAAK,EAAE,eAAe,GAAG,MAAM,IAAI;IAM5C,WAAW,CAAC,KAAK,EAAE,eAAe,GAAG,IAAI;IAOzC,2EAA2E;IAC3E,WAAW,CAAC,KAAK,EAAE,eAAe,EAAE,SAAS,EAAE,OAAO,GAAG,IAAI,GAAG,IAAI;IAcpE,QAAQ,CAAC,GAAG,EAAE,OAAO,EAAE,OAAO,EAAE,OAAO,GAAG,OAAO;IAKjD,uEAAuE;IACvE,aAAa,IAAI,OAAO,EAAE;IAI1B,OAAO,CAAC,IAAI,EAAE,OAAO,EAAE,EAAE,MAAM,EAAE,MAAM,GAAG,IAAI,GAAG,IAAI;IAUrD,YAAY,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI;IAKjC,WAAW,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI;IAKhC,OAAO,CAAC,UAAU;IAKlB,OAAO,CAAC,IAAI;CAGb"}
1
+ {"version":3,"file":"store.d.ts","sourceRoot":"","sources":["../src/store.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,OAAO,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;AAE9C,MAAM,MAAM,mBAAmB,GAAG;IAChC,GAAG,EAAE,MAAM,CAAC;IACZ,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC;IACzB,OAAO,EAAE,OAAO,CAAC;IACjB,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;IACtB;sEACkE;IAClE,MAAM,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;CAC5B,CAAC;AAEF,MAAM,MAAM,iBAAiB,GAAG;IAC9B,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,mBAAmB,CAAC,CAAC;CAC7C,CAAC;AAEF,MAAM,MAAM,eAAe,GAAG,MAAM,GAAG,SAAS,GAAG,OAAO,GAAG,OAAO,CAAC;AAErE,+EAA+E;AAC/E,MAAM,MAAM,gBAAgB,GAAG;IAC7B,EAAE,EAAE,MAAM,CAAC;IACX,+DAA+D;IAC/D,SAAS,EAAE,MAAM,CAAC;IAClB,KAAK,EAAE,OAAO,CAAC;IACf,KAAK,EAAE,KAAK,CAAC;IACb,EAAE,EAAE,MAAM,CAAC;IACX,8DAA8D;IAC9D,KAAK,EAAE,MAAM,OAAO,CAAC;IACrB,OAAO,EAAE,MAAM,IAAI,CAAC;CACrB,CAAC;AAEF,KAAK,eAAe,GAChB;IAAE,IAAI,EAAE,QAAQ,CAAC;IAAC,GAAG,EAAE,OAAO,CAAC;IAAC,GAAG,EAAE,OAAO,CAAA;CAAE,GAC9C;IAAE,IAAI,EAAE,QAAQ,CAAC;IAAC,GAAG,EAAE,OAAO,CAAA;CAAE,CAAC;AAErC;;;;GAIG;AACH,qBAAa,cAAc;IAkBvB,QAAQ,CAAC,IAAI,EAAE,MAAM;IACrB,QAAQ,CAAC,UAAU,EAAE,mBAAmB;IAlB1C,OAAO,CAAC,SAAS,CAA8B;IAC/C,OAAO,CAAC,MAAM,CAAyB;IACvC,OAAO,CAAC,SAAS,CAAyB;IAC1C,OAAO,CAAC,QAAQ,CAAiB;IACjC,OAAO,CAAC,aAAa,CAAQ;IAE7B,MAAM,EAAE,eAAe,CAAU;IACjC,KAAK,EAAE,KAAK,GAAG,IAAI,CAAQ;IAC3B,MAAM,EAAE,MAAM,GAAG,IAAI,CAAQ;IAC7B,OAAO,SAAK;IACZ,MAAM,SAAK;IACX;0EACsE;IACtE,QAAQ,EAAE,SAAS,gBAAgB,EAAE,CAAM;IAC3C,OAAO,CAAC,aAAa,CAAK;gBAGf,IAAI,EAAE,MAAM,EACZ,UAAU,EAAE,mBAAmB;IAG1C,SAAS,CAAC,QAAQ,EAAE,MAAM,IAAI,GAAG,MAAM,IAAI;IAO3C,mFAAmF;IACnF,OAAO,IAAI,OAAO,EAAE;IAepB,GAAG,CAAC,GAAG,EAAE,OAAO,GAAG,OAAO,GAAG,SAAS;IAItC,SAAS,CAAC,MAAM,EAAE,eAAe,EAAE,KAAK,GAAE,KAAK,GAAG,IAAW,GAAG,IAAI;IAMpE,+EAA+E;IAC/E,eAAe,CAAC,IAAI,EAAE,OAAO,EAAE,EAAE,OAAO,EAAE;QAAE,IAAI,EAAE,OAAO,CAAC;QAAC,MAAM,EAAE,MAAM,GAAG,IAAI,CAAA;KAAE,GAAG,IAAI;IAWzF,QAAQ,CAAC,KAAK,EAAE,eAAe,GAAG,MAAM,IAAI;IAM5C,WAAW,CAAC,KAAK,EAAE,eAAe,GAAG,IAAI;IAOzC,2EAA2E;IAC3E,WAAW,CAAC,KAAK,EAAE,eAAe,EAAE,SAAS,EAAE,OAAO,GAAG,IAAI,GAAG,IAAI;IAcpE,QAAQ,CAAC,GAAG,EAAE,OAAO,EAAE,OAAO,EAAE,OAAO,GAAG,OAAO;IAKjD,uEAAuE;IACvE,aAAa,IAAI,OAAO,EAAE;IAI1B,OAAO,CAAC,IAAI,EAAE,OAAO,EAAE,EAAE,MAAM,EAAE,MAAM,GAAG,IAAI,GAAG,IAAI;IAUrD,kEAAkE;IAClE,WAAW,CAAC,GAAG,EAAE,OAAO,GAAG,OAAO;IAKlC,aAAa,CAAC,KAAK,EAAE;QACnB,SAAS,EAAE,MAAM,CAAC;QAClB,KAAK,EAAE,OAAO,CAAC;QACf,KAAK,EAAE,KAAK,CAAC;QACb,KAAK,EAAE,MAAM,OAAO,CAAC;KACtB,GAAG,IAAI;IAoBR,cAAc,CAAC,EAAE,EAAE,MAAM,GAAG,IAAI;IAMhC,YAAY,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI;IAKjC,WAAW,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI;IAKhC,OAAO,CAAC,UAAU;IAKlB,OAAO,CAAC,IAAI;CAGb"}
package/dist/store.js CHANGED
@@ -16,6 +16,10 @@ export class SyncModelStore {
16
16
  cursor = null;
17
17
  pending = 0;
18
18
  paused = 0;
19
+ /** Rolled-back writes awaiting the user. Replaced, never mutated, so a
20
+ * useSyncExternalStore snapshot of it stays referentially stable. */
21
+ failures = [];
22
+ nextFailureId = 1;
19
23
  constructor(name, descriptor) {
20
24
  this.name = name;
21
25
  this.descriptor = descriptor;
@@ -109,6 +113,36 @@ export class SyncModelStore {
109
113
  this.status = "ready";
110
114
  this.invalidate();
111
115
  }
116
+ /** True when no optimistic layer still shadows this row's key. */
117
+ isPersisted(row) {
118
+ const key = String(row[this.descriptor.key]);
119
+ return !this.layers.some((layer) => String(layer.key) === key);
120
+ }
121
+ recordFailure(entry) {
122
+ const id = this.nextFailureId++;
123
+ const failure = {
124
+ id,
125
+ operation: entry.operation,
126
+ input: entry.input,
127
+ error: entry.error,
128
+ at: Date.now(),
129
+ // Retrying is a fresh write; the stale entry goes first so a second
130
+ // refusal records once instead of stacking duplicates.
131
+ retry: () => {
132
+ this.dismissFailure(id);
133
+ return entry.retry();
134
+ },
135
+ dismiss: () => this.dismissFailure(id),
136
+ };
137
+ this.failures = [...this.failures, failure];
138
+ this.emit();
139
+ }
140
+ dismissFailure(id) {
141
+ if (!this.failures.some((failure) => failure.id === id))
142
+ return;
143
+ this.failures = this.failures.filter((failure) => failure.id !== id);
144
+ this.emit();
145
+ }
112
146
  trackPending(delta) {
113
147
  this.pending = Math.max(0, this.pending + delta);
114
148
  this.emit();
@@ -0,0 +1,35 @@
1
+ declare module "./client.js" {
2
+ interface SyncModels {
3
+ tasks: {
4
+ id: string;
5
+ title: string;
6
+ status: "open" | "done" | "archived";
7
+ };
8
+ lists: {
9
+ id: string;
10
+ name: string;
11
+ };
12
+ }
13
+ interface SyncModelInputs {
14
+ tasks: {
15
+ id?: string;
16
+ title: string;
17
+ status?: "open" | "done" | "archived";
18
+ };
19
+ lists: {
20
+ id?: string;
21
+ name: string;
22
+ };
23
+ }
24
+ }
25
+ export declare function compileTimeContract(): {
26
+ title: string;
27
+ persisted: boolean;
28
+ handle: import("./client.js").SyncMutationHandle<{
29
+ id: string;
30
+ title: string;
31
+ status: "open" | "done" | "archived";
32
+ }>;
33
+ counts: number;
34
+ };
35
+ //# sourceMappingURL=type-safety.typetest.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"type-safety.typetest.d.ts","sourceRoot":"","sources":["../src/type-safety.typetest.ts"],"names":[],"mappings":"AAOA,OAAO,QAAQ,aAAa,CAAC;IAC3B,UAAU,UAAU;QAClB,KAAK,EAAE;YAAE,EAAE,EAAE,MAAM,CAAC;YAAC,KAAK,EAAE,MAAM,CAAC;YAAC,MAAM,EAAE,MAAM,GAAG,MAAM,GAAG,UAAU,CAAA;SAAE,CAAC;QAC3E,KAAK,EAAE;YAAE,EAAE,EAAE,MAAM,CAAC;YAAC,IAAI,EAAE,MAAM,CAAA;SAAE,CAAC;KACrC;IACD,UAAU,eAAe;QACvB,KAAK,EAAE;YAAE,EAAE,CAAC,EAAE,MAAM,CAAC;YAAC,KAAK,EAAE,MAAM,CAAC;YAAC,MAAM,CAAC,EAAE,MAAM,GAAG,MAAM,GAAG,UAAU,CAAA;SAAE,CAAC;QAC7E,KAAK,EAAE;YAAE,EAAE,CAAC,EAAE,MAAM,CAAC;YAAC,IAAI,EAAE,MAAM,CAAA;SAAE,CAAC;KACtC;CACF;AAED,wBAAgB,mBAAmB;;;;YATlB,MAAM;eAAS,MAAM;gBAAU,MAAM,GAAG,MAAM,GAAG,UAAU;;;EA2D3E"}
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Compile-time contract for the generated SyncModels map. Never imported at
3
+ * runtime; `pnpm --filter @farm.js/sync type-check` fails if any expectation
4
+ * breaks. Mirrors what the generated farm.d.ts does in an app.
5
+ */
6
+ import { useLiveQuery, useSyncAction } from "./react.js";
7
+ export function compileTimeContract() {
8
+ const tasks = useLiveQuery("tasks", (t) => t.status === "open");
9
+ // Rows and writes are typed from the model the first argument names.
10
+ const title = tasks.rows[0].title;
11
+ tasks.insert({ title: "typed" }); // id and status optional per the input map
12
+ tasks.update("t1", { status: "done" });
13
+ const persisted = tasks.isPersisted(tasks.rows[0]);
14
+ // @ts-expect-error unknown model name
15
+ useLiveQuery("task", () => true);
16
+ // @ts-expect-error no such field on tasks
17
+ useLiveQuery("tasks", (t) => t.stauts === "open");
18
+ // @ts-expect-error "opne" is outside the enum union
19
+ useLiveQuery("tasks", (t) => t.status === "opne");
20
+ // @ts-expect-error the row type follows the named model: lists has no status
21
+ useLiveQuery("lists", (t) => t.status === "open");
22
+ // @ts-expect-error unknown insert field
23
+ tasks.insert({ titel: "typo" });
24
+ // @ts-expect-error title is required on insert
25
+ tasks.insert({ status: "open" });
26
+ // The model-name form is the plain write surface; reads never mutate.
27
+ const writes = useSyncAction("tasks");
28
+ writes.insert({ title: "typed" });
29
+ writes.update("t1", { status: "done" });
30
+ const counts = writes.inFlight + writes.queued;
31
+ // @ts-expect-error unknown insert field on the CRUD form
32
+ writes.insert({ titel: "typo" });
33
+ // @ts-expect-error unknown model on the CRUD form
34
+ useSyncAction("task");
35
+ const complete = useSyncAction((input) => Promise.resolve(input), "tasks", {
36
+ optimistic: { status: "done" },
37
+ });
38
+ const handle = complete({ id: "t1" });
39
+ // The function form works too when the patch depends on the input.
40
+ useSyncAction((input) => Promise.resolve(input), "tasks", {
41
+ optimistic: (input) => ({ title: input.note }),
42
+ });
43
+ // @ts-expect-error unknown model on an action binding
44
+ useSyncAction((input) => Promise.resolve(input), "task");
45
+ useSyncAction((input) => Promise.resolve(input), "tasks", {
46
+ // @ts-expect-error the optimistic patch is checked against the model's row
47
+ optimistic: { status: "opne" },
48
+ });
49
+ return { title, persisted, handle, counts };
50
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@farm.js/sync",
3
- "version": "0.1.0-beta.1",
3
+ "version": "0.1.0-beta.2",
4
4
  "description": "Local-first collections for Farm.js: schema-driven reads, optimistic writes, offline queueing",
5
5
  "keywords": [
6
6
  "farm.js",
@@ -40,7 +40,7 @@
40
40
  "access": "public"
41
41
  },
42
42
  "dependencies": {
43
- "@farm.js/core": "0.1.0-beta.103"
43
+ "@farm.js/core": "0.1.0-beta.104"
44
44
  },
45
45
  "devDependencies": {
46
46
  "@farming-labs/orm": "0.0.62",