@remit/drizzle-service 0.0.32 → 0.0.33

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.32",
3
+ "version": "0.0.33",
4
4
  "description": "Drizzle ORM service parameterized by dialect (Postgres / SQLite)",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -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 () => {
@@ -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` is set to the same instant as creation: a new filter's
70
- * predicate/action is, by definition, freshly asserted (RFC 034 Decision
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: nowSeconds(),
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` only advances when `input` touches the predicate, the
116
- * action, the scope, or the expiry — never on a cosmetic `name` edit (RFC
117
- * 034 Decision 3.2, reader #266).
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 patch = changesRuleAssertion(input)
134
- ? { ...input, ruleChangedAt: nowSeconds() }
135
- : input;
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(),