@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/drizzle-service",
3
- "version": "0.0.36",
3
+ "version": "0.0.38",
4
4
  "description": "Drizzle ORM service over SQLite",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -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` (the field's default)
68
- // a new flip decision starts a new lifecycle regardless of whatever
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
  })
@@ -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 { DrizzleThreadMessageRepository } from "./thread-message.js";
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
- ACCOUNT,
168
- MAILBOX,
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, limit: 2 },
244
+ { excludeDeleted: true },
245
245
  );
246
- assert.equal(count, 2, "count is capped at the page limit when more match");
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 equals the
251
- // page length when more rows match than the limit.
252
- test("desc page holds the newest matches and count equals items.length", async () => {
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, limit: 2 },
280
+ { excludeDeleted: true },
281
281
  );
282
- assert.equal(count, window.items.length, "count == items.length");
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 agrees with the page.
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, limit: 2, order: "asc" },
319
+ { excludeDeleted: true, order: "asc" },
321
320
  );
322
- assert.equal(count, window.items.length, "count == items.length for asc");
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`, capped
880
- * at the same clamped limit so `count` never exceeds one page count equals
881
- * `items.length` whenever both are issued with the same limit.
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 Math.min(count, cap);
905
+ return count;
908
906
  }
909
907
 
910
908
  async listAllByAccount(