@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 +137 -0
- package/dist/index.d.mts +30 -0
- package/dist/index.d.ts +30 -0
- package/dist/index.js +349 -0
- package/dist/index.mjs +330 -0
- package/package.json +52 -0
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>
|
package/dist/index.d.mts
ADDED
|
@@ -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.d.ts
ADDED
|
@@ -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
|
+
}
|