@remit/drizzle-service 0.0.26 → 0.0.28

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.26",
3
+ "version": "0.0.28",
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),
@@ -1,6 +1,7 @@
1
1
  import assert from "node:assert/strict";
2
2
  import { randomUUID } from "node:crypto";
3
3
  import { after, before, describe, test } from "node:test";
4
+ import { MailboxSyncStatus } from "@remit/domain-enums";
4
5
  import Database from "better-sqlite3";
5
6
  import { drizzle } from "drizzle-orm/better-sqlite3";
6
7
  import { mailboxTable } from "../schema.js";
@@ -66,6 +67,33 @@ describe("MailboxRepo (sqlite)", () => {
66
67
  const reread = await repo.get(accountId, created.mailboxId);
67
68
  assert.equal(reread.highestModseq, "9007199254740993");
68
69
  });
70
+
71
+ test("renameChildPaths marks each child pending along with its new path (#290)", async () => {
72
+ // A renamed parent is set pending by the caller; its children's new paths
73
+ // are equally absent from the server until MAILBOX_RENAME lands, so they
74
+ // must be pending too — otherwise a reconcile in that window reaps the
75
+ // child as server-deleted.
76
+ const accountId = randomUUID();
77
+ const parent = await repo.create({
78
+ ...makeMailboxInput(accountId, "Work"),
79
+ syncStatus: MailboxSyncStatus.pending,
80
+ });
81
+ const child = await repo.create({
82
+ ...makeMailboxInput(accountId, "Work/sub"),
83
+ syncStatus: MailboxSyncStatus.synced,
84
+ });
85
+
86
+ await repo.renameChildPaths(accountId, "Work", "Projects", "/");
87
+
88
+ const renamedChild = await repo.get(accountId, child.mailboxId);
89
+ assert.equal(renamedChild.fullPath, "Projects/sub");
90
+ assert.equal(renamedChild.syncStatus, MailboxSyncStatus.pending);
91
+
92
+ // The parent row is untouched by this call (its own path/status is the
93
+ // caller's job).
94
+ const parentRow = await repo.get(accountId, parent.mailboxId);
95
+ assert.equal(parentRow.fullPath, "Work");
96
+ });
69
97
  });
70
98
 
71
99
  /**
@@ -6,7 +6,7 @@ import type {
6
6
  ResultList,
7
7
  UpdateMailboxInput,
8
8
  } from "@remit/data-ports";
9
- import { MailboxCursorState } from "@remit/domain-enums";
9
+ import { MailboxCursorState, MailboxSyncStatus } from "@remit/domain-enums";
10
10
  import { and, asc, eq, gt, inArray, or } from "drizzle-orm";
11
11
  import type { NodePgDatabase } from "drizzle-orm/node-postgres";
12
12
  import shortUuid from "short-uuid";
@@ -341,7 +341,13 @@ export class MailboxRepo implements IMailboxRepository {
341
341
  const children = await this.findByPathPrefix(accountId, oldPath, delimiter);
342
342
  for (const child of children) {
343
343
  const newChildPath = child.fullPath.replace(oldPath, newPath);
344
- await this.update(accountId, child.mailboxId, { fullPath: newChildPath });
344
+ // Mark the child pending, like the renamed parent: its new path is not
345
+ // on the server until MAILBOX_RENAME lands, so a reconcile running in
346
+ // that window must not reap it as server-deleted (#290).
347
+ await this.update(accountId, child.mailboxId, {
348
+ fullPath: newChildPath,
349
+ syncStatus: MailboxSyncStatus.pending,
350
+ });
345
351
  }
346
352
  }
347
353
  }