@remit/drizzle-service 0.0.88 → 0.0.90

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/drizzle-service",
3
- "version": "0.0.88",
3
+ "version": "0.0.90",
4
4
  "description": "Drizzle ORM service over SQLite",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -0,0 +1,66 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, test } from "node:test";
3
+ import Database from "better-sqlite3";
4
+ import { drizzle } from "drizzle-orm/better-sqlite3";
5
+ import { AccountRepo } from "./repos/i4-account.js";
6
+ import { accountTable } from "./schema.js";
7
+ import {
8
+ applyMigration,
9
+ migrationJournal,
10
+ } from "./test-shipped-sqlite-schema.js";
11
+
12
+ const MIGRATION = "0032_account_granted_scopes";
13
+
14
+ const atPredecessor = (): Database.Database => {
15
+ const sqlite = new Database(":memory:");
16
+ for (const entry of migrationJournal()) {
17
+ if (entry.tag === MIGRATION) return sqlite;
18
+ applyMigration(sqlite, entry.tag);
19
+ }
20
+ throw new Error(`${MIGRATION} is not in the journal`);
21
+ };
22
+
23
+ const migrateOnward = (sqlite: Database.Database): void => {
24
+ const tags = migrationJournal().map((entry) => entry.tag);
25
+ for (const tag of tags.slice(tags.indexOf(MIGRATION))) {
26
+ applyMigration(sqlite, tag);
27
+ }
28
+ };
29
+
30
+ const insertAccount = (
31
+ sqlite: Database.Database,
32
+ accountId: string,
33
+ authType: string,
34
+ ): void => {
35
+ sqlite
36
+ .prepare(
37
+ `INSERT INTO account (
38
+ account_id, account_config_id, username, email, auth_type,
39
+ imap_host, imap_port, imap_tls, imap_start_tls, smtp_port,
40
+ is_active, connection_state, created_at, updated_at
41
+ ) VALUES (?, 'config', 'person', 'person@example.com', ?,
42
+ 'outlook.office365.com', 993, 1, 0, 587, 1,
43
+ 'not_authenticated', 0, 0)`,
44
+ )
45
+ .run(accountId, authType);
46
+ };
47
+
48
+ describe("an account written before granted scopes were recorded", () => {
49
+ test("a Microsoft account holds the mail grant its consent always asked for", async () => {
50
+ const sqlite = atPredecessor();
51
+ insertAccount(sqlite, "microsoft", "oauthMicrosoft");
52
+ insertAccount(sqlite, "password", "password");
53
+
54
+ migrateOnward(sqlite);
55
+
56
+ const repo = new AccountRepo(
57
+ drizzle(sqlite, { schema: { accounts: accountTable } }) as never,
58
+ );
59
+ assert.deepEqual((await repo.get("microsoft")).grantedScopes, [
60
+ "https://outlook.office.com/IMAP.AccessAsUser.All",
61
+ "https://outlook.office.com/SMTP.Send",
62
+ ]);
63
+ assert.deepEqual((await repo.get("password")).grantedScopes, []);
64
+ sqlite.close();
65
+ });
66
+ });
@@ -21,6 +21,13 @@ const atPredecessor = (): Database.Database => {
21
21
  throw new Error(`${MIGRATION} is not in the journal`);
22
22
  };
23
23
 
24
+ const migrateOnward = (sqlite: Database.Database): void => {
25
+ const tags = migrationJournal().map((entry) => entry.tag);
26
+ for (const tag of tags.slice(tags.indexOf(MIGRATION))) {
27
+ applyMigration(sqlite, tag);
28
+ }
29
+ };
30
+
24
31
  describe("an account written before the service selection existed", () => {
25
32
  test("comes out of the migration syncing mail only", async () => {
26
33
  const sqlite = atPredecessor();
@@ -36,7 +43,7 @@ describe("an account written before the service selection existed", () => {
36
43
  )
37
44
  .run();
38
45
 
39
- applyMigration(sqlite, MIGRATION);
46
+ migrateOnward(sqlite);
40
47
 
41
48
  const repo = new AccountRepo(
42
49
  drizzle(sqlite, { schema: { accounts: accountTable } }) as never,
@@ -18,6 +18,7 @@ import {
18
18
 
19
19
  const DEDUPE = "0030_mailbox_path_dedupe";
20
20
  const UNIQUE = "0031_mailbox_path_unique";
21
+ const MAILBOX_COLUMNS_ADDED_SINCE = ["0033_mailbox_sync_failure_reason"];
21
22
  const ACCOUNT = "acct";
22
23
  const CONFIG = "cfg";
23
24
 
@@ -27,6 +28,8 @@ const atPredecessor = (): Handle => {
27
28
  const sqlite = new Database(":memory:");
28
29
  for (const entry of migrationJournal()) {
29
30
  if (entry.tag === DEDUPE) {
31
+ for (const tag of MAILBOX_COLUMNS_ADDED_SINCE)
32
+ applyMigration(sqlite, tag);
30
33
  return {
31
34
  sqlite,
32
35
  db: drizzle(sqlite) as unknown as Db<Record<string, unknown>>,
@@ -203,6 +203,7 @@ describe("addresses standing only on mail in Junk", () => {
203
203
  // `mailbox` is read through the repo, which selects every column the
204
204
  // entity declares — including the rename target added after this DDL.
205
205
  applyMigration(sqlite, "0028_mailbox_pending_path");
206
+ applyMigration(sqlite, "0033_mailbox_sync_failure_reason");
206
207
  sqlite.exec(
207
208
  readFileSync(
208
209
  new URL(
@@ -40,6 +40,7 @@ export function rowToAccount(
40
40
  email: row.email,
41
41
  authType: row.authType as AccountItem["authType"],
42
42
  syncedServices: row.syncedServices as AccountItem["syncedServices"],
43
+ grantedScopes: row.grantedScopes as AccountItem["grantedScopes"],
43
44
  passwordHash: row.passwordHash ?? undefined,
44
45
  oauthRefreshTokenHash: row.oauthRefreshTokenHash ?? undefined,
45
46
  oauthTokenUpdatedAt: row.oauthTokenUpdatedAt ?? undefined,
@@ -83,6 +84,7 @@ export class AccountRepo implements IAccountRepository {
83
84
  email: input.email,
84
85
  authType: input.authType ?? "password",
85
86
  syncedServices: input.syncedServices ?? [AccountService.Mail],
87
+ grantedScopes: input.grantedScopes ?? [],
86
88
  passwordHash: input.passwordHash,
87
89
  oauthRefreshTokenHash: input.oauthRefreshTokenHash,
88
90
  oauthTokenUpdatedAt: input.oauthTokenUpdatedAt,
@@ -148,6 +150,8 @@ export class AccountRepo implements IAccountRepository {
148
150
  if (input.authType !== undefined) updates.authType = input.authType;
149
151
  if (input.syncedServices !== undefined)
150
152
  updates.syncedServices = input.syncedServices;
153
+ if (input.grantedScopes !== undefined)
154
+ updates.grantedScopes = input.grantedScopes;
151
155
  if (input.passwordHash !== undefined)
152
156
  updates.passwordHash = input.passwordHash;
153
157
  if (input.oauthRefreshTokenHash !== undefined)
@@ -154,6 +154,37 @@ describe("MailboxRepo (sqlite)", () => {
154
154
  /UNIQUE constraint failed: mailbox\.account_id, mailbox\.full_path/,
155
155
  );
156
156
  });
157
+
158
+ test("syncFailureReason is written on failed and cleared by any other state", async () => {
159
+ const accountId = randomUUID();
160
+ const created = mailboxCreated(
161
+ await repo.create(makeMailboxInput(accountId, "Locked")),
162
+ );
163
+ assert.equal(created.syncFailureReason, "");
164
+
165
+ await repo.transition(accountId, created.mailboxId, {
166
+ from: [MailboxSyncStatus.synced],
167
+ to: MailboxSyncStatus.deleting,
168
+ set: { syncFailureReason: "ignored outside failed" },
169
+ });
170
+ assert.equal(
171
+ (await repo.get(accountId, created.mailboxId)).syncFailureReason,
172
+ "",
173
+ );
174
+
175
+ const failed = await repo.transition(accountId, created.mailboxId, {
176
+ from: [MailboxSyncStatus.deleting],
177
+ to: MailboxSyncStatus.failed,
178
+ set: { syncFailureReason: "Permission denied" },
179
+ });
180
+ assert.equal(failed?.syncFailureReason, "Permission denied");
181
+
182
+ const dismissed = await repo.transition(accountId, created.mailboxId, {
183
+ from: [MailboxSyncStatus.failed],
184
+ to: MailboxSyncStatus.synced,
185
+ });
186
+ assert.equal(dismissed?.syncFailureReason, "");
187
+ });
157
188
  });
158
189
 
159
190
  /**
@@ -179,6 +210,7 @@ describe("MailboxRepo (sqlite, shipped migrations)", () => {
179
210
  applyMigration(sqlite, "0027_mailbox_sync_status_total");
180
211
  applyMigration(sqlite, "0028_mailbox_pending_path");
181
212
  applyMigration(sqlite, "0031_mailbox_path_unique");
213
+ applyMigration(sqlite, "0033_mailbox_sync_failure_reason");
182
214
  const db = drizzle(sqlite, { schema: { mailbox: mailboxTable } });
183
215
  repo = new MailboxRepo(db as never);
184
216
  close = async () => {
@@ -254,6 +286,25 @@ describe("MailboxRepo (sqlite, shipped migrations)", () => {
254
286
  assert.equal(cleared?.pendingPath, undefined);
255
287
  });
256
288
 
289
+ test("syncFailureReason round-trips on the shipped shape", async () => {
290
+ const accountId = randomUUID();
291
+ const created = mailboxCreated(
292
+ await repo.create(makeMailboxInput(accountId, "Held")),
293
+ );
294
+ assert.equal(created.syncFailureReason, "");
295
+
296
+ await repo.transition(accountId, created.mailboxId, {
297
+ from: [MailboxSyncStatus.synced],
298
+ to: MailboxSyncStatus.deleting,
299
+ });
300
+ const failed = await repo.transition(accountId, created.mailboxId, {
301
+ from: [MailboxSyncStatus.deleting],
302
+ to: MailboxSyncStatus.failed,
303
+ set: { syncFailureReason: "Permission denied" },
304
+ });
305
+ assert.equal(failed?.syncFailureReason, "Permission denied");
306
+ });
307
+
257
308
  test("round-trips a cursor above 2^53 with its exact digits", async () => {
258
309
  const accountId = randomUUID();
259
310
  const modseq = "18446744073709551615";
@@ -92,6 +92,7 @@ const transitionSet = (
92
92
  ? { pendingPath: write.pendingPath }
93
93
  : {}
94
94
  : { pendingPath: null }),
95
+ syncFailureReason: to === "failed" ? (write?.syncFailureReason ?? "") : "",
95
96
  updatedAt: Date.now(),
96
97
  });
97
98
 
@@ -119,6 +120,7 @@ export function rowToMailbox(
119
120
  parentMailboxId: row.parentMailboxId,
120
121
  syncStatus: row.syncStatus as MailboxItem["syncStatus"],
121
122
  ...(row.pendingPath !== null ? { pendingPath: row.pendingPath } : {}),
123
+ syncFailureReason: row.syncFailureReason,
122
124
  cursorState: (row.cursorState as MailboxItem["cursorState"]) ?? undefined,
123
125
  specialUse: (row.specialUse as MailboxItem["specialUse"]) ?? undefined,
124
126
  createdAt: row.createdAt,