@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,6 +1,7 @@
1
1
  import assert from "node:assert";
2
2
  import { after, before, describe, test } from "node:test";
3
3
  import {
4
+ FilterClauseField,
4
5
  FilterDisabledReason,
5
6
  FilterScope,
6
7
  FilterState,
@@ -9,6 +10,8 @@ import { NotFoundError } from "../error.js";
9
10
  import { createTestDb, randomId, type TestDb } from "../test-db.js";
10
11
  import { FilterRepo } from "./filter.js";
11
12
 
13
+ const CLAUSES = [{ field: FilterClauseField.Subject, value: "booking" }];
14
+
12
15
  describe("FilterRepo", () => {
13
16
  let db: TestDb;
14
17
  let close: () => Promise<void>;
@@ -29,12 +32,12 @@ describe("FilterRepo", () => {
29
32
  accountConfigId,
30
33
  name: "Booking confirmations",
31
34
  scope: FilterScope.Standing,
35
+ literalClauses: CLAUSES,
32
36
  });
33
37
 
34
38
  assert.equal(filter.state, FilterState.Active);
35
39
  assert.equal(filter.disabledReason, FilterDisabledReason.None);
36
- assert.equal(filter.hasAnchor, false);
37
- assert.equal(filter.literalClauses.length, 0);
40
+ assert.deepEqual(filter.literalClauses, CLAUSES);
38
41
  assert.equal(filter.actionLabelId, "None");
39
42
  assert.equal(filter.actionMailboxId, "None");
40
43
  assert.ok(
@@ -55,6 +58,7 @@ describe("FilterRepo", () => {
55
58
  accountConfigId,
56
59
  name: "Invoices",
57
60
  scope: FilterScope.Standing,
61
+ literalClauses: CLAUSES,
58
62
  state: FilterState.Disabled,
59
63
  disabledReason: FilterDisabledReason.AwaitingFolder,
60
64
  });
@@ -88,6 +92,7 @@ describe("FilterRepo", () => {
88
92
  accountConfigId,
89
93
  name: "Until my trip",
90
94
  scope: FilterScope.Temporary,
95
+ literalClauses: CLAUSES,
91
96
  expiresAt,
92
97
  ttl,
93
98
  });
@@ -124,6 +129,7 @@ describe("FilterRepo", () => {
124
129
  accountConfigId,
125
130
  name: "Newsletter admin",
126
131
  scope: FilterScope.Standing,
132
+ literalClauses: CLAUSES,
127
133
  });
128
134
 
129
135
  const renamed = await repo.update(accountConfigId, filter.filterId, {
@@ -149,6 +155,7 @@ describe("FilterRepo", () => {
149
155
  accountConfigId,
150
156
  name: "Move to archive",
151
157
  scope: FilterScope.Standing,
158
+ literalClauses: CLAUSES,
152
159
  });
153
160
 
154
161
  await new Promise((resolve) => setTimeout(resolve, 1100));
@@ -173,6 +180,7 @@ describe("FilterRepo", () => {
173
180
  accountConfigId,
174
181
  name: "Trip out-of-office",
175
182
  scope: FilterScope.Standing,
183
+ literalClauses: CLAUSES,
176
184
  });
177
185
 
178
186
  await new Promise((resolve) => setTimeout(resolve, 1100));
@@ -205,6 +213,7 @@ describe("FilterRepo", () => {
205
213
  accountConfigId,
206
214
  name: "Until my trip",
207
215
  scope: FilterScope.Temporary,
216
+ literalClauses: CLAUSES,
208
217
  expiresAt: initialExpiresAt,
209
218
  ttl: Math.floor(new Date(initialExpiresAt).getTime() / 1000),
210
219
  });
@@ -236,6 +245,7 @@ describe("FilterRepo", () => {
236
245
  accountConfigId,
237
246
  name: "Until my trip",
238
247
  scope: FilterScope.Temporary,
248
+ literalClauses: CLAUSES,
239
249
  expiresAt,
240
250
  ttl: Math.floor(new Date(expiresAt).getTime() / 1000),
241
251
  });
@@ -268,11 +278,13 @@ describe("FilterRepo", () => {
268
278
  accountConfigId,
269
279
  name: "Standing",
270
280
  scope: FilterScope.Standing,
281
+ literalClauses: CLAUSES,
271
282
  });
272
283
  await repo.create({
273
284
  accountConfigId: other,
274
285
  name: "Foreign",
275
286
  scope: FilterScope.Standing,
287
+ literalClauses: CLAUSES,
276
288
  });
277
289
 
278
290
  const all = await repo.listByAccountConfig(accountConfigId);
@@ -299,6 +311,7 @@ describe("FilterRepo", () => {
299
311
  accountConfigId,
300
312
  name: "Standing",
301
313
  scope: FilterScope.Standing,
314
+ literalClauses: CLAUSES,
302
315
  });
303
316
 
304
317
  const result = await repo.refreshExpiry(filter);
@@ -312,6 +325,7 @@ describe("FilterRepo", () => {
312
325
  accountConfigId,
313
326
  name: "Until tomorrow",
314
327
  scope: FilterScope.Temporary,
328
+ literalClauses: CLAUSES,
315
329
  expiresAt: future,
316
330
  ttl: Math.floor(new Date(future).getTime() / 1000),
317
331
  });
@@ -327,6 +341,7 @@ describe("FilterRepo", () => {
327
341
  accountConfigId,
328
342
  name: "Until yesterday, awaiting its folder",
329
343
  scope: FilterScope.Temporary,
344
+ literalClauses: CLAUSES,
330
345
  expiresAt: past,
331
346
  ttl: Math.floor(new Date(past).getTime() / 1000) + 172_800,
332
347
  state: FilterState.Disabled,
@@ -345,6 +360,7 @@ describe("FilterRepo", () => {
345
360
  accountConfigId,
346
361
  name: "Until yesterday",
347
362
  scope: FilterScope.Temporary,
363
+ literalClauses: CLAUSES,
348
364
  expiresAt: past,
349
365
  ttl: Math.floor(new Date(past).getTime() / 1000) + 172_800,
350
366
  });
@@ -368,6 +384,7 @@ describe("FilterRepo", () => {
368
384
  accountConfigId,
369
385
  name: "To delete",
370
386
  scope: FilterScope.Standing,
387
+ literalClauses: CLAUSES,
371
388
  });
372
389
 
373
390
  await repo.delete(accountConfigId, filter.filterId);
@@ -385,16 +402,19 @@ describe("FilterRepo", () => {
385
402
  accountConfigId,
386
403
  name: "One",
387
404
  scope: FilterScope.Standing,
405
+ literalClauses: CLAUSES,
388
406
  }),
389
407
  repo.create({
390
408
  accountConfigId,
391
409
  name: "Two",
392
410
  scope: FilterScope.Standing,
411
+ literalClauses: CLAUSES,
393
412
  }),
394
413
  repo.create({
395
414
  accountConfigId,
396
415
  name: "Three",
397
416
  scope: FilterScope.Standing,
417
+ literalClauses: CLAUSES,
398
418
  }),
399
419
  ]);
400
420
 
@@ -21,7 +21,6 @@ import { filterTable } from "../schema.js";
21
21
  type DB = Db<Record<string, unknown>>;
22
22
 
23
23
  const RULE_ASSERTION_FIELDS = [
24
- "hasAnchor",
25
24
  "matchOperator",
26
25
  "literalClauses",
27
26
  "actionLabelId",
@@ -37,7 +36,6 @@ const RULE_ASSERTION_FIELDS = [
37
36
  * narrower signal `selectMoveWinner` reads for exclusive-move precedence.
38
37
  */
39
38
  const ACTION_ASSERTION_FIELDS = [
40
- "hasAnchor",
41
39
  "matchOperator",
42
40
  "literalClauses",
43
41
  "actionLabelId",
@@ -81,7 +79,6 @@ function rowToFilter(row: typeof filterTable.$inferSelect): FilterItem {
81
79
  ttl: row.ttl ?? undefined,
82
80
  state: row.state,
83
81
  disabledReason: row.disabledReason,
84
- hasAnchor: row.hasAnchor,
85
82
  ruleChangedAt: row.ruleChangedAt,
86
83
  actionChangedAt: row.actionChangedAt,
87
84
  matchOperator: row.matchOperator,
@@ -115,11 +112,10 @@ export class FilterRepo implements IFilterRepository {
115
112
  ttl: input.ttl ?? null,
116
113
  state: input.state ?? FilterState.Active,
117
114
  disabledReason: input.disabledReason ?? FilterDisabledReason.None,
118
- hasAnchor: input.hasAnchor ?? false,
119
115
  ruleChangedAt: createdRuleChangedAt,
120
116
  actionChangedAt: createdRuleChangedAt,
121
117
  matchOperator: input.matchOperator ?? FilterMatchOperator.And,
122
- literalClauses: input.literalClauses ?? [],
118
+ literalClauses: input.literalClauses,
123
119
  actionLabelId: input.actionLabelId ?? "None",
124
120
  actionMailboxId: input.actionMailboxId ?? "None",
125
121
  createdAt: now,
@@ -12,7 +12,7 @@ describe("AccountConfigRepo", () => {
12
12
 
13
13
  before(async () => {
14
14
  ({ db, close } = await createTestDb());
15
- repo = new AccountConfigRepo(db as never);
15
+ repo = new AccountConfigRepo(db as never, db as never);
16
16
  });
17
17
 
18
18
  after(async () => {
@@ -58,7 +58,7 @@ describe("AccountConfigRepo", () => {
58
58
  const userId = randomId();
59
59
  const cfg = await repo.create({ userId });
60
60
 
61
- const accountRepo = new AccountRepo(db as never);
61
+ const accountRepo = new AccountRepo(db as never, db as never);
62
62
  const addrRepo = new AddressRepo(db as never);
63
63
 
64
64
  await accountRepo.create({
@@ -37,7 +37,10 @@ function rowToAccountConfig(
37
37
  }
38
38
 
39
39
  export class AccountConfigRepo implements IAccountConfigRepository {
40
- constructor(private db: DB) {}
40
+ constructor(
41
+ private db: DB,
42
+ private mail: DB,
43
+ ) {}
41
44
 
42
45
  async create(input: CreateAccountConfigInput): Promise<AccountConfigItem> {
43
46
  const now = Date.now();
@@ -179,7 +182,7 @@ export class AccountConfigRepo implements IAccountConfigRepository {
179
182
  .select()
180
183
  .from(accountTable)
181
184
  .where(eq(accountTable.accountConfigId, accountConfigId)),
182
- this.db
185
+ this.mail
183
186
  .select()
184
187
  .from(addressTable)
185
188
  .where(eq(addressTable.accountConfigId, accountConfigId)),
@@ -1,5 +1,6 @@
1
1
  import assert from "node:assert";
2
2
  import { after, before, describe, test } from "node:test";
3
+ import { SearchIndex } from "../search/thread-message-search.js";
3
4
  import { createTestDb, randomId, type TestDb } from "../test-db.js";
4
5
  import { AccountRepo } from "./i4-account.js";
5
6
  import { MailboxRepo } from "./i4-mailbox.js";
@@ -26,7 +27,7 @@ describe("AccountRepo", () => {
26
27
 
27
28
  before(async () => {
28
29
  ({ db, close } = await createTestDb());
29
- repo = new AccountRepo(db as never);
30
+ repo = new AccountRepo(db as never, db as never);
30
31
  });
31
32
 
32
33
  after(async () => {
@@ -160,7 +161,10 @@ describe("AccountRepo", () => {
160
161
  const accountConfigId = randomId();
161
162
  const account = await repo.create(makeAccountInput(accountConfigId));
162
163
 
163
- const mailboxRepo = new MailboxRepo(db as never);
164
+ const mailboxRepo = new MailboxRepo(
165
+ db as never,
166
+ new SearchIndex(db as never, db as never),
167
+ );
164
168
  mailboxCreated(
165
169
  await mailboxRepo.create({
166
170
  accountId: account.accountId,
@@ -4,6 +4,7 @@ import type {
4
4
  AccountSchedulerPage,
5
5
  CreateAccountInput,
6
6
  IAccountRepository,
7
+ RemovableAccountField,
7
8
  ResultList,
8
9
  UpdateAccountInput,
9
10
  } from "@remit/data-ports";
@@ -70,7 +71,10 @@ export function rowToAccount(
70
71
  }
71
72
 
72
73
  export class AccountRepo implements IAccountRepository {
73
- constructor(private db: DB) {}
74
+ constructor(
75
+ private db: DB,
76
+ private mail: DB,
77
+ ) {}
74
78
 
75
79
  async create(input: CreateAccountInput): Promise<AccountItem> {
76
80
  const now = Date.now();
@@ -107,6 +111,7 @@ export class AccountRepo implements IAccountRepository {
107
111
  syncPhase: input.syncPhase,
108
112
  mailboxCountTotal: input.mailboxCountTotal,
109
113
  mailboxCountSynced: input.mailboxCountSynced,
114
+ deletedAt: input.deletedAt,
110
115
  createdAt: now,
111
116
  updatedAt: now,
112
117
  })
@@ -138,63 +143,14 @@ export class AccountRepo implements IAccountRepository {
138
143
  async update(
139
144
  accountId: string,
140
145
  input: UpdateAccountInput,
141
- remove?: (keyof UpdateAccountInput)[],
146
+ remove?: RemovableAccountField[],
142
147
  ): Promise<AccountItem> {
143
- const now = Date.now();
144
148
  const updates: Partial<typeof accountTable.$inferInsert> = {
145
- updatedAt: now,
149
+ ...input,
150
+ lastError: clampLastError(input.lastError),
151
+ updatedAt: Date.now(),
146
152
  };
147
-
148
- if (input.username !== undefined) updates.username = input.username;
149
- if (input.email !== undefined) updates.email = input.email;
150
- if (input.authType !== undefined) updates.authType = input.authType;
151
- if (input.syncedServices !== undefined)
152
- updates.syncedServices = input.syncedServices;
153
- if (input.grantedScopes !== undefined)
154
- updates.grantedScopes = input.grantedScopes;
155
- if (input.passwordHash !== undefined)
156
- updates.passwordHash = input.passwordHash;
157
- if (input.oauthRefreshTokenHash !== undefined)
158
- updates.oauthRefreshTokenHash = input.oauthRefreshTokenHash;
159
- if (input.oauthTokenUpdatedAt !== undefined)
160
- updates.oauthTokenUpdatedAt = input.oauthTokenUpdatedAt;
161
- if (input.imapHost !== undefined) updates.imapHost = input.imapHost;
162
- if (input.imapPort !== undefined) updates.imapPort = input.imapPort;
163
- if (input.imapTls !== undefined) updates.imapTls = input.imapTls;
164
- if (input.imapStartTls !== undefined)
165
- updates.imapStartTls = input.imapStartTls;
166
- if (input.smtpEnabled !== undefined)
167
- updates.smtpEnabled = input.smtpEnabled;
168
- if (input.smtpHost !== undefined) updates.smtpHost = input.smtpHost;
169
- if (input.smtpPort !== undefined) updates.smtpPort = input.smtpPort;
170
- if (input.smtpTls !== undefined) updates.smtpTls = input.smtpTls;
171
- if (input.smtpStartTls !== undefined)
172
- updates.smtpStartTls = input.smtpStartTls;
173
- if (input.smtpUsername !== undefined)
174
- updates.smtpUsername = input.smtpUsername;
175
- if (input.isActive !== undefined) updates.isActive = input.isActive;
176
- if (input.connectionState !== undefined)
177
- updates.connectionState = input.connectionState;
178
- if (input.lastConnectedAt !== undefined)
179
- updates.lastConnectedAt = input.lastConnectedAt;
180
- if (input.lastSyncAt !== undefined) updates.lastSyncAt = input.lastSyncAt;
181
- if (input.lastError !== undefined)
182
- updates.lastError = clampLastError(input.lastError);
183
- if (input.syncPhase !== undefined) updates.syncPhase = input.syncPhase;
184
- if (input.mailboxCountTotal !== undefined)
185
- updates.mailboxCountTotal = input.mailboxCountTotal;
186
- if (input.mailboxCountSynced !== undefined)
187
- updates.mailboxCountSynced = input.mailboxCountSynced;
188
-
189
- // Handle field removal (set to null)
190
- if (remove) {
191
- for (const field of remove) {
192
- if (field === "lastError") updates.lastError = null;
193
- if (field === "lastConnectedAt") updates.lastConnectedAt = null;
194
- if (field === "lastSyncAt") updates.lastSyncAt = null;
195
- if (field === "syncPhase") updates.syncPhase = null;
196
- }
197
- }
153
+ for (const field of remove ?? []) updates[field] = null;
198
154
 
199
155
  const [row] = await this.db
200
156
  .update(accountTable)
@@ -312,7 +268,7 @@ export class AccountRepo implements IAccountRepository {
312
268
  .select()
313
269
  .from(accountTable)
314
270
  .where(eq(accountTable.accountId, accountId)),
315
- this.db
271
+ this.mail
316
272
  .select()
317
273
  .from(mailboxTable)
318
274
  .where(eq(mailboxTable.accountId, accountId)),
@@ -124,7 +124,7 @@ describe("reconciling one message's addresses at the moment it moves", () => {
124
124
  before(async () => {
125
125
  ({ db, sqlite, close } = await createTestDb());
126
126
  repo = new AddressRepo(db as never);
127
- specialUse = new MailboxSpecialUseRepo(db as never);
127
+ specialUse = new MailboxSpecialUseRepo(db as never, db as never);
128
128
  });
129
129
 
130
130
  /**
@@ -2,7 +2,6 @@ import assert from "node:assert/strict";
2
2
  import { after, before, describe, test } from "node:test";
3
3
  import type { AddressFlags } from "@remit/data-ports";
4
4
  import { createTestDb, randomId, type TestDb } from "../test-db.js";
5
- import { serializeSqliteWrites } from "../tx.js";
6
5
  import { AddressRepo } from "./i4-address.js";
7
6
 
8
7
  const FLAG_KEYS = [
@@ -25,7 +24,7 @@ describe("concurrent flag merges on one address", () => {
25
24
 
26
25
  before(async () => {
27
26
  ({ db, close } = await createTestDb());
28
- repo = new AddressRepo(serializeSqliteWrites(db) as never);
27
+ repo = new AddressRepo(db as never);
29
28
  });
30
29
 
31
30
  after(async () => {
@@ -35,7 +35,6 @@ import {
35
35
  } from "../repair/junk-only-address.js";
36
36
  import { addressTable } from "../schema/i4-address.js";
37
37
  import { envelopeAddressTable } from "../schema/message-data.js";
38
- import { runInTransaction } from "../tx.js";
39
38
  import {
40
39
  addressCorrespondence,
41
40
  addressListable,
@@ -53,29 +52,26 @@ type AddressUpdate = Partial<{
53
52
  | SQL;
54
53
  }>;
55
54
 
56
- const MERGE_FLAGS_ATTEMPTS = 8;
55
+ /** The stored flags as a JSON object: a row harvested before the column existed carries `''`. */
56
+ const storedFlags = sql<string>`coalesce(nullif(${addressTable.flags}, ''), '{}')`;
57
57
 
58
- /**
59
- * The stored flags exactly as they sit in the column, so a merge can guard its
60
- * write on the bytes it read rather than on a re-serialization of them.
61
- */
62
- const storedFlagsSql = sql<string>`cast(${addressTable.flags} as text)`;
58
+ const flagPath = (key: string): string => `$.${key}`;
63
59
 
64
- /** A row harvested before the column existed carries `''`, not `'{}'`. */
65
- const parseStoredFlags = (stored: string): AddressFlags =>
66
- stored === "" ? {} : (JSON.parse(stored) as AddressFlags);
60
+ const withFlag = (flags: SQL, key: string, value: unknown): SQL =>
61
+ sql`json_set(${flags}, ${flagPath(key)}, json(${JSON.stringify(value)}))`;
67
62
 
68
- type MergeAttempt =
69
- | { outcome: "merged"; address: AddressItem }
70
- | { outcome: "missing" }
71
- | { outcome: "contended" };
63
+ const withoutFlag = (flags: SQL, key: string): SQL =>
64
+ sql`json_remove(${flags}, ${flagPath(key)})`;
65
+
66
+ const flagRaised = (flags: SQL, key: string): SQL =>
67
+ sql`json_extract(${flags}, ${`$.${key}.value`}) = 1`;
72
68
 
73
69
  /**
74
70
  * `blocked` and `neverSpam` are two directly contradictory statements about
75
71
  * where a sender's mail belongs (issue #605), so a row asserting both is not a
76
72
  * tie to break — it is a row that should not exist. This is the only place that
77
73
  * sees both keys at once: `buildFlagsPatch` stays a pure per-key translation,
78
- * and the merge fold is the read-modify-write of the whole map.
74
+ * and the merge expression is the one write that sees the whole map.
79
75
  *
80
76
  * The flag the patch just raised wins, so the instruction the user gave last is
81
77
  * the one that stands. A patch raising both resolves to `blocked` — the same
@@ -89,20 +85,18 @@ type MergeAttempt =
89
85
  * healed the next time either key is written.
90
86
  */
91
87
  const dropContradictedPlacementFlag = (
92
- next: AddressFlags,
88
+ flags: SQL,
93
89
  patch: FlagsMergePatch,
94
- ): AddressFlags => {
95
- if (next.blocked?.value !== true || next.neverSpam?.value !== true)
96
- return next;
97
- if (patch.blocked?.value === true) {
98
- const { neverSpam: _neverSpam, ...rest } = next;
99
- return rest;
100
- }
101
- if (patch.neverSpam?.value === true) {
102
- const { blocked: _blocked, ...rest } = next;
103
- return rest;
104
- }
105
- return next;
90
+ ): SQL => {
91
+ const kept =
92
+ patch.blocked?.value === true
93
+ ? "blocked"
94
+ : patch.neverSpam?.value === true
95
+ ? "neverSpam"
96
+ : undefined;
97
+ if (kept === undefined) return flags;
98
+ const dropped = kept === "blocked" ? "neverSpam" : "blocked";
99
+ return sql`case when ${flagRaised(flags, kept)} and ${flagRaised(flags, dropped)} then ${withoutFlag(flags, dropped)} else ${flags} end`;
106
100
  };
107
101
 
108
102
  /**
@@ -463,54 +457,28 @@ export class AddressRepo implements IAddressRepository {
463
457
  }
464
458
 
465
459
  /**
466
- * Read the flags, fold `merge` over them, and write the result back only if
467
- * the column still holds the bytes the read returned. Read and write share
468
- * one serialized unit, so the only writer that can slip between them is the
469
- * junk-only reconcile, which runs its raw statement outside the write queue;
470
- * that write fails the guard and the merge folds again over the winner
471
- * instead of overwriting it. The unit never throws, so a reconcile statement
472
- * landing inside its savepoint is never rolled back with it.
460
+ * Fold a change into the stored flags in the one UPDATE that writes them:
461
+ * the change is a JSON expression over the column, so two concurrent merges
462
+ * each apply to what the other left and neither overwrites the other.
473
463
  */
474
464
  private async mergeStoredFlags(
475
465
  accountConfigId: string,
476
466
  addressId: string,
477
- merge: (current: AddressFlags) => AddressFlags,
467
+ flags: SQL,
478
468
  extra: AddressUpdate = {},
479
469
  ): Promise<AddressItem> {
480
- const key = and(
481
- eq(addressTable.accountConfigId, accountConfigId),
482
- eq(addressTable.addressId, addressId),
483
- );
484
- for (let attempt = 0; attempt < MERGE_FLAGS_ATTEMPTS; attempt++) {
485
- const result = await runInTransaction(
486
- this.db,
487
- async (tx): Promise<MergeAttempt> => {
488
- const [current] = await tx
489
- .select({ stored: storedFlagsSql })
490
- .from(addressTable)
491
- .where(key);
492
- if (!current) return { outcome: "missing" };
493
- const [row] = await tx
494
- .update(addressTable)
495
- .set({
496
- ...extra,
497
- flags: merge(parseStoredFlags(current.stored)) as never,
498
- updatedAt: Date.now(),
499
- })
500
- .where(and(key, eq(storedFlagsSql, current.stored)))
501
- .returning();
502
- return row
503
- ? { outcome: "merged", address: rowToAddress(row) }
504
- : { outcome: "contended" };
505
- },
506
- );
507
- if (result.outcome === "merged") return result.address;
508
- if (result.outcome === "missing")
509
- throw new NotFoundError(`Address not found: ${addressId}`);
510
- }
511
- throw new Error(
512
- `Address flags stayed contended after ${MERGE_FLAGS_ATTEMPTS} attempts: ${addressId}`,
513
- );
470
+ const [row] = await this.db
471
+ .update(addressTable)
472
+ .set({ ...extra, flags: flags as never, updatedAt: Date.now() })
473
+ .where(
474
+ and(
475
+ eq(addressTable.accountConfigId, accountConfigId),
476
+ eq(addressTable.addressId, addressId),
477
+ ),
478
+ )
479
+ .returning();
480
+ if (!row) throw new NotFoundError(`Address not found: ${addressId}`);
481
+ return rowToAddress(row);
514
482
  }
515
483
 
516
484
  async mergeFlags(
@@ -518,21 +486,16 @@ export class AddressRepo implements IAddressRepository {
518
486
  addressId: string,
519
487
  patch: FlagsMergePatch,
520
488
  ): Promise<AddressItem> {
521
- return this.mergeStoredFlags(accountConfigId, addressId, (current) => {
522
- const next: AddressFlags = { ...current };
523
- for (const [key, value] of Object.entries(patch) as [
524
- keyof AddressFlags,
525
- AddressFlags[keyof AddressFlags] | null | undefined,
526
- ][]) {
527
- if (value === undefined) continue;
528
- if (value === null) {
529
- delete next[key];
530
- continue;
531
- }
532
- (next[key] as AddressFlags[keyof AddressFlags]) = value;
533
- }
534
- return dropContradictedPlacementFlag(next, patch);
535
- });
489
+ const merged = Object.entries(patch).reduce<SQL>((flags, [key, value]) => {
490
+ if (value === undefined) return flags;
491
+ if (value === null) return withoutFlag(flags, key);
492
+ return withFlag(flags, key, value);
493
+ }, storedFlags);
494
+ return this.mergeStoredFlags(
495
+ accountConfigId,
496
+ addressId,
497
+ dropContradictedPlacementFlag(merged, patch),
498
+ );
536
499
  }
537
500
 
538
501
  async promoteWellknownByUser(
@@ -540,10 +503,15 @@ export class AddressRepo implements IAddressRepository {
540
503
  addressId: string,
541
504
  now: number,
542
505
  ): Promise<AddressItem> {
543
- return this.mergeStoredFlags(accountConfigId, addressId, (current) => ({
544
- ...current,
545
- wellknown: { value: true, setAt: now, setBy: "user-junk-rescue" },
546
- }));
506
+ return this.mergeStoredFlags(
507
+ accountConfigId,
508
+ addressId,
509
+ withFlag(storedFlags, "wellknown", {
510
+ value: true,
511
+ setAt: now,
512
+ setBy: "user-junk-rescue",
513
+ }),
514
+ );
547
515
  }
548
516
 
549
517
  async demoteSenderTrust(
@@ -554,7 +522,7 @@ export class AddressRepo implements IAddressRepository {
554
522
  return this.mergeStoredFlags(
555
523
  accountConfigId,
556
524
  addressId,
557
- ({ wellknown: _w, vip: _v, ...rest }) => rest,
525
+ withoutFlag(withoutFlag(storedFlags, "wellknown"), "vip"),
558
526
  { inboundCount: 0, replyCount: 0 },
559
527
  );
560
528
  }
@@ -12,6 +12,7 @@ import {
12
12
  mailboxSpecialUseTable,
13
13
  mailboxTable,
14
14
  } from "../schema.js";
15
+ import { SearchIndex } from "../search/thread-message-search.js";
15
16
  import { createSqliteTestDb } from "../test-db-sqlite.js";
16
17
  import { AccountSettingRepo } from "./i4-account-setting.js";
17
18
  import { MailboxRepo } from "./i4-mailbox.js";
@@ -56,8 +57,11 @@ describe("MailboxSpecialUseRepo role lookups (sqlite)", () => {
56
57
  });
57
58
  close = testDb.close;
58
59
  db = testDb.db;
59
- repo = new MailboxSpecialUseRepo(testDb.db as never);
60
- mailboxes = new MailboxRepo(testDb.db as never);
60
+ repo = new MailboxSpecialUseRepo(testDb.db as never, testDb.db as never);
61
+ mailboxes = new MailboxRepo(
62
+ testDb.db as never,
63
+ new SearchIndex(testDb.db as never, testDb.db as never),
64
+ );
61
65
  accountSettings = new AccountSettingRepo(testDb.db as never);
62
66
  });
63
67
 
@@ -90,8 +90,11 @@ function rowToSpecialUse(
90
90
  export class MailboxSpecialUseRepo implements IMailboxSpecialUseRepository {
91
91
  private readonly accountSetting: AccountSettingRepo;
92
92
 
93
- constructor(private db: DB) {
94
- this.accountSetting = new AccountSettingRepo(db);
93
+ constructor(
94
+ private app: DB,
95
+ private db: DB,
96
+ ) {
97
+ this.accountSetting = new AccountSettingRepo(app);
95
98
  }
96
99
 
97
100
  async create(
@@ -199,7 +202,7 @@ export class MailboxSpecialUseRepo implements IMailboxSpecialUseRepository {
199
202
  async resolveJunkRolesForConfig(
200
203
  accountConfigId: string,
201
204
  ): Promise<JunkRoleMailboxes> {
202
- const accounts = await this.db
205
+ const accounts = await this.app
203
206
  .select({ accountId: accountTable.accountId })
204
207
  .from(accountTable)
205
208
  .where(eq(accountTable.accountConfigId, accountConfigId));
@@ -213,7 +216,7 @@ export class MailboxSpecialUseRepo implements IMailboxSpecialUseRepository {
213
216
  * run one pass over the address table instead of one per config.
214
217
  */
215
218
  async resolveJunkRolesForInstance(): Promise<JunkRoleMailboxes> {
216
- const accounts = await this.db
219
+ const accounts = await this.app
217
220
  .select({ accountId: accountTable.accountId })
218
221
  .from(accountTable);
219
222
  return this.resolveJunkRoles(accounts.map((row) => row.accountId));
@@ -260,7 +263,7 @@ export class MailboxSpecialUseRepo implements IMailboxSpecialUseRepository {
260
263
  accountIds: readonly string[],
261
264
  roles: readonly CanonicalMailboxRoleValue[],
262
265
  ): Promise<Map<string, string>> {
263
- const accounts = await this.db
266
+ const accounts = await this.app
264
267
  .select({
265
268
  accountId: accountTable.accountId,
266
269
  accountConfigId: accountTable.accountConfigId,
@@ -363,7 +366,7 @@ export class MailboxSpecialUseRepo implements IMailboxSpecialUseRepository {
363
366
  accountId: string,
364
367
  role: CanonicalMailboxRoleValue,
365
368
  ): Promise<string | undefined> {
366
- const [account] = await this.db
369
+ const [account] = await this.app
367
370
  .select({ accountConfigId: accountTable.accountConfigId })
368
371
  .from(accountTable)
369
372
  .where(eq(accountTable.accountId, accountId));