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