@agentunion/fastaun 0.5.17 → 0.5.18

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 (47) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/_packed_docs/CHANGELOG.md +41 -0
  3. package/_packed_docs/INDEX.md +28 -3
  4. package/_packed_docs/KITE_DOCS_GUIDE.md +11 -5
  5. package/_packed_docs/agent.md/SCHEMA.md +37 -18
  6. package/_packed_docs/aun/345/210/206/345/270/203/345/274/217/346/265/213/350/257/225/350/277/220/350/241/214/346/214/207/345/215/227.md +23 -17
  7. package/_packed_docs/aun/346/265/213/350/257/225/350/277/220/350/241/214/346/214/207/345/215/227.md +4 -2
  8. package/_packed_docs/protocol/01-/350/272/253/344/273/275/344/270/216/345/207/255/350/257/201/345/215/217/350/256/256-auth.md +95 -6
  9. package/_packed_docs/protocol/07-/351/224/231/350/257/257/347/240/201/344/270/216/347/212/266/346/200/201/346/234/272.md +19 -4
  10. package/_packed_docs/protocol/11-Storage-/345/255/220/345/215/217/350/256/256.md +25 -2
  11. package/_packed_docs/sdk/06-API/346/211/213/345/206/214.md +42 -4
  12. package/_packed_docs/sdk/09-storage-rpc-manual.md +25 -4
  13. package/_packed_docs/sdk/INDEX.md +3 -2
  14. package/dist/agent-md-schema.js +2 -0
  15. package/dist/agent-md-schema.js.map +1 -1
  16. package/dist/agent-md.d.ts +1 -1
  17. package/dist/agent-md.js +3 -1
  18. package/dist/agent-md.js.map +1 -1
  19. package/dist/aid-store.d.ts +58 -0
  20. package/dist/aid-store.js +234 -3
  21. package/dist/aid-store.js.map +1 -1
  22. package/dist/aid.d.ts +17 -1
  23. package/dist/aid.js +48 -2
  24. package/dist/aid.js.map +1 -1
  25. package/dist/client/delivery.d.ts +2 -0
  26. package/dist/client/delivery.js +124 -19
  27. package/dist/client/delivery.js.map +1 -1
  28. package/dist/client/rpc-pipeline.js +7 -9
  29. package/dist/client/rpc-pipeline.js.map +1 -1
  30. package/dist/client/v2-e2ee.d.ts +7 -1
  31. package/dist/client/v2-e2ee.js +815 -515
  32. package/dist/client/v2-e2ee.js.map +1 -1
  33. package/dist/client.d.ts +3 -0
  34. package/dist/client.js +67 -7
  35. package/dist/client.js.map +1 -1
  36. package/dist/facades.d.ts +2 -0
  37. package/dist/facades.js +6 -0
  38. package/dist/facades.js.map +1 -1
  39. package/dist/index.d.ts +3 -2
  40. package/dist/index.js +1 -0
  41. package/dist/index.js.map +1 -1
  42. package/dist/ownership-proof.d.ts +40 -0
  43. package/dist/ownership-proof.js +140 -0
  44. package/dist/ownership-proof.js.map +1 -0
  45. package/dist/version.d.ts +1 -1
  46. package/dist/version.js +1 -1
  47. package/package.json +8 -8
@@ -10,6 +10,52 @@ const V2_GROUP_STALE_BOOTSTRAP_CODE = -33054;
10
10
  const V2_PULL_DEFAULT_PAGE_LIMIT = 50;
11
11
  const V2_PULL_MAX_PAGE_LIMIT = 50;
12
12
  const V2_SENDER_IK_SYNC_FETCH_TIMEOUT_MS = 3_000;
13
+ const V2_PULL_PIPELINE_QUEUE_CAPACITY = 100;
14
+ class V2PullPageQueue {
15
+ capacity;
16
+ items = [];
17
+ takers = [];
18
+ putters = [];
19
+ queuedWeight = 0;
20
+ closed = false;
21
+ constructor(capacity) {
22
+ this.capacity = capacity;
23
+ }
24
+ async put(item, weight = 1) {
25
+ const itemWeight = Math.max(1, Math.trunc(weight));
26
+ while (!this.closed && this.items.length > 0 && this.queuedWeight + itemWeight > this.capacity) {
27
+ await new Promise((resolve) => this.putters.push(resolve));
28
+ }
29
+ if (this.closed)
30
+ return false;
31
+ const taker = this.takers.shift();
32
+ if (taker)
33
+ taker(item);
34
+ else {
35
+ this.items.push(item);
36
+ this.queuedWeight += itemWeight;
37
+ }
38
+ return true;
39
+ }
40
+ async take() {
41
+ const item = this.items.shift();
42
+ if (item !== undefined) {
43
+ this.queuedWeight = Math.max(0, this.queuedWeight - Math.max(1, Math.trunc(Number(item.weight ?? 1))));
44
+ this.putters.shift()?.();
45
+ return item;
46
+ }
47
+ if (this.closed)
48
+ return undefined;
49
+ return await new Promise((resolve) => this.takers.push(resolve));
50
+ }
51
+ close() {
52
+ this.closed = true;
53
+ for (const resolve of this.takers.splice(0))
54
+ resolve(undefined);
55
+ for (const resolve of this.putters.splice(0))
56
+ resolve();
57
+ }
58
+ }
13
59
  function pullGateKeyForClient(client, method, params, fallback) {
14
60
  const key = client._rpcPipeline?.pullGateKeyForCall?.(method, params);
15
61
  return typeof key === 'string' && key ? key : fallback;
@@ -1097,6 +1143,11 @@ export class V2E2EECoordinator {
1097
1143
  payload: payload,
1098
1144
  encrypted: false,
1099
1145
  };
1146
+ if (msg.sender_device_id !== undefined)
1147
+ plaintext.sender_device_id = msg.sender_device_id;
1148
+ const direction = String(msg.direction ?? '').trim();
1149
+ if (direction)
1150
+ plaintext.direction = direction;
1100
1151
  attachGatewayProximity(plaintext, msg);
1101
1152
  }
1102
1153
  else if (isJsonObject(payload)
@@ -1114,6 +1165,9 @@ export class V2E2EECoordinator {
1114
1165
  client._clientLog.warn(`P2P Tail 单条消息解密失败,连续性证明仍保留: seq=${seq}`);
1115
1166
  return null;
1116
1167
  }
1168
+ if (publish && client._delivery.isSelfSentP2PMessage?.(plaintext)) {
1169
+ return plaintext;
1170
+ }
1117
1171
  if (publish) {
1118
1172
  const event = client._delivery.p2pAppEventForMessage(plaintext);
1119
1173
  await client._publishAppEvent(event.event, event.payload, source);
@@ -1166,6 +1220,8 @@ export class V2E2EECoordinator {
1166
1220
  payload: payload,
1167
1221
  encrypted: false,
1168
1222
  };
1223
+ if (msg.sender_device_id !== undefined)
1224
+ plaintext.sender_device_id = msg.sender_device_id;
1169
1225
  plaintext = normalizeGroupMentionMode(plaintext, msg, msg.envelope_json);
1170
1226
  attachGatewayProximity(plaintext, msg);
1171
1227
  }
@@ -1290,288 +1346,412 @@ export class V2E2EECoordinator {
1290
1346
  let lastAutoAckSeq = 0;
1291
1347
  let pendingAckSeq = deferredForwardAck;
1292
1348
  const maxPages = Math.max(1, Math.min(100, Math.trunc(Number(opts?.maxPages ?? 100) || 100)));
1293
- while (pageCount < maxPages) {
1294
- pageCount += 1;
1295
- const requestedVolatileCursor = volatileCursor;
1296
- const ackUpToSeq = pendingAckSeq;
1297
- const inlineAckGeneration = ackUpToSeq > 0
1298
- ? client._delivery.captureP2PInlineAckGeneration()
1299
- : null;
1300
- client._clientLog.debug(`message.v2.pull page request: page=${pageCount}, after_seq=${nextAfterSeq}, limit=${limit}, ack_up_to_seq=${ackUpToSeq}, ns=${ns || '<none>'}`);
1301
- let result;
1302
- try {
1303
- result = await client._callRawV2Rpc('message.v2.pull', {
1304
- after_seq: nextAfterSeq,
1305
- limit,
1306
- ...(volatileCursor ? { after_volatile_cursor: volatileCursor } : {}),
1307
- ...(opts?.force ? { force: true } : {}),
1308
- ...(ackUpToSeq > 0 ? { ack_up_to_seq: ackUpToSeq } : {}),
1309
- }, pullGateKey);
1310
- }
1311
- catch (exc) {
1312
- if (!volatileCursor || !String(exc instanceof Error ? exc.message : exc).toLowerCase().includes('volatile cursor epoch mismatch')) {
1313
- throw exc;
1349
+ let pipelineRunning = false;
1350
+ let pipelineError = null;
1351
+ const pipelineQueue = new V2PullPageQueue(V2_PULL_PIPELINE_QUEUE_CAPACITY);
1352
+ let pipelineProducer = null;
1353
+ let pipelineStopped = false;
1354
+ const startPipeline = (startAfterSeq, startVolatileCursor) => {
1355
+ if (pipelineRunning || pageCount >= maxPages || limit < V2_PULL_MAX_PAGE_LIMIT)
1356
+ return;
1357
+ pipelineRunning = true;
1358
+ pipelineProducer = (async () => {
1359
+ let producerAfterSeq = startAfterSeq;
1360
+ let producerVolatileCursor = startVolatileCursor;
1361
+ for (let produced = pageCount; produced < maxPages; produced += 1) {
1362
+ if (pipelineStopped)
1363
+ break;
1364
+ const requestedVolatileCursor = producerVolatileCursor;
1365
+ const result = await client._callRawV2Rpc('message.v2.pull', {
1366
+ after_seq: producerAfterSeq,
1367
+ limit,
1368
+ ...(producerVolatileCursor ? { after_volatile_cursor: producerVolatileCursor } : {}),
1369
+ ...(opts?.force ? { force: true } : {}),
1370
+ }, pullGateKey);
1371
+ if (pipelineStopped)
1372
+ break;
1373
+ const rawPageMessages = (Array.isArray(result?.messages) ? result.messages : []);
1374
+ const enqueued = await pipelineQueue.put({
1375
+ result,
1376
+ afterSeq: producerAfterSeq,
1377
+ ackUpToSeq: 0,
1378
+ inlineAckGeneration: null,
1379
+ requestedVolatileCursor,
1380
+ weight: rawPageMessages.length,
1381
+ }, rawPageMessages.length);
1382
+ if (!enqueued || pipelineStopped)
1383
+ break;
1384
+ const volatileMessages = client._delivery.volatileMessages?.(rawPageMessages) ?? [];
1385
+ if (volatileMessages.length > 0)
1386
+ break;
1387
+ const messages = deduplicateForwardPageMessages(rawPageMessages, producerAfterSeq);
1388
+ const seqs = messages
1389
+ .map((msg) => Number(msg.seq ?? 0))
1390
+ .filter((seq) => Number.isFinite(seq) && seq > 0);
1391
+ const nextAfter = Math.max(contiguousPullSeq(producerAfterSeq, seqs, result.contiguous_seq), producerAfterSeq);
1392
+ const cursor = isJsonObject(result.cursor)
1393
+ ? result.cursor : null;
1394
+ const shouldContinue = shouldContinueForwardPage({
1395
+ rawCount: messages.length,
1396
+ nextAfterSeq: producerAfterSeq,
1397
+ nextAfter,
1398
+ limit,
1399
+ hasMoreField: Object.prototype.hasOwnProperty.call(result, 'has_more'),
1400
+ hasMore: result.has_more === true,
1401
+ serverHead: safeNonNegativeSeq(cursor?.latest_seq) ?? 0,
1402
+ observedUpper: Math.max(nextAfter, Number(client._pendingP2pPullUpper?.get?.(ns) ?? 0)),
1403
+ pendingAckSeq: 0,
1404
+ });
1405
+ if (!shouldContinue)
1406
+ break;
1407
+ producerAfterSeq = nextAfter;
1408
+ producerVolatileCursor = '';
1314
1409
  }
1315
- client._delivery.resetVolatilePullCursor?.();
1316
- volatileCursor = '';
1317
- pageCount -= 1;
1318
- continue;
1319
- }
1320
- if (ackUpToSeq > 0) {
1321
- const actualAckSeq = client._delivery.resolveP2PPullAckSeq(result, ackUpToSeq);
1322
- if (actualAckSeq >= ackUpToSeq) {
1323
- if (inlineAckGeneration !== null) {
1324
- client._delivery.onP2PV2PullAckResult(result, ackUpToSeq, inlineAckGeneration);
1410
+ })().catch((error) => {
1411
+ pipelineError = error;
1412
+ }).finally(() => {
1413
+ pipelineQueue.close();
1414
+ });
1415
+ };
1416
+ const stopPipeline = async () => {
1417
+ pipelineStopped = true;
1418
+ pipelineQueue.close();
1419
+ if (pipelineProducer) {
1420
+ const producer = pipelineProducer;
1421
+ pipelineProducer = null;
1422
+ await producer;
1423
+ }
1424
+ pipelineRunning = false;
1425
+ };
1426
+ try {
1427
+ while (pageCount < maxPages) {
1428
+ let pipelinePage;
1429
+ if (pipelineRunning) {
1430
+ pipelinePage = await pipelineQueue.take();
1431
+ if (!pipelinePage) {
1432
+ pipelineRunning = false;
1433
+ if (pipelineError)
1434
+ throw pipelineError;
1435
+ break;
1325
1436
  }
1326
- pendingAckSeq = 0;
1327
- lastAutoAckSeq = Math.max(lastAutoAckSeq, ackUpToSeq);
1328
- this.clearForwardAck(ns, ackUpToSeq);
1329
1437
  }
1330
- else {
1331
- client._clientLog.warn(`message.v2.pull piggyback ACK below target: requested=${ackUpToSeq}, actual=${actualAckSeq}; fallback terminal ACK`);
1332
- await this.confirmForwardP2PAck(ackUpToSeq);
1333
- pendingAckSeq = 0;
1334
- lastAutoAckSeq = Math.max(lastAutoAckSeq, ackUpToSeq);
1438
+ pageCount += 1;
1439
+ const requestedVolatileCursor = pipelinePage?.requestedVolatileCursor ?? volatileCursor;
1440
+ const ackUpToSeq = pipelinePage?.ackUpToSeq ?? pendingAckSeq;
1441
+ const inlineAckGeneration = pipelinePage?.inlineAckGeneration ?? (ackUpToSeq > 0
1442
+ ? client._delivery.captureP2PInlineAckGeneration()
1443
+ : null);
1444
+ client._clientLog.debug(`message.v2.pull page request: page=${pageCount}, after_seq=${nextAfterSeq}, limit=${limit}, ack_up_to_seq=${ackUpToSeq}, ns=${ns || '<none>'}`);
1445
+ let result;
1446
+ if (pipelinePage) {
1447
+ nextAfterSeq = pipelinePage.afterSeq;
1448
+ result = pipelinePage.result;
1335
1449
  }
1336
- }
1337
- const pageMessages = (Array.isArray(result?.messages) ? result.messages : []);
1338
- const volatileMessages = client._delivery.volatileMessages?.(pageMessages) ?? [];
1339
- if (volatileMessages.length > 0) {
1340
- decrypted.push(...await this.publishV2VolatileMessages(volatileMessages, 'pull', true));
1341
- volatileCursor = String(client._delivery.volatilePullCursor?.() ?? '').trim();
1342
- }
1343
- const messages = deduplicateForwardPageMessages(pageMessages, nextAfterSeq);
1344
- const cursor = isJsonObject(result.cursor) ? result.cursor : null;
1345
- const hasMoreField = Object.prototype.hasOwnProperty.call(result, 'has_more');
1346
- const hasMore = result.has_more === true;
1347
- const volatileHasMore = result.volatile_has_more === true;
1348
- const serverHead = safeNonNegativeSeq(cursor?.latest_seq) ?? 0;
1349
- client._clientLog.debug(`message.v2.pull page response: page=${pageCount}, raw_count=${messages.length}, stale_count=${Math.max(0, pageMessages.length - messages.length)}, has_more=${String(result.has_more ?? '')}, server_head=${serverHead}, server_ack_seq=${String(result.server_ack_seq ?? '')}`);
1350
- for (const msg of messages) {
1351
- client._logMessageDebug('pull-raw', 'message.v2.pull', 'message.received', msg);
1352
- }
1353
- const seqs = messages
1354
- .map((msg) => Number(msg.seq ?? 0))
1355
- .filter((seq) => Number.isFinite(seq) && seq > 0);
1356
- const pageContigBefore = ns ? client._seqTracker.getContiguousSeq(ns) : 0;
1357
- const pageTrackerSnapshot = ns && typeof client._seqTracker.snapshotNamespace === 'function'
1358
- ? client._seqTracker.snapshotNamespace(ns)
1359
- : null;
1360
- let pageMaxSeq = nextAfterSeq;
1361
- if (seqs.length > 0) {
1362
- pageMaxSeq = Math.max(...seqs);
1363
- }
1364
- const pageContiguousSeq = contiguousPullSeq(pageContigBefore, seqs, result.contiguous_seq);
1365
- const deferredKeyFetches = new Map();
1366
- let blockedSeq = 0;
1367
- const pageDecryptedBefore = decrypted.length;
1368
- for (const msg of messages) {
1369
- const seq = Number(msg.seq ?? 0);
1370
- if (!Number.isFinite(seq) || seq <= 0)
1371
- continue;
1372
- const version = String(msg.version ?? 'v2');
1373
- if (version === 'v1') {
1374
- const legacy = isJsonObject(msg.legacy_v1) ? msg.legacy_v1 : {};
1375
- const legacyPayload = legacy.payload !== undefined ? legacy.payload : msg.payload;
1376
- const payloadType = isJsonObject(legacyPayload)
1377
- ? String(legacyPayload.type ?? '').trim()
1378
- : '';
1379
- if (legacyPayload !== undefined && legacyPayload !== null
1380
- && !['e2ee.encrypted', 'e2ee.group_encrypted', 'e2ee.p2p_encrypted'].includes(payloadType)) {
1381
- const v1Msg = {
1382
- message_id: String(msg.message_id ?? ''),
1383
- from: String(msg.from_aid ?? ''),
1384
- to: String(legacy.to ?? client._aid ?? ''),
1385
- seq: msg.seq,
1386
- type: String(msg.type ?? ''),
1387
- timestamp: msg.t_server,
1388
- payload: legacyPayload,
1389
- encrypted: false,
1390
- };
1391
- attachGatewayProximity(v1Msg, msg);
1392
- const appEvent = client._delivery.p2pAppEventForMessage(v1Msg);
1393
- if (ns) {
1394
- await client._publishPulledMessage(appEvent.event, ns, seq, appEvent.payload, false);
1395
- }
1396
- else {
1397
- await client._publishAppEvent(appEvent.event, appEvent.payload, 'pull');
1398
- }
1399
- decrypted.push(v1Msg);
1400
- client._clientLog.debug(`message.v2.pull plaintext V1 delivered: seq=${seq}, ns=${ns || '<none>'}`);
1450
+ else {
1451
+ try {
1452
+ result = await client._callRawV2Rpc('message.v2.pull', {
1453
+ after_seq: nextAfterSeq,
1454
+ limit,
1455
+ ...(volatileCursor ? { after_volatile_cursor: volatileCursor } : {}),
1456
+ ...(opts?.force ? { force: true } : {}),
1457
+ ...(ackUpToSeq > 0 ? { ack_up_to_seq: ackUpToSeq } : {}),
1458
+ }, pullGateKey);
1401
1459
  }
1402
- else if (isJsonObject(legacyPayload)
1403
- && ['e2ee.p2p_encrypted', 'e2ee.encrypted'].includes(payloadType)) {
1404
- const deferStatus = {};
1405
- const plaintext = await client._decryptV2Message({ ...msg, envelope_json: JSON.stringify(legacyPayload) }, true, true, true, true, deferStatus, true, 'pull');
1406
- if (deferStatus.deferred) {
1407
- blockedSeq = blockedSeq > 0 ? Math.min(blockedSeq, seq) : seq;
1408
- if (deferStatus.fromAid) {
1409
- const key = `${deferStatus.fromAid}\u0000${deferStatus.senderDeviceId ?? ''}\u0000${deferStatus.groupId ?? ''}`;
1410
- deferredKeyFetches.set(key, { fromAid: deferStatus.fromAid, senderDeviceId: deferStatus.senderDeviceId ?? '', groupId: deferStatus.groupId ?? '' });
1411
- }
1460
+ catch (exc) {
1461
+ if (!volatileCursor || !String(exc instanceof Error ? exc.message : exc).toLowerCase().includes('volatile cursor epoch mismatch')) {
1462
+ throw exc;
1412
1463
  }
1413
- if (plaintext) {
1414
- if (ns)
1415
- await client._publishPulledMessage('message.received', ns, seq, plaintext, false);
1416
- else
1417
- await client._publishAppEvent('message.received', plaintext, 'pull');
1418
- decrypted.push(plaintext);
1464
+ client._delivery.resetVolatilePullCursor?.();
1465
+ volatileCursor = '';
1466
+ pageCount -= 1;
1467
+ continue;
1468
+ }
1469
+ }
1470
+ if (ackUpToSeq > 0) {
1471
+ const actualAckSeq = client._delivery.resolveP2PPullAckSeq(result, ackUpToSeq);
1472
+ if (actualAckSeq >= ackUpToSeq) {
1473
+ if (inlineAckGeneration !== null) {
1474
+ client._delivery.onP2PV2PullAckResult(result, ackUpToSeq, inlineAckGeneration);
1419
1475
  }
1476
+ pendingAckSeq = 0;
1477
+ lastAutoAckSeq = Math.max(lastAutoAckSeq, ackUpToSeq);
1478
+ this.clearForwardAck(ns, ackUpToSeq);
1420
1479
  }
1421
1480
  else {
1422
- client._clientLog.debug(`message.v2.pull skipping V1 envelope seq=${seq} payload_type=${payloadType || '<none>'} (V1 E2EE removed)`);
1481
+ client._clientLog.warn(`message.v2.pull piggyback ACK below target: requested=${ackUpToSeq}, actual=${actualAckSeq}; fallback terminal ACK`);
1482
+ await this.confirmForwardP2PAck(ackUpToSeq);
1483
+ pendingAckSeq = 0;
1484
+ lastAutoAckSeq = Math.max(lastAutoAckSeq, ackUpToSeq);
1423
1485
  }
1424
- continue;
1425
- }
1426
- if (version !== 'v2') {
1427
- client._clientLog.debug(`message.v2.pull skipping non-V2 row seq=${seq} version=${String(msg.version ?? '')}`);
1428
- continue;
1429
1486
  }
1430
- const spkId = String(msg.spk_id ?? '');
1431
- if (spkId && client._v2Session && !client._v2Session.isCurrentSPK(spkId)) {
1432
- client._v2Session.trackOldSPKMaxSeq(spkId, seq);
1487
+ const pageMessages = (Array.isArray(result?.messages) ? result.messages : []);
1488
+ const volatileMessages = client._delivery.volatileMessages?.(pageMessages) ?? [];
1489
+ if (volatileMessages.length > 0) {
1490
+ decrypted.push(...await this.publishV2VolatileMessages(volatileMessages, 'pull', true));
1491
+ volatileCursor = String(client._delivery.volatilePullCursor?.() ?? '').trim();
1433
1492
  }
1434
- const deferStatus = {};
1435
- const plaintext = await client._decryptV2Message(msg, true, true, true, true, deferStatus, true, 'pull');
1436
- if (deferStatus.deferred) {
1437
- blockedSeq = blockedSeq > 0 ? Math.min(blockedSeq, seq) : seq;
1493
+ const messages = deduplicateForwardPageMessages(pageMessages, nextAfterSeq);
1494
+ const cursor = isJsonObject(result.cursor) ? result.cursor : null;
1495
+ const hasMoreField = Object.prototype.hasOwnProperty.call(result, 'has_more');
1496
+ const hasMore = result.has_more === true;
1497
+ const volatileHasMore = result.volatile_has_more === true;
1498
+ const serverHead = safeNonNegativeSeq(cursor?.latest_seq) ?? 0;
1499
+ client._clientLog.debug(`message.v2.pull page response: page=${pageCount}, raw_count=${messages.length}, stale_count=${Math.max(0, pageMessages.length - messages.length)}, has_more=${String(result.has_more ?? '')}, server_head=${serverHead}, server_ack_seq=${String(result.server_ack_seq ?? '')}`);
1500
+ for (const msg of messages) {
1501
+ client._logMessageDebug('pull-raw', 'message.v2.pull', 'message.received', msg);
1438
1502
  }
1439
- if (deferStatus.deferred && deferStatus.fromAid) {
1440
- const key = `${deferStatus.fromAid}\u0000${deferStatus.senderDeviceId ?? ''}\u0000${deferStatus.groupId ?? ''}`;
1441
- deferredKeyFetches.set(key, {
1442
- fromAid: deferStatus.fromAid,
1443
- senderDeviceId: deferStatus.senderDeviceId ?? '',
1444
- groupId: deferStatus.groupId ?? '',
1445
- });
1503
+ const seqs = messages
1504
+ .map((msg) => Number(msg.seq ?? 0))
1505
+ .filter((seq) => Number.isFinite(seq) && seq > 0);
1506
+ const pageContigBefore = ns ? client._seqTracker.getContiguousSeq(ns) : 0;
1507
+ const pageTrackerSnapshot = ns && typeof client._seqTracker.snapshotNamespace === 'function'
1508
+ ? client._seqTracker.snapshotNamespace(ns)
1509
+ : null;
1510
+ let pageMaxSeq = nextAfterSeq;
1511
+ if (seqs.length > 0) {
1512
+ pageMaxSeq = Math.max(...seqs);
1446
1513
  }
1447
- if (plaintext === null) {
1448
- client._clientLog.debug(`message.v2.pull decrypt returned null: seq=${seq}, ns=${ns || '<none>'}`);
1449
- continue;
1514
+ const pageContiguousSeq = contiguousPullSeq(pageContigBefore, seqs, result.contiguous_seq);
1515
+ const deferredKeyFetches = new Map();
1516
+ let blockedSeq = 0;
1517
+ const pageDecryptedBefore = decrypted.length;
1518
+ for (const msg of messages) {
1519
+ const seq = Number(msg.seq ?? 0);
1520
+ if (!Number.isFinite(seq) || seq <= 0)
1521
+ continue;
1522
+ const version = String(msg.version ?? 'v2');
1523
+ if (version === 'v1') {
1524
+ const legacy = isJsonObject(msg.legacy_v1) ? msg.legacy_v1 : {};
1525
+ const legacyPayload = legacy.payload !== undefined ? legacy.payload : msg.payload;
1526
+ const payloadType = isJsonObject(legacyPayload)
1527
+ ? String(legacyPayload.type ?? '').trim()
1528
+ : '';
1529
+ if (legacyPayload !== undefined && legacyPayload !== null
1530
+ && !['e2ee.encrypted', 'e2ee.group_encrypted', 'e2ee.p2p_encrypted'].includes(payloadType)) {
1531
+ const v1Msg = {
1532
+ message_id: String(msg.message_id ?? ''),
1533
+ from: String(msg.from_aid ?? ''),
1534
+ to: String(legacy.to ?? client._aid ?? ''),
1535
+ seq: msg.seq,
1536
+ type: String(msg.type ?? ''),
1537
+ timestamp: msg.t_server,
1538
+ payload: legacyPayload,
1539
+ encrypted: false,
1540
+ };
1541
+ const direction = String(msg.direction ?? '').trim();
1542
+ if (direction)
1543
+ v1Msg.direction = direction;
1544
+ else if (String(msg.from_aid ?? '').trim() && String(msg.from_aid ?? '').trim() !== String(client._aid ?? '').trim()) {
1545
+ v1Msg.direction = 'inbound';
1546
+ }
1547
+ if (msg.sender_device_id !== undefined)
1548
+ v1Msg.sender_device_id = msg.sender_device_id;
1549
+ attachGatewayProximity(v1Msg, msg);
1550
+ const appEvent = client._delivery.p2pAppEventForMessage(v1Msg);
1551
+ if (ns) {
1552
+ await client._publishPulledMessage(appEvent.event, ns, seq, appEvent.payload, false);
1553
+ }
1554
+ else {
1555
+ await client._publishAppEvent(appEvent.event, appEvent.payload, 'pull');
1556
+ }
1557
+ decrypted.push(v1Msg);
1558
+ client._clientLog.debug(`message.v2.pull plaintext V1 delivered: seq=${seq}, ns=${ns || '<none>'}`);
1559
+ }
1560
+ else if (isJsonObject(legacyPayload)
1561
+ && ['e2ee.p2p_encrypted', 'e2ee.encrypted'].includes(payloadType)) {
1562
+ const deferStatus = {};
1563
+ const plaintext = await client._decryptV2Message({ ...msg, envelope_json: JSON.stringify(legacyPayload) }, true, true, true, true, deferStatus, true, 'pull');
1564
+ if (deferStatus.deferred) {
1565
+ blockedSeq = blockedSeq > 0 ? Math.min(blockedSeq, seq) : seq;
1566
+ if (deferStatus.fromAid) {
1567
+ const key = `${deferStatus.fromAid}\u0000${deferStatus.senderDeviceId ?? ''}\u0000${deferStatus.groupId ?? ''}`;
1568
+ deferredKeyFetches.set(key, { fromAid: deferStatus.fromAid, senderDeviceId: deferStatus.senderDeviceId ?? '', groupId: deferStatus.groupId ?? '' });
1569
+ }
1570
+ }
1571
+ if (plaintext) {
1572
+ if (ns)
1573
+ await client._publishPulledMessage('message.received', ns, seq, plaintext, false);
1574
+ else
1575
+ await client._publishAppEvent('message.received', plaintext, 'pull');
1576
+ decrypted.push(plaintext);
1577
+ }
1578
+ }
1579
+ else {
1580
+ client._clientLog.debug(`message.v2.pull skipping V1 envelope seq=${seq} payload_type=${payloadType || '<none>'} (V1 E2EE removed)`);
1581
+ }
1582
+ continue;
1583
+ }
1584
+ if (version !== 'v2') {
1585
+ client._clientLog.debug(`message.v2.pull skipping non-V2 row seq=${seq} version=${String(msg.version ?? '')}`);
1586
+ continue;
1587
+ }
1588
+ const spkId = String(msg.spk_id ?? '');
1589
+ if (spkId && client._v2Session && !client._v2Session.isCurrentSPK(spkId)) {
1590
+ client._v2Session.trackOldSPKMaxSeq(spkId, seq);
1591
+ }
1592
+ const deferStatus = {};
1593
+ const plaintext = await client._decryptV2Message(msg, true, true, true, true, deferStatus, true, 'pull');
1594
+ if (deferStatus.deferred) {
1595
+ blockedSeq = blockedSeq > 0 ? Math.min(blockedSeq, seq) : seq;
1596
+ }
1597
+ if (deferStatus.deferred && deferStatus.fromAid) {
1598
+ const key = `${deferStatus.fromAid}\u0000${deferStatus.senderDeviceId ?? ''}\u0000${deferStatus.groupId ?? ''}`;
1599
+ deferredKeyFetches.set(key, {
1600
+ fromAid: deferStatus.fromAid,
1601
+ senderDeviceId: deferStatus.senderDeviceId ?? '',
1602
+ groupId: deferStatus.groupId ?? '',
1603
+ });
1604
+ }
1605
+ if (plaintext === null) {
1606
+ client._clientLog.debug(`message.v2.pull decrypt returned null: seq=${seq}, ns=${ns || '<none>'}`);
1607
+ continue;
1608
+ }
1609
+ if (ns) {
1610
+ await client._publishPulledMessage('message.received', ns, seq, plaintext, false);
1611
+ }
1612
+ else {
1613
+ await client._publishAppEvent('message.received', plaintext, 'pull');
1614
+ }
1615
+ decrypted.push(plaintext);
1616
+ client._logMessageDebug('decrypt-ok', 'message.v2.pull', 'message.received', plaintext);
1450
1617
  }
1618
+ const hasServerAckSeq = Object.prototype.hasOwnProperty.call(result, 'server_ack_seq');
1619
+ const parsedServerAckSeq = hasServerAckSeq ? safeNonNegativeSeq(result.server_ack_seq) : null;
1620
+ const serverAckSeq = parsedServerAckSeq ?? 0;
1621
+ const retentionFloor = Math.max(0, Number(result.retention_floor_seq ?? 0));
1451
1622
  if (ns) {
1452
- await client._publishPulledMessage('message.received', ns, seq, plaintext, false);
1453
- }
1454
- else {
1455
- await client._publishAppEvent('message.received', plaintext, 'pull');
1456
- }
1457
- decrypted.push(plaintext);
1458
- client._logMessageDebug('decrypt-ok', 'message.v2.pull', 'message.received', plaintext);
1459
- }
1460
- const hasServerAckSeq = Object.prototype.hasOwnProperty.call(result, 'server_ack_seq');
1461
- const parsedServerAckSeq = hasServerAckSeq ? safeNonNegativeSeq(result.server_ack_seq) : null;
1462
- const serverAckSeq = parsedServerAckSeq ?? 0;
1463
- const retentionFloor = Math.max(0, Number(result.retention_floor_seq ?? 0));
1464
- if (ns) {
1465
- const commitTarget = Math.max(pageContigBefore, Number.isFinite(retentionFloor) ? retentionFloor : 0, pageCount === 1 ? deferredServerCursor : 0, serverAckSeq, blockedSeq > 0 ? blockedSeq - 1 : pageContiguousSeq);
1466
- let ackSeq = pageContigBefore;
1467
- let contigAdvanced = false;
1468
- try {
1469
- if (commitTarget > pageContigBefore) {
1470
- client._seqTracker.forceContiguousSeq(ns, commitTarget);
1471
- if (retentionFloor > pageContigBefore) {
1472
- client._clientLog.info(`message.v2.pull retention-floor advance: ns=${ns} contiguous=${pageContigBefore} -> retention_floor_seq=${retentionFloor}`);
1623
+ const commitTarget = Math.max(pageContigBefore, Number.isFinite(retentionFloor) ? retentionFloor : 0, pageCount === 1 ? deferredServerCursor : 0, serverAckSeq, blockedSeq > 0 ? blockedSeq - 1 : pageContiguousSeq);
1624
+ let ackSeq = pageContigBefore;
1625
+ let contigAdvanced = false;
1626
+ try {
1627
+ if (commitTarget > pageContigBefore) {
1628
+ client._seqTracker.forceContiguousSeq(ns, commitTarget);
1629
+ if (retentionFloor > pageContigBefore) {
1630
+ client._clientLog.info(`message.v2.pull retention-floor advance: ns=${ns} contiguous=${pageContigBefore} -> retention_floor_seq=${retentionFloor}`);
1631
+ }
1632
+ if (deferredServerCursor > pageContigBefore && pageCount === 1) {
1633
+ client._clientLog.info(`message.v2.pull restore local A from deferred server ACK cursor: ns=${ns}, contiguous=${pageContigBefore} -> server_ack_seq=${deferredServerCursor}`);
1634
+ }
1635
+ if (seqs.length > 0) {
1636
+ client._clientLog.debug(`message.v2.pull force contiguous: ns=${ns}, page_max_seq=${pageMaxSeq}, previous=${pageContigBefore}`);
1637
+ }
1473
1638
  }
1474
- if (deferredServerCursor > pageContigBefore && pageCount === 1) {
1475
- client._clientLog.info(`message.v2.pull restore local A from deferred server ACK cursor: ns=${ns}, contiguous=${pageContigBefore} -> server_ack_seq=${deferredServerCursor}`);
1639
+ ackSeq = client._seqTracker.getContiguousSeq(ns);
1640
+ contigAdvanced = ackSeq !== pageContigBefore;
1641
+ if (contigAdvanced) {
1642
+ await client._commitSeqTrackerState(ns);
1643
+ if (pageCount === 1 && deferredServerCursor > 0)
1644
+ this.clearForwardCursor(ns, ackSeq);
1476
1645
  }
1477
- if (seqs.length > 0) {
1478
- client._clientLog.debug(`message.v2.pull force contiguous: ns=${ns}, page_max_seq=${pageMaxSeq}, previous=${pageContigBefore}`);
1646
+ }
1647
+ catch (exc) {
1648
+ if (typeof client._seqTracker.restoreNamespaceSnapshot === 'function') {
1649
+ client._seqTracker.restoreNamespaceSnapshot(ns, pageTrackerSnapshot);
1479
1650
  }
1651
+ client._clientLog.error?.(`message.v2.pull Forward commit rolled back: ns=${ns} target=${commitTarget} error=${formatE2EEError(exc)}`);
1652
+ throw exc;
1480
1653
  }
1481
- ackSeq = client._seqTracker.getContiguousSeq(ns);
1482
- contigAdvanced = ackSeq !== pageContigBefore;
1483
- if (contigAdvanced) {
1484
- await client._commitSeqTrackerState(ns);
1485
- if (pageCount === 1 && deferredServerCursor > 0)
1486
- this.clearForwardCursor(ns, ackSeq);
1654
+ if (contigAdvanced)
1655
+ await client._drainOrderedMessages(ns, undefined, false, false, 'pull');
1656
+ for (const fetch of deferredKeyFetches.values()) {
1657
+ this.scheduleSenderIKFetch(fetch.fromAid, fetch.senderDeviceId, fetch.groupId);
1487
1658
  }
1488
- }
1489
- catch (exc) {
1490
- if (typeof client._seqTracker.restoreNamespaceSnapshot === 'function') {
1491
- client._seqTracker.restoreNamespaceSnapshot(ns, pageTrackerSnapshot);
1659
+ if (parsedServerAckSeq !== null && serverAckSeq > ackSeq) {
1660
+ this.recordForwardCursor(ns, serverAckSeq, ackSeq);
1661
+ }
1662
+ // 无本页服务端水位时,只有本页成功提交了比已知服务端更高的 A
1663
+ // 才新增 ACK 义务。空页不能仅因本地已有 A 而重复 ACK。
1664
+ const knownServerAckSeq = hasServerAckSeq
1665
+ ? serverAckSeq
1666
+ : (pageCount === 1 ? deferredServerCursor : 0);
1667
+ const ackNeeded = ackSeq > 0
1668
+ && ackSeq > lastAutoAckSeq
1669
+ && ((hasServerAckSeq && ackSeq > serverAckSeq)
1670
+ || (contigAdvanced && ackSeq > knownServerAckSeq));
1671
+ if (ackNeeded) {
1672
+ this.recordForwardAck(ns, ackSeq);
1673
+ const nextAfter = Math.max(pageContiguousSeq, nextAfterSeq);
1674
+ const canContinuePage = blockedSeq <= 0 && messages.length > 0 && nextAfter > nextAfterSeq;
1675
+ if (canContinuePage && !pipelineRunning) {
1676
+ pendingAckSeq = Math.max(pendingAckSeq, ackSeq);
1677
+ lastAutoAckSeq = Math.max(lastAutoAckSeq, ackSeq);
1678
+ client._clientLog.debug(`message.v2.pull queued piggyback auto-ack: ns=${ns}, ack_seq=${pendingAckSeq}, raw_count=${messages.length}`);
1679
+ }
1680
+ else {
1681
+ client._clientLog.debug(`message.v2.pull sending terminal auto-ack: ns=${ns}, ack_seq=${ackSeq}, raw_count=${messages.length}`);
1682
+ await this.confirmForwardP2PAck(ackSeq);
1683
+ lastAutoAckSeq = Math.max(lastAutoAckSeq, ackSeq);
1684
+ }
1492
1685
  }
1493
- client._clientLog.error?.(`message.v2.pull Forward commit rolled back: ns=${ns} target=${commitTarget} error=${formatE2EEError(exc)}`);
1494
- throw exc;
1495
- }
1496
- if (contigAdvanced)
1497
- await client._drainOrderedMessages(ns, undefined, false, false, 'pull');
1498
- for (const fetch of deferredKeyFetches.values()) {
1499
- this.scheduleSenderIKFetch(fetch.fromAid, fetch.senderDeviceId, fetch.groupId);
1500
- }
1501
- if (parsedServerAckSeq !== null && serverAckSeq > ackSeq) {
1502
- this.recordForwardCursor(ns, serverAckSeq, ackSeq);
1503
1686
  }
1504
- // 无本页服务端水位时,只有本页成功提交了比已知服务端更高的 A
1505
- // 才新增 ACK 义务。空页不能仅因本地已有 A 而重复 ACK。
1506
- const knownServerAckSeq = hasServerAckSeq
1507
- ? serverAckSeq
1508
- : (pageCount === 1 ? deferredServerCursor : 0);
1509
- const ackNeeded = ackSeq > 0
1510
- && ackSeq > lastAutoAckSeq
1511
- && ((hasServerAckSeq && ackSeq > serverAckSeq)
1512
- || (contigAdvanced && ackSeq > knownServerAckSeq));
1513
- if (ackNeeded) {
1514
- this.recordForwardAck(ns, ackSeq);
1515
- const nextAfter = Math.max(pageContiguousSeq, nextAfterSeq);
1516
- const canContinuePage = blockedSeq <= 0 && messages.length > 0 && nextAfter > nextAfterSeq;
1517
- if (canContinuePage) {
1518
- pendingAckSeq = Math.max(pendingAckSeq, ackSeq);
1519
- lastAutoAckSeq = Math.max(lastAutoAckSeq, ackSeq);
1520
- client._clientLog.debug(`message.v2.pull queued piggyback auto-ack: ns=${ns}, ack_seq=${pendingAckSeq}, raw_count=${messages.length}`);
1687
+ if (!ns) {
1688
+ for (const fetch of deferredKeyFetches.values()) {
1689
+ this.scheduleSenderIKFetch(fetch.fromAid, fetch.senderDeviceId, fetch.groupId);
1521
1690
  }
1522
- else {
1523
- client._clientLog.debug(`message.v2.pull sending terminal auto-ack: ns=${ns}, ack_seq=${ackSeq}, raw_count=${messages.length}`);
1524
- await this.confirmForwardP2PAck(ackSeq);
1525
- lastAutoAckSeq = Math.max(lastAutoAckSeq, ackSeq);
1691
+ }
1692
+ if (ns)
1693
+ client._delivery.syncProgress(ns, {
1694
+ rawCount: messages.length,
1695
+ pulledCount: decrypted.length - pageDecryptedBefore,
1696
+ remaining: result.remaining,
1697
+ hasMore: hasMoreField ? hasMore : undefined,
1698
+ currentSeq: client._seqTracker.getContiguousSeq(ns),
1699
+ targetSeq: serverHead,
1700
+ });
1701
+ const nextAfter = Math.max(pageContiguousSeq, nextAfterSeq);
1702
+ const rawCount = messages.length;
1703
+ if (blockedSeq > 0)
1704
+ break;
1705
+ const shouldContinuePersistent = shouldContinueForwardPage({
1706
+ rawCount,
1707
+ nextAfterSeq,
1708
+ nextAfter,
1709
+ limit,
1710
+ hasMoreField,
1711
+ hasMore,
1712
+ serverHead,
1713
+ observedUpper: Math.max(client._seqTracker.getMaxSeenSeq(ns), Number(client._pendingP2pPullUpper?.get?.(ns) ?? 0)),
1714
+ pendingAckSeq,
1715
+ });
1716
+ const shouldContinueForPipeline = shouldContinueForwardPage({
1717
+ rawCount,
1718
+ nextAfterSeq,
1719
+ nextAfter,
1720
+ limit,
1721
+ hasMoreField,
1722
+ hasMore,
1723
+ serverHead,
1724
+ observedUpper: Math.max(client._seqTracker.getMaxSeenSeq(ns), Number(client._pendingP2pPullUpper?.get?.(ns) ?? 0)),
1725
+ pendingAckSeq: 0,
1726
+ });
1727
+ const shouldContinueVolatile = (hasMore || volatileHasMore)
1728
+ && volatileMessages.length > 0
1729
+ && Boolean(volatileCursor)
1730
+ && volatileCursor !== requestedVolatileCursor;
1731
+ if (!shouldContinuePersistent && !shouldContinueVolatile)
1732
+ break;
1733
+ if (shouldContinueForPipeline && !pipelineRunning && limit >= V2_PULL_MAX_PAGE_LIMIT) {
1734
+ if (pendingAckSeq > 0) {
1735
+ await this.confirmForwardP2PAck(pendingAckSeq);
1736
+ pendingAckSeq = 0;
1526
1737
  }
1738
+ startPipeline(nextAfter, volatileCursor);
1527
1739
  }
1528
- }
1529
- if (!ns) {
1530
- for (const fetch of deferredKeyFetches.values()) {
1531
- this.scheduleSenderIKFetch(fetch.fromAid, fetch.senderDeviceId, fetch.groupId);
1740
+ if (!pipelineRunning && pullGateKey && opts?.gateLocked && client._rpcPipeline?.yieldPullGate) {
1741
+ const nextKey = pullGateKeyForClient(client, 'message.v2.pull', {
1742
+ after_seq: nextAfter, force: opts?.force === true, limit,
1743
+ }, `${ns}|forward|after=${nextAfter}|force=${String(Boolean(opts?.force))}|limit=${limit}`);
1744
+ await client._rpcPipeline.yieldPullGate(pullGateKey, nextKey, true);
1745
+ pullGateKey = nextKey;
1532
1746
  }
1747
+ const tailDelayMs = v2PullTailDelayMs(rawCount, limit);
1748
+ if (tailDelayMs > 0)
1749
+ await v2Sleep(tailDelayMs);
1750
+ nextAfterSeq = nextAfter;
1533
1751
  }
1534
- if (ns)
1535
- client._delivery.syncProgress(ns, {
1536
- rawCount: messages.length,
1537
- pulledCount: decrypted.length - pageDecryptedBefore,
1538
- remaining: result.remaining,
1539
- hasMore: hasMoreField ? hasMore : undefined,
1540
- currentSeq: client._seqTracker.getContiguousSeq(ns),
1541
- targetSeq: serverHead,
1542
- });
1543
- const nextAfter = Math.max(pageContiguousSeq, nextAfterSeq);
1544
- const rawCount = messages.length;
1545
- if (blockedSeq > 0)
1546
- break;
1547
- const shouldContinuePersistent = shouldContinueForwardPage({
1548
- rawCount,
1549
- nextAfterSeq,
1550
- nextAfter,
1551
- limit,
1552
- hasMoreField,
1553
- hasMore,
1554
- serverHead,
1555
- observedUpper: Math.max(client._seqTracker.getMaxSeenSeq(ns), Number(client._pendingP2pPullUpper?.get?.(ns) ?? 0)),
1556
- pendingAckSeq,
1557
- });
1558
- const shouldContinueVolatile = (hasMore || volatileHasMore)
1559
- && volatileMessages.length > 0
1560
- && Boolean(volatileCursor)
1561
- && volatileCursor !== requestedVolatileCursor;
1562
- if (!shouldContinuePersistent && !shouldContinueVolatile)
1563
- break;
1564
- if (pullGateKey && opts?.gateLocked && client._rpcPipeline?.yieldPullGate) {
1565
- const nextKey = pullGateKeyForClient(client, 'message.v2.pull', {
1566
- after_seq: nextAfter, force: opts?.force === true, limit,
1567
- }, `${ns}|forward|after=${nextAfter}|force=${String(Boolean(opts?.force))}|limit=${limit}`);
1568
- await client._rpcPipeline.yieldPullGate(pullGateKey, nextKey, true);
1569
- pullGateKey = nextKey;
1570
- }
1571
- const tailDelayMs = v2PullTailDelayMs(rawCount, limit);
1572
- if (tailDelayMs > 0)
1573
- await v2Sleep(tailDelayMs);
1574
- nextAfterSeq = nextAfter;
1752
+ }
1753
+ finally {
1754
+ await stopPipeline();
1575
1755
  }
1576
1756
  if (pageCount >= maxPages) {
1577
1757
  client._clientLog.warn(`message.v2.pull reached max_pages=${maxPages} after_seq=${nextAfterSeq}`);
@@ -2109,91 +2289,208 @@ export class V2E2EECoordinator {
2109
2289
  let lastAutoAckSeq = 0;
2110
2290
  let pendingAckSeq = deferredForwardAck;
2111
2291
  const maxPages = Math.max(1, Math.min(100, Math.trunc(Number(opts?.maxPages ?? 100) || 100)));
2112
- while (pageCount < maxPages) {
2113
- pageCount += 1;
2114
- const ackUpToSeq = ownsCursor ? pendingAckSeq : 0;
2115
- const inlineAckGeneration = ackUpToSeq > 0
2116
- ? client._delivery.captureGroupInlineAckGeneration()
2117
- : null;
2118
- client._clientLog.debug(`group.v2.pull page request: group=${gid}, page=${pageCount}, after_seq=${nextAfterSeq}, limit=${limit}, ack_up_to_seq=${ackUpToSeq}, ns=${ns}`);
2119
- const result = await client._callRawV2Rpc('group.v2.pull', withExplicitGroupAid({
2120
- group_id: wireGroupId,
2121
- after_seq: nextAfterSeq,
2122
- limit,
2123
- ...cursorParams,
2124
- ...(opts?.force ? { force: true } : {}),
2125
- ...(ackUpToSeq > 0 ? { ack_up_to_seq: ackUpToSeq } : {}),
2126
- }, groupAid), pullGateKey);
2127
- if (ackUpToSeq > 0) {
2128
- const actualAckSeq = client._delivery.resolveGroupPullAckSeq(result, ackUpToSeq);
2129
- if (actualAckSeq >= ackUpToSeq) {
2130
- if (inlineAckGeneration !== null) {
2131
- client._delivery.onGroupV2PullAckResult(gid, result, ackUpToSeq, inlineAckGeneration);
2292
+ let pipelineRunning = false;
2293
+ let pipelineError = null;
2294
+ const pipelineQueue = new V2PullPageQueue(V2_PULL_PIPELINE_QUEUE_CAPACITY);
2295
+ let pipelineProducer = null;
2296
+ let pipelineStopped = false;
2297
+ const startPipeline = (startAfterSeq) => {
2298
+ if (pipelineRunning || pageCount >= maxPages || limit < V2_PULL_MAX_PAGE_LIMIT || !ownsCursor)
2299
+ return;
2300
+ pipelineRunning = true;
2301
+ pipelineProducer = (async () => {
2302
+ let producerAfterSeq = startAfterSeq;
2303
+ for (let produced = pageCount; produced < maxPages; produced += 1) {
2304
+ if (pipelineStopped)
2305
+ break;
2306
+ const result = await client._callRawV2Rpc('group.v2.pull', withExplicitGroupAid({
2307
+ group_id: wireGroupId,
2308
+ after_seq: producerAfterSeq,
2309
+ limit,
2310
+ ...cursorParams,
2311
+ ...(opts?.force ? { force: true } : {}),
2312
+ }, groupAid), pullGateKey);
2313
+ if (pipelineStopped)
2314
+ break;
2315
+ const pageMessages = (Array.isArray(result.messages) ? result.messages : []);
2316
+ const enqueued = await pipelineQueue.put({
2317
+ result,
2318
+ afterSeq: producerAfterSeq,
2319
+ ackUpToSeq: 0,
2320
+ inlineAckGeneration: null,
2321
+ requestedVolatileCursor: '',
2322
+ weight: pageMessages.length,
2323
+ }, pageMessages.length);
2324
+ if (!enqueued || pipelineStopped)
2325
+ break;
2326
+ const messages = deduplicateForwardPageMessages(pageMessages, producerAfterSeq);
2327
+ const seqs = messages
2328
+ .map((msg) => Number(msg.seq ?? 0))
2329
+ .filter((seq) => Number.isFinite(seq) && seq > 0);
2330
+ const nextAfter = Math.max(contiguousPullSeq(producerAfterSeq, seqs, result.contiguous_seq), producerAfterSeq);
2331
+ const cursor = isJsonObject(result.cursor)
2332
+ ? result.cursor : null;
2333
+ const shouldContinue = shouldContinueForwardPage({
2334
+ rawCount: messages.length,
2335
+ nextAfterSeq: producerAfterSeq,
2336
+ nextAfter,
2337
+ limit,
2338
+ hasMoreField: Object.prototype.hasOwnProperty.call(result, 'has_more'),
2339
+ hasMore: result.has_more === true,
2340
+ serverHead: Math.max(safeNonNegativeSeq(result.latest_message_seq) ?? 0, safeNonNegativeSeq(cursor?.latest_seq) ?? 0),
2341
+ observedUpper: Math.max(nextAfter, client._seqTracker.getMaxSeenSeq(ns)),
2342
+ pendingAckSeq: 0,
2343
+ });
2344
+ if (!shouldContinue)
2345
+ break;
2346
+ producerAfterSeq = nextAfter;
2347
+ }
2348
+ })().catch((error) => {
2349
+ pipelineError = error;
2350
+ }).finally(() => {
2351
+ pipelineQueue.close();
2352
+ });
2353
+ };
2354
+ const stopPipeline = async () => {
2355
+ pipelineStopped = true;
2356
+ pipelineQueue.close();
2357
+ if (pipelineProducer) {
2358
+ const producer = pipelineProducer;
2359
+ pipelineProducer = null;
2360
+ await producer;
2361
+ }
2362
+ pipelineRunning = false;
2363
+ };
2364
+ try {
2365
+ while (pageCount < maxPages) {
2366
+ let pipelinePage;
2367
+ if (pipelineRunning) {
2368
+ pipelinePage = await pipelineQueue.take();
2369
+ if (!pipelinePage) {
2370
+ pipelineRunning = false;
2371
+ if (pipelineError)
2372
+ throw pipelineError;
2373
+ break;
2132
2374
  }
2133
- pendingAckSeq = 0;
2134
- lastAutoAckSeq = Math.max(lastAutoAckSeq, ackUpToSeq);
2135
- this.clearForwardAck(ns, ackUpToSeq);
2375
+ }
2376
+ pageCount += 1;
2377
+ const ackUpToSeq = ownsCursor ? (pipelinePage?.ackUpToSeq ?? pendingAckSeq) : 0;
2378
+ const inlineAckGeneration = pipelinePage?.inlineAckGeneration ?? (ackUpToSeq > 0
2379
+ ? client._delivery.captureGroupInlineAckGeneration()
2380
+ : null);
2381
+ client._clientLog.debug(`group.v2.pull page request: group=${gid}, page=${pageCount}, after_seq=${nextAfterSeq}, limit=${limit}, ack_up_to_seq=${ackUpToSeq}, ns=${ns}`);
2382
+ let result;
2383
+ if (pipelinePage) {
2384
+ nextAfterSeq = pipelinePage.afterSeq;
2385
+ result = pipelinePage.result;
2136
2386
  }
2137
2387
  else {
2138
- client._clientLog.warn(`group.v2.pull piggyback ACK below target: group=${gid}, requested=${ackUpToSeq}, actual=${actualAckSeq}; fallback terminal ACK`);
2139
- await this.confirmForwardGroupAck(gid, ackUpToSeq, groupAid);
2140
- pendingAckSeq = 0;
2141
- lastAutoAckSeq = Math.max(lastAutoAckSeq, ackUpToSeq);
2388
+ result = await client._callRawV2Rpc('group.v2.pull', withExplicitGroupAid({
2389
+ group_id: wireGroupId,
2390
+ after_seq: nextAfterSeq,
2391
+ limit,
2392
+ ...cursorParams,
2393
+ ...(opts?.force ? { force: true } : {}),
2394
+ ...(ackUpToSeq > 0 ? { ack_up_to_seq: ackUpToSeq } : {}),
2395
+ }, groupAid), pullGateKey);
2142
2396
  }
2143
- }
2144
- const resultGroupAid = String(result.group_aid ?? result.groupAid ?? '').trim();
2145
- if (resultGroupAid)
2146
- groupAid = resultGroupAid;
2147
- const eventGroupAid = resultGroupAid || groupAid || gid;
2148
- const pageMessages = (Array.isArray(result.messages) ? result.messages : []);
2149
- const messages = deduplicateForwardPageMessages(pageMessages, nextAfterSeq);
2150
- const cursor = isJsonObject(result.cursor) ? result.cursor : null;
2151
- const hasMoreField = Object.prototype.hasOwnProperty.call(result, 'has_more');
2152
- const hasMore = result.has_more === true;
2153
- const serverHead = Math.max(safeNonNegativeSeq(result.latest_message_seq) ?? 0, safeNonNegativeSeq(cursor?.latest_seq) ?? 0);
2154
- client._clientLog.debug(`group.v2.pull page response: group=${gid}, page=${pageCount}, raw_count=${messages.length}, stale_count=${Math.max(0, pageMessages.length - messages.length)}, has_more=${String(result.has_more ?? '')}, server_head=${serverHead}, cursor_current=${String(cursor?.current_seq ?? '')}`);
2155
- for (const msg of messages) {
2156
- client._logMessageDebug('pull-raw', 'group.v2.pull', 'group.message_created', msg);
2157
- }
2158
- const seqs = messages
2159
- .map((msg) => Number(msg.seq ?? 0))
2160
- .filter((seq) => Number.isFinite(seq) && seq > 0);
2161
- const pageContigBefore = client._seqTracker.getContiguousSeq(ns);
2162
- const pageTrackerSnapshot = typeof client._seqTracker.snapshotNamespace === 'function'
2163
- ? client._seqTracker.snapshotNamespace(ns)
2164
- : null;
2165
- let pageMaxSeq = nextAfterSeq;
2166
- if (seqs.length > 0) {
2167
- pageMaxSeq = Math.max(...seqs);
2168
- }
2169
- const pageContiguousSeq = contiguousPullSeq(pageContigBefore, seqs, result.contiguous_seq);
2170
- const deferredKeyFetches = new Map();
2171
- let blockedSeq = 0;
2172
- const pageDecryptedBefore = decrypted.length;
2173
- for (const msg of messages) {
2174
- const seq = Number(msg.seq ?? 0);
2175
- if (!Number.isFinite(seq) || seq <= 0)
2176
- continue;
2177
- const version = String(msg.version ?? 'v2');
2178
- if (version === 'v1') {
2179
- const legacy = isJsonObject(msg.legacy_v1) ? msg.legacy_v1 : {};
2180
- const payload = msg.payload !== undefined ? msg.payload : legacy.payload;
2181
- const payloadObj = isJsonObject(payload) ? payload : null;
2182
- const payloadType = payloadObj ? String(payloadObj.type ?? '').trim() : '';
2183
- // 群撤回 tombstone(占位 / 通知):归一化为 group.message_recalled 事件,仍占 seq。
2184
- if (typeof client._delivery?.recallEventFromGroupMessage === 'function'
2185
- && client._delivery.recallEventFromGroupMessage(msg)) {
2186
- await client._delivery.publishGroupRecallTombstone(gid, seq, {
2187
- ...msg,
2188
- group_id: gid,
2189
- group_aid: eventGroupAid,
2190
- }, 'pull');
2191
- client._markPublishedSeq(ns, seq);
2192
- client._clientLog.debug(`group.v2.pull recall tombstone delivered: group=${gid}, seq=${seq}`);
2193
- continue;
2397
+ if (ackUpToSeq > 0) {
2398
+ const actualAckSeq = client._delivery.resolveGroupPullAckSeq(result, ackUpToSeq);
2399
+ if (actualAckSeq >= ackUpToSeq) {
2400
+ if (inlineAckGeneration !== null) {
2401
+ client._delivery.onGroupV2PullAckResult(gid, result, ackUpToSeq, inlineAckGeneration);
2402
+ }
2403
+ pendingAckSeq = 0;
2404
+ lastAutoAckSeq = Math.max(lastAutoAckSeq, ackUpToSeq);
2405
+ this.clearForwardAck(ns, ackUpToSeq);
2194
2406
  }
2195
- if (payloadObj) {
2196
- if (!['e2ee.encrypted', 'e2ee.group_encrypted', 'e2ee.p2p_encrypted'].includes(payloadType)) {
2407
+ else {
2408
+ client._clientLog.warn(`group.v2.pull piggyback ACK below target: group=${gid}, requested=${ackUpToSeq}, actual=${actualAckSeq}; fallback terminal ACK`);
2409
+ await this.confirmForwardGroupAck(gid, ackUpToSeq, groupAid);
2410
+ pendingAckSeq = 0;
2411
+ lastAutoAckSeq = Math.max(lastAutoAckSeq, ackUpToSeq);
2412
+ }
2413
+ }
2414
+ const resultGroupAid = String(result.group_aid ?? result.groupAid ?? '').trim();
2415
+ if (resultGroupAid)
2416
+ groupAid = resultGroupAid;
2417
+ const eventGroupAid = resultGroupAid || groupAid || gid;
2418
+ const pageMessages = (Array.isArray(result.messages) ? result.messages : []);
2419
+ const messages = deduplicateForwardPageMessages(pageMessages, nextAfterSeq);
2420
+ const cursor = isJsonObject(result.cursor) ? result.cursor : null;
2421
+ const hasMoreField = Object.prototype.hasOwnProperty.call(result, 'has_more');
2422
+ const hasMore = result.has_more === true;
2423
+ const serverHead = Math.max(safeNonNegativeSeq(result.latest_message_seq) ?? 0, safeNonNegativeSeq(cursor?.latest_seq) ?? 0);
2424
+ client._clientLog.debug(`group.v2.pull page response: group=${gid}, page=${pageCount}, raw_count=${messages.length}, stale_count=${Math.max(0, pageMessages.length - messages.length)}, has_more=${String(result.has_more ?? '')}, server_head=${serverHead}, cursor_current=${String(cursor?.current_seq ?? '')}`);
2425
+ for (const msg of messages) {
2426
+ client._logMessageDebug('pull-raw', 'group.v2.pull', 'group.message_created', msg);
2427
+ }
2428
+ const seqs = messages
2429
+ .map((msg) => Number(msg.seq ?? 0))
2430
+ .filter((seq) => Number.isFinite(seq) && seq > 0);
2431
+ const pageContigBefore = client._seqTracker.getContiguousSeq(ns);
2432
+ const pageTrackerSnapshot = typeof client._seqTracker.snapshotNamespace === 'function'
2433
+ ? client._seqTracker.snapshotNamespace(ns)
2434
+ : null;
2435
+ let pageMaxSeq = nextAfterSeq;
2436
+ if (seqs.length > 0) {
2437
+ pageMaxSeq = Math.max(...seqs);
2438
+ }
2439
+ const pageContiguousSeq = contiguousPullSeq(pageContigBefore, seqs, result.contiguous_seq);
2440
+ const deferredKeyFetches = new Map();
2441
+ let blockedSeq = 0;
2442
+ const pageDecryptedBefore = decrypted.length;
2443
+ for (const msg of messages) {
2444
+ const seq = Number(msg.seq ?? 0);
2445
+ if (!Number.isFinite(seq) || seq <= 0)
2446
+ continue;
2447
+ const version = String(msg.version ?? 'v2');
2448
+ if (version === 'v1') {
2449
+ const legacy = isJsonObject(msg.legacy_v1) ? msg.legacy_v1 : {};
2450
+ const payload = msg.payload !== undefined ? msg.payload : legacy.payload;
2451
+ const payloadObj = isJsonObject(payload) ? payload : null;
2452
+ const payloadType = payloadObj ? String(payloadObj.type ?? '').trim() : '';
2453
+ // 群撤回 tombstone(占位 / 通知):归一化为 group.message_recalled 事件,仍占 seq。
2454
+ if (typeof client._delivery?.recallEventFromGroupMessage === 'function'
2455
+ && client._delivery.recallEventFromGroupMessage(msg)) {
2456
+ await client._delivery.publishGroupRecallTombstone(gid, seq, {
2457
+ ...msg,
2458
+ group_id: gid,
2459
+ group_aid: eventGroupAid,
2460
+ }, 'pull');
2461
+ client._markPublishedSeq(ns, seq);
2462
+ client._clientLog.debug(`group.v2.pull recall tombstone delivered: group=${gid}, seq=${seq}`);
2463
+ continue;
2464
+ }
2465
+ if (payloadObj) {
2466
+ if (!['e2ee.encrypted', 'e2ee.group_encrypted', 'e2ee.p2p_encrypted'].includes(payloadType)) {
2467
+ let v1Msg = {
2468
+ message_id: String(msg.message_id ?? ''),
2469
+ from: String(msg.from_aid ?? ''),
2470
+ group_id: gid,
2471
+ group_aid: eventGroupAid,
2472
+ seq: msg.seq,
2473
+ type: String(msg.type ?? ''),
2474
+ timestamp: msg.t_server,
2475
+ payload,
2476
+ encrypted: false,
2477
+ };
2478
+ if (msg.sender_device_id !== undefined)
2479
+ v1Msg.sender_device_id = msg.sender_device_id;
2480
+ v1Msg = normalizeGroupMentionMode(v1Msg, msg, msg.envelope_json);
2481
+ attachGatewayProximity(v1Msg, msg);
2482
+ if (!client._delivery.isSelfSentGroupMessage?.(msg)) {
2483
+ await client._publishPulledMessage('group.message_created', ns, seq, v1Msg, false);
2484
+ }
2485
+ else {
2486
+ client._markPublishedSeq(ns, seq);
2487
+ }
2488
+ decrypted.push(v1Msg);
2489
+ client._clientLog.debug(`group.v2.pull plaintext V1 delivered: group=${gid}, seq=${seq}`);
2490
+ continue;
2491
+ }
2492
+ }
2493
+ else if (payload !== undefined && payload !== null) {
2197
2494
  let v1Msg = {
2198
2495
  message_id: String(msg.message_id ?? ''),
2199
2496
  from: String(msg.from_aid ?? ''),
@@ -2205,209 +2502,208 @@ export class V2E2EECoordinator {
2205
2502
  payload,
2206
2503
  encrypted: false,
2207
2504
  };
2505
+ if (msg.sender_device_id !== undefined)
2506
+ v1Msg.sender_device_id = msg.sender_device_id;
2208
2507
  v1Msg = normalizeGroupMentionMode(v1Msg, msg, msg.envelope_json);
2209
2508
  attachGatewayProximity(v1Msg, msg);
2210
- if (!client._delivery.isSelfSentGroupMessage?.(msg)) {
2211
- await client._publishPulledMessage('group.message_created', ns, seq, v1Msg, false);
2212
- }
2213
- else {
2214
- client._markPublishedSeq(ns, seq);
2215
- }
2509
+ await client._publishPulledMessage('group.message_created', ns, seq, v1Msg, false);
2216
2510
  decrypted.push(v1Msg);
2217
2511
  client._clientLog.debug(`group.v2.pull plaintext V1 delivered: group=${gid}, seq=${seq}`);
2218
2512
  continue;
2219
2513
  }
2514
+ if (payloadObj && ['e2ee.group_encrypted', 'e2ee.p2p_encrypted', 'e2ee.encrypted'].includes(payloadType)) {
2515
+ const deferStatus = {};
2516
+ const plaintext = await client._decryptV2Message({ ...msg, envelope_json: JSON.stringify(payloadObj) }, true, true, true, true, deferStatus, true, 'pull');
2517
+ if (deferStatus.deferred) {
2518
+ blockedSeq = blockedSeq > 0 ? Math.min(blockedSeq, seq) : seq;
2519
+ if (deferStatus.fromAid) {
2520
+ const key = `${deferStatus.fromAid}\u0000${deferStatus.senderDeviceId ?? ''}\u0000${deferStatus.groupId ?? ''}`;
2521
+ deferredKeyFetches.set(key, { fromAid: deferStatus.fromAid, senderDeviceId: deferStatus.senderDeviceId ?? '', groupId: deferStatus.groupId ?? '' });
2522
+ }
2523
+ }
2524
+ if (plaintext) {
2525
+ plaintext.group_id = gid;
2526
+ plaintext.group_aid = eventGroupAid;
2527
+ if (!client._delivery.isSelfSentGroupMessage?.(msg)) {
2528
+ await client._publishPulledMessage('group.message_created', ns, seq, plaintext, false);
2529
+ }
2530
+ else {
2531
+ client._markPublishedSeq(ns, seq);
2532
+ }
2533
+ decrypted.push(plaintext);
2534
+ }
2535
+ }
2536
+ else {
2537
+ client._clientLog.debug(`group.v2.pull skipping V1 envelope group=${gid} seq=${seq} payload_type=${payloadObj ? String(payloadObj.type ?? '') : '<none>'} (V1 E2EE removed)`);
2538
+ }
2539
+ continue;
2540
+ }
2541
+ if (version !== 'v2') {
2542
+ client._clientLog.debug(`group.v2.pull skipping non-V2 row group=${gid} seq=${seq} version=${String(msg.version ?? '')}`);
2543
+ continue;
2544
+ }
2545
+ const deferStatus = {};
2546
+ let plaintext = await client._decryptV2Message(msg, true, true, true, true, deferStatus, true, 'pull');
2547
+ if (deferStatus.deferred) {
2548
+ blockedSeq = blockedSeq > 0 ? Math.min(blockedSeq, seq) : seq;
2549
+ }
2550
+ if (deferStatus.deferred && deferStatus.fromAid) {
2551
+ const key = `${deferStatus.fromAid}\u0000${deferStatus.senderDeviceId ?? ''}\u0000${deferStatus.groupId ?? ''}`;
2552
+ deferredKeyFetches.set(key, {
2553
+ fromAid: deferStatus.fromAid,
2554
+ senderDeviceId: deferStatus.senderDeviceId ?? '',
2555
+ groupId: deferStatus.groupId ?? '',
2556
+ });
2220
2557
  }
2221
- else if (payload !== undefined && payload !== null) {
2222
- let v1Msg = {
2223
- message_id: String(msg.message_id ?? ''),
2224
- from: String(msg.from_aid ?? ''),
2225
- group_id: gid,
2226
- group_aid: eventGroupAid,
2227
- seq: msg.seq,
2228
- type: String(msg.type ?? ''),
2229
- timestamp: msg.t_server,
2230
- payload,
2231
- encrypted: false,
2232
- };
2233
- v1Msg = normalizeGroupMentionMode(v1Msg, msg, msg.envelope_json);
2234
- attachGatewayProximity(v1Msg, msg);
2235
- await client._publishPulledMessage('group.message_created', ns, seq, v1Msg, false);
2236
- decrypted.push(v1Msg);
2237
- client._clientLog.debug(`group.v2.pull plaintext V1 delivered: group=${gid}, seq=${seq}`);
2558
+ if (plaintext === null) {
2559
+ client._clientLog.debug(`group.v2.pull decrypt returned null: group=${gid}, seq=${seq}`);
2238
2560
  continue;
2239
2561
  }
2240
- if (payloadObj && ['e2ee.group_encrypted', 'e2ee.p2p_encrypted', 'e2ee.encrypted'].includes(payloadType)) {
2241
- const deferStatus = {};
2242
- const plaintext = await client._decryptV2Message({ ...msg, envelope_json: JSON.stringify(payloadObj) }, true, true, true, true, deferStatus, true, 'pull');
2243
- if (deferStatus.deferred) {
2244
- blockedSeq = blockedSeq > 0 ? Math.min(blockedSeq, seq) : seq;
2245
- if (deferStatus.fromAid) {
2246
- const key = `${deferStatus.fromAid}\u0000${deferStatus.senderDeviceId ?? ''}\u0000${deferStatus.groupId ?? ''}`;
2247
- deferredKeyFetches.set(key, { fromAid: deferStatus.fromAid, senderDeviceId: deferStatus.senderDeviceId ?? '', groupId: deferStatus.groupId ?? '' });
2562
+ plaintext = normalizeGroupMentionMode(plaintext, msg, msg.envelope_json);
2563
+ plaintext.group_id = gid;
2564
+ plaintext.group_aid = eventGroupAid;
2565
+ if (!client._delivery.isSelfSentGroupMessage?.(msg)) {
2566
+ await client._publishPulledMessage('group.message_created', ns, seq, plaintext, false);
2567
+ }
2568
+ else {
2569
+ client._markPublishedSeq(ns, seq);
2570
+ }
2571
+ decrypted.push(plaintext);
2572
+ client._logMessageDebug('decrypt-ok', 'group.v2.pull', 'group.message_created', plaintext);
2573
+ }
2574
+ const hasServerCursor = cursor !== null && Object.prototype.hasOwnProperty.call(cursor, 'current_seq');
2575
+ const parsedCursorCurrentSeq = hasServerCursor ? safeNonNegativeSeq(cursor?.current_seq) : null;
2576
+ const cursorCurrentSeq = parsedCursorCurrentSeq ?? 0;
2577
+ const retentionFloor = Math.max(client._pullRetentionFloor(result, 'retention_floor_message_seq', 'retention_floor_message_seq'));
2578
+ const visibilityFloor = Math.max(0, Math.max(0, Number(result.earliest_available_message_seq ?? 1) - 1), Number(cursor?.join_seq ?? 0));
2579
+ const effectiveFloor = Math.max(retentionFloor, visibilityFloor);
2580
+ const commitTarget = Math.max(pageContigBefore, effectiveFloor, ownsCursor && pageCount === 1 ? deferredServerCursor : 0, ownsCursor ? cursorCurrentSeq : 0, blockedSeq > 0 ? blockedSeq - 1 : pageContiguousSeq);
2581
+ let ackSeq = pageContigBefore;
2582
+ let contigAdvanced = false;
2583
+ if (ownsCursor) {
2584
+ try {
2585
+ if (commitTarget > pageContigBefore) {
2586
+ client._seqTracker.forceContiguousSeq(ns, commitTarget);
2587
+ if (effectiveFloor > pageContigBefore) {
2588
+ client._clientLog.info(`group.v2.pull floor advance: ns=${ns} contiguous=${pageContigBefore} -> effective_floor=${effectiveFloor} retention_floor=${retentionFloor} visibility_floor=${visibilityFloor}`);
2248
2589
  }
2249
- }
2250
- if (plaintext) {
2251
- plaintext.group_id = gid;
2252
- plaintext.group_aid = eventGroupAid;
2253
- if (!client._delivery.isSelfSentGroupMessage?.(msg)) {
2254
- await client._publishPulledMessage('group.message_created', ns, seq, plaintext, false);
2590
+ if (deferredServerCursor > pageContigBefore && pageCount === 1) {
2591
+ client._clientLog.info(`group.v2.pull restore local A from deferred server ACK cursor: group=${gid}, ns=${ns}, contiguous=${pageContigBefore} -> current_seq=${deferredServerCursor}`);
2255
2592
  }
2256
- else {
2257
- client._markPublishedSeq(ns, seq);
2593
+ if (seqs.length > 0) {
2594
+ client._clientLog.debug(`group.v2.pull force contiguous: group=${gid}, ns=${ns}, page_max_seq=${pageMaxSeq}, previous=${pageContigBefore}`);
2258
2595
  }
2259
- decrypted.push(plaintext);
2596
+ }
2597
+ ackSeq = client._seqTracker.getContiguousSeq(ns);
2598
+ contigAdvanced = ackSeq !== pageContigBefore;
2599
+ if (contigAdvanced) {
2600
+ await client._commitSeqTrackerState(ns);
2601
+ if (pageCount === 1 && deferredServerCursor > 0)
2602
+ this.clearForwardCursor(ns, ackSeq);
2260
2603
  }
2261
2604
  }
2262
- else {
2263
- client._clientLog.debug(`group.v2.pull skipping V1 envelope group=${gid} seq=${seq} payload_type=${payloadObj ? String(payloadObj.type ?? '') : '<none>'} (V1 E2EE removed)`);
2605
+ catch (exc) {
2606
+ if (typeof client._seqTracker.restoreNamespaceSnapshot === 'function') {
2607
+ client._seqTracker.restoreNamespaceSnapshot(ns, pageTrackerSnapshot);
2608
+ }
2609
+ client._clientLog.error?.(`group.v2.pull Forward commit rolled back: group=${gid} ns=${ns} target=${commitTarget} error=${formatE2EEError(exc)}`);
2610
+ throw exc;
2264
2611
  }
2265
- continue;
2266
- }
2267
- if (version !== 'v2') {
2268
- client._clientLog.debug(`group.v2.pull skipping non-V2 row group=${gid} seq=${seq} version=${String(msg.version ?? '')}`);
2269
- continue;
2270
- }
2271
- const deferStatus = {};
2272
- let plaintext = await client._decryptV2Message(msg, true, true, true, true, deferStatus, true, 'pull');
2273
- if (deferStatus.deferred) {
2274
- blockedSeq = blockedSeq > 0 ? Math.min(blockedSeq, seq) : seq;
2275
- }
2276
- if (deferStatus.deferred && deferStatus.fromAid) {
2277
- const key = `${deferStatus.fromAid}\u0000${deferStatus.senderDeviceId ?? ''}\u0000${deferStatus.groupId ?? ''}`;
2278
- deferredKeyFetches.set(key, {
2279
- fromAid: deferStatus.fromAid,
2280
- senderDeviceId: deferStatus.senderDeviceId ?? '',
2281
- groupId: deferStatus.groupId ?? '',
2282
- });
2283
- }
2284
- if (plaintext === null) {
2285
- client._clientLog.debug(`group.v2.pull decrypt returned null: group=${gid}, seq=${seq}`);
2286
- continue;
2612
+ if (contigAdvanced)
2613
+ await client._drainOrderedMessages(ns, undefined, false, false, 'pull');
2287
2614
  }
2288
- plaintext = normalizeGroupMentionMode(plaintext, msg, msg.envelope_json);
2289
- plaintext.group_id = gid;
2290
- plaintext.group_aid = eventGroupAid;
2291
- if (!client._delivery.isSelfSentGroupMessage?.(msg)) {
2292
- await client._publishPulledMessage('group.message_created', ns, seq, plaintext, false);
2615
+ for (const fetch of deferredKeyFetches.values()) {
2616
+ this.scheduleSenderIKFetch(fetch.fromAid, fetch.senderDeviceId, fetch.groupId);
2293
2617
  }
2294
- else {
2295
- client._markPublishedSeq(ns, seq);
2618
+ if (ownsCursor && parsedCursorCurrentSeq !== null && cursorCurrentSeq > ackSeq) {
2619
+ this.recordForwardCursor(ns, cursorCurrentSeq, ackSeq);
2296
2620
  }
2297
- decrypted.push(plaintext);
2298
- client._logMessageDebug('decrypt-ok', 'group.v2.pull', 'group.message_created', plaintext);
2299
- }
2300
- const hasServerCursor = cursor !== null && Object.prototype.hasOwnProperty.call(cursor, 'current_seq');
2301
- const parsedCursorCurrentSeq = hasServerCursor ? safeNonNegativeSeq(cursor?.current_seq) : null;
2302
- const cursorCurrentSeq = parsedCursorCurrentSeq ?? 0;
2303
- const retentionFloor = Math.max(client._pullRetentionFloor(result, 'retention_floor_message_seq', 'retention_floor_message_seq'));
2304
- const visibilityFloor = Math.max(0, Math.max(0, Number(result.earliest_available_message_seq ?? 1) - 1), Number(cursor?.join_seq ?? 0));
2305
- const effectiveFloor = Math.max(retentionFloor, visibilityFloor);
2306
- const commitTarget = Math.max(pageContigBefore, effectiveFloor, ownsCursor && pageCount === 1 ? deferredServerCursor : 0, ownsCursor ? cursorCurrentSeq : 0, blockedSeq > 0 ? blockedSeq - 1 : pageContiguousSeq);
2307
- let ackSeq = pageContigBefore;
2308
- let contigAdvanced = false;
2309
- if (ownsCursor) {
2310
- try {
2311
- if (commitTarget > pageContigBefore) {
2312
- client._seqTracker.forceContiguousSeq(ns, commitTarget);
2313
- if (effectiveFloor > pageContigBefore) {
2314
- client._clientLog.info(`group.v2.pull floor advance: ns=${ns} contiguous=${pageContigBefore} -> effective_floor=${effectiveFloor} retention_floor=${retentionFloor} visibility_floor=${visibilityFloor}`);
2315
- }
2316
- if (deferredServerCursor > pageContigBefore && pageCount === 1) {
2317
- client._clientLog.info(`group.v2.pull restore local A from deferred server ACK cursor: group=${gid}, ns=${ns}, contiguous=${pageContigBefore} -> current_seq=${deferredServerCursor}`);
2318
- }
2319
- if (seqs.length > 0) {
2320
- client._clientLog.debug(`group.v2.pull force contiguous: group=${gid}, ns=${ns}, page_max_seq=${pageMaxSeq}, previous=${pageContigBefore}`);
2321
- }
2322
- }
2323
- ackSeq = client._seqTracker.getContiguousSeq(ns);
2324
- contigAdvanced = ackSeq !== pageContigBefore;
2325
- if (contigAdvanced) {
2326
- await client._commitSeqTrackerState(ns);
2327
- if (pageCount === 1 && deferredServerCursor > 0)
2328
- this.clearForwardCursor(ns, ackSeq);
2621
+ // 无本页 cursor.current_seq 时,只有本页成功提交了比已知 cursor
2622
+ // 更高的 A 才新增 ACK 义务;空页不得因已有本地 A 重复确认。
2623
+ const knownServerCursorSeq = hasServerCursor
2624
+ ? cursorCurrentSeq
2625
+ : (ownsCursor && pageCount === 1 ? deferredServerCursor : 0);
2626
+ const ackNeeded = ackSeq > 0
2627
+ && ackSeq > lastAutoAckSeq
2628
+ && ownsCursor
2629
+ && ((hasServerCursor && ackSeq > cursorCurrentSeq)
2630
+ || (contigAdvanced && ackSeq > knownServerCursorSeq));
2631
+ if (ackNeeded) {
2632
+ this.recordForwardAck(ns, ackSeq);
2633
+ const nextAfter = Math.max(pageContiguousSeq, nextAfterSeq);
2634
+ const canContinuePage = blockedSeq <= 0 && messages.length > 0 && nextAfter > nextAfterSeq && ownsCursor;
2635
+ if (canContinuePage && !pipelineRunning) {
2636
+ pendingAckSeq = Math.max(pendingAckSeq, ackSeq);
2637
+ lastAutoAckSeq = Math.max(lastAutoAckSeq, ackSeq);
2638
+ client._clientLog.debug(`group.v2.pull queued piggyback auto-ack: group=${gid}, ns=${ns}, ack_seq=${pendingAckSeq}, raw_count=${messages.length}`);
2329
2639
  }
2330
- }
2331
- catch (exc) {
2332
- if (typeof client._seqTracker.restoreNamespaceSnapshot === 'function') {
2333
- client._seqTracker.restoreNamespaceSnapshot(ns, pageTrackerSnapshot);
2640
+ else {
2641
+ client._clientLog.debug(`group.v2.pull sending terminal auto-ack: group=${gid}, ns=${ns}, ack_seq=${ackSeq}, raw_count=${messages.length}`);
2642
+ await this.confirmForwardGroupAck(gid, ackSeq, groupAid);
2643
+ lastAutoAckSeq = Math.max(lastAutoAckSeq, ackSeq);
2334
2644
  }
2335
- client._clientLog.error?.(`group.v2.pull Forward commit rolled back: group=${gid} ns=${ns} target=${commitTarget} error=${formatE2EEError(exc)}`);
2336
- throw exc;
2337
2645
  }
2338
- if (contigAdvanced)
2339
- await client._drainOrderedMessages(ns, undefined, false, false, 'pull');
2340
- }
2341
- for (const fetch of deferredKeyFetches.values()) {
2342
- this.scheduleSenderIKFetch(fetch.fromAid, fetch.senderDeviceId, fetch.groupId);
2343
- }
2344
- if (ownsCursor && parsedCursorCurrentSeq !== null && cursorCurrentSeq > ackSeq) {
2345
- this.recordForwardCursor(ns, cursorCurrentSeq, ackSeq);
2346
- }
2347
- // 无本页 cursor.current_seq 时,只有本页成功提交了比已知 cursor
2348
- // 更高的 A 才新增 ACK 义务;空页不得因已有本地 A 重复确认。
2349
- const knownServerCursorSeq = hasServerCursor
2350
- ? cursorCurrentSeq
2351
- : (ownsCursor && pageCount === 1 ? deferredServerCursor : 0);
2352
- const ackNeeded = ackSeq > 0
2353
- && ackSeq > lastAutoAckSeq
2354
- && ownsCursor
2355
- && ((hasServerCursor && ackSeq > cursorCurrentSeq)
2356
- || (contigAdvanced && ackSeq > knownServerCursorSeq));
2357
- if (ackNeeded) {
2358
- this.recordForwardAck(ns, ackSeq);
2646
+ client._delivery.syncProgress(ns, {
2647
+ rawCount: messages.length,
2648
+ pulledCount: decrypted.length - pageDecryptedBefore,
2649
+ remaining: result.remaining,
2650
+ hasMore: hasMoreField ? hasMore : undefined,
2651
+ currentSeq: client._seqTracker.getContiguousSeq(ns),
2652
+ targetSeq: serverHead,
2653
+ });
2359
2654
  const nextAfter = Math.max(pageContiguousSeq, nextAfterSeq);
2360
- const canContinuePage = blockedSeq <= 0 && messages.length > 0 && nextAfter > nextAfterSeq && ownsCursor;
2361
- if (canContinuePage) {
2362
- pendingAckSeq = Math.max(pendingAckSeq, ackSeq);
2363
- lastAutoAckSeq = Math.max(lastAutoAckSeq, ackSeq);
2364
- client._clientLog.debug(`group.v2.pull queued piggyback auto-ack: group=${gid}, ns=${ns}, ack_seq=${pendingAckSeq}, raw_count=${messages.length}`);
2655
+ if (!ownsCursor)
2656
+ break;
2657
+ const rawCount = messages.length;
2658
+ if (blockedSeq > 0)
2659
+ break;
2660
+ const shouldContinue = shouldContinueForwardPage({
2661
+ rawCount,
2662
+ nextAfterSeq,
2663
+ nextAfter,
2664
+ limit,
2665
+ hasMoreField,
2666
+ hasMore,
2667
+ serverHead,
2668
+ observedUpper: Math.max(client._seqTracker.getMaxSeenSeq(ns), Number(client._delivery.pendingPullUpper?.(ns) ?? 0)),
2669
+ pendingAckSeq,
2670
+ });
2671
+ const shouldContinueForPipeline = shouldContinueForwardPage({
2672
+ rawCount,
2673
+ nextAfterSeq,
2674
+ nextAfter,
2675
+ limit,
2676
+ hasMoreField,
2677
+ hasMore,
2678
+ serverHead,
2679
+ observedUpper: Math.max(client._seqTracker.getMaxSeenSeq(ns), Number(client._delivery.pendingPullUpper?.(ns) ?? 0)),
2680
+ pendingAckSeq: 0,
2681
+ });
2682
+ if (!shouldContinue)
2683
+ break;
2684
+ if (shouldContinueForPipeline && !pipelineRunning && limit >= V2_PULL_MAX_PAGE_LIMIT) {
2685
+ if (pendingAckSeq > 0) {
2686
+ await this.confirmForwardGroupAck(gid, pendingAckSeq, groupAid);
2687
+ pendingAckSeq = 0;
2688
+ }
2689
+ startPipeline(nextAfter);
2365
2690
  }
2366
- else {
2367
- client._clientLog.debug(`group.v2.pull sending terminal auto-ack: group=${gid}, ns=${ns}, ack_seq=${ackSeq}, raw_count=${messages.length}`);
2368
- await this.confirmForwardGroupAck(gid, ackSeq, groupAid);
2369
- lastAutoAckSeq = Math.max(lastAutoAckSeq, ackSeq);
2691
+ if (!pipelineRunning && pullGateKey && opts?.gateLocked && client._rpcPipeline?.yieldPullGate) {
2692
+ const nextKey = pullGateKeyForClient(client, 'group.v2.pull', {
2693
+ group_id: gid, after_seq: nextAfter, force: opts?.force === true, limit,
2694
+ _group_cursor_params: cursorParams,
2695
+ }, `${ns}|forward|after=${nextAfter}|force=${String(Boolean(opts?.force))}|limit=${limit}`);
2696
+ await client._rpcPipeline.yieldPullGate(pullGateKey, nextKey, true);
2697
+ pullGateKey = nextKey;
2370
2698
  }
2699
+ const tailDelayMs = v2PullTailDelayMs(rawCount, limit);
2700
+ if (tailDelayMs > 0)
2701
+ await v2Sleep(tailDelayMs);
2702
+ nextAfterSeq = nextAfter;
2371
2703
  }
2372
- client._delivery.syncProgress(ns, {
2373
- rawCount: messages.length,
2374
- pulledCount: decrypted.length - pageDecryptedBefore,
2375
- remaining: result.remaining,
2376
- hasMore: hasMoreField ? hasMore : undefined,
2377
- currentSeq: client._seqTracker.getContiguousSeq(ns),
2378
- targetSeq: serverHead,
2379
- });
2380
- const nextAfter = Math.max(pageContiguousSeq, nextAfterSeq);
2381
- if (!ownsCursor)
2382
- break;
2383
- const rawCount = messages.length;
2384
- if (blockedSeq > 0)
2385
- break;
2386
- const shouldContinue = shouldContinueForwardPage({
2387
- rawCount,
2388
- nextAfterSeq,
2389
- nextAfter,
2390
- limit,
2391
- hasMoreField,
2392
- hasMore,
2393
- serverHead,
2394
- observedUpper: Math.max(client._seqTracker.getMaxSeenSeq(ns), Number(client._delivery.pendingPullUpper?.(ns) ?? 0)),
2395
- pendingAckSeq,
2396
- });
2397
- if (!shouldContinue)
2398
- break;
2399
- if (pullGateKey && opts?.gateLocked && client._rpcPipeline?.yieldPullGate) {
2400
- const nextKey = pullGateKeyForClient(client, 'group.v2.pull', {
2401
- group_id: gid, after_seq: nextAfter, force: opts?.force === true, limit,
2402
- _group_cursor_params: cursorParams,
2403
- }, `${ns}|forward|after=${nextAfter}|force=${String(Boolean(opts?.force))}|limit=${limit}`);
2404
- await client._rpcPipeline.yieldPullGate(pullGateKey, nextKey, true);
2405
- pullGateKey = nextKey;
2406
- }
2407
- const tailDelayMs = v2PullTailDelayMs(rawCount, limit);
2408
- if (tailDelayMs > 0)
2409
- await v2Sleep(tailDelayMs);
2410
- nextAfterSeq = nextAfter;
2704
+ }
2705
+ finally {
2706
+ await stopPipeline();
2411
2707
  }
2412
2708
  if (pageCount >= maxPages) {
2413
2709
  client._clientLog.warn(`group.v2.pull reached max_pages=${maxPages} group=${gid} after_seq=${nextAfterSeq}`);
@@ -2948,7 +3244,11 @@ export class V2E2EECoordinator {
2948
3244
  const senderDeviceId = String(aad.from_device ?? '').trim();
2949
3245
  if (senderDeviceId)
2950
3246
  result.sender_device_id = senderDeviceId;
2951
- result.direction = fromAid && fromAid === client._aid ? 'outbound_sync' : 'inbound';
3247
+ const direction = String(msg.direction ?? '').trim();
3248
+ if (direction)
3249
+ result.direction = direction;
3250
+ else if (fromAid && fromAid !== client._aid)
3251
+ result.direction = 'inbound';
2952
3252
  if (msg.t_server !== undefined)
2953
3253
  result.t_server = msg.t_server;
2954
3254
  if (msg.device_id !== undefined)
@@ -2990,10 +3290,10 @@ export class V2E2EECoordinator {
2990
3290
  }
2991
3291
  return await this.publishEncryptedPushAsUndecryptable(undecryptableEvent, ns, seq, msg, group);
2992
3292
  }
2993
- async decryptV2PushMessage(data) {
3293
+ async decryptV2PushMessage(data, deferStatus) {
2994
3294
  if (!isJsonObject(data))
2995
3295
  return null;
2996
- return await this.client._decryptV2Message(data, false, false, false, false, undefined, false, 'inline_push');
3296
+ return await this.client._decryptV2Message(data, false, true, false, false, deferStatus, false, 'inline_push');
2997
3297
  }
2998
3298
  handleGroupChangedSpk(data, groupId, action) {
2999
3299
  if (!this.client._v2Session || !groupId || !MEMBERSHIP_ACTIONS.has(action))