@farm.js/sync 0.1.0-beta.1
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/LICENSE +22 -0
- package/dist/client.d.ts +80 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +326 -0
- package/dist/index.d.ts +57 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +201 -0
- package/dist/react.d.ts +31 -0
- package/dist/react.d.ts.map +1 -0
- package/dist/react.js +71 -0
- package/dist/server.d.ts +47 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +167 -0
- package/dist/store.d.ts +64 -0
- package/dist/store.d.ts.map +1 -0
- package/dist/store.js +128 -0
- package/dist/types.d.ts +56 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +49 -0
- package/package.json +73 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import { declareSchemaTables, definePlugin } from "@farm.js/core";
|
|
2
|
+
import { executeSyncOperation, SyncOperationError } from "./server.js";
|
|
3
|
+
import { resolveSyncModels, } from "./types.js";
|
|
4
|
+
const DEFAULT_PATH = "/_farm/sync";
|
|
5
|
+
export function sync(options) {
|
|
6
|
+
if (!options?.schema?.models) {
|
|
7
|
+
throw new TypeError("sync() requires a `schema` created with defineSchema().");
|
|
8
|
+
}
|
|
9
|
+
const path = options.path ?? DEFAULT_PATH;
|
|
10
|
+
const models = resolveSyncModels(options.schema, options.models, options.where, options.persist ?? true);
|
|
11
|
+
if (models.size === 0) {
|
|
12
|
+
throw new Error("sync(): no models are exposed. Add at least one entry to `models`.");
|
|
13
|
+
}
|
|
14
|
+
let ormPromise;
|
|
15
|
+
const resolveOrm = () => {
|
|
16
|
+
ormPromise ??= resolveSyncClient(options, models);
|
|
17
|
+
return ormPromise;
|
|
18
|
+
};
|
|
19
|
+
const plugin = definePlugin({
|
|
20
|
+
name: "farm:sync",
|
|
21
|
+
client: {
|
|
22
|
+
// Serializable descriptor the browser runtime reads at startup.
|
|
23
|
+
public: {
|
|
24
|
+
path,
|
|
25
|
+
models: Object.fromEntries(Array.from(models.values(), (model) => [
|
|
26
|
+
model.name,
|
|
27
|
+
{
|
|
28
|
+
key: model.key,
|
|
29
|
+
access: model.access,
|
|
30
|
+
persist: model.persist,
|
|
31
|
+
cursor: model.cursorField ?? null,
|
|
32
|
+
},
|
|
33
|
+
])),
|
|
34
|
+
},
|
|
35
|
+
setup({ public: config }) {
|
|
36
|
+
// Hand the descriptor to the runtime, which the app bundle imports.
|
|
37
|
+
const scope = globalThis;
|
|
38
|
+
scope.__FARM_SYNC_CONFIG__ = config;
|
|
39
|
+
scope.__FARM_SYNC__?.start?.(config);
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
async beforeRequest(req, res) {
|
|
43
|
+
const pathname = (req.url ?? "").split("?")[0];
|
|
44
|
+
if (pathname !== path)
|
|
45
|
+
return;
|
|
46
|
+
if (req.method !== "POST") {
|
|
47
|
+
sendJson(res, 405, { error: { code: "invalid_input", message: "Sync uses POST." } });
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
let body;
|
|
51
|
+
try {
|
|
52
|
+
body = await readJsonBody(req);
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
sendJson(res, 400, {
|
|
56
|
+
error: { code: "invalid_input", message: "Request body must be JSON." },
|
|
57
|
+
});
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
if (!body || typeof body.model !== "string" || typeof body.operation !== "string") {
|
|
61
|
+
sendJson(res, 400, {
|
|
62
|
+
error: { code: "invalid_input", message: "Request body needs `model` and `operation`." },
|
|
63
|
+
});
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
// The operation layer works in web-standard terms, so adapt once here.
|
|
67
|
+
const request = toWebRequest(req, path);
|
|
68
|
+
try {
|
|
69
|
+
const context = await runMiddleware(options.middleware, request);
|
|
70
|
+
const orm = await resolveOrm();
|
|
71
|
+
const result = await executeSyncOperation({ body, models, orm, request, context });
|
|
72
|
+
sendJson(res, 200, result);
|
|
73
|
+
}
|
|
74
|
+
catch (error) {
|
|
75
|
+
if (error instanceof SyncOperationError) {
|
|
76
|
+
sendJson(res, error.status, { error: { code: error.code, message: error.message } });
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
// Never leak an internal message to the browser.
|
|
80
|
+
console.error("[farm:sync] operation failed", error);
|
|
81
|
+
sendJson(res, 500, {
|
|
82
|
+
error: { code: "server_error", message: "The sync operation failed." },
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
},
|
|
86
|
+
});
|
|
87
|
+
// Declare the tables sync owns, so `farm sync migrate` can create them
|
|
88
|
+
// without re-reading or re-validating configuration.
|
|
89
|
+
return declareSchemaTables(plugin, {
|
|
90
|
+
name: "sync",
|
|
91
|
+
schema: options.schema,
|
|
92
|
+
// A model the app has not opened to the browser is not sync's to create.
|
|
93
|
+
models: Array.from(models.keys()),
|
|
94
|
+
dialect: options.dialect,
|
|
95
|
+
resolveClient: async () => {
|
|
96
|
+
if (options.client) {
|
|
97
|
+
return typeof options.client === "function"
|
|
98
|
+
? await options.client()
|
|
99
|
+
: options.client;
|
|
100
|
+
}
|
|
101
|
+
if (options.storage) {
|
|
102
|
+
const { getStorage } = await import("@farm.js/core/storage");
|
|
103
|
+
return getStorage(options.storage);
|
|
104
|
+
}
|
|
105
|
+
return undefined;
|
|
106
|
+
},
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
async function runMiddleware(middleware, request) {
|
|
110
|
+
let context = {};
|
|
111
|
+
for (const entry of middleware ?? []) {
|
|
112
|
+
const produced = await entry({ request, context });
|
|
113
|
+
if (produced)
|
|
114
|
+
context = { ...context, ...produced };
|
|
115
|
+
}
|
|
116
|
+
return context;
|
|
117
|
+
}
|
|
118
|
+
async function resolveSyncClient(options, models) {
|
|
119
|
+
if (options.client) {
|
|
120
|
+
const provided = typeof options.client === "function"
|
|
121
|
+
? await options.client()
|
|
122
|
+
: 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
|
+
}
|
|
129
|
+
if (options.storage) {
|
|
130
|
+
const mount = options.storage;
|
|
131
|
+
// Imported lazily so the storage runtime never reaches a browser bundle.
|
|
132
|
+
const { getStorage } = await import("@farm.js/core/storage");
|
|
133
|
+
// A mount is an unstorage instance, which the orm runtime drives like any
|
|
134
|
+
// other backend — so this is the same code path as a real database.
|
|
135
|
+
return buildOrm(options.schema, getStorage(mount));
|
|
136
|
+
}
|
|
137
|
+
throw new Error("sync(): no data source is configured. Set `storage` to a mount name from " +
|
|
138
|
+
"storage.mounts in farm.config.ts, or pass `client` with your database connection.");
|
|
139
|
+
}
|
|
140
|
+
/** Let the orm runtime detect the driver and build the model client. */
|
|
141
|
+
async function buildOrm(schema, client) {
|
|
142
|
+
const { createIntegrationOrm } = await import("@farm.js/core");
|
|
143
|
+
return (await createIntegrationOrm({ schema, client }));
|
|
144
|
+
}
|
|
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
|
+
/** True when every exposed model is already reachable with the methods sync calls. */
|
|
160
|
+
function isModelClient(value, models) {
|
|
161
|
+
if (!value || typeof value !== "object")
|
|
162
|
+
return false;
|
|
163
|
+
for (const name of models.keys()) {
|
|
164
|
+
const model = value[name];
|
|
165
|
+
if (!model || typeof model !== "object")
|
|
166
|
+
return false;
|
|
167
|
+
if (typeof model.findMany !== "function" || typeof model.create !== "function")
|
|
168
|
+
return false;
|
|
169
|
+
}
|
|
170
|
+
return true;
|
|
171
|
+
}
|
|
172
|
+
/** Read and parse a JSON body, tolerating a body already parsed upstream. */
|
|
173
|
+
async function readJsonBody(req) {
|
|
174
|
+
if (req.body && typeof req.body === "object")
|
|
175
|
+
return req.body;
|
|
176
|
+
if (typeof req.body === "string")
|
|
177
|
+
return JSON.parse(req.body);
|
|
178
|
+
const chunks = [];
|
|
179
|
+
for await (const chunk of req) {
|
|
180
|
+
chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
|
|
181
|
+
}
|
|
182
|
+
const raw = Buffer.concat(chunks).toString("utf8");
|
|
183
|
+
if (!raw)
|
|
184
|
+
throw new SyntaxError("Empty body");
|
|
185
|
+
return JSON.parse(raw);
|
|
186
|
+
}
|
|
187
|
+
/** Minimal web Request view over the Node request, for middleware and filters. */
|
|
188
|
+
function toWebRequest(req, path) {
|
|
189
|
+
const headers = new Headers();
|
|
190
|
+
for (const [name, value] of Object.entries(req.headers ?? {})) {
|
|
191
|
+
if (typeof value === "string")
|
|
192
|
+
headers.set(name, value);
|
|
193
|
+
else if (Array.isArray(value))
|
|
194
|
+
headers.set(name, value.join(", "));
|
|
195
|
+
}
|
|
196
|
+
const host = headers.get("host") ?? "localhost";
|
|
197
|
+
const url = new URL(req.url ?? path, `http://${host}`);
|
|
198
|
+
return new Request(url, { method: req.method ?? "POST", headers });
|
|
199
|
+
}
|
|
200
|
+
/** @internal Exposed for tests. */
|
|
201
|
+
export { resolveSyncModels, executeSyncOperation, SyncOperationError };
|
package/dist/react.d.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { type SyncModelClient } from "./client.js";
|
|
2
|
+
import type { SyncRow, SyncStoreStatus } from "./store.js";
|
|
3
|
+
export type LiveQueryOptions<TRow> = {
|
|
4
|
+
orderBy?: (row: TRow) => string | number | Date;
|
|
5
|
+
direction?: "asc" | "desc";
|
|
6
|
+
limit?: number;
|
|
7
|
+
offset?: number;
|
|
8
|
+
enabled?: boolean;
|
|
9
|
+
};
|
|
10
|
+
export type LiveQueryResult<TRow> = {
|
|
11
|
+
rows: TRow[];
|
|
12
|
+
status: SyncStoreStatus;
|
|
13
|
+
error: Error | null;
|
|
14
|
+
/** Writes in flight against this model. */
|
|
15
|
+
pending: number;
|
|
16
|
+
/** Writes waiting for the connection to return. */
|
|
17
|
+
paused: number;
|
|
18
|
+
isEmpty: boolean;
|
|
19
|
+
refresh(): Promise<void>;
|
|
20
|
+
};
|
|
21
|
+
/**
|
|
22
|
+
* Subscribe to rows of a model, filtered in the browser.
|
|
23
|
+
*
|
|
24
|
+
* The predicate runs over the model's rows on every change; it never leaves
|
|
25
|
+
* the browser, so any expression is fair game. Which rows reach the device is
|
|
26
|
+
* decided separately by the server's row filter.
|
|
27
|
+
*/
|
|
28
|
+
export declare function useLiveQuery<TRow extends SyncRow = SyncRow>(model: SyncModelClient, predicate?: (row: TRow) => boolean, options?: LiveQueryOptions<TRow>): LiveQueryResult<TRow>;
|
|
29
|
+
/** Subscribe to a single row by key. */
|
|
30
|
+
export declare function useRow<TRow extends SyncRow = SyncRow>(model: SyncModelClient, key: unknown): TRow | undefined;
|
|
31
|
+
//# sourceMappingURL=react.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"react.d.ts","sourceRoot":"","sources":["../src/react.ts"],"names":[],"mappings":"AAGA,OAAO,EAA8B,KAAK,eAAe,EAAE,MAAM,aAAa,CAAC;AAC/E,OAAO,KAAK,EAAE,OAAO,EAAE,eAAe,EAAE,MAAM,YAAY,CAAC;AAI3D,MAAM,MAAM,gBAAgB,CAAC,IAAI,IAAI;IACnC,OAAO,CAAC,EAAE,CAAC,GAAG,EAAE,IAAI,KAAK,MAAM,GAAG,MAAM,GAAG,IAAI,CAAC;IAChD,SAAS,CAAC,EAAE,KAAK,GAAG,MAAM,CAAC;IAC3B,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,OAAO,CAAC,EAAE,OAAO,CAAC;CACnB,CAAC;AAEF,MAAM,MAAM,eAAe,CAAC,IAAI,IAAI;IAClC,IAAI,EAAE,IAAI,EAAE,CAAC;IACb,MAAM,EAAE,eAAe,CAAC;IACxB,KAAK,EAAE,KAAK,GAAG,IAAI,CAAC;IACpB,2CAA2C;IAC3C,OAAO,EAAE,MAAM,CAAC;IAChB,mDAAmD;IACnD,MAAM,EAAE,MAAM,CAAC;IACf,OAAO,EAAE,OAAO,CAAC;IACjB,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;CAC1B,CAAC;AAEF;;;;;;GAMG;AACH,wBAAgB,YAAY,CAAC,IAAI,SAAS,OAAO,GAAG,OAAO,EACzD,KAAK,EAAE,eAAe,EACtB,SAAS,CAAC,EAAE,CAAC,GAAG,EAAE,IAAI,KAAK,OAAO,EAClC,OAAO,GAAE,gBAAgB,CAAC,IAAI,CAAM,GACnC,eAAe,CAAC,IAAI,CAAC,CA6EvB;AAED,wCAAwC;AACxC,wBAAgB,MAAM,CAAC,IAAI,SAAS,OAAO,GAAG,OAAO,EACnD,KAAK,EAAE,eAAe,EACtB,GAAG,EAAE,OAAO,GACX,IAAI,GAAG,SAAS,CAYlB"}
|
package/dist/react.js
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { useCallback, useEffect, useMemo, useRef, useSyncExternalStore } from "react";
|
|
3
|
+
import { getSyncStore, hydrateModel } from "./client.js";
|
|
4
|
+
const EMPTY_ROWS = [];
|
|
5
|
+
/**
|
|
6
|
+
* Subscribe to rows of a model, filtered in the browser.
|
|
7
|
+
*
|
|
8
|
+
* The predicate runs over the model's rows on every change; it never leaves
|
|
9
|
+
* the browser, so any expression is fair game. Which rows reach the device is
|
|
10
|
+
* decided separately by the server's row filter.
|
|
11
|
+
*/
|
|
12
|
+
export function useLiveQuery(model, predicate, options = {}) {
|
|
13
|
+
const name = model.__farmSyncModel;
|
|
14
|
+
const store = getSyncStore(name);
|
|
15
|
+
const predicateRef = useRef(predicate);
|
|
16
|
+
predicateRef.current = predicate;
|
|
17
|
+
const optionsRef = useRef(options);
|
|
18
|
+
optionsRef.current = options;
|
|
19
|
+
const subscribe = useCallback((listener) => store.subscribe(listener), [store]);
|
|
20
|
+
// Rows live in the browser, so the server has none. Both sides must agree on
|
|
21
|
+
// that first snapshot or hydration mismatches; the real rows arrive on the
|
|
22
|
+
// store's first update right after.
|
|
23
|
+
const rows = useSyncExternalStore(subscribe, () => store.getRows(), () => EMPTY_ROWS);
|
|
24
|
+
const status = useSyncExternalStore(subscribe, () => store.status, () => "loading");
|
|
25
|
+
const error = useSyncExternalStore(subscribe, () => store.error, () => null);
|
|
26
|
+
const pending = useSyncExternalStore(subscribe, () => store.pending, () => 0);
|
|
27
|
+
const paused = useSyncExternalStore(subscribe, () => store.paused, () => 0);
|
|
28
|
+
useEffect(() => {
|
|
29
|
+
if (options.enabled === false)
|
|
30
|
+
return;
|
|
31
|
+
hydrateModel(name);
|
|
32
|
+
}, [name, options.enabled]);
|
|
33
|
+
// Recompute only when the underlying rows change; the predicate is read from
|
|
34
|
+
// a ref so an inline arrow does not invalidate the memo every render.
|
|
35
|
+
const visible = useMemo(() => {
|
|
36
|
+
const current = predicateRef.current;
|
|
37
|
+
const opts = optionsRef.current;
|
|
38
|
+
let result = current ? rows.filter((row) => current(row)) : rows;
|
|
39
|
+
if (opts.orderBy) {
|
|
40
|
+
const direction = opts.direction === "desc" ? -1 : 1;
|
|
41
|
+
result = [...result].sort((a, b) => {
|
|
42
|
+
const left = opts.orderBy(a);
|
|
43
|
+
const right = opts.orderBy(b);
|
|
44
|
+
if (left === right)
|
|
45
|
+
return 0;
|
|
46
|
+
return (left < right ? -1 : 1) * direction;
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
if (opts.offset || opts.limit !== undefined) {
|
|
50
|
+
const start = opts.offset ?? 0;
|
|
51
|
+
result = result.slice(start, opts.limit === undefined ? undefined : start + opts.limit);
|
|
52
|
+
}
|
|
53
|
+
return result;
|
|
54
|
+
}, [rows]);
|
|
55
|
+
return {
|
|
56
|
+
rows: visible,
|
|
57
|
+
status,
|
|
58
|
+
error,
|
|
59
|
+
pending,
|
|
60
|
+
paused,
|
|
61
|
+
isEmpty: visible.length === 0,
|
|
62
|
+
refresh: () => model.refresh(),
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
/** Subscribe to a single row by key. */
|
|
66
|
+
export function useRow(model, key) {
|
|
67
|
+
const store = getSyncStore(model.__farmSyncModel);
|
|
68
|
+
const subscribe = useCallback((listener) => store.subscribe(listener), [store]);
|
|
69
|
+
const rows = useSyncExternalStore(subscribe, () => store.getRows(), () => store.getRows());
|
|
70
|
+
return useMemo(() => rows.find((row) => String(row[store.descriptor.key]) === String(key)), [rows, key, store.descriptor.key]);
|
|
71
|
+
}
|
package/dist/server.d.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { ResolvedSyncModel, SyncErrorCode, SyncListResult, SyncRequestBody } from "./types.js";
|
|
2
|
+
/** Minimal slice of the ORM client the sync runtime relies on. */
|
|
3
|
+
export type SyncOrmModelClient = {
|
|
4
|
+
findMany(args?: Record<string, unknown>): Promise<Record<string, unknown>[]>;
|
|
5
|
+
create(args: {
|
|
6
|
+
data: Record<string, unknown>;
|
|
7
|
+
}): Promise<Record<string, unknown>>;
|
|
8
|
+
update(args: {
|
|
9
|
+
where: Record<string, unknown>;
|
|
10
|
+
data: Record<string, unknown>;
|
|
11
|
+
}): Promise<Record<string, unknown> | null>;
|
|
12
|
+
updateMany?(args: {
|
|
13
|
+
where: Record<string, unknown>;
|
|
14
|
+
data: Record<string, unknown>;
|
|
15
|
+
}): Promise<number>;
|
|
16
|
+
delete?(args: {
|
|
17
|
+
where: Record<string, unknown>;
|
|
18
|
+
}): Promise<number>;
|
|
19
|
+
deleteMany(args: {
|
|
20
|
+
where: Record<string, unknown>;
|
|
21
|
+
}): Promise<number>;
|
|
22
|
+
};
|
|
23
|
+
export type SyncOrmClient = Record<string, SyncOrmModelClient>;
|
|
24
|
+
export declare class SyncOperationError extends Error {
|
|
25
|
+
readonly code: SyncErrorCode;
|
|
26
|
+
readonly status: number;
|
|
27
|
+
constructor(code: SyncErrorCode, message: string, status?: number);
|
|
28
|
+
}
|
|
29
|
+
export type ExecuteSyncOptions = {
|
|
30
|
+
body: SyncRequestBody;
|
|
31
|
+
models: Map<string, ResolvedSyncModel>;
|
|
32
|
+
orm: SyncOrmClient;
|
|
33
|
+
request: Request;
|
|
34
|
+
/** Context produced by the configured middleware chain. */
|
|
35
|
+
context: Record<string, unknown>;
|
|
36
|
+
};
|
|
37
|
+
/**
|
|
38
|
+
* Run one sync operation against the ORM with the model's row filter applied.
|
|
39
|
+
*
|
|
40
|
+
* The filter is merged into the query itself rather than checked against the
|
|
41
|
+
* client's arguments, so a write aimed at a row outside the caller's scope
|
|
42
|
+
* matches nothing instead of being trusted and rejected after the fact.
|
|
43
|
+
*/
|
|
44
|
+
export declare function executeSyncOperation(options: ExecuteSyncOptions): Promise<SyncListResult | Record<string, unknown> | {
|
|
45
|
+
deleted: number;
|
|
46
|
+
}>;
|
|
47
|
+
//# sourceMappingURL=server.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,iBAAiB,EACjB,aAAa,EACb,cAAc,EAEd,eAAe,EAChB,MAAM,YAAY,CAAC;AAEpB,kEAAkE;AAClE,MAAM,MAAM,kBAAkB,GAAG;IAC/B,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,CAAC,CAAC;IAC7E,MAAM,CAAC,IAAI,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;KAAE,GAAG,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAC;IAClF,MAAM,CAAC,IAAI,EAAE;QACX,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;QAC/B,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;KAC/B,GAAG,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAAC,CAAC;IAC5C,UAAU,CAAC,CAAC,IAAI,EAAE;QAChB,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;QAC/B,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;KAC/B,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC;IACpB,MAAM,CAAC,CAAC,IAAI,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;KAAE,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC;IACnE,UAAU,CAAC,IAAI,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;KAAE,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC;CACvE,CAAC;AAEF,MAAM,MAAM,aAAa,GAAG,MAAM,CAAC,MAAM,EAAE,kBAAkB,CAAC,CAAC;AAE/D,qBAAa,kBAAmB,SAAQ,KAAK;IAEzC,QAAQ,CAAC,IAAI,EAAE,aAAa;IAE5B,QAAQ,CAAC,MAAM;gBAFN,IAAI,EAAE,aAAa,EAC5B,OAAO,EAAE,MAAM,EACN,MAAM,SAAM;CAKxB;AAED,MAAM,MAAM,kBAAkB,GAAG;IAC/B,IAAI,EAAE,eAAe,CAAC;IACtB,MAAM,EAAE,GAAG,CAAC,MAAM,EAAE,iBAAiB,CAAC,CAAC;IACvC,GAAG,EAAE,aAAa,CAAC;IACnB,OAAO,EAAE,OAAO,CAAC;IACjB,2DAA2D;IAC3D,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CAClC,CAAC;AAEF;;;;;;GAMG;AACH,wBAAsB,oBAAoB,CACxC,OAAO,EAAE,kBAAkB,GAC1B,OAAO,CAAC,cAAc,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG;IAAE,OAAO,EAAE,MAAM,CAAA;CAAE,CAAC,CA0CzE"}
|
package/dist/server.js
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
export class SyncOperationError extends Error {
|
|
2
|
+
code;
|
|
3
|
+
status;
|
|
4
|
+
constructor(code, message, status = 400) {
|
|
5
|
+
super(message);
|
|
6
|
+
this.code = code;
|
|
7
|
+
this.status = status;
|
|
8
|
+
this.name = "SyncOperationError";
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Run one sync operation against the ORM with the model's row filter applied.
|
|
13
|
+
*
|
|
14
|
+
* The filter is merged into the query itself rather than checked against the
|
|
15
|
+
* client's arguments, so a write aimed at a row outside the caller's scope
|
|
16
|
+
* matches nothing instead of being trusted and rejected after the fact.
|
|
17
|
+
*/
|
|
18
|
+
export async function executeSyncOperation(options) {
|
|
19
|
+
const { body, models, orm, request, context } = options;
|
|
20
|
+
const resolved = models.get(body.model);
|
|
21
|
+
if (!resolved) {
|
|
22
|
+
throw new SyncOperationError("not_exposed", `Model "${body.model}" is not exposed to the browser.`, 404);
|
|
23
|
+
}
|
|
24
|
+
if (body.operation !== "list" && resolved.access !== "write") {
|
|
25
|
+
throw new SyncOperationError("read_only", `Model "${body.model}" is read-only from the browser.`, 403);
|
|
26
|
+
}
|
|
27
|
+
const client = orm[body.model];
|
|
28
|
+
if (!client) {
|
|
29
|
+
throw new SyncOperationError("unknown_model", `Model "${body.model}" is missing from the ORM client.`, 500);
|
|
30
|
+
}
|
|
31
|
+
const scope = await resolveScope(resolved, { context, request, operation: body.operation });
|
|
32
|
+
switch (body.operation) {
|
|
33
|
+
case "list":
|
|
34
|
+
return listRows(client, resolved, scope, body.since ?? null);
|
|
35
|
+
case "insert":
|
|
36
|
+
return insertRow(client, resolved, scope, body.input);
|
|
37
|
+
case "update":
|
|
38
|
+
return updateRow(client, resolved, scope, body.input);
|
|
39
|
+
case "delete":
|
|
40
|
+
return deleteRow(client, resolved, scope, body.input);
|
|
41
|
+
default:
|
|
42
|
+
throw new SyncOperationError("invalid_input", `Unknown operation "${body.operation}".`);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
async function resolveScope(resolved, args) {
|
|
46
|
+
if (!resolved.where)
|
|
47
|
+
return {};
|
|
48
|
+
const scope = await resolved.where({
|
|
49
|
+
context: args.context,
|
|
50
|
+
request: args.request,
|
|
51
|
+
model: resolved.name,
|
|
52
|
+
operation: args.operation,
|
|
53
|
+
});
|
|
54
|
+
if (!scope || typeof scope !== "object" || Array.isArray(scope)) {
|
|
55
|
+
throw new SyncOperationError("server_error", `sync(): the row filter for "${resolved.name}" must return an object.`, 500);
|
|
56
|
+
}
|
|
57
|
+
return scope;
|
|
58
|
+
}
|
|
59
|
+
async function listRows(client, resolved, scope, since) {
|
|
60
|
+
const cursorField = resolved.cursorField;
|
|
61
|
+
const incremental = Boolean(cursorField && since);
|
|
62
|
+
const where = { ...scope };
|
|
63
|
+
if (incremental)
|
|
64
|
+
where[cursorField] = { gt: new Date(since) };
|
|
65
|
+
const rows = await client.findMany({ where });
|
|
66
|
+
return {
|
|
67
|
+
rows,
|
|
68
|
+
cursor: cursorField ? nextCursor(rows, cursorField, since) : null,
|
|
69
|
+
full: !incremental,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
function nextCursor(rows, cursorField, previous) {
|
|
73
|
+
let latest = previous ? new Date(previous).getTime() : 0;
|
|
74
|
+
for (const row of rows) {
|
|
75
|
+
const value = row[cursorField];
|
|
76
|
+
const time = value instanceof Date
|
|
77
|
+
? value.getTime()
|
|
78
|
+
: typeof value === "string" || typeof value === "number"
|
|
79
|
+
? new Date(value).getTime()
|
|
80
|
+
: Number.NaN;
|
|
81
|
+
if (Number.isFinite(time) && time > latest)
|
|
82
|
+
latest = time;
|
|
83
|
+
}
|
|
84
|
+
return latest > 0 ? new Date(latest).toISOString() : previous;
|
|
85
|
+
}
|
|
86
|
+
async function insertRow(client, resolved, scope, input) {
|
|
87
|
+
const data = sanitizeInput(resolved, input, { allowKey: true });
|
|
88
|
+
// The browser generates keys so an optimistic row is addressable immediately,
|
|
89
|
+
// but a direct API call may omit one. Without this an id-less row is written
|
|
90
|
+
// that no update or delete can ever target.
|
|
91
|
+
if (data[resolved.key] === undefined || data[resolved.key] === null) {
|
|
92
|
+
const generated = generateKey(resolved);
|
|
93
|
+
if (generated === undefined) {
|
|
94
|
+
throw new SyncOperationError("invalid_input", `${resolved.name}.${resolved.key} is required: it has no generatable type.`);
|
|
95
|
+
}
|
|
96
|
+
data[resolved.key] = generated;
|
|
97
|
+
}
|
|
98
|
+
// Scope and cursor columns are server-owned: client-supplied values are
|
|
99
|
+
// discarded so a caller cannot widen its scope or stall everyone's
|
|
100
|
+
// incremental sync by writing a stale timestamp.
|
|
101
|
+
return client.create({ data: { ...data, ...scope, ...cursorStamp(resolved) } });
|
|
102
|
+
}
|
|
103
|
+
async function updateRow(client, resolved, scope, input) {
|
|
104
|
+
const data = sanitizeInput(resolved, input, { allowKey: false });
|
|
105
|
+
const key = requireKey(resolved, input);
|
|
106
|
+
const where = { ...scope, [resolved.key]: key };
|
|
107
|
+
const updated = await client.update({ where, data: { ...data, ...cursorStamp(resolved) } });
|
|
108
|
+
if (!updated) {
|
|
109
|
+
throw new SyncOperationError("not_found", `No ${resolved.name} row matched ${String(key)} within the current scope.`, 404);
|
|
110
|
+
}
|
|
111
|
+
return updated;
|
|
112
|
+
}
|
|
113
|
+
async function deleteRow(client, resolved, scope, input) {
|
|
114
|
+
const key = requireKey(resolved, input);
|
|
115
|
+
const deleted = await client.deleteMany({ where: { ...scope, [resolved.key]: key } });
|
|
116
|
+
if (deleted === 0) {
|
|
117
|
+
throw new SyncOperationError("not_found", `No ${resolved.name} row matched ${String(key)} within the current scope.`, 404);
|
|
118
|
+
}
|
|
119
|
+
return { deleted };
|
|
120
|
+
}
|
|
121
|
+
function requireKey(resolved, input) {
|
|
122
|
+
const key = input?.[resolved.key];
|
|
123
|
+
if (key === undefined || key === null || key === "") {
|
|
124
|
+
throw new SyncOperationError("invalid_input", `${resolved.name}.${resolved.key} is required for this operation.`);
|
|
125
|
+
}
|
|
126
|
+
return key;
|
|
127
|
+
}
|
|
128
|
+
/** Drop unknown columns so a client cannot write outside the declared schema. */
|
|
129
|
+
function sanitizeInput(resolved, input, options) {
|
|
130
|
+
if (input !== undefined &&
|
|
131
|
+
(typeof input !== "object" || input === null || Array.isArray(input))) {
|
|
132
|
+
throw new SyncOperationError("invalid_input", "Operation input must be an object.");
|
|
133
|
+
}
|
|
134
|
+
const data = {};
|
|
135
|
+
for (const [field, value] of Object.entries(input ?? {})) {
|
|
136
|
+
if (!Object.hasOwn(resolved.fields, field))
|
|
137
|
+
continue;
|
|
138
|
+
if (field === resolved.key && !options.allowKey)
|
|
139
|
+
continue;
|
|
140
|
+
if (field === resolved.cursorField)
|
|
141
|
+
continue;
|
|
142
|
+
data[field] = value;
|
|
143
|
+
}
|
|
144
|
+
return data;
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Value for the cursor column on a write.
|
|
148
|
+
*
|
|
149
|
+
* Drivers do not maintain this: the orm writes exactly the columns it is given.
|
|
150
|
+
* Stamping it here keeps incremental sync working the same way on every
|
|
151
|
+
* backend rather than depending on a database default or trigger.
|
|
152
|
+
*/
|
|
153
|
+
function cursorStamp(resolved) {
|
|
154
|
+
if (!resolved.cursorField)
|
|
155
|
+
return {};
|
|
156
|
+
return { [resolved.cursorField]: new Date().toISOString() };
|
|
157
|
+
}
|
|
158
|
+
/** Generate a key for schema types that carry one; other types must be supplied. */
|
|
159
|
+
function generateKey(resolved) {
|
|
160
|
+
const type = resolved.fields[resolved.key]?.type;
|
|
161
|
+
if (type !== "uuid" && type !== "id" && type !== "string" && type !== "text")
|
|
162
|
+
return undefined;
|
|
163
|
+
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
|
|
164
|
+
return crypto.randomUUID();
|
|
165
|
+
}
|
|
166
|
+
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
|
|
167
|
+
}
|
package/dist/store.d.ts
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
export type SyncRow = Record<string, unknown>;
|
|
2
|
+
export type SyncModelDescriptor = {
|
|
3
|
+
key: string;
|
|
4
|
+
access: "read" | "write";
|
|
5
|
+
persist: boolean;
|
|
6
|
+
cursor: string | null;
|
|
7
|
+
};
|
|
8
|
+
export type SyncRuntimeConfig = {
|
|
9
|
+
path: string;
|
|
10
|
+
models: Record<string, SyncModelDescriptor>;
|
|
11
|
+
};
|
|
12
|
+
export type SyncStoreStatus = "idle" | "loading" | "ready" | "error";
|
|
13
|
+
type OptimisticLayer = {
|
|
14
|
+
type: "upsert";
|
|
15
|
+
key: unknown;
|
|
16
|
+
row: SyncRow;
|
|
17
|
+
} | {
|
|
18
|
+
type: "delete";
|
|
19
|
+
key: unknown;
|
|
20
|
+
};
|
|
21
|
+
/**
|
|
22
|
+
* In-memory rows for one model: confirmed server rows plus an ordered stack of
|
|
23
|
+
* optimistic layers. Visible rows are the confirmed set with layers applied, so
|
|
24
|
+
* a rollback is removing one layer rather than reconstructing prior state.
|
|
25
|
+
*/
|
|
26
|
+
export declare class SyncModelStore {
|
|
27
|
+
readonly name: string;
|
|
28
|
+
readonly descriptor: SyncModelDescriptor;
|
|
29
|
+
private confirmed;
|
|
30
|
+
private layers;
|
|
31
|
+
private listeners;
|
|
32
|
+
private snapshot;
|
|
33
|
+
private snapshotDirty;
|
|
34
|
+
status: SyncStoreStatus;
|
|
35
|
+
error: Error | null;
|
|
36
|
+
cursor: string | null;
|
|
37
|
+
pending: number;
|
|
38
|
+
paused: number;
|
|
39
|
+
constructor(name: string, descriptor: SyncModelDescriptor);
|
|
40
|
+
subscribe(listener: () => void): () => void;
|
|
41
|
+
/** Stable array identity between changes so `useSyncExternalStore` stays quiet. */
|
|
42
|
+
getRows(): SyncRow[];
|
|
43
|
+
get(key: unknown): SyncRow | undefined;
|
|
44
|
+
setStatus(status: SyncStoreStatus, error?: Error | null): void;
|
|
45
|
+
/** Apply a server snapshot or delta. A full result replaces confirmed rows. */
|
|
46
|
+
applyServerRows(rows: SyncRow[], options: {
|
|
47
|
+
full: boolean;
|
|
48
|
+
cursor: string | null;
|
|
49
|
+
}): void;
|
|
50
|
+
addLayer(layer: OptimisticLayer): () => void;
|
|
51
|
+
removeLayer(layer: OptimisticLayer): void;
|
|
52
|
+
/** Fold a confirmed server row in and drop the layer that predicted it. */
|
|
53
|
+
commitLayer(layer: OptimisticLayer, confirmed: SyncRow | null): void;
|
|
54
|
+
mergeRow(key: unknown, changes: SyncRow): SyncRow;
|
|
55
|
+
/** Rows worth persisting: confirmed only, never optimistic guesses. */
|
|
56
|
+
confirmedRows(): SyncRow[];
|
|
57
|
+
hydrate(rows: SyncRow[], cursor: string | null): void;
|
|
58
|
+
trackPending(delta: number): void;
|
|
59
|
+
trackPaused(delta: number): void;
|
|
60
|
+
private invalidate;
|
|
61
|
+
private emit;
|
|
62
|
+
}
|
|
63
|
+
export {};
|
|
64
|
+
//# sourceMappingURL=store.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"store.d.ts","sourceRoot":"","sources":["../src/store.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,OAAO,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;AAE9C,MAAM,MAAM,mBAAmB,GAAG;IAChC,GAAG,EAAE,MAAM,CAAC;IACZ,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC;IACzB,OAAO,EAAE,OAAO,CAAC;IACjB,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;CACvB,CAAC;AAEF,MAAM,MAAM,iBAAiB,GAAG;IAC9B,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,mBAAmB,CAAC,CAAC;CAC7C,CAAC;AAEF,MAAM,MAAM,eAAe,GAAG,MAAM,GAAG,SAAS,GAAG,OAAO,GAAG,OAAO,CAAC;AAErE,KAAK,eAAe,GAChB;IAAE,IAAI,EAAE,QAAQ,CAAC;IAAC,GAAG,EAAE,OAAO,CAAC;IAAC,GAAG,EAAE,OAAO,CAAA;CAAE,GAC9C;IAAE,IAAI,EAAE,QAAQ,CAAC;IAAC,GAAG,EAAE,OAAO,CAAA;CAAE,CAAC;AAErC;;;;GAIG;AACH,qBAAa,cAAc;IAcvB,QAAQ,CAAC,IAAI,EAAE,MAAM;IACrB,QAAQ,CAAC,UAAU,EAAE,mBAAmB;IAd1C,OAAO,CAAC,SAAS,CAA8B;IAC/C,OAAO,CAAC,MAAM,CAAyB;IACvC,OAAO,CAAC,SAAS,CAAyB;IAC1C,OAAO,CAAC,QAAQ,CAAiB;IACjC,OAAO,CAAC,aAAa,CAAQ;IAE7B,MAAM,EAAE,eAAe,CAAU;IACjC,KAAK,EAAE,KAAK,GAAG,IAAI,CAAQ;IAC3B,MAAM,EAAE,MAAM,GAAG,IAAI,CAAQ;IAC7B,OAAO,SAAK;IACZ,MAAM,SAAK;gBAGA,IAAI,EAAE,MAAM,EACZ,UAAU,EAAE,mBAAmB;IAG1C,SAAS,CAAC,QAAQ,EAAE,MAAM,IAAI,GAAG,MAAM,IAAI;IAO3C,mFAAmF;IACnF,OAAO,IAAI,OAAO,EAAE;IAepB,GAAG,CAAC,GAAG,EAAE,OAAO,GAAG,OAAO,GAAG,SAAS;IAItC,SAAS,CAAC,MAAM,EAAE,eAAe,EAAE,KAAK,GAAE,KAAK,GAAG,IAAW,GAAG,IAAI;IAMpE,+EAA+E;IAC/E,eAAe,CAAC,IAAI,EAAE,OAAO,EAAE,EAAE,OAAO,EAAE;QAAE,IAAI,EAAE,OAAO,CAAC;QAAC,MAAM,EAAE,MAAM,GAAG,IAAI,CAAA;KAAE,GAAG,IAAI;IAWzF,QAAQ,CAAC,KAAK,EAAE,eAAe,GAAG,MAAM,IAAI;IAM5C,WAAW,CAAC,KAAK,EAAE,eAAe,GAAG,IAAI;IAOzC,2EAA2E;IAC3E,WAAW,CAAC,KAAK,EAAE,eAAe,EAAE,SAAS,EAAE,OAAO,GAAG,IAAI,GAAG,IAAI;IAcpE,QAAQ,CAAC,GAAG,EAAE,OAAO,EAAE,OAAO,EAAE,OAAO,GAAG,OAAO;IAKjD,uEAAuE;IACvE,aAAa,IAAI,OAAO,EAAE;IAI1B,OAAO,CAAC,IAAI,EAAE,OAAO,EAAE,EAAE,MAAM,EAAE,MAAM,GAAG,IAAI,GAAG,IAAI;IAUrD,YAAY,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI;IAKjC,WAAW,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI;IAKhC,OAAO,CAAC,UAAU;IAKlB,OAAO,CAAC,IAAI;CAGb"}
|