@farm.js/sync 0.1.0-beta.1 → 0.1.0-beta.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/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
  /**
@@ -73,7 +100,12 @@ type SyncPersistence = {
73
100
  };
74
101
  /** Replace the default browser store, for example with an IndexedDB one. */
75
102
  export declare function setSyncPersistence(store: SyncPersistence | null): void;
76
- /** Remove every persisted row. Call on logout so a shared device stays clean. */
103
+ /**
104
+ * Forget every synced row, on disk and in memory. Call it when the session
105
+ * changes (sign out, sign in as someone else, switch organization) so the next
106
+ * account never sees the previous one's rows. Each model reloads in full on its
107
+ * next use, scoped to the session current at that point.
108
+ */
77
109
  export declare function clearSyncedRows(): void;
78
110
  export declare function hydrateModel(model: string): void;
79
111
  export {};
@@ -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
@@ -30,11 +30,31 @@ function requireRuntime() {
30
30
  }
31
31
  throw new Error("The sync runtime is not started. Add `sync({ ... })` to the plugins in farm.config.ts.");
32
32
  }
33
+ const scheduledReloads = new Set();
34
+ /**
35
+ * A store cleared by clearSyncedRows() reloads on its next use, under whichever
36
+ * session is current by then. Deferred so the reload's status change never
37
+ * fires while a component is rendering, and so loadModel's own lookup of the
38
+ * store does not schedule it again.
39
+ */
40
+ function scheduleReload(model, store) {
41
+ if (scheduledReloads.has(model))
42
+ return;
43
+ scheduledReloads.add(model);
44
+ queueMicrotask(() => {
45
+ if (store.status === "idle")
46
+ void loadModel(model);
47
+ scheduledReloads.delete(model);
48
+ });
49
+ }
33
50
  export function getSyncStore(model) {
34
51
  const state = requireRuntime();
35
52
  let store = state.stores.get(model);
36
- if (store)
53
+ if (store) {
54
+ if (store.status === "idle" && isBrowser())
55
+ scheduleReload(model, store);
37
56
  return store;
57
+ }
38
58
  const descriptor = state.config.models[model];
39
59
  if (!descriptor) {
40
60
  if (!isBrowser())
@@ -68,10 +88,13 @@ async function request(body) {
68
88
  /** Fetch rows for a model, incrementally when the schema supports a cursor. */
69
89
  export async function loadModel(model) {
70
90
  const store = getSyncStore(model);
91
+ const generation = store.generation;
71
92
  if (store.status === "idle")
72
93
  store.setStatus("loading");
73
94
  try {
74
95
  const result = await request({ model, operation: "list", since: store.cursor });
96
+ if (store.generation !== generation)
97
+ return;
75
98
  store.applyServerRows(result.rows ?? [], {
76
99
  full: result.full !== false,
77
100
  cursor: result.cursor ?? null,
@@ -80,6 +103,8 @@ export async function loadModel(model) {
80
103
  persistModel(model);
81
104
  }
82
105
  catch (cause) {
106
+ if (store.generation !== generation)
107
+ return;
83
108
  store.setStatus("error", cause instanceof Error ? cause : new Error(String(cause)));
84
109
  }
85
110
  }
@@ -147,18 +172,30 @@ function runMutation(model, operation, input, optimistic) {
147
172
  : { type: "upsert", key: optimistic.key, row: optimistic.row };
148
173
  store.addLayer(layer);
149
174
  store.trackPending(1);
175
+ const generation = store.generation;
150
176
  let handleState = "pending";
151
177
  const isPersisted = (async () => {
152
178
  try {
153
179
  const result = await dispatchWrite(store, { model, operation, input }, state.options);
154
- store.commitLayer(layer, operation === "delete" ? null : result);
155
180
  handleState = "completed";
181
+ // The store was cleared for a new session while this was in flight.
182
+ if (store.generation !== generation)
183
+ return result;
184
+ store.commitLayer(layer, operation === "delete" ? null : result);
156
185
  persistModel(model);
157
186
  return result;
158
187
  }
159
188
  catch (error) {
160
- store.removeLayer(layer);
161
189
  handleState = "failed";
190
+ if (store.generation !== generation)
191
+ throw error;
192
+ store.removeLayer(layer);
193
+ store.recordFailure({
194
+ operation,
195
+ input,
196
+ error: error instanceof Error ? error : new Error(String(error)),
197
+ retry: () => runMutation(model, operation, input, optimistic),
198
+ });
162
199
  throw error;
163
200
  }
164
201
  finally {
@@ -181,7 +218,119 @@ function runMutation(model, operation, input, optimistic) {
181
218
  finally: (onFinally) => isPersisted.finally(onFinally),
182
219
  };
183
220
  }
221
+ /**
222
+ * Run a server-defined action with the mutation lifecycle: an optimistic
223
+ * layer when the effect is predictable, the same offline pause, commit of
224
+ * whatever rows the server returns, rollback plus a failures entry on
225
+ * refusal.
226
+ *
227
+ * The optimistic patch resolves in tiers: an explicit patch wins; otherwise
228
+ * input fields that are schema columns form one (when the input carries the
229
+ * model's key); otherwise the action runs with no preview and the screen
230
+ * updates when the server's rows land.
231
+ */
232
+ export function runSyncAction(model, action, invoke, input, explicitPatch) {
233
+ const state = requireRuntime();
234
+ const store = getSyncStore(model);
235
+ const keyField = store.descriptor.key;
236
+ const record = input && typeof input === "object" ? input : undefined;
237
+ const key = record?.[keyField];
238
+ let patch = explicitPatch;
239
+ if (!patch && record && key !== undefined) {
240
+ const columns = new Set(store.descriptor.fields ?? []);
241
+ const derived = Object.fromEntries(Object.entries(record).filter(([field]) => field !== keyField && columns.has(field)));
242
+ if (Object.keys(derived).length > 0)
243
+ patch = derived;
244
+ }
245
+ const layer = patch && key !== undefined
246
+ ? { type: "upsert", key, row: store.mergeRow(key, patch) }
247
+ : null;
248
+ if (layer)
249
+ store.addLayer(layer);
250
+ store.trackPending(1);
251
+ const generation = store.generation;
252
+ let handleState = "pending";
253
+ const isPersisted = (async () => {
254
+ try {
255
+ if ((state.options.networkMode ?? "online") === "online" && !isOnline()) {
256
+ store.trackPaused(1);
257
+ try {
258
+ await waitForOnline();
259
+ }
260
+ finally {
261
+ store.trackPaused(-1);
262
+ }
263
+ }
264
+ const result = await invoke(input);
265
+ const rows = Array.isArray(result) ? result : [result];
266
+ if (store.generation !== generation) {
267
+ handleState = "completed";
268
+ return (rows[0] ?? null);
269
+ }
270
+ let committed = false;
271
+ for (const row of rows) {
272
+ if (row && typeof row === "object" && row[keyField] !== undefined) {
273
+ // Commit through the layer once so the prediction is replaced, then
274
+ // fold any further returned rows straight into the confirmed set.
275
+ store.commitLayer(!committed && layer
276
+ ? layer
277
+ : { type: "upsert", key: row[keyField], row: row }, row);
278
+ committed = true;
279
+ }
280
+ }
281
+ if (!committed) {
282
+ // The action changed something this client cannot address by key;
283
+ // drop the prediction and converge on the server's truth instead.
284
+ if (layer)
285
+ store.removeLayer(layer);
286
+ void loadModel(model);
287
+ }
288
+ handleState = "completed";
289
+ persistModel(model);
290
+ return (rows[0] ?? null);
291
+ }
292
+ catch (error) {
293
+ handleState = "failed";
294
+ if (store.generation !== generation)
295
+ throw error;
296
+ if (layer)
297
+ store.removeLayer(layer);
298
+ store.recordFailure({
299
+ operation: action,
300
+ input,
301
+ error: error instanceof Error ? error : new Error(String(error)),
302
+ retry: () => runSyncAction(model, action, invoke, input, explicitPatch),
303
+ });
304
+ throw error;
305
+ }
306
+ finally {
307
+ store.trackPending(-1);
308
+ }
309
+ })();
310
+ void isPersisted.catch(() => undefined);
311
+ return {
312
+ key,
313
+ get state() {
314
+ return handleState;
315
+ },
316
+ get persisted() {
317
+ return isPersisted;
318
+ },
319
+ then: (onFulfilled, onRejected) => isPersisted.then(onFulfilled, onRejected),
320
+ catch: (onRejected) => isPersisted.catch(onRejected),
321
+ finally: (onFinally) => isPersisted.finally(onFinally),
322
+ };
323
+ }
184
324
  const clientCache = new Map();
325
+ /** The per-model client, created lazily. The `db` proxy delegates here. */
326
+ export function getSyncModelClient(model) {
327
+ let client = clientCache.get(model);
328
+ if (!client) {
329
+ client = createModelClient(model);
330
+ clientCache.set(model, client);
331
+ }
332
+ return client;
333
+ }
185
334
  function createModelClient(model) {
186
335
  const client = {
187
336
  insert(input) {
@@ -243,12 +392,7 @@ export const db = new Proxy({}, {
243
392
  get(_target, property) {
244
393
  if (typeof property !== "string")
245
394
  return undefined;
246
- let client = clientCache.get(property);
247
- if (!client) {
248
- client = createModelClient(property);
249
- clientCache.set(property, client);
250
- }
251
- return client;
395
+ return getSyncModelClient(property);
252
396
  },
253
397
  });
254
398
  // ── persistence ────────────────────────────────────────────────────────────
@@ -300,9 +444,16 @@ function getPersistence() {
300
444
  export function setSyncPersistence(store) {
301
445
  persistence = store;
302
446
  }
303
- /** Remove every persisted row. Call on logout so a shared device stays clean. */
447
+ /**
448
+ * Forget every synced row, on disk and in memory. Call it when the session
449
+ * changes (sign out, sign in as someone else, switch organization) so the next
450
+ * account never sees the previous one's rows. Each model reloads in full on its
451
+ * next use, scoped to the session current at that point.
452
+ */
304
453
  export function clearSyncedRows() {
305
454
  getPersistence()?.clear();
455
+ for (const store of runtime?.stores.values() ?? [])
456
+ store.reset();
306
457
  }
307
458
  function persistModel(model) {
308
459
  const store = runtime?.stores.get(model);
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":"AAMA,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;;;;;;;;;;;yDAmI9C;AAsOD,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 { 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,13 +11,24 @@ 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);
17
19
  return ormPromise;
18
20
  };
19
- const plugin = definePlugin({
21
+ return definePlugin({
20
22
  name: "farm:sync",
23
+ // `farm sync migrate` creates the tables of the models the app exposed.
24
+ // The rest of the app's schema is described, not created: those tables
25
+ // are the app's.
26
+ schema: syncOwnedSchema(options.schema, models),
27
+ // The database the app gave sync, not storage.client.
28
+ database: {
29
+ client: () => resolveSyncConnection(options),
30
+ dialect: options.dialect,
31
+ },
21
32
  client: {
22
33
  // Serializable descriptor the browser runtime reads at startup.
23
34
  public: {
@@ -29,6 +40,7 @@ export function sync(options) {
29
40
  access: model.access,
30
41
  persist: model.persist,
31
42
  cursor: model.cursorField ?? null,
43
+ fields: Object.keys(model.fields),
32
44
  },
33
45
  ])),
34
46
  },
@@ -39,12 +51,26 @@ export function sync(options) {
39
51
  scope.__FARM_SYNC__?.start?.(config);
40
52
  },
41
53
  },
54
+ // The production runtime only has a Web Request/Response boundary. Keep
55
+ // the endpoint on the runtime hook so it is handled by both Nitro and the
56
+ // development server instead of relying solely on the legacy Node hook.
57
+ runtime: {
58
+ async before({ request }) {
59
+ return handleSyncRequest(request, undefined, {
60
+ path,
61
+ models,
62
+ middleware: options.middleware,
63
+ resolveOrm,
64
+ allowedOrigins,
65
+ });
66
+ },
67
+ },
42
68
  async beforeRequest(req, res) {
43
69
  const pathname = (req.url ?? "").split("?")[0];
44
70
  if (pathname !== path)
45
71
  return;
46
72
  if (req.method !== "POST") {
47
- sendJson(res, 405, { error: { code: "invalid_input", message: "Sync uses POST." } });
73
+ await sendNodeResponse(res, jsonResponse(405, { error: { code: "invalid_input", message: "Sync uses POST." } }));
48
74
  return;
49
75
  }
50
76
  let body;
@@ -52,60 +78,117 @@ export function sync(options) {
52
78
  body = await readJsonBody(req);
53
79
  }
54
80
  catch {
55
- sendJson(res, 400, {
81
+ await sendNodeResponse(res, jsonResponse(400, {
56
82
  error: { code: "invalid_input", message: "Request body must be JSON." },
57
- });
83
+ }));
58
84
  return;
59
85
  }
60
86
  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
- });
87
+ await sendNodeResponse(res, jsonResponse(400, {
88
+ error: {
89
+ code: "invalid_input",
90
+ message: "Request body needs `model` and `operation`.",
91
+ },
92
+ }));
64
93
  return;
65
94
  }
66
95
  // The operation layer works in web-standard terms, so adapt once here.
67
96
  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
- }
97
+ const response = await handleSyncRequest(request, body, {
98
+ path,
99
+ models,
100
+ middleware: options.middleware,
101
+ resolveOrm,
102
+ allowedOrigins,
103
+ });
104
+ if (response)
105
+ await sendNodeResponse(res, response);
85
106
  },
86
107
  });
87
- // Declare the tables sync owns, so `farm sync migrate` can create them
88
- // without re-reading or re-validating configuration.
89
- return declareSchemaTables(plugin, {
90
- name: "sync",
91
- schema: options.schema,
92
- // A model the app has not opened to the browser is not sync's to create.
93
- models: Array.from(models.keys()),
94
- dialect: options.dialect,
95
- resolveClient: async () => {
96
- if (options.client) {
97
- return typeof options.client === "function"
98
- ? await options.client()
99
- : options.client;
100
- }
101
- if (options.storage) {
102
- const { getStorage } = await import("@farm.js/core/storage");
103
- return getStorage(options.storage);
104
- }
108
+ }
109
+ async function handleSyncRequest(request, bodyOverride, options) {
110
+ const pathname = new URL(request.url).pathname;
111
+ if (pathname !== options.path)
112
+ return undefined;
113
+ if (request.method !== "POST") {
114
+ return jsonResponse(405, { error: { code: "invalid_input", message: "Sync uses POST." } });
115
+ }
116
+ // The development server invokes runtime hooks with a Request that carries
117
+ // headers but no body stream, and an upstream consumer can leave the body
118
+ // unreadable. Neither is a client error: fall through so the Node adapter,
119
+ // which can read the underlying stream, serves the request instead of this
120
+ // hook answering 400 for a perfectly valid POST. The Node adapter re-enters
121
+ // this handler with bodyOverride, so the origin check below still guards
122
+ // every served request exactly once.
123
+ if (bodyOverride === undefined && request.body === null)
124
+ return undefined;
125
+ // Sync operations ride the caller's cookies, and a cross-site page can send
126
+ // this POST as a simple request with no preflight. Reject foreign origins
127
+ // before touching the body, mirroring the server-action origin contract.
128
+ const origin = validateIntegrationRequestOrigin(request, {
129
+ allowedOrigins: options.allowedOrigins,
130
+ requireOriginMetadata: true,
131
+ });
132
+ if (!origin.ok) {
133
+ return jsonResponse(403, {
134
+ error: { code: "unauthorized", message: describeIntegrationOriginRejection(origin.reason) },
135
+ });
136
+ }
137
+ let body;
138
+ try {
139
+ body = bodyOverride ?? (await request.clone().json());
140
+ }
141
+ catch (error) {
142
+ if (bodyOverride === undefined && error?.name !== "SyntaxError") {
143
+ // clone() throws when the body was already consumed elsewhere; that is
144
+ // the runtime's shape, not the client's JSON.
105
145
  return undefined;
106
- },
146
+ }
147
+ return jsonResponse(400, {
148
+ error: { code: "invalid_input", message: "Request body must be JSON." },
149
+ });
150
+ }
151
+ if (!body || typeof body.model !== "string" || typeof body.operation !== "string") {
152
+ return jsonResponse(400, {
153
+ error: { code: "invalid_input", message: "Request body needs `model` and `operation`." },
154
+ });
155
+ }
156
+ try {
157
+ const context = await runMiddleware(options.middleware, request);
158
+ const orm = await options.resolveOrm();
159
+ const result = await executeSyncOperation({
160
+ body,
161
+ models: options.models,
162
+ orm,
163
+ request,
164
+ context,
165
+ });
166
+ return jsonResponse(200, result);
167
+ }
168
+ catch (error) {
169
+ if (error instanceof SyncOperationError) {
170
+ return jsonResponse(error.status, {
171
+ error: { code: error.code, message: error.message },
172
+ });
173
+ }
174
+ // Never leak an internal message to the browser.
175
+ console.error("[farm:sync] operation failed", error);
176
+ return jsonResponse(500, {
177
+ error: { code: "server_error", message: "The sync operation failed." },
178
+ });
179
+ }
180
+ }
181
+ function jsonResponse(status, payload) {
182
+ return new Response(JSON.stringify(payload), {
183
+ status,
184
+ headers: { "content-type": "application/json; charset=utf-8" },
107
185
  });
108
186
  }
187
+ async function sendNodeResponse(res, response) {
188
+ res.statusCode = response.status;
189
+ response.headers.forEach((value, name) => res.setHeader?.(name, value));
190
+ res.end(Buffer.from(await response.arrayBuffer()));
191
+ }
109
192
  async function runMiddleware(middleware, request) {
110
193
  let context = {};
111
194
  for (const entry of middleware ?? []) {
@@ -115,47 +198,64 @@ async function runMiddleware(middleware, request) {
115
198
  }
116
199
  return context;
117
200
  }
118
- async function resolveSyncClient(options, models) {
201
+ /**
202
+ * The app's schema as sync owns it: exposed models are sync's tables, the rest
203
+ * are described but never created. `extend` is folded into the models first,
204
+ * so the app composing its own schema is never read as sync asking to add
205
+ * columns to the app's tables.
206
+ */
207
+ function syncOwnedSchema(schema, exposed) {
208
+ const models = { ...schema.models };
209
+ for (const [key, extension] of Object.entries(schema.extend ?? {})) {
210
+ const existing = models[key];
211
+ models[key] = {
212
+ ...(existing ?? { fields: {} }),
213
+ ...(extension.name ? { name: extension.name } : {}),
214
+ ...(extension.description ? { description: extension.description } : {}),
215
+ fields: { ...existing?.fields, ...extension.fields },
216
+ constraints: [...(existing?.constraints ?? []), ...(extension.constraints ?? [])],
217
+ meta: { ...existing?.meta, ...extension.meta },
218
+ };
219
+ }
220
+ return {
221
+ ...schema,
222
+ extend: undefined,
223
+ models: Object.fromEntries(Object.entries(models).map(([key, model]) => [
224
+ key,
225
+ { ...model, external: !exposed.has(key) },
226
+ ])),
227
+ };
228
+ }
229
+ /** The connection or storage mount the app gave sync. */
230
+ async function resolveSyncConnection(options) {
119
231
  if (options.client) {
120
- const provided = typeof options.client === "function"
232
+ return typeof options.client === "function"
121
233
  ? await options.client()
122
234
  : options.client;
123
- // Already model-shaped: an @farming-labs/orm client, or anything exposing
124
- // the same four methods. Use it directly.
125
- if (isModelClient(provided, models))
126
- return provided;
127
- return buildOrm(options.schema, provided);
128
235
  }
129
236
  if (options.storage) {
130
- const mount = options.storage;
131
237
  // Imported lazily so the storage runtime never reaches a browser bundle.
132
238
  const { getStorage } = await import("@farm.js/core/storage");
133
239
  // A mount is an unstorage instance, which the orm runtime drives like any
134
240
  // other backend — so this is the same code path as a real database.
135
- return buildOrm(options.schema, getStorage(mount));
241
+ return getStorage(options.storage);
136
242
  }
137
243
  throw new Error("sync(): no data source is configured. Set `storage` to a mount name from " +
138
244
  "storage.mounts in farm.config.ts, or pass `client` with your database connection.");
139
245
  }
246
+ async function resolveSyncClient(options, models) {
247
+ const provided = await resolveSyncConnection(options);
248
+ // Already model-shaped: an @farming-labs/orm client, or anything exposing
249
+ // the same four methods. Use it directly.
250
+ if (options.client && isModelClient(provided, models))
251
+ return provided;
252
+ return buildOrm(options.schema, provided);
253
+ }
140
254
  /** Let the orm runtime detect the driver and build the model client. */
141
255
  async function buildOrm(schema, client) {
142
256
  const { createIntegrationOrm } = await import("@farm.js/core");
143
257
  return (await createIntegrationOrm({ schema, client }));
144
258
  }
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
259
  /** True when every exposed model is already reachable with the methods sync calls. */
160
260
  function isModelClient(value, models) {
161
261
  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":"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"}
package/dist/react.js CHANGED
@@ -1,21 +1,14 @@
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
- const predicateRef = useRef(predicate);
16
- predicateRef.current = predicate;
17
- const optionsRef = useRef(options);
18
- optionsRef.current = options;
19
12
  const subscribe = useCallback((listener) => store.subscribe(listener), [store]);
20
13
  // Rows live in the browser, so the server has none. Both sides must agree on
21
14
  // that first snapshot or hydration mismatches; the real rows arrive on the
@@ -25,47 +18,93 @@ export function useLiveQuery(model, predicate, options = {}) {
25
18
  const error = useSyncExternalStore(subscribe, () => store.error, () => null);
26
19
  const pending = useSyncExternalStore(subscribe, () => store.pending, () => 0);
27
20
  const paused = useSyncExternalStore(subscribe, () => store.paused, () => 0);
21
+ const failures = useSyncExternalStore(subscribe, () => store.failures, () => EMPTY_FAILURES);
28
22
  useEffect(() => {
29
23
  if (options.enabled === false)
30
24
  return;
31
25
  hydrateModel(name);
32
26
  }, [name, options.enabled]);
33
- // Recompute only when the underlying rows change; the predicate is read from
34
- // a ref so an inline arrow does not invalidate the memo every render.
27
+ // The memo depends on the real predicate and options, not refs: a predicate
28
+ // that closes over changed state (row => row.status === filter) must produce
29
+ // fresh rows on the next render, not stay frozen until some row mutates. An
30
+ // inline arrow recomputes every render, which is the correct trade; a caller
31
+ // with a large collection keeps memoization by passing a stable predicate.
35
32
  const visible = useMemo(() => {
36
- const current = predicateRef.current;
37
- const opts = optionsRef.current;
38
- let result = current ? rows.filter((row) => current(row)) : rows;
39
- if (opts.orderBy) {
40
- const direction = opts.direction === "desc" ? -1 : 1;
33
+ let result = predicate ? rows.filter((row) => predicate(row)) : rows;
34
+ if (options.orderBy) {
35
+ const direction = options.direction === "desc" ? -1 : 1;
36
+ const orderBy = options.orderBy;
41
37
  result = [...result].sort((a, b) => {
42
- const left = opts.orderBy(a);
43
- const right = opts.orderBy(b);
38
+ const left = orderBy(a);
39
+ const right = orderBy(b);
44
40
  if (left === right)
45
41
  return 0;
46
42
  return (left < right ? -1 : 1) * direction;
47
43
  });
48
44
  }
49
- if (opts.offset || opts.limit !== undefined) {
50
- const start = opts.offset ?? 0;
51
- result = result.slice(start, opts.limit === undefined ? undefined : start + opts.limit);
45
+ if (options.offset || options.limit !== undefined) {
46
+ const start = options.offset ?? 0;
47
+ result = result.slice(start, options.limit === undefined ? undefined : start + options.limit);
52
48
  }
53
49
  return result;
54
- }, [rows]);
50
+ }, [rows, predicate, options.orderBy, options.direction, options.offset, options.limit]);
51
+ const client = useMemo(() => getSyncModelClient(name), [name]);
55
52
  return {
56
53
  rows: visible,
57
54
  status,
58
55
  error,
59
56
  pending,
60
57
  paused,
58
+ queued: paused,
61
59
  isEmpty: visible.length === 0,
62
- refresh: () => model.refresh(),
60
+ refresh: () => client.refresh(),
61
+ insert: (input) => client.insert(input),
62
+ update: (key, patch) => client.update({
63
+ ...patch,
64
+ [store.descriptor.key]: key,
65
+ }),
66
+ delete: (key) => client.delete({ [store.descriptor.key]: key }),
67
+ isPersisted: (row) => store.isPersisted(row),
68
+ failures,
63
69
  };
64
70
  }
65
- /** Subscribe to a single row by key. */
66
71
  export function useRow(model, key) {
67
- const store = getSyncStore(model.__farmSyncModel);
72
+ const store = getSyncStore(resolveModelName(model));
68
73
  const subscribe = useCallback((listener) => store.subscribe(listener), [store]);
69
74
  const rows = useSyncExternalStore(subscribe, () => store.getRows(), () => store.getRows());
70
75
  return useMemo(() => rows.find((row) => String(row[store.descriptor.key]) === String(key)), [rows, key, store.descriptor.key]);
71
76
  }
77
+ export function useSyncAction(fnOrModel, maybeModel, options = {}) {
78
+ const fn = typeof fnOrModel === "function" ? fnOrModel : undefined;
79
+ const model = (typeof fnOrModel === "string" ? fnOrModel : maybeModel);
80
+ const store = getSyncStore(model);
81
+ const subscribe = useCallback((listener) => store.subscribe(listener), [store]);
82
+ const failures = useSyncExternalStore(subscribe, () => store.failures, () => EMPTY_FAILURES);
83
+ const inFlight = useSyncExternalStore(subscribe, () => store.pending, () => 0);
84
+ const queued = useSyncExternalStore(subscribe, () => store.paused, () => 0);
85
+ const optionsRef = useRef(options);
86
+ optionsRef.current = options;
87
+ const fnRef = useRef(fn);
88
+ fnRef.current = fn;
89
+ if (!fn) {
90
+ const client = getSyncModelClient(model);
91
+ const crud = {
92
+ insert: (input) => client.insert(input),
93
+ update: (key, patch) => client.update({ ...patch, [store.descriptor.key]: key }),
94
+ delete: (key) => client.delete({ [store.descriptor.key]: key }),
95
+ inFlight,
96
+ queued,
97
+ failures,
98
+ };
99
+ return crud;
100
+ }
101
+ const call = (input) => {
102
+ const current = optionsRef.current;
103
+ const bound = fnRef.current;
104
+ return runSyncAction(model, current.name || bound.name || "action", (value) => bound(value), input, (typeof current.optimistic === "function" ? current.optimistic(input) : current.optimistic));
105
+ };
106
+ call.inFlight = inFlight;
107
+ call.queued = queued;
108
+ call.failures = failures;
109
+ return call;
110
+ }
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,16 @@ 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
+ /**
58
+ * Bumped by reset(). A load or write that started before a reset compares
59
+ * against it and drops its result, so a response for the previous session
60
+ * cannot land in the store after it was cleared.
61
+ */
62
+ generation: number;
63
+ private nextFailureId;
39
64
  constructor(name: string, descriptor: SyncModelDescriptor);
40
65
  subscribe(listener: () => void): () => void;
41
66
  /** Stable array identity between changes so `useSyncExternalStore` stays quiet. */
@@ -52,9 +77,24 @@ export declare class SyncModelStore {
52
77
  /** Fold a confirmed server row in and drop the layer that predicted it. */
53
78
  commitLayer(layer: OptimisticLayer, confirmed: SyncRow | null): void;
54
79
  mergeRow(key: unknown, changes: SyncRow): SyncRow;
80
+ /**
81
+ * Forget everything this store holds for a session change: confirmed rows,
82
+ * optimistic layers, recorded failures, and the incremental cursor. The next
83
+ * load is a full one, scoped to whoever is signed in by then.
84
+ */
85
+ reset(): void;
55
86
  /** Rows worth persisting: confirmed only, never optimistic guesses. */
56
87
  confirmedRows(): SyncRow[];
57
88
  hydrate(rows: SyncRow[], cursor: string | null): void;
89
+ /** True when no optimistic layer still shadows this row's key. */
90
+ isPersisted(row: SyncRow): boolean;
91
+ recordFailure(entry: {
92
+ operation: string;
93
+ input: unknown;
94
+ error: Error;
95
+ retry: () => unknown;
96
+ }): void;
97
+ dismissFailure(id: number): void;
58
98
  trackPending(delta: number): void;
59
99
  trackPaused(delta: number): void;
60
100
  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;IAwBvB,QAAQ,CAAC,IAAI,EAAE,MAAM;IACrB,QAAQ,CAAC,UAAU,EAAE,mBAAmB;IAxB1C,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;;;;OAIG;IACH,UAAU,SAAK;IACf,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;;;;OAIG;IACH,KAAK,IAAI,IAAI;IAWb,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,16 @@ 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
+ /**
23
+ * Bumped by reset(). A load or write that started before a reset compares
24
+ * against it and drops its result, so a response for the previous session
25
+ * cannot land in the store after it was cleared.
26
+ */
27
+ generation = 0;
28
+ nextFailureId = 1;
19
29
  constructor(name, descriptor) {
20
30
  this.name = name;
21
31
  this.descriptor = descriptor;
@@ -94,6 +104,21 @@ export class SyncModelStore {
94
104
  const current = this.get(key) ?? { [this.descriptor.key]: key };
95
105
  return { ...current, ...changes };
96
106
  }
107
+ /**
108
+ * Forget everything this store holds for a session change: confirmed rows,
109
+ * optimistic layers, recorded failures, and the incremental cursor. The next
110
+ * load is a full one, scoped to whoever is signed in by then.
111
+ */
112
+ reset() {
113
+ this.generation += 1;
114
+ this.confirmed.clear();
115
+ this.layers = [];
116
+ this.failures = [];
117
+ this.cursor = null;
118
+ this.status = "idle";
119
+ this.error = null;
120
+ this.invalidate();
121
+ }
97
122
  /** Rows worth persisting: confirmed only, never optimistic guesses. */
98
123
  confirmedRows() {
99
124
  return Array.from(this.confirmed.values());
@@ -109,6 +134,36 @@ export class SyncModelStore {
109
134
  this.status = "ready";
110
135
  this.invalidate();
111
136
  }
137
+ /** True when no optimistic layer still shadows this row's key. */
138
+ isPersisted(row) {
139
+ const key = String(row[this.descriptor.key]);
140
+ return !this.layers.some((layer) => String(layer.key) === key);
141
+ }
142
+ recordFailure(entry) {
143
+ const id = this.nextFailureId++;
144
+ const failure = {
145
+ id,
146
+ operation: entry.operation,
147
+ input: entry.input,
148
+ error: entry.error,
149
+ at: Date.now(),
150
+ // Retrying is a fresh write; the stale entry goes first so a second
151
+ // refusal records once instead of stacking duplicates.
152
+ retry: () => {
153
+ this.dismissFailure(id);
154
+ return entry.retry();
155
+ },
156
+ dismiss: () => this.dismissFailure(id),
157
+ };
158
+ this.failures = [...this.failures, failure];
159
+ this.emit();
160
+ }
161
+ dismissFailure(id) {
162
+ if (!this.failures.some((failure) => failure.id === id))
163
+ return;
164
+ this.failures = this.failures.filter((failure) => failure.id !== id);
165
+ this.emit();
166
+ }
112
167
  trackPending(delta) {
113
168
  this.pending = Math.max(0, this.pending + delta);
114
169
  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.11",
4
4
  "description": "Local-first collections for Farm.js: schema-driven reads, optimistic writes, offline queueing",
5
5
  "keywords": [
6
6
  "farm.js",
@@ -40,15 +40,17 @@
40
40
  "access": "public"
41
41
  },
42
42
  "dependencies": {
43
- "@farm.js/core": "0.1.0-beta.103"
43
+ "@farm.js/core": "0.1.6"
44
44
  },
45
45
  "devDependencies": {
46
46
  "@farming-labs/orm": "0.0.62",
47
47
  "@farming-labs/orm-runtime": "0.0.62",
48
48
  "@types/node": "^20.10.5",
49
49
  "@types/react": "^19.0.0",
50
+ "@types/react-dom": "^19.0.0",
50
51
  "jsdom": "^25.0.0",
51
52
  "react": "^19.0.0",
53
+ "react-dom": "^19.0.0",
52
54
  "typescript": "^5.3.3",
53
55
  "unstorage": "^1.16.1",
54
56
  "vitest": "^3.2.7"