@remit/drizzle-service 0.0.27 → 0.0.29

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.27",
3
+ "version": "0.0.29",
4
4
  "description": "Drizzle ORM service parameterized by dialect (Postgres / SQLite)",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -119,6 +119,82 @@ describe("FilterRepo", () => {
119
119
  );
120
120
  });
121
121
 
122
+ test("update bumps ruleChangedAt when scope changes (reader #266)", async () => {
123
+ const accountConfigId = randomId();
124
+ const filter = await repo.create({
125
+ accountConfigId,
126
+ name: "Trip out-of-office",
127
+ scope: FilterScope.Standing,
128
+ });
129
+
130
+ await new Promise((resolve) => setTimeout(resolve, 1100));
131
+
132
+ const expiresAt = "2027-01-01T00:00:00+00:00";
133
+ const updated = await repo.update(accountConfigId, filter.filterId, {
134
+ scope: FilterScope.Temporary,
135
+ expiresAt,
136
+ ttl: Math.floor(new Date(expiresAt).getTime() / 1000),
137
+ state: FilterState.Active,
138
+ });
139
+
140
+ assert.equal(updated.scope, FilterScope.Temporary);
141
+ assert.equal(updated.expiresAt, expiresAt);
142
+ assert.ok(
143
+ updated.ruleChangedAt > filter.ruleChangedAt,
144
+ "a scope edit must move ruleChangedAt forward",
145
+ );
146
+ });
147
+
148
+ test("update bumps ruleChangedAt when only expiresAt changes (reader #266)", async () => {
149
+ const accountConfigId = randomId();
150
+ const initialExpiresAt = "2026-08-01T00:00:00+00:00";
151
+ const filter = await repo.create({
152
+ accountConfigId,
153
+ name: "Until my trip",
154
+ scope: FilterScope.Temporary,
155
+ expiresAt: initialExpiresAt,
156
+ ttl: Math.floor(new Date(initialExpiresAt).getTime() / 1000),
157
+ });
158
+
159
+ await new Promise((resolve) => setTimeout(resolve, 1100));
160
+
161
+ const extendedExpiresAt = "2026-09-01T00:00:00+00:00";
162
+ const updated = await repo.update(accountConfigId, filter.filterId, {
163
+ expiresAt: extendedExpiresAt,
164
+ ttl: Math.floor(new Date(extendedExpiresAt).getTime() / 1000),
165
+ });
166
+
167
+ assert.equal(updated.expiresAt, extendedExpiresAt);
168
+ assert.ok(
169
+ updated.ruleChangedAt > filter.ruleChangedAt,
170
+ "an expiresAt edit must move ruleChangedAt forward",
171
+ );
172
+ });
173
+
174
+ test("update to Standing clears expiresAt and ttl at the storage layer, even if the caller didn't null them (reader #266)", async () => {
175
+ const accountConfigId = randomId();
176
+ const expiresAt = "2026-08-01T00:00:00+00:00";
177
+ const filter = await repo.create({
178
+ accountConfigId,
179
+ name: "Until my trip",
180
+ scope: FilterScope.Temporary,
181
+ expiresAt,
182
+ ttl: Math.floor(new Date(expiresAt).getTime() / 1000),
183
+ });
184
+
185
+ const updated = await repo.update(accountConfigId, filter.filterId, {
186
+ scope: FilterScope.Standing,
187
+ });
188
+
189
+ assert.equal(updated.scope, FilterScope.Standing);
190
+ assert.equal(updated.expiresAt, undefined);
191
+ assert.equal(updated.ttl, undefined);
192
+
193
+ const reread = await repo.get(accountConfigId, filter.filterId);
194
+ assert.equal(reread.expiresAt, undefined);
195
+ assert.equal(reread.ttl, undefined);
196
+ });
197
+
122
198
  test("update throws NotFoundError for a missing filter", async () => {
123
199
  await assert.rejects(
124
200
  repo.update(randomId(), randomId(), { name: "x" }),
@@ -5,7 +5,11 @@ import type {
5
5
  ResultList,
6
6
  UpdateFilterInput,
7
7
  } from "@remit/data-ports";
8
- import { FilterMatchOperator, FilterState } from "@remit/domain-enums";
8
+ import {
9
+ FilterMatchOperator,
10
+ FilterScope,
11
+ FilterState,
12
+ } from "@remit/domain-enums";
9
13
  import { and, asc, eq, gt, or } from "drizzle-orm";
10
14
  import type { NodePgDatabase } from "drizzle-orm/node-postgres";
11
15
  import { NotFoundError } from "../error.js";
@@ -15,22 +19,28 @@ import { filterTable } from "../schema.js";
15
19
 
16
20
  type DB = NodePgDatabase<Record<string, unknown>>;
17
21
 
18
- const PREDICATE_OR_ACTION_FIELDS = [
22
+ const RULE_ASSERTION_FIELDS = [
19
23
  "hasAnchor",
20
24
  "matchOperator",
21
25
  "literalClauses",
22
26
  "actionLabelId",
23
27
  "actionMailboxId",
28
+ "scope",
29
+ "expiresAt",
24
30
  ] as const satisfies readonly (keyof UpdateFilterInput)[];
25
31
 
26
32
  const nowSeconds = (): number => Math.floor(Date.now() / 1000);
27
33
 
28
34
  /**
29
- * Whether `input` touches the predicate or the action (RFC 034 Decision
30
- * 3.2) — a plain rename (`name` only) must not bump `ruleChangedAt`.
35
+ * Whether `input` touches the predicate, the action, the scope, or the
36
+ * expiry (RFC 034 Decision 3.2, reader #266) — a plain rename (`name` only)
37
+ * must not bump `ruleChangedAt`. Scope and expiry count as a rule assertion
38
+ * alongside the predicate/action: re-asserting how long a filter runs is the
39
+ * same kind of "the user just told Remit something new" moment, and it is
40
+ * what lets a lapsed filter's back-application be offered again.
31
41
  */
32
- const changesPredicateOrAction = (input: UpdateFilterInput): boolean =>
33
- PREDICATE_OR_ACTION_FIELDS.some((field) => field in input);
42
+ const changesRuleAssertion = (input: UpdateFilterInput): boolean =>
43
+ RULE_ASSERTION_FIELDS.some((field) => field in input);
34
44
 
35
45
  function rowToFilter(row: typeof filterTable.$inferSelect): FilterItem {
36
46
  return {
@@ -102,20 +112,38 @@ export class FilterRepo implements IFilterRepository {
102
112
  }
103
113
 
104
114
  /**
105
- * `ruleChangedAt` only advances when `input` touches the predicate or the
106
- * action — never on a cosmetic `name` edit (RFC 034 Decision 3.2).
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).
118
+ *
119
+ * A patch moving `scope` to `Standing` clears `expiresAt`/`ttl` at the SQL
120
+ * layer regardless of what `input` carries for them: the caller (the
121
+ * backend handler's `resolveFilterScopeExpiry`) already resolved `input` to
122
+ * `undefined` for both, but `undefined` in a `.set()` value means "leave
123
+ * column alone," not "clear it" — only an explicit `null` does that. This
124
+ * keeps the Filter model's invariant that a `Standing` filter never carries
125
+ * `expiresAt`/`ttl` (RFC 034 Decision 1.4) true at the storage layer itself,
126
+ * not just by caller convention.
107
127
  */
108
128
  async update(
109
129
  accountConfigId: string,
110
130
  filterId: string,
111
131
  input: UpdateFilterInput,
112
132
  ): Promise<FilterItem> {
113
- const patch = changesPredicateOrAction(input)
133
+ const patch = changesRuleAssertion(input)
114
134
  ? { ...input, ruleChangedAt: nowSeconds() }
115
135
  : input;
136
+ const updates: Partial<typeof filterTable.$inferInsert> = {
137
+ ...patch,
138
+ updatedAt: Date.now(),
139
+ };
140
+ if (patch.scope === FilterScope.Standing) {
141
+ updates.expiresAt = null;
142
+ updates.ttl = null;
143
+ }
116
144
  const [row] = await this.db
117
145
  .update(filterTable)
118
- .set({ ...patch, updatedAt: Date.now() })
146
+ .set(updates)
119
147
  .where(
120
148
  and(
121
149
  eq(filterTable.accountConfigId, accountConfigId),
@@ -2,12 +2,14 @@ import type {
2
2
  CreateLabelInput,
3
3
  ILabelRepository,
4
4
  LabelItem,
5
+ ResultList,
5
6
  UpdateLabelInput,
6
7
  } from "@remit/data-ports";
7
- import { and, eq } from "drizzle-orm";
8
+ import { and, asc, eq, gt, or } from "drizzle-orm";
8
9
  import type { NodePgDatabase } from "drizzle-orm/node-postgres";
9
10
  import { NotFoundError } from "../error.js";
10
11
  import { randomId } from "../id.js";
12
+ import { decodeToken, resultList } from "../pagination.js";
11
13
  import { labelTable } from "../schema.js";
12
14
 
13
15
  type DB = NodePgDatabase<Record<string, unknown>>;
@@ -107,6 +109,58 @@ export class LabelRepo implements ILabelRepository {
107
109
  return rows.map(rowToLabel);
108
110
  }
109
111
 
112
+ /**
113
+ * A single signed page of an account config's labels (issue #26), mirroring
114
+ * `FilterRepo.listPageByAccountConfig`: a `(createdAt, labelId)` keyset
115
+ * cursor that round-trips through `continuationToken`.
116
+ */
117
+ async listPageByAccountConfig(
118
+ accountConfigId: string,
119
+ options?: { limit?: number; continuationToken?: string },
120
+ ): Promise<ResultList<LabelItem>> {
121
+ const limit = options?.limit ?? 100;
122
+ const cursor = options?.continuationToken
123
+ ? decodeToken(options.continuationToken)
124
+ : undefined;
125
+ const after = cursor
126
+ ? {
127
+ createdAt: cursor.createdAt as number,
128
+ labelId: cursor.labelId as string,
129
+ }
130
+ : undefined;
131
+
132
+ const rows = await this.db
133
+ .select()
134
+ .from(labelTable)
135
+ .where(
136
+ and(
137
+ eq(labelTable.accountConfigId, accountConfigId),
138
+ after
139
+ ? or(
140
+ gt(labelTable.createdAt, after.createdAt),
141
+ and(
142
+ eq(labelTable.createdAt, after.createdAt),
143
+ gt(labelTable.labelId, after.labelId),
144
+ ),
145
+ )
146
+ : undefined,
147
+ ),
148
+ )
149
+ .orderBy(asc(labelTable.createdAt), asc(labelTable.labelId))
150
+ .limit(limit + 1);
151
+
152
+ const hasMore = rows.length > limit;
153
+ const items = rows.slice(0, limit).map(rowToLabel);
154
+ const lastItem = items[items.length - 1];
155
+ return resultList(
156
+ items,
157
+ limit,
158
+ hasMore && lastItem
159
+ ? { createdAt: lastItem.createdAt, labelId: lastItem.labelId }
160
+ : undefined,
161
+ );
162
+ }
163
+
110
164
  async findByNormalizedName(
111
165
  accountConfigId: string,
112
166
  normalizedName: string,
@@ -107,4 +107,55 @@ describe("MessageLabelRepo", () => {
107
107
  const messageIds = rows.map((r) => r.messageId).sort();
108
108
  assert.deepEqual(messageIds, [messageIdA, messageIdB].sort());
109
109
  });
110
+
111
+ test("listByMessageIds batch-fetches across several messages in one call", async () => {
112
+ const accountConfigId = randomId();
113
+ const messageIdA = randomId();
114
+ const messageIdB = randomId();
115
+ const messageIdC = randomId();
116
+ const labelId = randomId();
117
+
118
+ await repo.apply({ accountConfigId, messageId: messageIdA, labelId });
119
+ await repo.apply({ accountConfigId, messageId: messageIdB, labelId });
120
+
121
+ const rows = await repo.listByMessageIds([
122
+ messageIdA,
123
+ messageIdB,
124
+ messageIdC,
125
+ ]);
126
+ const messageIds = rows.map((r) => r.messageId).sort();
127
+ assert.deepEqual(messageIds, [messageIdA, messageIdB].sort());
128
+ });
129
+
130
+ test("listByMessageIds returns nothing for an empty list", async () => {
131
+ const rows = await repo.listByMessageIds([]);
132
+ assert.deepEqual(rows, []);
133
+ });
134
+
135
+ test("removeAllByLabelId clears every MessageLabel row for the label, scoped to the account", async () => {
136
+ const accountConfigId = randomId();
137
+ const labelId = randomId();
138
+ const messageIdA = randomId();
139
+ const messageIdB = randomId();
140
+ const foreignAccountConfigId = randomId();
141
+ const foreignMessageId = randomId();
142
+
143
+ await repo.apply({ accountConfigId, messageId: messageIdA, labelId });
144
+ await repo.apply({ accountConfigId, messageId: messageIdB, labelId });
145
+ await repo.apply({
146
+ accountConfigId: foreignAccountConfigId,
147
+ messageId: foreignMessageId,
148
+ labelId,
149
+ });
150
+
151
+ await repo.removeAllByLabelId(accountConfigId, labelId);
152
+
153
+ assert.deepEqual(await repo.listByMessageId(messageIdA), []);
154
+ assert.deepEqual(await repo.listByMessageId(messageIdB), []);
155
+ const foreignRows = await repo.listByLabelId(
156
+ foreignAccountConfigId,
157
+ labelId,
158
+ );
159
+ assert.equal(foreignRows.length, 1);
160
+ });
110
161
  });
@@ -3,7 +3,7 @@ import type {
3
3
  IMessageLabelRepository,
4
4
  MessageLabelItem,
5
5
  } from "@remit/data-ports";
6
- import { and, desc, eq } from "drizzle-orm";
6
+ import { and, desc, eq, inArray } from "drizzle-orm";
7
7
  import type { NodePgDatabase } from "drizzle-orm/node-postgres";
8
8
  import { deterministicBase36Id } from "../id.js";
9
9
  import { messageLabelTable } from "../schema.js";
@@ -77,6 +77,15 @@ export class MessageLabelRepo implements IMessageLabelRepository {
77
77
  return rows.map(rowToMessageLabel);
78
78
  }
79
79
 
80
+ async listByMessageIds(messageIds: string[]): Promise<MessageLabelItem[]> {
81
+ if (messageIds.length === 0) return [];
82
+ const rows = await this.db
83
+ .select()
84
+ .from(messageLabelTable)
85
+ .where(inArray(messageLabelTable.messageId, messageIds));
86
+ return rows.map(rowToMessageLabel);
87
+ }
88
+
80
89
  async listByLabelId(
81
90
  accountConfigId: string,
82
91
  labelId: string,
@@ -93,4 +102,18 @@ export class MessageLabelRepo implements IMessageLabelRepository {
93
102
  .orderBy(desc(messageLabelTable.createdAt));
94
103
  return rows.map(rowToMessageLabel);
95
104
  }
105
+
106
+ async removeAllByLabelId(
107
+ accountConfigId: string,
108
+ labelId: string,
109
+ ): Promise<void> {
110
+ await this.db
111
+ .delete(messageLabelTable)
112
+ .where(
113
+ and(
114
+ eq(messageLabelTable.accountConfigId, accountConfigId),
115
+ eq(messageLabelTable.labelId, labelId),
116
+ ),
117
+ );
118
+ }
96
119
  }