@memrosetta/sync-client 0.1.5 → 0.1.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/dist/index.d.ts CHANGED
@@ -6,8 +6,21 @@ declare class Outbox {
6
6
  private readonly db;
7
7
  constructor(db: Database.Database);
8
8
  addOp(op: SyncOp): void;
9
- getPending(): readonly SyncOp[];
10
- countPending(): number;
9
+ /**
10
+ * Return pending outbox ops in chronological order.
11
+ *
12
+ * When `userId` is supplied, only ops belonging to that user are
13
+ * returned. This is the v0.5.2 hardening that prevents a
14
+ * cross-user fragmentation: if an older client wrote ops under a
15
+ * legacy `user_id` and the current `SyncClient` is configured with
16
+ * a canonical user, the transport will no longer silently pick up
17
+ * the legacy ops and ship them to the hub. The legacy queue is
18
+ * cleared by `memrosetta migrate legacy-user-ids` before the first
19
+ * post-migration push so this filter is defense-in-depth, not the
20
+ * primary fix.
21
+ */
22
+ getPending(userId?: string): readonly SyncOp[];
23
+ countPending(userId?: string): number;
11
24
  markPushed(opIds: readonly string[]): void;
12
25
  }
13
26
 
package/dist/index.js CHANGED
@@ -52,12 +52,27 @@ var Outbox = class {
52
52
  VALUES (?, ?, ?, ?, ?, ?, ?)`
53
53
  ).run(op.opId, op.opType, op.deviceId, op.userId, payloadStr, op.createdAt, null);
54
54
  }
55
- getPending() {
56
- const rows = this.db.prepare("SELECT * FROM sync_outbox WHERE pushed_at IS NULL ORDER BY created_at ASC").all();
55
+ /**
56
+ * Return pending outbox ops in chronological order.
57
+ *
58
+ * When `userId` is supplied, only ops belonging to that user are
59
+ * returned. This is the v0.5.2 hardening that prevents a
60
+ * cross-user fragmentation: if an older client wrote ops under a
61
+ * legacy `user_id` and the current `SyncClient` is configured with
62
+ * a canonical user, the transport will no longer silently pick up
63
+ * the legacy ops and ship them to the hub. The legacy queue is
64
+ * cleared by `memrosetta migrate legacy-user-ids` before the first
65
+ * post-migration push so this filter is defense-in-depth, not the
66
+ * primary fix.
67
+ */
68
+ getPending(userId) {
69
+ const rows = userId && userId.length > 0 ? this.db.prepare(
70
+ "SELECT * FROM sync_outbox WHERE pushed_at IS NULL AND user_id = ? ORDER BY created_at ASC"
71
+ ).all(userId) : this.db.prepare("SELECT * FROM sync_outbox WHERE pushed_at IS NULL ORDER BY created_at ASC").all();
57
72
  return rows.map(rowToSyncOp);
58
73
  }
59
- countPending() {
60
- const row = this.db.prepare("SELECT COUNT(*) as count FROM sync_outbox WHERE pushed_at IS NULL").get();
74
+ countPending(userId) {
75
+ const row = userId && userId.length > 0 ? this.db.prepare("SELECT COUNT(*) as count FROM sync_outbox WHERE pushed_at IS NULL AND user_id = ?").get(userId) : this.db.prepare("SELECT COUNT(*) as count FROM sync_outbox WHERE pushed_at IS NULL").get();
61
76
  return row.count;
62
77
  }
63
78
  markPushed(opIds) {
@@ -248,6 +263,7 @@ function applyInboxOps(db, ops) {
248
263
 
249
264
  // src/sync-client.ts
250
265
  var MAX_OPS_PER_PUSH = 400;
266
+ var PULL_PAGE_SIZE = 1e3;
251
267
  var SyncClient = class {
252
268
  db;
253
269
  config;
@@ -274,7 +290,7 @@ var SyncClient = class {
274
290
  serverUrl: this.config.serverUrl,
275
291
  userId: this.config.userId,
276
292
  deviceId: this.config.deviceId,
277
- pendingOps: this.outbox.countPending(),
293
+ pendingOps: this.outbox.countPending(this.config.userId),
278
294
  lastPush: {
279
295
  attemptAt: this.getState("last_push_attempt_at"),
280
296
  successAt: this.getState("last_push_success_at")
@@ -289,7 +305,7 @@ var SyncClient = class {
289
305
  async push() {
290
306
  const now = (/* @__PURE__ */ new Date()).toISOString();
291
307
  this.setState("last_push_attempt_at", now);
292
- const pending = this.outbox.getPending();
308
+ const pending = this.outbox.getPending(this.config.userId);
293
309
  if (pending.length === 0) {
294
310
  this.setState("last_push_success_at", now);
295
311
  return { pushed: 0, results: [], highWatermark: 0 };
@@ -340,48 +356,55 @@ var SyncClient = class {
340
356
  }
341
357
  async pull() {
342
358
  this.setState("last_pull_attempt_at", (/* @__PURE__ */ new Date()).toISOString());
343
- const since = this.getCursor();
344
- const params = new URLSearchParams({
345
- since: String(since),
346
- userId: this.config.userId
347
- });
348
- const url = `${this.config.serverUrl}/sync/pull?${params.toString()}`;
349
- const response = await fetch(url, {
350
- method: "GET",
351
- headers: {
352
- Authorization: `Bearer ${this.config.apiKey}`
359
+ let totalPulled = 0;
360
+ let totalSkipped = 0;
361
+ let hasMore = true;
362
+ while (hasMore) {
363
+ const since = this.getCursor();
364
+ const params = new URLSearchParams({
365
+ since: String(since),
366
+ userId: this.config.userId,
367
+ limit: String(PULL_PAGE_SIZE)
368
+ });
369
+ const url = `${this.config.serverUrl}/sync/pull?${params.toString()}`;
370
+ const response = await fetch(url, {
371
+ method: "GET",
372
+ headers: {
373
+ Authorization: `Bearer ${this.config.apiKey}`
374
+ }
375
+ });
376
+ if (!response.ok) {
377
+ throw new Error(`Pull failed: ${response.status} ${response.statusText}`);
353
378
  }
354
- });
355
- if (!response.ok) {
356
- throw new Error(`Pull failed: ${response.status} ${response.statusText}`);
357
- }
358
- const body = await response.json();
359
- const { ops, nextCursor } = body.data;
360
- if (ops.length > 0) {
361
- this.inbox.addOps(ops);
362
- }
363
- const pending = this.inbox.getPending();
364
- let skippedCount = 0;
365
- if (pending.length > 0) {
366
- const result = applyInboxOps(this.db, pending);
367
- if (result.applied.length > 0) {
368
- this.inbox.markApplied(result.applied);
379
+ const body = await response.json();
380
+ const { ops, nextCursor } = body.data;
381
+ hasMore = body.data.hasMore ?? false;
382
+ if (ops.length > 0) {
383
+ this.inbox.addOps(ops);
369
384
  }
370
- skippedCount = result.skipped.length;
371
- if (skippedCount > 0) {
372
- for (const s of result.skipped) {
373
- process.stderr.write(
374
- `[sync] apply skipped op ${s.opId}: ${s.reason}
385
+ const pending = this.inbox.getPending();
386
+ if (pending.length > 0) {
387
+ const result = applyInboxOps(this.db, pending);
388
+ if (result.applied.length > 0) {
389
+ this.inbox.markApplied(result.applied);
390
+ }
391
+ totalSkipped += result.skipped.length;
392
+ if (result.skipped.length > 0) {
393
+ for (const s of result.skipped) {
394
+ process.stderr.write(
395
+ `[sync] apply skipped op ${s.opId}: ${s.reason}
375
396
  `
376
- );
397
+ );
398
+ }
377
399
  }
378
400
  }
401
+ this.setCursor(nextCursor);
402
+ totalPulled += ops.length;
379
403
  }
380
- this.setCursor(nextCursor);
381
- if (skippedCount === 0) {
404
+ if (totalSkipped === 0) {
382
405
  this.setState("last_pull_success_at", (/* @__PURE__ */ new Date()).toISOString());
383
406
  }
384
- return ops.length;
407
+ return totalPulled;
385
408
  }
386
409
  /** For tests / advanced callers: apply currently-pending inbox ops manually. */
387
410
  applyPendingInbox() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@memrosetta/sync-client",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
4
4
  "description": "Local-first sync client for MemRosetta (outbox/inbox, push/pull)",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",