@remit/drizzle-service 0.0.32 → 0.0.34
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 +1 -1
- package/src/repos/filter.test.ts +27 -4
- package/src/repos/filter.ts +48 -10
- package/src/repos/message.ts +7 -0
- package/src/repos/test-helpers.ts +1 -0
package/package.json
CHANGED
package/src/repos/filter.test.ts
CHANGED
|
@@ -36,6 +36,10 @@ describe("FilterRepo", () => {
|
|
|
36
36
|
filter.ruleChangedAt > 0,
|
|
37
37
|
"ruleChangedAt should be set on create",
|
|
38
38
|
);
|
|
39
|
+
assert.ok(
|
|
40
|
+
filter.actionChangedAt > 0,
|
|
41
|
+
"actionChangedAt should be set on create",
|
|
42
|
+
);
|
|
39
43
|
assert.equal(filter.expiresAt, undefined);
|
|
40
44
|
assert.equal(filter.ttl, undefined);
|
|
41
45
|
});
|
|
@@ -97,9 +101,14 @@ describe("FilterRepo", () => {
|
|
|
97
101
|
filter.ruleChangedAt,
|
|
98
102
|
"a name-only edit must not move ruleChangedAt (RFC 034 Decision 3.2)",
|
|
99
103
|
);
|
|
104
|
+
assert.equal(
|
|
105
|
+
renamed.actionChangedAt,
|
|
106
|
+
filter.actionChangedAt,
|
|
107
|
+
"a name-only edit must not move actionChangedAt either",
|
|
108
|
+
);
|
|
100
109
|
});
|
|
101
110
|
|
|
102
|
-
test("update bumps ruleChangedAt when the action changes", async () => {
|
|
111
|
+
test("update bumps both ruleChangedAt and actionChangedAt when the action changes", async () => {
|
|
103
112
|
const accountConfigId = randomId();
|
|
104
113
|
const filter = await repo.create({
|
|
105
114
|
accountConfigId,
|
|
@@ -117,9 +126,13 @@ describe("FilterRepo", () => {
|
|
|
117
126
|
updated.ruleChangedAt > filter.ruleChangedAt,
|
|
118
127
|
"an action edit must move ruleChangedAt forward",
|
|
119
128
|
);
|
|
129
|
+
assert.ok(
|
|
130
|
+
updated.actionChangedAt > filter.actionChangedAt,
|
|
131
|
+
"an action edit must move actionChangedAt forward too — it is what a matching message's exclusive move goes by",
|
|
132
|
+
);
|
|
120
133
|
});
|
|
121
134
|
|
|
122
|
-
test("update bumps ruleChangedAt when scope changes (reader #266)", async () => {
|
|
135
|
+
test("update bumps ruleChangedAt but NOT actionChangedAt when scope changes (reader #266, #384)", async () => {
|
|
123
136
|
const accountConfigId = randomId();
|
|
124
137
|
const filter = await repo.create({
|
|
125
138
|
accountConfigId,
|
|
@@ -141,11 +154,16 @@ describe("FilterRepo", () => {
|
|
|
141
154
|
assert.equal(updated.expiresAt, expiresAt);
|
|
142
155
|
assert.ok(
|
|
143
156
|
updated.ruleChangedAt > filter.ruleChangedAt,
|
|
144
|
-
"a scope edit must move ruleChangedAt forward",
|
|
157
|
+
"a scope edit must move ruleChangedAt forward, so a lapsed filter's back-application can be offered again",
|
|
158
|
+
);
|
|
159
|
+
assert.equal(
|
|
160
|
+
updated.actionChangedAt,
|
|
161
|
+
filter.actionChangedAt,
|
|
162
|
+
"a scope edit changes the filter's lifecycle, not what it matches or does — it must not move actionChangedAt, or it would wrongly promote the filter to move-winner",
|
|
145
163
|
);
|
|
146
164
|
});
|
|
147
165
|
|
|
148
|
-
test("update bumps ruleChangedAt when only expiresAt changes (reader #266)", async () => {
|
|
166
|
+
test("update bumps ruleChangedAt but NOT actionChangedAt when only expiresAt changes (reader #266, #384)", async () => {
|
|
149
167
|
const accountConfigId = randomId();
|
|
150
168
|
const initialExpiresAt = "2026-08-01T00:00:00+00:00";
|
|
151
169
|
const filter = await repo.create({
|
|
@@ -169,6 +187,11 @@ describe("FilterRepo", () => {
|
|
|
169
187
|
updated.ruleChangedAt > filter.ruleChangedAt,
|
|
170
188
|
"an expiresAt edit must move ruleChangedAt forward",
|
|
171
189
|
);
|
|
190
|
+
assert.equal(
|
|
191
|
+
updated.actionChangedAt,
|
|
192
|
+
filter.actionChangedAt,
|
|
193
|
+
"extending a filter's expiry alone must not promote it to move-winner (reader #384) — actionChangedAt must not move",
|
|
194
|
+
);
|
|
172
195
|
});
|
|
173
196
|
|
|
174
197
|
test("update to Standing clears expiresAt and ttl at the storage layer, even if the caller didn't null them (reader #266)", async () => {
|
package/src/repos/filter.ts
CHANGED
|
@@ -29,6 +29,20 @@ const RULE_ASSERTION_FIELDS = [
|
|
|
29
29
|
"expiresAt",
|
|
30
30
|
] as const satisfies readonly (keyof UpdateFilterInput)[];
|
|
31
31
|
|
|
32
|
+
/**
|
|
33
|
+
* The subset of {@link RULE_ASSERTION_FIELDS} that changes what a filter
|
|
34
|
+
* matches or what it does — excluding `scope`/`expiresAt`, which only change
|
|
35
|
+
* how long the filter runs. Backs `actionChangedAt` (reader #384), the
|
|
36
|
+
* narrower signal `selectMoveWinner` reads for exclusive-move precedence.
|
|
37
|
+
*/
|
|
38
|
+
const ACTION_ASSERTION_FIELDS = [
|
|
39
|
+
"hasAnchor",
|
|
40
|
+
"matchOperator",
|
|
41
|
+
"literalClauses",
|
|
42
|
+
"actionLabelId",
|
|
43
|
+
"actionMailboxId",
|
|
44
|
+
] as const satisfies readonly (keyof UpdateFilterInput)[];
|
|
45
|
+
|
|
32
46
|
const nowSeconds = (): number => Math.floor(Date.now() / 1000);
|
|
33
47
|
|
|
34
48
|
/**
|
|
@@ -38,10 +52,24 @@ const nowSeconds = (): number => Math.floor(Date.now() / 1000);
|
|
|
38
52
|
* alongside the predicate/action: re-asserting how long a filter runs is the
|
|
39
53
|
* same kind of "the user just told Remit something new" moment, and it is
|
|
40
54
|
* what lets a lapsed filter's back-application be offered again.
|
|
55
|
+
*
|
|
56
|
+
* This is deliberately broader than {@link changesActionAssertion} — it backs
|
|
57
|
+
* `ruleChangedAt`, the settings UI's back-apply-offer signal, not exclusive-
|
|
58
|
+
* move precedence (reader #384).
|
|
41
59
|
*/
|
|
42
60
|
const changesRuleAssertion = (input: UpdateFilterInput): boolean =>
|
|
43
61
|
RULE_ASSERTION_FIELDS.some((field) => field in input);
|
|
44
62
|
|
|
63
|
+
/**
|
|
64
|
+
* Whether `input` touches the predicate or the action — never `scope` or
|
|
65
|
+
* `expiresAt` (reader #384). Backs `actionChangedAt`, which `selectMoveWinner`
|
|
66
|
+
* reads to decide an exclusive-move conflict: extending a filter's expiry or
|
|
67
|
+
* flipping Standing/Temporary changes its lifecycle, not where a matching
|
|
68
|
+
* message goes, so it must not promote the filter to move-winner.
|
|
69
|
+
*/
|
|
70
|
+
const changesActionAssertion = (input: UpdateFilterInput): boolean =>
|
|
71
|
+
ACTION_ASSERTION_FIELDS.some((field) => field in input);
|
|
72
|
+
|
|
45
73
|
function rowToFilter(row: typeof filterTable.$inferSelect): FilterItem {
|
|
46
74
|
return {
|
|
47
75
|
filterId: row.filterId,
|
|
@@ -53,6 +81,7 @@ function rowToFilter(row: typeof filterTable.$inferSelect): FilterItem {
|
|
|
53
81
|
state: row.state,
|
|
54
82
|
hasAnchor: row.hasAnchor,
|
|
55
83
|
ruleChangedAt: row.ruleChangedAt,
|
|
84
|
+
actionChangedAt: row.actionChangedAt,
|
|
56
85
|
matchOperator: row.matchOperator,
|
|
57
86
|
literalClauses: row.literalClauses as FilterItem["literalClauses"],
|
|
58
87
|
actionLabelId: row.actionLabelId,
|
|
@@ -66,12 +95,13 @@ export class FilterRepo implements IFilterRepository {
|
|
|
66
95
|
constructor(private db: DB) {}
|
|
67
96
|
|
|
68
97
|
/**
|
|
69
|
-
* `ruleChangedAt`
|
|
70
|
-
* predicate/action
|
|
71
|
-
* 3.2).
|
|
98
|
+
* `ruleChangedAt` and `actionChangedAt` are both set to the same instant as
|
|
99
|
+
* creation: a new filter's predicate/action (and its scope/expiry) are, by
|
|
100
|
+
* definition, freshly asserted (RFC 034 Decision 3.2).
|
|
72
101
|
*/
|
|
73
102
|
async create(input: CreateFilterInput): Promise<FilterItem> {
|
|
74
103
|
const now = Date.now();
|
|
104
|
+
const createdRuleChangedAt = nowSeconds();
|
|
75
105
|
const [row] = await this.db
|
|
76
106
|
.insert(filterTable)
|
|
77
107
|
.values({
|
|
@@ -83,7 +113,8 @@ export class FilterRepo implements IFilterRepository {
|
|
|
83
113
|
ttl: input.ttl ?? null,
|
|
84
114
|
state: input.state ?? FilterState.Active,
|
|
85
115
|
hasAnchor: input.hasAnchor ?? false,
|
|
86
|
-
ruleChangedAt:
|
|
116
|
+
ruleChangedAt: createdRuleChangedAt,
|
|
117
|
+
actionChangedAt: createdRuleChangedAt,
|
|
87
118
|
matchOperator: input.matchOperator ?? FilterMatchOperator.And,
|
|
88
119
|
literalClauses: input.literalClauses ?? [],
|
|
89
120
|
actionLabelId: input.actionLabelId ?? "None",
|
|
@@ -112,9 +143,13 @@ export class FilterRepo implements IFilterRepository {
|
|
|
112
143
|
}
|
|
113
144
|
|
|
114
145
|
/**
|
|
115
|
-
* `ruleChangedAt`
|
|
116
|
-
*
|
|
117
|
-
*
|
|
146
|
+
* `ruleChangedAt` advances when `input` touches the predicate, the action,
|
|
147
|
+
* the scope, or the expiry — never on a cosmetic `name` edit (RFC 034
|
|
148
|
+
* Decision 3.2, reader #266). `actionChangedAt` advances only for the
|
|
149
|
+
* narrower predicate/action subset (reader #384): a scope/expiresAt-only
|
|
150
|
+
* edit bumps `ruleChangedAt` (so a lapsed filter's back-application can be
|
|
151
|
+
* offered again) without touching `actionChangedAt`, so it cannot flip
|
|
152
|
+
* `selectMoveWinner`'s exclusive-move precedence.
|
|
118
153
|
*
|
|
119
154
|
* A patch moving `scope` to `Standing` clears `expiresAt`/`ttl` at the SQL
|
|
120
155
|
* layer regardless of what `input` carries for them: the caller (the
|
|
@@ -130,9 +165,12 @@ export class FilterRepo implements IFilterRepository {
|
|
|
130
165
|
filterId: string,
|
|
131
166
|
input: UpdateFilterInput,
|
|
132
167
|
): Promise<FilterItem> {
|
|
133
|
-
const
|
|
134
|
-
|
|
135
|
-
|
|
168
|
+
const changedAt = nowSeconds();
|
|
169
|
+
const patch = {
|
|
170
|
+
...input,
|
|
171
|
+
...(changesRuleAssertion(input) ? { ruleChangedAt: changedAt } : {}),
|
|
172
|
+
...(changesActionAssertion(input) ? { actionChangedAt: changedAt } : {}),
|
|
173
|
+
};
|
|
136
174
|
const updates: Partial<typeof filterTable.$inferInsert> = {
|
|
137
175
|
...patch,
|
|
138
176
|
updatedAt: Date.now(),
|
package/src/repos/message.ts
CHANGED
|
@@ -91,6 +91,9 @@ function toMessageItem(row: typeof messageTable.$inferSelect): MessageItem {
|
|
|
91
91
|
...(row.filterMove !== null
|
|
92
92
|
? { filterMove: row.filterMove as MessageItem["filterMove"] }
|
|
93
93
|
: {}),
|
|
94
|
+
...(row.placementDecidedAt !== null
|
|
95
|
+
? { placementDecidedAt: row.placementDecidedAt }
|
|
96
|
+
: {}),
|
|
94
97
|
};
|
|
95
98
|
}
|
|
96
99
|
|
|
@@ -192,6 +195,7 @@ export class DrizzleMessageRepository implements IMessageRepository {
|
|
|
192
195
|
providerSpam: input.providerSpam ?? null,
|
|
193
196
|
placementVerdict: input.placementVerdict ?? null,
|
|
194
197
|
filterMove: input.filterMove ?? null,
|
|
198
|
+
placementDecidedAt: input.placementDecidedAt ?? null,
|
|
195
199
|
createdAt: now,
|
|
196
200
|
updatedAt: now,
|
|
197
201
|
};
|
|
@@ -381,6 +385,9 @@ export class DrizzleMessageRepository implements IMessageRepository {
|
|
|
381
385
|
...(input.filterMove !== undefined
|
|
382
386
|
? { filterMove: input.filterMove }
|
|
383
387
|
: {}),
|
|
388
|
+
...(input.placementDecidedAt !== undefined
|
|
389
|
+
? { placementDecidedAt: input.placementDecidedAt }
|
|
390
|
+
: {}),
|
|
384
391
|
...(input.messageIdHeader !== undefined
|
|
385
392
|
? { messageIdHeader: input.messageIdHeader }
|
|
386
393
|
: {}),
|