@remit/drizzle-service 0.0.92 → 0.0.94

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.92",
3
+ "version": "0.0.94",
4
4
  "description": "Drizzle ORM service over SQLite",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -4,6 +4,7 @@ import * as entities from "@remit/drizzle-sqlite-schema";
4
4
  import Database from "better-sqlite3";
5
5
  import { asc, eq } from "drizzle-orm";
6
6
  import { drizzle } from "drizzle-orm/better-sqlite3";
7
+ import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
7
8
  import type { Db } from "./db.js";
8
9
  import {
9
10
  PARKED_MAILBOX_ID,
@@ -22,6 +23,18 @@ const MAILBOX_COLUMNS_ADDED_SINCE = ["0033_mailbox_sync_failure_reason"];
22
23
  const ACCOUNT = "acct";
23
24
  const CONFIG = "cfg";
24
25
 
26
+ const filterAtDedupe = sqliteTable("filter", {
27
+ filterId: text("filter_id").primaryKey(),
28
+ accountConfigId: text("account_config_id").notNull(),
29
+ name: text("name").notNull(),
30
+ scope: text("scope").notNull(),
31
+ ruleChangedAt: integer("rule_changed_at").notNull(),
32
+ actionChangedAt: integer("action_changed_at").notNull(),
33
+ actionMailboxId: text("action_mailbox_id").notNull(),
34
+ createdAt: integer("created_at").notNull(),
35
+ updatedAt: integer("updated_at").notNull(),
36
+ });
37
+
25
38
  type Handle = { sqlite: Database.Database; db: Db<Record<string, unknown>> };
26
39
 
27
40
  const atPredecessor = (): Handle => {
@@ -205,7 +218,7 @@ describe("(account_id, full_path) becomes unique", () => {
205
218
  .update(entities.mailboxes)
206
219
  .set({ parentMailboxId: "older-empty" })
207
220
  .where(eq(entities.mailboxes.mailboxId, "child"));
208
- await handle.db.insert(entities.filters).values({
221
+ await handle.db.insert(filterAtDedupe).values({
209
222
  filterId: "f1",
210
223
  accountConfigId: CONFIG,
211
224
  name: "Alerts",
@@ -257,7 +270,7 @@ describe("(account_id, full_path) becomes unique", () => {
257
270
  .from(entities.mailboxes)
258
271
  .where(eq(entities.mailboxes.mailboxId, "child"));
259
272
  assert.equal(child?.parent, "with-mail");
260
- const [filter] = await handle.db.select().from(entities.filters);
273
+ const [filter] = await handle.db.select().from(filterAtDedupe);
261
274
  assert.equal(filter?.actionMailboxId, "with-mail");
262
275
  const [job] = await handle.db.select().from(entities.organizeJobRequests);
263
276
  assert.equal(job?.actionMailboxId, "with-mail");
@@ -37,6 +37,7 @@ function rowToCalendarSuggestion(
37
37
  zoneCertainty: row.zoneCertainty,
38
38
  icalData: row.icalData,
39
39
  acceptedCalendarObjectId: row.acceptedCalendarObjectId,
40
+ supersededByMessageId: row.supersededByMessageId,
40
41
  createdAt: row.createdAt,
41
42
  updatedAt: row.updatedAt,
42
43
  };
@@ -61,6 +62,7 @@ export class CalendarSuggestionRepo implements ICalendarSuggestionRepository {
61
62
  suggestionId,
62
63
  state: CalendarSuggestionState.Pending,
63
64
  acceptedCalendarObjectId: "",
65
+ supersededByMessageId: "",
64
66
  createdAt: now,
65
67
  updatedAt: now,
66
68
  })
@@ -200,12 +202,14 @@ export class CalendarSuggestionRepo implements ICalendarSuggestionRepository {
200
202
  async supersedeIfPending(
201
203
  accountConfigId: string,
202
204
  suggestionId: string,
205
+ supersededByMessageId: string,
203
206
  ): Promise<CalendarSuggestionItem | null> {
204
207
  const [row] = await this.db
205
208
  .update(calendarSuggestionTable)
206
209
  .set({
207
210
  state: CalendarSuggestionState.Superseded,
208
211
  acceptedCalendarObjectId: "",
212
+ supersededByMessageId,
209
213
  updatedAt: Date.now(),
210
214
  })
211
215
  .where(
@@ -220,4 +224,23 @@ export class CalendarSuggestionRepo implements ICalendarSuggestionRepository {
220
224
  .returning();
221
225
  return row ? rowToCalendarSuggestion(row) : null;
222
226
  }
227
+
228
+ async repointSuperseded(
229
+ accountConfigId: string,
230
+ suggestionId: string,
231
+ supersededByMessageId: string,
232
+ ): Promise<CalendarSuggestionItem | null> {
233
+ const [row] = await this.db
234
+ .update(calendarSuggestionTable)
235
+ .set({ supersededByMessageId, updatedAt: Date.now() })
236
+ .where(
237
+ and(
238
+ eq(calendarSuggestionTable.accountConfigId, accountConfigId),
239
+ eq(calendarSuggestionTable.suggestionId, suggestionId),
240
+ eq(calendarSuggestionTable.state, CalendarSuggestionState.Superseded),
241
+ ),
242
+ )
243
+ .returning();
244
+ return row ? rowToCalendarSuggestion(row) : null;
245
+ }
223
246
  }
@@ -1,6 +1,10 @@
1
1
  import assert from "node:assert";
2
2
  import { after, before, describe, test } from "node:test";
3
- import { FilterScope, FilterState } from "@remit/domain-enums";
3
+ import {
4
+ FilterDisabledReason,
5
+ FilterScope,
6
+ FilterState,
7
+ } from "@remit/domain-enums";
4
8
  import { NotFoundError } from "../error.js";
5
9
  import { createTestDb, randomId, type TestDb } from "../test-db.js";
6
10
  import { FilterRepo } from "./filter.js";
@@ -28,6 +32,7 @@ describe("FilterRepo", () => {
28
32
  });
29
33
 
30
34
  assert.equal(filter.state, FilterState.Active);
35
+ assert.equal(filter.disabledReason, FilterDisabledReason.None);
31
36
  assert.equal(filter.hasAnchor, false);
32
37
  assert.equal(filter.literalClauses.length, 0);
33
38
  assert.equal(filter.actionLabelId, "None");
@@ -44,6 +49,36 @@ describe("FilterRepo", () => {
44
49
  assert.equal(filter.ttl, undefined);
45
50
  });
46
51
 
52
+ test("a disabled filter keeps its reason and drops out of the Active listing (#1103)", async () => {
53
+ const accountConfigId = randomId();
54
+ const filter = await repo.create({
55
+ accountConfigId,
56
+ name: "Invoices",
57
+ scope: FilterScope.Standing,
58
+ state: FilterState.Disabled,
59
+ disabledReason: FilterDisabledReason.AwaitingFolder,
60
+ });
61
+
62
+ assert.equal(filter.state, FilterState.Disabled);
63
+ assert.equal(filter.disabledReason, FilterDisabledReason.AwaitingFolder);
64
+ assert.deepEqual(
65
+ await repo.listByAccountAndState(accountConfigId, FilterState.Active),
66
+ [],
67
+ );
68
+
69
+ const enabled = await repo.update(accountConfigId, filter.filterId, {
70
+ state: FilterState.Active,
71
+ disabledReason: FilterDisabledReason.None,
72
+ });
73
+ assert.equal(enabled.disabledReason, FilterDisabledReason.None);
74
+ assert.deepEqual(
75
+ (
76
+ await repo.listByAccountAndState(accountConfigId, FilterState.Active)
77
+ ).map((item) => item.filterId),
78
+ [filter.filterId],
79
+ );
80
+ });
81
+
47
82
  test("a Temporary filter round-trips expiresAt and ttl", async () => {
48
83
  const accountConfigId = randomId();
49
84
  const expiresAt = "2026-08-01T00:00:00+02:00";
@@ -285,6 +320,24 @@ describe("FilterRepo", () => {
285
320
  assert.equal(result.state, FilterState.Active);
286
321
  });
287
322
 
323
+ test("refreshExpiry leaves a disabled filter and its reason alone (#1103)", async () => {
324
+ const accountConfigId = randomId();
325
+ const past = new Date(Date.now() - 86_400_000).toISOString();
326
+ const filter = await repo.create({
327
+ accountConfigId,
328
+ name: "Until yesterday, awaiting its folder",
329
+ scope: FilterScope.Temporary,
330
+ expiresAt: past,
331
+ ttl: Math.floor(new Date(past).getTime() / 1000) + 172_800,
332
+ state: FilterState.Disabled,
333
+ disabledReason: FilterDisabledReason.AwaitingFolder,
334
+ });
335
+
336
+ const result = await repo.refreshExpiry(filter);
337
+ assert.equal(result.state, FilterState.Disabled);
338
+ assert.equal(result.disabledReason, FilterDisabledReason.AwaitingFolder);
339
+ });
340
+
288
341
  test("refreshExpiry lazily patches a past-expiresAt Temporary filter to Expired", async () => {
289
342
  const accountConfigId = randomId();
290
343
  const past = new Date(Date.now() - 86_400_000).toISOString();
@@ -6,6 +6,7 @@ import type {
6
6
  UpdateFilterInput,
7
7
  } from "@remit/data-ports";
8
8
  import {
9
+ FilterDisabledReason,
9
10
  FilterMatchOperator,
10
11
  FilterScope,
11
12
  FilterState,
@@ -79,6 +80,7 @@ function rowToFilter(row: typeof filterTable.$inferSelect): FilterItem {
79
80
  expiresAt: row.expiresAt ?? undefined,
80
81
  ttl: row.ttl ?? undefined,
81
82
  state: row.state,
83
+ disabledReason: row.disabledReason,
82
84
  hasAnchor: row.hasAnchor,
83
85
  ruleChangedAt: row.ruleChangedAt,
84
86
  actionChangedAt: row.actionChangedAt,
@@ -112,6 +114,7 @@ export class FilterRepo implements IFilterRepository {
112
114
  expiresAt: input.expiresAt ?? null,
113
115
  ttl: input.ttl ?? null,
114
116
  state: input.state ?? FilterState.Active,
117
+ disabledReason: input.disabledReason ?? FilterDisabledReason.None,
115
118
  hasAnchor: input.hasAnchor ?? false,
116
119
  ruleChangedAt: createdRuleChangedAt,
117
120
  actionChangedAt: createdRuleChangedAt,
@@ -290,8 +293,8 @@ export class FilterRepo implements IFilterRepository {
290
293
  * Patches a Temporary filter's `state` to `Expired` when read past its
291
294
  * `expiresAt` (RFC 034 Decision 1.2). `expiresAt`/`now` are compared
292
295
  * directly — `state` is only ever a lazily-refreshed cache of that
293
- * comparison. A no-op for a Standing filter (no `expiresAt`) or one already
294
- * Expired.
296
+ * comparison. A no-op for a Standing filter (no `expiresAt`) or one that is not
297
+ * Active: a Disabled filter keeps the reason it was turned off for.
295
298
  *
296
299
  * There is no TTL reaper here, so an Expired Temporary row is never deleted by
297
300
  * a background sweep the way DynamoDB reaps it via the `ttl` attribute. That
@@ -301,12 +304,16 @@ export class FilterRepo implements IFilterRepository {
301
304
  */
302
305
  async refreshExpiry(item: FilterItem): Promise<FilterItem> {
303
306
  if (item.scope !== "Temporary" || !item.expiresAt) return item;
304
- if (item.state === FilterState.Expired) return item;
307
+ if (item.state !== FilterState.Active) return item;
305
308
  if (new Date(item.expiresAt).getTime() > Date.now()) return item;
306
309
 
307
310
  const [row] = await this.db
308
311
  .update(filterTable)
309
- .set({ state: FilterState.Expired, updatedAt: Date.now() })
312
+ .set({
313
+ state: FilterState.Expired,
314
+ disabledReason: FilterDisabledReason.None,
315
+ updatedAt: Date.now(),
316
+ })
310
317
  .where(
311
318
  and(
312
319
  eq(filterTable.accountConfigId, item.accountConfigId),
@@ -1,8 +1,10 @@
1
- import type {
2
- ConfigImportItem,
3
- CreateConfigImportInput,
4
- IConfigImportRepository,
5
- UpdateConfigImportInput,
1
+ import {
2
+ type ConfigImportItem,
3
+ type ConfigImportUnresolvedRefItem,
4
+ type CreateConfigImportInput,
5
+ FILTER_NO_ACTION,
6
+ type IConfigImportRepository,
7
+ type UpdateConfigImportInput,
6
8
  } from "@remit/data-ports";
7
9
  import { desc, eq } from "drizzle-orm";
8
10
  import type { Db } from "../db.js";
@@ -12,6 +14,30 @@ import { configImportTable } from "../schema/i4-config-import.js";
12
14
 
13
15
  type DB = Db<Record<string, unknown>>;
14
16
 
17
+ type StoredRef = Omit<ConfigImportUnresolvedRefItem, "mailboxId"> & {
18
+ mailboxId?: string;
19
+ };
20
+
21
+ const isStoredRef = (value: unknown): value is StoredRef =>
22
+ typeof value === "object" &&
23
+ value !== null &&
24
+ "kind" in value &&
25
+ "accountId" in value &&
26
+ "folderPath" in value &&
27
+ "target" in value;
28
+
29
+ const refsOf = (value: unknown): ConfigImportUnresolvedRefItem[] => {
30
+ if (!Array.isArray(value) || !value.every(isStoredRef)) {
31
+ throw new Error(
32
+ "config_import.unresolved_refs is not a list of folder references",
33
+ );
34
+ }
35
+ return value.map((ref) => ({
36
+ ...ref,
37
+ mailboxId: ref.mailboxId ?? FILTER_NO_ACTION,
38
+ }));
39
+ };
40
+
15
41
  function rowToConfigImport(
16
42
  row: typeof configImportTable.$inferSelect,
17
43
  ): ConfigImportItem {
@@ -21,7 +47,7 @@ function rowToConfigImport(
21
47
  schemaVersion: row.schemaVersion,
22
48
  state: row.state as ConfigImportItem["state"],
23
49
  document: row.document as ConfigImportItem["document"],
24
- unresolvedRefs: row.unresolvedRefs as ConfigImportItem["unresolvedRefs"],
50
+ unresolvedRefs: refsOf(row.unresolvedRefs),
25
51
  createdAt: row.createdAt,
26
52
  completedAt: row.completedAt,
27
53
  updatedAt: row.updatedAt,