@instantdb/core 0.22.56 → 0.22.57

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.
Files changed (57) hide show
  1. package/dist/commonjs/Reactor.d.ts +5 -0
  2. package/dist/commonjs/Reactor.d.ts.map +1 -1
  3. package/dist/commonjs/Reactor.js +43 -5
  4. package/dist/commonjs/Reactor.js.map +1 -1
  5. package/dist/commonjs/SyncTable.d.ts +140 -0
  6. package/dist/commonjs/SyncTable.d.ts.map +1 -0
  7. package/dist/commonjs/SyncTable.js +538 -0
  8. package/dist/commonjs/SyncTable.js.map +1 -0
  9. package/dist/commonjs/index.d.ts +21 -1
  10. package/dist/commonjs/index.d.ts.map +1 -1
  11. package/dist/commonjs/index.js +19 -1
  12. package/dist/commonjs/index.js.map +1 -1
  13. package/dist/commonjs/instaql.d.ts +1 -0
  14. package/dist/commonjs/instaql.d.ts.map +1 -1
  15. package/dist/commonjs/instaql.js +9 -5
  16. package/dist/commonjs/instaql.js.map +1 -1
  17. package/dist/commonjs/store.d.ts +5 -0
  18. package/dist/commonjs/store.d.ts.map +1 -1
  19. package/dist/commonjs/store.js +11 -0
  20. package/dist/commonjs/store.js.map +1 -1
  21. package/dist/commonjs/utils/PersistedObject.d.ts +11 -5
  22. package/dist/commonjs/utils/PersistedObject.d.ts.map +1 -1
  23. package/dist/commonjs/utils/PersistedObject.js +12 -2
  24. package/dist/commonjs/utils/PersistedObject.js.map +1 -1
  25. package/dist/esm/Reactor.d.ts +5 -0
  26. package/dist/esm/Reactor.d.ts.map +1 -1
  27. package/dist/esm/Reactor.js +43 -5
  28. package/dist/esm/Reactor.js.map +1 -1
  29. package/dist/esm/SyncTable.d.ts +140 -0
  30. package/dist/esm/SyncTable.d.ts.map +1 -0
  31. package/dist/esm/SyncTable.js +498 -0
  32. package/dist/esm/SyncTable.js.map +1 -0
  33. package/dist/esm/index.d.ts +21 -1
  34. package/dist/esm/index.d.ts.map +1 -1
  35. package/dist/esm/index.js +19 -1
  36. package/dist/esm/index.js.map +1 -1
  37. package/dist/esm/instaql.d.ts +1 -0
  38. package/dist/esm/instaql.d.ts.map +1 -1
  39. package/dist/esm/instaql.js +8 -5
  40. package/dist/esm/instaql.js.map +1 -1
  41. package/dist/esm/store.d.ts +5 -0
  42. package/dist/esm/store.d.ts.map +1 -1
  43. package/dist/esm/store.js +9 -3
  44. package/dist/esm/store.js.map +1 -1
  45. package/dist/esm/utils/PersistedObject.d.ts +11 -5
  46. package/dist/esm/utils/PersistedObject.d.ts.map +1 -1
  47. package/dist/esm/utils/PersistedObject.js +12 -2
  48. package/dist/esm/utils/PersistedObject.js.map +1 -1
  49. package/dist/standalone/index.js +1579 -1135
  50. package/dist/standalone/index.umd.cjs +3 -3
  51. package/package.json +2 -2
  52. package/src/Reactor.js +60 -4
  53. package/src/SyncTable.ts +821 -0
  54. package/src/index.ts +48 -0
  55. package/src/instaql.js +11 -5
  56. package/src/store.js +11 -3
  57. package/src/utils/PersistedObject.ts +15 -6
@@ -0,0 +1,821 @@
1
+ import { PersistedObject } from './utils/PersistedObject.ts';
2
+ import * as s from './store.js';
3
+ import weakHash from './utils/weakHash.ts';
4
+ import uuid from './utils/uuid.ts';
5
+ import { Logger } from './Reactor.js';
6
+ import instaql, { compareOrder } from './instaql.js';
7
+ import { InstaQLResponse, ValidQuery } from './queryTypes.ts';
8
+ import { EntitiesDef, IContainEntitiesAndLinks } from './schemaTypes.ts';
9
+
10
+ type SubState = {
11
+ txId?: number;
12
+ subscriptionId: string;
13
+ token: string;
14
+ };
15
+
16
+ type Sub = {
17
+ query: any;
18
+ hash: string;
19
+ table: string;
20
+ orderField: string;
21
+ orderDirection: 'asc' | 'desc';
22
+ orderFieldType: 'string' | 'number' | 'date' | 'boolean';
23
+ state?: SubState;
24
+ entities?: Array<{ entity: any; store: any; serverCreatedAt: number }>;
25
+ createdAt: number;
26
+ updatedAt: number;
27
+ };
28
+
29
+ // We could make a better type for this if we had a return type for s.toJSON
30
+ type SubsInStorage = Subs;
31
+
32
+ type Subs = { [hash: string]: Sub };
33
+
34
+ type StartMsg = {
35
+ op: 'start-sync';
36
+ q: string;
37
+ };
38
+
39
+ type EndMsg = {
40
+ op: 'remove-sync';
41
+ 'subscription-id': string;
42
+ };
43
+
44
+ type ResyncMsg = {
45
+ op: 'resync-table';
46
+ 'subscription-id': string;
47
+ 'tx-id': number;
48
+ token: string;
49
+ };
50
+
51
+ type SendMsg = StartMsg | EndMsg | ResyncMsg;
52
+
53
+ type StartSyncOkMsg = {
54
+ 'subscription-id': string;
55
+ 'client-event-id': string;
56
+ q: string;
57
+ token: string;
58
+ };
59
+
60
+ type Triple = [string, string, any, number];
61
+
62
+ type SyncLoadBatchMsg = {
63
+ 'subscription-id': string;
64
+ 'join-rows': Array<Triple[]>;
65
+ };
66
+
67
+ type SyncInitFinishMsg = {
68
+ 'subscription-id': string;
69
+ 'tx-id': number;
70
+ };
71
+
72
+ type SyncUpdateTriplesMsg = {
73
+ 'subscription-id': string;
74
+ txes: {
75
+ 'tx-id': number;
76
+ changes: { action: 'added' | 'removed'; triple: Triple }[];
77
+ }[];
78
+ };
79
+
80
+ type TrySend = (eventId: string, msg: SendMsg) => void;
81
+
82
+ type Config = { useDateObjects: boolean };
83
+
84
+ // Modifies the data in place because it comes directly from storage
85
+ function syncSubsFromStorage(
86
+ parsed: SubsInStorage,
87
+ useDateObjects: boolean,
88
+ ): Subs {
89
+ for (const key in parsed) {
90
+ const sub = parsed[key];
91
+ for (const e of sub.entities || []) {
92
+ e.store.useDateObjects = useDateObjects;
93
+ e.store = s.fromJSON(e.store);
94
+ }
95
+ }
96
+ return parsed;
97
+ }
98
+
99
+ function syncSubsToStorage(subs: Subs): SubsInStorage {
100
+ const jsonSubs = {};
101
+ for (const key in subs) {
102
+ const sub = subs[key];
103
+ if (sub.entities) {
104
+ const entities = [];
105
+ for (const e of sub.entities) {
106
+ entities.push({ ...e, store: s.toJSON(e.store) });
107
+ }
108
+ jsonSubs[key] = { ...sub, entities };
109
+ } else {
110
+ jsonSubs[key] = sub;
111
+ }
112
+ }
113
+ return jsonSubs;
114
+ }
115
+
116
+ function onMergeSubs(storageSubs: Subs | null, inMemorySubs: Subs): Subs {
117
+ const subs = {};
118
+ for (const [k, v] of Object.entries(inMemorySubs)) {
119
+ subs[k] = v;
120
+ }
121
+ if (storageSubs) {
122
+ for (const [k, v] of Object.entries(storageSubs)) {
123
+ subs[k] = v;
124
+ }
125
+ }
126
+ return subs;
127
+ }
128
+
129
+ function queryEntity(sub: Sub, store: any) {
130
+ const res = instaql({ store, pageInfo: null, aggregate: null }, sub.query);
131
+ return res.data[sub.table][0];
132
+ }
133
+
134
+ function getServerCreatedAt(sub: Sub, store: any, entityId: string): number {
135
+ const aid = s.getAttrByFwdIdentName(store, sub.table, 'id')?.id;
136
+ if (!aid) {
137
+ return -1;
138
+ }
139
+ const t = s.getInMap(store.eav, [entityId, aid, entityId]);
140
+ if (!t) {
141
+ return -1;
142
+ }
143
+ return t[3];
144
+ }
145
+
146
+ function applyChangesToStore(
147
+ store: any,
148
+ changes: SyncUpdateTriplesMsg['txes'][number]['changes'],
149
+ ): void {
150
+ for (const { action, triple } of changes) {
151
+ switch (action) {
152
+ case 'added':
153
+ s.addTriple(store, triple);
154
+ break;
155
+ case 'removed':
156
+ s.retractTriple(store, triple);
157
+ break;
158
+ }
159
+ }
160
+ }
161
+
162
+ function changedFieldsOfChanges(
163
+ store: any,
164
+ changes: SyncUpdateTriplesMsg['txes'][number]['changes'],
165
+ ): {
166
+ [eid: string]: SyncTransaction<
167
+ any,
168
+ any,
169
+ any
170
+ >['updated'][number]['changedFields'];
171
+ } {
172
+ // This will be more complicated when we include links, we can either add a
173
+ // changedLinks field or we can have something like 'bookshelves.title`
174
+ const changedFields = {};
175
+ for (const { action, triple } of changes) {
176
+ const [e, a, v] = triple;
177
+ const field = store.attrs[a]?.['forward-identity']?.[2];
178
+ if (!field) continue;
179
+
180
+ const fields = changedFields[e] ?? {};
181
+ changedFields[e] = fields;
182
+
183
+ const oldNew = fields[field] ?? {};
184
+ fields[field] = oldNew;
185
+
186
+ switch (action) {
187
+ case 'added':
188
+ oldNew.newValue = v;
189
+ break;
190
+ case 'removed':
191
+ // Only take the first thing that was removed, in case we modified things in the middle
192
+ if (oldNew.oldValue === undefined) {
193
+ oldNew.oldValue = v;
194
+ }
195
+ break;
196
+ }
197
+ }
198
+ return changedFields;
199
+ }
200
+
201
+ function subData(sub: Sub, entities: NonNullable<Sub['entities']>) {
202
+ return { [sub.table]: entities.map((e) => e.entity) };
203
+ }
204
+
205
+ function sortEntitiesInPlace(sub: Sub, entities: NonNullable<Sub['entities']>) {
206
+ const dataType = sub.orderFieldType;
207
+ if (sub.orderField === 'serverCreatedAt') {
208
+ entities.sort(
209
+ sub.orderDirection === 'asc'
210
+ ? function compareEntities(a, b) {
211
+ return compareOrder(
212
+ a.entity.id,
213
+ a.serverCreatedAt,
214
+ b.entity.id,
215
+ b.serverCreatedAt,
216
+ dataType,
217
+ );
218
+ }
219
+ : function compareEntities(b, a) {
220
+ return compareOrder(
221
+ a.entity.id,
222
+ a.serverCreatedAt,
223
+ b.entity.id,
224
+ b.serverCreatedAt,
225
+ dataType,
226
+ );
227
+ },
228
+ );
229
+ return;
230
+ }
231
+
232
+ const field = sub.orderField;
233
+
234
+ entities.sort(
235
+ sub.orderDirection === 'asc'
236
+ ? function compareEntities(a, b) {
237
+ return compareOrder(
238
+ a.entity.id,
239
+ a.entity[field],
240
+ b.entity.id,
241
+ b.entity[field],
242
+ dataType,
243
+ );
244
+ }
245
+ : function compareEntities(b, a) {
246
+ return compareOrder(
247
+ a.entity.id,
248
+ a.entity[field],
249
+ b.entity.id,
250
+ b.entity[field],
251
+ dataType,
252
+ );
253
+ },
254
+ );
255
+ }
256
+
257
+ export enum CallbackEventType {
258
+ InitialSyncBatch = 'InitialSyncBatch',
259
+ InitialSyncComplete = 'InitialSyncComplete',
260
+ LoadFromStorage = 'LoadFromStorage',
261
+ SyncTransaction = 'SyncTransaction',
262
+ Error = 'Error',
263
+ }
264
+
265
+ type QueryEntities<
266
+ Schema extends IContainEntitiesAndLinks<EntitiesDef, any>,
267
+ Q extends ValidQuery<Q, Schema>,
268
+ UseDates extends boolean,
269
+ > = InstaQLResponse<Schema, Q, UseDates>[keyof InstaQLResponse<
270
+ Schema,
271
+ Q,
272
+ UseDates
273
+ >];
274
+
275
+ type QueryEntity<
276
+ Schema extends IContainEntitiesAndLinks<EntitiesDef, any>,
277
+ Q extends ValidQuery<Q, Schema>,
278
+ UseDates extends boolean,
279
+ > = QueryEntities<Schema, Q, UseDates> extends (infer E)[] ? E : never;
280
+
281
+ type ChangedFields<Entity> = {
282
+ [K in keyof Entity]?: {
283
+ oldValue: Entity[K];
284
+ newValue: Entity[K];
285
+ };
286
+ };
287
+
288
+ export interface BaseCallbackEvent<
289
+ Schema extends IContainEntitiesAndLinks<EntitiesDef, any>,
290
+ Q extends ValidQuery<Q, Schema>,
291
+ UseDates extends boolean,
292
+ > {
293
+ type: CallbackEventType;
294
+ data: InstaQLResponse<Schema, Q, UseDates>;
295
+ }
296
+
297
+ export interface InitialSyncBatch<
298
+ Schema extends IContainEntitiesAndLinks<EntitiesDef, any>,
299
+ Q extends ValidQuery<Q, Schema>,
300
+ UseDates extends boolean,
301
+ > extends BaseCallbackEvent<Schema, Q, UseDates> {
302
+ type: CallbackEventType.InitialSyncBatch;
303
+ batch: QueryEntities<Schema, Q, UseDates>;
304
+ }
305
+
306
+ export interface InitialSyncComplete<
307
+ Schema extends IContainEntitiesAndLinks<EntitiesDef, any>,
308
+ Q extends ValidQuery<Q, Schema>,
309
+ UseDates extends boolean,
310
+ > extends BaseCallbackEvent<Schema, Q, UseDates> {
311
+ type: CallbackEventType.InitialSyncComplete;
312
+ }
313
+
314
+ export interface SyncTransaction<
315
+ Schema extends IContainEntitiesAndLinks<EntitiesDef, any>,
316
+ Q extends ValidQuery<Q, Schema>,
317
+ UseDates extends boolean,
318
+ > extends BaseCallbackEvent<Schema, Q, UseDates> {
319
+ type: CallbackEventType.SyncTransaction;
320
+ added: QueryEntities<Schema, Q, UseDates>;
321
+ removed: QueryEntities<Schema, Q, UseDates>;
322
+ updated: {
323
+ oldEntity: QueryEntity<Schema, Q, UseDates>;
324
+ newEntity: QueryEntity<Schema, Q, UseDates>;
325
+ changedFields: ChangedFields<QueryEntity<Schema, Q, UseDates>>;
326
+ }[];
327
+ }
328
+
329
+ export interface LoadFromStorage<
330
+ Schema extends IContainEntitiesAndLinks<EntitiesDef, any>,
331
+ Q extends ValidQuery<Q, Schema>,
332
+ UseDates extends boolean,
333
+ > extends BaseCallbackEvent<Schema, Q, UseDates> {
334
+ type: CallbackEventType.LoadFromStorage;
335
+ }
336
+
337
+ export interface SetupError<
338
+ Schema extends IContainEntitiesAndLinks<EntitiesDef, any>,
339
+ Q extends ValidQuery<Q, Schema>,
340
+ UseDates extends boolean,
341
+ > extends BaseCallbackEvent<Schema, Q, UseDates> {
342
+ type: CallbackEventType.Error;
343
+ error: { message: string; hint?: any; type: string; status: number };
344
+ }
345
+
346
+ export type CallbackEvent<
347
+ Schema extends IContainEntitiesAndLinks<EntitiesDef, any>,
348
+ Q extends ValidQuery<Q, Schema>,
349
+ UseDates extends boolean,
350
+ > =
351
+ | InitialSyncBatch<Schema, Q, UseDates>
352
+ | InitialSyncComplete<Schema, Q, UseDates>
353
+ | SyncTransaction<Schema, Q, UseDates>
354
+ | LoadFromStorage<Schema, Q, UseDates>
355
+ | SetupError<Schema, Q, UseDates>;
356
+
357
+ export type SyncTableCallback<
358
+ Schema extends IContainEntitiesAndLinks<EntitiesDef, any>,
359
+ Q extends ValidQuery<Q, Schema>,
360
+ UseDates extends boolean,
361
+ > = (event: CallbackEvent<Schema, Q, UseDates>) => void;
362
+
363
+ export class SyncTable {
364
+ private trySend: TrySend;
365
+ private subs: PersistedObject<Subs>;
366
+ // Using any for the SyncCallback because we'd need Reactor to be typed
367
+ private callbacks: { [hash: string]: SyncTableCallback<any, any, any>[] } =
368
+ {};
369
+ private config: Config;
370
+ private idToHash: { [subscriptionId: string]: string } = {};
371
+ private log: Logger;
372
+ private createStore: (triples: Triple[]) => any;
373
+
374
+ constructor(
375
+ trySend: TrySend,
376
+ persister: Storage,
377
+ config: Config,
378
+ log: Logger,
379
+ createStore: (triples: Triple[]) => any,
380
+ ) {
381
+ this.trySend = trySend;
382
+ this.config = config;
383
+ this.log = log;
384
+ this.createStore = createStore;
385
+
386
+ this.subs = new PersistedObject<Subs>(
387
+ persister,
388
+ 'subs',
389
+ {},
390
+ onMergeSubs,
391
+ syncSubsToStorage,
392
+ (x) => syncSubsFromStorage(x, this.config.useDateObjects),
393
+ );
394
+ }
395
+
396
+ public beforeUnload() {
397
+ this.subs.flush();
398
+ }
399
+
400
+ public subscribe(
401
+ q: any,
402
+ cb: SyncTableCallback<any, any, any>,
403
+ ): (
404
+ opts?: { keepSubscription?: boolean | null | undefined } | null | undefined,
405
+ ) => void {
406
+ const hash = weakHash(q);
407
+ this.callbacks[hash] = this.callbacks[hash] || [];
408
+ this.callbacks[hash].push(cb);
409
+
410
+ this.initSubscription(q, hash, cb);
411
+
412
+ return (opts?: { keepSubscription?: boolean | null | undefined }) => {
413
+ this.unsubscribe(hash, cb, opts?.keepSubscription);
414
+ };
415
+ }
416
+
417
+ private unsubscribe(
418
+ hash: string,
419
+ cb: SyncTableCallback<any, any, any>,
420
+ keepSubscription: boolean | null | undefined,
421
+ ) {
422
+ const cbs = (this.callbacks[hash] || []).filter((x) => x !== cb);
423
+ this.callbacks[hash] = cbs;
424
+
425
+ if (!cbs.length) {
426
+ delete this.callbacks[hash];
427
+ if (keepSubscription) {
428
+ return;
429
+ }
430
+ const sub = this.subs.currentValue[hash];
431
+ if (sub?.state) {
432
+ this.clearSubscriptionData(sub.state.subscriptionId);
433
+ }
434
+ this.subs.set((prev) => {
435
+ delete prev[hash];
436
+ return prev;
437
+ });
438
+ }
439
+ }
440
+
441
+ private sendStart(query: string) {
442
+ this.trySend(uuid(), {
443
+ op: 'start-sync',
444
+ q: query,
445
+ });
446
+ }
447
+
448
+ private sendResync(sub: Sub, state: SubState) {
449
+ // Make sure we can find the hash from the subscriptionId
450
+ this.idToHash[state.subscriptionId] = sub.hash;
451
+ this.trySend(state.subscriptionId, {
452
+ op: 'resync-table',
453
+ 'subscription-id': state.subscriptionId,
454
+ 'tx-id': state.txId,
455
+ token: state.token,
456
+ });
457
+ }
458
+
459
+ private sendRemove(state: SubState) {
460
+ this.trySend(uuid(), {
461
+ op: 'remove-sync',
462
+ 'subscription-id': state.subscriptionId,
463
+ });
464
+ }
465
+
466
+ private async initSubscription(
467
+ query: any,
468
+ hash: string,
469
+ cb?: SyncTableCallback<any, any, any>,
470
+ ) {
471
+ // Wait for storage to load so that we know if we already have an existing subscription
472
+ await this.subs.waitForLoaded();
473
+ const existingSub = this.subs.currentValue[hash];
474
+
475
+ if (existingSub && existingSub.state && existingSub.state.txId) {
476
+ this.sendResync(existingSub, existingSub.state);
477
+
478
+ if (existingSub.entities && cb) {
479
+ const k = Object.keys(query)[0];
480
+ cb({
481
+ type: CallbackEventType.LoadFromStorage,
482
+ data: subData(existingSub, existingSub.entities),
483
+ });
484
+ }
485
+
486
+ return;
487
+ }
488
+
489
+ const table = Object.keys(query)[0];
490
+ const orderBy = query[table]?.$?.order || { serverCreatedAt: 'asc' };
491
+ const [orderField, orderDirection] = Object.entries(orderBy)[0] as [
492
+ string,
493
+ 'asc' | 'desc',
494
+ ];
495
+
496
+ const orderFieldType =
497
+ orderField === 'serverCreatedAt'
498
+ ? 'number'
499
+ : s.getAttrByFwdIdentName(this.createStore([]), table, orderField)?.[
500
+ 'checked-data-type'
501
+ ];
502
+
503
+ this.subs.set((prev) => {
504
+ prev[hash] = {
505
+ query,
506
+ hash: hash,
507
+ table,
508
+ orderDirection,
509
+ orderField,
510
+ orderFieldType,
511
+ createdAt: Date.now(),
512
+ updatedAt: Date.now(),
513
+ };
514
+ return prev;
515
+ });
516
+
517
+ this.sendStart(query);
518
+ }
519
+
520
+ public async flushPending() {
521
+ await this.subs.waitForLoaded();
522
+ for (const hash of Object.keys(this.callbacks)) {
523
+ const sub = this.subs.currentValue[hash];
524
+ if (sub) {
525
+ await this.initSubscription(sub.query, sub.hash);
526
+ } else {
527
+ this.log.error('Missing sub for hash in flushPending', hash);
528
+ }
529
+ }
530
+ }
531
+
532
+ public onStartSyncOk(msg: StartSyncOkMsg) {
533
+ const subscriptionId = msg['subscription-id'];
534
+ const q = msg.q;
535
+ const hash = weakHash(q);
536
+
537
+ this.idToHash[subscriptionId] = hash;
538
+
539
+ this.subs.set((prev) => {
540
+ const sub = prev[hash];
541
+ if (!sub) {
542
+ this.log.error(
543
+ 'Missing sub for hash',
544
+ hash,
545
+ 'subscription-id',
546
+ subscriptionId,
547
+ 'query',
548
+ q,
549
+ );
550
+ return prev;
551
+ }
552
+
553
+ sub.state = {
554
+ subscriptionId: subscriptionId,
555
+ token: msg.token,
556
+ };
557
+ return prev;
558
+ });
559
+ }
560
+
561
+ private notifyCbs(hash: string, event: CallbackEvent<any, any, any>) {
562
+ for (const cb of this.callbacks[hash] || []) {
563
+ cb(event);
564
+ }
565
+ }
566
+
567
+ public onSyncLoadBatch(msg: SyncLoadBatchMsg) {
568
+ const subscriptionId = msg['subscription-id'];
569
+ const joinRows = msg['join-rows'];
570
+ const hash = this.idToHash[subscriptionId];
571
+ if (!hash) {
572
+ this.log.error('Missing hash for subscription', msg);
573
+ return;
574
+ }
575
+
576
+ const batch = [];
577
+ const currentValue = this.subs.set((prev) => {
578
+ const sub = prev[hash];
579
+ if (!sub) {
580
+ this.log.error('Missing sub for hash', hash, msg);
581
+ return prev;
582
+ }
583
+
584
+ const entities = sub.entities ?? [];
585
+ sub.entities = entities;
586
+
587
+ for (const entRows of joinRows) {
588
+ const store = this.createStore(entRows);
589
+ const entity = queryEntity(sub, store);
590
+ entities.push({
591
+ store,
592
+ entity,
593
+ serverCreatedAt: getServerCreatedAt(sub, store, entity.id),
594
+ });
595
+ batch.push(entity);
596
+ }
597
+
598
+ sub.updatedAt = Date.now();
599
+
600
+ return prev;
601
+ });
602
+
603
+ const sub = currentValue[hash];
604
+ if (sub.entities) {
605
+ this.notifyCbs(hash, {
606
+ type: CallbackEventType.InitialSyncBatch,
607
+ data: subData(sub, sub.entities),
608
+ batch,
609
+ });
610
+ }
611
+ }
612
+
613
+ public onSyncInitFinish(msg: SyncInitFinishMsg) {
614
+ const subscriptionId = msg['subscription-id'];
615
+ const hash = this.idToHash[subscriptionId];
616
+ if (!hash) {
617
+ this.log.error('Missing hash for subscription', msg);
618
+ return;
619
+ }
620
+ this.subs.set((prev) => {
621
+ const sub = prev[hash];
622
+ if (!sub) {
623
+ this.log.error('Missing sub for hash', hash, msg);
624
+ return prev;
625
+ }
626
+ const state = sub.state;
627
+ if (!state) {
628
+ this.log.error('Sub never set init, missing result', sub, msg);
629
+ return prev;
630
+ }
631
+ state.txId = msg['tx-id'];
632
+ sub.updatedAt = Date.now();
633
+ return prev;
634
+ });
635
+
636
+ const sub = this.subs.currentValue[hash];
637
+
638
+ if (sub) {
639
+ this.notifyCbs(hash, {
640
+ type: CallbackEventType.InitialSyncComplete,
641
+ data: subData(sub, sub.entities || []),
642
+ });
643
+ }
644
+ }
645
+
646
+ public onSyncUpdateTriples(msg: SyncUpdateTriplesMsg) {
647
+ const subscriptionId = msg['subscription-id'];
648
+ const hash = this.idToHash[subscriptionId];
649
+ if (!hash) {
650
+ this.log.error('Missing hash for subscription', msg);
651
+ return;
652
+ }
653
+ this.subs.set((prev) => {
654
+ const sub = prev[hash];
655
+ if (!sub) {
656
+ this.log.error('Missing sub for hash', hash, msg);
657
+ return prev;
658
+ }
659
+
660
+ const state = sub.state;
661
+ if (!state) {
662
+ this.log.error('Missing state for sub', sub, msg);
663
+ return prev;
664
+ }
665
+
666
+ for (const tx of msg.txes) {
667
+ if (state.txId >= tx['tx-id']) {
668
+ continue;
669
+ }
670
+ state.txId = tx['tx-id'];
671
+ const idxesToDelete = [];
672
+ // Note: this won't work as well when links are involved
673
+ const byEid: {
674
+ [eid: string]: SyncUpdateTriplesMsg['txes'][number]['changes'];
675
+ } = {};
676
+ for (const change of tx.changes) {
677
+ const eidChanges = byEid[change.triple[0]] ?? [];
678
+ byEid[change.triple[0]] = eidChanges;
679
+ eidChanges.push(change);
680
+ }
681
+
682
+ const entities = sub.entities || [];
683
+ sub.entities = entities;
684
+
685
+ const updated: SyncTransaction<any, any, any>['updated'] = [];
686
+ // Update the existing stores, if we already know about this entity
687
+ eidLoop: for (const [eid, changes] of Object.entries(byEid)) {
688
+ for (const [entIdx, ent] of Object.entries(entities)) {
689
+ if (s.hasEntity(ent.store, eid)) {
690
+ applyChangesToStore(ent.store, changes);
691
+ const entity = queryEntity(sub, ent.store);
692
+ const changedFields = changedFieldsOfChanges(ent.store, changes)[
693
+ eid
694
+ ];
695
+ for (const [k, { oldValue, newValue }] of Object.entries(
696
+ changedFields || {},
697
+ )) {
698
+ if (oldValue === newValue) {
699
+ delete changedFields[k];
700
+ }
701
+ }
702
+ if (entity) {
703
+ updated.push({
704
+ oldEntity: ent.entity,
705
+ newEntity: entity,
706
+ changedFields: changedFields || {},
707
+ });
708
+ ent.entity = entity;
709
+ } else {
710
+ idxesToDelete.push(entIdx);
711
+ }
712
+ delete byEid[eid];
713
+ continue eidLoop;
714
+ }
715
+ }
716
+ }
717
+
718
+ const added = [];
719
+ // If we have anything left in byEid, then this must be a new entity we don't know about
720
+ for (const [_eid, changes] of Object.entries(byEid)) {
721
+ const store = this.createStore([]);
722
+ applyChangesToStore(store, changes);
723
+ const entity = queryEntity(sub, store);
724
+ if (!entity) {
725
+ this.log.error('No entity found after applying change', {
726
+ sub,
727
+ changes,
728
+ store,
729
+ });
730
+ 1;
731
+ continue;
732
+ }
733
+ entities.push({
734
+ store,
735
+ entity,
736
+ serverCreatedAt: getServerCreatedAt(sub, store, entity.id),
737
+ });
738
+ added.push(entity);
739
+ }
740
+
741
+ const removed = [];
742
+
743
+ for (const idx of idxesToDelete.sort().reverse()) {
744
+ removed.push(entities[idx].entity);
745
+ entities.splice(idx, 1);
746
+ }
747
+
748
+ sortEntitiesInPlace(sub, entities);
749
+ this.notifyCbs(hash, {
750
+ type: CallbackEventType.SyncTransaction,
751
+ data: subData(sub, sub.entities),
752
+ added,
753
+ removed,
754
+ updated,
755
+ });
756
+ sub.updatedAt = Date.now();
757
+ }
758
+
759
+ return prev;
760
+ });
761
+ }
762
+
763
+ private clearSubscriptionData(subscriptionId: string) {
764
+ const hash = this.idToHash[subscriptionId];
765
+ if (hash) {
766
+ delete this.idToHash[subscriptionId];
767
+ const sub = this.subs.currentValue[hash];
768
+ if (sub.state) {
769
+ this.sendRemove(sub.state);
770
+ }
771
+ this.subs.set((prev) => {
772
+ delete prev[hash];
773
+ return prev;
774
+ });
775
+ if (sub) {
776
+ return sub;
777
+ }
778
+ }
779
+ }
780
+
781
+ public onStartSyncError(msg: {
782
+ op: 'error';
783
+ 'original-event': StartMsg;
784
+ 'client-event-id': string;
785
+ status: number;
786
+ type: string;
787
+ message?: string;
788
+ hint?: any;
789
+ }) {
790
+ const hash = weakHash(msg['original-event']['q']);
791
+ const error = {
792
+ message: msg.message || 'Uh-oh, something went wrong. Ping Joe & Stopa.',
793
+ status: msg.status,
794
+ type: msg.type,
795
+ hint: msg.hint,
796
+ };
797
+
798
+ const k = Object.keys(msg['original-event']['q'])[0];
799
+ this.notifyCbs(hash, {
800
+ type: CallbackEventType.Error,
801
+ data: { [k]: [] },
802
+ error,
803
+ });
804
+ }
805
+
806
+ public onResyncError(msg: {
807
+ op: 'error';
808
+ 'original-event': ResyncMsg;
809
+ status: number;
810
+ type: string;
811
+ }) {
812
+ // Clear the subscription and start from scrath on any resync error
813
+ // This can happen if the auth changed and we need to refetch with the
814
+ // new auth or if the subscription is too far behind.
815
+ const subscriptionId = msg['original-event']['subscription-id'];
816
+ const removedSub = this.clearSubscriptionData(subscriptionId);
817
+ if (removedSub) {
818
+ this.initSubscription(removedSub.query, removedSub.hash);
819
+ }
820
+ }
821
+ }