@noy-db/hub 0.1.0-pre.3
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/README.md +197 -0
- package/dist/aggregate/index.cjs +476 -0
- package/dist/aggregate/index.cjs.map +1 -0
- package/dist/aggregate/index.d.cts +38 -0
- package/dist/aggregate/index.d.ts +38 -0
- package/dist/aggregate/index.js +53 -0
- package/dist/aggregate/index.js.map +1 -0
- package/dist/blobs/index.cjs +1480 -0
- package/dist/blobs/index.cjs.map +1 -0
- package/dist/blobs/index.d.cts +45 -0
- package/dist/blobs/index.d.ts +45 -0
- package/dist/blobs/index.js +48 -0
- package/dist/blobs/index.js.map +1 -0
- package/dist/bundle/index.cjs +436 -0
- package/dist/bundle/index.cjs.map +1 -0
- package/dist/bundle/index.d.cts +7 -0
- package/dist/bundle/index.d.ts +7 -0
- package/dist/bundle/index.js +40 -0
- package/dist/bundle/index.js.map +1 -0
- package/dist/chunk-2QR2PQTT.js +217 -0
- package/dist/chunk-2QR2PQTT.js.map +1 -0
- package/dist/chunk-4OWFYIDQ.js +79 -0
- package/dist/chunk-4OWFYIDQ.js.map +1 -0
- package/dist/chunk-5AATM2M2.js +90 -0
- package/dist/chunk-5AATM2M2.js.map +1 -0
- package/dist/chunk-ACLDOTNQ.js +543 -0
- package/dist/chunk-ACLDOTNQ.js.map +1 -0
- package/dist/chunk-BTDCBVJW.js +160 -0
- package/dist/chunk-BTDCBVJW.js.map +1 -0
- package/dist/chunk-CIMZBAZB.js +72 -0
- package/dist/chunk-CIMZBAZB.js.map +1 -0
- package/dist/chunk-E445ICYI.js +365 -0
- package/dist/chunk-E445ICYI.js.map +1 -0
- package/dist/chunk-EXQRC2L4.js +722 -0
- package/dist/chunk-EXQRC2L4.js.map +1 -0
- package/dist/chunk-FZU343FL.js +32 -0
- package/dist/chunk-FZU343FL.js.map +1 -0
- package/dist/chunk-GJILMRPO.js +354 -0
- package/dist/chunk-GJILMRPO.js.map +1 -0
- package/dist/chunk-GOUT6DND.js +1285 -0
- package/dist/chunk-GOUT6DND.js.map +1 -0
- package/dist/chunk-J66GRPNH.js +111 -0
- package/dist/chunk-J66GRPNH.js.map +1 -0
- package/dist/chunk-M2F2JAWB.js +464 -0
- package/dist/chunk-M2F2JAWB.js.map +1 -0
- package/dist/chunk-M5INGEFC.js +84 -0
- package/dist/chunk-M5INGEFC.js.map +1 -0
- package/dist/chunk-M62XNWRA.js +72 -0
- package/dist/chunk-M62XNWRA.js.map +1 -0
- package/dist/chunk-MR4424N3.js +275 -0
- package/dist/chunk-MR4424N3.js.map +1 -0
- package/dist/chunk-NPC4LFV5.js +132 -0
- package/dist/chunk-NPC4LFV5.js.map +1 -0
- package/dist/chunk-NXFEYLVG.js +311 -0
- package/dist/chunk-NXFEYLVG.js.map +1 -0
- package/dist/chunk-R36SIKES.js +79 -0
- package/dist/chunk-R36SIKES.js.map +1 -0
- package/dist/chunk-TDR6T5CJ.js +381 -0
- package/dist/chunk-TDR6T5CJ.js.map +1 -0
- package/dist/chunk-UF3BUNQZ.js +1 -0
- package/dist/chunk-UF3BUNQZ.js.map +1 -0
- package/dist/chunk-UQFSPSWG.js +1109 -0
- package/dist/chunk-UQFSPSWG.js.map +1 -0
- package/dist/chunk-USKYUS74.js +793 -0
- package/dist/chunk-USKYUS74.js.map +1 -0
- package/dist/chunk-XCL3WP6J.js +121 -0
- package/dist/chunk-XCL3WP6J.js.map +1 -0
- package/dist/chunk-XHFOENR2.js +680 -0
- package/dist/chunk-XHFOENR2.js.map +1 -0
- package/dist/chunk-ZFKD4QMV.js +430 -0
- package/dist/chunk-ZFKD4QMV.js.map +1 -0
- package/dist/chunk-ZLMV3TUA.js +490 -0
- package/dist/chunk-ZLMV3TUA.js.map +1 -0
- package/dist/chunk-ZRG4V3F5.js +17 -0
- package/dist/chunk-ZRG4V3F5.js.map +1 -0
- package/dist/consent/index.cjs +204 -0
- package/dist/consent/index.cjs.map +1 -0
- package/dist/consent/index.d.cts +24 -0
- package/dist/consent/index.d.ts +24 -0
- package/dist/consent/index.js +23 -0
- package/dist/consent/index.js.map +1 -0
- package/dist/crdt/index.cjs +152 -0
- package/dist/crdt/index.cjs.map +1 -0
- package/dist/crdt/index.d.cts +30 -0
- package/dist/crdt/index.d.ts +30 -0
- package/dist/crdt/index.js +24 -0
- package/dist/crdt/index.js.map +1 -0
- package/dist/crypto-IVKU7YTT.js +44 -0
- package/dist/crypto-IVKU7YTT.js.map +1 -0
- package/dist/delegation-XDJCBTI2.js +16 -0
- package/dist/delegation-XDJCBTI2.js.map +1 -0
- package/dist/dev-unlock-CeXic1xC.d.cts +263 -0
- package/dist/dev-unlock-KrKkcqD3.d.ts +263 -0
- package/dist/hash-9KO1BGxh.d.cts +63 -0
- package/dist/hash-ChfJjRjQ.d.ts +63 -0
- package/dist/history/index.cjs +1215 -0
- package/dist/history/index.cjs.map +1 -0
- package/dist/history/index.d.cts +62 -0
- package/dist/history/index.d.ts +62 -0
- package/dist/history/index.js +79 -0
- package/dist/history/index.js.map +1 -0
- package/dist/i18n/index.cjs +746 -0
- package/dist/i18n/index.cjs.map +1 -0
- package/dist/i18n/index.d.cts +38 -0
- package/dist/i18n/index.d.ts +38 -0
- package/dist/i18n/index.js +55 -0
- package/dist/i18n/index.js.map +1 -0
- package/dist/index-BRHBCmLt.d.ts +1940 -0
- package/dist/index-C8kQtmOk.d.ts +380 -0
- package/dist/index-DN-J-5wT.d.cts +1940 -0
- package/dist/index-DhjMjz7L.d.cts +380 -0
- package/dist/index.cjs +14756 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +269 -0
- package/dist/index.d.ts +269 -0
- package/dist/index.js +6085 -0
- package/dist/index.js.map +1 -0
- package/dist/indexing/index.cjs +736 -0
- package/dist/indexing/index.cjs.map +1 -0
- package/dist/indexing/index.d.cts +36 -0
- package/dist/indexing/index.d.ts +36 -0
- package/dist/indexing/index.js +77 -0
- package/dist/indexing/index.js.map +1 -0
- package/dist/lazy-builder-BwEoBQZ9.d.ts +304 -0
- package/dist/lazy-builder-CZVLKh0Z.d.cts +304 -0
- package/dist/ledger-2NX4L7PN.js +33 -0
- package/dist/ledger-2NX4L7PN.js.map +1 -0
- package/dist/mime-magic-CBBSOkjm.d.cts +50 -0
- package/dist/mime-magic-CBBSOkjm.d.ts +50 -0
- package/dist/periods/index.cjs +1035 -0
- package/dist/periods/index.cjs.map +1 -0
- package/dist/periods/index.d.cts +21 -0
- package/dist/periods/index.d.ts +21 -0
- package/dist/periods/index.js +25 -0
- package/dist/periods/index.js.map +1 -0
- package/dist/predicate-SBHmi6D0.d.cts +161 -0
- package/dist/predicate-SBHmi6D0.d.ts +161 -0
- package/dist/query/index.cjs +1957 -0
- package/dist/query/index.cjs.map +1 -0
- package/dist/query/index.d.cts +3 -0
- package/dist/query/index.d.ts +3 -0
- package/dist/query/index.js +62 -0
- package/dist/query/index.js.map +1 -0
- package/dist/session/index.cjs +487 -0
- package/dist/session/index.cjs.map +1 -0
- package/dist/session/index.d.cts +45 -0
- package/dist/session/index.d.ts +45 -0
- package/dist/session/index.js +44 -0
- package/dist/session/index.js.map +1 -0
- package/dist/shadow/index.cjs +133 -0
- package/dist/shadow/index.cjs.map +1 -0
- package/dist/shadow/index.d.cts +16 -0
- package/dist/shadow/index.d.ts +16 -0
- package/dist/shadow/index.js +20 -0
- package/dist/shadow/index.js.map +1 -0
- package/dist/store/index.cjs +1069 -0
- package/dist/store/index.cjs.map +1 -0
- package/dist/store/index.d.cts +491 -0
- package/dist/store/index.d.ts +491 -0
- package/dist/store/index.js +34 -0
- package/dist/store/index.js.map +1 -0
- package/dist/strategy-BSxFXGzb.d.cts +110 -0
- package/dist/strategy-BSxFXGzb.d.ts +110 -0
- package/dist/strategy-D-SrOLCl.d.cts +548 -0
- package/dist/strategy-D-SrOLCl.d.ts +548 -0
- package/dist/sync/index.cjs +1062 -0
- package/dist/sync/index.cjs.map +1 -0
- package/dist/sync/index.d.cts +42 -0
- package/dist/sync/index.d.ts +42 -0
- package/dist/sync/index.js +28 -0
- package/dist/sync/index.js.map +1 -0
- package/dist/team/index.cjs +1233 -0
- package/dist/team/index.cjs.map +1 -0
- package/dist/team/index.d.cts +117 -0
- package/dist/team/index.d.ts +117 -0
- package/dist/team/index.js +39 -0
- package/dist/team/index.js.map +1 -0
- package/dist/tx/index.cjs +212 -0
- package/dist/tx/index.cjs.map +1 -0
- package/dist/tx/index.d.cts +20 -0
- package/dist/tx/index.d.ts +20 -0
- package/dist/tx/index.js +20 -0
- package/dist/tx/index.js.map +1 -0
- package/dist/types-BZpCZB8N.d.ts +7526 -0
- package/dist/types-Bfs0qr5F.d.cts +7526 -0
- package/dist/ulid-COREQ2RQ.js +9 -0
- package/dist/ulid-COREQ2RQ.js.map +1 -0
- package/dist/util/index.cjs +230 -0
- package/dist/util/index.cjs.map +1 -0
- package/dist/util/index.d.cts +77 -0
- package/dist/util/index.d.ts +77 -0
- package/dist/util/index.js +190 -0
- package/dist/util/index.js.map +1 -0
- package/package.json +244 -0
|
@@ -0,0 +1,722 @@
|
|
|
1
|
+
import {
|
|
2
|
+
SyncScheduler
|
|
3
|
+
} from "./chunk-2QR2PQTT.js";
|
|
4
|
+
import {
|
|
5
|
+
NOYDB_SYNC_VERSION
|
|
6
|
+
} from "./chunk-ZRG4V3F5.js";
|
|
7
|
+
import {
|
|
8
|
+
bufferToBase64,
|
|
9
|
+
decrypt,
|
|
10
|
+
derivePresenceKey,
|
|
11
|
+
encrypt,
|
|
12
|
+
generateIV
|
|
13
|
+
} from "./chunk-MR4424N3.js";
|
|
14
|
+
import {
|
|
15
|
+
ConflictError
|
|
16
|
+
} from "./chunk-ACLDOTNQ.js";
|
|
17
|
+
|
|
18
|
+
// src/team/presence.ts
|
|
19
|
+
var PresenceHandle = class {
|
|
20
|
+
adapter;
|
|
21
|
+
syncAdapter;
|
|
22
|
+
vault;
|
|
23
|
+
collectionName;
|
|
24
|
+
userId;
|
|
25
|
+
encrypted;
|
|
26
|
+
getDEK;
|
|
27
|
+
staleMs;
|
|
28
|
+
pollIntervalMs;
|
|
29
|
+
channel;
|
|
30
|
+
storageCollection;
|
|
31
|
+
presenceKey = null;
|
|
32
|
+
subscribers = [];
|
|
33
|
+
unsubscribePubSub = null;
|
|
34
|
+
pollTimer = null;
|
|
35
|
+
stopped = false;
|
|
36
|
+
constructor(opts) {
|
|
37
|
+
this.adapter = opts.adapter;
|
|
38
|
+
this.syncAdapter = opts.syncAdapter;
|
|
39
|
+
this.vault = opts.vault;
|
|
40
|
+
this.collectionName = opts.collectionName;
|
|
41
|
+
this.userId = opts.userId;
|
|
42
|
+
this.encrypted = opts.encrypted;
|
|
43
|
+
this.getDEK = opts.getDEK;
|
|
44
|
+
this.staleMs = opts.staleMs ?? 3e4;
|
|
45
|
+
this.pollIntervalMs = opts.pollIntervalMs ?? 5e3;
|
|
46
|
+
this.channel = `${opts.vault}:${opts.collectionName}:presence`;
|
|
47
|
+
this.storageCollection = `_presence_${opts.collectionName}`;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Announce yourself (or update your cursor/status).
|
|
51
|
+
* Encrypts `payload` with the presence key and publishes it.
|
|
52
|
+
*/
|
|
53
|
+
async update(payload) {
|
|
54
|
+
if (this.stopped) return;
|
|
55
|
+
const key = await this.getPresenceKey();
|
|
56
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
57
|
+
const plaintext = JSON.stringify({ userId: this.userId, lastSeen: now, payload });
|
|
58
|
+
let encryptedPayload;
|
|
59
|
+
if (this.encrypted && key) {
|
|
60
|
+
const iv = generateIV();
|
|
61
|
+
const ivB64 = bufferToBase64(iv);
|
|
62
|
+
const { data } = await encrypt(plaintext, key);
|
|
63
|
+
encryptedPayload = JSON.stringify({ iv: ivB64, data });
|
|
64
|
+
} else {
|
|
65
|
+
encryptedPayload = plaintext;
|
|
66
|
+
}
|
|
67
|
+
const pubAdapter = this.getPubSubAdapter();
|
|
68
|
+
if (pubAdapter?.presencePublish) {
|
|
69
|
+
await pubAdapter.presencePublish(this.channel, encryptedPayload);
|
|
70
|
+
}
|
|
71
|
+
await this.writeStorageRecord(payload, now);
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Subscribe to presence updates. The callback receives a filtered, decrypted
|
|
75
|
+
* list of all currently-active peers (excluding yourself, excluding stale).
|
|
76
|
+
*
|
|
77
|
+
* Returns an unsubscribe function. Also call `stop()` to release all resources.
|
|
78
|
+
*/
|
|
79
|
+
subscribe(cb) {
|
|
80
|
+
if (this.stopped) return () => {
|
|
81
|
+
};
|
|
82
|
+
this.subscribers.push(cb);
|
|
83
|
+
if (this.subscribers.length === 1) {
|
|
84
|
+
this.startListening();
|
|
85
|
+
}
|
|
86
|
+
return () => {
|
|
87
|
+
this.subscribers = this.subscribers.filter((s) => s !== cb);
|
|
88
|
+
if (this.subscribers.length === 0) this.stopListening();
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
/** Stop all listening and clear resources. */
|
|
92
|
+
stop() {
|
|
93
|
+
this.stopped = true;
|
|
94
|
+
this.stopListening();
|
|
95
|
+
this.subscribers = [];
|
|
96
|
+
}
|
|
97
|
+
// ─── Private ────────────────────────────────────────────────────────
|
|
98
|
+
async getPresenceKey() {
|
|
99
|
+
if (!this.encrypted) return null;
|
|
100
|
+
if (!this.presenceKey) {
|
|
101
|
+
try {
|
|
102
|
+
const dek = await this.getDEK(this.collectionName);
|
|
103
|
+
this.presenceKey = await derivePresenceKey(dek, this.collectionName);
|
|
104
|
+
} catch {
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return this.presenceKey;
|
|
108
|
+
}
|
|
109
|
+
getPubSubAdapter() {
|
|
110
|
+
if (this.syncAdapter?.presencePublish) return this.syncAdapter;
|
|
111
|
+
if (this.adapter.presencePublish) return this.adapter;
|
|
112
|
+
return void 0;
|
|
113
|
+
}
|
|
114
|
+
startListening() {
|
|
115
|
+
const pubAdapter = this.getPubSubAdapter();
|
|
116
|
+
if (pubAdapter?.presenceSubscribe) {
|
|
117
|
+
this.unsubscribePubSub = pubAdapter.presenceSubscribe(
|
|
118
|
+
this.channel,
|
|
119
|
+
(encryptedPayload) => {
|
|
120
|
+
void this.handlePubSubMessage(encryptedPayload);
|
|
121
|
+
}
|
|
122
|
+
);
|
|
123
|
+
} else {
|
|
124
|
+
this.pollTimer = setInterval(
|
|
125
|
+
() => {
|
|
126
|
+
void this.pollStoragePresence();
|
|
127
|
+
},
|
|
128
|
+
this.pollIntervalMs
|
|
129
|
+
);
|
|
130
|
+
void this.pollStoragePresence();
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
stopListening() {
|
|
134
|
+
if (this.unsubscribePubSub) {
|
|
135
|
+
this.unsubscribePubSub();
|
|
136
|
+
this.unsubscribePubSub = null;
|
|
137
|
+
}
|
|
138
|
+
if (this.pollTimer) {
|
|
139
|
+
clearInterval(this.pollTimer);
|
|
140
|
+
this.pollTimer = null;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
async handlePubSubMessage(encryptedPayload) {
|
|
144
|
+
try {
|
|
145
|
+
const peer = await this.decryptPresencePayload(encryptedPayload);
|
|
146
|
+
if (!peer || peer.userId === this.userId) return;
|
|
147
|
+
const cutoff = new Date(Date.now() - this.staleMs).toISOString();
|
|
148
|
+
if (peer.lastSeen < cutoff) return;
|
|
149
|
+
await this.pollStoragePresence();
|
|
150
|
+
} catch {
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
async decryptPresencePayload(encryptedPayload) {
|
|
154
|
+
const key = await this.getPresenceKey();
|
|
155
|
+
if (!this.encrypted || !key) {
|
|
156
|
+
return JSON.parse(encryptedPayload);
|
|
157
|
+
}
|
|
158
|
+
const { iv: ivB64, data } = JSON.parse(encryptedPayload);
|
|
159
|
+
const plaintext = await decrypt(ivB64, data, key);
|
|
160
|
+
return JSON.parse(plaintext);
|
|
161
|
+
}
|
|
162
|
+
async writeStorageRecord(payload, now) {
|
|
163
|
+
const key = await this.getPresenceKey();
|
|
164
|
+
const plaintext = JSON.stringify(payload);
|
|
165
|
+
let iv = "";
|
|
166
|
+
let data;
|
|
167
|
+
if (this.encrypted && key) {
|
|
168
|
+
const ivBytes = generateIV();
|
|
169
|
+
iv = bufferToBase64(ivBytes);
|
|
170
|
+
const result = await encrypt(plaintext, key);
|
|
171
|
+
data = result.data;
|
|
172
|
+
} else {
|
|
173
|
+
data = plaintext;
|
|
174
|
+
}
|
|
175
|
+
const record = { userId: this.userId, lastSeen: now, iv, data };
|
|
176
|
+
const json = JSON.stringify(record);
|
|
177
|
+
const storeAdapter = this.syncAdapter ?? this.adapter;
|
|
178
|
+
const envelope = {
|
|
179
|
+
_noydb: 1,
|
|
180
|
+
_v: 1,
|
|
181
|
+
_ts: now,
|
|
182
|
+
_iv: "",
|
|
183
|
+
_data: json
|
|
184
|
+
};
|
|
185
|
+
try {
|
|
186
|
+
await storeAdapter.put(
|
|
187
|
+
this.vault,
|
|
188
|
+
this.storageCollection,
|
|
189
|
+
this.userId,
|
|
190
|
+
envelope
|
|
191
|
+
);
|
|
192
|
+
} catch {
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
async pollStoragePresence() {
|
|
196
|
+
if (this.stopped || this.subscribers.length === 0) return;
|
|
197
|
+
try {
|
|
198
|
+
const storeAdapter = this.syncAdapter ?? this.adapter;
|
|
199
|
+
const ids = await storeAdapter.list(this.vault, this.storageCollection);
|
|
200
|
+
const cutoff = new Date(Date.now() - this.staleMs).toISOString();
|
|
201
|
+
const peers = [];
|
|
202
|
+
for (const id of ids) {
|
|
203
|
+
if (id === this.userId) continue;
|
|
204
|
+
const envelope = await storeAdapter.get(this.vault, this.storageCollection, id);
|
|
205
|
+
if (!envelope) continue;
|
|
206
|
+
const record = JSON.parse(envelope._data);
|
|
207
|
+
if (record.lastSeen < cutoff) continue;
|
|
208
|
+
let peerPayload;
|
|
209
|
+
if (this.encrypted && this.presenceKey && record.iv) {
|
|
210
|
+
const plaintext = await decrypt(record.iv, record.data, this.presenceKey);
|
|
211
|
+
peerPayload = JSON.parse(plaintext);
|
|
212
|
+
} else {
|
|
213
|
+
peerPayload = JSON.parse(record.data);
|
|
214
|
+
}
|
|
215
|
+
peers.push({ userId: record.userId, payload: peerPayload, lastSeen: record.lastSeen });
|
|
216
|
+
}
|
|
217
|
+
for (const cb of this.subscribers) {
|
|
218
|
+
cb(peers);
|
|
219
|
+
}
|
|
220
|
+
} catch {
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
// src/team/sync.ts
|
|
226
|
+
var SyncEngine = class {
|
|
227
|
+
local;
|
|
228
|
+
remote;
|
|
229
|
+
strategy;
|
|
230
|
+
emitter;
|
|
231
|
+
vault;
|
|
232
|
+
role;
|
|
233
|
+
label;
|
|
234
|
+
dirty = [];
|
|
235
|
+
lastPush = null;
|
|
236
|
+
lastPull = null;
|
|
237
|
+
loaded = false;
|
|
238
|
+
autoSyncInterval = null;
|
|
239
|
+
isOnline = true;
|
|
240
|
+
/** Sync scheduler. Manages push/pull timing. */
|
|
241
|
+
scheduler;
|
|
242
|
+
/** Per-collection conflict resolvers registered by Collection instances. */
|
|
243
|
+
conflictResolvers = /* @__PURE__ */ new Map();
|
|
244
|
+
constructor(opts) {
|
|
245
|
+
this.local = opts.local;
|
|
246
|
+
this.remote = opts.remote;
|
|
247
|
+
this.vault = opts.vault;
|
|
248
|
+
this.strategy = opts.strategy;
|
|
249
|
+
this.emitter = opts.emitter;
|
|
250
|
+
this.role = opts.role ?? "sync-peer";
|
|
251
|
+
this.label = opts.label;
|
|
252
|
+
const policy = opts.syncPolicy;
|
|
253
|
+
if (policy && policy.push.mode !== "manual") {
|
|
254
|
+
this.scheduler = new SyncScheduler(policy, {
|
|
255
|
+
push: () => this.push().then(() => {
|
|
256
|
+
}),
|
|
257
|
+
pull: () => this.pull().then(() => {
|
|
258
|
+
}),
|
|
259
|
+
getDirtyCount: () => this.dirty.length
|
|
260
|
+
});
|
|
261
|
+
} else {
|
|
262
|
+
this.scheduler = null;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
/** Start the sync scheduler. Called after vault is fully opened. */
|
|
266
|
+
startScheduler() {
|
|
267
|
+
this.scheduler?.start();
|
|
268
|
+
}
|
|
269
|
+
/** Stop the sync scheduler. Called on close. */
|
|
270
|
+
stopScheduler() {
|
|
271
|
+
this.scheduler?.stop();
|
|
272
|
+
}
|
|
273
|
+
/**
|
|
274
|
+
* Register a per-collection conflict resolver.
|
|
275
|
+
* Called by Collection when `conflictPolicy` is set.
|
|
276
|
+
*/
|
|
277
|
+
registerConflictResolver(collection, resolver) {
|
|
278
|
+
this.conflictResolvers.set(collection, resolver);
|
|
279
|
+
}
|
|
280
|
+
/** Record a local change for later push. */
|
|
281
|
+
async trackChange(collection, id, action, version) {
|
|
282
|
+
await this.ensureLoaded();
|
|
283
|
+
const idx = this.dirty.findIndex((d) => d.collection === collection && d.id === id);
|
|
284
|
+
const entry = {
|
|
285
|
+
vault: this.vault,
|
|
286
|
+
collection,
|
|
287
|
+
id,
|
|
288
|
+
action,
|
|
289
|
+
version,
|
|
290
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
291
|
+
};
|
|
292
|
+
if (idx >= 0) {
|
|
293
|
+
this.dirty[idx] = entry;
|
|
294
|
+
} else {
|
|
295
|
+
this.dirty.push(entry);
|
|
296
|
+
}
|
|
297
|
+
await this.persistMeta();
|
|
298
|
+
this.scheduler?.notifyChange();
|
|
299
|
+
}
|
|
300
|
+
/** Push dirty records to remote adapter. Accepts optional `PushOptions` for partial sync. */
|
|
301
|
+
async push(options) {
|
|
302
|
+
await this.ensureLoaded();
|
|
303
|
+
let pushed = 0;
|
|
304
|
+
const conflicts = [];
|
|
305
|
+
const errors = [];
|
|
306
|
+
const completed = [];
|
|
307
|
+
for (let i = 0; i < this.dirty.length; i++) {
|
|
308
|
+
const entry = this.dirty[i];
|
|
309
|
+
if (options?.collections && !options.collections.includes(entry.collection)) {
|
|
310
|
+
continue;
|
|
311
|
+
}
|
|
312
|
+
try {
|
|
313
|
+
if (entry.action === "delete") {
|
|
314
|
+
await this.remote.delete(this.vault, entry.collection, entry.id);
|
|
315
|
+
completed.push(i);
|
|
316
|
+
pushed++;
|
|
317
|
+
} else {
|
|
318
|
+
const envelope = await this.local.get(this.vault, entry.collection, entry.id);
|
|
319
|
+
if (!envelope) {
|
|
320
|
+
completed.push(i);
|
|
321
|
+
continue;
|
|
322
|
+
}
|
|
323
|
+
try {
|
|
324
|
+
await this.remote.put(
|
|
325
|
+
this.vault,
|
|
326
|
+
entry.collection,
|
|
327
|
+
entry.id,
|
|
328
|
+
envelope,
|
|
329
|
+
entry.version - 1
|
|
330
|
+
);
|
|
331
|
+
completed.push(i);
|
|
332
|
+
pushed++;
|
|
333
|
+
} catch (err) {
|
|
334
|
+
if (err instanceof ConflictError) {
|
|
335
|
+
const remoteEnvelope = await this.remote.get(this.vault, entry.collection, entry.id);
|
|
336
|
+
if (remoteEnvelope) {
|
|
337
|
+
const { handled, conflict } = await this.handleConflict(
|
|
338
|
+
entry.collection,
|
|
339
|
+
entry.id,
|
|
340
|
+
envelope,
|
|
341
|
+
remoteEnvelope,
|
|
342
|
+
"push"
|
|
343
|
+
);
|
|
344
|
+
conflicts.push(conflict);
|
|
345
|
+
if (handled === "local") {
|
|
346
|
+
await this.remote.put(this.vault, entry.collection, entry.id, conflict.local);
|
|
347
|
+
completed.push(i);
|
|
348
|
+
pushed++;
|
|
349
|
+
} else if (handled === "remote") {
|
|
350
|
+
await this.local.put(this.vault, entry.collection, entry.id, conflict.remote);
|
|
351
|
+
completed.push(i);
|
|
352
|
+
} else if (handled === "merged" && conflict.local !== envelope) {
|
|
353
|
+
const merged = conflict.local;
|
|
354
|
+
await this.remote.put(this.vault, entry.collection, entry.id, merged);
|
|
355
|
+
await this.local.put(this.vault, entry.collection, entry.id, merged);
|
|
356
|
+
completed.push(i);
|
|
357
|
+
pushed++;
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
} else {
|
|
361
|
+
throw err;
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
} catch (err) {
|
|
366
|
+
errors.push(err instanceof Error ? err : new Error(String(err)));
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
for (const i of completed.sort((a, b) => b - a)) {
|
|
370
|
+
this.dirty.splice(i, 1);
|
|
371
|
+
}
|
|
372
|
+
this.lastPush = (/* @__PURE__ */ new Date()).toISOString();
|
|
373
|
+
await this.persistMeta();
|
|
374
|
+
const result = { pushed, conflicts, errors };
|
|
375
|
+
this.emitter.emit("sync:push", result);
|
|
376
|
+
return result;
|
|
377
|
+
}
|
|
378
|
+
/** Pull remote records to local adapter. Accepts optional `PullOptions` for partial sync. */
|
|
379
|
+
async pull(options) {
|
|
380
|
+
await this.ensureLoaded();
|
|
381
|
+
let pulled = 0;
|
|
382
|
+
const conflicts = [];
|
|
383
|
+
const errors = [];
|
|
384
|
+
try {
|
|
385
|
+
const remoteSnapshot = await this.remote.loadAll(this.vault);
|
|
386
|
+
for (const [collName, records] of Object.entries(remoteSnapshot)) {
|
|
387
|
+
if (options?.collections && !options.collections.includes(collName)) {
|
|
388
|
+
continue;
|
|
389
|
+
}
|
|
390
|
+
for (const [id, remoteEnvelope] of Object.entries(records)) {
|
|
391
|
+
if (options?.modifiedSince && remoteEnvelope._ts <= options.modifiedSince) {
|
|
392
|
+
continue;
|
|
393
|
+
}
|
|
394
|
+
try {
|
|
395
|
+
const localEnvelope = await this.local.get(this.vault, collName, id);
|
|
396
|
+
if (!localEnvelope) {
|
|
397
|
+
await this.local.put(this.vault, collName, id, remoteEnvelope);
|
|
398
|
+
pulled++;
|
|
399
|
+
} else if (remoteEnvelope._v > localEnvelope._v) {
|
|
400
|
+
const isDirty = this.dirty.some((d) => d.collection === collName && d.id === id);
|
|
401
|
+
if (isDirty) {
|
|
402
|
+
const { handled, conflict } = await this.handleConflict(
|
|
403
|
+
collName,
|
|
404
|
+
id,
|
|
405
|
+
localEnvelope,
|
|
406
|
+
remoteEnvelope,
|
|
407
|
+
"pull"
|
|
408
|
+
);
|
|
409
|
+
conflicts.push(conflict);
|
|
410
|
+
if (handled === "remote") {
|
|
411
|
+
await this.local.put(this.vault, collName, id, conflict.remote);
|
|
412
|
+
this.dirty = this.dirty.filter((d) => !(d.collection === collName && d.id === id));
|
|
413
|
+
pulled++;
|
|
414
|
+
} else if (handled === "merged" && conflict.local !== localEnvelope) {
|
|
415
|
+
const merged = conflict.local;
|
|
416
|
+
await this.local.put(this.vault, collName, id, merged);
|
|
417
|
+
this.dirty = this.dirty.filter((d) => !(d.collection === collName && d.id === id));
|
|
418
|
+
pulled++;
|
|
419
|
+
}
|
|
420
|
+
} else {
|
|
421
|
+
await this.local.put(this.vault, collName, id, remoteEnvelope);
|
|
422
|
+
pulled++;
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
} catch (err) {
|
|
426
|
+
errors.push(err instanceof Error ? err : new Error(String(err)));
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
} catch (err) {
|
|
431
|
+
errors.push(err instanceof Error ? err : new Error(String(err)));
|
|
432
|
+
}
|
|
433
|
+
this.lastPull = (/* @__PURE__ */ new Date()).toISOString();
|
|
434
|
+
await this.persistMeta();
|
|
435
|
+
const result = { pulled, conflicts, errors };
|
|
436
|
+
this.emitter.emit("sync:pull", result);
|
|
437
|
+
return result;
|
|
438
|
+
}
|
|
439
|
+
/** Bidirectional sync: pull then push. */
|
|
440
|
+
async sync(options) {
|
|
441
|
+
const pullResult = await this.pull(options?.pull);
|
|
442
|
+
const pushResult = await this.push(options?.push);
|
|
443
|
+
return { pull: pullResult, push: pushResult };
|
|
444
|
+
}
|
|
445
|
+
/**
|
|
446
|
+
* Push a specific subset of dirty entries (for sync transactions, ).
|
|
447
|
+
* Entries are matched by collection+id from the dirty log; matched entries
|
|
448
|
+
* are removed from the dirty log on success.
|
|
449
|
+
*/
|
|
450
|
+
async pushFiltered(predicate) {
|
|
451
|
+
await this.ensureLoaded();
|
|
452
|
+
let pushed = 0;
|
|
453
|
+
const conflicts = [];
|
|
454
|
+
const errors = [];
|
|
455
|
+
const completed = [];
|
|
456
|
+
for (let i = 0; i < this.dirty.length; i++) {
|
|
457
|
+
const entry = this.dirty[i];
|
|
458
|
+
if (!predicate(entry)) continue;
|
|
459
|
+
try {
|
|
460
|
+
if (entry.action === "delete") {
|
|
461
|
+
await this.remote.delete(this.vault, entry.collection, entry.id);
|
|
462
|
+
completed.push(i);
|
|
463
|
+
pushed++;
|
|
464
|
+
} else {
|
|
465
|
+
const envelope = await this.local.get(this.vault, entry.collection, entry.id);
|
|
466
|
+
if (!envelope) {
|
|
467
|
+
completed.push(i);
|
|
468
|
+
continue;
|
|
469
|
+
}
|
|
470
|
+
try {
|
|
471
|
+
await this.remote.put(
|
|
472
|
+
this.vault,
|
|
473
|
+
entry.collection,
|
|
474
|
+
entry.id,
|
|
475
|
+
envelope,
|
|
476
|
+
entry.version - 1
|
|
477
|
+
);
|
|
478
|
+
completed.push(i);
|
|
479
|
+
pushed++;
|
|
480
|
+
} catch (err) {
|
|
481
|
+
if (err instanceof ConflictError) {
|
|
482
|
+
const remoteEnvelope = await this.remote.get(this.vault, entry.collection, entry.id);
|
|
483
|
+
if (remoteEnvelope) {
|
|
484
|
+
const { handled, conflict } = await this.handleConflict(
|
|
485
|
+
entry.collection,
|
|
486
|
+
entry.id,
|
|
487
|
+
envelope,
|
|
488
|
+
remoteEnvelope,
|
|
489
|
+
"push"
|
|
490
|
+
);
|
|
491
|
+
conflicts.push(conflict);
|
|
492
|
+
if (handled === "local") {
|
|
493
|
+
await this.remote.put(this.vault, entry.collection, entry.id, conflict.local);
|
|
494
|
+
completed.push(i);
|
|
495
|
+
pushed++;
|
|
496
|
+
} else if (handled === "remote") {
|
|
497
|
+
await this.local.put(this.vault, entry.collection, entry.id, conflict.remote);
|
|
498
|
+
completed.push(i);
|
|
499
|
+
} else if (handled === "merged" && conflict.local !== envelope) {
|
|
500
|
+
const merged = conflict.local;
|
|
501
|
+
await this.remote.put(this.vault, entry.collection, entry.id, merged);
|
|
502
|
+
await this.local.put(this.vault, entry.collection, entry.id, merged);
|
|
503
|
+
completed.push(i);
|
|
504
|
+
pushed++;
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
} else {
|
|
508
|
+
throw err;
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
} catch (err) {
|
|
513
|
+
errors.push(err instanceof Error ? err : new Error(String(err)));
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
for (const i of completed.sort((a, b) => b - a)) {
|
|
517
|
+
this.dirty.splice(i, 1);
|
|
518
|
+
}
|
|
519
|
+
this.lastPush = (/* @__PURE__ */ new Date()).toISOString();
|
|
520
|
+
await this.persistMeta();
|
|
521
|
+
const result = { pushed, conflicts, errors };
|
|
522
|
+
this.emitter.emit("sync:push", result);
|
|
523
|
+
return result;
|
|
524
|
+
}
|
|
525
|
+
/** Get current sync status. */
|
|
526
|
+
status() {
|
|
527
|
+
return {
|
|
528
|
+
dirty: this.dirty.length,
|
|
529
|
+
lastPush: this.lastPush,
|
|
530
|
+
lastPull: this.lastPull,
|
|
531
|
+
online: this.isOnline
|
|
532
|
+
};
|
|
533
|
+
}
|
|
534
|
+
// ─── Auto-Sync ───────────────────────────────────────────────────
|
|
535
|
+
/** Start auto-sync: listen for online/offline events, optional periodic sync. */
|
|
536
|
+
startAutoSync(intervalMs) {
|
|
537
|
+
if (typeof globalThis.addEventListener === "function") {
|
|
538
|
+
globalThis.addEventListener("online", this.handleOnline);
|
|
539
|
+
globalThis.addEventListener("offline", this.handleOffline);
|
|
540
|
+
}
|
|
541
|
+
if (intervalMs && intervalMs > 0) {
|
|
542
|
+
this.autoSyncInterval = setInterval(() => {
|
|
543
|
+
if (this.isOnline) {
|
|
544
|
+
void this.sync();
|
|
545
|
+
}
|
|
546
|
+
}, intervalMs);
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
/** Stop auto-sync and scheduler. */
|
|
550
|
+
stopAutoSync() {
|
|
551
|
+
this.stopScheduler();
|
|
552
|
+
if (typeof globalThis.removeEventListener === "function") {
|
|
553
|
+
globalThis.removeEventListener("online", this.handleOnline);
|
|
554
|
+
globalThis.removeEventListener("offline", this.handleOffline);
|
|
555
|
+
}
|
|
556
|
+
if (this.autoSyncInterval) {
|
|
557
|
+
clearInterval(this.autoSyncInterval);
|
|
558
|
+
this.autoSyncInterval = null;
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
handleOnline = () => {
|
|
562
|
+
this.isOnline = true;
|
|
563
|
+
this.emitter.emit("sync:online", void 0);
|
|
564
|
+
void this.sync();
|
|
565
|
+
};
|
|
566
|
+
handleOffline = () => {
|
|
567
|
+
this.isOnline = false;
|
|
568
|
+
this.emitter.emit("sync:offline", void 0);
|
|
569
|
+
};
|
|
570
|
+
/**
|
|
571
|
+
* Resolve a conflict, checking per-collection resolvers first,
|
|
572
|
+
* then falling back to the db-level `ConflictStrategy`.
|
|
573
|
+
*
|
|
574
|
+
* Returns the resolved `Conflict` object (possibly with `resolve` set for
|
|
575
|
+
* manual mode) and a `handled` discriminant:
|
|
576
|
+
* - `'local'` — keep the local envelope; push it to remote.
|
|
577
|
+
* - `'remote'` — keep the remote envelope; update local.
|
|
578
|
+
* - `'merged'` — a custom merge fn produced a new envelope stored as `conflict.local`.
|
|
579
|
+
* - `'deferred'` — manual mode, resolve was not called synchronously.
|
|
580
|
+
*/
|
|
581
|
+
async handleConflict(collection, id, local, remote, _phase) {
|
|
582
|
+
const resolver = this.conflictResolvers.get(collection);
|
|
583
|
+
if (resolver) {
|
|
584
|
+
const winner = await resolver(id, local, remote);
|
|
585
|
+
const base = {
|
|
586
|
+
vault: this.vault,
|
|
587
|
+
collection,
|
|
588
|
+
id,
|
|
589
|
+
local,
|
|
590
|
+
remote,
|
|
591
|
+
localVersion: local._v,
|
|
592
|
+
remoteVersion: remote._v
|
|
593
|
+
};
|
|
594
|
+
if (winner === null) return { handled: "deferred", conflict: base };
|
|
595
|
+
if (winner === local) return { handled: "local", conflict: base };
|
|
596
|
+
if (winner === remote) return { handled: "remote", conflict: base };
|
|
597
|
+
return {
|
|
598
|
+
handled: "merged",
|
|
599
|
+
conflict: { ...base, local: winner, localVersion: winner._v }
|
|
600
|
+
};
|
|
601
|
+
}
|
|
602
|
+
const baseConflict = {
|
|
603
|
+
vault: this.vault,
|
|
604
|
+
collection,
|
|
605
|
+
id,
|
|
606
|
+
local,
|
|
607
|
+
remote,
|
|
608
|
+
localVersion: local._v,
|
|
609
|
+
remoteVersion: remote._v
|
|
610
|
+
};
|
|
611
|
+
this.emitter.emit("sync:conflict", baseConflict);
|
|
612
|
+
const side = this.legacyResolve(baseConflict);
|
|
613
|
+
return { handled: side, conflict: baseConflict };
|
|
614
|
+
}
|
|
615
|
+
/** DB-level ConflictStrategy resolution (legacy, kept for backward compat). */
|
|
616
|
+
legacyResolve(conflict) {
|
|
617
|
+
if (typeof this.strategy === "function") {
|
|
618
|
+
return this.strategy(conflict);
|
|
619
|
+
}
|
|
620
|
+
switch (this.strategy) {
|
|
621
|
+
case "local-wins":
|
|
622
|
+
return "local";
|
|
623
|
+
case "remote-wins":
|
|
624
|
+
return "remote";
|
|
625
|
+
case "version":
|
|
626
|
+
default:
|
|
627
|
+
return conflict.localVersion >= conflict.remoteVersion ? "local" : "remote";
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
// ─── Persistence ─────────────────────────────────────────────────
|
|
631
|
+
async ensureLoaded() {
|
|
632
|
+
if (this.loaded) return;
|
|
633
|
+
const envelope = await this.local.get(this.vault, "_sync", "meta");
|
|
634
|
+
if (envelope) {
|
|
635
|
+
const meta = JSON.parse(envelope._data);
|
|
636
|
+
this.dirty = [...meta.dirty];
|
|
637
|
+
this.lastPush = meta.last_push;
|
|
638
|
+
this.lastPull = meta.last_pull;
|
|
639
|
+
}
|
|
640
|
+
this.loaded = true;
|
|
641
|
+
}
|
|
642
|
+
async persistMeta() {
|
|
643
|
+
const meta = {
|
|
644
|
+
_noydb_sync: NOYDB_SYNC_VERSION,
|
|
645
|
+
last_push: this.lastPush,
|
|
646
|
+
last_pull: this.lastPull,
|
|
647
|
+
dirty: this.dirty
|
|
648
|
+
};
|
|
649
|
+
const envelope = {
|
|
650
|
+
_noydb: 1,
|
|
651
|
+
_v: 1,
|
|
652
|
+
_ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
653
|
+
_iv: "",
|
|
654
|
+
_data: JSON.stringify(meta)
|
|
655
|
+
};
|
|
656
|
+
await this.local.put(this.vault, "_sync", "meta", envelope);
|
|
657
|
+
}
|
|
658
|
+
};
|
|
659
|
+
|
|
660
|
+
// src/team/sync-transaction.ts
|
|
661
|
+
var SyncTransaction = class {
|
|
662
|
+
comp;
|
|
663
|
+
engine;
|
|
664
|
+
ops = [];
|
|
665
|
+
/** @internal — constructed by `Noydb.transaction()` */
|
|
666
|
+
constructor(comp, engine) {
|
|
667
|
+
this.comp = comp;
|
|
668
|
+
this.engine = engine;
|
|
669
|
+
}
|
|
670
|
+
/** Stage a record write. Does not write to any adapter until `commit()`. */
|
|
671
|
+
put(collection, id, record) {
|
|
672
|
+
this.ops.push({ type: "put", collection, id, record });
|
|
673
|
+
return this;
|
|
674
|
+
}
|
|
675
|
+
/** Stage a record delete. Does not write to any adapter until `commit()`. */
|
|
676
|
+
delete(collection, id) {
|
|
677
|
+
this.ops.push({ type: "delete", collection, id });
|
|
678
|
+
return this;
|
|
679
|
+
}
|
|
680
|
+
/**
|
|
681
|
+
* Commit the transaction.
|
|
682
|
+
*
|
|
683
|
+
* Phase 1 — writes all staged operations to the local adapter via the
|
|
684
|
+
* collection layer (encryption + dirty-log tracking).
|
|
685
|
+
*
|
|
686
|
+
* Phase 2 — pushes only the records that were written in this
|
|
687
|
+
* transaction to the remote adapter. Existing dirty entries from
|
|
688
|
+
* outside this transaction are not affected.
|
|
689
|
+
*
|
|
690
|
+
* If any record conflicts during the push, `status` is `'conflict'`
|
|
691
|
+
* and `conflicts` lists the affected records. No automatic rollback is
|
|
692
|
+
* performed.
|
|
693
|
+
*/
|
|
694
|
+
async commit() {
|
|
695
|
+
for (const op of this.ops) {
|
|
696
|
+
if (op.type === "put") {
|
|
697
|
+
await this.comp.collection(op.collection).put(op.id, op.record);
|
|
698
|
+
} else {
|
|
699
|
+
await this.comp.collection(op.collection).delete(op.id);
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
const opSet = /* @__PURE__ */ new Set();
|
|
703
|
+
for (const op of this.ops) {
|
|
704
|
+
opSet.add(`${op.collection}::${op.id}`);
|
|
705
|
+
}
|
|
706
|
+
const pushResult = await this.engine.pushFiltered(
|
|
707
|
+
(entry) => opSet.has(`${entry.collection}::${entry.id}`)
|
|
708
|
+
);
|
|
709
|
+
return {
|
|
710
|
+
status: pushResult.conflicts.length > 0 ? "conflict" : "committed",
|
|
711
|
+
pushed: pushResult.pushed,
|
|
712
|
+
conflicts: pushResult.conflicts
|
|
713
|
+
};
|
|
714
|
+
}
|
|
715
|
+
};
|
|
716
|
+
|
|
717
|
+
export {
|
|
718
|
+
PresenceHandle,
|
|
719
|
+
SyncEngine,
|
|
720
|
+
SyncTransaction
|
|
721
|
+
};
|
|
722
|
+
//# sourceMappingURL=chunk-EXQRC2L4.js.map
|