@memrosetta/sync-client 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/LICENSE +21 -0
- package/dist/index.d.ts +53 -0
- package/dist/index.js +207 -0
- package/package.json +39 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 obst
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import Database from 'better-sqlite3';
|
|
2
|
+
import { SyncOp, SyncPulledOp, SyncPushResult } from '@memrosetta/types';
|
|
3
|
+
export { SyncConfig, SyncOp, SyncPullParams, SyncPullResponse, SyncPulledOp, SyncPushRequest, SyncPushResponse, SyncPushResult } from '@memrosetta/types';
|
|
4
|
+
|
|
5
|
+
declare class Outbox {
|
|
6
|
+
private readonly db;
|
|
7
|
+
constructor(db: Database.Database);
|
|
8
|
+
addOp(op: SyncOp): void;
|
|
9
|
+
getPending(): readonly SyncOp[];
|
|
10
|
+
markPushed(opIds: readonly string[]): void;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
declare class Inbox {
|
|
14
|
+
private readonly db;
|
|
15
|
+
constructor(db: Database.Database);
|
|
16
|
+
addOps(ops: readonly SyncPulledOp[]): void;
|
|
17
|
+
getPending(): readonly SyncPulledOp[];
|
|
18
|
+
markApplied(opIds: readonly string[]): void;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Minimal config required by SyncClient at runtime.
|
|
23
|
+
* serverUrl and apiKey are required (unlike the optional SyncConfig
|
|
24
|
+
* from @memrosetta/types which is aimed at feature-flag configuration).
|
|
25
|
+
*/
|
|
26
|
+
interface SyncClientConfig {
|
|
27
|
+
readonly serverUrl: string;
|
|
28
|
+
readonly apiKey: string;
|
|
29
|
+
readonly deviceId: string;
|
|
30
|
+
}
|
|
31
|
+
interface SyncClientPushResponse {
|
|
32
|
+
readonly pushed: number;
|
|
33
|
+
readonly results: readonly SyncPushResult[];
|
|
34
|
+
readonly highWatermark: number;
|
|
35
|
+
}
|
|
36
|
+
declare class SyncClient {
|
|
37
|
+
private readonly db;
|
|
38
|
+
private readonly config;
|
|
39
|
+
private readonly outbox;
|
|
40
|
+
private readonly inbox;
|
|
41
|
+
constructor(db: Database.Database, config: SyncClientConfig);
|
|
42
|
+
initialize(): void;
|
|
43
|
+
getOutbox(): Outbox;
|
|
44
|
+
getInbox(): Inbox;
|
|
45
|
+
push(): Promise<SyncClientPushResponse>;
|
|
46
|
+
pull(): Promise<number>;
|
|
47
|
+
getState(key: string): string | null;
|
|
48
|
+
setState(key: string, value: string): void;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
declare function ensureSyncSchema(db: Database.Database): void;
|
|
52
|
+
|
|
53
|
+
export { Inbox, Outbox, SyncClient, ensureSyncSchema };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
// src/schema.ts
|
|
2
|
+
var SYNC_SCHEMA = `
|
|
3
|
+
CREATE TABLE IF NOT EXISTS sync_outbox (
|
|
4
|
+
op_id TEXT PRIMARY KEY,
|
|
5
|
+
op_type TEXT NOT NULL,
|
|
6
|
+
device_id TEXT NOT NULL,
|
|
7
|
+
user_id TEXT NOT NULL,
|
|
8
|
+
payload TEXT NOT NULL,
|
|
9
|
+
created_at TEXT NOT NULL,
|
|
10
|
+
pushed_at TEXT
|
|
11
|
+
);
|
|
12
|
+
|
|
13
|
+
CREATE TABLE IF NOT EXISTS sync_inbox (
|
|
14
|
+
op_id TEXT PRIMARY KEY,
|
|
15
|
+
op_type TEXT NOT NULL,
|
|
16
|
+
device_id TEXT NOT NULL,
|
|
17
|
+
user_id TEXT NOT NULL,
|
|
18
|
+
payload TEXT NOT NULL,
|
|
19
|
+
created_at TEXT NOT NULL,
|
|
20
|
+
applied_at TEXT
|
|
21
|
+
);
|
|
22
|
+
|
|
23
|
+
CREATE TABLE IF NOT EXISTS sync_state (
|
|
24
|
+
key TEXT PRIMARY KEY,
|
|
25
|
+
value TEXT NOT NULL
|
|
26
|
+
);
|
|
27
|
+
`;
|
|
28
|
+
function ensureSyncSchema(db) {
|
|
29
|
+
db.exec(SYNC_SCHEMA);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// src/outbox.ts
|
|
33
|
+
function rowToSyncOp(row) {
|
|
34
|
+
return {
|
|
35
|
+
opId: row.op_id,
|
|
36
|
+
opType: row.op_type,
|
|
37
|
+
deviceId: row.device_id,
|
|
38
|
+
userId: row.user_id,
|
|
39
|
+
payload: JSON.parse(row.payload),
|
|
40
|
+
createdAt: row.created_at
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
var Outbox = class {
|
|
44
|
+
db;
|
|
45
|
+
constructor(db) {
|
|
46
|
+
this.db = db;
|
|
47
|
+
}
|
|
48
|
+
addOp(op) {
|
|
49
|
+
const payloadStr = typeof op.payload === "string" ? op.payload : JSON.stringify(op.payload);
|
|
50
|
+
this.db.prepare(
|
|
51
|
+
`INSERT INTO sync_outbox (op_id, op_type, device_id, user_id, payload, created_at, pushed_at)
|
|
52
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)`
|
|
53
|
+
).run(op.opId, op.opType, op.deviceId, op.userId, payloadStr, op.createdAt, null);
|
|
54
|
+
}
|
|
55
|
+
getPending() {
|
|
56
|
+
const rows = this.db.prepare("SELECT * FROM sync_outbox WHERE pushed_at IS NULL ORDER BY created_at ASC").all();
|
|
57
|
+
return rows.map(rowToSyncOp);
|
|
58
|
+
}
|
|
59
|
+
markPushed(opIds) {
|
|
60
|
+
if (opIds.length === 0) return;
|
|
61
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
62
|
+
const placeholders = opIds.map(() => "?").join(", ");
|
|
63
|
+
this.db.prepare(`UPDATE sync_outbox SET pushed_at = ? WHERE op_id IN (${placeholders})`).run(now, ...opIds);
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
// src/inbox.ts
|
|
68
|
+
function rowToPulledOp(row) {
|
|
69
|
+
return {
|
|
70
|
+
opId: row.op_id,
|
|
71
|
+
opType: row.op_type,
|
|
72
|
+
deviceId: row.device_id,
|
|
73
|
+
userId: row.user_id,
|
|
74
|
+
payload: JSON.parse(row.payload),
|
|
75
|
+
createdAt: row.created_at,
|
|
76
|
+
cursor: 0,
|
|
77
|
+
receivedAt: ""
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
var Inbox = class {
|
|
81
|
+
db;
|
|
82
|
+
constructor(db) {
|
|
83
|
+
this.db = db;
|
|
84
|
+
}
|
|
85
|
+
addOps(ops) {
|
|
86
|
+
if (ops.length === 0) return;
|
|
87
|
+
const stmt = this.db.prepare(
|
|
88
|
+
`INSERT OR IGNORE INTO sync_inbox (op_id, op_type, device_id, user_id, payload, created_at)
|
|
89
|
+
VALUES (?, ?, ?, ?, ?, ?)`
|
|
90
|
+
);
|
|
91
|
+
const insertMany = this.db.transaction((items) => {
|
|
92
|
+
for (const op of items) {
|
|
93
|
+
const payloadStr = typeof op.payload === "string" ? op.payload : JSON.stringify(op.payload);
|
|
94
|
+
stmt.run(op.opId, op.opType, op.deviceId, op.userId, payloadStr, op.createdAt);
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
insertMany(ops);
|
|
98
|
+
}
|
|
99
|
+
getPending() {
|
|
100
|
+
const rows = this.db.prepare("SELECT * FROM sync_inbox WHERE applied_at IS NULL ORDER BY created_at ASC").all();
|
|
101
|
+
return rows.map(rowToPulledOp);
|
|
102
|
+
}
|
|
103
|
+
markApplied(opIds) {
|
|
104
|
+
if (opIds.length === 0) return;
|
|
105
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
106
|
+
const placeholders = opIds.map(() => "?").join(", ");
|
|
107
|
+
this.db.prepare(`UPDATE sync_inbox SET applied_at = ? WHERE op_id IN (${placeholders})`).run(now, ...opIds);
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
// src/sync-client.ts
|
|
112
|
+
var SyncClient = class {
|
|
113
|
+
db;
|
|
114
|
+
config;
|
|
115
|
+
outbox;
|
|
116
|
+
inbox;
|
|
117
|
+
constructor(db, config) {
|
|
118
|
+
this.db = db;
|
|
119
|
+
this.config = config;
|
|
120
|
+
this.outbox = new Outbox(db);
|
|
121
|
+
this.inbox = new Inbox(db);
|
|
122
|
+
}
|
|
123
|
+
initialize() {
|
|
124
|
+
ensureSyncSchema(this.db);
|
|
125
|
+
}
|
|
126
|
+
getOutbox() {
|
|
127
|
+
return this.outbox;
|
|
128
|
+
}
|
|
129
|
+
getInbox() {
|
|
130
|
+
return this.inbox;
|
|
131
|
+
}
|
|
132
|
+
async push() {
|
|
133
|
+
const pending = this.outbox.getPending();
|
|
134
|
+
if (pending.length === 0) {
|
|
135
|
+
return { pushed: 0, results: [], highWatermark: 0 };
|
|
136
|
+
}
|
|
137
|
+
const baseCursorStr = this.getState("pull_cursor");
|
|
138
|
+
const baseCursor = baseCursorStr ? parseInt(baseCursorStr, 10) : 0;
|
|
139
|
+
const wireOps = pending.map((op) => ({
|
|
140
|
+
...op,
|
|
141
|
+
payload: typeof op.payload === "string" ? JSON.parse(op.payload) : op.payload
|
|
142
|
+
}));
|
|
143
|
+
const url = `${this.config.serverUrl}/sync/push`;
|
|
144
|
+
const response = await fetch(url, {
|
|
145
|
+
method: "POST",
|
|
146
|
+
headers: {
|
|
147
|
+
"Content-Type": "application/json",
|
|
148
|
+
Authorization: `Bearer ${this.config.apiKey}`
|
|
149
|
+
},
|
|
150
|
+
body: JSON.stringify({
|
|
151
|
+
deviceId: this.config.deviceId,
|
|
152
|
+
baseCursor,
|
|
153
|
+
ops: wireOps
|
|
154
|
+
})
|
|
155
|
+
});
|
|
156
|
+
if (!response.ok) {
|
|
157
|
+
throw new Error(`Push failed: ${response.status} ${response.statusText}`);
|
|
158
|
+
}
|
|
159
|
+
const body = await response.json();
|
|
160
|
+
const { results, highWatermark } = body.data;
|
|
161
|
+
const pushedIds = results.filter((r) => r.status === "accepted" || r.status === "duplicate").map((r) => r.opId);
|
|
162
|
+
this.outbox.markPushed(pushedIds);
|
|
163
|
+
this.setState("pull_cursor", String(highWatermark));
|
|
164
|
+
return {
|
|
165
|
+
pushed: pushedIds.length,
|
|
166
|
+
results,
|
|
167
|
+
highWatermark
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
async pull() {
|
|
171
|
+
const cursorStr = this.getState("pull_cursor");
|
|
172
|
+
const since = cursorStr ? parseInt(cursorStr, 10) : 0;
|
|
173
|
+
const params = new URLSearchParams({
|
|
174
|
+
since: String(since)
|
|
175
|
+
});
|
|
176
|
+
const url = `${this.config.serverUrl}/sync/pull?${params.toString()}`;
|
|
177
|
+
const response = await fetch(url, {
|
|
178
|
+
method: "GET",
|
|
179
|
+
headers: {
|
|
180
|
+
Authorization: `Bearer ${this.config.apiKey}`
|
|
181
|
+
}
|
|
182
|
+
});
|
|
183
|
+
if (!response.ok) {
|
|
184
|
+
throw new Error(`Pull failed: ${response.status} ${response.statusText}`);
|
|
185
|
+
}
|
|
186
|
+
const body = await response.json();
|
|
187
|
+
const { ops, nextCursor, hasMore } = body.data;
|
|
188
|
+
if (ops.length > 0) {
|
|
189
|
+
this.inbox.addOps(ops);
|
|
190
|
+
}
|
|
191
|
+
this.setState("pull_cursor", String(nextCursor));
|
|
192
|
+
return ops.length;
|
|
193
|
+
}
|
|
194
|
+
getState(key) {
|
|
195
|
+
const row = this.db.prepare("SELECT value FROM sync_state WHERE key = ?").get(key);
|
|
196
|
+
return row?.value ?? null;
|
|
197
|
+
}
|
|
198
|
+
setState(key, value) {
|
|
199
|
+
this.db.prepare("INSERT OR REPLACE INTO sync_state (key, value) VALUES (?, ?)").run(key, value);
|
|
200
|
+
}
|
|
201
|
+
};
|
|
202
|
+
export {
|
|
203
|
+
Inbox,
|
|
204
|
+
Outbox,
|
|
205
|
+
SyncClient,
|
|
206
|
+
ensureSyncSchema
|
|
207
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@memrosetta/sync-client",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Local-first sync client for MemRosetta (outbox/inbox, push/pull)",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"import": "./dist/index.js",
|
|
11
|
+
"types": "./dist/index.d.ts"
|
|
12
|
+
},
|
|
13
|
+
"./package.json": "./package.json"
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"dist",
|
|
17
|
+
"README.md",
|
|
18
|
+
"LICENSE"
|
|
19
|
+
],
|
|
20
|
+
"dependencies": {
|
|
21
|
+
"better-sqlite3": "^11.0.0",
|
|
22
|
+
"@memrosetta/types": "0.4.0"
|
|
23
|
+
},
|
|
24
|
+
"devDependencies": {
|
|
25
|
+
"@types/better-sqlite3": "^7.6.0",
|
|
26
|
+
"tsup": "^8.0.0",
|
|
27
|
+
"typescript": "^5.5.0",
|
|
28
|
+
"vitest": "^2.0.0"
|
|
29
|
+
},
|
|
30
|
+
"engines": {
|
|
31
|
+
"node": ">=22.0.0"
|
|
32
|
+
},
|
|
33
|
+
"scripts": {
|
|
34
|
+
"build": "tsup src/index.ts --format esm --dts",
|
|
35
|
+
"test": "vitest run",
|
|
36
|
+
"test:watch": "vitest",
|
|
37
|
+
"typecheck": "tsc --noEmit"
|
|
38
|
+
}
|
|
39
|
+
}
|