@remit/drizzle-service 0.0.1

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.
Files changed (82) hide show
  1. package/drizzle.config.ts +15 -0
  2. package/package.json +52 -0
  3. package/src/db.ts +13 -0
  4. package/src/dialect.ts +13 -0
  5. package/src/error.ts +37 -0
  6. package/src/id.ts +50 -0
  7. package/src/index.ts +62 -0
  8. package/src/pagination.ts +29 -0
  9. package/src/repos/cascade-delete.sqlite.test.ts +159 -0
  10. package/src/repos/cascade-delete.test.ts +423 -0
  11. package/src/repos/cascade-delete.ts +219 -0
  12. package/src/repos/envelope.sqlite.test.ts +94 -0
  13. package/src/repos/envelope.test.ts +225 -0
  14. package/src/repos/envelope.ts +342 -0
  15. package/src/repos/filter-anchor.test.ts +131 -0
  16. package/src/repos/filter-anchor.ts +105 -0
  17. package/src/repos/filter.test.ts +279 -0
  18. package/src/repos/filter.ts +253 -0
  19. package/src/repos/i4-account-config.test.ts +105 -0
  20. package/src/repos/i4-account-config.ts +257 -0
  21. package/src/repos/i4-account-export-request.ts +141 -0
  22. package/src/repos/i4-account-setting.test.ts +94 -0
  23. package/src/repos/i4-account-setting.ts +92 -0
  24. package/src/repos/i4-account.test.ts +223 -0
  25. package/src/repos/i4-account.ts +380 -0
  26. package/src/repos/i4-address-wellknown.ts +31 -0
  27. package/src/repos/i4-address.test.ts +358 -0
  28. package/src/repos/i4-address.ts +613 -0
  29. package/src/repos/i4-mailbox-lock.test.ts +368 -0
  30. package/src/repos/i4-mailbox-lock.ts +140 -0
  31. package/src/repos/i4-mailbox-special-use.ts +165 -0
  32. package/src/repos/i4-mailbox.test.ts +188 -0
  33. package/src/repos/i4-mailbox.ts +347 -0
  34. package/src/repos/i4-message-flag-push.test.ts +189 -0
  35. package/src/repos/i4-message-flag-push.ts +173 -0
  36. package/src/repos/i4-message-placement-move.test.ts +135 -0
  37. package/src/repos/i4-message-placement-move.ts +144 -0
  38. package/src/repos/i4-organize-job-request.ts +142 -0
  39. package/src/repos/i4-outbox-message.test.ts +298 -0
  40. package/src/repos/i4-outbox-message.ts +299 -0
  41. package/src/repos/label.conformance.sqlite.test.ts +23 -0
  42. package/src/repos/label.conformance.test.ts +19 -0
  43. package/src/repos/label.ts +125 -0
  44. package/src/repos/mappers.ts +171 -0
  45. package/src/repos/message-flag.ts +162 -0
  46. package/src/repos/message-label.test.ts +110 -0
  47. package/src/repos/message-label.ts +96 -0
  48. package/src/repos/message.sqlite.test.ts +118 -0
  49. package/src/repos/message.test.ts +488 -0
  50. package/src/repos/message.ts +558 -0
  51. package/src/repos/serialized-writes.sqlite.test.ts +199 -0
  52. package/src/repos/test-helpers.ts +222 -0
  53. package/src/repos/thread-message.sqlite.test.ts +198 -0
  54. package/src/repos/thread-message.test.ts +744 -0
  55. package/src/repos/thread-message.ts +832 -0
  56. package/src/repos/thread-search-predicates.ts +79 -0
  57. package/src/repos/unit-of-work.sqlite.test.ts +138 -0
  58. package/src/repos/unit-of-work.test.ts +105 -0
  59. package/src/repos/unit-of-work.ts +31 -0
  60. package/src/schema/active-entities.ts +19 -0
  61. package/src/schema/i4-account-config.ts +4 -0
  62. package/src/schema/i4-account-export-request.ts +3 -0
  63. package/src/schema/i4-account-setting.ts +3 -0
  64. package/src/schema/i4-address.ts +3 -0
  65. package/src/schema/i4-mailbox-lock.ts +3 -0
  66. package/src/schema/i4-mailbox.ts +4 -0
  67. package/src/schema/i4-message-flag-push.ts +3 -0
  68. package/src/schema/i4-message-placement-move.ts +3 -0
  69. package/src/schema/i4-organize-job-request.ts +1 -0
  70. package/src/schema/i4-outbox-message.ts +3 -0
  71. package/src/schema/message-data.ts +44 -0
  72. package/src/schema/outbox.ts +74 -0
  73. package/src/schema/thread-message.ts +3 -0
  74. package/src/schema-full-sqlite.ts +11 -0
  75. package/src/schema-full.ts +16 -0
  76. package/src/schema.ts +28 -0
  77. package/src/sqlite-client.ts +52 -0
  78. package/src/test-db-sqlite.ts +75 -0
  79. package/src/test-db.ts +76 -0
  80. package/src/tx.ts +208 -0
  81. package/src/vps-migrations-drift.test.ts +34 -0
  82. package/tsconfig.json +8 -0
@@ -0,0 +1,253 @@
1
+ import type {
2
+ CreateFilterInput,
3
+ FilterItem,
4
+ IFilterRepository,
5
+ ResultList,
6
+ UpdateFilterInput,
7
+ } from "@remit/data-ports";
8
+ import { FilterMatchOperator, FilterState } from "@remit/domain-enums";
9
+ import { and, asc, eq, gt, or } from "drizzle-orm";
10
+ import type { NodePgDatabase } from "drizzle-orm/node-postgres";
11
+ import { NotFoundError } from "../error.js";
12
+ import { randomId } from "../id.js";
13
+ import { decodeToken, resultList } from "../pagination.js";
14
+ import { filterTable } from "../schema.js";
15
+
16
+ type DB = NodePgDatabase<Record<string, unknown>>;
17
+
18
+ const PREDICATE_OR_ACTION_FIELDS = [
19
+ "hasAnchor",
20
+ "matchOperator",
21
+ "literalClauses",
22
+ "actionLabelId",
23
+ "actionMailboxId",
24
+ ] as const satisfies readonly (keyof UpdateFilterInput)[];
25
+
26
+ const nowSeconds = (): number => Math.floor(Date.now() / 1000);
27
+
28
+ /**
29
+ * Whether `input` touches the predicate or the action (RFC 034 Decision
30
+ * 3.2) — a plain rename (`name` only) must not bump `ruleChangedAt`.
31
+ */
32
+ const changesPredicateOrAction = (input: UpdateFilterInput): boolean =>
33
+ PREDICATE_OR_ACTION_FIELDS.some((field) => field in input);
34
+
35
+ function rowToFilter(row: typeof filterTable.$inferSelect): FilterItem {
36
+ return {
37
+ filterId: row.filterId,
38
+ accountConfigId: row.accountConfigId,
39
+ name: row.name,
40
+ scope: row.scope,
41
+ expiresAt: row.expiresAt ?? undefined,
42
+ ttl: row.ttl ?? undefined,
43
+ state: row.state,
44
+ hasAnchor: row.hasAnchor,
45
+ ruleChangedAt: row.ruleChangedAt,
46
+ matchOperator: row.matchOperator,
47
+ literalClauses: row.literalClauses as FilterItem["literalClauses"],
48
+ actionLabelId: row.actionLabelId,
49
+ actionMailboxId: row.actionMailboxId,
50
+ createdAt: row.createdAt,
51
+ updatedAt: row.updatedAt,
52
+ };
53
+ }
54
+
55
+ export class FilterRepo implements IFilterRepository {
56
+ constructor(private db: DB) {}
57
+
58
+ /**
59
+ * `ruleChangedAt` is set to the same instant as creation: a new filter's
60
+ * predicate/action is, by definition, freshly asserted (RFC 034 Decision
61
+ * 3.2).
62
+ */
63
+ async create(input: CreateFilterInput): Promise<FilterItem> {
64
+ const now = Date.now();
65
+ const [row] = await this.db
66
+ .insert(filterTable)
67
+ .values({
68
+ filterId: randomId(),
69
+ accountConfigId: input.accountConfigId,
70
+ name: input.name,
71
+ scope: input.scope,
72
+ expiresAt: input.expiresAt ?? null,
73
+ ttl: input.ttl ?? null,
74
+ state: input.state ?? FilterState.Active,
75
+ hasAnchor: input.hasAnchor ?? false,
76
+ ruleChangedAt: nowSeconds(),
77
+ matchOperator: input.matchOperator ?? FilterMatchOperator.And,
78
+ literalClauses: input.literalClauses ?? [],
79
+ actionLabelId: input.actionLabelId ?? "None",
80
+ actionMailboxId: input.actionMailboxId ?? "None",
81
+ createdAt: now,
82
+ updatedAt: now,
83
+ })
84
+ .returning();
85
+ return rowToFilter(row);
86
+ }
87
+
88
+ async get(accountConfigId: string, filterId: string): Promise<FilterItem> {
89
+ const [row] = await this.db
90
+ .select()
91
+ .from(filterTable)
92
+ .where(
93
+ and(
94
+ eq(filterTable.accountConfigId, accountConfigId),
95
+ eq(filterTable.filterId, filterId),
96
+ ),
97
+ );
98
+ if (!row) {
99
+ throw new NotFoundError(`Filter not found: ${filterId}`);
100
+ }
101
+ return rowToFilter(row);
102
+ }
103
+
104
+ /**
105
+ * `ruleChangedAt` only advances when `input` touches the predicate or the
106
+ * action — never on a cosmetic `name` edit (RFC 034 Decision 3.2).
107
+ */
108
+ async update(
109
+ accountConfigId: string,
110
+ filterId: string,
111
+ input: UpdateFilterInput,
112
+ ): Promise<FilterItem> {
113
+ const patch = changesPredicateOrAction(input)
114
+ ? { ...input, ruleChangedAt: nowSeconds() }
115
+ : input;
116
+ const [row] = await this.db
117
+ .update(filterTable)
118
+ .set({ ...patch, updatedAt: Date.now() })
119
+ .where(
120
+ and(
121
+ eq(filterTable.accountConfigId, accountConfigId),
122
+ eq(filterTable.filterId, filterId),
123
+ ),
124
+ )
125
+ .returning();
126
+ if (!row) {
127
+ throw new NotFoundError(`Filter not found: ${filterId}`);
128
+ }
129
+ return rowToFilter(row);
130
+ }
131
+
132
+ async delete(accountConfigId: string, filterId: string): Promise<void> {
133
+ await this.db
134
+ .delete(filterTable)
135
+ .where(
136
+ and(
137
+ eq(filterTable.accountConfigId, accountConfigId),
138
+ eq(filterTable.filterId, filterId),
139
+ ),
140
+ );
141
+ }
142
+
143
+ async listByAccountConfig(accountConfigId: string): Promise<FilterItem[]> {
144
+ const rows = await this.db
145
+ .select()
146
+ .from(filterTable)
147
+ .where(eq(filterTable.accountConfigId, accountConfigId));
148
+ return rows.map(rowToFilter);
149
+ }
150
+
151
+ /**
152
+ * A single signed page of an account config's filters (RFC 034), mirroring
153
+ * `MailboxRepo.listByAccount`: a `(createdAt, filterId)` keyset cursor that
154
+ * round-trips through `continuationToken`.
155
+ */
156
+ async listPageByAccountConfig(
157
+ accountConfigId: string,
158
+ options?: { limit?: number; continuationToken?: string },
159
+ ): Promise<ResultList<FilterItem>> {
160
+ const limit = options?.limit ?? 100;
161
+ const cursor = options?.continuationToken
162
+ ? decodeToken(options.continuationToken)
163
+ : undefined;
164
+ const after = cursor
165
+ ? {
166
+ createdAt: cursor.createdAt as number,
167
+ filterId: cursor.filterId as string,
168
+ }
169
+ : undefined;
170
+
171
+ const rows = await this.db
172
+ .select()
173
+ .from(filterTable)
174
+ .where(
175
+ and(
176
+ eq(filterTable.accountConfigId, accountConfigId),
177
+ after
178
+ ? or(
179
+ gt(filterTable.createdAt, after.createdAt),
180
+ and(
181
+ eq(filterTable.createdAt, after.createdAt),
182
+ gt(filterTable.filterId, after.filterId),
183
+ ),
184
+ )
185
+ : undefined,
186
+ ),
187
+ )
188
+ .orderBy(asc(filterTable.createdAt), asc(filterTable.filterId))
189
+ .limit(limit + 1);
190
+
191
+ const hasMore = rows.length > limit;
192
+ const items = rows.slice(0, limit).map(rowToFilter);
193
+ const lastItem = items[items.length - 1];
194
+ return resultList(
195
+ items,
196
+ limit,
197
+ hasMore && lastItem
198
+ ? { createdAt: lastItem.createdAt, filterId: lastItem.filterId }
199
+ : undefined,
200
+ );
201
+ }
202
+
203
+ /**
204
+ * Lists filters via `byAccountAndState` (RFC 034 Decision 1.2) — the
205
+ * index-time worker's "what do I evaluate for this account" query.
206
+ */
207
+ async listByAccountAndState(
208
+ accountConfigId: string,
209
+ state: FilterItem["state"],
210
+ ): Promise<FilterItem[]> {
211
+ const rows = await this.db
212
+ .select()
213
+ .from(filterTable)
214
+ .where(
215
+ and(
216
+ eq(filterTable.accountConfigId, accountConfigId),
217
+ eq(filterTable.state, state),
218
+ ),
219
+ );
220
+ return rows.map(rowToFilter);
221
+ }
222
+
223
+ /**
224
+ * Patches a Temporary filter's `state` to `Expired` when read past its
225
+ * `expiresAt` (RFC 034 Decision 1.2). `expiresAt`/`now` are compared
226
+ * directly — `state` is only ever a lazily-refreshed cache of that
227
+ * comparison. A no-op for a Standing filter (no `expiresAt`) or one already
228
+ * Expired.
229
+ *
230
+ * Postgres has no TTL reaper, so an Expired Temporary row is never deleted by
231
+ * a background sweep the way DynamoDB reaps it via the `ttl` attribute. That
232
+ * is correct by design: RFC 034 Decision 1.1 makes match-time correctness
233
+ * depend on the `expiresAt`/`now` comparison, never on the row's existence —
234
+ * the reaper is housekeeping, not a correctness mechanism.
235
+ */
236
+ async refreshExpiry(item: FilterItem): Promise<FilterItem> {
237
+ if (item.scope !== "Temporary" || !item.expiresAt) return item;
238
+ if (item.state === FilterState.Expired) return item;
239
+ if (new Date(item.expiresAt).getTime() > Date.now()) return item;
240
+
241
+ const [row] = await this.db
242
+ .update(filterTable)
243
+ .set({ state: FilterState.Expired, updatedAt: Date.now() })
244
+ .where(
245
+ and(
246
+ eq(filterTable.accountConfigId, item.accountConfigId),
247
+ eq(filterTable.filterId, item.filterId),
248
+ ),
249
+ )
250
+ .returning();
251
+ return row ? rowToFilter(row) : item;
252
+ }
253
+ }
@@ -0,0 +1,105 @@
1
+ import assert from "node:assert";
2
+ import { after, before, describe, test } from "node:test";
3
+ import { createTestDb, randomId, type TestDb } from "../test-db.js";
4
+ import { AccountRepo } from "./i4-account.js";
5
+ import { AccountConfigRepo } from "./i4-account-config.js";
6
+ import { AddressRepo } from "./i4-address.js";
7
+
8
+ describe("AccountConfigRepo", () => {
9
+ let db: TestDb;
10
+ let close: () => Promise<void>;
11
+ let repo: AccountConfigRepo;
12
+
13
+ before(async () => {
14
+ ({ db, close } = await createTestDb());
15
+ repo = new AccountConfigRepo(db as never);
16
+ });
17
+
18
+ after(async () => {
19
+ await close();
20
+ });
21
+
22
+ test("create and get", async () => {
23
+ const userId = randomId();
24
+ const cfg = await repo.create({ userId, name: "My Config" });
25
+
26
+ assert.ok(cfg.accountConfigId);
27
+ assert.equal(cfg.userId, userId);
28
+ assert.equal(cfg.name, "My Config");
29
+ assert.equal(cfg.state, "active");
30
+
31
+ const fetched = await repo.get(cfg.accountConfigId);
32
+ assert.equal(fetched.accountConfigId, cfg.accountConfigId);
33
+
34
+ await repo.delete(cfg.accountConfigId);
35
+ });
36
+
37
+ test("batchGet: WHERE id = ANY($1)", async () => {
38
+ const userId = randomId();
39
+ const c1 = await repo.create({ userId });
40
+ const c2 = await repo.create({ userId });
41
+
42
+ const results = await repo.get([c1.accountConfigId, c2.accountConfigId]);
43
+ assert.equal(results.length, 2);
44
+
45
+ await repo.deleteMany([c1.accountConfigId, c2.accountConfigId]);
46
+ });
47
+
48
+ test("batchGet empty array returns []", async () => {
49
+ const results = await repo.get([]);
50
+ assert.deepEqual(results, []);
51
+ });
52
+
53
+ test("get throws for missing config", async () => {
54
+ await assert.rejects(repo.get(randomId()), /not found/i);
55
+ });
56
+
57
+ test("describe assembles collection (accountConfig + account + address)", async () => {
58
+ const userId = randomId();
59
+ const cfg = await repo.create({ userId });
60
+
61
+ const accountRepo = new AccountRepo(db as never);
62
+ const addrRepo = new AddressRepo(db as never);
63
+
64
+ await accountRepo.create({
65
+ accountConfigId: cfg.accountConfigId,
66
+ username: "u",
67
+ email: "u@test.com",
68
+ isActive: true,
69
+ imapHost: "imap.test.com",
70
+ imapPort: 993,
71
+ imapTls: true,
72
+ imapStartTls: false,
73
+ connectionState: "not_authenticated",
74
+ });
75
+
76
+ await addrRepo.createAddress({
77
+ addressId: randomId(),
78
+ accountConfigId: cfg.accountConfigId,
79
+ localPart: "u",
80
+ domain: "test.com",
81
+ normalizedEmail: "u@test.com",
82
+ normalizedCompound: "u@test.com:u",
83
+ });
84
+
85
+ const desc = await repo.describe(cfg.accountConfigId);
86
+ assert.equal(desc.accountConfig.length, 1);
87
+ assert.equal(desc.account.length, 1);
88
+ assert.equal(desc.address.length, 1);
89
+
90
+ await repo.delete(cfg.accountConfigId);
91
+ });
92
+
93
+ test("listAll returns all configs", async () => {
94
+ const userId = randomId();
95
+ const c1 = await repo.create({ userId });
96
+ const c2 = await repo.create({ userId });
97
+
98
+ const all = await repo.listAll();
99
+ const ids = new Set(all.map((c) => c.accountConfigId));
100
+ assert.ok(ids.has(c1.accountConfigId));
101
+ assert.ok(ids.has(c2.accountConfigId));
102
+
103
+ await repo.deleteMany([c1.accountConfigId, c2.accountConfigId]);
104
+ });
105
+ });
@@ -0,0 +1,257 @@
1
+ import type {
2
+ AccountConfigDescription,
3
+ AccountConfigItem,
4
+ AccountItem,
5
+ AddressItem,
6
+ CreateAccountConfigInput,
7
+ IAccountConfigRepository,
8
+ ResultList,
9
+ UpdateAccountConfigInput,
10
+ } from "@remit/data-ports";
11
+ import { and, asc, eq, gt, inArray, or } from "drizzle-orm";
12
+ import type { NodePgDatabase } from "drizzle-orm/node-postgres";
13
+ import { NotFoundError } from "../error.js";
14
+ import { randomId } from "../id.js";
15
+ import { decodeToken, resultList } from "../pagination.js";
16
+ import {
17
+ accountConfigTable,
18
+ accountTable,
19
+ } from "../schema/i4-account-config.js";
20
+ import { addressTable } from "../schema/i4-address.js";
21
+
22
+ type DB = NodePgDatabase<Record<string, unknown>>;
23
+
24
+ function rowToAccountConfig(
25
+ row: typeof accountConfigTable.$inferSelect,
26
+ ): AccountConfigItem {
27
+ return {
28
+ accountConfigId: row.accountConfigId,
29
+ userId: row.userId,
30
+ name: row.name ?? undefined,
31
+ state: row.state as AccountConfigItem["state"],
32
+ deletedAt: row.deletedAt ?? undefined,
33
+ cascadeStartedAt: row.cascadeStartedAt ?? undefined,
34
+ createdAt: row.createdAt,
35
+ updatedAt: row.updatedAt,
36
+ };
37
+ }
38
+
39
+ function rowToAccount(row: typeof accountTable.$inferSelect): AccountItem {
40
+ return {
41
+ accountId: row.accountId,
42
+ accountConfigId: row.accountConfigId,
43
+ username: row.username,
44
+ email: row.email,
45
+ authType: row.authType as AccountItem["authType"],
46
+ passwordHash: row.passwordHash ?? undefined,
47
+ oauthRefreshTokenHash: row.oauthRefreshTokenHash ?? undefined,
48
+ oauthTokenUpdatedAt: row.oauthTokenUpdatedAt ?? undefined,
49
+ imapHost: row.imapHost,
50
+ imapPort: row.imapPort,
51
+ imapTls: row.imapTls,
52
+ imapStartTls: row.imapStartTls,
53
+ smtpEnabled: row.smtpEnabled,
54
+ smtpHost: row.smtpHost,
55
+ smtpPort: row.smtpPort,
56
+ smtpTls: row.smtpTls,
57
+ smtpStartTls: row.smtpStartTls,
58
+ smtpUsername: row.smtpUsername,
59
+ smtpPasswordHash: row.smtpPasswordHash ?? undefined,
60
+ isActive: row.isActive,
61
+ connectionState: row.connectionState as AccountItem["connectionState"],
62
+ lastConnectedAt: row.lastConnectedAt ?? undefined,
63
+ lastSyncAt: row.lastSyncAt ?? undefined,
64
+ lastError: row.lastError ?? undefined,
65
+ syncPhase: (row.syncPhase as AccountItem["syncPhase"]) ?? undefined,
66
+ mailboxCountTotal: row.mailboxCountTotal ?? undefined,
67
+ mailboxCountSynced: row.mailboxCountSynced ?? undefined,
68
+ createdAt: row.createdAt,
69
+ updatedAt: row.updatedAt,
70
+ deletedAt: row.deletedAt ?? undefined,
71
+ };
72
+ }
73
+
74
+ function rowToAddress(row: typeof addressTable.$inferSelect): AddressItem {
75
+ return {
76
+ addressId: row.addressId,
77
+ accountConfigId: row.accountConfigId,
78
+ displayName: row.displayName ?? undefined,
79
+ localPart: row.localPart,
80
+ domain: row.domain,
81
+ normalizedEmail: row.normalizedEmail,
82
+ normalizedCompound: row.normalizedCompound,
83
+ flags: (row.flags ?? {}) as AddressItem["flags"],
84
+ inboundCount: row.inboundCount,
85
+ outboundCount: row.outboundCount,
86
+ replyCount: row.replyCount,
87
+ lastInboundAt: row.lastInboundAt,
88
+ lastOutboundAt: row.lastOutboundAt ?? undefined,
89
+ lastReplyAt: row.lastReplyAt,
90
+ createdAt: row.createdAt,
91
+ updatedAt: row.updatedAt,
92
+ };
93
+ }
94
+
95
+ export class AccountConfigRepo implements IAccountConfigRepository {
96
+ constructor(private db: DB) {}
97
+
98
+ async create(input: CreateAccountConfigInput): Promise<AccountConfigItem> {
99
+ const now = Date.now();
100
+ const accountConfigId = input.accountConfigId ?? randomId();
101
+ const [row] = await this.db
102
+ .insert(accountConfigTable)
103
+ .values({
104
+ accountConfigId,
105
+ userId: input.userId,
106
+ name: input.name,
107
+ state: input.state ?? "active",
108
+ deletedAt: input.deletedAt,
109
+ cascadeStartedAt: input.cascadeStartedAt,
110
+ createdAt: now,
111
+ updatedAt: now,
112
+ })
113
+ .returning();
114
+ return rowToAccountConfig(row);
115
+ }
116
+
117
+ async get(accountConfigId: string): Promise<AccountConfigItem>;
118
+ async get(accountConfigIds: string[]): Promise<AccountConfigItem[]>;
119
+ async get(
120
+ accountConfigId: string | string[],
121
+ ): Promise<AccountConfigItem | AccountConfigItem[]> {
122
+ if (Array.isArray(accountConfigId)) {
123
+ if (accountConfigId.length === 0) return [];
124
+ const rows = await this.db
125
+ .select()
126
+ .from(accountConfigTable)
127
+ .where(inArray(accountConfigTable.accountConfigId, accountConfigId));
128
+ return rows.map(rowToAccountConfig);
129
+ }
130
+ const [row] = await this.db
131
+ .select()
132
+ .from(accountConfigTable)
133
+ .where(eq(accountConfigTable.accountConfigId, accountConfigId));
134
+ if (!row)
135
+ throw new NotFoundError(`AccountConfig not found: ${accountConfigId}`);
136
+ return rowToAccountConfig(row);
137
+ }
138
+
139
+ async update(
140
+ accountConfigId: string,
141
+ input: UpdateAccountConfigInput,
142
+ ): Promise<AccountConfigItem> {
143
+ const now = Date.now();
144
+ const [row] = await this.db
145
+ .update(accountConfigTable)
146
+ .set({
147
+ ...(input.name !== undefined && { name: input.name }),
148
+ ...(input.state !== undefined && { state: input.state }),
149
+ ...(input.deletedAt !== undefined && { deletedAt: input.deletedAt }),
150
+ ...(input.cascadeStartedAt !== undefined && {
151
+ cascadeStartedAt: input.cascadeStartedAt,
152
+ }),
153
+ updatedAt: now,
154
+ })
155
+ .where(eq(accountConfigTable.accountConfigId, accountConfigId))
156
+ .returning();
157
+ if (!row)
158
+ throw new NotFoundError(`AccountConfig not found: ${accountConfigId}`);
159
+ return rowToAccountConfig(row);
160
+ }
161
+
162
+ async delete(accountConfigId: string): Promise<void> {
163
+ await this.db
164
+ .delete(accountConfigTable)
165
+ .where(eq(accountConfigTable.accountConfigId, accountConfigId));
166
+ }
167
+
168
+ async deleteMany(accountConfigIds: string[]): Promise<void> {
169
+ if (accountConfigIds.length === 0) return;
170
+ await this.db
171
+ .delete(accountConfigTable)
172
+ .where(inArray(accountConfigTable.accountConfigId, accountConfigIds));
173
+ }
174
+
175
+ async listByUser(
176
+ userId: string,
177
+ options?: { limit?: number; continuationToken?: string },
178
+ ): Promise<ResultList<AccountConfigItem>> {
179
+ const limit = options?.limit ?? 100;
180
+ const cursor = options?.continuationToken
181
+ ? decodeToken(options.continuationToken)
182
+ : undefined;
183
+ const after = cursor
184
+ ? {
185
+ createdAt: cursor.createdAt as number,
186
+ accountConfigId: cursor.accountConfigId as string,
187
+ }
188
+ : undefined;
189
+
190
+ const rows = await this.db
191
+ .select()
192
+ .from(accountConfigTable)
193
+ .where(
194
+ and(
195
+ eq(accountConfigTable.userId, userId),
196
+ after
197
+ ? or(
198
+ gt(accountConfigTable.createdAt, after.createdAt),
199
+ and(
200
+ eq(accountConfigTable.createdAt, after.createdAt),
201
+ gt(accountConfigTable.accountConfigId, after.accountConfigId),
202
+ ),
203
+ )
204
+ : undefined,
205
+ ),
206
+ )
207
+ .orderBy(
208
+ asc(accountConfigTable.createdAt),
209
+ asc(accountConfigTable.accountConfigId),
210
+ )
211
+ .limit(limit + 1);
212
+
213
+ const hasMore = rows.length > limit;
214
+ const items = rows.slice(0, limit).map(rowToAccountConfig);
215
+ const lastItem = items[items.length - 1];
216
+ return resultList(
217
+ items,
218
+ limit,
219
+ hasMore && lastItem
220
+ ? {
221
+ createdAt: lastItem.createdAt,
222
+ accountConfigId: lastItem.accountConfigId,
223
+ }
224
+ : undefined,
225
+ );
226
+ }
227
+
228
+ async describe(accountConfigId: string): Promise<AccountConfigDescription> {
229
+ const [configs, accounts, addresses] = await Promise.all([
230
+ this.db
231
+ .select()
232
+ .from(accountConfigTable)
233
+ .where(eq(accountConfigTable.accountConfigId, accountConfigId)),
234
+ this.db
235
+ .select()
236
+ .from(accountTable)
237
+ .where(eq(accountTable.accountConfigId, accountConfigId)),
238
+ this.db
239
+ .select()
240
+ .from(addressTable)
241
+ .where(eq(addressTable.accountConfigId, accountConfigId)),
242
+ ]);
243
+ if (configs.length === 0) {
244
+ throw new NotFoundError(`AccountConfig not found: ${accountConfigId}`);
245
+ }
246
+ return {
247
+ accountConfig: configs.map(rowToAccountConfig),
248
+ account: accounts.map(rowToAccount),
249
+ address: addresses.map(rowToAddress),
250
+ };
251
+ }
252
+
253
+ async listAll(): Promise<AccountConfigItem[]> {
254
+ const rows = await this.db.select().from(accountConfigTable);
255
+ return rows.map(rowToAccountConfig);
256
+ }
257
+ }