@remit/drizzle-service 0.0.36 → 0.0.38
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/repos/i4-message-flag-push.test.ts +20 -0
- package/src/repos/i4-message-flag-push.ts +9 -3
- package/src/repos/message.ts +29 -0
- package/src/repos/thread-message.sqlite.test.ts +35 -10
- package/src/repos/thread-message.test.ts +10 -12
- package/src/repos/thread-message.ts +4 -6
package/package.json
CHANGED
|
@@ -122,6 +122,26 @@ describe("MessageFlagPushRepo (relational counterpart to MessageFlagPushService)
|
|
|
122
122
|
assert.equal(found?.state, "pending");
|
|
123
123
|
});
|
|
124
124
|
|
|
125
|
+
test("put resets createdAt too — a stale createdAt would misread as a stuck deferred marker later", async () => {
|
|
126
|
+
const input = seedInput();
|
|
127
|
+
const first = await repo.put(input);
|
|
128
|
+
|
|
129
|
+
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
130
|
+
|
|
131
|
+
const second = await repo.put(
|
|
132
|
+
seedInput({
|
|
133
|
+
messageId: input.messageId,
|
|
134
|
+
flagName: input.flagName,
|
|
135
|
+
operation: "remove",
|
|
136
|
+
}),
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
assert.ok(
|
|
140
|
+
second.createdAt > first.createdAt,
|
|
141
|
+
"a replacing put must start a fresh lifecycle, not inherit the row it replaces",
|
|
142
|
+
);
|
|
143
|
+
});
|
|
144
|
+
|
|
125
145
|
test("put is idempotent — a later flip of the SAME field replaces the marker", async () => {
|
|
126
146
|
const messageId = randomId();
|
|
127
147
|
const flagName = "\\Seen";
|
|
@@ -64,9 +64,14 @@ export class MessageFlagPushRepo {
|
|
|
64
64
|
input: PutMessageFlagPushInput,
|
|
65
65
|
): Promise<MessageFlagPushItem> => {
|
|
66
66
|
const now = Date.now();
|
|
67
|
-
// A fresh put ALWAYS resets state to `pending`
|
|
68
|
-
//
|
|
69
|
-
// state the row it replaces was in.
|
|
67
|
+
// A fresh put ALWAYS resets state to `pending` AND createdAt to now — a
|
|
68
|
+
// new flip decision starts a genuinely new lifecycle regardless of
|
|
69
|
+
// whatever state (or age) the row it replaces was in. The conflict path
|
|
70
|
+
// must reset createdAt explicitly: onConflictDoUpdate only touches the
|
|
71
|
+
// columns named in `set`, so an omission here would let a replacing put
|
|
72
|
+
// silently inherit the original row's createdAt (its age then misreads
|
|
73
|
+
// as a stuck deferred marker elsewhere — see flag-push.ts's defer-max
|
|
74
|
+
// check, which measures a marker's age off exactly this field).
|
|
70
75
|
const [row] = await this.db
|
|
71
76
|
.insert(messageFlagPushTable)
|
|
72
77
|
.values({
|
|
@@ -83,6 +88,7 @@ export class MessageFlagPushRepo {
|
|
|
83
88
|
mailboxId: input.mailboxId,
|
|
84
89
|
operation: input.operation,
|
|
85
90
|
state: DEFAULT_STATE,
|
|
91
|
+
createdAt: now,
|
|
86
92
|
updatedAt: now,
|
|
87
93
|
},
|
|
88
94
|
})
|
package/src/repos/message.ts
CHANGED
|
@@ -94,6 +94,9 @@ function toMessageItem(row: typeof messageTable.$inferSelect): MessageItem {
|
|
|
94
94
|
...(row.placementDecidedAt !== null
|
|
95
95
|
? { placementDecidedAt: row.placementDecidedAt }
|
|
96
96
|
: {}),
|
|
97
|
+
...(row.spamReport !== null
|
|
98
|
+
? { spamReport: row.spamReport as MessageItem["spamReport"] }
|
|
99
|
+
: {}),
|
|
97
100
|
};
|
|
98
101
|
}
|
|
99
102
|
|
|
@@ -193,6 +196,7 @@ export class DrizzleMessageRepository implements IMessageRepository {
|
|
|
193
196
|
placementVerdict: input.placementVerdict ?? null,
|
|
194
197
|
filterMove: input.filterMove ?? null,
|
|
195
198
|
placementDecidedAt: input.placementDecidedAt ?? null,
|
|
199
|
+
spamReport: input.spamReport ?? null,
|
|
196
200
|
createdAt: now,
|
|
197
201
|
updatedAt: now,
|
|
198
202
|
};
|
|
@@ -388,6 +392,9 @@ export class DrizzleMessageRepository implements IMessageRepository {
|
|
|
388
392
|
...(input.messageIdHeader !== undefined
|
|
389
393
|
? { messageIdHeader: input.messageIdHeader }
|
|
390
394
|
: {}),
|
|
395
|
+
...(input.spamReport !== undefined
|
|
396
|
+
? { spamReport: input.spamReport }
|
|
397
|
+
: {}),
|
|
391
398
|
updatedAt: now,
|
|
392
399
|
};
|
|
393
400
|
|
|
@@ -432,6 +439,28 @@ export class DrizzleMessageRepository implements IMessageRepository {
|
|
|
432
439
|
return this.get(messageId);
|
|
433
440
|
}
|
|
434
441
|
|
|
442
|
+
async clearSpamReport(
|
|
443
|
+
messageId: string,
|
|
444
|
+
): ReturnType<IMessageRepository["clearSpamReport"]> {
|
|
445
|
+
const now = Date.now();
|
|
446
|
+
await this.db
|
|
447
|
+
.update(messageTable)
|
|
448
|
+
.set({ spamReport: null, updatedAt: now })
|
|
449
|
+
.where(eq(messageTable.messageId, messageId));
|
|
450
|
+
return this.get(messageId);
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
async clearOriginalMailboxId(
|
|
454
|
+
messageId: string,
|
|
455
|
+
): ReturnType<IMessageRepository["clearOriginalMailboxId"]> {
|
|
456
|
+
const now = Date.now();
|
|
457
|
+
await this.db
|
|
458
|
+
.update(messageTable)
|
|
459
|
+
.set({ originalMailboxId: null, originalUid: null, updatedAt: now })
|
|
460
|
+
.where(eq(messageTable.messageId, messageId));
|
|
461
|
+
return this.get(messageId);
|
|
462
|
+
}
|
|
463
|
+
|
|
435
464
|
async delete(messageId: string): Promise<void> {
|
|
436
465
|
await this.deleteMany([messageId]);
|
|
437
466
|
}
|
|
@@ -10,7 +10,10 @@ import {
|
|
|
10
10
|
LIVE_TOTALS,
|
|
11
11
|
totalRows,
|
|
12
12
|
} from "./category-fixture.js";
|
|
13
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
DrizzleThreadMessageRepository,
|
|
15
|
+
THREAD_SEARCH_MAX_LIMIT,
|
|
16
|
+
} from "./thread-message.js";
|
|
14
17
|
|
|
15
18
|
// The thread-message repo on sqlite (RFC 036 D1): CRUD, keyset pagination, and
|
|
16
19
|
// text search — the FTS5 trigram index for terms of three characters or more
|
|
@@ -163,12 +166,9 @@ describe("DrizzleThreadMessageRepository (sqlite)", () => {
|
|
|
163
166
|
});
|
|
164
167
|
|
|
165
168
|
test("countByMailbox counts matches under the same predicate", async () => {
|
|
166
|
-
const n = await repo.countByMailbox(
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
{ subject: "invoice" },
|
|
170
|
-
{ limit: 100 },
|
|
171
|
-
);
|
|
169
|
+
const n = await repo.countByMailbox(ACCOUNT, MAILBOX, {
|
|
170
|
+
subject: "invoice",
|
|
171
|
+
});
|
|
172
172
|
assert.ok(n >= 1);
|
|
173
173
|
});
|
|
174
174
|
|
|
@@ -754,9 +754,6 @@ describe("DrizzleThreadMessageRepository (sqlite)", () => {
|
|
|
754
754
|
assert.equal(seen.length, LIVE_TOTALS.social + LIVE_TOTALS.transactional);
|
|
755
755
|
});
|
|
756
756
|
|
|
757
|
-
// No `limit`: countByMailbox still clamps its answer to the clamped limit,
|
|
758
|
-
// which is what #305 changes. What this asserts is the predicate — the
|
|
759
|
-
// count is over the mailbox rather than over a page of it.
|
|
760
757
|
test("countByMailbox counts the matches in the mailbox, not the page", async () => {
|
|
761
758
|
assert.equal(
|
|
762
759
|
await repo.countByMailbox(
|
|
@@ -769,6 +766,34 @@ describe("DrizzleThreadMessageRepository (sqlite)", () => {
|
|
|
769
766
|
);
|
|
770
767
|
});
|
|
771
768
|
|
|
769
|
+
// Regression for #509. The count was computed in full and then discarded
|
|
770
|
+
// down to THREAD_SEARCH_MAX_LIMIT, so this mailbox's 4,753 personal
|
|
771
|
+
// messages reported as 500 — a number that reads to the user as the whole
|
|
772
|
+
// match. A page size bounds the rows a response carries; it cannot bound
|
|
773
|
+
// how many messages match.
|
|
774
|
+
test("a match far larger than a page reports its real size", async () => {
|
|
775
|
+
assert.ok(
|
|
776
|
+
LIVE_TOTALS.personal > THREAD_SEARCH_MAX_LIMIT,
|
|
777
|
+
"the fixture has to outgrow the page cap for this to mean anything",
|
|
778
|
+
);
|
|
779
|
+
const page = await repo.searchByMailboxWindow(
|
|
780
|
+
CAT_ACCOUNT,
|
|
781
|
+
CAT_MAILBOX,
|
|
782
|
+
{ category: ["personal"] },
|
|
783
|
+
{ order: "desc", excludeDeleted: true },
|
|
784
|
+
);
|
|
785
|
+
assert.equal(page.items.length, THREAD_SEARCH_MAX_LIMIT);
|
|
786
|
+
assert.equal(
|
|
787
|
+
await repo.countByMailbox(
|
|
788
|
+
CAT_ACCOUNT,
|
|
789
|
+
CAT_MAILBOX,
|
|
790
|
+
{ category: ["personal"] },
|
|
791
|
+
{ excludeDeleted: true },
|
|
792
|
+
),
|
|
793
|
+
LIVE_TOTALS.personal,
|
|
794
|
+
);
|
|
795
|
+
});
|
|
796
|
+
|
|
772
797
|
test("the category predicate composes with the other filters", async () => {
|
|
773
798
|
const page = await repo.searchByMailboxWindow(
|
|
774
799
|
CAT_ACCOUNT,
|
|
@@ -241,15 +241,15 @@ describe("DrizzleThreadMessageRepository.searchByMailboxWindow / countByMailbox"
|
|
|
241
241
|
acct,
|
|
242
242
|
mbx,
|
|
243
243
|
{ subject: "match" },
|
|
244
|
-
{ excludeDeleted: true
|
|
244
|
+
{ excludeDeleted: true },
|
|
245
245
|
);
|
|
246
|
-
assert.equal(count,
|
|
246
|
+
assert.equal(count, 4, "count names every match, not one page of them");
|
|
247
247
|
});
|
|
248
248
|
|
|
249
249
|
// ── Scenario 5 ────────────────────────────────────────────────────────────
|
|
250
|
-
// desc default: the first page holds the newest matches; count
|
|
251
|
-
//
|
|
252
|
-
test("desc page holds the newest matches and count
|
|
250
|
+
// desc default: the first page holds the newest matches; the count names the
|
|
251
|
+
// whole match set behind that page.
|
|
252
|
+
test("desc page holds the newest matches and the count names the whole match", async () => {
|
|
253
253
|
const acct = uuid();
|
|
254
254
|
const mbx = uuid();
|
|
255
255
|
const now = Date.now();
|
|
@@ -277,14 +277,13 @@ describe("DrizzleThreadMessageRepository.searchByMailboxWindow / countByMailbox"
|
|
|
277
277
|
acct,
|
|
278
278
|
mbx,
|
|
279
279
|
{ subject: "alpha" },
|
|
280
|
-
{ excludeDeleted: true
|
|
280
|
+
{ excludeDeleted: true },
|
|
281
281
|
);
|
|
282
|
-
assert.equal(count,
|
|
283
|
-
assert.equal(count, 2);
|
|
282
|
+
assert.equal(count, 5, "every alpha row is counted, not the page of two");
|
|
284
283
|
});
|
|
285
284
|
|
|
286
285
|
// ── Scenario 6 ────────────────────────────────────────────────────────────
|
|
287
|
-
// order:asc returns the OLDEST matches first; count
|
|
286
|
+
// order:asc returns the OLDEST matches first; the count is order-independent.
|
|
288
287
|
test("order:asc returns the oldest matches first", async () => {
|
|
289
288
|
const acct = uuid();
|
|
290
289
|
const mbx = uuid();
|
|
@@ -317,10 +316,9 @@ describe("DrizzleThreadMessageRepository.searchByMailboxWindow / countByMailbox"
|
|
|
317
316
|
acct,
|
|
318
317
|
mbx,
|
|
319
318
|
{ subject: "beta" },
|
|
320
|
-
{ excludeDeleted: true,
|
|
319
|
+
{ excludeDeleted: true, order: "asc" },
|
|
321
320
|
);
|
|
322
|
-
assert.equal(count,
|
|
323
|
-
assert.equal(count, 2);
|
|
321
|
+
assert.equal(count, 5, "asc counts the whole match set too");
|
|
324
322
|
});
|
|
325
323
|
});
|
|
326
324
|
|
|
@@ -876,21 +876,19 @@ export class DrizzleThreadMessageRepository
|
|
|
876
876
|
}
|
|
877
877
|
|
|
878
878
|
/**
|
|
879
|
-
* COUNT of matches over the SAME predicate as `searchByMailboxWindow`,
|
|
880
|
-
*
|
|
881
|
-
*
|
|
879
|
+
* COUNT of matches over the SAME predicate as `searchByMailboxWindow`, over
|
|
880
|
+
* the whole mailbox. `THREAD_SEARCH_MAX_LIMIT` bounds a page of rows; a count
|
|
881
|
+
* returns no rows, so it is answered in full however many messages match.
|
|
882
882
|
*/
|
|
883
883
|
async countByMailbox(
|
|
884
884
|
accountConfigId: string,
|
|
885
885
|
mailboxId: string,
|
|
886
886
|
search: SearchOptions,
|
|
887
887
|
options?: {
|
|
888
|
-
limit?: number;
|
|
889
888
|
excludeDeleted?: boolean;
|
|
890
889
|
order?: "asc" | "desc";
|
|
891
890
|
},
|
|
892
891
|
): Promise<number> {
|
|
893
|
-
const cap = clampThreadSearchLimit(options?.limit);
|
|
894
892
|
const [{ count }] = await this.db
|
|
895
893
|
.select({ count: sql<number>`cast(count(*) as int)` })
|
|
896
894
|
.from(threadMessageTable)
|
|
@@ -904,7 +902,7 @@ export class DrizzleThreadMessageRepository
|
|
|
904
902
|
...buildSearchConditions(search),
|
|
905
903
|
),
|
|
906
904
|
);
|
|
907
|
-
return
|
|
905
|
+
return count;
|
|
908
906
|
}
|
|
909
907
|
|
|
910
908
|
async listAllByAccount(
|