@remit/drizzle-service 0.0.99 → 0.0.101

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.
Files changed (93) hide show
  1. package/package.json +1 -1
  2. package/src/batch.sqlite.test.ts +78 -0
  3. package/src/batch.ts +46 -0
  4. package/src/databases.ts +70 -0
  5. package/src/db.ts +10 -9
  6. package/src/generated-enum.test.ts +8 -0
  7. package/src/generated-id.test.ts +19 -0
  8. package/src/id.test.ts +0 -8
  9. package/src/id.ts +0 -6
  10. package/src/index.ts +16 -6
  11. package/src/repair/address-display-name.sqlite.test.ts +3 -5
  12. package/src/repair/address-display-name.ts +8 -3
  13. package/src/repair/junk-only-address.sqlite.test.ts +8 -36
  14. package/src/repair/stranded-sent-outbox.sqlite.test.ts +2 -9
  15. package/src/repos/account.conformance.sqlite.test.ts +1 -1
  16. package/src/repos/calendar-default.sqlite.test.ts +6 -6
  17. package/src/repos/calendar-put.sqlite.test.ts +40 -39
  18. package/src/repos/calendar-store.ts +273 -0
  19. package/src/repos/cascade-delete.sqlite.test.ts +7 -9
  20. package/src/repos/cascade-delete.test.ts +6 -13
  21. package/src/repos/cascade-delete.ts +127 -97
  22. package/src/repos/envelope.test.ts +1 -17
  23. package/src/repos/envelope.ts +83 -67
  24. package/src/repos/filter.test.ts +22 -2
  25. package/src/repos/filter.ts +1 -5
  26. package/src/repos/i4-account-config.test.ts +2 -2
  27. package/src/repos/i4-account-config.ts +5 -2
  28. package/src/repos/i4-account.test.ts +6 -2
  29. package/src/repos/i4-account.ts +12 -56
  30. package/src/repos/i4-address-junk-move.sqlite.test.ts +1 -1
  31. package/src/repos/i4-address-merge-flags.sqlite.test.ts +1 -2
  32. package/src/repos/i4-address.ts +58 -90
  33. package/src/repos/i4-mailbox-special-use.sqlite.test.ts +6 -2
  34. package/src/repos/i4-mailbox-special-use.ts +9 -6
  35. package/src/repos/i4-mailbox.sqlite.test.ts +12 -14
  36. package/src/repos/i4-mailbox.test.ts +17 -62
  37. package/src/repos/i4-mailbox.ts +121 -94
  38. package/src/repos/i4-organize-job-request.ts +0 -4
  39. package/src/repos/i4-outbox-attachment.ts +62 -33
  40. package/src/repos/mappers.ts +0 -16
  41. package/src/repos/message.sqlite.test.ts +32 -106
  42. package/src/repos/message.test.ts +6 -167
  43. package/src/repos/message.ts +216 -228
  44. package/src/repos/sync-warning.ts +76 -0
  45. package/src/repos/thread-message-body-muted.sqlite.test.ts +5 -10
  46. package/src/repos/thread-message-field-terms.sqlite.test.ts +5 -1
  47. package/src/repos/thread-message.sqlite.test.ts +2 -1
  48. package/src/repos/thread-message.test.ts +17 -4
  49. package/src/repos/thread-message.ts +502 -285
  50. package/src/repos/thread-search-predicates.ts +24 -26
  51. package/src/schema/message-data.ts +4 -8
  52. package/src/schema/sync-warning.ts +3 -0
  53. package/src/schema-sets/app.ts +17 -0
  54. package/src/schema-sets/mail.ts +32 -0
  55. package/src/schema-sets/search.ts +5 -0
  56. package/src/schema-version-accounting.sqlite.test.ts +56 -43
  57. package/src/schema.ts +3 -4
  58. package/src/search/thread-message-search.sqlite.test.ts +131 -0
  59. package/src/search/thread-message-search.ts +271 -0
  60. package/src/sqlite-client.ts +5 -7
  61. package/src/sqlite-repositories.ts +75 -0
  62. package/src/test-db-sqlite.ts +5 -13
  63. package/src/test-shipped-sqlite-schema.ts +46 -45
  64. package/src/test-sqlite.ts +35 -11
  65. package/src/vps-migrations-drift.sqlite.test.ts +81 -15
  66. package/src/account-granted-scopes-migration.sqlite.test.ts +0 -66
  67. package/src/account-synced-services-migration.sqlite.test.ts +0 -56
  68. package/src/mailbox-path-dedupe.sqlite.test.ts +0 -462
  69. package/src/mailbox-sync-status-backfill.sqlite.test.ts +0 -118
  70. package/src/outbox-created-cleanup.sqlite.test.ts +0 -83
  71. package/src/repair/duplicate-mailbox-mail.ts +0 -98
  72. package/src/repair/thread-message-category-contract.ts +0 -505
  73. package/src/repair/thread-message-category.sqlite.test.ts +0 -100
  74. package/src/repair/thread-message-category.ts +0 -415
  75. package/src/repos/calendar-migrations.sqlite.test.ts +0 -345
  76. package/src/repos/calendar-unit-of-work.ts +0 -31
  77. package/src/repos/config-import-atomicity.sqlite.test.ts +0 -255
  78. package/src/repos/filter-anchor-transaction.test.ts +0 -136
  79. package/src/repos/filter-anchor-transaction.ts +0 -45
  80. package/src/repos/filter-anchor.test.ts +0 -131
  81. package/src/repos/filter-anchor.ts +0 -105
  82. package/src/repos/i4-mailbox-special-use.structure.test.ts +0 -27
  83. package/src/repos/message-placement-backfill.sqlite.test.ts +0 -190
  84. package/src/repos/search-index-shape.test.ts +0 -82
  85. package/src/repos/search-index-shape.ts +0 -56
  86. package/src/repos/serialized-writes.sqlite.test.ts +0 -199
  87. package/src/repos/unit-of-work.sqlite.test.ts +0 -138
  88. package/src/repos/unit-of-work.test.ts +0 -109
  89. package/src/repos/unit-of-work.ts +0 -31
  90. package/src/schema/outbox.ts +0 -46
  91. package/src/schema-full-sqlite.ts +0 -13
  92. package/src/tx.sqlite.test.ts +0 -83
  93. package/src/tx.ts +0 -233
@@ -1,5 +1,10 @@
1
1
  import { and, eq, inArray, or, type SQL } from "drizzle-orm";
2
- import type { Db } from "../db.js";
2
+ import { runBatch, type SqliteBatchItem } from "../batch.js";
3
+ import {
4
+ openSqliteDatabases,
5
+ type SqliteDatabasePaths,
6
+ type SqliteDatabases,
7
+ } from "../databases.js";
3
8
  import { accountTable } from "../schema/i4-account-config.js";
4
9
  import { accountSettingTable } from "../schema/i4-account-setting.js";
5
10
  import { addressTable } from "../schema/i4-address.js";
@@ -8,12 +13,11 @@ import { mailboxLockTable } from "../schema/i4-mailbox-lock.js";
8
13
  import { messageFlagPushTable } from "../schema/i4-message-flag-push.js";
9
14
  import { messagePlacementMoveTable } from "../schema/i4-message-placement-move.js";
10
15
  import { outboxMessageTable } from "../schema/i4-outbox-message.js";
11
- import { messageDataSchema } from "../schema/message-data.js";
12
16
  import { quarantineTable } from "../schema/quarantine.js";
17
+ import { syncWarningTable } from "../schema/sync-warning.js";
13
18
  import { threadMessageTable } from "../schema/thread-message.js";
14
- import { createSqliteDatabase } from "../sqlite-client.js";
15
- import { runInTransaction } from "../tx.js";
16
- import { deleteMessageSubtree, type SubtreeDb } from "./message.js";
19
+ import { SearchIndex } from "../search/thread-message-search.js";
20
+ import { deleteMessageSubtree } from "./message.js";
17
21
 
18
22
  export interface CascadeEntity {
19
23
  entityType: string;
@@ -24,8 +28,6 @@ export interface CascadeDeleteLogger {
24
28
  info(obj: Record<string, unknown>, msg: string): void;
25
29
  }
26
30
 
27
- type CascadeDb = Db<Record<string, unknown>>;
28
-
29
31
  /**
30
32
  * Per-message entity types. Their rows are removed with the message subtree by
31
33
  * message id, so their individually-enumerated keys are not deleted one by one —
@@ -40,7 +42,6 @@ const MESSAGE_CHILD_TYPES = new Set([
40
42
  "BodyPart",
41
43
  "BodyPartParameter",
42
44
  "BodyPartStorage",
43
- "BodyPartContent",
44
45
  "RawMessageStorage",
45
46
  ]);
46
47
 
@@ -71,15 +72,17 @@ const groupByType = (
71
72
 
72
73
  /**
73
74
  * Relational equivalent of the DynamoDB BatchWriteItem cascade: delete every
74
- * enumerated row for an account/tenant in one transaction. Message subtrees
75
- * (message + nine child tables) are deleted set-wise by message id and emit a
76
- * `message.removed` outbox row each for search-index cleanup; the remaining
75
+ * enumerated row for an account/tenant, one batch per database — the mail rows
76
+ * first, then the app rows, then the search documents of the deleted thread
77
+ * messages. Every statement is keyed, so a rerun after a failure between two
78
+ * batches deletes what is left. Message subtrees
79
+ * (message + nine child tables) are deleted set-wise by message id; the remaining
77
80
  * entity types are deleted by their enumerated primary keys. AccountConfig is
78
81
  * never deleted here — the caller removes it last through its repository, the
79
82
  * cascade-in-progress marker.
80
83
  */
81
84
  export const runDrizzleCascadeDelete = async (
82
- db: CascadeDb,
85
+ { app, mail, search }: SqliteDatabases,
83
86
  entities: CascadeEntity[],
84
87
  log: CascadeDeleteLogger,
85
88
  ): Promise<void> => {
@@ -92,103 +95,132 @@ export const runDrizzleCascadeDelete = async (
92
95
  }
93
96
  }
94
97
 
95
- await runInTransaction(db, async (tx) => {
96
- const messageIds = (grouped.get("Message") ?? []).map((k) => k.messageId);
97
- await deleteMessageSubtree(tx as unknown as SubtreeDb, messageIds);
98
-
99
- const threadMessageIds = (grouped.get("ThreadMessage") ?? []).map(
100
- (k) => k.threadMessageId,
101
- );
102
- if (threadMessageIds.length > 0) {
103
- await tx
98
+ const items: SqliteBatchItem[] = [];
99
+ const appItems: SqliteBatchItem[] = [];
100
+ const messageIds = (grouped.get("Message") ?? []).map((k) => k.messageId);
101
+ items.push(...deleteMessageSubtree(mail, messageIds));
102
+
103
+ const threadMessageIds = (grouped.get("ThreadMessage") ?? []).map(
104
+ (k) => k.threadMessageId,
105
+ );
106
+ if (threadMessageIds.length > 0) {
107
+ items.push(
108
+ mail
104
109
  .delete(threadMessageTable)
105
- .where(inArray(threadMessageTable.threadMessageId, threadMessageIds));
106
- }
110
+ .where(inArray(threadMessageTable.threadMessageId, threadMessageIds)),
111
+ );
112
+ }
107
113
 
108
- const mailboxIds = (grouped.get("Mailbox") ?? []).map((k) => k.mailboxId);
109
- if (mailboxIds.length > 0) {
110
- await tx
114
+ const mailboxIds = (grouped.get("Mailbox") ?? []).map((k) => k.mailboxId);
115
+ if (mailboxIds.length > 0) {
116
+ items.push(
117
+ mail
118
+ .delete(syncWarningTable)
119
+ .where(inArray(syncWarningTable.mailboxId, mailboxIds)),
120
+ );
121
+ items.push(
122
+ mail
111
123
  .delete(mailboxTable)
112
- .where(inArray(mailboxTable.mailboxId, mailboxIds));
113
- }
114
-
115
- const outboxMessageIds = (grouped.get("OutboxMessage") ?? []).map(
116
- (k) => k.outboxMessageId,
124
+ .where(inArray(mailboxTable.mailboxId, mailboxIds)),
117
125
  );
118
- if (outboxMessageIds.length > 0) {
119
- await tx
126
+ }
127
+
128
+ const outboxMessageIds = (grouped.get("OutboxMessage") ?? []).map(
129
+ (k) => k.outboxMessageId,
130
+ );
131
+ if (outboxMessageIds.length > 0) {
132
+ items.push(
133
+ mail
120
134
  .delete(outboxMessageTable)
121
- .where(inArray(outboxMessageTable.outboxMessageId, outboxMessageIds));
122
- }
135
+ .where(inArray(outboxMessageTable.outboxMessageId, outboxMessageIds)),
136
+ );
137
+ }
123
138
 
124
- const lockKeys = grouped.get("MailboxLock") ?? [];
125
- if (lockKeys.length > 0) {
126
- const conditions: SQL[] = lockKeys.map((k) =>
127
- and(
128
- eq(mailboxLockTable.mailboxId, k.mailboxId),
129
- eq(mailboxLockTable.eventName, k.eventName),
130
- ),
131
- ) as SQL[];
132
- await tx.delete(mailboxLockTable).where(or(...conditions));
133
- }
139
+ const lockKeys = grouped.get("MailboxLock") ?? [];
140
+ if (lockKeys.length > 0) {
141
+ const conditions: SQL[] = lockKeys.map((k) =>
142
+ and(
143
+ eq(mailboxLockTable.mailboxId, k.mailboxId),
144
+ eq(mailboxLockTable.eventName, k.eventName),
145
+ ),
146
+ ) as SQL[];
147
+ items.push(mail.delete(mailboxLockTable).where(or(...conditions)));
148
+ }
134
149
 
135
- const accountSettingIds = (grouped.get("AccountSetting") ?? []).map(
136
- (k) => k.accountSettingId,
137
- );
138
- if (accountSettingIds.length > 0) {
139
- await tx
150
+ const accountSettingIds = (grouped.get("AccountSetting") ?? []).map(
151
+ (k) => k.accountSettingId,
152
+ );
153
+ if (accountSettingIds.length > 0) {
154
+ appItems.push(
155
+ app
140
156
  .delete(accountSettingTable)
141
157
  .where(
142
158
  inArray(accountSettingTable.accountSettingId, accountSettingIds),
143
- );
144
- }
159
+ ),
160
+ );
161
+ }
145
162
 
146
- const addressIds = (grouped.get("Address") ?? []).map((k) => k.addressId);
147
- if (addressIds.length > 0) {
148
- await tx
163
+ const addressIds = (grouped.get("Address") ?? []).map((k) => k.addressId);
164
+ if (addressIds.length > 0) {
165
+ items.push(
166
+ mail
149
167
  .delete(addressTable)
150
- .where(inArray(addressTable.addressId, addressIds));
151
- }
168
+ .where(inArray(addressTable.addressId, addressIds)),
169
+ );
170
+ }
152
171
 
153
- const accountIds = (grouped.get("Account") ?? []).map((k) => k.accountId);
154
- if (accountIds.length > 0) {
155
- // Quarantine rows are deleted set-wise by account rather than from an
156
- // enumerated key, the way message subtrees are. They carry the user's
157
- // own folder names and parser output quoting the message, so account
158
- // deletion has to take them even though nothing enumerates them.
159
- await tx
172
+ const accountIds = (grouped.get("Account") ?? []).map((k) => k.accountId);
173
+ if (accountIds.length > 0) {
174
+ // Quarantine rows are deleted set-wise by account rather than from an
175
+ // enumerated key, the way message subtrees are. They carry the user's
176
+ // own folder names and parser output quoting the message, so account
177
+ // deletion has to take them even though nothing enumerates them.
178
+ items.push(
179
+ mail
160
180
  .delete(quarantineTable)
161
- .where(inArray(quarantineTable.accountId, accountIds));
162
- await tx
181
+ .where(inArray(quarantineTable.accountId, accountIds)),
182
+ );
183
+ items.push(
184
+ mail
185
+ .delete(syncWarningTable)
186
+ .where(inArray(syncWarningTable.accountId, accountIds)),
187
+ );
188
+ appItems.push(
189
+ app
163
190
  .delete(accountTable)
164
- .where(inArray(accountTable.accountId, accountIds));
165
- }
191
+ .where(inArray(accountTable.accountId, accountIds)),
192
+ );
193
+ }
166
194
 
167
- const placementMoveMessageIds = (
168
- grouped.get("MessagePlacementMove") ?? []
169
- ).map((k) => k.messageId);
170
- if (placementMoveMessageIds.length > 0) {
171
- await tx
195
+ const placementMoveMessageIds = (
196
+ grouped.get("MessagePlacementMove") ?? []
197
+ ).map((k) => k.messageId);
198
+ if (placementMoveMessageIds.length > 0) {
199
+ items.push(
200
+ mail
172
201
  .delete(messagePlacementMoveTable)
173
202
  .where(
174
203
  inArray(messagePlacementMoveTable.messageId, placementMoveMessageIds),
175
- );
176
- }
177
-
178
- // Composite key (messageId, flagName) — a message can carry an
179
- // independent pending read-state marker and pending star marker at once
180
- // (issue #1273), so a single-column inArray cannot target one row.
181
- const flagPushKeys = grouped.get("MessageFlagPush") ?? [];
182
- if (flagPushKeys.length > 0) {
183
- const conditions: SQL[] = flagPushKeys.map((k) =>
184
- and(
185
- eq(messageFlagPushTable.messageId, k.messageId),
186
- eq(messageFlagPushTable.flagName, k.flagName),
187
204
  ),
188
- ) as SQL[];
189
- await tx.delete(messageFlagPushTable).where(or(...conditions));
190
- }
191
- });
205
+ );
206
+ }
207
+
208
+ // Composite key (messageId, flagName) — a message can carry an
209
+ // independent pending read-state marker and pending star marker at once
210
+ // (issue #1273), so a single-column inArray cannot target one row.
211
+ const flagPushKeys = grouped.get("MessageFlagPush") ?? [];
212
+ if (flagPushKeys.length > 0) {
213
+ const conditions: SQL[] = flagPushKeys.map((k) =>
214
+ and(
215
+ eq(messageFlagPushTable.messageId, k.messageId),
216
+ eq(messageFlagPushTable.flagName, k.flagName),
217
+ ),
218
+ ) as SQL[];
219
+ items.push(mail.delete(messageFlagPushTable).where(or(...conditions)));
220
+ }
221
+ await runBatch(mail, items);
222
+ await runBatch(app, appItems);
223
+ await new SearchIndex(mail, search).sync(threadMessageIds);
192
224
 
193
225
  log.info({ entityCount: entities.length }, "Drizzle cascade delete complete");
194
226
  };
@@ -199,15 +231,13 @@ export type CascadeDeleter = (
199
231
  ) => Promise<void>;
200
232
 
201
233
  /**
202
- * Open the shared SQLite file through the same serialized connection every
203
- * writer uses and bind a cascade deleter to it. Async because opening the
204
- * SQLite handle loads the native binding lazily.
234
+ * Open the SQLite databases through the same connection settings every writer
235
+ * uses and bind a cascade deleter to them. Async because opening a SQLite handle
236
+ * loads the native binding lazily.
205
237
  */
206
238
  export const createSqliteCascadeDeleter = async (
207
- dbPath: string,
239
+ paths: SqliteDatabasePaths,
208
240
  ): Promise<CascadeDeleter> => {
209
- const { db } = await createSqliteDatabase(messageDataSchema, {
210
- filename: dbPath,
211
- });
212
- return (entities, log) => runDrizzleCascadeDelete(db, entities, log);
241
+ const databases = await openSqliteDatabases(paths);
242
+ return (entities, log) => runDrizzleCascadeDelete(databases, entities, log);
213
243
  };
@@ -34,22 +34,7 @@ describe("DrizzleEnvelopeRepository", () => {
34
34
  await stop();
35
35
  });
36
36
 
37
- describe("getMessageData — MessageData shape (8 members)", () => {
38
- test("returns all 8 collection members", async () => {
39
- const data = await repo.getMessageData(MESSAGE_ID);
40
- const keys = Object.keys(data).sort();
41
- assert.deepStrictEqual(keys, [
42
- "bodyPart",
43
- "bodyPartContent",
44
- "bodyPartParameter",
45
- "bodyPartStorage",
46
- "envelope",
47
- "envelopeAddress",
48
- "messageReference",
49
- "rawMessageStorage",
50
- ]);
51
- });
52
-
37
+ describe("getMessageData — MessageData shape", () => {
53
38
  test("envelope has 1 row with correct fields", async () => {
54
39
  const data = await repo.getMessageData(MESSAGE_ID);
55
40
  assert.equal(data.envelope.length, 1);
@@ -70,7 +55,6 @@ describe("DrizzleEnvelopeRepository", () => {
70
55
  assert.equal(data.bodyPartParameter.length, 0);
71
56
  assert.equal(data.rawMessageStorage.length, 0);
72
57
  assert.equal(data.bodyPartStorage.length, 0);
73
- assert.equal(data.bodyPartContent.length, 0);
74
58
  });
75
59
 
76
60
  test("throws NotFoundError for unknown messageId", async () => {
@@ -1,14 +1,15 @@
1
1
  import type {
2
- BodyPartContentUpsertInput,
3
2
  BodyPartItem,
4
3
  BodyPartUpsertInput,
5
4
  CreateEnvelopeInput,
6
5
  EnvelopeItem,
7
6
  IEnvelopeRepository,
8
7
  MessageData,
8
+ MessageReferenceInput,
9
9
  UpdateEnvelopeInput,
10
10
  } from "@remit/data-ports";
11
11
  import { eq, inArray } from "drizzle-orm";
12
+ import { runBatch } from "../batch.js";
12
13
  import type { Db } from "../db.js";
13
14
  import {
14
15
  CreateFailedConflictError,
@@ -16,25 +17,24 @@ import {
16
17
  NotFoundError,
17
18
  } from "../error.js";
18
19
  import {
19
- bodyPartContentId as deriveBodyPartContentId,
20
20
  bodyPartId as deriveBodyPartId,
21
21
  bodyPartParameterId as deriveBodyPartParameterId,
22
22
  envelopeId as deriveEnvelopeId,
23
+ deterministicBase36Id,
23
24
  } from "../id.js";
24
25
  import {
25
- bodyPartContentTable,
26
26
  bodyPartParameterTable,
27
27
  bodyPartStorageTable,
28
28
  bodyPartTable,
29
+ bodyStructureRefetchTable,
29
30
  envelopeAddressTable,
30
31
  envelopeTable,
31
32
  type MessageDataSchema,
32
33
  messageReferenceTable,
34
+ messageTable,
33
35
  rawMessageStorageTable,
34
36
  } from "../schema/message-data.js";
35
- import { runInTransaction } from "../tx.js";
36
37
  import {
37
- toBodyPartContentItem,
38
38
  toBodyPartItem,
39
39
  toBodyPartParameterItem,
40
40
  toBodyPartStorageItem,
@@ -66,7 +66,6 @@ export class DrizzleEnvelopeRepository implements IEnvelopeRepository {
66
66
  bodyPartParameters,
67
67
  rawMessageStorages,
68
68
  bodyPartStorages,
69
- bodyPartContents,
70
69
  ] = await Promise.all([
71
70
  this.db
72
71
  .select()
@@ -96,10 +95,6 @@ export class DrizzleEnvelopeRepository implements IEnvelopeRepository {
96
95
  .select()
97
96
  .from(bodyPartStorageTable)
98
97
  .where(eq(bodyPartStorageTable.messageId, messageId)),
99
- this.db
100
- .select()
101
- .from(bodyPartContentTable)
102
- .where(eq(bodyPartContentTable.messageId, messageId)),
103
98
  ]);
104
99
 
105
100
  if (envelopes.length === 0) {
@@ -114,7 +109,6 @@ export class DrizzleEnvelopeRepository implements IEnvelopeRepository {
114
109
  bodyPartParameter: bodyPartParameters.map(toBodyPartParameterItem),
115
110
  rawMessageStorage: rawMessageStorages.map(toRawMessageStorageItem),
116
111
  bodyPartStorage: bodyPartStorages.map(toBodyPartStorageItem),
117
- bodyPartContent: bodyPartContents.map(toBodyPartContentItem),
118
112
  };
119
113
  }
120
114
 
@@ -180,38 +174,68 @@ export class DrizzleEnvelopeRepository implements IEnvelopeRepository {
180
174
  (r) => r.bodyPartParameterId,
181
175
  );
182
176
 
183
- await runInTransaction(this.db, async (tx) => {
184
- await tx
185
- .insert(bodyPartTable)
186
- .values(dedupedBodyPartRows)
177
+ const insertParts = this.db
178
+ .insert(bodyPartTable)
179
+ .values(dedupedBodyPartRows)
180
+ .onConflictDoUpdate({
181
+ target: bodyPartTable.bodyPartId,
182
+ set: { updatedAt: now },
183
+ });
184
+ if (dedupedParameterRows.length === 0) {
185
+ await runBatch(this.db, [insertParts]);
186
+ return;
187
+ }
188
+ await runBatch(this.db, [
189
+ insertParts,
190
+ this.db
191
+ .insert(bodyPartParameterTable)
192
+ .values(dedupedParameterRows)
187
193
  .onConflictDoUpdate({
188
- target: bodyPartTable.bodyPartId,
189
- set: { updatedAt: now },
190
- });
194
+ target: bodyPartParameterTable.bodyPartParameterId,
195
+ set: {
196
+ parameterValue: bodyPartParameterTable.parameterValue,
197
+ updatedAt: now,
198
+ },
199
+ }),
200
+ ]);
201
+ }
191
202
 
192
- if (dedupedParameterRows.length > 0) {
193
- await tx
194
- .insert(bodyPartParameterTable)
195
- .values(dedupedParameterRows)
196
- .onConflictDoUpdate({
197
- target: bodyPartParameterTable.bodyPartParameterId,
198
- set: {
199
- parameterValue: bodyPartParameterTable.parameterValue,
200
- updatedAt: now,
201
- },
202
- });
203
- }
204
- });
203
+ async replaceMessageReferences(
204
+ messageId: string,
205
+ references: MessageReferenceInput[],
206
+ ): Promise<void> {
207
+ const now = Date.now();
208
+ const envelopeId = deriveEnvelopeId(messageId);
209
+ const clear = this.db
210
+ .delete(messageReferenceTable)
211
+ .where(eq(messageReferenceTable.messageId, messageId));
212
+ if (references.length === 0) {
213
+ await runBatch(this.db, [clear]);
214
+ return;
215
+ }
216
+ await runBatch(this.db, [
217
+ clear,
218
+ this.db.insert(messageReferenceTable).values(
219
+ references.map((reference) => ({
220
+ ...reference,
221
+ messageReferenceId: deterministicBase36Id(
222
+ `msgref:${messageId}:${reference.referenceType}:${reference.referenceOrder}`,
223
+ ),
224
+ messageId,
225
+ envelopeId,
226
+ createdAt: now,
227
+ updatedAt: now,
228
+ })),
229
+ ),
230
+ ]);
205
231
  }
206
232
 
207
233
  async deleteManyEnvelopes(envelopeIds: string[]): Promise<void> {
208
234
  if (envelopeIds.length === 0) return;
209
235
 
210
- await runInTransaction(this.db, async (tx) => {
211
- await tx
212
- .delete(envelopeTable)
213
- .where(inArray(envelopeTable.envelopeId, envelopeIds));
214
- });
236
+ await this.db
237
+ .delete(envelopeTable)
238
+ .where(inArray(envelopeTable.envelopeId, envelopeIds));
215
239
  }
216
240
 
217
241
  async createEnvelope(input: CreateEnvelopeInput): Promise<EnvelopeItem> {
@@ -301,37 +325,6 @@ export class DrizzleEnvelopeRepository implements IEnvelopeRepository {
301
325
  .where(eq(envelopeTable.envelopeId, envelopeId));
302
326
  }
303
327
 
304
- async upsertBodyPartContents(
305
- messageId: string,
306
- contents: BodyPartContentUpsertInput[],
307
- ): Promise<void> {
308
- if (contents.length === 0) return;
309
-
310
- const now = Date.now();
311
-
312
- const rows = contents.map((c) => ({
313
- bodyPartContentId: deriveBodyPartContentId(messageId, c.bodyPartId),
314
- messageId,
315
- bodyPartId: c.bodyPartId,
316
- content: c.content,
317
- contentLength: c.content.length,
318
- createdAt: now,
319
- updatedAt: now,
320
- }));
321
-
322
- await this.db
323
- .insert(bodyPartContentTable)
324
- .values(rows)
325
- .onConflictDoUpdate({
326
- target: bodyPartContentTable.bodyPartContentId,
327
- set: {
328
- content: bodyPartContentTable.content,
329
- contentLength: bodyPartContentTable.contentLength,
330
- updatedAt: now,
331
- },
332
- });
333
- }
334
-
335
328
  async listBodyParts(messageId: string): Promise<BodyPartItem[]> {
336
329
  const rows = await this.db
337
330
  .select()
@@ -339,4 +332,27 @@ export class DrizzleEnvelopeRepository implements IEnvelopeRepository {
339
332
  .where(eq(bodyPartTable.messageId, messageId));
340
333
  return rows.map(toBodyPartItem);
341
334
  }
335
+
336
+ async listQueuedStructureRefetches(
337
+ mailboxId: string,
338
+ limit: number,
339
+ ): Promise<Array<{ messageId: string; uid: number }>> {
340
+ return this.db
341
+ .select({ messageId: messageTable.messageId, uid: messageTable.uid })
342
+ .from(bodyStructureRefetchTable)
343
+ .innerJoin(
344
+ messageTable,
345
+ eq(messageTable.messageId, bodyStructureRefetchTable.messageId),
346
+ )
347
+ .where(eq(messageTable.mailboxId, mailboxId))
348
+ .orderBy(messageTable.uid)
349
+ .limit(limit);
350
+ }
351
+
352
+ async clearStructureRefetches(messageIds: string[]): Promise<void> {
353
+ if (messageIds.length === 0) return;
354
+ await this.db
355
+ .delete(bodyStructureRefetchTable)
356
+ .where(inArray(bodyStructureRefetchTable.messageId, messageIds));
357
+ }
342
358
  }