@interop/wallet-core 0.1.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.
Files changed (55) hide show
  1. package/LICENSE.md +20 -0
  2. package/README.md +83 -0
  3. package/dist/index.d.ts +15 -0
  4. package/dist/index.d.ts.map +1 -0
  5. package/dist/index.js +15 -0
  6. package/dist/index.js.map +1 -0
  7. package/dist/space/activity.d.ts +175 -0
  8. package/dist/space/activity.d.ts.map +1 -0
  9. package/dist/space/activity.js +209 -0
  10. package/dist/space/activity.js.map +1 -0
  11. package/dist/space/collections.d.ts +65 -0
  12. package/dist/space/collections.d.ts.map +1 -0
  13. package/dist/space/collections.js +65 -0
  14. package/dist/space/collections.js.map +1 -0
  15. package/dist/space/errors.d.ts +14 -0
  16. package/dist/space/errors.d.ts.map +1 -0
  17. package/dist/space/errors.js +18 -0
  18. package/dist/space/errors.js.map +1 -0
  19. package/dist/space/index.d.ts +26 -0
  20. package/dist/space/index.d.ts.map +1 -0
  21. package/dist/space/index.js +23 -0
  22. package/dist/space/index.js.map +1 -0
  23. package/dist/space/publicLink.d.ts +17 -0
  24. package/dist/space/publicLink.d.ts.map +1 -0
  25. package/dist/space/publicLink.js +27 -0
  26. package/dist/space/publicLink.js.map +1 -0
  27. package/dist/space/wasLink.d.ts +37 -0
  28. package/dist/space/wasLink.d.ts.map +1 -0
  29. package/dist/space/wasLink.js +123 -0
  30. package/dist/space/wasLink.js.map +1 -0
  31. package/dist/sync/collections.d.ts +61 -0
  32. package/dist/sync/collections.d.ts.map +1 -0
  33. package/dist/sync/collections.js +2 -0
  34. package/dist/sync/collections.js.map +1 -0
  35. package/dist/sync/engine.d.ts +112 -0
  36. package/dist/sync/engine.d.ts.map +1 -0
  37. package/dist/sync/engine.js +188 -0
  38. package/dist/sync/engine.js.map +1 -0
  39. package/dist/sync/index.d.ts +33 -0
  40. package/dist/sync/index.d.ts.map +1 -0
  41. package/dist/sync/index.js +29 -0
  42. package/dist/sync/index.js.map +1 -0
  43. package/dist/sync/pull.d.ts +68 -0
  44. package/dist/sync/pull.d.ts.map +1 -0
  45. package/dist/sync/pull.js +92 -0
  46. package/dist/sync/pull.js.map +1 -0
  47. package/dist/sync/push.d.ts +55 -0
  48. package/dist/sync/push.d.ts.map +1 -0
  49. package/dist/sync/push.js +191 -0
  50. package/dist/sync/push.js.map +1 -0
  51. package/dist/sync/types.d.ts +123 -0
  52. package/dist/sync/types.d.ts.map +1 -0
  53. package/dist/sync/types.js +25 -0
  54. package/dist/sync/types.js.map +1 -0
  55. package/package.json +94 -0
@@ -0,0 +1,188 @@
1
+ import { runPull } from './pull.js';
2
+ import { runPush } from './push.js';
3
+ const DEFAULT_BATCH_SIZE = 100;
4
+ const DEFAULT_BASE_DELAY_MS = 1_000;
5
+ const DEFAULT_MAX_DELAY_MS = 60_000;
6
+ function defaultSchedule(fn, delayMs) {
7
+ const timer = setTimeout(fn, delayMs);
8
+ return () => clearTimeout(timer);
9
+ }
10
+ export class SyncEngine {
11
+ deps;
12
+ status = 'idle';
13
+ batchSize;
14
+ baseDelayMs;
15
+ maxDelayMs;
16
+ schedule;
17
+ random;
18
+ running = false;
19
+ rerunRequested = false;
20
+ stopped = false;
21
+ failureCount = 0;
22
+ currentRun = null;
23
+ abortController = null;
24
+ cancelRetry = null;
25
+ constructor(deps) {
26
+ this.deps = deps;
27
+ this.batchSize = deps.batchSize ?? DEFAULT_BATCH_SIZE;
28
+ this.baseDelayMs = deps.backoff?.baseDelayMs ?? DEFAULT_BASE_DELAY_MS;
29
+ this.maxDelayMs = deps.backoff?.maxDelayMs ?? DEFAULT_MAX_DELAY_MS;
30
+ this.schedule = deps.schedule ?? defaultSchedule;
31
+ this.random = deps.random ?? Math.random;
32
+ }
33
+ /**
34
+ * Requests a sync. Single-flight: if a cycle is in flight this only flags a
35
+ * rerun (so writes that land mid-cycle are not lost) and resolves with the
36
+ * in-flight run; otherwise it starts a fresh run. Never rejects -- failures
37
+ * settle into `status = 'error'` plus a scheduled backoff retry, per the
38
+ * local-first invariant (sync must never surface as a rejected write).
39
+ *
40
+ * @returns {Promise<void>}
41
+ */
42
+ sync() {
43
+ if (this.stopped) {
44
+ return Promise.resolve();
45
+ }
46
+ if (this.running && this.currentRun !== null) {
47
+ this.rerunRequested = true;
48
+ return this.currentRun;
49
+ }
50
+ this.currentRun = this.run();
51
+ return this.currentRun;
52
+ }
53
+ /**
54
+ * Stops the engine: aborts any in-flight cycle (the injected signal unwinds
55
+ * pull/push between pages/rows), cancels a pending retry, and resets to idle.
56
+ * The caller drops the cached agents/ciphers so key material does not outlive
57
+ * the unlocked session.
58
+ */
59
+ stop() {
60
+ this.stopped = true;
61
+ this.abortController?.abort();
62
+ this.clearRetry();
63
+ this.setStatus('idle');
64
+ }
65
+ async run() {
66
+ this.running = true;
67
+ this.clearRetry();
68
+ this.setStatus('syncing');
69
+ const controller = new AbortController();
70
+ this.abortController = controller;
71
+ const { signal } = controller;
72
+ try {
73
+ do {
74
+ this.rerunRequested = false;
75
+ await this.runCycle(signal);
76
+ } while (this.rerunRequested && !signal.aborted && !this.stopped);
77
+ if (!signal.aborted && !this.stopped) {
78
+ this.failureCount = 0;
79
+ this.setStatus('synced');
80
+ }
81
+ }
82
+ catch {
83
+ // Local-first: an engine failure is never fatal. A stop-driven abort is
84
+ // not an error; any other failure flips to `error` and schedules a retry.
85
+ if (!signal.aborted && !this.stopped) {
86
+ this.setStatus('error');
87
+ this.scheduleRetry();
88
+ }
89
+ }
90
+ finally {
91
+ this.running = false;
92
+ this.currentRun = null;
93
+ }
94
+ }
95
+ /**
96
+ * One full replication cycle. On the very first run (never migrated) it pulls
97
+ * before the sweep so existing local rows hash-link to any bodies already on
98
+ * the server -- the sweep then only encrypts genuinely-new records
99
+ * (re-encrypting an existing one would mint a different content id and leave a
100
+ * permanent server duplicate). The unlinked-record sweep (`runLazyMigration`)
101
+ * runs on EVERY cycle, not just the first, so records that enter the replica
102
+ * outside the synced write path -- an import, or a write whose minting failed
103
+ * and fell back to a plain insert -- are still picked up and pushed (it is a
104
+ * cheap no-op when there are none). Steady state is sweep-then-push-then-pull:
105
+ * our own writes echo back in the same cycle's pull, idempotently.
106
+ */
107
+ async runCycle(signal) {
108
+ await this.deps.ensureProvisioned();
109
+ if (signal.aborted) {
110
+ return;
111
+ }
112
+ const firstCycle = !(await this.deps.isMigrated());
113
+ if (firstCycle) {
114
+ await this.pull(signal);
115
+ if (signal.aborted) {
116
+ return;
117
+ }
118
+ }
119
+ await this.deps.runLazyMigration(signal);
120
+ if (signal.aborted) {
121
+ return;
122
+ }
123
+ if (firstCycle) {
124
+ await this.deps.stampMigrated();
125
+ if (signal.aborted) {
126
+ return;
127
+ }
128
+ }
129
+ const { conflictsResolved } = await runPush({
130
+ port: this.deps.port,
131
+ store: this.deps.store,
132
+ resolveConflict: this.deps.resolveConflict,
133
+ signal
134
+ });
135
+ if (signal.aborted) {
136
+ return;
137
+ }
138
+ // A resolved last-write-wins conflict may leave a row dirty (local-wins
139
+ // re-encrypt); rerun this run so the re-push settles instead of waiting for
140
+ // the next external trigger. Idempotent once the conflict clears.
141
+ if (conflictsResolved > 0) {
142
+ this.rerunRequested = true;
143
+ }
144
+ const applied = await this.pull(signal);
145
+ if (signal.aborted) {
146
+ return;
147
+ }
148
+ await this.deps.stampLastSynced();
149
+ if (applied > 0) {
150
+ this.deps.onPullApplied?.();
151
+ }
152
+ }
153
+ async pull(signal) {
154
+ const { applied } = await runPull({
155
+ port: this.deps.port,
156
+ store: this.deps.store,
157
+ batchSize: this.batchSize,
158
+ decryptDoc: this.deps.decryptDoc,
159
+ signal
160
+ });
161
+ return applied;
162
+ }
163
+ scheduleRetry() {
164
+ const capped = Math.min(this.baseDelayMs * 2 ** this.failureCount, this.maxDelayMs);
165
+ // Full jitter over the first half of the interval keeps N engines from
166
+ // retrying in lockstep.
167
+ const delay = capped + capped * 0.5 * this.random();
168
+ this.failureCount += 1;
169
+ this.cancelRetry = this.schedule(() => {
170
+ this.cancelRetry = null;
171
+ void this.sync();
172
+ }, delay);
173
+ }
174
+ clearRetry() {
175
+ if (this.cancelRetry !== null) {
176
+ this.cancelRetry();
177
+ this.cancelRetry = null;
178
+ }
179
+ }
180
+ setStatus(status) {
181
+ if (this.status === status) {
182
+ return;
183
+ }
184
+ this.status = status;
185
+ this.deps.onStatusChange?.(status);
186
+ }
187
+ }
188
+ //# sourceMappingURL=engine.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"engine.js","sourceRoot":"","sources":["../../src/sync/engine.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,33 @@
1
+ /*!
2
+ * Copyright (c) 2026 Interop Alliance. All rights reserved.
3
+ */
4
+ /**
5
+ * The `@interop/wallet-core/sync` subpath: the WAS replication engine core --
6
+ * the correctness-critical, cross-replica byte-compatibility surface two
7
+ * WAS-enabled wallet apps share.
8
+ *
9
+ * - The wire contract and port seam (`WasSyncPort`, `SyncCheckpoint`, `WireDoc`,
10
+ * `Json`, `DocCipher`, `MasterState`, and the `WasSyncConflictError` /
11
+ * `WasSyncNotFoundError` signals) come from `@interop/was-client/sync` and are
12
+ * re-exported here so an engine consumer imports one package.
13
+ * - `SyncStore` / `SyncedRow` / `ProjectionAction` / `ResolveConflict` are the
14
+ * replica-side persistence seam.
15
+ * - `runPull` / `projectionForDoc`, `runPush`, and `SyncEngine` are the pull,
16
+ * push, and orchestration algorithms.
17
+ * - `SyncedCollectionSpec` is the generic per-collection spec shape a concrete
18
+ * registry implements.
19
+ *
20
+ * The RxDB adapter (the web wallet's driver) is intentionally not part of this
21
+ * subpath in v0: that app keeps its own `replicateRxCollection` driver, and its
22
+ * metadata (`putMeta` / `metaVersion`) push half stays driver-side. See
23
+ * `push.ts` for why the metadata half is left out of the shared core.
24
+ */
25
+ export { WasSyncConflictError, WasSyncNotFoundError } from '@interop/was-client/sync';
26
+ export type { Json, SyncCheckpoint, WireDoc, MasterState, WasSyncPort, DocCipher } from '@interop/was-client/sync';
27
+ export type { SyncStore, SyncedRow, ProjectionAction, ResolveConflict } from './types.js';
28
+ export { runPull, projectionForDoc } from './pull.js';
29
+ export { runPush, formatEtag } from './push.js';
30
+ export { SyncEngine } from './engine.js';
31
+ export type { SyncEngineDeps, SyncStatus } from './engine.js';
32
+ export type { SyncedCollectionSpec } from './collections.js';
33
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/sync/index.ts"],"names":[],"mappings":"AAAA;;GAEG;AACH;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,OAAO,EACL,oBAAoB,EACpB,oBAAoB,EACrB,MAAM,0BAA0B,CAAA;AACjC,YAAY,EACV,IAAI,EACJ,cAAc,EACd,OAAO,EACP,WAAW,EACX,WAAW,EACX,SAAS,EACV,MAAM,0BAA0B,CAAA;AAEjC,YAAY,EACV,SAAS,EACT,SAAS,EACT,gBAAgB,EAChB,eAAe,EAChB,MAAM,YAAY,CAAA;AAEnB,OAAO,EAAE,OAAO,EAAE,gBAAgB,EAAE,MAAM,WAAW,CAAA;AACrD,OAAO,EAAE,OAAO,EAAE,UAAU,EAAE,MAAM,WAAW,CAAA;AAC/C,OAAO,EAAE,UAAU,EAAE,MAAM,aAAa,CAAA;AACxC,YAAY,EAAE,cAAc,EAAE,UAAU,EAAE,MAAM,aAAa,CAAA;AAC7D,YAAY,EAAE,oBAAoB,EAAE,MAAM,kBAAkB,CAAA"}
@@ -0,0 +1,29 @@
1
+ /*!
2
+ * Copyright (c) 2026 Interop Alliance. All rights reserved.
3
+ */
4
+ /**
5
+ * The `@interop/wallet-core/sync` subpath: the WAS replication engine core --
6
+ * the correctness-critical, cross-replica byte-compatibility surface two
7
+ * WAS-enabled wallet apps share.
8
+ *
9
+ * - The wire contract and port seam (`WasSyncPort`, `SyncCheckpoint`, `WireDoc`,
10
+ * `Json`, `DocCipher`, `MasterState`, and the `WasSyncConflictError` /
11
+ * `WasSyncNotFoundError` signals) come from `@interop/was-client/sync` and are
12
+ * re-exported here so an engine consumer imports one package.
13
+ * - `SyncStore` / `SyncedRow` / `ProjectionAction` / `ResolveConflict` are the
14
+ * replica-side persistence seam.
15
+ * - `runPull` / `projectionForDoc`, `runPush`, and `SyncEngine` are the pull,
16
+ * push, and orchestration algorithms.
17
+ * - `SyncedCollectionSpec` is the generic per-collection spec shape a concrete
18
+ * registry implements.
19
+ *
20
+ * The RxDB adapter (the web wallet's driver) is intentionally not part of this
21
+ * subpath in v0: that app keeps its own `replicateRxCollection` driver, and its
22
+ * metadata (`putMeta` / `metaVersion`) push half stays driver-side. See
23
+ * `push.ts` for why the metadata half is left out of the shared core.
24
+ */
25
+ export { WasSyncConflictError, WasSyncNotFoundError } from '@interop/was-client/sync';
26
+ export { runPull, projectionForDoc } from './pull.js';
27
+ export { runPush, formatEtag } from './push.js';
28
+ export { SyncEngine } from './engine.js';
29
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/sync/index.ts"],"names":[],"mappings":"AAAA;;GAEG;AACH;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,OAAO,EACL,oBAAoB,EACpB,oBAAoB,EACrB,MAAM,0BAA0B,CAAA;AAiBjC,OAAO,EAAE,OAAO,EAAE,gBAAgB,EAAE,MAAM,WAAW,CAAA;AACrD,OAAO,EAAE,OAAO,EAAE,UAAU,EAAE,MAAM,WAAW,CAAA;AAC/C,OAAO,EAAE,UAAU,EAAE,MAAM,aAAa,CAAA"}
@@ -0,0 +1,68 @@
1
+ /*!
2
+ * Copyright (c) 2026 Interop Alliance. All rights reserved.
3
+ */
4
+ /**
5
+ * The pull side of the WAS replication engine core: iterate the `changes` feed
6
+ * from the stored checkpoint, decrypt each document to a projection action
7
+ * OUTSIDE the store transaction, and hand each page to the store to apply
8
+ * atomically.
9
+ *
10
+ * All I/O is through the injected {@link WasSyncPort} and {@link SyncStore};
11
+ * decryption is the injected `decryptDoc`. No environment-specific imports, so
12
+ * the loop runs identically in the browser, Node, and React Native.
13
+ *
14
+ * Cross-replica invariants this loop and the RxDB `createPullHandler` in the web
15
+ * wallet's driver both hold, so the two replicas resume the same feed the same
16
+ * way:
17
+ * - Omit `checkpoint` entirely on the first pull (never send `null`).
18
+ * - Empty page (`checkpoint: null`, "no change") keeps the PRIOR checkpoint --
19
+ * never overwrite it with `null`, or the feed restarts from the beginning.
20
+ * - A tombstone deletes the projected row; a live document decrypts its `data`
21
+ * body to the payload to upsert.
22
+ */
23
+ import type { Json, ProjectionAction, SyncStore, WasSyncPort, WireDoc } from './types.js';
24
+ /**
25
+ * Maps one pulled wire document to the projection action for the decrypted
26
+ * read-model. A tombstone deletes the projected row; a live document decrypts
27
+ * its `data` body to the payload to upsert. A live document with no body (should
28
+ * not occur on an encrypted collection) is a no-op.
29
+ *
30
+ * Decryption runs here, outside the store transaction, so a slow/failing decrypt
31
+ * never holds the store's write lock. A document whose body cannot be decrypted
32
+ * (legacy plaintext row, corrupt/foreign envelope, key mismatch) is skipped with
33
+ * a `none` projection rather than throwing: the body is still stored and the
34
+ * checkpoint advances past it, so one poison document can never permanently
35
+ * wedge the feed for the whole replica.
36
+ *
37
+ * @param doc {WireDoc}
38
+ * @param decryptDoc {(envelope: Json) => Promise<Json>}
39
+ * @returns {Promise<ProjectionAction>}
40
+ */
41
+ export declare function projectionForDoc(doc: WireDoc, decryptDoc: (envelope: Json) => Promise<Json>): Promise<ProjectionAction>;
42
+ /**
43
+ * Runs the pull loop to exhaustion for one feed. Fetches a page from the current
44
+ * checkpoint, decrypts it to projections, and applies it (upserts + projection +
45
+ * checkpoint advance) in one exclusive store transaction. Terminates on a short
46
+ * page (caught up) or an empty page (no change -- the prior checkpoint is kept,
47
+ * never overwritten with `null`). Honors `signal` between pages so a lock drops
48
+ * the loop promptly; a mid-loop abort leaves each already-applied page intact
49
+ * and the feed resumable.
50
+ *
51
+ * @param options {object}
52
+ * @param options.port {WasSyncPort}
53
+ * @param options.store {SyncStore}
54
+ * @param options.batchSize {number} pull `limit` (server clamps at 1000)
55
+ * @param options.decryptDoc {(envelope: Json) => Promise<Json>}
56
+ * @param [options.signal] {AbortSignal}
57
+ * @returns {Promise<{ applied: number }>} documents applied across all pages
58
+ */
59
+ export declare function runPull({ port, store, batchSize, decryptDoc, signal }: {
60
+ port: WasSyncPort;
61
+ store: SyncStore;
62
+ batchSize: number;
63
+ decryptDoc: (envelope: Json) => Promise<Json>;
64
+ signal?: AbortSignal;
65
+ }): Promise<{
66
+ applied: number;
67
+ }>;
68
+ //# sourceMappingURL=pull.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"pull.d.ts","sourceRoot":"","sources":["../../src/sync/pull.ts"],"names":[],"mappings":"AAAA;;GAEG;AACH;;;;;;;;;;;;;;;;;;GAkBG;AACH,OAAO,KAAK,EACV,IAAI,EACJ,gBAAgB,EAEhB,SAAS,EACT,WAAW,EACX,OAAO,EACR,MAAM,YAAY,CAAA;AAEnB;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAsB,gBAAgB,CACpC,GAAG,EAAE,OAAO,EACZ,UAAU,EAAE,CAAC,QAAQ,EAAE,IAAI,KAAK,OAAO,CAAC,IAAI,CAAC,GAC5C,OAAO,CAAC,gBAAgB,CAAC,CAiB3B;AAED;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAsB,OAAO,CAAC,EAC5B,IAAI,EACJ,KAAK,EACL,SAAS,EACT,UAAU,EACV,MAAM,EACP,EAAE;IACD,IAAI,EAAE,WAAW,CAAA;IACjB,KAAK,EAAE,SAAS,CAAA;IAChB,SAAS,EAAE,MAAM,CAAA;IACjB,UAAU,EAAE,CAAC,QAAQ,EAAE,IAAI,KAAK,OAAO,CAAC,IAAI,CAAC,CAAA;IAC7C,MAAM,CAAC,EAAE,WAAW,CAAA;CACrB,GAAG,OAAO,CAAC;IAAE,OAAO,EAAE,MAAM,CAAA;CAAE,CAAC,CA8C/B"}
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Maps one pulled wire document to the projection action for the decrypted
3
+ * read-model. A tombstone deletes the projected row; a live document decrypts
4
+ * its `data` body to the payload to upsert. A live document with no body (should
5
+ * not occur on an encrypted collection) is a no-op.
6
+ *
7
+ * Decryption runs here, outside the store transaction, so a slow/failing decrypt
8
+ * never holds the store's write lock. A document whose body cannot be decrypted
9
+ * (legacy plaintext row, corrupt/foreign envelope, key mismatch) is skipped with
10
+ * a `none` projection rather than throwing: the body is still stored and the
11
+ * checkpoint advances past it, so one poison document can never permanently
12
+ * wedge the feed for the whole replica.
13
+ *
14
+ * @param doc {WireDoc}
15
+ * @param decryptDoc {(envelope: Json) => Promise<Json>}
16
+ * @returns {Promise<ProjectionAction>}
17
+ */
18
+ export async function projectionForDoc(doc, decryptDoc) {
19
+ if (doc._deleted) {
20
+ return { kind: 'delete' };
21
+ }
22
+ if (doc.data === undefined || doc.data === null) {
23
+ return { kind: 'none' };
24
+ }
25
+ try {
26
+ const payload = await decryptDoc(doc.data);
27
+ return { kind: 'upsert', payload };
28
+ }
29
+ catch (err) {
30
+ console.warn(`Skipping undecryptable synced document "${doc.id}" (no projection):`, err);
31
+ return { kind: 'none' };
32
+ }
33
+ }
34
+ /**
35
+ * Runs the pull loop to exhaustion for one feed. Fetches a page from the current
36
+ * checkpoint, decrypts it to projections, and applies it (upserts + projection +
37
+ * checkpoint advance) in one exclusive store transaction. Terminates on a short
38
+ * page (caught up) or an empty page (no change -- the prior checkpoint is kept,
39
+ * never overwritten with `null`). Honors `signal` between pages so a lock drops
40
+ * the loop promptly; a mid-loop abort leaves each already-applied page intact
41
+ * and the feed resumable.
42
+ *
43
+ * @param options {object}
44
+ * @param options.port {WasSyncPort}
45
+ * @param options.store {SyncStore}
46
+ * @param options.batchSize {number} pull `limit` (server clamps at 1000)
47
+ * @param options.decryptDoc {(envelope: Json) => Promise<Json>}
48
+ * @param [options.signal] {AbortSignal}
49
+ * @returns {Promise<{ applied: number }>} documents applied across all pages
50
+ */
51
+ export async function runPull({ port, store, batchSize, decryptDoc, signal }) {
52
+ let applied = 0;
53
+ for (;;) {
54
+ if (signal?.aborted) {
55
+ break;
56
+ }
57
+ const checkpoint = await store.getCheckpoint();
58
+ const { documents, checkpoint: next } = await port.query({
59
+ // Omit `checkpoint` entirely on the first pull.
60
+ ...(checkpoint !== undefined && { checkpoint }),
61
+ limit: batchSize
62
+ });
63
+ // Empty page ("no change") -- or a defensively-guarded null checkpoint on a
64
+ // non-empty page: keep the prior checkpoint so the feed does not restart.
65
+ if (documents.length === 0 || next === null) {
66
+ break;
67
+ }
68
+ const projections = new Map();
69
+ for (const doc of documents) {
70
+ projections.set(doc.id, await projectionForDoc(doc, decryptDoc));
71
+ }
72
+ // A lock/stop between decrypt and apply drops this page (checkpoint not
73
+ // advanced), leaving it to a clean re-pull -- cheaper than holding the write
74
+ // lock across the abort check.
75
+ if (signal?.aborted) {
76
+ break;
77
+ }
78
+ await store.applyPulledPage({
79
+ documents,
80
+ checkpoint: next,
81
+ projections
82
+ });
83
+ applied += documents.length;
84
+ // Short page: the server had fewer than a full batch left, so we are caught
85
+ // up. A full page means there may be more -- loop with the new checkpoint.
86
+ if (documents.length < batchSize) {
87
+ break;
88
+ }
89
+ }
90
+ return { applied };
91
+ }
92
+ //# sourceMappingURL=pull.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"pull.js","sourceRoot":"","sources":["../../src/sync/pull.ts"],"names":[],"mappings":"AA+BA;;;;;;;;;;;;;;;;GAgBG;AACH,MAAM,CAAC,KAAK,UAAU,gBAAgB,CACpC,GAAY,EACZ,UAA6C;IAE7C,IAAI,GAAG,CAAC,QAAQ,EAAE,CAAC;QACjB,OAAO,EAAE,IAAI,EAAE,QAAQ,EAAE,CAAA;IAC3B,CAAC;IACD,IAAI,GAAG,CAAC,IAAI,KAAK,SAAS,IAAI,GAAG,CAAC,IAAI,KAAK,IAAI,EAAE,CAAC;QAChD,OAAO,EAAE,IAAI,EAAE,MAAM,EAAE,CAAA;IACzB,CAAC;IACD,IAAI,CAAC;QACH,MAAM,OAAO,GAAG,MAAM,UAAU,CAAC,GAAG,CAAC,IAAY,CAAC,CAAA;QAClD,OAAO,EAAE,IAAI,EAAE,QAAQ,EAAE,OAAO,EAAE,CAAA;IACpC,CAAC;IAAC,OAAO,GAAG,EAAE,CAAC;QACb,OAAO,CAAC,IAAI,CACV,2CAA2C,GAAG,CAAC,EAAE,oBAAoB,EACrE,GAAG,CACJ,CAAA;QACD,OAAO,EAAE,IAAI,EAAE,MAAM,EAAE,CAAA;IACzB,CAAC;AACH,CAAC;AAED;;;;;;;;;;;;;;;;GAgBG;AACH,MAAM,CAAC,KAAK,UAAU,OAAO,CAAC,EAC5B,IAAI,EACJ,KAAK,EACL,SAAS,EACT,UAAU,EACV,MAAM,EAOP;IACC,IAAI,OAAO,GAAG,CAAC,CAAA;IACf,SAAS,CAAC;QACR,IAAI,MAAM,EAAE,OAAO,EAAE,CAAC;YACpB,MAAK;QACP,CAAC;QAED,MAAM,UAAU,GAAG,MAAM,KAAK,CAAC,aAAa,EAAE,CAAA;QAC9C,MAAM,EAAE,SAAS,EAAE,UAAU,EAAE,IAAI,EAAE,GAAG,MAAM,IAAI,CAAC,KAAK,CAAC;YACvD,gDAAgD;YAChD,GAAG,CAAC,UAAU,KAAK,SAAS,IAAI,EAAE,UAAU,EAAE,CAAC;YAC/C,KAAK,EAAE,SAAS;SACjB,CAAC,CAAA;QAEF,4EAA4E;QAC5E,0EAA0E;QAC1E,IAAI,SAAS,CAAC,MAAM,KAAK,CAAC,IAAI,IAAI,KAAK,IAAI,EAAE,CAAC;YAC5C,MAAK;QACP,CAAC;QAED,MAAM,WAAW,GAAG,IAAI,GAAG,EAA4B,CAAA;QACvD,KAAK,MAAM,GAAG,IAAI,SAAS,EAAE,CAAC;YAC5B,WAAW,CAAC,GAAG,CAAC,GAAG,CAAC,EAAE,EAAE,MAAM,gBAAgB,CAAC,GAAG,EAAE,UAAU,CAAC,CAAC,CAAA;QAClE,CAAC;QAED,wEAAwE;QACxE,6EAA6E;QAC7E,+BAA+B;QAC/B,IAAI,MAAM,EAAE,OAAO,EAAE,CAAC;YACpB,MAAK;QACP,CAAC;QAED,MAAM,KAAK,CAAC,eAAe,CAAC;YAC1B,SAAS;YACT,UAAU,EAAE,IAAsB;YAClC,WAAW;SACZ,CAAC,CAAA;QACF,OAAO,IAAI,SAAS,CAAC,MAAM,CAAA;QAE3B,4EAA4E;QAC5E,2EAA2E;QAC3E,IAAI,SAAS,CAAC,MAAM,GAAG,SAAS,EAAE,CAAC;YACjC,MAAK;QACP,CAAC;IACH,CAAC;IACD,OAAO,EAAE,OAAO,EAAE,CAAA;AACpB,CAAC"}
@@ -0,0 +1,55 @@
1
+ /*!
2
+ * Copyright (c) 2026 Interop Alliance. All rights reserved.
3
+ */
4
+ /**
5
+ * The push side of the WAS replication engine core: fan each dirty local row out
6
+ * to a conditional WAS write, then reconcile per the content-addressed conflict
7
+ * table.
8
+ *
9
+ * On a content-addressed collection an id's `data` never mutates, so a live row
10
+ * only ever pushes as a create (`If-None-Match: *`) and a tombstone as a delete;
11
+ * there is no update path. A mutable (last-write-wins) collection pushes a live
12
+ * row as a create while never-acked (`version 0`) and as an in-place update
13
+ * (`If-Match`) once acked, and settles a `412` through its injected
14
+ * {@link ResolveConflict} policy.
15
+ *
16
+ * This loop covers the CONTENT sub-resource only (`data` / `version`, at
17
+ * `PUT/DELETE /:id`). It does not drive the independently-versioned METADATA
18
+ * sub-resource (`custom` / `metaVersion`, at `PUT /:id/meta`): a replica that
19
+ * syncs user-writable metadata (the web wallet's RxDB driver, via
20
+ * `WasSyncPort.putMeta`) keeps that half in its own push handler. It is left out
21
+ * of this core deliberately -- none of the wallet Space collections
22
+ * (`private-credentials`, `public-credentials`, `wallet-activity`, `contacts`,
23
+ * `contacts-history`) versions its metadata independently of its content, so
24
+ * folding a `putMeta` diff into this loop would add an untested code path with
25
+ * no collection to exercise it. The `WasSyncPort.putMeta` capability stays
26
+ * optional on the port for the driver that needs it.
27
+ */
28
+ import { formatEtag } from '@interop/was-client/sync';
29
+ import type { ResolveConflict, SyncStore, WasSyncPort } from './types.js';
30
+ export { formatEtag };
31
+ /**
32
+ * Pushes every dirty row for one feed, sequentially (bounds sockets/CPU, and
33
+ * keeps conflict reconciliation deterministic). Honors `signal` between rows.
34
+ * A non-conflict error from any row propagates so the engine aborts the cycle
35
+ * and backs off; already-pushed rows in the batch stay settled.
36
+ *
37
+ * @param options {object}
38
+ * @param options.port {WasSyncPort}
39
+ * @param options.store {SyncStore}
40
+ * @param [options.resolveConflict] {ResolveConflict} mutable-collection policy
41
+ * @param [options.signal] {AbortSignal}
42
+ * @returns {Promise<{ pushed: number; conflictsResolved: number }>} dirty rows
43
+ * processed this cycle, and how many invoked the LWW resolver (a positive
44
+ * count means the caller should rerun so a local-wins re-push settles)
45
+ */
46
+ export declare function runPush({ port, store, resolveConflict, signal }: {
47
+ port: WasSyncPort;
48
+ store: SyncStore;
49
+ resolveConflict?: ResolveConflict;
50
+ signal?: AbortSignal;
51
+ }): Promise<{
52
+ pushed: number;
53
+ conflictsResolved: number;
54
+ }>;
55
+ //# sourceMappingURL=push.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"push.d.ts","sourceRoot":"","sources":["../../src/sync/push.ts"],"names":[],"mappings":"AAAA;;GAEG;AACH;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,OAAO,EAAE,UAAU,EAAE,MAAM,0BAA0B,CAAA;AACrD,OAAO,KAAK,EAAQ,eAAe,EAAE,SAAS,EAAE,WAAW,EAAE,MAAM,YAAY,CAAA;AAM/E,OAAO,EAAE,UAAU,EAAE,CAAA;AAoJrB;;;;;;;;;;;;;;GAcG;AACH,wBAAsB,OAAO,CAAC,EAC5B,IAAI,EACJ,KAAK,EACL,eAAe,EACf,MAAM,EACP,EAAE;IACD,IAAI,EAAE,WAAW,CAAA;IACjB,KAAK,EAAE,SAAS,CAAA;IAChB,eAAe,CAAC,EAAE,eAAe,CAAA;IACjC,MAAM,CAAC,EAAE,WAAW,CAAA;CACrB,GAAG,OAAO,CAAC;IAAE,MAAM,EAAE,MAAM,CAAC;IAAC,iBAAiB,EAAE,MAAM,CAAA;CAAE,CAAC,CAwBzD"}