@vue-skuilder/db 0.2.19 → 0.2.20

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/package.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
7
- "version": "0.2.19",
7
+ "version": "0.2.20",
8
8
  "description": "Database layer for vue-skuilder",
9
9
  "main": "dist/index.js",
10
10
  "module": "dist/index.mjs",
@@ -48,7 +48,7 @@
48
48
  },
49
49
  "dependencies": {
50
50
  "@nilock2/pouchdb-authentication": "^1.0.2",
51
- "@vue-skuilder/common": "0.2.19",
51
+ "@vue-skuilder/common": "0.2.20",
52
52
  "cross-fetch": "^4.1.0",
53
53
  "moment": "^2.29.4",
54
54
  "pouchdb": "^9.0.0",
@@ -63,5 +63,5 @@
63
63
  "vite": "^8.0.0",
64
64
  "vitest": "^4.1.0"
65
65
  },
66
- "stableVersion": "0.2.19"
66
+ "stableVersion": "0.2.20"
67
67
  }
@@ -129,6 +129,14 @@ export class BaseUser implements UserDBInterface, DocumentUpdater {
129
129
  private _hydration: UserHydrationStatus = { state: 'not-required' };
130
130
  /** In-flight hydration, so concurrent callers can wait for a real answer. */
131
131
  private _hydrationPromise: Promise<void> | null = null;
132
+ /**
133
+ * Sequence the local mirror was filled to by a hydration that succeeded in
134
+ * THIS init(), handed to startSync() so the live pull can skip history it
135
+ * would otherwise re-walk. Undefined whenever there is no such guarantee —
136
+ * hydration skipped, not required, or failed. Never persisted; see the
137
+ * `since` note in CouchDBSyncStrategy.startSync().
138
+ */
139
+ private _hydratedSeq?: string | number;
132
140
 
133
141
  /**
134
142
  * How far the local mirror can be trusted, RIGHT NOW. See
@@ -769,7 +777,10 @@ Currently logged-in as ${this._username}.`
769
777
  this._hydrationPromise = this.hydrateLocalMirror();
770
778
  await this._hydrationPromise;
771
779
 
772
- this.syncStrategy.startSync(this.localDB, this.remoteDB);
780
+ // _hydratedSeq is set only by a hydration that just succeeded above, for
781
+ // this identity — see hydrateLocalMirror(). Anything else leaves it
782
+ // undefined and the live sync resumes from its own checkpoint.
783
+ this.syncStrategy.startSync(this.localDB, this.remoteDB, this._hydratedSeq);
773
784
  this.applyDesignDocs().catch((error) => {
774
785
  log(`Error in applyDesignDocs background task: ${error}`);
775
786
  if (error && typeof error === 'object') {
@@ -798,6 +809,11 @@ Currently logged-in as ${this._username}.`
798
809
  * ceiling). Callers decide what a failure means for them.
799
810
  */
800
811
  private async hydrateLocalMirror(): Promise<void> {
812
+ // Clear first: init() reruns on every identity change, and a sequence from
813
+ // the departing account would be meaningless against the incoming one's
814
+ // database. Every path below either sets it or leaves it unset.
815
+ this._hydratedSeq = undefined;
816
+
801
817
  // Guests and static-mode users have no remote — setupRemoteDB() hands back
802
818
  // the local database itself, so there is nothing to pull from.
803
819
  if (this.localDB.name === this.remoteDB.name || !this.syncStrategy.hydrate) {
@@ -817,11 +833,12 @@ Currently logged-in as ${this._username}.`
817
833
  const start = Date.now();
818
834
 
819
835
  try {
820
- const { docsWritten } = await withTimeout(
836
+ const { docsWritten, lastSeq } = await withTimeout(
821
837
  this.syncStrategy.hydrate(this.localDB, this.remoteDB),
822
838
  HYDRATION_TIMEOUT_MS,
823
839
  `Hydration of local mirror for ${this._username}`
824
840
  );
841
+ this._hydratedSeq = lastSeq;
825
842
  await this.writeHydrationMarker();
826
843
  this._hydration = {
827
844
  state: 'hydrated',
@@ -33,19 +33,28 @@ export interface SyncStrategy {
33
33
  *
34
34
  * @param localDB The local PouchDB instance
35
35
  * @param remoteDB The remote PouchDB instance
36
- * @returns Count of documents written locally
36
+ * @returns Count of documents written locally, and — only when the pull is
37
+ * known to have landed the remote's complete current state — the sequence
38
+ * it corresponds to, for startSync() to resume from. Omitted means "no
39
+ * safe shortcut, walk from the checkpoint".
37
40
  */
38
41
  hydrate?(
39
42
  localDB: PouchDB.Database,
40
43
  remoteDB: PouchDB.Database
41
- ): Promise<{ docsWritten: number }>;
44
+ ): Promise<{ docsWritten: number; lastSeq?: string | number }>;
42
45
 
43
46
  /**
44
47
  * Start synchronization between local and remote databases
45
48
  * @param localDB The local PouchDB instance
46
49
  * @param remoteDB The remote PouchDB instance
50
+ * @param since Sequence to start the pull from, as returned by a hydrate()
51
+ * in the same session. Omit to resume from the stored checkpoint.
47
52
  */
48
- startSync(localDB: PouchDB.Database, remoteDB: PouchDB.Database): void;
53
+ startSync(
54
+ localDB: PouchDB.Database,
55
+ remoteDB: PouchDB.Database,
56
+ since?: string | number
57
+ ): void;
49
58
 
50
59
  /**
51
60
  * Stop synchronization (optional - for cleanup)
@@ -93,7 +102,11 @@ export interface SyncStrategy {
93
102
  */
94
103
  export abstract class BaseSyncStrategy implements SyncStrategy {
95
104
  abstract setupRemoteDB(username: string): PouchDB.Database;
96
- abstract startSync(localDB: PouchDB.Database, remoteDB: PouchDB.Database): void;
105
+ abstract startSync(
106
+ localDB: PouchDB.Database,
107
+ remoteDB: PouchDB.Database,
108
+ since?: string | number
109
+ ): void;
97
110
  abstract canCreateAccount(): boolean;
98
111
  abstract canAuthenticate(): boolean;
99
112
  abstract getCurrentUsername(): Promise<string>;
@@ -51,26 +51,61 @@ export class CouchDBSyncStrategy implements SyncStrategy {
51
51
  * before we know what the remote already has, which is the conflict-leaf
52
52
  * problem hydration exists to avoid. Local changes go up when startSync()
53
53
  * takes over.
54
+ *
55
+ * Into an EMPTY local DB this skips deleted documents, because a mirror that
56
+ * never held a document does not need to be told it was removed. That
57
+ * matters more than it sounds: scheduled reviews (`card_review_*`) are
58
+ * created and deleted once per review completed, so the changes feed is
59
+ * dominated by tombstones and grows without bound, while the durable record
60
+ * lives in card history. One real account measured 385 live documents behind
61
+ * 8,370 deletions — an unfiltered pull moved 9,520 documents and took ~20s,
62
+ * blowing the hydration timeout on a phone; filtered, the same pull moves
63
+ * 404 and takes ~2s, reaching a byte-identical local state.
64
+ *
65
+ * The filter runs server-side, so `last_seq` still reports the source's true
66
+ * sequence. Returning it lets startSync() begin the live pull from there
67
+ * instead of re-walking the same history in the background — without that,
68
+ * the cost is merely deferred, not removed.
54
69
  */
55
70
  async hydrate(
56
71
  localDB: PouchDB.Database,
57
72
  remoteDB: PouchDB.Database
58
- ): Promise<{ docsWritten: number }> {
73
+ ): Promise<{ docsWritten: number; lastSeq?: string | number }> {
74
+ // Skipping tombstones is only sound when there is nothing local for a
75
+ // missed deletion to strand. A non-empty local DB here means a previous
76
+ // hydration failed partway (no marker was written, so we are retrying) and
77
+ // may hold documents the remote has since deleted — take the slow, honest
78
+ // path and let the full changes feed reconcile them.
79
+ const pristine = (await localDB.info()).doc_count === 0;
80
+
59
81
  // A one-shot Replication is itself a promise of the completed result, so
60
82
  // it can be awaited directly — the handle is retained only so a pull that
61
83
  // outlives its timeout can be cancelled.
62
- const replication = pouch.replicate(remoteDB, localDB, {});
84
+ const replication = pouch.replicate(
85
+ remoteDB,
86
+ localDB,
87
+ pristine ? { selector: { _deleted: { $exists: false } } } : {}
88
+ );
63
89
  this.hydrationHandle = replication;
64
90
 
65
91
  try {
66
92
  const info = await replication;
67
- return { docsWritten: info.docs_written };
93
+ return {
94
+ docsWritten: info.docs_written,
95
+ // Only a pristine pull is a trustworthy starting point for the live
96
+ // sync; otherwise leave startSync() to its own checkpoint.
97
+ lastSeq: pristine ? info.last_seq : undefined,
98
+ };
68
99
  } finally {
69
100
  this.hydrationHandle = undefined;
70
101
  }
71
102
  }
72
103
 
73
- startSync(localDB: PouchDB.Database, remoteDB: PouchDB.Database): void {
104
+ startSync(
105
+ localDB: PouchDB.Database,
106
+ remoteDB: PouchDB.Database,
107
+ since?: string | number
108
+ ): void {
74
109
  // Compare by NAME, not object identity. In guest mode setupRemoteDB()
75
110
  // returns getLocalUserDB(username) — the same database as localDB — but
76
111
  // getLocalUserDB() constructs a fresh PouchDB handle on every call rather
@@ -82,6 +117,19 @@ export class CouchDBSyncStrategy implements SyncStrategy {
82
117
  this.syncHandle = pouch.sync(localDB, remoteDB, {
83
118
  live: true,
84
119
  retry: true,
120
+ // `since` applies to the pull only, and only when hydrate() just
121
+ // established that local matches remote at that sequence. Without it,
122
+ // a filtered hydration leaves the pull with no usable checkpoint (the
123
+ // filter changes the replication id), so it restarts from zero and
124
+ // re-walks in the background exactly the tombstone history hydration
125
+ // just skipped — same work, now competing with the study session.
126
+ //
127
+ // Deliberately NOT persisted across launches: on later boots hydration
128
+ // is skipped and the pull's own checkpoint is the correct resume
129
+ // point, so a stored sequence could only be stale. If the app dies
130
+ // before that first checkpoint is written, the next launch simply
131
+ // walks the full feed once.
132
+ ...(since !== undefined ? { pull: { since } } : {}),
85
133
  });
86
134
  }
87
135
  // If they're the same DB (guest mode), no sync needed