@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/dist/store.js ADDED
@@ -0,0 +1,128 @@
1
+ /**
2
+ * In-memory rows for one model: confirmed server rows plus an ordered stack of
3
+ * optimistic layers. Visible rows are the confirmed set with layers applied, so
4
+ * a rollback is removing one layer rather than reconstructing prior state.
5
+ */
6
+ export class SyncModelStore {
7
+ name;
8
+ descriptor;
9
+ confirmed = new Map();
10
+ layers = [];
11
+ listeners = new Set();
12
+ snapshot = [];
13
+ snapshotDirty = true;
14
+ status = "idle";
15
+ error = null;
16
+ cursor = null;
17
+ pending = 0;
18
+ paused = 0;
19
+ constructor(name, descriptor) {
20
+ this.name = name;
21
+ this.descriptor = descriptor;
22
+ }
23
+ subscribe(listener) {
24
+ this.listeners.add(listener);
25
+ return () => {
26
+ this.listeners.delete(listener);
27
+ };
28
+ }
29
+ /** Stable array identity between changes so `useSyncExternalStore` stays quiet. */
30
+ getRows() {
31
+ if (!this.snapshotDirty)
32
+ return this.snapshot;
33
+ const rows = new Map(this.confirmed);
34
+ for (const layer of this.layers) {
35
+ const id = String(layer.key);
36
+ if (layer.type === "delete")
37
+ rows.delete(id);
38
+ else
39
+ rows.set(id, layer.row);
40
+ }
41
+ this.snapshot = Array.from(rows.values());
42
+ this.snapshotDirty = false;
43
+ return this.snapshot;
44
+ }
45
+ get(key) {
46
+ return this.getRows().find((row) => String(row[this.descriptor.key]) === String(key));
47
+ }
48
+ setStatus(status, error = null) {
49
+ this.status = status;
50
+ this.error = error;
51
+ this.emit();
52
+ }
53
+ /** Apply a server snapshot or delta. A full result replaces confirmed rows. */
54
+ applyServerRows(rows, options) {
55
+ if (options.full)
56
+ this.confirmed.clear();
57
+ for (const row of rows) {
58
+ this.confirmed.set(String(row[this.descriptor.key]), row);
59
+ }
60
+ this.cursor = options.cursor ?? this.cursor;
61
+ this.status = "ready";
62
+ this.error = null;
63
+ this.invalidate();
64
+ }
65
+ addLayer(layer) {
66
+ this.layers.push(layer);
67
+ this.invalidate();
68
+ return () => this.removeLayer(layer);
69
+ }
70
+ removeLayer(layer) {
71
+ const index = this.layers.indexOf(layer);
72
+ if (index === -1)
73
+ return;
74
+ this.layers.splice(index, 1);
75
+ this.invalidate();
76
+ }
77
+ /** Fold a confirmed server row in and drop the layer that predicted it. */
78
+ commitLayer(layer, confirmed) {
79
+ const index = this.layers.indexOf(layer);
80
+ if (index !== -1)
81
+ this.layers.splice(index, 1);
82
+ if (layer.type === "delete") {
83
+ this.confirmed.delete(String(layer.key));
84
+ }
85
+ else if (confirmed) {
86
+ this.confirmed.set(String(confirmed[this.descriptor.key] ?? layer.key), confirmed);
87
+ }
88
+ else {
89
+ this.confirmed.set(String(layer.key), layer.row);
90
+ }
91
+ this.invalidate();
92
+ }
93
+ mergeRow(key, changes) {
94
+ const current = this.get(key) ?? { [this.descriptor.key]: key };
95
+ return { ...current, ...changes };
96
+ }
97
+ /** Rows worth persisting: confirmed only, never optimistic guesses. */
98
+ confirmedRows() {
99
+ return Array.from(this.confirmed.values());
100
+ }
101
+ hydrate(rows, cursor) {
102
+ if (this.confirmed.size > 0)
103
+ return; // live data always wins
104
+ for (const row of rows) {
105
+ this.confirmed.set(String(row[this.descriptor.key]), row);
106
+ }
107
+ this.cursor = cursor;
108
+ if (rows.length > 0)
109
+ this.status = "ready";
110
+ this.invalidate();
111
+ }
112
+ trackPending(delta) {
113
+ this.pending = Math.max(0, this.pending + delta);
114
+ this.emit();
115
+ }
116
+ trackPaused(delta) {
117
+ this.paused = Math.max(0, this.paused + delta);
118
+ this.emit();
119
+ }
120
+ invalidate() {
121
+ this.snapshotDirty = true;
122
+ this.emit();
123
+ }
124
+ emit() {
125
+ for (const listener of this.listeners)
126
+ listener();
127
+ }
128
+ }
@@ -0,0 +1,56 @@
1
+ import type { FarmSchema, FarmSchemaField, FarmSchemaModel } from "@farm.js/core";
2
+ /** Operations a browser may perform on a model. */
3
+ export type SyncAccess = "read" | "write";
4
+ export type SyncModelConfig = {
5
+ access?: SyncAccess;
6
+ /** Overrides the global row filter for this model. `false` disables scoping. */
7
+ where?: SyncWhere | false;
8
+ /** Persist this model's rows through the configured client cache adapter. */
9
+ persist?: boolean;
10
+ };
11
+ export type SyncModelSetting = SyncAccess | false | SyncModelConfig;
12
+ export type SyncWhereContext = {
13
+ context: Record<string, unknown>;
14
+ request: Request;
15
+ model: string;
16
+ operation: SyncOperation;
17
+ };
18
+ export type SyncWhere = (context: SyncWhereContext) => Record<string, unknown> | Promise<Record<string, unknown>>;
19
+ export type SyncOperation = "list" | "insert" | "update" | "delete";
20
+ /** Wire payload for a single sync operation. */
21
+ export type SyncRequestBody = {
22
+ model: string;
23
+ operation: SyncOperation;
24
+ /** Row data for insert, `{ [key]: value, ...changes }` for update, `{ [key] }` for delete. */
25
+ input?: Record<string, unknown>;
26
+ /** Incremental cursor for list; the largest `updatedAt` the client already holds. */
27
+ since?: string | null;
28
+ };
29
+ export type SyncListResult = {
30
+ rows: Record<string, unknown>[];
31
+ cursor: string | null;
32
+ /** True when the server sent a full snapshot rather than a delta. */
33
+ full: boolean;
34
+ };
35
+ export type SyncErrorCode = "unknown_model" | "read_only" | "not_exposed" | "invalid_input" | "not_found" | "unauthorized" | "server_error";
36
+ export type SyncErrorBody = {
37
+ error: {
38
+ code: SyncErrorCode;
39
+ message: string;
40
+ };
41
+ };
42
+ /** Resolved, validated model metadata derived from the schema. */
43
+ export type ResolvedSyncModel = {
44
+ name: string;
45
+ model: FarmSchemaModel;
46
+ /** Primary key field name. */
47
+ key: string;
48
+ access: SyncAccess;
49
+ where: SyncWhere | false | undefined;
50
+ persist: boolean;
51
+ /** Field carrying the incremental cursor, when the schema declares one. */
52
+ cursorField: string | undefined;
53
+ fields: Record<string, FarmSchemaField>;
54
+ };
55
+ export declare function resolveSyncModels(schema: FarmSchema, models: Record<string, SyncModelSetting> | undefined, globalWhere: SyncWhere | false | undefined, defaultPersist: boolean): Map<string, ResolvedSyncModel>;
56
+ //# sourceMappingURL=types.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,eAAe,EAAE,eAAe,EAAE,MAAM,eAAe,CAAC;AAElF,mDAAmD;AACnD,MAAM,MAAM,UAAU,GAAG,MAAM,GAAG,OAAO,CAAC;AAE1C,MAAM,MAAM,eAAe,GAAG;IAC5B,MAAM,CAAC,EAAE,UAAU,CAAC;IACpB,gFAAgF;IAChF,KAAK,CAAC,EAAE,SAAS,GAAG,KAAK,CAAC;IAC1B,6EAA6E;IAC7E,OAAO,CAAC,EAAE,OAAO,CAAC;CACnB,CAAC;AAEF,MAAM,MAAM,gBAAgB,GAAG,UAAU,GAAG,KAAK,GAAG,eAAe,CAAC;AAEpE,MAAM,MAAM,gBAAgB,GAAG;IAC7B,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACjC,OAAO,EAAE,OAAO,CAAC;IACjB,KAAK,EAAE,MAAM,CAAC;IACd,SAAS,EAAE,aAAa,CAAC;CAC1B,CAAC;AAEF,MAAM,MAAM,SAAS,GAAG,CACtB,OAAO,EAAE,gBAAgB,KACtB,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAC;AAEhE,MAAM,MAAM,aAAa,GAAG,MAAM,GAAG,QAAQ,GAAG,QAAQ,GAAG,QAAQ,CAAC;AAEpE,gDAAgD;AAChD,MAAM,MAAM,eAAe,GAAG;IAC5B,KAAK,EAAE,MAAM,CAAC;IACd,SAAS,EAAE,aAAa,CAAC;IACzB,8FAA8F;IAC9F,KAAK,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAChC,qFAAqF;IACrF,KAAK,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CACvB,CAAC;AAEF,MAAM,MAAM,cAAc,GAAG;IAC3B,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,CAAC;IAChC,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;IACtB,qEAAqE;IACrE,IAAI,EAAE,OAAO,CAAC;CACf,CAAC;AAEF,MAAM,MAAM,aAAa,GACrB,eAAe,GACf,WAAW,GACX,aAAa,GACb,eAAe,GACf,WAAW,GACX,cAAc,GACd,cAAc,CAAC;AAEnB,MAAM,MAAM,aAAa,GAAG;IAAE,KAAK,EAAE;QAAE,IAAI,EAAE,aAAa,CAAC;QAAC,OAAO,EAAE,MAAM,CAAA;KAAE,CAAA;CAAE,CAAC;AAEhF,kEAAkE;AAClE,MAAM,MAAM,iBAAiB,GAAG;IAC9B,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,eAAe,CAAC;IACvB,8BAA8B;IAC9B,GAAG,EAAE,MAAM,CAAC;IACZ,MAAM,EAAE,UAAU,CAAC;IACnB,KAAK,EAAE,SAAS,GAAG,KAAK,GAAG,SAAS,CAAC;IACrC,OAAO,EAAE,OAAO,CAAC;IACjB,2EAA2E;IAC3E,WAAW,EAAE,MAAM,GAAG,SAAS,CAAC;IAChC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,eAAe,CAAC,CAAC;CACzC,CAAC;AAEF,wBAAgB,iBAAiB,CAC/B,MAAM,EAAE,UAAU,EAClB,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,gBAAgB,CAAC,GAAG,SAAS,EACpD,WAAW,EAAE,SAAS,GAAG,KAAK,GAAG,SAAS,EAC1C,cAAc,EAAE,OAAO,GACtB,GAAG,CAAC,MAAM,EAAE,iBAAiB,CAAC,CAuChC"}
package/dist/types.js ADDED
@@ -0,0 +1,49 @@
1
+ export function resolveSyncModels(schema, models, globalWhere, defaultPersist) {
2
+ const resolved = new Map();
3
+ if (!models)
4
+ return resolved;
5
+ for (const [name, setting] of Object.entries(models)) {
6
+ if (setting === false || setting === undefined)
7
+ continue;
8
+ const model = schema.models[name];
9
+ if (!model) {
10
+ throw new Error(`sync(): models.${name} is not in the schema. Available models: ${Object.keys(schema.models).join(", ") || "(none)"}.`);
11
+ }
12
+ const config = typeof setting === "string" ? { access: setting } : (setting ?? {});
13
+ const access = config.access ?? "write";
14
+ const where = config.where !== undefined ? config.where : globalWhere;
15
+ if (access === "write" && where === undefined) {
16
+ throw new Error(`sync(): models.${name} allows writes but no row filter is configured. ` +
17
+ `Add a \`where\` to scope rows to the current session, or set \`where: false\` to expose every row deliberately.`);
18
+ }
19
+ resolved.set(name, {
20
+ name,
21
+ model,
22
+ key: resolvePrimaryKey(name, model),
23
+ access,
24
+ where,
25
+ persist: config.persist ?? defaultPersist,
26
+ cursorField: resolveCursorField(model),
27
+ fields: model.fields,
28
+ });
29
+ }
30
+ return resolved;
31
+ }
32
+ function resolvePrimaryKey(name, model) {
33
+ const keys = Object.entries(model.fields).filter(([, field]) => field.primaryKey);
34
+ if (keys.length === 1)
35
+ return keys[0][0];
36
+ if (keys.length === 0) {
37
+ throw new Error(`sync(): model "${name}" needs a field marked \`primaryKey: true\`.`);
38
+ }
39
+ throw new Error(`sync(): model "${name}" has ${keys.length} primary key fields; sync requires exactly one.`);
40
+ }
41
+ /** A `datetime` field named updatedAt enables incremental sync without extra config. */
42
+ function resolveCursorField(model) {
43
+ for (const [name, field] of Object.entries(model.fields)) {
44
+ if (field.type === "datetime" && (name === "updatedAt" || field.name === "updated_at")) {
45
+ return name;
46
+ }
47
+ }
48
+ return undefined;
49
+ }
package/package.json ADDED
@@ -0,0 +1,73 @@
1
+ {
2
+ "name": "@farm.js/sync",
3
+ "version": "0.1.0-beta.1",
4
+ "description": "Local-first collections for Farm.js: schema-driven reads, optimistic writes, offline queueing",
5
+ "keywords": [
6
+ "farm.js",
7
+ "local-first",
8
+ "offline",
9
+ "optimistic",
10
+ "sync"
11
+ ],
12
+ "license": "MIT",
13
+ "repository": {
14
+ "type": "git",
15
+ "url": "https://github.com/farming-labs/farm.js",
16
+ "directory": "packages/farm-sync"
17
+ },
18
+ "files": [
19
+ "dist"
20
+ ],
21
+ "type": "module",
22
+ "main": "./dist/index.js",
23
+ "module": "./dist/index.js",
24
+ "types": "./dist/index.d.ts",
25
+ "exports": {
26
+ ".": {
27
+ "types": "./dist/index.d.ts",
28
+ "import": "./dist/index.js"
29
+ },
30
+ "./client": {
31
+ "types": "./dist/client.d.ts",
32
+ "import": "./dist/client.js"
33
+ },
34
+ "./react": {
35
+ "types": "./dist/react.d.ts",
36
+ "import": "./dist/react.js"
37
+ }
38
+ },
39
+ "publishConfig": {
40
+ "access": "public"
41
+ },
42
+ "dependencies": {
43
+ "@farm.js/core": "0.1.0-beta.103"
44
+ },
45
+ "devDependencies": {
46
+ "@farming-labs/orm": "0.0.62",
47
+ "@farming-labs/orm-runtime": "0.0.62",
48
+ "@types/node": "^20.10.5",
49
+ "@types/react": "^19.0.0",
50
+ "jsdom": "^25.0.0",
51
+ "react": "^19.0.0",
52
+ "typescript": "^5.3.3",
53
+ "unstorage": "^1.16.1",
54
+ "vitest": "^3.2.7"
55
+ },
56
+ "peerDependencies": {
57
+ "react": "^18.2.0 || ^19.0.0"
58
+ },
59
+ "peerDependenciesMeta": {
60
+ "react": {
61
+ "optional": true
62
+ }
63
+ },
64
+ "engines": {
65
+ "node": ">=22.13.0"
66
+ },
67
+ "scripts": {
68
+ "build": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\" && tsc",
69
+ "dev": "tsc --watch",
70
+ "type-check": "tsc --noEmit",
71
+ "test": "vitest run"
72
+ }
73
+ }