@foretag/tanstack-db-surrealdb 0.1.0

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/README.md ADDED
@@ -0,0 +1,137 @@
1
+ # TanstackDB SurrealDB Collections
2
+
3
+ > Note: Please note this Software is Pre-release pending testing and not yet recommended for production use.
4
+
5
+ Add Offline / Local First Caching & Syncing to your SurrealDB app with TanstackDB and Loro (CRDTs).
6
+
7
+ - Local / Offline first applications with TanstackDB and Loro
8
+ - High performance with Low resource consumption
9
+ - Works with Web, Desktop or Native (WASM based)
10
+ - Support for React, Svelte, Vue and any Framework!
11
+
12
+ ## Installation
13
+
14
+ ### NPM
15
+ ```sh
16
+ # NPM
17
+ npm install @foretag/tanstack-db-surrealdb
18
+ # Bun
19
+ bun install @foretag/tanstack-db-surrealdb
20
+ ```
21
+
22
+ ### JSR
23
+ ```sh
24
+ # NPM
25
+ npx jsr add @foretag/tanstack-db-surrealdb
26
+ # Bun
27
+ bunx jsr add @foretag/tanstack-db-surrealdb
28
+ ```
29
+
30
+ ## Usage
31
+ ```ts
32
+ // db.ts
33
+ import { Surreal } from 'surrealdb';
34
+
35
+ export const db = new Surreal();
36
+ await db.connect('ws://localhost:8000/rpc');
37
+ await db.use({ ns: 'ns', db: 'db' });
38
+
39
+ // collections/products.ts
40
+ import { expr, eq } from 'surrealdb';
41
+ import { db } from '../db';
42
+ import { createCollection } from '@tanstack/db';
43
+ import { surrealCollectionOptions } from '@foretag/tanstack-db-surrealdb';
44
+
45
+ // Collection Type, could also be generated
46
+ type Product = {
47
+ id: string;
48
+ name: string;
49
+ price: number;
50
+ };
51
+
52
+ export const products = createCollection(
53
+ surrealCollection<Product>({
54
+ id: 'products',
55
+ useLoro: true, // Optional if you need CRDTs
56
+ getKey: (collection) => collection.id,
57
+ table: {
58
+ db,
59
+ name: 'products',
60
+ where: expr(eq('store', '123'))
61
+ },
62
+ });
63
+ )
64
+ ```
65
+
66
+ ## Vite / Next.JS
67
+
68
+ ### Vite
69
+ ```sh
70
+ bun install vite-plugin-wasm vite-plugin-top-level-await -D
71
+ ```
72
+
73
+ `vite.config.ts`
74
+
75
+ ```ts
76
+ // Plugins
77
+ import wasm from 'vite-plugin-wasm';
78
+ import topLevelAwait from 'vite-plugin-top-level-await';
79
+
80
+ export default defineConfig({
81
+ plugins: [...otherConfigures, wasm(), topLevelAwait()],
82
+ });
83
+ ```
84
+
85
+ ### NextJS
86
+ `next.config.js`
87
+
88
+ ```ts
89
+ module.exports = {
90
+ webpack: function (config) {
91
+ config.experiments = {
92
+ layers: true,
93
+ asyncWebAssembly: true,
94
+ };
95
+ return config;
96
+ },
97
+ };
98
+ ```
99
+
100
+ ## CRDTs
101
+
102
+ If you need to use CRDTs for your application consider adding the following fields to the specific tables and set `useLoro: true` for the respective table. Please note these fields are opinionated, therefore fixed and required:
103
+
104
+ ```sql
105
+ DEFINE FIELD OVERWRITE sync_deleted ON <table>
106
+ TYPE bool
107
+ DEFAULT false
108
+ COMMENT 'Tombstone for CRDTs';
109
+
110
+ DEFINE FIELD OVERWRITE updated_at ON <table>
111
+ TYPE datetime
112
+ VALUE time::now();
113
+ ```
114
+
115
+ > While using SurrealDB as a Web Database, please remember to allow `SELECT` & `UPDATE` permissions for the `sync_deleted` and `updated_at` fields for the respective access.
116
+
117
+ ## FAQ
118
+
119
+ <details>
120
+ <summary><strong>When do I need CRDTs?</strong></summary>
121
+ <p>In most cases Tanstack DB is sufficient to handle CRUD operations. However, if you need to implement a distributed system that is offline first, CRDTs are the way to go. Think: Google Docs, Figma Pages, Notion Blocks etc. We recommend you check out <a href='https://www.loro.dev/' target='_blank'>Loro</a> for a deeper understanding.</p>
122
+ </details>
123
+
124
+ <details>
125
+ <summary><strong>How do I achieve type safety?</strong></summary>
126
+ <p>Using Codegen tools that generate types from your SurrealDB Schema, this means you don't have to manually maintain types for each Collection.</p>
127
+ </details>
128
+
129
+ <details>
130
+ <summary><strong>Can I use GraphQL alongside this Library?</strong></summary>
131
+ <p>GraphQL workflow is in the works as SurrealDB's own implementation of the GraphQL protocol matures, we'll be able to provide a seamless integration. Since this library only targets TanstackDB, you can also use GraphQL for direct querying through Tanstack Query.</p>
132
+ </details>
133
+
134
+ <details>
135
+ <summary><strong>Can I reduce the package sizes?</strong></summary>
136
+ <p>They can be reduced, but these steps are very unique based on use-case. Loro ships a WASM binary thats 3-4 MB in size, it's one of the tradeoffs of using this approach. The maintainers up-stream are working on reducing the size of the WASM binary.</p>
137
+ </details>
@@ -0,0 +1,30 @@
1
+ import { CollectionConfig } from '@tanstack/db';
2
+ import { Container } from 'loro-crdt';
3
+ import { Surreal, Expr } from 'surrealdb';
4
+
5
+ type Id = string;
6
+ type SurrealObject<T> = T & {
7
+ id: Id;
8
+ };
9
+ type SyncedRow = SurrealObject<{
10
+ sync_deleted: boolean;
11
+ updated_at: Date;
12
+ }>;
13
+ type TableOptions = {
14
+ db: Surreal;
15
+ name: string;
16
+ where?: Expr;
17
+ };
18
+ type SurrealCollectionConfig<T extends SyncedRow> = {
19
+ id?: string;
20
+ getKey: (row: T) => Id;
21
+ table: TableOptions;
22
+ useLoro?: boolean;
23
+ onError?: (e: unknown) => void;
24
+ };
25
+
26
+ declare function surrealCollectionOptions<T extends SyncedRow, S extends Record<string, Container> = {
27
+ [k: string]: never;
28
+ }>({ id, getKey, useLoro, onError, ...config }: SurrealCollectionConfig<T>): CollectionConfig<T>;
29
+
30
+ export { surrealCollectionOptions };
@@ -0,0 +1,30 @@
1
+ import { CollectionConfig } from '@tanstack/db';
2
+ import { Container } from 'loro-crdt';
3
+ import { Surreal, Expr } from 'surrealdb';
4
+
5
+ type Id = string;
6
+ type SurrealObject<T> = T & {
7
+ id: Id;
8
+ };
9
+ type SyncedRow = SurrealObject<{
10
+ sync_deleted: boolean;
11
+ updated_at: Date;
12
+ }>;
13
+ type TableOptions = {
14
+ db: Surreal;
15
+ name: string;
16
+ where?: Expr;
17
+ };
18
+ type SurrealCollectionConfig<T extends SyncedRow> = {
19
+ id?: string;
20
+ getKey: (row: T) => Id;
21
+ table: TableOptions;
22
+ useLoro?: boolean;
23
+ onError?: (e: unknown) => void;
24
+ };
25
+
26
+ declare function surrealCollectionOptions<T extends SyncedRow, S extends Record<string, Container> = {
27
+ [k: string]: never;
28
+ }>({ id, getKey, useLoro, onError, ...config }: SurrealCollectionConfig<T>): CollectionConfig<T>;
29
+
30
+ export { surrealCollectionOptions };
package/dist/index.js ADDED
@@ -0,0 +1,349 @@
1
+ var __defProp = Object.defineProperty;
2
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
3
+ var __getOwnPropNames = Object.getOwnPropertyNames;
4
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
5
+ var __export = (target, all) => {
6
+ for (var name in all)
7
+ __defProp(target, name, { get: all[name], enumerable: true });
8
+ };
9
+ var __copyProps = (to, from, except, desc) => {
10
+ if (from && typeof from === "object" || typeof from === "function") {
11
+ for (let key of __getOwnPropNames(from))
12
+ if (!__hasOwnProp.call(to, key) && key !== except)
13
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
14
+ }
15
+ return to;
16
+ };
17
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
18
+
19
+ // src/index.ts
20
+ var index_exports = {};
21
+ __export(index_exports, {
22
+ surrealCollectionOptions: () => surrealCollectionOptions
23
+ });
24
+ module.exports = __toCommonJS(index_exports);
25
+ var import_loro_crdt = require("loro-crdt");
26
+ var import_surrealdb2 = require("surrealdb");
27
+
28
+ // src/table.ts
29
+ var import_surrealdb = require("surrealdb");
30
+ function manageTable({
31
+ db,
32
+ name,
33
+ where
34
+ }) {
35
+ const listAll = async () => {
36
+ if (!where) {
37
+ const res = await db.select(new import_surrealdb.Table(name)) ?? [];
38
+ return Array.isArray(res) ? res : [res];
39
+ }
40
+ return await db.select(new import_surrealdb.Table(name)).where(where);
41
+ };
42
+ const listActive = async () => {
43
+ return await db.select(new import_surrealdb.Table(name)).where((0, import_surrealdb.and)(where, (0, import_surrealdb.eq)("sync_deleted", false)));
44
+ };
45
+ const upsert = async (id, data) => {
46
+ await db.upsert(id).merge({
47
+ ...data,
48
+ sync_deleted: false,
49
+ updated_at: Date.now()
50
+ });
51
+ };
52
+ const remove = async (id) => {
53
+ await db.delete(id);
54
+ };
55
+ const softDelete = async (id) => {
56
+ await db.upsert(id).merge({
57
+ sync_deleted: true,
58
+ updated_at: Date.now()
59
+ });
60
+ };
61
+ const subscribe = (cb) => {
62
+ let killed = false;
63
+ let live;
64
+ const on = ({ action, value }) => {
65
+ if (action === "KILLED") return;
66
+ if (action === "CREATE") cb({ type: "insert", row: value });
67
+ else if (action === "UPDATE")
68
+ cb({ type: "update", row: value });
69
+ else if (action === "DELETE")
70
+ cb({ type: "delete", row: { id: value.id } });
71
+ };
72
+ const start = async () => {
73
+ if (!where) {
74
+ live = await db.live(new import_surrealdb.Table(name));
75
+ live.subscribe(on);
76
+ } else {
77
+ const ctx = {
78
+ def() {
79
+ return "";
80
+ }
81
+ };
82
+ const [id] = await db.query(
83
+ `LIVE SELECT * FROM ${name} WHERE ${where.toSQL(ctx)}`
84
+ ).collect();
85
+ live = await db.liveOf(new import_surrealdb.Uuid(id));
86
+ live.subscribe(on);
87
+ }
88
+ };
89
+ void start();
90
+ return () => {
91
+ if (killed) return;
92
+ killed = true;
93
+ if (live) void live.kill();
94
+ };
95
+ };
96
+ return {
97
+ listAll,
98
+ listActive,
99
+ upsert,
100
+ remove,
101
+ softDelete,
102
+ subscribe
103
+ };
104
+ }
105
+
106
+ // src/index.ts
107
+ function surrealCollectionOptions({
108
+ id,
109
+ getKey,
110
+ useLoro = false,
111
+ onError,
112
+ ...config
113
+ }) {
114
+ var _a, _b;
115
+ let loro;
116
+ if (useLoro) loro = { doc: new import_loro_crdt.LoroDoc(), key: id };
117
+ const loroKey = (loro == null ? void 0 : loro.key) ?? id ?? "surreal";
118
+ const loroMap = useLoro ? ((_b = (_a = loro == null ? void 0 : loro.doc) == null ? void 0 : _a.getMap) == null ? void 0 : _b.call(_a, loroKey)) ?? null : null;
119
+ const loroToArray = () => {
120
+ var _a2;
121
+ if (!loroMap) return [];
122
+ const json = ((_a2 = loroMap.toJSON) == null ? void 0 : _a2.call(loroMap)) ?? {};
123
+ return Object.values(json);
124
+ };
125
+ const loroPut = (row) => {
126
+ var _a2, _b2;
127
+ if (!loroMap) return;
128
+ loroMap.set(String(getKey(row)), row);
129
+ (_b2 = (_a2 = loro == null ? void 0 : loro.doc) == null ? void 0 : _a2.commit) == null ? void 0 : _b2.call(_a2);
130
+ };
131
+ const loroRemove = (id2) => {
132
+ var _a2, _b2;
133
+ if (!loroMap) return;
134
+ loroMap.delete(String(id2));
135
+ (_b2 = (_a2 = loro == null ? void 0 : loro.doc) == null ? void 0 : _a2.commit) == null ? void 0 : _b2.call(_a2);
136
+ };
137
+ const flushPushQueue = async () => {
138
+ const ops = pushQueue.splice(0, pushQueue.length);
139
+ for (const op of ops) {
140
+ if (op.kind === "upsert") {
141
+ const rid = new import_surrealdb2.RecordId(config.table.name, op.row.id);
142
+ await table.upsert(rid, op.row);
143
+ } else {
144
+ const rid = new import_surrealdb2.RecordId(config.table.name, op.id);
145
+ await table.softDelete(rid);
146
+ }
147
+ }
148
+ };
149
+ const pushQueue = [];
150
+ const enqueuePush = (op) => pushQueue.push(op);
151
+ const newer = (a, b) => ((a == null ? void 0 : a.getTime()) ?? -1) > ((b == null ? void 0 : b.getTime()) ?? -1);
152
+ const reconcileBoot = (serverRows, write) => {
153
+ const localRows = useLoro ? loroToArray() : [];
154
+ const serverById = new Map(
155
+ serverRows.map((r) => [getKey(r), r])
156
+ );
157
+ const localById = new Map(localRows.map((r) => [getKey(r), r]));
158
+ const ids = /* @__PURE__ */ new Set([...serverById.keys(), ...localById.keys()]);
159
+ const current = [];
160
+ const applyLocal = (row) => {
161
+ if (!useLoro || !row) return;
162
+ if (row.sync_deleted) loroRemove(row.id);
163
+ else loroPut(row);
164
+ };
165
+ for (const id2 of ids) {
166
+ const s = serverById.get(id2);
167
+ const l = localById.get(id2);
168
+ if (s && l) {
169
+ if (s.sync_deleted && l.sync_deleted) {
170
+ applyLocal(s);
171
+ current.push(s);
172
+ } else if (s.sync_deleted && !l.sync_deleted) {
173
+ applyLocal(s);
174
+ current.push(s);
175
+ } else if (!s.sync_deleted && l.sync_deleted) {
176
+ if (newer(l.updated_at, s.updated_at)) {
177
+ enqueuePush({
178
+ kind: "delete",
179
+ id: id2,
180
+ updated_at: l.updated_at
181
+ });
182
+ applyLocal(l);
183
+ current.push(l);
184
+ } else {
185
+ applyLocal(s);
186
+ current.push(s);
187
+ }
188
+ } else {
189
+ if (newer(l.updated_at, s.updated_at)) {
190
+ enqueuePush({ kind: "upsert", row: l });
191
+ applyLocal(l);
192
+ current.push(l);
193
+ } else {
194
+ applyLocal(s);
195
+ current.push(s);
196
+ }
197
+ }
198
+ } else if (s && !l) {
199
+ applyLocal(s);
200
+ current.push(s);
201
+ } else if (!s && l) {
202
+ if (l.sync_deleted) {
203
+ enqueuePush({
204
+ kind: "delete",
205
+ id: id2,
206
+ updated_at: l.updated_at
207
+ });
208
+ applyLocal(l);
209
+ current.push(l);
210
+ } else {
211
+ enqueuePush({ kind: "upsert", row: l });
212
+ applyLocal(l);
213
+ current.push(l);
214
+ }
215
+ }
216
+ }
217
+ diffAndEmit(current, write);
218
+ };
219
+ let prevById = /* @__PURE__ */ new Map();
220
+ const buildMap = (rows) => new Map(rows.map((r) => [getKey(r), r]));
221
+ const same = (a, b) => a.sync_deleted === b.sync_deleted && a.updated_at.getTime() === b.updated_at.getTime() && JSON.stringify({
222
+ ...a,
223
+ updated_at: void 0,
224
+ sync_deleted: void 0
225
+ }) === JSON.stringify({
226
+ ...b,
227
+ updated_at: void 0,
228
+ sync_deleted: void 0
229
+ });
230
+ const diffAndEmit = (currentRows, write) => {
231
+ const currById = buildMap(currentRows);
232
+ for (const [id2, row] of currById) {
233
+ const prev = prevById.get(id2);
234
+ if (!prev) {
235
+ write({ type: "insert", value: row });
236
+ } else if (!same(prev, row)) {
237
+ write({ type: "update", value: row });
238
+ }
239
+ }
240
+ for (const [id2, prev] of prevById) {
241
+ if (!currById.has(id2)) {
242
+ write({ type: "delete", value: prev });
243
+ }
244
+ }
245
+ prevById = currById;
246
+ };
247
+ const table = manageTable(config.table);
248
+ const sync = ({
249
+ begin,
250
+ write,
251
+ commit,
252
+ markReady
253
+ }) => {
254
+ let offLive = null;
255
+ const makeTombstone = (id2) => ({ id: id2, updated_at: /* @__PURE__ */ new Date(), sync_deleted: true });
256
+ const start = async () => {
257
+ try {
258
+ const serverRows = await table.listAll();
259
+ begin();
260
+ if (useLoro) reconcileBoot(serverRows, write);
261
+ else diffAndEmit(serverRows, write);
262
+ commit();
263
+ markReady();
264
+ await flushPushQueue();
265
+ offLive = table.subscribe((evt) => {
266
+ begin();
267
+ try {
268
+ if (evt.type === "insert" || evt.type === "update") {
269
+ const row = evt.row;
270
+ if (row.sync_deleted) {
271
+ if (useLoro) loroRemove(row.id);
272
+ const prev = prevById.get(row.id) ?? makeTombstone(row.id);
273
+ write({ type: "delete", value: prev });
274
+ prevById.delete(row.id);
275
+ } else {
276
+ if (useLoro) loroPut(row);
277
+ const had = prevById.has(row.id);
278
+ write({
279
+ type: had ? "update" : "insert",
280
+ value: row
281
+ });
282
+ prevById.set(row.id, row);
283
+ }
284
+ } else if (evt.type === "delete") {
285
+ const id2 = getKey(evt.row);
286
+ if (useLoro) loroRemove(id2);
287
+ const prev = prevById.get(id2) ?? makeTombstone(id2);
288
+ write({ type: "delete", value: prev });
289
+ prevById.delete(id2);
290
+ }
291
+ } finally {
292
+ commit();
293
+ }
294
+ });
295
+ } catch (e) {
296
+ onError == null ? void 0 : onError(e);
297
+ markReady();
298
+ }
299
+ };
300
+ void start();
301
+ return () => {
302
+ if (offLive) offLive();
303
+ };
304
+ };
305
+ const now = () => Date.now();
306
+ const onInsert = async (p) => {
307
+ for (const m of p.transaction.mutations) {
308
+ if (m.type !== "insert") continue;
309
+ const row = {
310
+ ...m.modified,
311
+ updated_at: now(),
312
+ deleted: false
313
+ };
314
+ if (useLoro) loroPut(row);
315
+ const rid = new import_surrealdb2.RecordId(config.table.name, row.id);
316
+ await table.upsert(rid, row);
317
+ }
318
+ };
319
+ const onUpdate = async (p) => {
320
+ for (const m of p.transaction.mutations) {
321
+ if (m.type !== "update") continue;
322
+ const id2 = m.key;
323
+ const merged = { ...m.modified, id: id2, updated_at: now() };
324
+ if (useLoro) loroPut(merged);
325
+ const rid = new import_surrealdb2.RecordId(config.table.name, id2);
326
+ await table.upsert(rid, merged);
327
+ }
328
+ };
329
+ const onDelete = async (p) => {
330
+ for (const m of p.transaction.mutations) {
331
+ if (m.type !== "delete") continue;
332
+ const id2 = m.key;
333
+ if (useLoro) loroRemove(id2);
334
+ await table.softDelete(new import_surrealdb2.RecordId(config.table.name, id2));
335
+ }
336
+ };
337
+ return {
338
+ id,
339
+ getKey,
340
+ sync: { sync },
341
+ onInsert,
342
+ onDelete,
343
+ onUpdate
344
+ };
345
+ }
346
+ // Annotate the CommonJS export names for ESM import in node:
347
+ 0 && (module.exports = {
348
+ surrealCollectionOptions
349
+ });
package/dist/index.mjs ADDED
@@ -0,0 +1,330 @@
1
+ // src/index.ts
2
+ import { LoroDoc } from "loro-crdt";
3
+ import { RecordId } from "surrealdb";
4
+
5
+ // src/table.ts
6
+ import {
7
+ and,
8
+ eq,
9
+ Table,
10
+ Uuid
11
+ } from "surrealdb";
12
+ function manageTable({
13
+ db,
14
+ name,
15
+ where
16
+ }) {
17
+ const listAll = async () => {
18
+ if (!where) {
19
+ const res = await db.select(new Table(name)) ?? [];
20
+ return Array.isArray(res) ? res : [res];
21
+ }
22
+ return await db.select(new Table(name)).where(where);
23
+ };
24
+ const listActive = async () => {
25
+ return await db.select(new Table(name)).where(and(where, eq("sync_deleted", false)));
26
+ };
27
+ const upsert = async (id, data) => {
28
+ await db.upsert(id).merge({
29
+ ...data,
30
+ sync_deleted: false,
31
+ updated_at: Date.now()
32
+ });
33
+ };
34
+ const remove = async (id) => {
35
+ await db.delete(id);
36
+ };
37
+ const softDelete = async (id) => {
38
+ await db.upsert(id).merge({
39
+ sync_deleted: true,
40
+ updated_at: Date.now()
41
+ });
42
+ };
43
+ const subscribe = (cb) => {
44
+ let killed = false;
45
+ let live;
46
+ const on = ({ action, value }) => {
47
+ if (action === "KILLED") return;
48
+ if (action === "CREATE") cb({ type: "insert", row: value });
49
+ else if (action === "UPDATE")
50
+ cb({ type: "update", row: value });
51
+ else if (action === "DELETE")
52
+ cb({ type: "delete", row: { id: value.id } });
53
+ };
54
+ const start = async () => {
55
+ if (!where) {
56
+ live = await db.live(new Table(name));
57
+ live.subscribe(on);
58
+ } else {
59
+ const ctx = {
60
+ def() {
61
+ return "";
62
+ }
63
+ };
64
+ const [id] = await db.query(
65
+ `LIVE SELECT * FROM ${name} WHERE ${where.toSQL(ctx)}`
66
+ ).collect();
67
+ live = await db.liveOf(new Uuid(id));
68
+ live.subscribe(on);
69
+ }
70
+ };
71
+ void start();
72
+ return () => {
73
+ if (killed) return;
74
+ killed = true;
75
+ if (live) void live.kill();
76
+ };
77
+ };
78
+ return {
79
+ listAll,
80
+ listActive,
81
+ upsert,
82
+ remove,
83
+ softDelete,
84
+ subscribe
85
+ };
86
+ }
87
+
88
+ // src/index.ts
89
+ function surrealCollectionOptions({
90
+ id,
91
+ getKey,
92
+ useLoro = false,
93
+ onError,
94
+ ...config
95
+ }) {
96
+ var _a, _b;
97
+ let loro;
98
+ if (useLoro) loro = { doc: new LoroDoc(), key: id };
99
+ const loroKey = (loro == null ? void 0 : loro.key) ?? id ?? "surreal";
100
+ const loroMap = useLoro ? ((_b = (_a = loro == null ? void 0 : loro.doc) == null ? void 0 : _a.getMap) == null ? void 0 : _b.call(_a, loroKey)) ?? null : null;
101
+ const loroToArray = () => {
102
+ var _a2;
103
+ if (!loroMap) return [];
104
+ const json = ((_a2 = loroMap.toJSON) == null ? void 0 : _a2.call(loroMap)) ?? {};
105
+ return Object.values(json);
106
+ };
107
+ const loroPut = (row) => {
108
+ var _a2, _b2;
109
+ if (!loroMap) return;
110
+ loroMap.set(String(getKey(row)), row);
111
+ (_b2 = (_a2 = loro == null ? void 0 : loro.doc) == null ? void 0 : _a2.commit) == null ? void 0 : _b2.call(_a2);
112
+ };
113
+ const loroRemove = (id2) => {
114
+ var _a2, _b2;
115
+ if (!loroMap) return;
116
+ loroMap.delete(String(id2));
117
+ (_b2 = (_a2 = loro == null ? void 0 : loro.doc) == null ? void 0 : _a2.commit) == null ? void 0 : _b2.call(_a2);
118
+ };
119
+ const flushPushQueue = async () => {
120
+ const ops = pushQueue.splice(0, pushQueue.length);
121
+ for (const op of ops) {
122
+ if (op.kind === "upsert") {
123
+ const rid = new RecordId(config.table.name, op.row.id);
124
+ await table.upsert(rid, op.row);
125
+ } else {
126
+ const rid = new RecordId(config.table.name, op.id);
127
+ await table.softDelete(rid);
128
+ }
129
+ }
130
+ };
131
+ const pushQueue = [];
132
+ const enqueuePush = (op) => pushQueue.push(op);
133
+ const newer = (a, b) => ((a == null ? void 0 : a.getTime()) ?? -1) > ((b == null ? void 0 : b.getTime()) ?? -1);
134
+ const reconcileBoot = (serverRows, write) => {
135
+ const localRows = useLoro ? loroToArray() : [];
136
+ const serverById = new Map(
137
+ serverRows.map((r) => [getKey(r), r])
138
+ );
139
+ const localById = new Map(localRows.map((r) => [getKey(r), r]));
140
+ const ids = /* @__PURE__ */ new Set([...serverById.keys(), ...localById.keys()]);
141
+ const current = [];
142
+ const applyLocal = (row) => {
143
+ if (!useLoro || !row) return;
144
+ if (row.sync_deleted) loroRemove(row.id);
145
+ else loroPut(row);
146
+ };
147
+ for (const id2 of ids) {
148
+ const s = serverById.get(id2);
149
+ const l = localById.get(id2);
150
+ if (s && l) {
151
+ if (s.sync_deleted && l.sync_deleted) {
152
+ applyLocal(s);
153
+ current.push(s);
154
+ } else if (s.sync_deleted && !l.sync_deleted) {
155
+ applyLocal(s);
156
+ current.push(s);
157
+ } else if (!s.sync_deleted && l.sync_deleted) {
158
+ if (newer(l.updated_at, s.updated_at)) {
159
+ enqueuePush({
160
+ kind: "delete",
161
+ id: id2,
162
+ updated_at: l.updated_at
163
+ });
164
+ applyLocal(l);
165
+ current.push(l);
166
+ } else {
167
+ applyLocal(s);
168
+ current.push(s);
169
+ }
170
+ } else {
171
+ if (newer(l.updated_at, s.updated_at)) {
172
+ enqueuePush({ kind: "upsert", row: l });
173
+ applyLocal(l);
174
+ current.push(l);
175
+ } else {
176
+ applyLocal(s);
177
+ current.push(s);
178
+ }
179
+ }
180
+ } else if (s && !l) {
181
+ applyLocal(s);
182
+ current.push(s);
183
+ } else if (!s && l) {
184
+ if (l.sync_deleted) {
185
+ enqueuePush({
186
+ kind: "delete",
187
+ id: id2,
188
+ updated_at: l.updated_at
189
+ });
190
+ applyLocal(l);
191
+ current.push(l);
192
+ } else {
193
+ enqueuePush({ kind: "upsert", row: l });
194
+ applyLocal(l);
195
+ current.push(l);
196
+ }
197
+ }
198
+ }
199
+ diffAndEmit(current, write);
200
+ };
201
+ let prevById = /* @__PURE__ */ new Map();
202
+ const buildMap = (rows) => new Map(rows.map((r) => [getKey(r), r]));
203
+ const same = (a, b) => a.sync_deleted === b.sync_deleted && a.updated_at.getTime() === b.updated_at.getTime() && JSON.stringify({
204
+ ...a,
205
+ updated_at: void 0,
206
+ sync_deleted: void 0
207
+ }) === JSON.stringify({
208
+ ...b,
209
+ updated_at: void 0,
210
+ sync_deleted: void 0
211
+ });
212
+ const diffAndEmit = (currentRows, write) => {
213
+ const currById = buildMap(currentRows);
214
+ for (const [id2, row] of currById) {
215
+ const prev = prevById.get(id2);
216
+ if (!prev) {
217
+ write({ type: "insert", value: row });
218
+ } else if (!same(prev, row)) {
219
+ write({ type: "update", value: row });
220
+ }
221
+ }
222
+ for (const [id2, prev] of prevById) {
223
+ if (!currById.has(id2)) {
224
+ write({ type: "delete", value: prev });
225
+ }
226
+ }
227
+ prevById = currById;
228
+ };
229
+ const table = manageTable(config.table);
230
+ const sync = ({
231
+ begin,
232
+ write,
233
+ commit,
234
+ markReady
235
+ }) => {
236
+ let offLive = null;
237
+ const makeTombstone = (id2) => ({ id: id2, updated_at: /* @__PURE__ */ new Date(), sync_deleted: true });
238
+ const start = async () => {
239
+ try {
240
+ const serverRows = await table.listAll();
241
+ begin();
242
+ if (useLoro) reconcileBoot(serverRows, write);
243
+ else diffAndEmit(serverRows, write);
244
+ commit();
245
+ markReady();
246
+ await flushPushQueue();
247
+ offLive = table.subscribe((evt) => {
248
+ begin();
249
+ try {
250
+ if (evt.type === "insert" || evt.type === "update") {
251
+ const row = evt.row;
252
+ if (row.sync_deleted) {
253
+ if (useLoro) loroRemove(row.id);
254
+ const prev = prevById.get(row.id) ?? makeTombstone(row.id);
255
+ write({ type: "delete", value: prev });
256
+ prevById.delete(row.id);
257
+ } else {
258
+ if (useLoro) loroPut(row);
259
+ const had = prevById.has(row.id);
260
+ write({
261
+ type: had ? "update" : "insert",
262
+ value: row
263
+ });
264
+ prevById.set(row.id, row);
265
+ }
266
+ } else if (evt.type === "delete") {
267
+ const id2 = getKey(evt.row);
268
+ if (useLoro) loroRemove(id2);
269
+ const prev = prevById.get(id2) ?? makeTombstone(id2);
270
+ write({ type: "delete", value: prev });
271
+ prevById.delete(id2);
272
+ }
273
+ } finally {
274
+ commit();
275
+ }
276
+ });
277
+ } catch (e) {
278
+ onError == null ? void 0 : onError(e);
279
+ markReady();
280
+ }
281
+ };
282
+ void start();
283
+ return () => {
284
+ if (offLive) offLive();
285
+ };
286
+ };
287
+ const now = () => Date.now();
288
+ const onInsert = async (p) => {
289
+ for (const m of p.transaction.mutations) {
290
+ if (m.type !== "insert") continue;
291
+ const row = {
292
+ ...m.modified,
293
+ updated_at: now(),
294
+ deleted: false
295
+ };
296
+ if (useLoro) loroPut(row);
297
+ const rid = new RecordId(config.table.name, row.id);
298
+ await table.upsert(rid, row);
299
+ }
300
+ };
301
+ const onUpdate = async (p) => {
302
+ for (const m of p.transaction.mutations) {
303
+ if (m.type !== "update") continue;
304
+ const id2 = m.key;
305
+ const merged = { ...m.modified, id: id2, updated_at: now() };
306
+ if (useLoro) loroPut(merged);
307
+ const rid = new RecordId(config.table.name, id2);
308
+ await table.upsert(rid, merged);
309
+ }
310
+ };
311
+ const onDelete = async (p) => {
312
+ for (const m of p.transaction.mutations) {
313
+ if (m.type !== "delete") continue;
314
+ const id2 = m.key;
315
+ if (useLoro) loroRemove(id2);
316
+ await table.softDelete(new RecordId(config.table.name, id2));
317
+ }
318
+ };
319
+ return {
320
+ id,
321
+ getKey,
322
+ sync: { sync },
323
+ onInsert,
324
+ onDelete,
325
+ onUpdate
326
+ };
327
+ }
328
+ export {
329
+ surrealCollectionOptions
330
+ };
package/package.json ADDED
@@ -0,0 +1,52 @@
1
+ {
2
+ "name": "@foretag/tanstack-db-surrealdb",
3
+ "description": "Add Offline / Local First Caching & Syncing to your SurrealDB app with TanstackDB and Loro (CRDTs)",
4
+ "version": "0.1.0",
5
+ "files": [
6
+ "dist"
7
+ ],
8
+ "module": "./dist/index.mjs",
9
+ "main": "./dist/index.js",
10
+ "types": "./dist/index.d.ts",
11
+ "exports": {
12
+ ".": {
13
+ "import": {
14
+ "types": "./dist/index.d.mts",
15
+ "default": "./dist/index.mjs"
16
+ },
17
+ "require": {
18
+ "types": "./dist/index.d.ts",
19
+ "default": "./dist/index.js"
20
+ }
21
+ },
22
+ "./package.json": "./package.json"
23
+ },
24
+ "keywords": [
25
+ "surrealdb",
26
+ "tanstackdb",
27
+ "crdts",
28
+ "sync",
29
+ "local-first"
30
+ ],
31
+ "author": {
32
+ "name": "Chiru Boggavarapu",
33
+ "email": "chiru@foretag.co"
34
+ },
35
+ "repository": {
36
+ "type": "git",
37
+ "url": "https://github.com/ForetagInc/tanstack-db-surrealdb"
38
+ },
39
+ "scripts": {
40
+ "build": "tsup src/index.ts --dts --format esm,cjs"
41
+ },
42
+ "dependencies": {
43
+ "@tanstack/db": "^0.4.3",
44
+ "loro-crdt": "^1.8.1",
45
+ "surrealdb": "2.0.0-alpha.8"
46
+ },
47
+ "devDependencies": {
48
+ "@biomejs/biome": "^2.2.5",
49
+ "bunup": "^0.14.11",
50
+ "tsup": "^8.5.0"
51
+ }
52
+ }