@remit/drizzle-service 0.0.52 → 0.0.54

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.52",
3
+ "version": "0.0.54",
4
4
  "description": "Drizzle ORM service over SQLite",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -3,14 +3,17 @@
3
3
  * `CREATE TABLE` block, not a schema pushed from the drizzle table objects.
4
4
  *
5
5
  * What it has to hold on a live database: a message whose filing is still in
6
- * flight is left alone, a stranded one becomes visible, no row is removed, and
7
- * a second run writes nothing.
6
+ * flight is left alone, one that was never filed becomes visible, one that was
7
+ * filed loses the row that outlived its delete, and a second run writes nothing.
8
8
  */
9
9
 
10
10
  import assert from "node:assert/strict";
11
11
  import { after, before, describe, test } from "node:test";
12
12
  import Database from "better-sqlite3";
13
- import { shippedTableDdl } from "../test-shipped-sqlite-schema.js";
13
+ import {
14
+ applyMigration,
15
+ shippedTableDdl,
16
+ } from "../test-shipped-sqlite-schema.js";
14
17
  import {
15
18
  STRANDED_AFTER_MILLIS,
16
19
  type StrandedSentRepairClient,
@@ -34,31 +37,56 @@ describe("outbox rows stranded at sent", () => {
34
37
  outboxMessageId: string,
35
38
  status: string,
36
39
  sentAt: number,
40
+ appendedUid = 0,
37
41
  ): void => {
38
42
  sqlite
39
43
  .prepare(
40
44
  `INSERT INTO outbox_message (
41
45
  outbox_message_id, account_id, account_config_id, from_address,
42
46
  to_addresses, cc_addresses, bcc_addresses, "references",
43
- message_id_value, status, sent_at, created_at, updated_at
47
+ message_id_value, status, sent_at, appended_uid, created_at, updated_at
44
48
  ) VALUES (?, 'acc', 'cfg', 'me@example.com', '["you@example.com"]',
45
- '[]', '[]', '[]', 'mid@example.com', ?, ?, ?, ?)`,
49
+ '[]', '[]', '[]', 'mid@example.com', ?, ?, ?, ?, ?)`,
46
50
  )
47
- .run(outboxMessageId, status, sentAt, sentAt, sentAt);
51
+ .run(outboxMessageId, status, sentAt, appendedUid, sentAt, sentAt);
48
52
  };
49
53
 
50
- const read = (outboxMessageId: string): Row =>
54
+ const attach = (outboxMessageId: string): void => {
55
+ sqlite
56
+ .prepare(
57
+ `INSERT INTO outbox_attachment (
58
+ outbox_attachment_id, outbox_message_id, account_id,
59
+ account_config_id, filename, content_type, size_bytes, state,
60
+ storage_key, reservation_expires_at, created_at, updated_at
61
+ ) VALUES (?, ?, 'acc', 'cfg', 'q.pdf', 'application/pdf', 12,
62
+ 'Stored', 'k', 0, 0, 0)`,
63
+ )
64
+ .run(`att-${outboxMessageId}`, outboxMessageId);
65
+ };
66
+
67
+ const read = (outboxMessageId: string): Row | undefined =>
51
68
  sqlite
52
69
  .prepare(
53
70
  "SELECT status, last_error FROM outbox_message WHERE outbox_message_id = ?",
54
71
  )
55
- .get(outboxMessageId) as Row;
72
+ .get(outboxMessageId) as Row | undefined;
73
+
74
+ const attachmentCount = (outboxMessageId: string): number =>
75
+ (
76
+ sqlite
77
+ .prepare(
78
+ "SELECT count(*) AS n FROM outbox_attachment WHERE outbox_message_id = ?",
79
+ )
80
+ .get(outboxMessageId) as { n: number }
81
+ ).n;
56
82
 
57
83
  before(() => {
58
84
  sqlite = new Database(":memory:");
59
85
  sqlite.exec(
60
86
  shippedTableDdl("0000_happy_roland_deschain", "outbox_message"),
61
87
  );
88
+ sqlite.exec(shippedTableDdl("0013_low_harpoon", "outbox_attachment"));
89
+ applyMigration(sqlite, "0015_true_meggan");
62
90
  });
63
91
 
64
92
  after(() => {
@@ -72,10 +100,11 @@ describe("outbox rows stranded at sent", () => {
72
100
  const report = await sweepStrandedSentOutbox(clientOver(sqlite), "repair");
73
101
 
74
102
  assert.equal(report.stranded, 1);
103
+ assert.equal(report.neverFiled, 1);
75
104
  assert.equal(report.settled, 1);
76
105
  const row = read("stranded");
77
- assert.equal(row.status, "unfiled");
78
- assert.match(String(row.last_error), /not filed/);
106
+ assert.equal(row?.status, "unfiled");
107
+ assert.match(String(row?.last_error), /not filed/);
79
108
  });
80
109
 
81
110
  test("a second run writes nothing", async () => {
@@ -83,6 +112,7 @@ describe("outbox rows stranded at sent", () => {
83
112
 
84
113
  assert.equal(report.stranded, 0);
85
114
  assert.equal(report.settled, 0);
115
+ assert.equal(report.dropped, 0);
86
116
  });
87
117
 
88
118
  test("leaves a filing that is still in flight alone", async () => {
@@ -91,7 +121,7 @@ describe("outbox rows stranded at sent", () => {
91
121
  const report = await sweepStrandedSentOutbox(clientOver(sqlite), "repair");
92
122
 
93
123
  assert.equal(report.stranded, 0);
94
- assert.equal(read("in-flight").status, "sent");
124
+ assert.equal(read("in-flight")?.status, "sent");
95
125
  });
96
126
 
97
127
  test("touches no other status", async () => {
@@ -101,8 +131,8 @@ describe("outbox rows stranded at sent", () => {
101
131
 
102
132
  await sweepStrandedSentOutbox(clientOver(sqlite), "repair");
103
133
 
104
- assert.equal(read("a-draft").status, "draft");
105
- assert.equal(read("a-failure").status, "failed");
134
+ assert.equal(read("a-draft")?.status, "draft");
135
+ assert.equal(read("a-failure")?.status, "failed");
106
136
  });
107
137
 
108
138
  test("check mode reports what it would do and writes nothing", async () => {
@@ -112,6 +142,53 @@ describe("outbox rows stranded at sent", () => {
112
142
 
113
143
  assert.equal(report.stranded, 1);
114
144
  assert.equal(report.settled, 0);
115
- assert.equal(read("also-stranded").status, "sent");
145
+ assert.equal(read("also-stranded")?.status, "sent");
146
+ });
147
+
148
+ test("drops the leftover row of a message that was filed (#858)", async () => {
149
+ const old = Date.now() - STRANDED_AFTER_MILLIS - 1000;
150
+ insert("filed", "sent", old, 55);
151
+ attach("filed");
152
+ insert("never-filed", "sent", old);
153
+ attach("never-filed");
154
+
155
+ const report = await sweepStrandedSentOutbox(clientOver(sqlite), "repair");
156
+
157
+ // The copy is in the user's Sent folder. Marking it unfiled would say the
158
+ // message was never filed and show it in the Outbox alongside the copy.
159
+ assert.equal(report.filed, 1);
160
+ assert.equal(report.dropped, 1);
161
+ assert.equal(read("filed"), undefined);
162
+
163
+ // Its attachment rows go with it, so the objects stop being vouched for
164
+ // and the scheduler's attachment sweep can collect them.
165
+ assert.equal(attachmentCount("filed"), 0);
166
+
167
+ assert.equal(read("never-filed")?.status, "unfiled");
168
+ assert.equal(attachmentCount("never-filed"), 1);
169
+ });
170
+
171
+ test("check mode reports a filed leftover without dropping it", async () => {
172
+ const old = Date.now() - STRANDED_AFTER_MILLIS - 1000;
173
+ insert("filed-too", "sent", old, 55);
174
+
175
+ const report = await sweepStrandedSentOutbox(clientOver(sqlite), "check");
176
+
177
+ assert.equal(report.filed, 1);
178
+ assert.equal(report.dropped, 0);
179
+ assert.equal(read("filed-too")?.status, "sent");
180
+ });
181
+
182
+ test("counts a copy the server named no uid for as filed", async () => {
183
+ // A server without UIDPLUS files the copy and reports nothing. Settling
184
+ // that row unfiled would be as wrong as settling one with a real uid.
185
+ insert("no-uid", "sent", Date.now() - STRANDED_AFTER_MILLIS - 1000, -1);
186
+
187
+ const report = await sweepStrandedSentOutbox(clientOver(sqlite), "repair");
188
+
189
+ assert.equal(report.filed, 2);
190
+ assert.equal(report.dropped, 2);
191
+ assert.equal(read("no-uid"), undefined);
192
+ assert.equal(read("filed-too"), undefined);
116
193
  });
117
194
  });
@@ -14,14 +14,25 @@
14
14
  * once, at boot — the migrate one-shot is the only step every self-host
15
15
  * instance runs before its app containers start (#281).
16
16
  *
17
+ * Two different things strand a row, and `appended_uid` is what tells them
18
+ * apart (issue #858). At `0` the APPEND was never confirmed: the message is in
19
+ * no folder, and `unfiled` with an error the user can read is the honest
20
+ * outcome. At anything else the copy is in the user's Sent folder and only the
21
+ * delete that follows the APPEND failed, so the row is a leftover — marking it
22
+ * `unfiled` would tell the user a message that is sitting in Sent was never
23
+ * filed, and show it to them twice. Those rows are dropped instead, finishing
24
+ * the delete the handler owed. Their attachment objects are collected by the
25
+ * scheduler's attachment sweep, which is where every object that outlives its
26
+ * row already ends up.
27
+ *
17
28
  * The age bound is what separates a stranded row from an ordinary one. A
18
29
  * message sent seconds before a restart has its APPEND event still queued, and
19
30
  * that event settles the row itself once the workers come back; rewriting it
20
31
  * here would take a filing that was about to succeed. An hour is far past any
21
32
  * redrive budget, so a row older than that has no event coming.
22
33
  *
23
- * It is a status flip and an error string, on rows whose only other outcome is
24
- * to stay invisible. Nothing is deleted and no message content is touched.
34
+ * No message content is touched, and nothing is dropped that the mail server is
35
+ * not already holding a copy of.
25
36
  */
26
37
 
27
38
  /**
@@ -38,7 +49,12 @@ export type StrandedSentRepairMode = "check" | "repair";
38
49
  export type StrandedSentReport = {
39
50
  readonly mode: StrandedSentRepairMode;
40
51
  readonly stranded: number;
52
+ /** Stranded rows whose APPEND was never confirmed. */
53
+ readonly neverFiled: number;
54
+ /** Stranded rows whose copy reached Sent and whose delete did not. */
55
+ readonly filed: number;
41
56
  readonly settled: number;
57
+ readonly dropped: number;
42
58
  };
43
59
 
44
60
  /**
@@ -50,46 +66,93 @@ export const STRANDED_AFTER_MILLIS = 60 * 60 * 1000;
50
66
  const STRANDED_SENT_REASON =
51
67
  "Sent, but not filed: filing a copy in the Sent folder never completed. The message was delivered.";
52
68
 
53
- const NOW_MILLIS = "CAST(unixepoch('subsec') * 1000 AS INTEGER)";
69
+ /**
70
+ * The cutoff is computed once and bound to all four statements, rather than
71
+ * each of them subtracting the age bound from its own `unixepoch()`. Four
72
+ * clocks give four different populations: a row that crosses the hour between
73
+ * the count and a write, or between the two deletes, is counted and not written
74
+ * or has its files taken and its row left.
75
+ */
76
+ const STRANDED = "status = 'sent' AND coalesce(sent_at, updated_at) < ?";
77
+
78
+ /**
79
+ * `isSentCopyFiled` in @remit/data-ports, in SQL. Only the two values that mean
80
+ * "filed" say so, so a value neither writes reads as unfiled — which settles a
81
+ * row rather than dropping it.
82
+ */
83
+ const FILED_UID = "(appended_uid > 0 OR appended_uid = -1)";
84
+
85
+ const NEVER_FILED = `${STRANDED} AND NOT ${FILED_UID}`;
54
86
 
55
- const AGE = `coalesce(sent_at, updated_at) < ${NOW_MILLIS} - ?`;
87
+ const FILED = `${STRANDED} AND ${FILED_UID}`;
56
88
 
57
- const COUNT_SQL = `SELECT count(*) AS row_count
89
+ const COUNT_SQL = `SELECT
90
+ coalesce(sum(CASE WHEN NOT ${FILED_UID} THEN 1 ELSE 0 END), 0) AS never_filed,
91
+ coalesce(sum(CASE WHEN ${FILED_UID} THEN 1 ELSE 0 END), 0) AS filed
58
92
  FROM outbox_message
59
- WHERE status = 'sent' AND ${AGE}`;
93
+ WHERE ${STRANDED}`;
60
94
 
61
95
  const SETTLE_SQL = `UPDATE outbox_message
62
96
  SET status = 'unfiled', last_error = ?
63
- WHERE status = 'sent' AND ${AGE}`;
97
+ WHERE ${NEVER_FILED}`;
98
+
99
+ const DROP_ATTACHMENTS_SQL = `DELETE FROM outbox_attachment
100
+ WHERE outbox_message_id IN (
101
+ SELECT outbox_message_id FROM outbox_message WHERE ${FILED}
102
+ )`;
103
+
104
+ const DROP_MESSAGES_SQL = `DELETE FROM outbox_message
105
+ WHERE ${FILED}`;
64
106
 
65
107
  const countStranded = async (
66
108
  client: StrandedSentRepairClient,
67
- ): Promise<number> => {
68
- const [row] = (await client.all(COUNT_SQL, [STRANDED_AFTER_MILLIS])) as {
69
- row_count: number;
109
+ strandedBefore: number,
110
+ ): Promise<{ neverFiled: number; filed: number }> => {
111
+ const [row] = (await client.all(COUNT_SQL, [strandedBefore])) as {
112
+ never_filed: number;
113
+ filed: number;
70
114
  }[];
71
- return row?.row_count ?? 0;
115
+ return { neverFiled: row?.never_filed ?? 0, filed: row?.filed ?? 0 };
72
116
  };
73
117
 
74
118
  /**
75
- * The UPDATE runs only when the count found rows. SQLite takes its exclusive
76
- * write lock the moment an UPDATE begins, before it can know the WHERE matches
77
- * nothing, and a lock the migrator cannot get inside its `busy_timeout` fails
78
- * the migration and holds every gated service down. Zero is the steady state.
119
+ * Each write runs only when the count found rows for it. SQLite takes its
120
+ * exclusive write lock the moment a statement begins, before it can know the
121
+ * WHERE matches nothing, and a lock the migrator cannot get inside its
122
+ * `busy_timeout` fails the migration and holds every gated service down. Zero
123
+ * is the steady state.
124
+ *
125
+ * The attachment rows go before the message rows they hang off. Reversed, a
126
+ * crash between the two statements leaves attachment rows naming a message that
127
+ * no longer exists — nothing would ever find them again, and their objects would
128
+ * be vouched for forever.
79
129
  */
80
130
  export const sweepStrandedSentOutbox = async (
81
131
  client: StrandedSentRepairClient,
82
132
  mode: StrandedSentRepairMode,
83
133
  ): Promise<StrandedSentReport> => {
84
- const stranded = await countStranded(client);
134
+ const strandedBefore = Date.now() - STRANDED_AFTER_MILLIS;
135
+ const { neverFiled, filed } = await countStranded(client, strandedBefore);
136
+ const stranded = neverFiled + filed;
137
+ const nothingWritten = { mode, stranded, neverFiled, filed } as const;
138
+
85
139
  if (mode === "check" || stranded === 0) {
86
- return { mode, stranded, settled: 0 };
140
+ return { ...nothingWritten, settled: 0, dropped: 0 };
87
141
  }
88
- const settled = await client.run(SETTLE_SQL, [
89
- STRANDED_SENT_REASON,
90
- STRANDED_AFTER_MILLIS,
91
- ]);
92
- return { mode, stranded, settled };
142
+
143
+ const settled =
144
+ neverFiled === 0
145
+ ? 0
146
+ : await client.run(SETTLE_SQL, [STRANDED_SENT_REASON, strandedBefore]);
147
+
148
+ if (filed === 0) {
149
+ return { ...nothingWritten, settled, dropped: 0 };
150
+ }
151
+
152
+ await client.run(DROP_ATTACHMENTS_SQL, [strandedBefore]);
153
+ const dropped = await client.run(DROP_MESSAGES_SQL, [strandedBefore]);
154
+
155
+ return { ...nothingWritten, settled, dropped };
93
156
  };
94
157
 
95
158
  export const formatStrandedSentReport = (
@@ -98,12 +161,31 @@ export const formatStrandedSentReport = (
98
161
  if (report.stranded === 0) {
99
162
  return ["No sent message is stranded in the outbox"];
100
163
  }
164
+
165
+ const lines: string[] = [];
101
166
  if (report.mode === "check") {
102
- return [
103
- `${report.stranded} sent message(s) stranded in the outbox, would be marked unfiled`,
104
- ];
167
+ if (report.neverFiled > 0) {
168
+ lines.push(
169
+ `${report.neverFiled} sent message(s) stranded in the outbox, would be marked unfiled`,
170
+ );
171
+ }
172
+ if (report.filed > 0) {
173
+ lines.push(
174
+ `${report.filed} sent message(s) already filed in Sent, their outbox row would be dropped`,
175
+ );
176
+ }
177
+ return lines;
178
+ }
179
+
180
+ if (report.neverFiled > 0) {
181
+ lines.push(
182
+ `${report.settled} of ${report.neverFiled} stranded sent message(s) marked unfiled`,
183
+ );
184
+ }
185
+ if (report.filed > 0) {
186
+ lines.push(
187
+ `${report.dropped} of ${report.filed} already-filed sent message(s) dropped from the outbox`,
188
+ );
105
189
  }
106
- return [
107
- `${report.settled} of ${report.stranded} stranded sent message(s) marked unfiled`,
108
- ];
190
+ return lines;
109
191
  };
@@ -14,11 +14,15 @@ import { AccountSettingRepo } from "./i4-account-setting.js";
14
14
  import { MailboxRepo } from "./i4-mailbox.js";
15
15
  import { MailboxSpecialUseRepo } from "./i4-mailbox-special-use.js";
16
16
 
17
- const makeMailboxInput = (accountId: string, fullPath: string) => ({
17
+ const makeMailboxInput = (
18
+ accountId: string,
19
+ fullPath: string,
20
+ hierarchyDelimiter = "/",
21
+ ) => ({
18
22
  accountId,
19
23
  namespaceType: "personal" as const,
20
24
  namespacePrefix: "",
21
- hierarchyDelimiter: "/",
25
+ hierarchyDelimiter,
22
26
  fullPath,
23
27
  uidValidity: 1,
24
28
  uidNext: 1,
@@ -210,6 +214,59 @@ describe("MailboxSpecialUseRepo role lookups (sqlite)", () => {
210
214
  assert.equal(found?.mailboxId, spam.mailboxId);
211
215
  });
212
216
 
217
+ test("resolves an INBOX-nested Trash folder that advertises no special use", async () => {
218
+ // #837: `findTrashMailbox` carried its own copy of the discarded
219
+ // whole-path rule, so `INBOX/Trash` resolved to nothing and a delete
220
+ // refused on an account that plainly has a Trash folder.
221
+ const { accountId } = await makeAccount();
222
+ await mailboxes.create(makeMailboxInput(accountId, "INBOX"));
223
+ const trash = await mailboxes.create(
224
+ makeMailboxInput(accountId, "INBOX/Trash"),
225
+ );
226
+
227
+ assert.equal(
228
+ (await repo.findTrashMailbox(accountId))?.mailboxId,
229
+ trash.mailboxId,
230
+ );
231
+ // Resolving the name widens where a delete FILES mail, never what an
232
+ // Empty Trash may expunge (#846): that still needs the flag or an
233
+ // appointment.
234
+ assert.equal(await repo.findConfirmedTrashMailbox(accountId), null);
235
+ });
236
+
237
+ test("resolves an INBOX-nested Archive folder that advertises no special use", async () => {
238
+ const { accountId } = await makeAccount();
239
+ await mailboxes.create(makeMailboxInput(accountId, "INBOX"));
240
+ const archive = await mailboxes.create(
241
+ makeMailboxInput(accountId, "INBOX/Archive"),
242
+ );
243
+
244
+ assert.equal(
245
+ (await repo.findArchiveMailbox(accountId))?.mailboxId,
246
+ archive.mailboxId,
247
+ );
248
+ });
249
+
250
+ test("splits the leaf on the account's own delimiter, not on a slash", async () => {
251
+ const { accountId } = await makeAccount();
252
+ await mailboxes.create(makeMailboxInput(accountId, "INBOX", "."));
253
+ const trash = await mailboxes.create(
254
+ makeMailboxInput(accountId, "INBOX.Trash", "."),
255
+ );
256
+ const archive = await mailboxes.create(
257
+ makeMailboxInput(accountId, "INBOX.Archive", "."),
258
+ );
259
+
260
+ assert.equal(
261
+ (await repo.findTrashMailbox(accountId))?.mailboxId,
262
+ trash.mailboxId,
263
+ );
264
+ assert.equal(
265
+ (await repo.findArchiveMailbox(accountId))?.mailboxId,
266
+ archive.mailboxId,
267
+ );
268
+ });
269
+
213
270
  test("answers null when the account has no Junk folder at all", async () => {
214
271
  const { accountId } = await makeAccount();
215
272
  await mailboxes.create(makeMailboxInput(accountId, "INBOX"));
@@ -37,6 +37,7 @@ function rowToOutboxMessage(
37
37
  lastSmtpCode: row.lastSmtpCode ?? undefined,
38
38
  sentAt: row.sentAt ?? undefined,
39
39
  smtpMessageId: row.smtpMessageId ?? undefined,
40
+ appendedUid: row.appendedUid,
40
41
  createdAt: row.createdAt,
41
42
  updatedAt: row.updatedAt,
42
43
  };
@@ -139,6 +140,8 @@ export class OutboxMessageRepo implements IOutboxMessageRepository {
139
140
  if (input.sentAt !== undefined) updates.sentAt = input.sentAt;
140
141
  if (input.smtpMessageId !== undefined)
141
142
  updates.smtpMessageId = input.smtpMessageId;
143
+ if (input.appendedUid !== undefined)
144
+ updates.appendedUid = input.appendedUid;
142
145
  if (input.toAddresses !== undefined)
143
146
  updates.toAddresses = input.toAddresses;
144
147
  if (input.ccAddresses !== undefined)