@runappsai/sdk 0.7.5 → 0.7.7
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 +14 -0
- package/dist/browser-auth.d.ts +21 -2
- package/dist/browser-auth.d.ts.map +1 -1
- package/dist/browser-auth.js +98 -3
- package/dist/browser-auth.js.map +1 -1
- package/dist/client.d.ts +9 -1
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +2 -1
- package/dist/client.js.map +1 -1
- package/dist/sdk.umd.js +3 -3
- package/dist/sdk.umd.js.map +1 -1
- package/dist/sync/engine.d.ts +16 -1
- package/dist/sync/engine.d.ts.map +1 -1
- package/dist/sync/engine.js +118 -20
- package/dist/sync/engine.js.map +1 -1
- package/package.json +1 -1
- package/src/browser-auth.ts +89 -4
- package/src/client.ts +11 -2
- package/src/sync/engine.ts +119 -19
package/src/sync/engine.ts
CHANGED
|
@@ -176,6 +176,12 @@ interface StoreSchema {
|
|
|
176
176
|
}
|
|
177
177
|
|
|
178
178
|
const TOMBSTONE_TTL_MS = 30 * 24 * 3600 * 1000;
|
|
179
|
+
/** Shards one push writes at a time. Each is a read-merge-write against
|
|
180
|
+
* the files API, so the push is latency bound: done one by one, a few
|
|
181
|
+
* hundred records over every shard took a minute and a half. */
|
|
182
|
+
const PUSH_CONCURRENCY = 6;
|
|
183
|
+
/** Shard copies kept, with their etags, so a write needs no read first. */
|
|
184
|
+
const SHARD_CACHE_MAX = 256;
|
|
179
185
|
const MAX_PUSH_DELAY_MS = 10_000;
|
|
180
186
|
const BACKOFF_MIN_MS = 5_000;
|
|
181
187
|
const BACKOFF_MAX_MS = 5 * 60_000;
|
|
@@ -208,6 +214,15 @@ export class SyncEngine {
|
|
|
208
214
|
private readonly clock = new Clock();
|
|
209
215
|
private readonly schemas = new Map<string, Map<string, StoreSchema>>();
|
|
210
216
|
private readonly etags = new Map<string, string>();
|
|
217
|
+
/** The last copy of a shard this engine read or wrote, with its etag:
|
|
218
|
+
* the next write merges into it and sends If-Match, and only a 412
|
|
219
|
+
* (someone else wrote in between) costs a read. Oldest first. */
|
|
220
|
+
private readonly shardCache = new Map<string, { etag: string; shard: Shard }>();
|
|
221
|
+
/** Every shard path the last listing showed, and the prefixes that
|
|
222
|
+
* listing covered: a path under a covered prefix that isn't listed
|
|
223
|
+
* does not exist, and is created with If-None-Match, unread. */
|
|
224
|
+
private readonly listedPaths = new Set<string>();
|
|
225
|
+
private readonly listedPrefixes = new Set<string>();
|
|
211
226
|
private readonly conns = new Map<string, Promise<IDBDatabase>>();
|
|
212
227
|
private state: SyncState = { state: "off", pending: 0 };
|
|
213
228
|
private paused = false;
|
|
@@ -576,6 +591,9 @@ export class SyncEngine {
|
|
|
576
591
|
}
|
|
577
592
|
await this.log.clearAll();
|
|
578
593
|
this.etags.clear();
|
|
594
|
+
this.shardCache.clear();
|
|
595
|
+
this.listedPaths.clear();
|
|
596
|
+
this.listedPrefixes.clear();
|
|
579
597
|
this.metaLoaded = null;
|
|
580
598
|
await this.loadMeta();
|
|
581
599
|
}
|
|
@@ -647,13 +665,23 @@ export class SyncEngine {
|
|
|
647
665
|
if (!g) groups.set(k, (g = []));
|
|
648
666
|
g.push(op);
|
|
649
667
|
}
|
|
650
|
-
const
|
|
651
|
-
|
|
668
|
+
const idsOf = (list: LoggedOp[]) => list.map((op) => op.id).filter((id): id is number => id !== undefined);
|
|
669
|
+
// Ops leave the log as soon as the shard holding them is written, so
|
|
670
|
+
// `pending` counts down while a long push runs, and a push cut short
|
|
671
|
+
// keeps only what really is still unsent.
|
|
672
|
+
const settle = async (ids: number[]) => {
|
|
673
|
+
if (ids.length === 0) return;
|
|
674
|
+
await this.log.remove(ids);
|
|
675
|
+
this.moved = true;
|
|
676
|
+
await this.refreshPending();
|
|
677
|
+
};
|
|
678
|
+
const limit = limiter(PUSH_CONCURRENCY);
|
|
679
|
+
const work: Promise<void>[] = [];
|
|
652
680
|
for (const [, g] of groups) {
|
|
653
681
|
const db = g[0]!.db;
|
|
654
682
|
const store = g[0]!.store;
|
|
655
683
|
if (this.storeExcluded(db, store)) {
|
|
656
|
-
|
|
684
|
+
work.push(settle(idsOf(g)));
|
|
657
685
|
continue;
|
|
658
686
|
}
|
|
659
687
|
// A clear drops everything written before it; ops after the last
|
|
@@ -675,19 +703,22 @@ export class SyncEngine {
|
|
|
675
703
|
list.push(op);
|
|
676
704
|
}
|
|
677
705
|
const shards = clearedAt > 0 ? Array.from({ length: this.options.shards }, (_, i) => i) : Array.from(byShard.keys());
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
706
|
+
const written = shards.map((n) =>
|
|
707
|
+
limit(async () => {
|
|
708
|
+
const path = this.shardPath(db, store, n);
|
|
709
|
+
const shardOps = byShard.get(n) ?? [];
|
|
710
|
+
const encoded = await Promise.all(shardOps.map((op) => this.encodeOp(op)));
|
|
711
|
+
const etag = await this.writeShard(path, clearedAt, encoded);
|
|
712
|
+
await this.log.setMeta({ ["etag:" + path]: etag });
|
|
713
|
+
await settle(idsOf(shardOps));
|
|
714
|
+
}),
|
|
715
|
+
);
|
|
716
|
+
// The clear itself, and what it superseded, once every shard is.
|
|
717
|
+
work.push(Promise.all(written).then(() => settle(idsOf(g.slice(0, lastClear + 1)))));
|
|
686
718
|
}
|
|
687
|
-
await
|
|
688
|
-
|
|
689
|
-
if (
|
|
690
|
-
await this.refreshPending();
|
|
719
|
+
const results = await Promise.allSettled(work);
|
|
720
|
+
const failed = results.find((r): r is PromiseRejectedResult => r.status === "rejected");
|
|
721
|
+
if (failed) throw failed.reason;
|
|
691
722
|
}
|
|
692
723
|
|
|
693
724
|
private async encodeOp(op: LoggedOp): Promise<ShardOp> {
|
|
@@ -696,10 +727,17 @@ export class SyncEngine {
|
|
|
696
727
|
return { kid: op.kid, rec: { k: op.key, v, t: op.ts }, changed: op.changed };
|
|
697
728
|
}
|
|
698
729
|
|
|
699
|
-
/** Merge ops into one shard and write it; retried on a 412.
|
|
730
|
+
/** Merge ops into one shard and write it; retried on a 412. The first
|
|
731
|
+
* try starts from the cached copy, or from nothing when the last
|
|
732
|
+
* listing showed no such shard — no read either way. */
|
|
700
733
|
private async writeShard(path: string, clearedAt: number, ops: ShardOp[]): Promise<string> {
|
|
701
734
|
for (let attempt = 0; attempt < 5; attempt++) {
|
|
702
|
-
const
|
|
735
|
+
const cached = attempt === 0 ? this.shardCache.get(path) : undefined;
|
|
736
|
+
const { shard, etag } = cached
|
|
737
|
+
? { shard: copyShard(cached.shard), etag: cached.etag }
|
|
738
|
+
: attempt === 0 && this.knownAbsent(path)
|
|
739
|
+
? { shard: { v: 1 as const, cleared: 0, r: {} }, etag: undefined }
|
|
740
|
+
: await this.readShard(path);
|
|
703
741
|
const now = this.clock.now();
|
|
704
742
|
if (clearedAt > shard.cleared) {
|
|
705
743
|
shard.cleared = clearedAt;
|
|
@@ -721,13 +759,40 @@ export class SyncEngine {
|
|
|
721
759
|
if (r.status === "ok") {
|
|
722
760
|
if (r.serverTime) this.clock.observe(r.serverTime, before, Date.now());
|
|
723
761
|
this.etags.set(path, r.etag);
|
|
762
|
+
this.cacheShard(path, r.etag, shard);
|
|
763
|
+
this.listedPaths.add(path);
|
|
724
764
|
return r.etag;
|
|
725
765
|
}
|
|
726
766
|
this.etags.delete(path);
|
|
767
|
+
this.shardCache.delete(path);
|
|
768
|
+
this.listedPaths.add(path); // it exists after all
|
|
727
769
|
}
|
|
728
770
|
throw new Error(`could not write ${path}: too many concurrent writers`);
|
|
729
771
|
}
|
|
730
772
|
|
|
773
|
+
private cacheShard(path: string, etag: string, shard: Shard): void {
|
|
774
|
+
this.shardCache.delete(path);
|
|
775
|
+
this.shardCache.set(path, { etag, shard });
|
|
776
|
+
while (this.shardCache.size > SHARD_CACHE_MAX) {
|
|
777
|
+
const oldest = this.shardCache.keys().next().value;
|
|
778
|
+
if (oldest === undefined) break;
|
|
779
|
+
this.shardCache.delete(oldest);
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
private knownAbsent(path: string): boolean {
|
|
784
|
+
if (this.listedPaths.has(path)) return false;
|
|
785
|
+
for (const p of this.listedPrefixes) if (path.startsWith(p)) return true;
|
|
786
|
+
return false;
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
/** Remember what a listing of `prefix` showed (see listedPaths). */
|
|
790
|
+
private rememberListing(prefix: string, listing: { path: string }[]): void {
|
|
791
|
+
for (const p of [...this.listedPaths]) if (p.startsWith(prefix)) this.listedPaths.delete(p);
|
|
792
|
+
for (const e of listing) this.listedPaths.add(e.path);
|
|
793
|
+
this.listedPrefixes.add(prefix);
|
|
794
|
+
}
|
|
795
|
+
|
|
731
796
|
private async storeBlob(blob: Blob): Promise<BlobRef> {
|
|
732
797
|
const bytes = new Uint8Array(await blob.arrayBuffer());
|
|
733
798
|
const digest = await crypto.subtle.digest("SHA-256", bytes);
|
|
@@ -740,14 +805,18 @@ export class SyncEngine {
|
|
|
740
805
|
// ── Pull ─────────────────────────────────────────────────────────
|
|
741
806
|
|
|
742
807
|
private async pull(): Promise<void> {
|
|
743
|
-
const
|
|
808
|
+
const prefix = this.options.prefix + "/";
|
|
809
|
+
const listing = await this.deps.remote.list(prefix);
|
|
810
|
+
this.rememberListing(prefix, listing);
|
|
744
811
|
await this.applyListing(listing);
|
|
745
812
|
}
|
|
746
813
|
|
|
747
814
|
private async pullDatabase(name: string): Promise<void> {
|
|
748
815
|
if (!this.deps.userId()) return;
|
|
749
816
|
await this.loadMeta();
|
|
750
|
-
const
|
|
817
|
+
const prefix = `${this.options.prefix}/${pathSeg(name)}/`;
|
|
818
|
+
const listing = await this.deps.remote.list(prefix);
|
|
819
|
+
this.rememberListing(prefix, listing);
|
|
751
820
|
await this.applyListing(listing);
|
|
752
821
|
}
|
|
753
822
|
|
|
@@ -775,6 +844,8 @@ export class SyncEngine {
|
|
|
775
844
|
}
|
|
776
845
|
const s = r.value as Partial<Shard> | null;
|
|
777
846
|
const shard: Shard = { v: 1, cleared: s?.cleared ?? 0, r: s?.r ?? {} };
|
|
847
|
+
// Cached before applying: applyShard annotates the records.
|
|
848
|
+
this.cacheShard(entry.path, r.etag, copyShard(shard));
|
|
778
849
|
await this.applyShard(db, store, shard, pending);
|
|
779
850
|
this.etags.set(entry.path, r.etag);
|
|
780
851
|
etagUpdates["etag:" + entry.path] = r.etag;
|
|
@@ -894,3 +965,32 @@ export class SyncEngine {
|
|
|
894
965
|
}
|
|
895
966
|
|
|
896
967
|
export { idbRequest };
|
|
968
|
+
|
|
969
|
+
/** A shard is plain JSON: a deep copy is a JSON round trip. */
|
|
970
|
+
function copyShard(shard: Shard): Shard {
|
|
971
|
+
return JSON.parse(JSON.stringify(shard)) as Shard;
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
/** Run at most `n` of the given tasks at once. */
|
|
975
|
+
function limiter(n: number): <T>(task: () => Promise<T>) => Promise<T> {
|
|
976
|
+
let active = 0;
|
|
977
|
+
const queue: (() => void)[] = [];
|
|
978
|
+
const next = () => {
|
|
979
|
+
if (active >= n) return;
|
|
980
|
+
const start = queue.shift();
|
|
981
|
+
if (start) start();
|
|
982
|
+
};
|
|
983
|
+
return (task) =>
|
|
984
|
+
new Promise((resolve, reject) => {
|
|
985
|
+
queue.push(() => {
|
|
986
|
+
active++;
|
|
987
|
+
task()
|
|
988
|
+
.then(resolve, reject)
|
|
989
|
+
.finally(() => {
|
|
990
|
+
active--;
|
|
991
|
+
next();
|
|
992
|
+
});
|
|
993
|
+
});
|
|
994
|
+
next();
|
|
995
|
+
});
|
|
996
|
+
}
|