@remit/drizzle-service 0.0.75 → 0.0.77

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.75",
3
+ "version": "0.0.77",
4
4
  "description": "Drizzle ORM service over SQLite",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -9,6 +9,10 @@
9
9
  ".": {
10
10
  "types": "./src/index.ts",
11
11
  "default": "./src/index.ts"
12
+ },
13
+ "./test-sqlite": {
14
+ "types": "./src/test-sqlite.ts",
15
+ "default": "./src/test-sqlite.ts"
12
16
  }
13
17
  },
14
18
  "scripts": {
@@ -470,7 +470,7 @@ export const describeRepairContract = (
470
470
  "ahead:",
471
471
  "fan-out:",
472
472
  "orphans:",
473
- "not-yet-classified:",
473
+ "uncategorized:",
474
474
  "divergent per mailbox:",
475
475
  "category tally:",
476
476
  ]) {
@@ -53,10 +53,9 @@
53
53
  * `message.category` is still `uncategorized`, so the pending exclusion
54
54
  * above refuses the row outright.
55
55
  *
56
- * Only the fourth is interesting, and it is not impossible. It is unreachable
57
- * through `backfillClassification`, which returns early once the category is
58
- * decided. It is reachable through the primary path, which is re-enterable: the
59
- * skip guard is `if (message.bodyStorageKey && !force)` (`body-sync.ts`), and
56
+ * Only the fourth is interesting, and it is not impossible. It is reachable
57
+ * through the one path that classifies, which is re-enterable: the skip guard
58
+ * is `if (hasStoredBody(message.bodyStorageKey) && !force)` (`body-sync.ts`), and
60
59
  * `force` is a live event flag — the read-miss re-arm cue, resolved in the
61
60
  * imap-worker's `sync-message-body.ts`. A forced re-fetch re-runs
62
61
  * `classifyByHeaders` over the same bytes, so it normally rewrites the value it
@@ -351,7 +350,7 @@ export const formatCheckReport = (
351
350
  ` ahead: ${report.ahead} ${plural(report.ahead)} classified against a pending message — after #326 body-sync writes the row before the message, so this is a classification in flight. Not repaired: pushing it back to pending would undo a correct classification and serve Unclassified for mail that is already classified. Expected non-zero on a live instance mid-sync, zero on a quiescent one.`,
352
351
  `fan-out: ${report.fanOutMessages} messages holding ${report.fanOutRows} thread_message rows — the multi-row shape #326 hardens against. Expected zero: deriveMessageId and deriveThreadMessageId are both mailbox-independent, so a message in two mailboxes collapses to one row, and the reachable case is thread-root drift.`,
353
352
  `orphans: ${report.orphanRows} ${plural(report.orphanRows)} with no message row — not repaired, nothing to copy. Expected zero.`,
354
- `not-yet-classified: ${report.notYetClassified} ${plural(report.notYetClassified)} pending against a pending message — not classified yet. Not a defect, not touched by the repair, and unchanged by it. Expected non-zero on a live instance.`,
353
+ `uncategorized: ${report.notYetClassified} ${plural(report.notYetClassified)} uncategorized against an uncategorized message — the row agrees with its message, so there is nothing to repair and this figure is untouched by the repair. It is NOT the same as "not classified yet": it mixes mail the classifier has not reached with mail it reached and had nothing to say about. Only \`message.classification_state\` separates them (\`SELECT classification_state, count(*) FROM message WHERE category = 'uncategorized' GROUP BY 1\`), and a large \`NotExamined\` count is the cohort worth acting on. Expected non-zero on a live instance.`,
355
354
  `divergent per mailbox: ${
356
355
  report.divergentByMailbox.length === 0
357
356
  ? "none"
@@ -19,6 +19,17 @@ import { rowToMailbox } from "./i4-mailbox.js";
19
19
 
20
20
  type DB = Db<Record<string, unknown>>;
21
21
 
22
+ /**
23
+ * A stored failure is a sentence the account card shows, not a transcript.
24
+ * Long enough for what a mail server says when it refuses, short enough that a
25
+ * chatty one — or a stack-carrying `Error.message` from a sync — cannot fill the
26
+ * column. Clamped here so every writer is bounded, whatever it hands over.
27
+ */
28
+ const LAST_ERROR_MAX_LENGTH = 500;
29
+
30
+ const clampLastError = (value: string | undefined): string | undefined =>
31
+ value === undefined ? undefined : value.slice(0, LAST_ERROR_MAX_LENGTH);
32
+
22
33
  export function rowToAccount(
23
34
  row: typeof accountTable.$inferSelect,
24
35
  ): AccountItem {
@@ -88,7 +99,7 @@ export class AccountRepo implements IAccountRepository {
88
99
  connectionState: input.connectionState,
89
100
  lastConnectedAt: input.lastConnectedAt,
90
101
  lastSyncAt: input.lastSyncAt,
91
- lastError: input.lastError,
102
+ lastError: clampLastError(input.lastError),
92
103
  syncPhase: input.syncPhase,
93
104
  mailboxCountTotal: input.mailboxCountTotal,
94
105
  mailboxCountSynced: input.mailboxCountSynced,
@@ -159,7 +170,8 @@ export class AccountRepo implements IAccountRepository {
159
170
  if (input.lastConnectedAt !== undefined)
160
171
  updates.lastConnectedAt = input.lastConnectedAt;
161
172
  if (input.lastSyncAt !== undefined) updates.lastSyncAt = input.lastSyncAt;
162
- if (input.lastError !== undefined) updates.lastError = input.lastError;
173
+ if (input.lastError !== undefined)
174
+ updates.lastError = clampLastError(input.lastError);
163
175
  if (input.syncPhase !== undefined) updates.syncPhase = input.syncPhase;
164
176
  if (input.mailboxCountTotal !== undefined)
165
177
  updates.mailboxCountTotal = input.mailboxCountTotal;
@@ -59,6 +59,7 @@ function toMessageItem(row: typeof messageTable.$inferSelect): MessageItem {
59
59
  status: row.status,
60
60
  syncStatus: row.syncStatus,
61
61
  category: row.category,
62
+ classificationState: row.classificationState,
62
63
  authenticityVerdict: row.authenticityVerdict,
63
64
  hasListUnsubscribe: row.hasListUnsubscribe,
64
65
  movedByRemit: row.movedByRemit,
@@ -185,6 +186,8 @@ export class DrizzleMessageRepository implements IMessageRepository {
185
186
  status: input.status ?? ("active" as const),
186
187
  syncStatus: input.syncStatus ?? ("pending" as const),
187
188
  category: input.category ?? ("uncategorized" as const),
189
+ classificationState:
190
+ input.classificationState ?? ("NotExamined" as const),
188
191
  authenticityVerdict:
189
192
  input.authenticityVerdict ?? ("NotEvaluated" as const),
190
193
  hasListUnsubscribe: input.hasListUnsubscribe ?? false,
@@ -368,6 +371,9 @@ export class DrizzleMessageRepository implements IMessageRepository {
368
371
  ? { syncStatus: input.syncStatus }
369
372
  : {}),
370
373
  ...(input.category !== undefined ? { category: input.category } : {}),
374
+ ...(input.classificationState !== undefined
375
+ ? { classificationState: input.classificationState }
376
+ : {}),
371
377
  ...(input.authenticityVerdict !== undefined
372
378
  ? { authenticityVerdict: input.authenticityVerdict }
373
379
  : {}),
@@ -0,0 +1,39 @@
1
+ import Database from "better-sqlite3";
2
+ import { drizzle } from "drizzle-orm/better-sqlite3";
3
+ import type { Db } from "./db.js";
4
+ import {
5
+ applyMigration,
6
+ migrationJournal,
7
+ } from "./test-shipped-sqlite-schema.js";
8
+
9
+ /**
10
+ * The store a test runs against: the committed SQLite entity migrations, in the
11
+ * order the migrator runs them, over the same engine a self-host deployment
12
+ * boots. Applying the shipped DDL rather than pushing the drizzle table objects
13
+ * is the point — a test then fails when the two drift (reader#73).
14
+ */
15
+ export const applyShippedMigrations = (sqlite: Database.Database): void => {
16
+ for (const entry of [...migrationJournal()].sort(
17
+ (left, right) => left.idx - right.idx,
18
+ )) {
19
+ applyMigration(sqlite, entry.tag);
20
+ }
21
+ };
22
+
23
+ /**
24
+ * A migrated in-memory database and a drizzle handle over it, for a test that
25
+ * wants one repo and no file to clean up.
26
+ */
27
+ export const createShippedSqliteDb = (): {
28
+ db: Db<Record<string, unknown>>;
29
+ sqlite: Database.Database;
30
+ close: () => void;
31
+ } => {
32
+ const sqlite = new Database(":memory:");
33
+ applyShippedMigrations(sqlite);
34
+ return {
35
+ db: drizzle(sqlite) as unknown as Db<Record<string, unknown>>,
36
+ sqlite,
37
+ close: () => sqlite.close(),
38
+ };
39
+ };