@steve31415/baselib 2.0.0 → 2.2.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 +13 -2
- package/dist/app-update/index.d.ts +2 -0
- package/dist/app-update/index.js +2 -0
- package/dist/app-update/retained-assets.d.ts +21 -0
- package/dist/app-update/retained-assets.js +51 -0
- package/dist/app-update/shell.d.ts +16 -0
- package/dist/app-update/shell.js +35 -0
- package/dist/app-update-browser/build-update.d.ts +52 -0
- package/dist/app-update-browser/build-update.js +161 -0
- package/dist/app-update-browser/index.d.ts +2 -0
- package/dist/app-update-browser/index.js +2 -0
- package/dist/app-update-browser/reload-safety.d.ts +8 -0
- package/dist/app-update-browser/reload-safety.js +23 -0
- package/dist/log-browser.js +3 -1
- package/dist/rum.d.ts +2 -0
- package/dist/rum.js +2 -0
- package/dist/sync/broadcaster.d.ts +9 -0
- package/dist/sync/broadcaster.js +47 -0
- package/dist/sync/engine.d.ts +55 -0
- package/dist/sync/engine.js +197 -0
- package/dist/sync/index.d.ts +5 -0
- package/dist/sync/index.js +5 -0
- package/dist/sync/schema.d.ts +13 -0
- package/dist/sync/schema.js +24 -0
- package/dist/sync/session.d.ts +29 -0
- package/dist/sync/session.js +189 -0
- package/dist/sync/types.d.ts +79 -0
- package/dist/sync/types.js +8 -0
- package/dist/sync-browser/client.d.ts +187 -0
- package/dist/sync-browser/client.js +832 -0
- package/dist/sync-browser/index.d.ts +2 -0
- package/dist/sync-browser/index.js +2 -0
- package/dist/sync-browser/outbox.d.ts +47 -0
- package/dist/sync-browser/outbox.js +317 -0
- package/package.json +20 -3
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
interface OutboxRecord {
|
|
2
|
+
event: {
|
|
3
|
+
eventGuid: string;
|
|
4
|
+
};
|
|
5
|
+
summary: unknown;
|
|
6
|
+
createdAt: number;
|
|
7
|
+
ordinal?: number;
|
|
8
|
+
}
|
|
9
|
+
export interface IndexedDbOutboxOptions {
|
|
10
|
+
databaseName: string;
|
|
11
|
+
storeName: string;
|
|
12
|
+
version: number;
|
|
13
|
+
/** Defaults to the browser global; injectable for isolation and testing. */
|
|
14
|
+
indexedDB?: IDBFactory;
|
|
15
|
+
onDurabilityChange(durable: false, error: unknown): void;
|
|
16
|
+
}
|
|
17
|
+
/** A durable browser outbox with an explicit, observable memory fallback. */
|
|
18
|
+
export declare class IndexedDbOutbox<T extends OutboxRecord> {
|
|
19
|
+
private readonly options;
|
|
20
|
+
private readonly memory;
|
|
21
|
+
private database;
|
|
22
|
+
private initialization;
|
|
23
|
+
private mutationTail;
|
|
24
|
+
private memoryOnly;
|
|
25
|
+
private nextMemoryOrdinal;
|
|
26
|
+
constructor(options: IndexedDbOutboxOptions);
|
|
27
|
+
init(): Promise<T[]>;
|
|
28
|
+
private initialize;
|
|
29
|
+
add(entry: T): Promise<void>;
|
|
30
|
+
private addInternal;
|
|
31
|
+
remove(eventGuid: string): Promise<void>;
|
|
32
|
+
private removeInternal;
|
|
33
|
+
get(eventGuid: string): Promise<T | undefined>;
|
|
34
|
+
all(): Promise<T[]>;
|
|
35
|
+
count(): Promise<number>;
|
|
36
|
+
private enqueueMutation;
|
|
37
|
+
private snapshot;
|
|
38
|
+
private loadAndBackfill;
|
|
39
|
+
private addWithOrdinal;
|
|
40
|
+
private nextOrdinal;
|
|
41
|
+
private highestOrdinal;
|
|
42
|
+
private withMemoryOrdinal;
|
|
43
|
+
private open;
|
|
44
|
+
private fallback;
|
|
45
|
+
private persisted;
|
|
46
|
+
}
|
|
47
|
+
export {};
|
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
function transaction(database, storeName, mode, operation) {
|
|
2
|
+
return new Promise((resolve, reject) => {
|
|
3
|
+
let request;
|
|
4
|
+
let result;
|
|
5
|
+
try {
|
|
6
|
+
const tx = database.transaction(storeName, mode);
|
|
7
|
+
request = operation(tx.objectStore(storeName));
|
|
8
|
+
request.onsuccess = () => {
|
|
9
|
+
result = request.result;
|
|
10
|
+
};
|
|
11
|
+
request.onerror = () => reject(request.error ?? new Error('IndexedDB request failed'));
|
|
12
|
+
tx.oncomplete = () => resolve(result);
|
|
13
|
+
tx.onerror = () => reject(tx.error ?? request.error ?? new Error('IndexedDB transaction failed'));
|
|
14
|
+
tx.onabort = () => reject(tx.error ?? request.error ?? new Error('IndexedDB transaction aborted'));
|
|
15
|
+
}
|
|
16
|
+
catch (error) {
|
|
17
|
+
reject(error);
|
|
18
|
+
}
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
function hasOrdinal(record) {
|
|
22
|
+
const ordinal = record.ordinal;
|
|
23
|
+
return typeof ordinal === 'number' && Number.isSafeInteger(ordinal) && ordinal > 0;
|
|
24
|
+
}
|
|
25
|
+
function compareGuids(left, right) {
|
|
26
|
+
if (left.event.eventGuid < right.event.eventGuid)
|
|
27
|
+
return -1;
|
|
28
|
+
if (left.event.eventGuid > right.event.eventGuid)
|
|
29
|
+
return 1;
|
|
30
|
+
return 0;
|
|
31
|
+
}
|
|
32
|
+
function compareLegacy(left, right) {
|
|
33
|
+
const leftCreatedAt = Number.isFinite(left.createdAt) ? left.createdAt : 0;
|
|
34
|
+
const rightCreatedAt = Number.isFinite(right.createdAt) ? right.createdAt : 0;
|
|
35
|
+
return leftCreatedAt - rightCreatedAt || compareGuids(left, right);
|
|
36
|
+
}
|
|
37
|
+
function compareOrdered(left, right) {
|
|
38
|
+
if (hasOrdinal(left) && hasOrdinal(right))
|
|
39
|
+
return left.ordinal - right.ordinal || compareGuids(left, right);
|
|
40
|
+
if (hasOrdinal(left))
|
|
41
|
+
return -1;
|
|
42
|
+
if (hasOrdinal(right))
|
|
43
|
+
return 1;
|
|
44
|
+
return compareLegacy(left, right);
|
|
45
|
+
}
|
|
46
|
+
class OutboxOrdinalCapacityError extends Error {
|
|
47
|
+
name = 'OutboxOrdinalCapacityError';
|
|
48
|
+
constructor() {
|
|
49
|
+
super('Outbox ordinal space exhausted');
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
/** A durable browser outbox with an explicit, observable memory fallback. */
|
|
53
|
+
export class IndexedDbOutbox {
|
|
54
|
+
options;
|
|
55
|
+
memory = new Map();
|
|
56
|
+
database = null;
|
|
57
|
+
initialization = null;
|
|
58
|
+
mutationTail = Promise.resolve();
|
|
59
|
+
memoryOnly = false;
|
|
60
|
+
nextMemoryOrdinal = 1;
|
|
61
|
+
constructor(options) {
|
|
62
|
+
this.options = options;
|
|
63
|
+
}
|
|
64
|
+
init() {
|
|
65
|
+
this.initialization ??= this.initialize();
|
|
66
|
+
return this.initialization;
|
|
67
|
+
}
|
|
68
|
+
async initialize() {
|
|
69
|
+
let entries = [];
|
|
70
|
+
try {
|
|
71
|
+
this.database = await this.open();
|
|
72
|
+
entries = await this.loadAndBackfill(this.database, (recovered) => {
|
|
73
|
+
entries = recovered;
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
catch (error) {
|
|
77
|
+
this.fallback(error);
|
|
78
|
+
}
|
|
79
|
+
this.memory.clear();
|
|
80
|
+
for (const entry of entries)
|
|
81
|
+
this.memory.set(entry.event.eventGuid, entry);
|
|
82
|
+
const highest = this.highestOrdinal(entries);
|
|
83
|
+
this.nextMemoryOrdinal = highest >= Number.MAX_SAFE_INTEGER ? null : highest + 1;
|
|
84
|
+
return this.snapshot();
|
|
85
|
+
}
|
|
86
|
+
add(entry) {
|
|
87
|
+
return this.enqueueMutation(() => this.addInternal(entry));
|
|
88
|
+
}
|
|
89
|
+
async addInternal(entry) {
|
|
90
|
+
await this.init();
|
|
91
|
+
let persisted;
|
|
92
|
+
if (this.database && !this.memoryOnly) {
|
|
93
|
+
try {
|
|
94
|
+
const durable = await this.addWithOrdinal(this.database, entry);
|
|
95
|
+
persisted = durable;
|
|
96
|
+
if (durable.ordinal >= Number.MAX_SAFE_INTEGER) {
|
|
97
|
+
this.nextMemoryOrdinal = null;
|
|
98
|
+
}
|
|
99
|
+
else if (this.nextMemoryOrdinal !== null) {
|
|
100
|
+
this.nextMemoryOrdinal = Math.max(this.nextMemoryOrdinal, durable.ordinal + 1);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
catch (error) {
|
|
104
|
+
if (error instanceof OutboxOrdinalCapacityError)
|
|
105
|
+
throw error;
|
|
106
|
+
this.fallback(error);
|
|
107
|
+
persisted = this.withMemoryOrdinal(entry);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
else {
|
|
111
|
+
persisted = this.withMemoryOrdinal(entry);
|
|
112
|
+
}
|
|
113
|
+
this.memory.set(persisted.event.eventGuid, persisted);
|
|
114
|
+
}
|
|
115
|
+
remove(eventGuid) {
|
|
116
|
+
return this.enqueueMutation(() => this.removeInternal(eventGuid));
|
|
117
|
+
}
|
|
118
|
+
async removeInternal(eventGuid) {
|
|
119
|
+
await this.init();
|
|
120
|
+
if (this.database && !this.memoryOnly) {
|
|
121
|
+
try {
|
|
122
|
+
await transaction(this.database, this.options.storeName, 'readwrite', (store) => store.delete(eventGuid));
|
|
123
|
+
}
|
|
124
|
+
catch (error) {
|
|
125
|
+
this.fallback(error);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
this.memory.delete(eventGuid);
|
|
129
|
+
}
|
|
130
|
+
async get(eventGuid) {
|
|
131
|
+
await this.init();
|
|
132
|
+
await this.mutationTail;
|
|
133
|
+
return this.memory.get(eventGuid);
|
|
134
|
+
}
|
|
135
|
+
async all() {
|
|
136
|
+
await this.init();
|
|
137
|
+
await this.mutationTail;
|
|
138
|
+
return this.snapshot();
|
|
139
|
+
}
|
|
140
|
+
async count() {
|
|
141
|
+
await this.init();
|
|
142
|
+
await this.mutationTail;
|
|
143
|
+
return this.memory.size;
|
|
144
|
+
}
|
|
145
|
+
enqueueMutation(operation) {
|
|
146
|
+
const result = this.mutationTail.then(operation);
|
|
147
|
+
this.mutationTail = result.catch(() => undefined);
|
|
148
|
+
return result;
|
|
149
|
+
}
|
|
150
|
+
snapshot() {
|
|
151
|
+
return [...this.memory.values()].sort(compareOrdered);
|
|
152
|
+
}
|
|
153
|
+
loadAndBackfill(database, onRead) {
|
|
154
|
+
// Record-only migration: the existing keyPath store provides the readwrite lock, so no DB version bump is needed.
|
|
155
|
+
return new Promise((resolve, reject) => {
|
|
156
|
+
let entries = [];
|
|
157
|
+
let request;
|
|
158
|
+
try {
|
|
159
|
+
const tx = database.transaction(this.options.storeName, 'readwrite');
|
|
160
|
+
const store = tx.objectStore(this.options.storeName);
|
|
161
|
+
request = store.getAll();
|
|
162
|
+
request.onsuccess = () => {
|
|
163
|
+
const records = request.result.map((entry) => this.persisted(entry));
|
|
164
|
+
const legacy = records.filter((entry) => !hasOrdinal(entry)).sort(compareLegacy);
|
|
165
|
+
try {
|
|
166
|
+
if (legacy.length > 0) {
|
|
167
|
+
const existingOrdinals = records.flatMap((entry) => (hasOrdinal(entry) ? [entry.ordinal] : []));
|
|
168
|
+
const firstOrdinal = existingOrdinals.length > 0 ? this.nextOrdinal(records, legacy.length) : 1;
|
|
169
|
+
legacy.forEach((entry, index) => {
|
|
170
|
+
entry.ordinal = firstOrdinal + index;
|
|
171
|
+
store.put(entry);
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
entries = records.sort(compareOrdered);
|
|
175
|
+
onRead(entries);
|
|
176
|
+
}
|
|
177
|
+
catch (error) {
|
|
178
|
+
entries = records.sort(compareOrdered);
|
|
179
|
+
onRead(entries);
|
|
180
|
+
reject(error);
|
|
181
|
+
try {
|
|
182
|
+
tx.abort();
|
|
183
|
+
}
|
|
184
|
+
catch {
|
|
185
|
+
// Preserve the allocation error if IndexedDB has already ended the transaction.
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
};
|
|
189
|
+
request.onerror = () => reject(request.error ?? new Error('IndexedDB request failed'));
|
|
190
|
+
tx.oncomplete = () => resolve(entries);
|
|
191
|
+
tx.onerror = () => reject(tx.error ?? request.error ?? new Error('IndexedDB transaction failed'));
|
|
192
|
+
tx.onabort = () => reject(tx.error ?? request.error ?? new Error('IndexedDB transaction aborted'));
|
|
193
|
+
}
|
|
194
|
+
catch (error) {
|
|
195
|
+
reject(error);
|
|
196
|
+
}
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
addWithOrdinal(database, entry) {
|
|
200
|
+
return new Promise((resolve, reject) => {
|
|
201
|
+
let recordsRequest;
|
|
202
|
+
let putRequest;
|
|
203
|
+
let persisted;
|
|
204
|
+
try {
|
|
205
|
+
const tx = database.transaction(this.options.storeName, 'readwrite');
|
|
206
|
+
const store = tx.objectStore(this.options.storeName);
|
|
207
|
+
recordsRequest = store.getAll();
|
|
208
|
+
recordsRequest.onsuccess = () => {
|
|
209
|
+
try {
|
|
210
|
+
const records = recordsRequest.result.map((record) => this.persisted(record));
|
|
211
|
+
const legacy = records.filter((record) => !hasOrdinal(record)).sort(compareLegacy);
|
|
212
|
+
const existing = records.find((record) => record.event.eventGuid === entry.event.eventGuid);
|
|
213
|
+
const requiredOrdinals = legacy.length + (existing === undefined ? 1 : 0);
|
|
214
|
+
const firstOrdinal = requiredOrdinals > 0 ? this.nextOrdinal(records, requiredOrdinals) : null;
|
|
215
|
+
const migratedOrdinals = new Map();
|
|
216
|
+
legacy.forEach((record, index) => {
|
|
217
|
+
if (firstOrdinal === null)
|
|
218
|
+
throw new Error('Outbox ordinal allocation invariant failed');
|
|
219
|
+
const ordinal = firstOrdinal + index;
|
|
220
|
+
migratedOrdinals.set(record.event.eventGuid, ordinal);
|
|
221
|
+
store.put(this.persisted(record, ordinal));
|
|
222
|
+
});
|
|
223
|
+
const migratedOrdinal = migratedOrdinals.get(entry.event.eventGuid);
|
|
224
|
+
let ordinal;
|
|
225
|
+
if (existing && hasOrdinal(existing)) {
|
|
226
|
+
ordinal = existing.ordinal;
|
|
227
|
+
}
|
|
228
|
+
else if (migratedOrdinal !== undefined) {
|
|
229
|
+
ordinal = migratedOrdinal;
|
|
230
|
+
}
|
|
231
|
+
else if (firstOrdinal !== null) {
|
|
232
|
+
ordinal = firstOrdinal + legacy.length;
|
|
233
|
+
}
|
|
234
|
+
else {
|
|
235
|
+
throw new Error('Outbox ordinal allocation invariant failed');
|
|
236
|
+
}
|
|
237
|
+
persisted = this.persisted(entry, ordinal);
|
|
238
|
+
putRequest = store.put(persisted);
|
|
239
|
+
putRequest.onerror = () => reject(putRequest.error ?? new Error('IndexedDB request failed'));
|
|
240
|
+
}
|
|
241
|
+
catch (error) {
|
|
242
|
+
reject(error);
|
|
243
|
+
try {
|
|
244
|
+
tx.abort();
|
|
245
|
+
}
|
|
246
|
+
catch {
|
|
247
|
+
// Preserve the allocation error if IndexedDB has already ended the transaction.
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
};
|
|
251
|
+
recordsRequest.onerror = () => reject(recordsRequest.error ?? new Error('IndexedDB request failed'));
|
|
252
|
+
tx.oncomplete = () => resolve(persisted);
|
|
253
|
+
tx.onerror = () => reject(tx.error ?? putRequest?.error ?? recordsRequest.error ?? new Error('IndexedDB transaction failed'));
|
|
254
|
+
tx.onabort = () => reject(tx.error ?? putRequest?.error ?? recordsRequest.error ?? new Error('IndexedDB transaction aborted'));
|
|
255
|
+
}
|
|
256
|
+
catch (error) {
|
|
257
|
+
reject(error);
|
|
258
|
+
}
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
nextOrdinal(entries, count = 1) {
|
|
262
|
+
const highest = this.highestOrdinal(entries);
|
|
263
|
+
if (count > Number.MAX_SAFE_INTEGER - highest)
|
|
264
|
+
throw new OutboxOrdinalCapacityError();
|
|
265
|
+
return highest + 1;
|
|
266
|
+
}
|
|
267
|
+
highestOrdinal(entries) {
|
|
268
|
+
return entries.reduce((maximum, entry) => (hasOrdinal(entry) ? Math.max(maximum, entry.ordinal) : maximum), 0);
|
|
269
|
+
}
|
|
270
|
+
withMemoryOrdinal(entry) {
|
|
271
|
+
const existing = this.memory.get(entry.event.eventGuid);
|
|
272
|
+
if (existing && hasOrdinal(existing))
|
|
273
|
+
return this.persisted(entry, existing.ordinal);
|
|
274
|
+
if (this.nextMemoryOrdinal === null)
|
|
275
|
+
throw new OutboxOrdinalCapacityError();
|
|
276
|
+
const ordinal = this.nextMemoryOrdinal;
|
|
277
|
+
this.nextMemoryOrdinal = ordinal >= Number.MAX_SAFE_INTEGER ? null : ordinal + 1;
|
|
278
|
+
return this.persisted(entry, ordinal);
|
|
279
|
+
}
|
|
280
|
+
open() {
|
|
281
|
+
return new Promise((resolve, reject) => {
|
|
282
|
+
try {
|
|
283
|
+
const factory = this.options.indexedDB ?? globalThis.indexedDB;
|
|
284
|
+
if (!factory)
|
|
285
|
+
throw new Error('IndexedDB is unavailable');
|
|
286
|
+
const request = factory.open(this.options.databaseName, this.options.version);
|
|
287
|
+
request.onupgradeneeded = () => {
|
|
288
|
+
if (!request.result.objectStoreNames.contains(this.options.storeName)) {
|
|
289
|
+
request.result.createObjectStore(this.options.storeName, { keyPath: 'event.eventGuid' });
|
|
290
|
+
}
|
|
291
|
+
};
|
|
292
|
+
request.onsuccess = () => resolve(request.result);
|
|
293
|
+
request.onerror = () => reject(request.error ?? new Error('IndexedDB open failed'));
|
|
294
|
+
request.onblocked = () => reject(new Error('IndexedDB open blocked'));
|
|
295
|
+
}
|
|
296
|
+
catch (error) {
|
|
297
|
+
reject(error);
|
|
298
|
+
}
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
fallback(error) {
|
|
302
|
+
if (this.memoryOnly)
|
|
303
|
+
return;
|
|
304
|
+
this.memoryOnly = true;
|
|
305
|
+
this.database?.close();
|
|
306
|
+
this.database = null;
|
|
307
|
+
this.options.onDurabilityChange(false, error);
|
|
308
|
+
}
|
|
309
|
+
persisted(entry, ordinal = hasOrdinal(entry) ? entry.ordinal : undefined) {
|
|
310
|
+
return {
|
|
311
|
+
event: entry.event,
|
|
312
|
+
summary: entry.summary,
|
|
313
|
+
createdAt: entry.createdAt,
|
|
314
|
+
...(ordinal === undefined ? {} : { ordinal }),
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@steve31415/baselib",
|
|
3
|
-
"version": "2.
|
|
4
|
-
"description": "Plasticine new-world shared platform library: logging, auth, service-to-service auth, db,
|
|
3
|
+
"version": "2.2.0",
|
|
4
|
+
"description": "Plasticine new-world shared platform library: logging, auth, service-to-service auth, db, HTTP, sync, app updates",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"repository": "github:plasticine-apps/baselib",
|
|
@@ -44,6 +44,22 @@
|
|
|
44
44
|
"./rum": {
|
|
45
45
|
"types": "./dist/rum.d.ts",
|
|
46
46
|
"default": "./dist/rum.js"
|
|
47
|
+
},
|
|
48
|
+
"./sync": {
|
|
49
|
+
"types": "./dist/sync/index.d.ts",
|
|
50
|
+
"default": "./dist/sync/index.js"
|
|
51
|
+
},
|
|
52
|
+
"./sync-browser": {
|
|
53
|
+
"types": "./dist/sync-browser/index.d.ts",
|
|
54
|
+
"default": "./dist/sync-browser/index.js"
|
|
55
|
+
},
|
|
56
|
+
"./app-update": {
|
|
57
|
+
"types": "./dist/app-update/index.d.ts",
|
|
58
|
+
"default": "./dist/app-update/index.js"
|
|
59
|
+
},
|
|
60
|
+
"./app-update-browser": {
|
|
61
|
+
"types": "./dist/app-update-browser/index.d.ts",
|
|
62
|
+
"default": "./dist/app-update-browser/index.js"
|
|
47
63
|
}
|
|
48
64
|
},
|
|
49
65
|
"bin": {
|
|
@@ -71,7 +87,8 @@
|
|
|
71
87
|
"devDependencies": {
|
|
72
88
|
"@types/node": "^22.0.0",
|
|
73
89
|
"@types/pg": "^8.11.0",
|
|
74
|
-
"
|
|
90
|
+
"fake-indexeddb": "^6.2.5",
|
|
91
|
+
"happy-dom": "^20.11.2",
|
|
75
92
|
"tsx": "^4.19.0",
|
|
76
93
|
"typescript": "^5.7.0",
|
|
77
94
|
"vitest": "^3.0.0"
|