@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 +1 -1
- package/src/repos/filter.test.ts +76 -0
- package/src/repos/filter.ts +38 -10
- package/src/repos/label.ts +55 -1
- package/src/repos/message-label.test.ts +51 -0
- package/src/repos/message-label.ts +24 -1
package/package.json
CHANGED
package/src/repos/filter.test.ts
CHANGED
|
@@ -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" }),
|
package/src/repos/filter.ts
CHANGED
|
@@ -5,7 +5,11 @@ import type {
|
|
|
5
5
|
ResultList,
|
|
6
6
|
UpdateFilterInput,
|
|
7
7
|
} from "@remit/data-ports";
|
|
8
|
-
import {
|
|
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
|
|
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
|
|
30
|
-
* 3.2) — a plain rename (`name` only)
|
|
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
|
|
33
|
-
|
|
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
|
|
106
|
-
* action — never on a cosmetic `name` edit (RFC
|
|
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 =
|
|
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(
|
|
146
|
+
.set(updates)
|
|
119
147
|
.where(
|
|
120
148
|
and(
|
|
121
149
|
eq(filterTable.accountConfigId, accountConfigId),
|
package/src/repos/label.ts
CHANGED
|
@@ -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
|
}
|