@remit/drizzle-service 0.0.46 → 0.0.47

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.46",
3
+ "version": "0.0.47",
4
4
  "description": "Drizzle ORM service over SQLite",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -0,0 +1,117 @@
1
+ /**
2
+ * The repair against the shape a deployment actually runs: the committed
3
+ * `CREATE TABLE` block, not a schema pushed from the drizzle table objects.
4
+ *
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.
8
+ */
9
+
10
+ import assert from "node:assert/strict";
11
+ import { after, before, describe, test } from "node:test";
12
+ import Database from "better-sqlite3";
13
+ import { shippedTableDdl } from "../test-shipped-sqlite-schema.js";
14
+ import {
15
+ STRANDED_AFTER_MILLIS,
16
+ type StrandedSentRepairClient,
17
+ sweepStrandedSentOutbox,
18
+ } from "./stranded-sent-outbox.js";
19
+
20
+ const clientOver = (sqlite: Database.Database): StrandedSentRepairClient => ({
21
+ all: async (sql, params) => sqlite.prepare(sql).all(...params),
22
+ run: async (sql, params) => sqlite.prepare(sql).run(...params).changes,
23
+ });
24
+
25
+ interface Row {
26
+ status: string;
27
+ last_error: string | null;
28
+ }
29
+
30
+ describe("outbox rows stranded at sent", () => {
31
+ let sqlite: Database.Database;
32
+
33
+ const insert = (
34
+ outboxMessageId: string,
35
+ status: string,
36
+ sentAt: number,
37
+ ): void => {
38
+ sqlite
39
+ .prepare(
40
+ `INSERT INTO outbox_message (
41
+ outbox_message_id, account_id, account_config_id, from_address,
42
+ to_addresses, cc_addresses, bcc_addresses, "references",
43
+ message_id_value, status, sent_at, created_at, updated_at
44
+ ) VALUES (?, 'acc', 'cfg', 'me@example.com', '["you@example.com"]',
45
+ '[]', '[]', '[]', 'mid@example.com', ?, ?, ?, ?)`,
46
+ )
47
+ .run(outboxMessageId, status, sentAt, sentAt, sentAt);
48
+ };
49
+
50
+ const read = (outboxMessageId: string): Row =>
51
+ sqlite
52
+ .prepare(
53
+ "SELECT status, last_error FROM outbox_message WHERE outbox_message_id = ?",
54
+ )
55
+ .get(outboxMessageId) as Row;
56
+
57
+ before(() => {
58
+ sqlite = new Database(":memory:");
59
+ sqlite.exec(
60
+ shippedTableDdl("0000_happy_roland_deschain", "outbox_message"),
61
+ );
62
+ });
63
+
64
+ after(() => {
65
+ sqlite.close();
66
+ });
67
+
68
+ test("makes a stranded sent message visible again", async () => {
69
+ const stranded = Date.now() - STRANDED_AFTER_MILLIS - 1000;
70
+ insert("stranded", "sent", stranded);
71
+
72
+ const report = await sweepStrandedSentOutbox(clientOver(sqlite), "repair");
73
+
74
+ assert.equal(report.stranded, 1);
75
+ assert.equal(report.settled, 1);
76
+ const row = read("stranded");
77
+ assert.equal(row.status, "unfiled");
78
+ assert.match(String(row.last_error), /not filed/);
79
+ });
80
+
81
+ test("a second run writes nothing", async () => {
82
+ const report = await sweepStrandedSentOutbox(clientOver(sqlite), "repair");
83
+
84
+ assert.equal(report.stranded, 0);
85
+ assert.equal(report.settled, 0);
86
+ });
87
+
88
+ test("leaves a filing that is still in flight alone", async () => {
89
+ insert("in-flight", "sent", Date.now());
90
+
91
+ const report = await sweepStrandedSentOutbox(clientOver(sqlite), "repair");
92
+
93
+ assert.equal(report.stranded, 0);
94
+ assert.equal(read("in-flight").status, "sent");
95
+ });
96
+
97
+ test("touches no other status", async () => {
98
+ const old = Date.now() - STRANDED_AFTER_MILLIS - 1000;
99
+ insert("a-draft", "draft", old);
100
+ insert("a-failure", "failed", old);
101
+
102
+ await sweepStrandedSentOutbox(clientOver(sqlite), "repair");
103
+
104
+ assert.equal(read("a-draft").status, "draft");
105
+ assert.equal(read("a-failure").status, "failed");
106
+ });
107
+
108
+ test("check mode reports what it would do and writes nothing", async () => {
109
+ insert("also-stranded", "sent", Date.now() - STRANDED_AFTER_MILLIS - 1000);
110
+
111
+ const report = await sweepStrandedSentOutbox(clientOver(sqlite), "check");
112
+
113
+ assert.equal(report.stranded, 1);
114
+ assert.equal(report.settled, 0);
115
+ assert.equal(read("also-stranded").status, "sent");
116
+ });
117
+ });
@@ -0,0 +1,109 @@
1
+ /**
2
+ * The repair for outbox rows stranded at `sent` (issue #824).
3
+ *
4
+ * A row holds `sent` only between SMTP accepting the message and the IMAP
5
+ * APPEND that files a copy in Sent, whose last act is to delete the row. Every
6
+ * way that APPEND could fail used to end in a silent return, so the row stayed
7
+ * at `sent` forever — and `sent` is the one non-draft status the Outbox list
8
+ * hides, on the assumption that the APPEND deletes it. The message was
9
+ * delivered, exists in no server folder, and appears in no view.
10
+ *
11
+ * The handler now settles those failures as `unfiled`. That reaches nothing
12
+ * already stranded: the events are long gone, and the rows are on instances
13
+ * that only an image update reaches. So the same settlement is applied here,
14
+ * once, at boot — the migrate one-shot is the only step every self-host
15
+ * instance runs before its app containers start (#281).
16
+ *
17
+ * The age bound is what separates a stranded row from an ordinary one. A
18
+ * message sent seconds before a restart has its APPEND event still queued, and
19
+ * that event settles the row itself once the workers come back; rewriting it
20
+ * here would take a filing that was about to succeed. An hour is far past any
21
+ * redrive budget, so a row older than that has no event coming.
22
+ *
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.
25
+ */
26
+
27
+ /**
28
+ * The smallest surface this needs, so the module imports no schema and no
29
+ * driver and can be driven by the migrator's `better-sqlite3` handle or a test.
30
+ */
31
+ export interface StrandedSentRepairClient {
32
+ all(sql: string, params: unknown[]): Promise<unknown[]>;
33
+ run(sql: string, params: unknown[]): Promise<number>;
34
+ }
35
+
36
+ export type StrandedSentRepairMode = "check" | "repair";
37
+
38
+ export type StrandedSentReport = {
39
+ readonly mode: StrandedSentRepairMode;
40
+ readonly stranded: number;
41
+ readonly settled: number;
42
+ };
43
+
44
+ /**
45
+ * One hour. A row younger than this may still have its APPEND event in the
46
+ * queue, and that event settles the row correctly on its own.
47
+ */
48
+ export const STRANDED_AFTER_MILLIS = 60 * 60 * 1000;
49
+
50
+ const STRANDED_SENT_REASON =
51
+ "Sent, but not filed: filing a copy in the Sent folder never completed. The message was delivered.";
52
+
53
+ const NOW_MILLIS = "CAST(unixepoch('subsec') * 1000 AS INTEGER)";
54
+
55
+ const AGE = `coalesce(sent_at, updated_at) < ${NOW_MILLIS} - ?`;
56
+
57
+ const COUNT_SQL = `SELECT count(*) AS row_count
58
+ FROM outbox_message
59
+ WHERE status = 'sent' AND ${AGE}`;
60
+
61
+ const SETTLE_SQL = `UPDATE outbox_message
62
+ SET status = 'unfiled', last_error = ?
63
+ WHERE status = 'sent' AND ${AGE}`;
64
+
65
+ const countStranded = async (
66
+ client: StrandedSentRepairClient,
67
+ ): Promise<number> => {
68
+ const [row] = (await client.all(COUNT_SQL, [STRANDED_AFTER_MILLIS])) as {
69
+ row_count: number;
70
+ }[];
71
+ return row?.row_count ?? 0;
72
+ };
73
+
74
+ /**
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.
79
+ */
80
+ export const sweepStrandedSentOutbox = async (
81
+ client: StrandedSentRepairClient,
82
+ mode: StrandedSentRepairMode,
83
+ ): Promise<StrandedSentReport> => {
84
+ const stranded = await countStranded(client);
85
+ if (mode === "check" || stranded === 0) {
86
+ return { mode, stranded, settled: 0 };
87
+ }
88
+ const settled = await client.run(SETTLE_SQL, [
89
+ STRANDED_SENT_REASON,
90
+ STRANDED_AFTER_MILLIS,
91
+ ]);
92
+ return { mode, stranded, settled };
93
+ };
94
+
95
+ export const formatStrandedSentReport = (
96
+ report: StrandedSentReport,
97
+ ): string[] => {
98
+ if (report.stranded === 0) {
99
+ return ["No sent message is stranded in the outbox"];
100
+ }
101
+ if (report.mode === "check") {
102
+ return [
103
+ `${report.stranded} sent message(s) stranded in the outbox, would be marked unfiled`,
104
+ ];
105
+ }
106
+ return [
107
+ `${report.settled} of ${report.stranded} stranded sent message(s) marked unfiled`,
108
+ ];
109
+ };