@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,136 +0,0 @@
1
- import assert from "node:assert";
2
- import { after, before, describe, test } from "node:test";
3
- import { FilterScope } from "@remit/domain-enums";
4
- import { NotFoundError } from "../error.js";
5
- import { createTestDb, randomId, type TestDb } from "../test-db.js";
6
- import { FilterRepo } from "./filter.js";
7
- import { FilterAnchorRepo } from "./filter-anchor.js";
8
- import { DrizzleFilterAnchorTransaction } from "./filter-anchor-transaction.js";
9
-
10
- describe("DrizzleFilterAnchorTransaction", () => {
11
- let db: TestDb;
12
- let close: () => Promise<void>;
13
- let transaction: DrizzleFilterAnchorTransaction;
14
- let filterRepo: FilterRepo;
15
- let filterAnchorRepo: FilterAnchorRepo;
16
-
17
- before(async () => {
18
- ({ db, close } = await createTestDb());
19
- transaction = new DrizzleFilterAnchorTransaction(db as never);
20
- filterRepo = new FilterRepo(db as never);
21
- filterAnchorRepo = new FilterAnchorRepo(db as never);
22
- });
23
-
24
- after(async () => {
25
- await close();
26
- });
27
-
28
- test("creates the Filter and its FilterAnchor together", async () => {
29
- const accountConfigId = randomId();
30
-
31
- const filter = await transaction.createWithAnchor(
32
- {
33
- accountConfigId,
34
- name: "Booking confirmations",
35
- scope: FilterScope.Standing,
36
- hasAnchor: true,
37
- },
38
- {
39
- accountConfigId,
40
- anchorMessageId: randomId(),
41
- anchorEmbedding: [0.1, 0.2, 0.3],
42
- anchorEmbeddingId: "amazon.titan-embed-text-v2:0@1024",
43
- anchorSourceText: "Your booking is confirmed",
44
- },
45
- );
46
-
47
- assert.equal(filter.hasAnchor, true);
48
- const anchor = await filterAnchorRepo.get(accountConfigId, filter.filterId);
49
- assert.ok(anchor, "the FilterAnchor row must exist");
50
- assert.equal(anchor?.anchorSourceText, "Your booking is confirmed");
51
- });
52
-
53
- test("creates a purely-literal Filter when anchor is null", async () => {
54
- const accountConfigId = randomId();
55
-
56
- const filter = await transaction.createWithAnchor(
57
- {
58
- accountConfigId,
59
- name: "From billing",
60
- scope: FilterScope.Standing,
61
- hasAnchor: false,
62
- },
63
- null,
64
- );
65
-
66
- assert.equal(filter.hasAnchor, false);
67
- const anchor = await filterAnchorRepo.get(accountConfigId, filter.filterId);
68
- assert.equal(anchor, null);
69
- });
70
-
71
- test("rolls back the Filter row when the FilterAnchor write fails (#351)", async () => {
72
- const accountConfigId = randomId();
73
-
74
- await assert.rejects(() =>
75
- transaction.createWithAnchor(
76
- {
77
- accountConfigId,
78
- name: "Broken anchor",
79
- scope: FilterScope.Standing,
80
- hasAnchor: true,
81
- },
82
- {
83
- accountConfigId,
84
- anchorMessageId: randomId(),
85
- anchorEmbedding: [0.1, 0.2, 0.3],
86
- anchorEmbeddingId: "amazon.titan-embed-text-v2:0@1024",
87
- // A NOT NULL column at the DB level — simulates a real write
88
- // failure on the second half of the pair (network blip,
89
- // transient error), the exact case #351 leaves broken today.
90
- anchorSourceText: null as unknown as string,
91
- },
92
- ),
93
- );
94
-
95
- const filters = await filterRepo.listByAccountConfig(accountConfigId);
96
- assert.equal(
97
- filters.length,
98
- 0,
99
- "the Filter row must not survive when its anchor write fails",
100
- );
101
- });
102
-
103
- test("the caller never sees a Filter row with hasAnchor: true and no anchor (#351)", async () => {
104
- const accountConfigId = randomId();
105
-
106
- await assert.rejects(() =>
107
- transaction.createWithAnchor(
108
- {
109
- accountConfigId,
110
- name: "Broken anchor 2",
111
- scope: FilterScope.Standing,
112
- hasAnchor: true,
113
- },
114
- {
115
- accountConfigId,
116
- anchorMessageId: randomId(),
117
- anchorEmbedding: [0.1, 0.2, 0.3],
118
- anchorEmbeddingId: "amazon.titan-embed-text-v2:0@1024",
119
- anchorSourceText: null as unknown as string,
120
- },
121
- ),
122
- );
123
-
124
- const filters = await filterRepo.listByAccountConfig(accountConfigId);
125
- const orphan = filters.find((f) => f.hasAnchor);
126
- assert.equal(
127
- orphan,
128
- undefined,
129
- "no Filter with hasAnchor: true may exist without a FilterAnchor row",
130
- );
131
- });
132
-
133
- test("get on a never-created filter still throws NotFoundError (sanity)", async () => {
134
- await assert.rejects(filterRepo.get(randomId(), randomId()), NotFoundError);
135
- });
136
- });
@@ -1,45 +0,0 @@
1
- import type {
2
- CreateFilterAnchorInput,
3
- CreateFilterInput,
4
- FilterItem,
5
- IFilterAnchorTransaction,
6
- } from "@remit/data-ports";
7
- import type { Db } from "../db.js";
8
- import { runInTransaction } from "../tx.js";
9
- import { FilterRepo } from "./filter.js";
10
- import { FilterAnchorRepo } from "./filter-anchor.js";
11
-
12
- type DB = Db<Record<string, unknown>>;
13
-
14
- /**
15
- * Runs the `Filter` create and its optional `FilterAnchor` write in one
16
- * transaction (#351): a failure on either side rolls back both, so a
17
- * `Filter` row can never persist with `hasAnchor: true` and no matching
18
- * `FilterAnchor` row, and a `FilterAnchor` write failure surfaces as a
19
- * failed create request rather than a silently-broken filter.
20
- */
21
- export class DrizzleFilterAnchorTransaction
22
- implements IFilterAnchorTransaction
23
- {
24
- constructor(private db: DB) {}
25
-
26
- createWithAnchor(
27
- filterInput: CreateFilterInput,
28
- anchorInput: Omit<CreateFilterAnchorInput, "filterId"> | null,
29
- ): Promise<FilterItem> {
30
- return runInTransaction(this.db, async (tx) => {
31
- const filterRepo = new FilterRepo(tx as never);
32
- const filter = await filterRepo.create(filterInput);
33
-
34
- if (anchorInput) {
35
- const filterAnchorRepo = new FilterAnchorRepo(tx as never);
36
- await filterAnchorRepo.put({
37
- ...anchorInput,
38
- filterId: filter.filterId,
39
- });
40
- }
41
-
42
- return filter;
43
- });
44
- }
45
- }
@@ -1,131 +0,0 @@
1
- import assert from "node:assert";
2
- import { after, before, describe, test } from "node:test";
3
- import { createTestDb, randomId, type TestDb } from "../test-db.js";
4
- import { FilterAnchorRepo } from "./filter-anchor.js";
5
-
6
- describe("FilterAnchorRepo", () => {
7
- let db: TestDb;
8
- let close: () => Promise<void>;
9
- let repo: FilterAnchorRepo;
10
-
11
- before(async () => {
12
- ({ db, close } = await createTestDb());
13
- repo = new FilterAnchorRepo(db as never);
14
- });
15
-
16
- after(async () => {
17
- await close();
18
- });
19
-
20
- test("put creates the row and round-trips the anchor vector", async () => {
21
- const accountConfigId = randomId();
22
- const filterId = randomId();
23
- const anchorMessageId = randomId();
24
- const anchorEmbedding = [0.1, -0.2, 0.3, 0];
25
-
26
- const anchor = await repo.put({
27
- accountConfigId,
28
- filterId,
29
- anchorEmbedding,
30
- anchorEmbeddingId: "amazon.titan-embed-text-v2:0@1024",
31
- anchorSourceText: "Your booking is confirmed for...",
32
- anchorMessageId,
33
- });
34
-
35
- assert.deepEqual(anchor.anchorEmbedding, anchorEmbedding);
36
- assert.equal(anchor.anchorMessageId, anchorMessageId);
37
- });
38
-
39
- test("get returns null when no anchor exists for the filter", async () => {
40
- const anchor = await repo.get(randomId(), randomId());
41
- assert.equal(anchor, null);
42
- });
43
-
44
- test("put upserts — a re-embed migration overwrites the existing row", async () => {
45
- const accountConfigId = randomId();
46
- const filterId = randomId();
47
- const anchorMessageId = randomId();
48
-
49
- await repo.put({
50
- accountConfigId,
51
- filterId,
52
- anchorEmbedding: [1, 2, 3],
53
- anchorEmbeddingId: "amazon.titan-embed-text-v2:0@1024",
54
- anchorSourceText: "original",
55
- anchorMessageId,
56
- });
57
-
58
- const migrated = await repo.put({
59
- accountConfigId,
60
- filterId,
61
- anchorEmbedding: [4, 5, 6],
62
- anchorEmbeddingId: "amazon.titan-embed-text-v3:0@1536",
63
- anchorSourceText: "original",
64
- anchorMessageId,
65
- });
66
-
67
- assert.deepEqual(migrated.anchorEmbedding, [4, 5, 6]);
68
- assert.equal(
69
- migrated.anchorEmbeddingId,
70
- "amazon.titan-embed-text-v3:0@1536",
71
- );
72
-
73
- const reread = await repo.get(accountConfigId, filterId);
74
- assert.deepEqual(reread?.anchorEmbedding, [4, 5, 6]);
75
- });
76
-
77
- test("listByAccountConfig returns every anchor for the account config", async () => {
78
- const accountConfigId = randomId();
79
- const filterIdA = randomId();
80
- const filterIdB = randomId();
81
- const otherConfigId = randomId();
82
- const otherFilterId = randomId();
83
-
84
- await repo.put({
85
- accountConfigId,
86
- filterId: filterIdA,
87
- anchorEmbedding: [1, 2, 3],
88
- anchorEmbeddingId: "amazon.titan-embed-text-v2:0@1024",
89
- anchorSourceText: "a",
90
- anchorMessageId: randomId(),
91
- });
92
- await repo.put({
93
- accountConfigId,
94
- filterId: filterIdB,
95
- anchorEmbedding: [4, 5, 6],
96
- anchorEmbeddingId: "amazon.titan-embed-text-v2:0@1024",
97
- anchorSourceText: "b",
98
- anchorMessageId: randomId(),
99
- });
100
- await repo.put({
101
- accountConfigId: otherConfigId,
102
- filterId: otherFilterId,
103
- anchorEmbedding: [7, 8, 9],
104
- anchorEmbeddingId: "amazon.titan-embed-text-v2:0@1024",
105
- anchorSourceText: "other",
106
- anchorMessageId: randomId(),
107
- });
108
-
109
- const anchors = await repo.listByAccountConfig(accountConfigId);
110
- const filterIds = anchors.map((a) => a.filterId).sort();
111
- assert.deepEqual(filterIds, [filterIdA, filterIdB].sort());
112
- });
113
-
114
- test("delete removes the row", async () => {
115
- const accountConfigId = randomId();
116
- const filterId = randomId();
117
-
118
- await repo.put({
119
- accountConfigId,
120
- filterId,
121
- anchorEmbedding: [1],
122
- anchorEmbeddingId: "amazon.titan-embed-text-v2:0@1024",
123
- anchorSourceText: "text",
124
- anchorMessageId: randomId(),
125
- });
126
-
127
- await repo.delete(accountConfigId, filterId);
128
-
129
- assert.equal(await repo.get(accountConfigId, filterId), null);
130
- });
131
- });
@@ -1,105 +0,0 @@
1
- import type {
2
- CreateFilterAnchorInput,
3
- FilterAnchorItem,
4
- IFilterAnchorRepository,
5
- } from "@remit/data-ports";
6
- import { and, eq } from "drizzle-orm";
7
- import type { Db } from "../db.js";
8
- import { filterAnchorTable } from "../schema.js";
9
-
10
- type DB = Db<Record<string, unknown>>;
11
-
12
- function rowToFilterAnchor(
13
- row: typeof filterAnchorTable.$inferSelect,
14
- ): FilterAnchorItem {
15
- return {
16
- accountConfigId: row.accountConfigId,
17
- filterId: row.filterId,
18
- anchorEmbedding: row.anchorEmbedding as number[],
19
- anchorEmbeddingId: row.anchorEmbeddingId,
20
- anchorSourceText: row.anchorSourceText,
21
- anchorMessageId: row.anchorMessageId,
22
- createdAt: row.createdAt,
23
- updatedAt: row.updatedAt,
24
- };
25
- }
26
-
27
- /**
28
- * Relational counterpart to `FilterAnchorService` (remit-electrodb-service). The
29
- * anchor vector is persisted inline on the row (RFC 034 Decision 2.1/2.3); the
30
- * vector store's per-message chunk vectors are read only by the search
31
- * service when it builds the anchor, never re-derived here at match time.
32
- */
33
- export class FilterAnchorRepo implements IFilterAnchorRepository {
34
- constructor(private db: DB) {}
35
-
36
- /**
37
- * Upsert — a filter's anchor is (re)computed once at save time (RFC 034
38
- * Decision 2.1) and again only by an explicit re-embed migration
39
- * (Decision 2.4).
40
- */
41
- async put(input: CreateFilterAnchorInput): Promise<FilterAnchorItem> {
42
- const now = Date.now();
43
- const [row] = await this.db
44
- .insert(filterAnchorTable)
45
- .values({
46
- accountConfigId: input.accountConfigId,
47
- filterId: input.filterId,
48
- anchorEmbedding: input.anchorEmbedding,
49
- anchorEmbeddingId: input.anchorEmbeddingId,
50
- anchorSourceText: input.anchorSourceText,
51
- anchorMessageId: input.anchorMessageId,
52
- createdAt: now,
53
- updatedAt: now,
54
- })
55
- .onConflictDoUpdate({
56
- target: [filterAnchorTable.accountConfigId, filterAnchorTable.filterId],
57
- set: {
58
- anchorEmbedding: input.anchorEmbedding,
59
- anchorEmbeddingId: input.anchorEmbeddingId,
60
- anchorSourceText: input.anchorSourceText,
61
- anchorMessageId: input.anchorMessageId,
62
- updatedAt: now,
63
- },
64
- })
65
- .returning();
66
- return rowToFilterAnchor(row);
67
- }
68
-
69
- async get(
70
- accountConfigId: string,
71
- filterId: string,
72
- ): Promise<FilterAnchorItem | null> {
73
- const [row] = await this.db
74
- .select()
75
- .from(filterAnchorTable)
76
- .where(
77
- and(
78
- eq(filterAnchorTable.accountConfigId, accountConfigId),
79
- eq(filterAnchorTable.filterId, filterId),
80
- ),
81
- );
82
- return row ? rowToFilterAnchor(row) : null;
83
- }
84
-
85
- async listByAccountConfig(
86
- accountConfigId: string,
87
- ): Promise<FilterAnchorItem[]> {
88
- const rows = await this.db
89
- .select()
90
- .from(filterAnchorTable)
91
- .where(eq(filterAnchorTable.accountConfigId, accountConfigId));
92
- return rows.map(rowToFilterAnchor);
93
- }
94
-
95
- async delete(accountConfigId: string, filterId: string): Promise<void> {
96
- await this.db
97
- .delete(filterAnchorTable)
98
- .where(
99
- and(
100
- eq(filterAnchorTable.accountConfigId, accountConfigId),
101
- eq(filterAnchorTable.filterId, filterId),
102
- ),
103
- );
104
- }
105
- }
@@ -1,27 +0,0 @@
1
- import assert from "node:assert/strict";
2
- import { readFileSync } from "node:fs";
3
- import { describe, it } from "node:test";
4
-
5
- /**
6
- * The path a stale appointment last pointed at is a display-only sibling row
7
- * (`FolderRoleAppointmentLabel`, #887). "Never read by resolution" is meant to
8
- * be structural rather than a habit: this repository composes one setting name
9
- * and has no way to reach the other. Read the source to prove it, because the
10
- * guarantee is about what the file cannot do, not about what any one call
11
- * returns.
12
- */
13
- describe("MailboxSpecialUseRepo and the appointment label row", () => {
14
- const source = readFileSync(
15
- new URL("./i4-mailbox-special-use.ts", import.meta.url),
16
- "utf8",
17
- );
18
-
19
- it("never names the label setting", () => {
20
- assert.equal(source.includes("FolderRoleAppointmentLabel"), false);
21
- });
22
-
23
- it("composes the appointment name in exactly one place", () => {
24
- const uses = source.split("composeFolderRoleAppointmentName(").length - 1;
25
- assert.equal(uses, 1);
26
- });
27
- });
@@ -1,190 +0,0 @@
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
- });