weflow-cli 1.6.3 → 1.7.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.
- package/CHANGELOG.md +38 -0
- package/OPERATIONS.md +36 -1
- package/README.en.md +1 -1
- package/README.md +1 -1
- package/bin/weflow-cli.ts +312 -0
- package/dist/bin/weflow-cli.js +352 -0
- package/dist/bin/weflow-cli.js.map +1 -1
- package/dist/src/core/ntCore.d.ts +26 -0
- package/dist/src/core/ntCore.d.ts.map +1 -1
- package/dist/src/core/ntCore.js +21 -0
- package/dist/src/core/ntCore.js.map +1 -1
- package/dist/src/services/chatService.d.ts +13 -0
- package/dist/src/services/chatService.d.ts.map +1 -1
- package/dist/src/services/chatService.js +20 -0
- package/dist/src/services/chatService.js.map +1 -1
- package/dist/src/services/messageQuery.d.ts +35 -0
- package/dist/src/services/messageQuery.d.ts.map +1 -1
- package/dist/src/services/messageQuery.js +61 -0
- package/dist/src/services/messageQuery.js.map +1 -1
- package/dist/src/services/syncService.d.ts +121 -0
- package/dist/src/services/syncService.d.ts.map +1 -0
- package/dist/src/services/syncService.js +244 -0
- package/dist/src/services/syncService.js.map +1 -0
- package/dist/src/services/syncState.d.ts +113 -0
- package/dist/src/services/syncState.d.ts.map +1 -0
- package/dist/src/services/syncState.js +214 -0
- package/dist/src/services/syncState.js.map +1 -0
- package/docs/DECISIONS.md +60 -20
- package/docs/FUNCTIONAL-IMPROVEMENT-SPEC.md +338 -0
- package/docs/HEALTH-CHECK.md +6 -5
- package/docs/MCP.md +1 -1
- package/docs/NPM-PUBLISH.md +12 -4
- package/docs/PROJECT_STATE.md +8 -6
- package/docs/SETUP.md +1 -1
- package/docs/SYNC_CONTRACT.md +152 -0
- package/package.json +3 -3
- package/scripts/export_chat_html.py +242 -26
- package/scripts/nt_decrypt.py +123 -15
- package/src/core/ntCore.ts +42 -0
- package/src/services/chatService.ts +26 -0
- package/src/services/messageQuery.ts +97 -0
- package/src/services/syncService.ts +335 -0
- package/src/services/syncState.ts +275 -0
package/scripts/nt_decrypt.py
CHANGED
|
@@ -510,14 +510,24 @@ def derive_database_key(path, fallback_key, fallback_salt, passphrase=''):
|
|
|
510
510
|
return fallback_key, fallback_salt
|
|
511
511
|
|
|
512
512
|
|
|
513
|
-
def
|
|
514
|
-
"""Open
|
|
513
|
+
def connect_message_shards_detailed(db_path, key_hex, salt_hex, passphrase=''):
|
|
514
|
+
"""Open every shard and report what happened to each one.
|
|
515
515
|
|
|
516
|
-
Returns
|
|
517
|
-
|
|
518
|
-
|
|
516
|
+
Returns (pairs, failures):
|
|
517
|
+
pairs [(shard_path, conn)] for the shards that opened
|
|
518
|
+
failures [{"name": basename, "reason": "KEY_REJECTED" | "OPEN_FAILED"}]
|
|
519
|
+
|
|
520
|
+
A shard that fails is still skipped rather than fatal - a partially
|
|
521
|
+
readable transcript beats a command that refuses to run at all - but the
|
|
522
|
+
failure stops being invisible. `shardsFailed` is the difference between
|
|
523
|
+
"read everything" and "read what happened to be reachable", which is the
|
|
524
|
+
whole point of the coverage report.
|
|
525
|
+
|
|
526
|
+
Only the basename is reported: the caller stores this in a state file, and
|
|
527
|
+
absolute paths must not end up there.
|
|
519
528
|
"""
|
|
520
|
-
|
|
529
|
+
pairs = []
|
|
530
|
+
failures = []
|
|
521
531
|
for shard in discover_message_shards(db_path):
|
|
522
532
|
derived_key, derived_salt = derive_database_key(
|
|
523
533
|
shard, key_hex, salt_hex, passphrase)
|
|
@@ -526,16 +536,60 @@ def connect_message_shards(db_path, key_hex, salt_hex, passphrase=''):
|
|
|
526
536
|
candidates = [(derived_key, derived_salt)]
|
|
527
537
|
if (derived_key, derived_salt) != (key_hex, salt_hex):
|
|
528
538
|
candidates.append((key_hex, salt_hex))
|
|
539
|
+
failure_reason = 'OPEN_FAILED'
|
|
529
540
|
for candidate_key, candidate_salt in candidates:
|
|
541
|
+
conn = None
|
|
530
542
|
try:
|
|
531
543
|
conn, _ = connect_nt_db(shard, candidate_key, candidate_salt)
|
|
532
544
|
# PRAGMA key alone never fails; only a read surfaces a bad key.
|
|
533
545
|
conn.execute('SELECT count(*) FROM sqlite_master').fetchone()
|
|
534
546
|
except Exception:
|
|
547
|
+
# Reached the database but could not decrypt it: the key is
|
|
548
|
+
# wrong, not the file. Close before retrying, or the handle
|
|
549
|
+
# leaks and on Windows the file stays locked.
|
|
550
|
+
if conn is not None:
|
|
551
|
+
try:
|
|
552
|
+
conn.close()
|
|
553
|
+
except Exception:
|
|
554
|
+
pass
|
|
555
|
+
failure_reason = 'KEY_REJECTED'
|
|
535
556
|
continue
|
|
536
|
-
|
|
557
|
+
pairs.append((shard, conn))
|
|
537
558
|
break
|
|
538
|
-
|
|
559
|
+
else:
|
|
560
|
+
failures.append({'name': os.path.basename(shard), 'reason': failure_reason})
|
|
561
|
+
return pairs, failures
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
def connect_message_shards(db_path, key_hex, salt_hex, passphrase=''):
|
|
565
|
+
"""Open the configured database plus every sibling shard.
|
|
566
|
+
|
|
567
|
+
Returns the connections that actually opened. A shard that fails is
|
|
568
|
+
skipped rather than fatal: a partially readable transcript beats a
|
|
569
|
+
command that refuses to run at all.
|
|
570
|
+
|
|
571
|
+
Behaviour-preserving delegate: callers that do not ask for the failure
|
|
572
|
+
detail get exactly what they got before.
|
|
573
|
+
"""
|
|
574
|
+
pairs, _failures = connect_message_shards_detailed(
|
|
575
|
+
db_path, key_hex, salt_hex, passphrase)
|
|
576
|
+
return [conn for _path, conn in pairs]
|
|
577
|
+
|
|
578
|
+
|
|
579
|
+
def count_talker_rows(conn, msg_table):
|
|
580
|
+
"""Rows this conversation has in one shard, or None if it has no table there.
|
|
581
|
+
|
|
582
|
+
Distinguishes "this shard holds nothing for the conversation" from "this
|
|
583
|
+
shard could not be read" - without it both look like zero.
|
|
584
|
+
"""
|
|
585
|
+
try:
|
|
586
|
+
cur = conn.execute(
|
|
587
|
+
"SELECT COUNT(*) FROM sqlite_master WHERE name=?", (msg_table,))
|
|
588
|
+
if cur.fetchone()[0] == 0:
|
|
589
|
+
return None
|
|
590
|
+
return conn.execute('SELECT COUNT(*) FROM "%s"' % msg_table).fetchone()[0]
|
|
591
|
+
except Exception:
|
|
592
|
+
return None
|
|
539
593
|
|
|
540
594
|
|
|
541
595
|
def msg_tables(conn):
|
|
@@ -808,12 +862,17 @@ def _message_dict(row, sender_id_map, name_map, own_wxid, is_group=False):
|
|
|
808
862
|
}
|
|
809
863
|
|
|
810
864
|
|
|
811
|
-
def get_messages(conns, talker, limit=100, offset=0, name_map=None, own_wxid=None
|
|
865
|
+
def get_messages(conns, talker, limit=100, offset=0, name_map=None, own_wxid=None,
|
|
866
|
+
shard_names=None, shard_report=None):
|
|
812
867
|
"""Get messages for a specific talker, merged across every shard.
|
|
813
868
|
|
|
814
869
|
Args:
|
|
815
870
|
name_map: optional {wxid: display_name} dict for resolving sender names
|
|
816
871
|
own_wxid: account owner wxid for self-message detection
|
|
872
|
+
shard_names: optional basename per connection, parallel to `conns`
|
|
873
|
+
shard_report: optional list to append one outcome dict per shard to.
|
|
874
|
+
Purely additive: the returned messages are identical with or
|
|
875
|
+
without it, which is asserted by test/nt_decrypt_shards_test.py.
|
|
817
876
|
"""
|
|
818
877
|
if name_map is None:
|
|
819
878
|
name_map = {}
|
|
@@ -821,19 +880,36 @@ def get_messages(conns, talker, limit=100, offset=0, name_map=None, own_wxid=Non
|
|
|
821
880
|
msg_table = f"Msg_{hashlib.md5(talker.encode()).hexdigest()}"
|
|
822
881
|
is_group = '@chatroom' in talker
|
|
823
882
|
|
|
883
|
+
def record(index, opened, has_table, rows_for_talker, reason):
|
|
884
|
+
if shard_report is None:
|
|
885
|
+
return
|
|
886
|
+
name = shard_names[index] if shard_names and index < len(shard_names) else ''
|
|
887
|
+
shard_report.append({
|
|
888
|
+
'name': name,
|
|
889
|
+
'opened': opened,
|
|
890
|
+
'hasTalkerTable': has_table,
|
|
891
|
+
'rowsForTalker': rows_for_talker,
|
|
892
|
+
'reason': reason,
|
|
893
|
+
})
|
|
894
|
+
|
|
824
895
|
# Each shard only needs to yield its newest window: once every shard's rows
|
|
825
896
|
# are merged and re-sorted, nothing older than that can reach this page.
|
|
826
897
|
window = 0 if limit <= 0 else limit + offset
|
|
827
898
|
collected = []
|
|
828
899
|
found = False
|
|
829
900
|
|
|
830
|
-
for conn in conns:
|
|
901
|
+
for index, conn in enumerate(conns):
|
|
831
902
|
c = conn.cursor()
|
|
832
903
|
try:
|
|
833
904
|
c.execute("SELECT COUNT(*) FROM sqlite_master WHERE name=?", (msg_table,))
|
|
834
905
|
if c.fetchone()[0] == 0:
|
|
906
|
+
# The shard is readable and simply holds nothing for this
|
|
907
|
+
# conversation - distinct from not being readable at all.
|
|
908
|
+
record(index, True, False, None, None)
|
|
835
909
|
continue
|
|
836
910
|
found = True
|
|
911
|
+
rows_for_talker = c.execute(
|
|
912
|
+
'SELECT COUNT(*) FROM "%s"' % msg_table).fetchone()[0]
|
|
837
913
|
|
|
838
914
|
sql = f'''
|
|
839
915
|
SELECT local_id, server_id, local_type, sort_seq, real_sender_id,
|
|
@@ -852,7 +928,11 @@ def get_messages(conns, talker, limit=100, offset=0, name_map=None, own_wxid=Non
|
|
|
852
928
|
c.execute("SELECT rowid, user_name FROM Name2Id")
|
|
853
929
|
sender_id_map = {rowid: uname for rowid, uname in c.fetchall()}
|
|
854
930
|
except Exception:
|
|
931
|
+
# This shard had the conversation's table but could not be read.
|
|
932
|
+
# Previously this was indistinguishable from "no rows here".
|
|
933
|
+
record(index, True, None, None, 'READ_FAILED')
|
|
855
934
|
continue
|
|
935
|
+
record(index, True, True, rows_for_talker, None)
|
|
856
936
|
|
|
857
937
|
for row in rows:
|
|
858
938
|
collected.append(_message_dict(row, sender_id_map, name_map, own_wxid, is_group))
|
|
@@ -1138,6 +1218,9 @@ def main():
|
|
|
1138
1218
|
msg_parser.add_argument('--contact-key', default=os.environ.get('WEFLOW_CONTACT_KEY'), help='Contact DB key hex (64 chars)')
|
|
1139
1219
|
msg_parser.add_argument('--contact-salt', default=os.environ.get('WEFLOW_CONTACT_SALT'), help='Contact DB salt hex (32 chars)')
|
|
1140
1220
|
msg_parser.add_argument('--own-wxid', default=os.environ.get('WEFLOW_OWN_WXID'), help='Account owner wxid (for self-message detection)')
|
|
1221
|
+
# Opt-in: without it the JSON is byte-identical to before this flag existed.
|
|
1222
|
+
msg_parser.add_argument('--report-shards', action='store_true',
|
|
1223
|
+
help='Add a per-shard read report to the result (additive)')
|
|
1141
1224
|
|
|
1142
1225
|
# contacts command
|
|
1143
1226
|
contacts_parser = sub.add_parser('contacts', help='List contacts')
|
|
@@ -1282,12 +1365,37 @@ def main():
|
|
|
1282
1365
|
conn.close()
|
|
1283
1366
|
|
|
1284
1367
|
elif args.command == 'messages':
|
|
1285
|
-
|
|
1286
|
-
if not conns:
|
|
1287
|
-
print(json.dumps({"error": "无法打开消息数据库,请检查密钥"}, ensure_ascii=True))
|
|
1288
|
-
return
|
|
1368
|
+
passphrase = getattr(args, 'passphrase', '') or ''
|
|
1289
1369
|
own_wxid = getattr(args, 'own_wxid', None)
|
|
1290
|
-
|
|
1370
|
+
|
|
1371
|
+
if getattr(args, 'report_shards', False):
|
|
1372
|
+
pairs, failures = connect_message_shards_detailed(
|
|
1373
|
+
args.db, args.key, args.salt, passphrase)
|
|
1374
|
+
if not pairs:
|
|
1375
|
+
print(json.dumps({"error": "无法打开消息数据库,请检查密钥"}, ensure_ascii=True))
|
|
1376
|
+
return
|
|
1377
|
+
conns = [conn for _path, conn in pairs]
|
|
1378
|
+
names = [os.path.basename(path) for path, _conn in pairs]
|
|
1379
|
+
shard_items = []
|
|
1380
|
+
result = get_messages(conns, args.talker, args.limit, args.offset,
|
|
1381
|
+
contact_name_map, own_wxid,
|
|
1382
|
+
shard_names=names, shard_report=shard_items)
|
|
1383
|
+
# Failure entries carry no opened connection, so they are not in
|
|
1384
|
+
# `conns` and could not be recorded by get_messages itself.
|
|
1385
|
+
result['shards'] = {
|
|
1386
|
+
'scanned': len(pairs) + len(failures),
|
|
1387
|
+
'opened': len(pairs),
|
|
1388
|
+
'failed': len(failures),
|
|
1389
|
+
'items': failures + shard_items,
|
|
1390
|
+
}
|
|
1391
|
+
else:
|
|
1392
|
+
conns = connect_message_shards(args.db, args.key, args.salt, passphrase)
|
|
1393
|
+
if not conns:
|
|
1394
|
+
print(json.dumps({"error": "无法打开消息数据库,请检查密钥"}, ensure_ascii=True))
|
|
1395
|
+
return
|
|
1396
|
+
result = get_messages(conns, args.talker, args.limit, args.offset,
|
|
1397
|
+
contact_name_map, own_wxid)
|
|
1398
|
+
|
|
1291
1399
|
print(json.dumps(result, ensure_ascii=True))
|
|
1292
1400
|
for conn in conns:
|
|
1293
1401
|
conn.close()
|
package/src/core/ntCore.ts
CHANGED
|
@@ -30,8 +30,28 @@ export interface NtSessionsResult extends NtResult {
|
|
|
30
30
|
sessions?: ChatSession[]
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
/** One shard's outcome, as reported by `nt_decrypt.py --report-shards`. */
|
|
34
|
+
export interface ShardOutcome {
|
|
35
|
+
/** basename only - the child never reports a full path */
|
|
36
|
+
name: string
|
|
37
|
+
opened: boolean
|
|
38
|
+
/** null when the shard could not be read at all */
|
|
39
|
+
hasTalkerTable: boolean | null
|
|
40
|
+
rowsForTalker: number | null
|
|
41
|
+
/** null | KEY_REJECTED | OPEN_FAILED | READ_FAILED */
|
|
42
|
+
reason: string | null
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface ShardReport {
|
|
46
|
+
scanned: number
|
|
47
|
+
opened: number
|
|
48
|
+
failed: number
|
|
49
|
+
items: ShardOutcome[]
|
|
50
|
+
}
|
|
51
|
+
|
|
33
52
|
export interface NtMessagesResult extends NtResult {
|
|
34
53
|
messages?: Message[]
|
|
54
|
+
shards?: ShardReport
|
|
35
55
|
}
|
|
36
56
|
|
|
37
57
|
export interface NtContactsResult extends NtResult {
|
|
@@ -282,6 +302,28 @@ export class NtCore {
|
|
|
282
302
|
return { success: true, messages: result.messages || [] }
|
|
283
303
|
}
|
|
284
304
|
|
|
305
|
+
/**
|
|
306
|
+
* `getMessages` plus the per-shard read report.
|
|
307
|
+
*
|
|
308
|
+
* Separate method rather than a flag on `getMessages`: the extra argv token
|
|
309
|
+
* changes what the child returns, and every existing caller is better off
|
|
310
|
+
* with the smaller payload it already parses.
|
|
311
|
+
*/
|
|
312
|
+
async getMessagesWithShards(talker: string, limit = 100, offset = 0): Promise<NtMessagesResult> {
|
|
313
|
+
const args: string[] = [
|
|
314
|
+
'messages',
|
|
315
|
+
'--limit', String(limit),
|
|
316
|
+
'--offset', String(offset),
|
|
317
|
+
'--report-shards',
|
|
318
|
+
]
|
|
319
|
+
const ownWxid = configService.get('wxid')
|
|
320
|
+
const result = await this.callPython(args, { talker, ownWxid })
|
|
321
|
+
if (result.error) {
|
|
322
|
+
return { success: false, error: result.error }
|
|
323
|
+
}
|
|
324
|
+
return { success: true, messages: result.messages || [], shards: result.shards }
|
|
325
|
+
}
|
|
326
|
+
|
|
285
327
|
async getContacts(keyword?: string, limit = 200): Promise<NtContactsResult> {
|
|
286
328
|
const args: string[] = [
|
|
287
329
|
'contacts',
|
|
@@ -3,6 +3,7 @@ import { existsSync } from 'fs'
|
|
|
3
3
|
import { WcdbCore } from '../core/wcdbCore.js'
|
|
4
4
|
import { SqlcipherCore } from '../core/sqlcipherCore.js'
|
|
5
5
|
import { NtCore } from '../core/ntCore.js'
|
|
6
|
+
import type { ShardReport } from '../core/ntCore.js'
|
|
6
7
|
import { configService } from './configService.js'
|
|
7
8
|
import type { ChatSession, Message, Contact, DataVersion } from '../types.js'
|
|
8
9
|
import { collectMessagesInRange } from './messageQuery.js'
|
|
@@ -239,6 +240,31 @@ export class ChatService {
|
|
|
239
240
|
return []
|
|
240
241
|
}
|
|
241
242
|
|
|
243
|
+
/**
|
|
244
|
+
* Messages plus a per-shard read report, when the backend can produce one.
|
|
245
|
+
*
|
|
246
|
+
* Only the NT path (Python) knows about shards. The legacy 3.x, raw-4.x and
|
|
247
|
+
* WCDB paths return `shards: undefined`, which callers must read as "this
|
|
248
|
+
* backend cannot tell you" rather than "everything was read" - see
|
|
249
|
+
* `coverage: 'unverified'` in docs/SYNC_CONTRACT.md.
|
|
250
|
+
*/
|
|
251
|
+
async getMessagesWithShards(
|
|
252
|
+
talker: string,
|
|
253
|
+
limit = 0,
|
|
254
|
+
offset = 0
|
|
255
|
+
): Promise<{ messages: Message[]; shards?: ShardReport }> {
|
|
256
|
+
const conn = await this.connect()
|
|
257
|
+
if (!conn.success) return { messages: [] }
|
|
258
|
+
|
|
259
|
+
if (this.ntCore) {
|
|
260
|
+
const result = await this.ntCore.getMessagesWithShards(talker, limit, offset)
|
|
261
|
+
if (!result.success || !result.messages) return { messages: [] }
|
|
262
|
+
return { messages: result.messages, shards: result.shards }
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
return { messages: await this.getMessages(talker, limit, offset) }
|
|
266
|
+
}
|
|
267
|
+
|
|
242
268
|
async getMessagesInRange(
|
|
243
269
|
talker: string,
|
|
244
270
|
limit = 0,
|
|
@@ -36,3 +36,100 @@ export async function collectMessagesInRange(
|
|
|
36
36
|
|
|
37
37
|
return result
|
|
38
38
|
}
|
|
39
|
+
|
|
40
|
+
/** Why the paging loop stopped. The first two are expected; the last two are not. */
|
|
41
|
+
export type StopReason =
|
|
42
|
+
/** Reached the requested `from` bound. */
|
|
43
|
+
| 'range'
|
|
44
|
+
/** A page came back empty - there is nothing older. */
|
|
45
|
+
| 'exhausted'
|
|
46
|
+
/** Collected the caller's limit. */
|
|
47
|
+
| 'limit'
|
|
48
|
+
/** A page shorter than pageSize ended the loop before `from` was reached. */
|
|
49
|
+
| 'short-page'
|
|
50
|
+
|
|
51
|
+
export interface DetailedMessageCollection {
|
|
52
|
+
messages: Message[]
|
|
53
|
+
pages: number
|
|
54
|
+
stoppedBy: StopReason
|
|
55
|
+
mayHaveMore: boolean
|
|
56
|
+
/**
|
|
57
|
+
* A short page ended the loop while the requested `from` was still ahead
|
|
58
|
+
* of us.
|
|
59
|
+
*
|
|
60
|
+
* `collectMessagesInRange` breaks on a short page, which is right when the
|
|
61
|
+
* page is short because the conversation ended - but a live database can
|
|
62
|
+
* also return a short page while rows are being written, because offset
|
|
63
|
+
* paging shifts underneath. The two are indistinguishable from the result
|
|
64
|
+
* alone, so this records the difference instead of assuming the benign one.
|
|
65
|
+
*/
|
|
66
|
+
truncatedEarly: boolean
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* `collectMessagesInRange` plus the reason it stopped.
|
|
71
|
+
*
|
|
72
|
+
* Kept separate rather than folded into the original: that one is covered by
|
|
73
|
+
* existing tests and used by every export path, and changing what it returns
|
|
74
|
+
* would change those callers.
|
|
75
|
+
*/
|
|
76
|
+
export async function collectMessagesInRangeDetailed(
|
|
77
|
+
fetchPage: (limit: number, offset: number) => Promise<Message[]>,
|
|
78
|
+
limit: number,
|
|
79
|
+
range: MessageTimeRange,
|
|
80
|
+
pageSize = 500,
|
|
81
|
+
): Promise<DetailedMessageCollection> {
|
|
82
|
+
const messages: Message[] = []
|
|
83
|
+
let offset = 0
|
|
84
|
+
let pages = 0
|
|
85
|
+
let stoppedBy: StopReason = 'exhausted'
|
|
86
|
+
let reachedFrom = range.from === undefined
|
|
87
|
+
|
|
88
|
+
while (true) {
|
|
89
|
+
const page = await fetchPage(pageSize, offset)
|
|
90
|
+
pages += 1
|
|
91
|
+
if (page.length === 0) {
|
|
92
|
+
stoppedBy = 'exhausted'
|
|
93
|
+
reachedFrom = true
|
|
94
|
+
break
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
let oldestTimestamp = Number.POSITIVE_INFINITY
|
|
98
|
+
for (const message of page) {
|
|
99
|
+
const timestamp = Number(message.createTime)
|
|
100
|
+
if (!Number.isFinite(timestamp)) continue
|
|
101
|
+
oldestTimestamp = Math.min(oldestTimestamp, timestamp)
|
|
102
|
+
if (range.from !== undefined && timestamp < range.from) continue
|
|
103
|
+
if (range.to !== undefined && timestamp > range.to) continue
|
|
104
|
+
messages.push(message)
|
|
105
|
+
if (limit > 0 && messages.length >= limit) {
|
|
106
|
+
return { messages, pages, stoppedBy: 'limit', mayHaveMore: true, truncatedEarly: false }
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
offset += page.length
|
|
111
|
+
if (range.from !== undefined && oldestTimestamp < range.from) {
|
|
112
|
+
stoppedBy = 'range'
|
|
113
|
+
reachedFrom = true
|
|
114
|
+
break
|
|
115
|
+
}
|
|
116
|
+
if (page.length < pageSize) {
|
|
117
|
+
// A short *first* page is unambiguous: nothing has shifted yet, so the
|
|
118
|
+
// conversation simply has fewer than pageSize messages. A short later
|
|
119
|
+
// page is the ambiguous one offset paging can produce on a live
|
|
120
|
+
// database, so it keeps its own name.
|
|
121
|
+
stoppedBy = pages === 1 ? 'exhausted' : 'short-page'
|
|
122
|
+
break
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return {
|
|
127
|
+
messages,
|
|
128
|
+
pages,
|
|
129
|
+
stoppedBy,
|
|
130
|
+
// A limit hit is handled above; anything else that is not 'range' may
|
|
131
|
+
// still have older matching rows we never looked at.
|
|
132
|
+
mayHaveMore: stoppedBy !== 'range' && stoppedBy !== 'exhausted',
|
|
133
|
+
truncatedEarly: stoppedBy === 'short-page' && !reachedFrom,
|
|
134
|
+
}
|
|
135
|
+
}
|