@remit/drizzle-service 0.0.92 → 0.0.93
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
|
@@ -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(
|
|
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(
|
|
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");
|
package/src/repos/filter.test.ts
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import assert from "node:assert";
|
|
2
2
|
import { after, before, describe, test } from "node:test";
|
|
3
|
-
import {
|
|
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();
|
package/src/repos/filter.ts
CHANGED
|
@@ -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
|
|
294
|
-
*
|
|
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
|
|
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({
|
|
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
|
|
2
|
-
ConfigImportItem,
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
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
|
|
50
|
+
unresolvedRefs: refsOf(row.unresolvedRefs),
|
|
25
51
|
createdAt: row.createdAt,
|
|
26
52
|
completedAt: row.completedAt,
|
|
27
53
|
updatedAt: row.updatedAt,
|