@remit/drizzle-service 0.0.80 → 0.0.81
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
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The two data migrations this release carries, against a database written by
|
|
3
|
+
* the build before it.
|
|
4
|
+
*
|
|
5
|
+
* Both exist because a predicate changed meaning under rows already on disk.
|
|
6
|
+
* `carriesForeignUid` reads `originalUid` without consulting `status`, so a
|
|
7
|
+
* settled row that a pre-#1217 build left one on answers "this uid belongs to
|
|
8
|
+
* another folder" forever — and Empty Trash refuses to remove it after the
|
|
9
|
+
* server copy is gone (reader#1230). And the give-up signal moved from
|
|
10
|
+
* `active` + `failed` to its own value, so an abandoned delete written before
|
|
11
|
+
* the upgrade would go quiet: the row stays where it was handed back and the
|
|
12
|
+
* user is told nothing.
|
|
13
|
+
*
|
|
14
|
+
* Applied through the shipped journal rather than a schema pushed from the
|
|
15
|
+
* table objects, because a data migration that never runs is exactly the
|
|
16
|
+
* failure being pinned.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import assert from "node:assert/strict";
|
|
20
|
+
import { after, before, describe, test } from "node:test";
|
|
21
|
+
import Database from "better-sqlite3";
|
|
22
|
+
import {
|
|
23
|
+
applyMigration,
|
|
24
|
+
migrationJournal,
|
|
25
|
+
} from "../test-shipped-sqlite-schema.js";
|
|
26
|
+
|
|
27
|
+
const STALE_ORIGINAL_UID = "msg-stale-original-uid";
|
|
28
|
+
const IN_FLIGHT = "msg-in-flight";
|
|
29
|
+
const GAVE_UP = "msg-gave-up";
|
|
30
|
+
const RETRYING_DELETE = "msg-retrying-delete";
|
|
31
|
+
|
|
32
|
+
/** The migration that clears a settled row's stale `original_uid`. */
|
|
33
|
+
const ORIGINAL_UID_BACKFILL = 24;
|
|
34
|
+
|
|
35
|
+
/** The migration that carries an abandoned delete onto the give-up value. */
|
|
36
|
+
const GIVE_UP_BACKFILL = 25;
|
|
37
|
+
|
|
38
|
+
interface MessageRow {
|
|
39
|
+
message_id: string;
|
|
40
|
+
status: string;
|
|
41
|
+
sync_status: string;
|
|
42
|
+
abandoned_mutation: string;
|
|
43
|
+
original_uid: number | null;
|
|
44
|
+
original_mailbox_id: string | null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
describe("the placement backfills carry a pre-upgrade database forward", () => {
|
|
48
|
+
let sqlite: Database.Database;
|
|
49
|
+
|
|
50
|
+
const rowFor = (messageId: string): MessageRow =>
|
|
51
|
+
sqlite
|
|
52
|
+
.prepare("SELECT * FROM message WHERE message_id = ?")
|
|
53
|
+
.get(messageId) as MessageRow;
|
|
54
|
+
|
|
55
|
+
before(() => {
|
|
56
|
+
sqlite = new Database(":memory:");
|
|
57
|
+
const entries = migrationJournal();
|
|
58
|
+
|
|
59
|
+
// Everything the previous release shipped, and nothing this one adds.
|
|
60
|
+
for (const entry of entries.filter(
|
|
61
|
+
(candidate) => candidate.idx < ORIGINAL_UID_BACKFILL,
|
|
62
|
+
)) {
|
|
63
|
+
applyMigration(sqlite, entry.tag);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const insert = sqlite.prepare(
|
|
67
|
+
`INSERT INTO message (
|
|
68
|
+
message_id, mailbox_id, uid, sequence_number, rfc822_size,
|
|
69
|
+
internal_date, envelope_id, root_body_part_id,
|
|
70
|
+
status, sync_status, original_mailbox_id, original_uid,
|
|
71
|
+
created_at, updated_at
|
|
72
|
+
) VALUES (?, ?, ?, 1, 100, 0, ?, ?, ?, ?, ?, ?, 0, 0)`,
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
// Settled, with the `original_uid` a pre-#1217 `updateUid` left behind —
|
|
76
|
+
// and Trash handing back the source's own number, which on a young
|
|
77
|
+
// account is the common case rather than the exotic one.
|
|
78
|
+
insert.run(
|
|
79
|
+
STALE_ORIGINAL_UID,
|
|
80
|
+
"mbx-trash",
|
|
81
|
+
10,
|
|
82
|
+
`env-${STALE_ORIGINAL_UID}`,
|
|
83
|
+
`bp-${STALE_ORIGINAL_UID}`,
|
|
84
|
+
"active",
|
|
85
|
+
"synced",
|
|
86
|
+
"mbx-inbox",
|
|
87
|
+
10,
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
// Genuinely mid-move: this row's `original_uid` is not stale and must
|
|
91
|
+
// survive, or the guard that stops a move binding somebody else's uid
|
|
92
|
+
// stops working on every row the upgrade touched.
|
|
93
|
+
insert.run(
|
|
94
|
+
IN_FLIGHT,
|
|
95
|
+
"mbx-archive",
|
|
96
|
+
42,
|
|
97
|
+
`env-${IN_FLIGHT}`,
|
|
98
|
+
`bp-${IN_FLIGHT}`,
|
|
99
|
+
"moving",
|
|
100
|
+
"pending",
|
|
101
|
+
"mbx-inbox",
|
|
102
|
+
42,
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
// The pair a give-up wrote before this release. Two writers produced it —
|
|
106
|
+
// `abandonDelete` and the paused-cursor hand-back for an unproven MOVE —
|
|
107
|
+
// through the identical `restoreSourcePlacement` call, so the row cannot
|
|
108
|
+
// say which.
|
|
109
|
+
insert.run(
|
|
110
|
+
GAVE_UP,
|
|
111
|
+
"mbx-inbox",
|
|
112
|
+
7,
|
|
113
|
+
`env-${GAVE_UP}`,
|
|
114
|
+
`bp-${GAVE_UP}`,
|
|
115
|
+
"active",
|
|
116
|
+
"failed",
|
|
117
|
+
"mbx-inbox",
|
|
118
|
+
null,
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
// A transient attempt with a redelivery behind it. It writes the same
|
|
122
|
+
// `failed`, and keeping `deleting` beside it is the only thing that tells
|
|
123
|
+
// the two apart — so the backfill must not touch it.
|
|
124
|
+
insert.run(
|
|
125
|
+
RETRYING_DELETE,
|
|
126
|
+
"mbx-trash",
|
|
127
|
+
8,
|
|
128
|
+
`env-${RETRYING_DELETE}`,
|
|
129
|
+
`bp-${RETRYING_DELETE}`,
|
|
130
|
+
"deleting",
|
|
131
|
+
"failed",
|
|
132
|
+
"mbx-inbox",
|
|
133
|
+
8,
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
for (const entry of entries.filter(
|
|
137
|
+
(candidate) => candidate.idx >= ORIGINAL_UID_BACKFILL,
|
|
138
|
+
)) {
|
|
139
|
+
applyMigration(sqlite, entry.tag);
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
after(() => {
|
|
144
|
+
sqlite.close();
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test("the journal ships both backfills", () => {
|
|
148
|
+
const idxs = migrationJournal().map((entry) => entry.idx);
|
|
149
|
+
assert.ok(idxs.includes(ORIGINAL_UID_BACKFILL));
|
|
150
|
+
assert.ok(idxs.includes(GIVE_UP_BACKFILL));
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test("a settled row loses the stale uid that made it look foreign", () => {
|
|
154
|
+
const row = rowFor(STALE_ORIGINAL_UID);
|
|
155
|
+
assert.equal(row.original_uid, null);
|
|
156
|
+
assert.equal(
|
|
157
|
+
row.original_mailbox_id,
|
|
158
|
+
"mbx-inbox",
|
|
159
|
+
"Undo still restores to the folder the message came from",
|
|
160
|
+
);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test("a row still mid-move keeps the uid its guard depends on", () => {
|
|
164
|
+
assert.equal(rowFor(IN_FLIGHT).original_uid, 42);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test("a give-up keeps its chip, on the value the client now reads", () => {
|
|
168
|
+
const row = rowFor(GAVE_UP);
|
|
169
|
+
assert.equal(row.sync_status, "abandoned");
|
|
170
|
+
assert.equal(row.status, "active");
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* The two pre-upgrade writers are indistinguishable in the row, so the
|
|
175
|
+
* backfill has to pick one label for both. It picks the survivable mistake:
|
|
176
|
+
* a wrongly-labelled move offers a folder picker the user can dismiss, while
|
|
177
|
+
* a wrongly-labelled delete offers a button that destroys the message — the
|
|
178
|
+
* #1229 defect, on a press the user believed was a repair.
|
|
179
|
+
*/
|
|
180
|
+
test("and is never labelled a delete, which would offer to destroy it", () => {
|
|
181
|
+
assert.equal(rowFor(GAVE_UP).abandoned_mutation, "move");
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test("a delete mid-retry is left exactly as it was", () => {
|
|
185
|
+
const row = rowFor(RETRYING_DELETE);
|
|
186
|
+
assert.equal(row.sync_status, "failed");
|
|
187
|
+
assert.equal(row.abandoned_mutation, "none");
|
|
188
|
+
assert.equal(row.status, "deleting");
|
|
189
|
+
});
|
|
190
|
+
});
|
|
@@ -171,6 +171,84 @@ describe("DrizzleMessageRepository (sqlite)", () => {
|
|
|
171
171
|
assert.equal(item.status, "active");
|
|
172
172
|
});
|
|
173
173
|
|
|
174
|
+
// The lock of imap-mutations R3. There is no version column: the predicate
|
|
175
|
+
// is over the placement fields themselves, so an unrelated write to any
|
|
176
|
+
// other column cannot make a transition lose, and a placement the caller
|
|
177
|
+
// did not read cannot make it win.
|
|
178
|
+
describe("transitionPlacement", () => {
|
|
179
|
+
const LOCK_ID = "00000000-0000-0000-2222-00000000000a";
|
|
180
|
+
const OTHER_MAILBOX = "00000000-0000-0000-2222-00000000000b";
|
|
181
|
+
|
|
182
|
+
before(async () => {
|
|
183
|
+
await repo.create({
|
|
184
|
+
...BASE_INPUT,
|
|
185
|
+
messageId: LOCK_ID,
|
|
186
|
+
uid: 7,
|
|
187
|
+
envelopeId: deriveEnvelopeId(LOCK_ID),
|
|
188
|
+
rootBodyPartId: deriveRootBodyPartId(LOCK_ID),
|
|
189
|
+
status: "moving" as const,
|
|
190
|
+
syncStatus: "pending" as const,
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test("writes the row when every named field still matches", async () => {
|
|
195
|
+
const written = await repo.transitionPlacement(
|
|
196
|
+
LOCK_ID,
|
|
197
|
+
{ status: "moving", mailboxId: MAILBOX_ID, uid: 7 },
|
|
198
|
+
{ status: "active", syncStatus: "synced", mailboxId: OTHER_MAILBOX },
|
|
199
|
+
);
|
|
200
|
+
|
|
201
|
+
assert.equal(written?.status, "active");
|
|
202
|
+
assert.equal(written?.syncStatus, "synced");
|
|
203
|
+
assert.equal(written?.mailboxId, OTHER_MAILBOX);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
test("answers undefined and writes nothing when the row has moved on", async () => {
|
|
207
|
+
const lost = await repo.transitionPlacement(
|
|
208
|
+
LOCK_ID,
|
|
209
|
+
{ status: "moving" },
|
|
210
|
+
{ status: "deleting" },
|
|
211
|
+
);
|
|
212
|
+
|
|
213
|
+
assert.equal(lost, undefined);
|
|
214
|
+
assert.equal(
|
|
215
|
+
(await repo.get(LOCK_ID)).status,
|
|
216
|
+
"active",
|
|
217
|
+
"the loser leaves the winner's placement exactly as it found it",
|
|
218
|
+
);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
test("a set of from-states matches any of them", async () => {
|
|
222
|
+
const written = await repo.transitionPlacement(
|
|
223
|
+
LOCK_ID,
|
|
224
|
+
{ status: ["moving", "active"] },
|
|
225
|
+
{ syncStatus: "abandoned" },
|
|
226
|
+
);
|
|
227
|
+
|
|
228
|
+
assert.equal(written?.syncStatus, "abandoned");
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
test("a field the caller did not read constrains nothing", async () => {
|
|
232
|
+
const written = await repo.transitionPlacement(
|
|
233
|
+
LOCK_ID,
|
|
234
|
+
{ uid: 7 },
|
|
235
|
+
{ syncStatus: "synced" },
|
|
236
|
+
);
|
|
237
|
+
|
|
238
|
+
assert.equal(written?.syncStatus, "synced");
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
test("a row that no longer exists loses like any other predicate", async () => {
|
|
242
|
+
const lost = await repo.transitionPlacement(
|
|
243
|
+
"00000000-0000-0000-2222-0000000000ff",
|
|
244
|
+
{ status: "active" },
|
|
245
|
+
{ status: "deleting" },
|
|
246
|
+
);
|
|
247
|
+
|
|
248
|
+
assert.equal(lost, undefined);
|
|
249
|
+
});
|
|
250
|
+
});
|
|
251
|
+
|
|
174
252
|
test("delete removes the message and appends a removal outbox row", async () => {
|
|
175
253
|
await repo.delete(MESSAGE_ID);
|
|
176
254
|
await assert.rejects(() => repo.get(MESSAGE_ID));
|
|
@@ -195,13 +195,18 @@ describe("DrizzleMessageRepository", () => {
|
|
|
195
195
|
});
|
|
196
196
|
|
|
197
197
|
test("returns the moved message with new mailbox and pending status", async () => {
|
|
198
|
-
const moved = await messageRepo.
|
|
199
|
-
|
|
200
|
-
status: "
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
198
|
+
const moved = await messageRepo.transitionPlacement(
|
|
199
|
+
MOVE_MESSAGE_ID,
|
|
200
|
+
{ status: "active", mailboxId: SOURCE_MAILBOX_ID, uid: 7 },
|
|
201
|
+
{
|
|
202
|
+
mailboxId: DEST_MAILBOX_ID,
|
|
203
|
+
status: "moving",
|
|
204
|
+
syncStatus: "pending",
|
|
205
|
+
originalMailboxId: SOURCE_MAILBOX_ID,
|
|
206
|
+
originalUid: 7,
|
|
207
|
+
},
|
|
208
|
+
);
|
|
209
|
+
assert.ok(moved);
|
|
205
210
|
assert.equal(moved.mailboxId, DEST_MAILBOX_ID);
|
|
206
211
|
assert.equal(moved.status, "moving");
|
|
207
212
|
assert.equal(moved.syncStatus, "pending");
|
|
@@ -288,16 +293,14 @@ describe("DrizzleMessageRepository", () => {
|
|
|
288
293
|
assert.equal(read.originalMailboxId, SOURCE_MAILBOX_ID);
|
|
289
294
|
});
|
|
290
295
|
|
|
291
|
-
test("
|
|
292
|
-
|
|
293
|
-
(
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
return true;
|
|
300
|
-
},
|
|
296
|
+
test("the optimistic move answers undefined for an unknown messageId", async () => {
|
|
297
|
+
assert.equal(
|
|
298
|
+
await messageRepo.transitionPlacement(
|
|
299
|
+
"00000000-0000-0000-9999-000000000001",
|
|
300
|
+
{ status: "active" },
|
|
301
|
+
{ mailboxId: DEST_MAILBOX_ID },
|
|
302
|
+
),
|
|
303
|
+
undefined,
|
|
301
304
|
);
|
|
302
305
|
});
|
|
303
306
|
|
package/src/repos/message.ts
CHANGED
|
@@ -4,8 +4,9 @@ import type {
|
|
|
4
4
|
IMessageRepository,
|
|
5
5
|
MessageDescription,
|
|
6
6
|
MessageItem,
|
|
7
|
+
PlacementPredicate,
|
|
7
8
|
} from "@remit/data-ports";
|
|
8
|
-
import { and, asc, eq, gt, inArray, or } from "drizzle-orm";
|
|
9
|
+
import { and, asc, eq, gt, inArray, or, type SQL } from "drizzle-orm";
|
|
9
10
|
|
|
10
11
|
import type { Db } from "../db.js";
|
|
11
12
|
import {
|
|
@@ -57,6 +58,7 @@ function toMessageItem(row: typeof messageTable.$inferSelect): MessageItem {
|
|
|
57
58
|
envelopeId: row.envelopeId,
|
|
58
59
|
rootBodyPartId: row.rootBodyPartId,
|
|
59
60
|
status: row.status,
|
|
61
|
+
abandonedMutation: row.abandonedMutation,
|
|
60
62
|
syncStatus: row.syncStatus,
|
|
61
63
|
category: row.category,
|
|
62
64
|
classificationState: row.classificationState,
|
|
@@ -169,6 +171,34 @@ export async function deleteMessageSubtree(
|
|
|
169
171
|
);
|
|
170
172
|
}
|
|
171
173
|
|
|
174
|
+
/**
|
|
175
|
+
* The WHERE terms of a placement transition (imap-mutations R3). A field the
|
|
176
|
+
* caller left out is a field it did not read, so it constrains nothing; a field
|
|
177
|
+
* given as a list matches any of the states that share one column value.
|
|
178
|
+
*/
|
|
179
|
+
const isList = <T>(value: T | readonly T[]): value is readonly T[] =>
|
|
180
|
+
Array.isArray(value);
|
|
181
|
+
|
|
182
|
+
const oneOf = <T>(value: T | readonly T[]): T[] =>
|
|
183
|
+
isList(value) ? [...value] : [value];
|
|
184
|
+
|
|
185
|
+
const placementTerms = (expected: PlacementPredicate): SQL[] => {
|
|
186
|
+
const terms: SQL[] = [];
|
|
187
|
+
if (expected.status !== undefined) {
|
|
188
|
+
terms.push(inArray(messageTable.status, oneOf(expected.status)));
|
|
189
|
+
}
|
|
190
|
+
if (expected.syncStatus !== undefined) {
|
|
191
|
+
terms.push(inArray(messageTable.syncStatus, oneOf(expected.syncStatus)));
|
|
192
|
+
}
|
|
193
|
+
if (expected.mailboxId !== undefined) {
|
|
194
|
+
terms.push(eq(messageTable.mailboxId, expected.mailboxId));
|
|
195
|
+
}
|
|
196
|
+
if (expected.uid !== undefined) {
|
|
197
|
+
terms.push(eq(messageTable.uid, expected.uid));
|
|
198
|
+
}
|
|
199
|
+
return terms;
|
|
200
|
+
};
|
|
201
|
+
|
|
172
202
|
export class DrizzleMessageRepository implements IMessageRepository {
|
|
173
203
|
constructor(private db: DB) {}
|
|
174
204
|
|
|
@@ -359,10 +389,6 @@ export class DrizzleMessageRepository implements IMessageRepository {
|
|
|
359
389
|
...(input.bodyStorageKey !== undefined
|
|
360
390
|
? { bodyStorageKey: input.bodyStorageKey }
|
|
361
391
|
: {}),
|
|
362
|
-
...(input.status !== undefined ? { status: input.status } : {}),
|
|
363
|
-
...(input.syncStatus !== undefined
|
|
364
|
-
? { syncStatus: input.syncStatus }
|
|
365
|
-
: {}),
|
|
366
392
|
...(input.category !== undefined ? { category: input.category } : {}),
|
|
367
393
|
...(input.classificationState !== undefined
|
|
368
394
|
? { classificationState: input.classificationState }
|
|
@@ -455,17 +481,6 @@ export class DrizzleMessageRepository implements IMessageRepository {
|
|
|
455
481
|
return this.get(messageId);
|
|
456
482
|
}
|
|
457
483
|
|
|
458
|
-
async clearOriginalMailboxId(
|
|
459
|
-
messageId: string,
|
|
460
|
-
): ReturnType<IMessageRepository["clearOriginalMailboxId"]> {
|
|
461
|
-
const now = Date.now();
|
|
462
|
-
await this.db
|
|
463
|
-
.update(messageTable)
|
|
464
|
-
.set({ originalMailboxId: null, originalUid: null, updatedAt: now })
|
|
465
|
-
.where(eq(messageTable.messageId, messageId));
|
|
466
|
-
return this.get(messageId);
|
|
467
|
-
}
|
|
468
|
-
|
|
469
484
|
async delete(messageId: string): Promise<void> {
|
|
470
485
|
await this.deleteMany([messageId]);
|
|
471
486
|
}
|
|
@@ -532,22 +547,24 @@ export class DrizzleMessageRepository implements IMessageRepository {
|
|
|
532
547
|
return rows.map(toMessageItem);
|
|
533
548
|
}
|
|
534
549
|
|
|
535
|
-
async
|
|
550
|
+
async transitionPlacement(
|
|
536
551
|
messageId: string,
|
|
537
|
-
|
|
538
|
-
|
|
552
|
+
expected: PlacementPredicate,
|
|
553
|
+
next: Parameters<IMessageRepository["transitionPlacement"]>[2],
|
|
554
|
+
): ReturnType<IMessageRepository["transitionPlacement"]> {
|
|
539
555
|
const setValues = {
|
|
540
|
-
...(
|
|
541
|
-
...(
|
|
542
|
-
...(
|
|
543
|
-
...(
|
|
544
|
-
|
|
556
|
+
...(next.mailboxId !== undefined ? { mailboxId: next.mailboxId } : {}),
|
|
557
|
+
...(next.uid !== undefined ? { uid: next.uid } : {}),
|
|
558
|
+
...(next.status !== undefined ? { status: next.status } : {}),
|
|
559
|
+
...(next.syncStatus !== undefined ? { syncStatus: next.syncStatus } : {}),
|
|
560
|
+
...(next.abandonedMutation !== undefined
|
|
561
|
+
? { abandonedMutation: next.abandonedMutation }
|
|
545
562
|
: {}),
|
|
546
|
-
...(
|
|
547
|
-
? { originalMailboxId:
|
|
563
|
+
...(next.originalMailboxId !== undefined
|
|
564
|
+
? { originalMailboxId: next.originalMailboxId }
|
|
548
565
|
: {}),
|
|
549
|
-
...(
|
|
550
|
-
? { originalUid:
|
|
566
|
+
...(next.originalUid !== undefined
|
|
567
|
+
? { originalUid: next.originalUid }
|
|
551
568
|
: {}),
|
|
552
569
|
updatedAt: Date.now(),
|
|
553
570
|
};
|
|
@@ -555,11 +572,11 @@ export class DrizzleMessageRepository implements IMessageRepository {
|
|
|
555
572
|
const rows = await this.db
|
|
556
573
|
.update(messageTable)
|
|
557
574
|
.set(setValues)
|
|
558
|
-
.where(
|
|
575
|
+
.where(
|
|
576
|
+
and(eq(messageTable.messageId, messageId), ...placementTerms(expected)),
|
|
577
|
+
)
|
|
559
578
|
.returning();
|
|
560
|
-
if (rows.length === 0)
|
|
561
|
-
throw new NotFoundError(`Message not found: ${messageId}`);
|
|
562
|
-
}
|
|
579
|
+
if (rows.length === 0) return undefined;
|
|
563
580
|
return toMessageItem(rows[0]);
|
|
564
581
|
}
|
|
565
582
|
|
|
@@ -590,6 +607,12 @@ export class DrizzleMessageRepository implements IMessageRepository {
|
|
|
590
607
|
originalUid: null,
|
|
591
608
|
status: "active",
|
|
592
609
|
syncStatus: "synced",
|
|
610
|
+
// The settle clears the epitaph as well as the pair. `syncStatus`
|
|
611
|
+
// alone already gates every reader of it, so this is tidiness
|
|
612
|
+
// rather than correctness — but a row that has just settled has
|
|
613
|
+
// nothing it gave up on, and leaving a value there invites a
|
|
614
|
+
// reader that forgets the gate.
|
|
615
|
+
abandonedMutation: "none",
|
|
593
616
|
updatedAt: Date.now(),
|
|
594
617
|
})
|
|
595
618
|
.where(eq(messageTable.messageId, messageId))
|