@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 +33 -1
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +161 -10
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +167 -67
- package/dist/react.d.ts +59 -3
- package/dist/react.d.ts.map +1 -1
- package/dist/react.js +68 -29
- package/dist/server.js +34 -3
- package/dist/store.d.ts +40 -0
- package/dist/store.d.ts.map +1 -1
- package/dist/store.js +55 -0
- package/dist/type-safety.typetest.d.ts +35 -0
- package/dist/type-safety.typetest.d.ts.map +1 -0
- package/dist/type-safety.typetest.js +50 -0
- package/package.json +4 -2
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
|
-
/**
|
|
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 {};
|
package/dist/client.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../src/client.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,cAAc,EAEd,KAAK,OAAO,EACZ,KAAK,iBAAiB,EACvB,MAAM,YAAY,CAAC;AAEpB,MAAM,MAAM,iBAAiB,GAAG,SAAS,GAAG,QAAQ,GAAG,WAAW,GAAG,QAAQ,CAAC;AAE9E;;;;;;;;;;;;GAYG;AACH,MAAM,MAAM,kBAAkB,CAAC,IAAI,GAAG,OAAO,IAAI,WAAW,CAAC,IAAI,CAAC,GAAG;IACnE,QAAQ,CAAC,GAAG,EAAE,OAAO,CAAC;IACtB,QAAQ,CAAC,KAAK,EAAE,iBAAiB,CAAC;IAClC,gFAAgF;IAChF,QAAQ,CAAC,SAAS,EAAE,OAAO,CAAC,IAAI,CAAC,CAAC;IAClC,KAAK,CAAC,OAAO,GAAG,KAAK,EACnB,UAAU,CAAC,EAAE,CAAC,CAAC,MAAM,EAAE,OAAO,KAAK,OAAO,GAAG,WAAW,CAAC,OAAO,CAAC,CAAC,GAAG,IAAI,GACxE,OAAO,CAAC,IAAI,GAAG,OAAO,CAAC,CAAC;IAC3B,OAAO,CAAC,SAAS,CAAC,EAAE,CAAC,MAAM,IAAI,CAAC,GAAG,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CACzD,CAAC;AAEF,MAAM,MAAM,iBAAiB,GAAG;IAC9B,6EAA6E;IAC7E,WAAW,CAAC,EAAE,QAAQ,GAAG,QAAQ,CAAC;IAClC,uEAAuE;IACvE,KAAK,CAAC,EAAE;QAAE,KAAK,CAAC,EAAE,MAAM,CAAC;QAAC,KAAK,CAAC,EAAE,MAAM,GAAG,CAAC,CAAC,OAAO,EAAE,MAAM,KAAK,MAAM,CAAC,CAAA;KAAE,CAAC;CAC5E,CAAC;AAUF,qFAAqF;AACrF,wBAAgB,gBAAgB,CAAC,MAAM,EAAE,iBAAiB,EAAE,OAAO,GAAE,iBAAsB,GAAG,IAAI,CAGjG;
|
|
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
|
-
|
|
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
|
-
/**
|
|
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>;
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
62
|
-
error: {
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
package/dist/react.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"react.d.ts","sourceRoot":"","sources":["../src/react.ts"],"names":[],"mappings":"AAGA,OAAO,
|
|
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
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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
|
|
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
|
-
//
|
|
34
|
-
//
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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 =
|
|
43
|
-
const right =
|
|
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 (
|
|
50
|
-
const start =
|
|
51
|
-
result = result.slice(start,
|
|
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: () =>
|
|
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
|
|
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
|
-
|
|
107
|
-
|
|
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:
|
|
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;
|
package/dist/store.d.ts.map
CHANGED
|
@@ -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;
|
|
1
|
+
{"version":3,"file":"store.d.ts","sourceRoot":"","sources":["../src/store.ts"],"names":[],"mappings":"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"}
|
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.
|
|
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.
|
|
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"
|