@interocitor/core 0.0.0-beta.10
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 +706 -0
- package/dist/adapters/cloudflare.d.ts +78 -0
- package/dist/adapters/cloudflare.d.ts.map +1 -0
- package/dist/adapters/cloudflare.js +325 -0
- package/dist/adapters/google-drive.d.ts +64 -0
- package/dist/adapters/google-drive.d.ts.map +1 -0
- package/dist/adapters/google-drive.js +339 -0
- package/dist/adapters/memory.d.ts +53 -0
- package/dist/adapters/memory.d.ts.map +1 -0
- package/dist/adapters/memory.js +182 -0
- package/dist/adapters/webdav.d.ts +70 -0
- package/dist/adapters/webdav.d.ts.map +1 -0
- package/dist/adapters/webdav.js +323 -0
- package/dist/core/codec.d.ts +20 -0
- package/dist/core/codec.d.ts.map +1 -0
- package/dist/core/codec.js +102 -0
- package/dist/core/compaction.d.ts +45 -0
- package/dist/core/compaction.d.ts.map +1 -0
- package/dist/core/compaction.js +190 -0
- package/dist/core/connected-stores.d.ts +77 -0
- package/dist/core/connected-stores.d.ts.map +1 -0
- package/dist/core/connected-stores.js +76 -0
- package/dist/core/crdt.d.ts +36 -0
- package/dist/core/crdt.d.ts.map +1 -0
- package/dist/core/crdt.js +174 -0
- package/dist/core/errors.d.ts +47 -0
- package/dist/core/errors.d.ts.map +1 -0
- package/dist/core/errors.js +61 -0
- package/dist/core/flush.d.ts +9 -0
- package/dist/core/flush.d.ts.map +1 -0
- package/dist/core/flush.js +98 -0
- package/dist/core/hlc.d.ts +25 -0
- package/dist/core/hlc.d.ts.map +1 -0
- package/dist/core/hlc.js +75 -0
- package/dist/core/ids.d.ts +49 -0
- package/dist/core/ids.d.ts.map +1 -0
- package/dist/core/ids.js +132 -0
- package/dist/core/internals.d.ts +33 -0
- package/dist/core/internals.d.ts.map +1 -0
- package/dist/core/internals.js +72 -0
- package/dist/core/manifest.d.ts +56 -0
- package/dist/core/manifest.d.ts.map +1 -0
- package/dist/core/manifest.js +203 -0
- package/dist/core/pull.d.ts +26 -0
- package/dist/core/pull.d.ts.map +1 -0
- package/dist/core/pull.js +113 -0
- package/dist/core/row-id.d.ts +12 -0
- package/dist/core/row-id.d.ts.map +1 -0
- package/dist/core/row-id.js +11 -0
- package/dist/core/schema-types.d.ts +26 -0
- package/dist/core/schema-types.d.ts.map +1 -0
- package/dist/core/schema-types.js +31 -0
- package/dist/core/schema-types.type-test.d.ts +2 -0
- package/dist/core/schema-types.type-test.d.ts.map +1 -0
- package/dist/core/schema-types.type-test.js +224 -0
- package/dist/core/sync-engine.d.ts +364 -0
- package/dist/core/sync-engine.d.ts.map +1 -0
- package/dist/core/sync-engine.js +2475 -0
- package/dist/core/table.d.ts +260 -0
- package/dist/core/table.d.ts.map +1 -0
- package/dist/core/table.js +461 -0
- package/dist/core/types.d.ts +952 -0
- package/dist/core/types.d.ts.map +1 -0
- package/dist/core/types.js +6 -0
- package/dist/crypto/encryption.d.ts +61 -0
- package/dist/crypto/encryption.d.ts.map +1 -0
- package/dist/crypto/encryption.js +216 -0
- package/dist/crypto/keys.d.ts +48 -0
- package/dist/crypto/keys.d.ts.map +1 -0
- package/dist/crypto/keys.js +54 -0
- package/dist/handshake/channel.d.ts +117 -0
- package/dist/handshake/channel.d.ts.map +1 -0
- package/dist/handshake/channel.js +245 -0
- package/dist/handshake/index.d.ts +216 -0
- package/dist/handshake/index.d.ts.map +1 -0
- package/dist/handshake/index.js +199 -0
- package/dist/handshake/qr-public.d.ts +3 -0
- package/dist/handshake/qr-public.d.ts.map +1 -0
- package/dist/handshake/qr-public.js +1 -0
- package/dist/handshake/qr.d.ts +100 -0
- package/dist/handshake/qr.d.ts.map +1 -0
- package/dist/handshake/qr.js +102 -0
- package/dist/index.d.ts +50 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +50 -0
- package/dist/storage/credential-store.d.ts +122 -0
- package/dist/storage/credential-store.d.ts.map +1 -0
- package/dist/storage/credential-store.js +356 -0
- package/dist/storage/local-store.d.ts +64 -0
- package/dist/storage/local-store.d.ts.map +1 -0
- package/dist/storage/local-store.js +490 -0
- package/dist/storage/reset.d.ts +10 -0
- package/dist/storage/reset.d.ts.map +1 -0
- package/dist/storage/reset.js +18 -0
- package/package.json +76 -0
|
@@ -0,0 +1,490 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local storage layer — IndexedDB
|
|
3
|
+
*
|
|
4
|
+
* This is a cache, not the source of truth.
|
|
5
|
+
* If cleared, the app rehydrates from cloud.
|
|
6
|
+
*
|
|
7
|
+
* Stores:
|
|
8
|
+
* - rows: the current merged state of all tables
|
|
9
|
+
* - outbox: change entries pending upload
|
|
10
|
+
* - cursors: byte offsets into each device's change log
|
|
11
|
+
* - meta: device ID, last snapshot epoch, etc.
|
|
12
|
+
*/
|
|
13
|
+
const DEFAULT_DB_NAME = 'interocitor';
|
|
14
|
+
const DEFAULT_DB_VERSION = 1;
|
|
15
|
+
const CACHE_FINGERPRINT_META_KEY = 'interocitor:cache:fingerprint';
|
|
16
|
+
const STORES = {
|
|
17
|
+
rows: 'rows', // key: "{table}/{rowId}"
|
|
18
|
+
outbox: 'outbox', // key: auto-increment
|
|
19
|
+
cursors: 'cursors', // key: deviceId
|
|
20
|
+
meta: 'meta', // key: string
|
|
21
|
+
};
|
|
22
|
+
const SCHEMA_INDEX_PREFIX = 'idx:';
|
|
23
|
+
function schemaIndexName(table, indexName) {
|
|
24
|
+
return `${SCHEMA_INDEX_PREFIX}${table}:${indexName}`;
|
|
25
|
+
}
|
|
26
|
+
function schemaIndexKeyPath(field) {
|
|
27
|
+
// Index key is composite: [table, payload-field-value]. Both live under
|
|
28
|
+
// namespaced parents now. ColumnEntry stores the user value under `.value`.
|
|
29
|
+
return ['_meta.table', `payload.${field}.value`];
|
|
30
|
+
}
|
|
31
|
+
function normalizeSchema(schema) {
|
|
32
|
+
if (!schema)
|
|
33
|
+
return undefined;
|
|
34
|
+
return schema;
|
|
35
|
+
}
|
|
36
|
+
function domStringListToArray(list) {
|
|
37
|
+
const out = [];
|
|
38
|
+
for (let i = 0; i < list.length; i++) {
|
|
39
|
+
const item = list.item(i);
|
|
40
|
+
if (item)
|
|
41
|
+
out.push(item);
|
|
42
|
+
}
|
|
43
|
+
return out;
|
|
44
|
+
}
|
|
45
|
+
function expectedSchemaIndexes(schema) {
|
|
46
|
+
const expected = new Map();
|
|
47
|
+
if (!schema)
|
|
48
|
+
return expected;
|
|
49
|
+
for (const table of Object.keys(schema.tables).sort()) {
|
|
50
|
+
const def = schema.tables[table];
|
|
51
|
+
const fieldEntries = Object.entries(def.fields ?? {}).sort(([a], [b]) => a.localeCompare(b));
|
|
52
|
+
for (const [fieldName, input] of fieldEntries) {
|
|
53
|
+
const fieldDef = normalizeFieldInput(input);
|
|
54
|
+
if (!fieldDef.index && !fieldDef.unique)
|
|
55
|
+
continue;
|
|
56
|
+
expected.set(schemaIndexName(table, `by_${fieldName}`), {
|
|
57
|
+
keyPath: schemaIndexKeyPath(fieldName),
|
|
58
|
+
unique: fieldDef.unique ?? false,
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
const indexes = [...(def.indexes ?? [])].sort((a, b) => a.name.localeCompare(b.name) || a.field.localeCompare(b.field));
|
|
62
|
+
for (const index of indexes) {
|
|
63
|
+
expected.set(schemaIndexName(table, index.name), {
|
|
64
|
+
keyPath: schemaIndexKeyPath(index.field),
|
|
65
|
+
unique: index.unique ?? false,
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return expected;
|
|
70
|
+
}
|
|
71
|
+
function normalizeFieldInput(input) {
|
|
72
|
+
return {
|
|
73
|
+
index: input.index ?? false,
|
|
74
|
+
unique: input.unique ?? false,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
function readColumnValue(row, field) {
|
|
78
|
+
const entry = row.payload?.[field];
|
|
79
|
+
if (entry === undefined)
|
|
80
|
+
return undefined;
|
|
81
|
+
return entry.value;
|
|
82
|
+
}
|
|
83
|
+
function compare(a, b) {
|
|
84
|
+
const av = a instanceof Date ? a.getTime() : a;
|
|
85
|
+
const bv = b instanceof Date ? b.getTime() : b;
|
|
86
|
+
if (av < bv)
|
|
87
|
+
return -1;
|
|
88
|
+
if (av > bv)
|
|
89
|
+
return 1;
|
|
90
|
+
return 0;
|
|
91
|
+
}
|
|
92
|
+
function matchesClause(value, clause) {
|
|
93
|
+
if (value === undefined || value === null)
|
|
94
|
+
return false;
|
|
95
|
+
switch (clause.op) {
|
|
96
|
+
case 'equals':
|
|
97
|
+
return compare(value, clause.value) === 0;
|
|
98
|
+
case 'above':
|
|
99
|
+
return compare(value, clause.value) > 0;
|
|
100
|
+
case 'aboveOrEqual':
|
|
101
|
+
return compare(value, clause.value) >= 0;
|
|
102
|
+
case 'below':
|
|
103
|
+
return compare(value, clause.value) < 0;
|
|
104
|
+
case 'belowOrEqual':
|
|
105
|
+
return compare(value, clause.value) <= 0;
|
|
106
|
+
case 'between': {
|
|
107
|
+
const lowerCmp = compare(value, clause.lower);
|
|
108
|
+
const upperCmp = compare(value, clause.upper);
|
|
109
|
+
const lowerOk = clause.lowerOpen ? lowerCmp > 0 : lowerCmp >= 0;
|
|
110
|
+
const upperOk = clause.upperOpen ? upperCmp < 0 : upperCmp <= 0;
|
|
111
|
+
return lowerOk && upperOk;
|
|
112
|
+
}
|
|
113
|
+
case 'startsWith':
|
|
114
|
+
return typeof value === 'string' && value.startsWith(String(clause.value));
|
|
115
|
+
case 'anyOf':
|
|
116
|
+
return (clause.values ?? []).some(v => compare(value, v) === 0);
|
|
117
|
+
default:
|
|
118
|
+
return false;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
function hasSchemaIndex(schema, table, field) {
|
|
122
|
+
const tableSchema = schema?.tables[table];
|
|
123
|
+
if (!tableSchema)
|
|
124
|
+
return undefined;
|
|
125
|
+
const legacy = tableSchema.indexes?.find(index => index.field === field);
|
|
126
|
+
if (legacy)
|
|
127
|
+
return legacy;
|
|
128
|
+
const fromField = tableSchema.fields?.[field];
|
|
129
|
+
if (!fromField)
|
|
130
|
+
return undefined;
|
|
131
|
+
const fieldDef = normalizeFieldInput(fromField);
|
|
132
|
+
if (!fieldDef.index && !fieldDef.unique)
|
|
133
|
+
return undefined;
|
|
134
|
+
return {
|
|
135
|
+
name: `by_${field}`,
|
|
136
|
+
field,
|
|
137
|
+
unique: fieldDef.unique,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
function rangeForClause(table, clause) {
|
|
141
|
+
// Schema indexes are keyed as [table, fieldValue]. Range queries must bound
|
|
142
|
+
// both sides of the compound key; lowerBound([table, value]) alone would also
|
|
143
|
+
// include later table names, and upperBound([table, value]) would include
|
|
144
|
+
// earlier table names.
|
|
145
|
+
const tableLowerBound = [table];
|
|
146
|
+
const tableUpperBound = [table, []];
|
|
147
|
+
switch (clause.op) {
|
|
148
|
+
case 'equals':
|
|
149
|
+
return IDBKeyRange.only([table, clause.value]);
|
|
150
|
+
case 'above':
|
|
151
|
+
return IDBKeyRange.bound([table, clause.value], tableUpperBound, true, false);
|
|
152
|
+
case 'aboveOrEqual':
|
|
153
|
+
return IDBKeyRange.bound([table, clause.value], tableUpperBound, false, false);
|
|
154
|
+
case 'below':
|
|
155
|
+
return IDBKeyRange.bound(tableLowerBound, [table, clause.value], false, true);
|
|
156
|
+
case 'belowOrEqual':
|
|
157
|
+
return IDBKeyRange.bound(tableLowerBound, [table, clause.value], false, false);
|
|
158
|
+
case 'between':
|
|
159
|
+
return IDBKeyRange.bound([table, clause.lower], [table, clause.upper], clause.lowerOpen ?? false, clause.upperOpen ?? false);
|
|
160
|
+
case 'startsWith': {
|
|
161
|
+
const prefix = String(clause.value ?? '');
|
|
162
|
+
return IDBKeyRange.bound([table, prefix], [table, `${prefix}\uFFFF`], false, false);
|
|
163
|
+
}
|
|
164
|
+
case 'anyOf':
|
|
165
|
+
return null;
|
|
166
|
+
default:
|
|
167
|
+
return null;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
function reconcileSchemaIndexes(rowsStore, schema) {
|
|
171
|
+
const expected = expectedSchemaIndexes(schema);
|
|
172
|
+
const existing = domStringListToArray(rowsStore.indexNames).filter(name => name.startsWith(SCHEMA_INDEX_PREFIX));
|
|
173
|
+
for (const indexName of existing) {
|
|
174
|
+
if (!expected.has(indexName)) {
|
|
175
|
+
rowsStore.deleteIndex(indexName);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
for (const [indexName, def] of expected.entries()) {
|
|
179
|
+
if (!rowsStore.indexNames.contains(indexName)) {
|
|
180
|
+
rowsStore.createIndex(indexName, def.keyPath, { unique: def.unique });
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
function openDB(dbName, dbVersion, schema) {
|
|
185
|
+
return new Promise((resolve, reject) => {
|
|
186
|
+
const req = dbVersion === undefined ? indexedDB.open(dbName) : indexedDB.open(dbName, dbVersion);
|
|
187
|
+
req.onupgradeneeded = () => {
|
|
188
|
+
const db = req.result;
|
|
189
|
+
if (!db.objectStoreNames.contains(STORES.rows)) {
|
|
190
|
+
// keyPath uses dotted path into the new namespaced row shape.
|
|
191
|
+
// IndexedDB resolves "_meta.key" against the stored object.
|
|
192
|
+
const rows = db.createObjectStore(STORES.rows, { keyPath: '_meta.key' });
|
|
193
|
+
rows.createIndex('by_table', '_meta.table', { unique: false });
|
|
194
|
+
}
|
|
195
|
+
if (!db.objectStoreNames.contains(STORES.outbox)) {
|
|
196
|
+
db.createObjectStore(STORES.outbox, { autoIncrement: true });
|
|
197
|
+
}
|
|
198
|
+
if (!db.objectStoreNames.contains(STORES.cursors)) {
|
|
199
|
+
db.createObjectStore(STORES.cursors);
|
|
200
|
+
}
|
|
201
|
+
if (!db.objectStoreNames.contains(STORES.meta)) {
|
|
202
|
+
db.createObjectStore(STORES.meta);
|
|
203
|
+
}
|
|
204
|
+
const rowsStore = req.transaction?.objectStore(STORES.rows);
|
|
205
|
+
if (rowsStore)
|
|
206
|
+
reconcileSchemaIndexes(rowsStore, schema);
|
|
207
|
+
};
|
|
208
|
+
req.onsuccess = () => resolve(req.result);
|
|
209
|
+
req.onerror = () => reject(req.error);
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
function tx(db, stores, mode) {
|
|
213
|
+
return db.transaction(stores, mode);
|
|
214
|
+
}
|
|
215
|
+
function reqToPromise(req) {
|
|
216
|
+
return new Promise((resolve, reject) => {
|
|
217
|
+
req.onsuccess = () => resolve(req.result);
|
|
218
|
+
req.onerror = () => reject(req.error);
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
function txComplete(transaction) {
|
|
222
|
+
return new Promise((resolve, reject) => {
|
|
223
|
+
transaction.oncomplete = () => resolve();
|
|
224
|
+
transaction.onerror = () => reject(transaction.error);
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
// ─── Public API ──────────────────────────────────────────────────────
|
|
228
|
+
/**
|
|
229
|
+
* Default IndexedDB-backed local persistence layer used by {@link Interocitor}.
|
|
230
|
+
*
|
|
231
|
+
* Most applications do not need to interact with this class directly unless
|
|
232
|
+
* they are supplying a custom `localStoreFactory` or swapping local storage at
|
|
233
|
+
* runtime for testing or advanced integrations.
|
|
234
|
+
*/
|
|
235
|
+
export class LocalStore {
|
|
236
|
+
/**
|
|
237
|
+
* @param dbName IndexedDB database name. Use distinct names to isolate
|
|
238
|
+
* multiple engine instances on the same origin.
|
|
239
|
+
* Default: "interocitor"
|
|
240
|
+
* @param dbVersion IndexedDB schema version. Default: 1
|
|
241
|
+
*/
|
|
242
|
+
constructor(dbName, dbVersion, schema) {
|
|
243
|
+
this.db = null;
|
|
244
|
+
this.schema = normalizeSchema(schema);
|
|
245
|
+
this.dbName = dbName ?? DEFAULT_DB_NAME;
|
|
246
|
+
this.configuredDbVersion = dbVersion;
|
|
247
|
+
this.expectedIndexes = expectedSchemaIndexes(this.schema);
|
|
248
|
+
this.desiredFingerprint = JSON.stringify(Array.from(this.expectedIndexes.entries()).map(([name, def]) => ({
|
|
249
|
+
name,
|
|
250
|
+
keyPath: def.keyPath,
|
|
251
|
+
unique: def.unique,
|
|
252
|
+
})));
|
|
253
|
+
}
|
|
254
|
+
async readCacheFingerprint(db) {
|
|
255
|
+
const t = tx(db, STORES.meta, 'readonly');
|
|
256
|
+
const value = await reqToPromise(t.objectStore(STORES.meta).get(CACHE_FINGERPRINT_META_KEY));
|
|
257
|
+
return typeof value === 'string' ? value : undefined;
|
|
258
|
+
}
|
|
259
|
+
async writeCacheFingerprint(db) {
|
|
260
|
+
const t = tx(db, STORES.meta, 'readwrite');
|
|
261
|
+
t.objectStore(STORES.meta).put(this.desiredFingerprint, CACHE_FINGERPRINT_META_KEY);
|
|
262
|
+
await txComplete(t);
|
|
263
|
+
}
|
|
264
|
+
needsRepair(db, storedFingerprint) {
|
|
265
|
+
if (!db.objectStoreNames.contains(STORES.rows))
|
|
266
|
+
return true;
|
|
267
|
+
const rows = tx(db, STORES.rows, 'readonly').objectStore(STORES.rows);
|
|
268
|
+
const existing = new Set(domStringListToArray(rows.indexNames).filter(name => name.startsWith(SCHEMA_INDEX_PREFIX)));
|
|
269
|
+
if (storedFingerprint !== this.desiredFingerprint)
|
|
270
|
+
return true;
|
|
271
|
+
if (existing.size !== this.expectedIndexes.size)
|
|
272
|
+
return true;
|
|
273
|
+
for (const name of this.expectedIndexes.keys()) {
|
|
274
|
+
if (!existing.has(name))
|
|
275
|
+
return true;
|
|
276
|
+
}
|
|
277
|
+
return false;
|
|
278
|
+
}
|
|
279
|
+
async open() {
|
|
280
|
+
const requestedVersion = this.configuredDbVersion ?? DEFAULT_DB_VERSION;
|
|
281
|
+
let db = await openDB(this.dbName, undefined, this.schema);
|
|
282
|
+
const reopenAt = async (nextVersion) => {
|
|
283
|
+
db.close();
|
|
284
|
+
return openDB(this.dbName, nextVersion, this.schema);
|
|
285
|
+
};
|
|
286
|
+
if (db.version < requestedVersion) {
|
|
287
|
+
db = await reopenAt(requestedVersion);
|
|
288
|
+
}
|
|
289
|
+
let storedFingerprint = await this.readCacheFingerprint(db);
|
|
290
|
+
const repairVersion = this.needsRepair(db, storedFingerprint)
|
|
291
|
+
? Math.max(db.version + 1, requestedVersion)
|
|
292
|
+
: null;
|
|
293
|
+
if (repairVersion !== null) {
|
|
294
|
+
db = await reopenAt(repairVersion);
|
|
295
|
+
storedFingerprint = await this.readCacheFingerprint(db);
|
|
296
|
+
}
|
|
297
|
+
if (storedFingerprint !== this.desiredFingerprint) {
|
|
298
|
+
await this.writeCacheFingerprint(db);
|
|
299
|
+
}
|
|
300
|
+
this.db = db;
|
|
301
|
+
}
|
|
302
|
+
close() {
|
|
303
|
+
this.db?.close();
|
|
304
|
+
this.db = null;
|
|
305
|
+
}
|
|
306
|
+
ensureDB() {
|
|
307
|
+
if (!this.db)
|
|
308
|
+
throw new Error('LocalStore not opened');
|
|
309
|
+
return this.db;
|
|
310
|
+
}
|
|
311
|
+
// ── Rows ─────────────────────────────────────────────────────────
|
|
312
|
+
rowKey(table, rowId) {
|
|
313
|
+
return `${table}/${rowId}`;
|
|
314
|
+
}
|
|
315
|
+
async getRow(table, rowId) {
|
|
316
|
+
const db = this.ensureDB();
|
|
317
|
+
const t = tx(db, STORES.rows, 'readonly');
|
|
318
|
+
const store = t.objectStore(STORES.rows);
|
|
319
|
+
const result = await reqToPromise(store.get(this.rowKey(table, rowId)));
|
|
320
|
+
return result;
|
|
321
|
+
}
|
|
322
|
+
/** Stamp the composite IndexedDB key into row._meta.key. Pure. */
|
|
323
|
+
withKey(row) {
|
|
324
|
+
return {
|
|
325
|
+
...row,
|
|
326
|
+
_meta: { ...row._meta, key: this.rowKey(row._meta.table, row._meta.rowId) },
|
|
327
|
+
payload: row.payload,
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
async putRow(row) {
|
|
331
|
+
const db = this.ensureDB();
|
|
332
|
+
const t = tx(db, STORES.rows, 'readwrite');
|
|
333
|
+
const store = t.objectStore(STORES.rows);
|
|
334
|
+
store.put(this.withKey(row));
|
|
335
|
+
await txComplete(t);
|
|
336
|
+
}
|
|
337
|
+
async putRows(rows) {
|
|
338
|
+
if (rows.length === 0)
|
|
339
|
+
return;
|
|
340
|
+
const db = this.ensureDB();
|
|
341
|
+
const t = tx(db, STORES.rows, 'readwrite');
|
|
342
|
+
const store = t.objectStore(STORES.rows);
|
|
343
|
+
for (const row of rows) {
|
|
344
|
+
store.put(this.withKey(row));
|
|
345
|
+
}
|
|
346
|
+
await txComplete(t);
|
|
347
|
+
}
|
|
348
|
+
async getTable(table) {
|
|
349
|
+
const db = this.ensureDB();
|
|
350
|
+
const t = tx(db, STORES.rows, 'readonly');
|
|
351
|
+
const store = t.objectStore(STORES.rows);
|
|
352
|
+
const index = store.index('by_table');
|
|
353
|
+
const results = await reqToPromise(index.getAll(table));
|
|
354
|
+
return results.filter(r => !r._meta.deleted);
|
|
355
|
+
}
|
|
356
|
+
async queryWhere(table, clause) {
|
|
357
|
+
const db = this.ensureDB();
|
|
358
|
+
const indexDef = hasSchemaIndex(this.schema, table, clause.field);
|
|
359
|
+
if (!indexDef) {
|
|
360
|
+
const rows = await this.getTable(table);
|
|
361
|
+
return rows.filter(row => matchesClause(readColumnValue(row, clause.field), clause));
|
|
362
|
+
}
|
|
363
|
+
const t = tx(db, STORES.rows, 'readonly');
|
|
364
|
+
const store = t.objectStore(STORES.rows);
|
|
365
|
+
const indexName = schemaIndexName(table, indexDef.name);
|
|
366
|
+
if (!store.indexNames.contains(indexName)) {
|
|
367
|
+
const rows = await this.getTable(table);
|
|
368
|
+
return rows.filter(row => matchesClause(readColumnValue(row, clause.field), clause));
|
|
369
|
+
}
|
|
370
|
+
const index = store.index(indexName);
|
|
371
|
+
if (clause.op === 'anyOf') {
|
|
372
|
+
const values = clause.values ?? [];
|
|
373
|
+
const merged = new Map();
|
|
374
|
+
for (const value of values) {
|
|
375
|
+
const matches = await reqToPromise(index.getAll(IDBKeyRange.only([table, value])));
|
|
376
|
+
for (const row of matches) {
|
|
377
|
+
if (!row._meta.deleted && row._meta.table === table) {
|
|
378
|
+
merged.set(`${row._meta.table}/${row._meta.rowId}`, row);
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
return Array.from(merged.values());
|
|
383
|
+
}
|
|
384
|
+
const range = rangeForClause(table, clause);
|
|
385
|
+
const results = await reqToPromise(index.getAll(range ?? undefined));
|
|
386
|
+
return results.filter(row => !row._meta.deleted && row._meta.table === table);
|
|
387
|
+
}
|
|
388
|
+
async getTableNames() {
|
|
389
|
+
const db = this.ensureDB();
|
|
390
|
+
// Intentionally avoids openKeyCursor: Safari rejects null as a key range
|
|
391
|
+
// argument in some IDB versions. A full-store getAll() is safe everywhere
|
|
392
|
+
// and acceptable here — called once at init on an otherwise-empty DB.
|
|
393
|
+
const t = tx(db, STORES.rows, 'readonly');
|
|
394
|
+
const store = t.objectStore(STORES.rows);
|
|
395
|
+
const all = await reqToPromise(store.getAll());
|
|
396
|
+
const names = new Set();
|
|
397
|
+
for (const row of all) {
|
|
398
|
+
const table = row._meta?.table;
|
|
399
|
+
if (table)
|
|
400
|
+
names.add(table);
|
|
401
|
+
}
|
|
402
|
+
return Array.from(names);
|
|
403
|
+
}
|
|
404
|
+
async getAllRows() {
|
|
405
|
+
const db = this.ensureDB();
|
|
406
|
+
const t = tx(db, STORES.rows, 'readonly');
|
|
407
|
+
const store = t.objectStore(STORES.rows);
|
|
408
|
+
return reqToPromise(store.getAll());
|
|
409
|
+
}
|
|
410
|
+
async clearRows() {
|
|
411
|
+
const db = this.ensureDB();
|
|
412
|
+
const t = tx(db, STORES.rows, 'readwrite');
|
|
413
|
+
t.objectStore(STORES.rows).clear();
|
|
414
|
+
await txComplete(t);
|
|
415
|
+
}
|
|
416
|
+
// ── Outbox ───────────────────────────────────────────────────────
|
|
417
|
+
async pushOutbox(entry) {
|
|
418
|
+
await this.pushOutboxEntries([entry]);
|
|
419
|
+
}
|
|
420
|
+
async pushOutboxEntries(entries) {
|
|
421
|
+
if (entries.length === 0)
|
|
422
|
+
return;
|
|
423
|
+
const db = this.ensureDB();
|
|
424
|
+
const t = tx(db, STORES.outbox, 'readwrite');
|
|
425
|
+
const store = t.objectStore(STORES.outbox);
|
|
426
|
+
for (const entry of entries)
|
|
427
|
+
store.add(entry);
|
|
428
|
+
await txComplete(t);
|
|
429
|
+
}
|
|
430
|
+
async drainOutbox() {
|
|
431
|
+
const db = this.ensureDB();
|
|
432
|
+
const t = tx(db, STORES.outbox, 'readwrite');
|
|
433
|
+
const store = t.objectStore(STORES.outbox);
|
|
434
|
+
const entries = await reqToPromise(store.getAll());
|
|
435
|
+
store.clear();
|
|
436
|
+
await txComplete(t);
|
|
437
|
+
return entries;
|
|
438
|
+
}
|
|
439
|
+
async outboxSize() {
|
|
440
|
+
const db = this.ensureDB();
|
|
441
|
+
const t = tx(db, STORES.outbox, 'readonly');
|
|
442
|
+
return reqToPromise(t.objectStore(STORES.outbox).count());
|
|
443
|
+
}
|
|
444
|
+
// ── Cursors ──────────────────────────────────────────────────────
|
|
445
|
+
async getCursor(deviceId) {
|
|
446
|
+
const db = this.ensureDB();
|
|
447
|
+
const t = tx(db, STORES.cursors, 'readonly');
|
|
448
|
+
const result = await reqToPromise(t.objectStore(STORES.cursors).get(deviceId));
|
|
449
|
+
return result || 0;
|
|
450
|
+
}
|
|
451
|
+
async setCursor(deviceId, offset) {
|
|
452
|
+
const db = this.ensureDB();
|
|
453
|
+
const t = tx(db, STORES.cursors, 'readwrite');
|
|
454
|
+
t.objectStore(STORES.cursors).put(offset, deviceId);
|
|
455
|
+
await txComplete(t);
|
|
456
|
+
}
|
|
457
|
+
async getAllCursors() {
|
|
458
|
+
const db = this.ensureDB();
|
|
459
|
+
const t = tx(db, STORES.cursors, 'readonly');
|
|
460
|
+
const store = t.objectStore(STORES.cursors);
|
|
461
|
+
const keys = await reqToPromise(store.getAllKeys());
|
|
462
|
+
const values = await reqToPromise(store.getAll());
|
|
463
|
+
const cursors = {};
|
|
464
|
+
for (let i = 0; i < keys.length; i++) {
|
|
465
|
+
cursors[keys[i]] = values[i];
|
|
466
|
+
}
|
|
467
|
+
return cursors;
|
|
468
|
+
}
|
|
469
|
+
// ── Meta ─────────────────────────────────────────────────────────
|
|
470
|
+
async getMeta(key) {
|
|
471
|
+
const db = this.ensureDB();
|
|
472
|
+
const t = tx(db, STORES.meta, 'readonly');
|
|
473
|
+
return reqToPromise(t.objectStore(STORES.meta).get(key));
|
|
474
|
+
}
|
|
475
|
+
async setMeta(key, value) {
|
|
476
|
+
const db = this.ensureDB();
|
|
477
|
+
const t = tx(db, STORES.meta, 'readwrite');
|
|
478
|
+
t.objectStore(STORES.meta).put(value, key);
|
|
479
|
+
await txComplete(t);
|
|
480
|
+
}
|
|
481
|
+
/** Nuke everything. Used before full rehydration. */
|
|
482
|
+
async clearAll() {
|
|
483
|
+
const db = this.ensureDB();
|
|
484
|
+
const t = tx(db, Object.values(STORES), 'readwrite');
|
|
485
|
+
for (const name of Object.values(STORES)) {
|
|
486
|
+
t.objectStore(name).clear();
|
|
487
|
+
}
|
|
488
|
+
await txComplete(t);
|
|
489
|
+
}
|
|
490
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Delete an Interocitor IndexedDB database after callers have disconnected
|
|
3
|
+
* and cleared credentials.
|
|
4
|
+
*
|
|
5
|
+
* This is intentionally low-level: it only deletes the local IndexedDB
|
|
6
|
+
* database named by `dbName`. Apps should call it as the final destructive
|
|
7
|
+
* local reset step, then reload before creating or joining a new mesh.
|
|
8
|
+
*/
|
|
9
|
+
export declare function resetLocalDatabase(dbName?: string): Promise<void>;
|
|
10
|
+
//# sourceMappingURL=reset.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"reset.d.ts","sourceRoot":"","sources":["../../src/storage/reset.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AACH,wBAAgB,kBAAkB,CAAC,MAAM,SAAgB,GAAG,OAAO,CAAC,IAAI,CAAC,CASxE"}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Delete an Interocitor IndexedDB database after callers have disconnected
|
|
3
|
+
* and cleared credentials.
|
|
4
|
+
*
|
|
5
|
+
* This is intentionally low-level: it only deletes the local IndexedDB
|
|
6
|
+
* database named by `dbName`. Apps should call it as the final destructive
|
|
7
|
+
* local reset step, then reload before creating or joining a new mesh.
|
|
8
|
+
*/
|
|
9
|
+
export function resetLocalDatabase(dbName = 'interocitor') {
|
|
10
|
+
return new Promise((resolve, reject) => {
|
|
11
|
+
const req = indexedDB.deleteDatabase(dbName);
|
|
12
|
+
req.onsuccess = () => resolve();
|
|
13
|
+
req.onerror = () => reject(req.error ?? new Error(`Failed to delete IndexedDB database "${dbName}"`));
|
|
14
|
+
req.onblocked = () => {
|
|
15
|
+
reject(new Error(`Cannot delete IndexedDB database "${dbName}" while another connection is open`));
|
|
16
|
+
};
|
|
17
|
+
});
|
|
18
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@interocitor/core",
|
|
3
|
+
"version": "0.0.0-beta.10",
|
|
4
|
+
"description": "Encrypted local-first CRDT database that syncs over cloud storage without a purpose-built backend.",
|
|
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
|
+
"./adapters/google-drive": {
|
|
14
|
+
"import": "./dist/adapters/google-drive.js",
|
|
15
|
+
"types": "./dist/adapters/google-drive.d.ts"
|
|
16
|
+
},
|
|
17
|
+
"./adapters/webdav": {
|
|
18
|
+
"import": "./dist/adapters/webdav.js",
|
|
19
|
+
"types": "./dist/adapters/webdav.d.ts"
|
|
20
|
+
},
|
|
21
|
+
"./adapters/cloudflare": {
|
|
22
|
+
"import": "./dist/adapters/cloudflare.js",
|
|
23
|
+
"types": "./dist/adapters/cloudflare.d.ts"
|
|
24
|
+
},
|
|
25
|
+
"./adapters/memory": {
|
|
26
|
+
"import": "./dist/adapters/memory.js",
|
|
27
|
+
"types": "./dist/adapters/memory.d.ts"
|
|
28
|
+
},
|
|
29
|
+
"./storage/local-store": {
|
|
30
|
+
"import": "./dist/storage/local-store.js",
|
|
31
|
+
"types": "./dist/storage/local-store.d.ts"
|
|
32
|
+
},
|
|
33
|
+
"./storage/reset": {
|
|
34
|
+
"import": "./dist/storage/reset.js",
|
|
35
|
+
"types": "./dist/storage/reset.d.ts"
|
|
36
|
+
},
|
|
37
|
+
"./handshake/qr": {
|
|
38
|
+
"import": "./dist/handshake/qr-public.js",
|
|
39
|
+
"types": "./dist/handshake/qr-public.d.ts"
|
|
40
|
+
},
|
|
41
|
+
"./crypto/keys": {
|
|
42
|
+
"import": "./dist/crypto/keys.js",
|
|
43
|
+
"types": "./dist/crypto/keys.d.ts"
|
|
44
|
+
}
|
|
45
|
+
},
|
|
46
|
+
"files": [
|
|
47
|
+
"dist",
|
|
48
|
+
"README.md",
|
|
49
|
+
"LICENSE"
|
|
50
|
+
],
|
|
51
|
+
"scripts": {
|
|
52
|
+
"build": "rm -rf ./dist && tsc -p tsconfig.json",
|
|
53
|
+
"validate": "node ./dist/index.js",
|
|
54
|
+
"test:e2e": "yarn build && yarn exec playwright test -c ./playwright.config.ts",
|
|
55
|
+
"test:e2e:todo": "yarn build && yarn exec playwright test -c ./playwright.config.ts tests/e2e/todo-webdav.spec.ts"
|
|
56
|
+
},
|
|
57
|
+
"keywords": [
|
|
58
|
+
"local-first",
|
|
59
|
+
"crdt",
|
|
60
|
+
"sync",
|
|
61
|
+
"offline-first",
|
|
62
|
+
"google-drive",
|
|
63
|
+
"webdav",
|
|
64
|
+
"encrypted",
|
|
65
|
+
"mesh",
|
|
66
|
+
"indexeddb"
|
|
67
|
+
],
|
|
68
|
+
"license": "MIT",
|
|
69
|
+
"devDependencies": {
|
|
70
|
+
"@playwright/test": "^1.54.2",
|
|
71
|
+
"typescript": "^6.0.0"
|
|
72
|
+
},
|
|
73
|
+
"engines": {
|
|
74
|
+
"node": ">=18.0.0"
|
|
75
|
+
}
|
|
76
|
+
}
|