@manablox/db 0.2.0 → 0.3.0

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 (56) hide show
  1. package/dist/index-Cyf_N5K3.d.ts +658 -0
  2. package/dist/index-rZ24t-Ln.d.ts +4338 -0
  3. package/dist/index.d.ts +123 -0
  4. package/dist/index.js +60 -0
  5. package/dist/repositories-DYjzuuF6.js +1533 -0
  6. package/dist/rolldown-runtime-D7D4PA-g.js +13 -0
  7. package/dist/schema-Bb4p16Yz.js +539 -0
  8. package/dist/schema.d.ts +2 -0
  9. package/dist/schema.js +2 -0
  10. package/dist/testing.d.ts +77 -0
  11. package/dist/testing.js +217 -0
  12. package/package.json +18 -10
  13. package/drizzle.config.ts +0 -11
  14. package/src/bootstrap.ts +0 -13
  15. package/src/cli/create-db.ts +0 -30
  16. package/src/cli/migrate.ts +0 -17
  17. package/src/client.ts +0 -44
  18. package/src/columns.ts +0 -39
  19. package/src/errors.ts +0 -50
  20. package/src/index.ts +0 -19
  21. package/src/migrate.ts +0 -21
  22. package/src/pagination.ts +0 -52
  23. package/src/query.ts +0 -213
  24. package/src/repositories/asset-usage.ts +0 -166
  25. package/src/repositories/asset.ts +0 -181
  26. package/src/repositories/content-type.ts +0 -116
  27. package/src/repositories/content.ts +0 -811
  28. package/src/repositories/index.ts +0 -40
  29. package/src/repositories/menu.ts +0 -235
  30. package/src/repositories/role.ts +0 -85
  31. package/src/repositories/space.ts +0 -83
  32. package/src/repositories/user.ts +0 -280
  33. package/src/repositories/webhook.ts +0 -46
  34. package/src/repositories/workflow.ts +0 -306
  35. package/src/schema/assets.ts +0 -108
  36. package/src/schema/auth.ts +0 -166
  37. package/src/schema/content-types.ts +0 -31
  38. package/src/schema/content.ts +0 -133
  39. package/src/schema/index.ts +0 -38
  40. package/src/schema/menus.ts +0 -61
  41. package/src/schema/relations.ts +0 -64
  42. package/src/schema/spaces.ts +0 -20
  43. package/src/schema/webhooks.ts +0 -46
  44. package/src/schema/workflows.ts +0 -92
  45. package/src/testing-fixtures.ts +0 -139
  46. package/src/testing.ts +0 -105
  47. package/test/asset-usage.test.ts +0 -101
  48. package/test/menu.test.ts +0 -126
  49. package/test/publish.test.ts +0 -130
  50. package/test/query.test.ts +0 -170
  51. package/test/role.test.ts +0 -81
  52. package/test/tree.test.ts +0 -188
  53. package/test/user.test.ts +0 -126
  54. package/test/webhook.test.ts +0 -48
  55. package/tsconfig.json +0 -4
  56. package/vitest.config.ts +0 -10
@@ -1,280 +0,0 @@
1
- import { ManabloxError } from '@manablox/core';
2
- import { and, desc, eq, inArray, sql } from 'drizzle-orm';
3
- import type { Database } from '../client.js';
4
- import { type Paginated, paginate } from '../pagination.js';
5
- import type { Pagination } from '../query.js';
6
- import {
7
- accounts,
8
- type MembershipRow,
9
- memberships,
10
- roles,
11
- type SpaceRow,
12
- sessions,
13
- spaces,
14
- type UserRow,
15
- users,
16
- } from '../schema/index.js';
17
-
18
- /** The name of a role in a space: one of the built-in five, or a row in `roles`. */
19
- export type SpaceRole = string;
20
-
21
- /** What it takes to create an account that can sign in with a password. */
22
- export interface UserCreateData {
23
- name: string;
24
- email: string;
25
- role: string;
26
- /** Already hashed; the repository never sees a plaintext password. */
27
- passwordHash: string;
28
- }
29
-
30
- export interface UserUpdateData {
31
- name?: string;
32
- email?: string;
33
- }
34
-
35
- /**
36
- * How better-auth 1.7 identifies an email + password credential: sign-in looks for an
37
- * account with this provider *and* this issuer, so a row missing either is invisible to
38
- * it and the account can never sign in.
39
- */
40
- const CREDENTIAL_PROVIDER = 'credential';
41
- const CREDENTIAL_ISSUER = 'local:credential';
42
-
43
- export class UserRepository {
44
- constructor(private readonly db: Database) {}
45
-
46
- async findById(id: string): Promise<UserRow | null> {
47
- const rows = await this.db.select().from(users).where(eq(users.id, id)).limit(1);
48
- return rows[0] ?? null;
49
- }
50
-
51
- async findManyByIds(ids: string[]): Promise<UserRow[]> {
52
- if (ids.length === 0) return [];
53
- return this.db.select().from(users).where(inArray(users.id, ids));
54
- }
55
-
56
- async findByEmail(email: string): Promise<UserRow | null> {
57
- const rows = await this.db.select().from(users).where(eq(users.email, email)).limit(1);
58
- return rows[0] ?? null;
59
- }
60
-
61
- async list(pagination: Pagination, search?: string): Promise<Paginated<UserRow>> {
62
- const where = search
63
- ? sql`${users.email} ilike ${`%${search}%`} or ${users.name} ilike ${`%${search}%`}`
64
- : undefined;
65
-
66
- return paginate(this.db, users, { where, orderBy: desc(users.createdAt), pagination });
67
- }
68
-
69
- /**
70
- * Users who are not members of a space, matching a search, newest first: the
71
- * add-member picker's candidates, decided in SQL rather than by loading a page of
72
- * users and filtering it here.
73
- */
74
- async candidates(spaceId: string, search: string | undefined, limit: number): Promise<UserRow[]> {
75
- const notMember = sql`not exists (select 1 from ${memberships} where ${memberships.userId} = ${users.id} and ${memberships.spaceId} = ${spaceId})`;
76
- const where = search
77
- ? and(
78
- notMember,
79
- sql`(${users.email} ilike ${`%${search}%`} or ${users.name} ilike ${`%${search}%`})`,
80
- )
81
- : notMember;
82
- return this.db.select().from(users).where(where).orderBy(desc(users.createdAt)).limit(limit);
83
- }
84
-
85
- async count(): Promise<number> {
86
- const rows = await this.db.select({ count: sql<number>`count(*)::int` }).from(users);
87
- return rows[0]?.count ?? 0;
88
- }
89
-
90
- /**
91
- * Inserts the user and its password credential together, so a failure on the second
92
- * row cannot leave an account nobody can sign in to. The account row is shaped the way
93
- * better-auth writes it on sign-up, so a sign-in later finds it as its own.
94
- */
95
- async create(data: UserCreateData): Promise<UserRow> {
96
- return this.db.transaction(async (tx) => {
97
- const [user] = await tx
98
- .insert(users)
99
- .values({ name: data.name, email: data.email, role: data.role })
100
- .returning();
101
- if (!user) throw new ManabloxError('user.create.failed');
102
- await tx.insert(accounts).values({
103
- userId: user.id,
104
- accountId: user.id,
105
- providerId: CREDENTIAL_PROVIDER,
106
- issuer: CREDENTIAL_ISSUER,
107
- password: data.passwordHash,
108
- });
109
- return user;
110
- });
111
- }
112
-
113
- async update(id: string, data: UserUpdateData): Promise<UserRow> {
114
- const [row] = await this.db
115
- .update(users)
116
- .set({ ...data, updatedAt: new Date() })
117
- .where(eq(users.id, id))
118
- .returning();
119
- if (!row) throw ManabloxError.notFound('user.notFound', { id });
120
- return row;
121
- }
122
-
123
- async delete(id: string): Promise<void> {
124
- // Sessions, accounts, api keys and memberships cascade in the schema.
125
- await this.db.delete(users).where(eq(users.id, id));
126
- }
127
-
128
- async setBanned(id: string, banned: boolean, reason: string | null): Promise<UserRow> {
129
- const [row] = await this.db
130
- .update(users)
131
- .set({ banned, banReason: banned ? reason : null, updatedAt: new Date() })
132
- .where(eq(users.id, id))
133
- .returning();
134
- if (!row) throw ManabloxError.notFound('user.notFound', { id });
135
- return row;
136
- }
137
-
138
- /**
139
- * Replaces the password credential, creating it for an account that only ever signed
140
- * in through another provider.
141
- */
142
- async setPasswordHash(userId: string, passwordHash: string): Promise<void> {
143
- const updated = await this.db
144
- .update(accounts)
145
- .set({ password: passwordHash, updatedAt: new Date() })
146
- .where(
147
- and(
148
- eq(accounts.userId, userId),
149
- eq(accounts.providerId, CREDENTIAL_PROVIDER),
150
- eq(accounts.issuer, CREDENTIAL_ISSUER),
151
- ),
152
- )
153
- .returning({ id: accounts.id });
154
- if (updated.length) return;
155
- await this.db.insert(accounts).values({
156
- userId,
157
- accountId: userId,
158
- providerId: CREDENTIAL_PROVIDER,
159
- issuer: CREDENTIAL_ISSUER,
160
- password: passwordHash,
161
- });
162
- }
163
-
164
- /** Signs the user out everywhere. */
165
- async revokeSessions(userId: string): Promise<void> {
166
- await this.db.delete(sessions).where(eq(sessions.userId, userId));
167
- }
168
-
169
- async countByRole(role: string): Promise<number> {
170
- const rows = await this.db
171
- .select({ count: sql<number>`count(*)::int` })
172
- .from(users)
173
- .where(eq(users.role, role));
174
- return rows[0]?.count ?? 0;
175
- }
176
-
177
- async setRole(id: string, role: string): Promise<UserRow> {
178
- const [row] = await this.db
179
- .update(users)
180
- .set({ role, updatedAt: new Date() })
181
- .where(eq(users.id, id))
182
- .returning();
183
- if (!row) throw ManabloxError.notFound('user.notFound', { id });
184
- return row;
185
- }
186
-
187
- // --- space membership -----------------------------------------------------
188
-
189
- /**
190
- * Authoritative role plus space memberships in one query.
191
- *
192
- * Read on every authenticated request rather than trusting the role embedded in the
193
- * session: better-auth caches the session payload (five minutes by default), so a
194
- * promotion or demotion would otherwise not take effect until that cache expired.
195
- *
196
- * A membership naming a custom role joins that role's grants; one naming a built-in
197
- * role has none here, and the auth package answers those from its own table.
198
- */
199
- async principal(userId: string): Promise<{
200
- role: string;
201
- banned: boolean;
202
- spaces: Record<string, SpaceRole>;
203
- permissions: Record<string, string[]>;
204
- } | null> {
205
- const rows = await this.db
206
- .select({
207
- role: users.role,
208
- banned: users.banned,
209
- spaceId: memberships.spaceId,
210
- spaceRole: memberships.role,
211
- grants: roles.permissions,
212
- })
213
- .from(users)
214
- .leftJoin(memberships, eq(memberships.userId, users.id))
215
- .leftJoin(
216
- roles,
217
- and(eq(roles.spaceId, memberships.spaceId), eq(roles.machineName, memberships.role)),
218
- )
219
- .where(eq(users.id, userId));
220
-
221
- const first = rows[0];
222
- if (!first) return null;
223
-
224
- const spaces: Record<string, SpaceRole> = {};
225
- const permissions: Record<string, string[]> = {};
226
- for (const row of rows) {
227
- if (!row.spaceId || !row.spaceRole) continue;
228
- spaces[row.spaceId] = row.spaceRole;
229
- if (row.grants) permissions[row.spaceId] = row.grants;
230
- }
231
-
232
- return { role: first.role, banned: first.banned, spaces, permissions };
233
- }
234
-
235
- async memberships(userId: string): Promise<MembershipRow[]> {
236
- return this.db.select().from(memberships).where(eq(memberships.userId, userId));
237
- }
238
-
239
- /** The user's memberships with the space each one is in, for a per-user view. */
240
- async membershipsWithSpaces(userId: string): Promise<Array<MembershipRow & { space: SpaceRow }>> {
241
- const rows = await this.db
242
- .select({ membership: memberships, space: spaces })
243
- .from(memberships)
244
- .innerJoin(spaces, eq(spaces.id, memberships.spaceId))
245
- .where(eq(memberships.userId, userId))
246
- .orderBy(spaces.name);
247
- return rows.map((row) => ({ ...row.membership, space: row.space }));
248
- }
249
-
250
- async membersOf(spaceId: string): Promise<Array<MembershipRow & { user: UserRow }>> {
251
- const rows = await this.db
252
- .select({ membership: memberships, user: users })
253
- .from(memberships)
254
- .innerJoin(users, eq(users.id, memberships.userId))
255
- .where(eq(memberships.spaceId, spaceId));
256
- return rows.map((row) => ({ ...row.membership, user: row.user }));
257
- }
258
-
259
- async roleIn(userId: string, spaceId: string): Promise<SpaceRole | null> {
260
- const rows = await this.db
261
- .select({ role: memberships.role })
262
- .from(memberships)
263
- .where(and(eq(memberships.userId, userId), eq(memberships.spaceId, spaceId)))
264
- .limit(1);
265
- return rows[0]?.role ?? null;
266
- }
267
-
268
- async grant(userId: string, spaceId: string, role: SpaceRole): Promise<void> {
269
- await this.db
270
- .insert(memberships)
271
- .values({ userId, spaceId, role })
272
- .onConflictDoUpdate({ target: [memberships.userId, memberships.spaceId], set: { role } });
273
- }
274
-
275
- async revoke(userId: string, spaceId: string): Promise<void> {
276
- await this.db
277
- .delete(memberships)
278
- .where(and(eq(memberships.userId, userId), eq(memberships.spaceId, spaceId)));
279
- }
280
- }
@@ -1,46 +0,0 @@
1
- import { and, eq } from 'drizzle-orm';
2
- import type { Database } from '../client.js';
3
- import { webhookDeliveries, webhooks } from '../schema/index.js';
4
-
5
- export type WebhookRow = typeof webhooks.$inferSelect;
6
- export type WebhookDeliveryRow = typeof webhookDeliveries.$inferSelect;
7
-
8
- export interface WebhookDeliveryData {
9
- webhookId: string;
10
- event: string;
11
- payload: Record<string, unknown>;
12
- status: number | null;
13
- error: string | null;
14
- }
15
-
16
- /** The webhooks of a space and the log of what was sent to them. */
17
- export class WebhookRepository {
18
- constructor(private readonly db: Database) {}
19
-
20
- async findById(id: string): Promise<WebhookRow | null> {
21
- const rows = await this.db.select().from(webhooks).where(eq(webhooks.id, id)).limit(1);
22
- return rows[0] ?? null;
23
- }
24
-
25
- /** The switched-on webhooks of a space, for fanning an event out. */
26
- async findEnabled(spaceId: string): Promise<WebhookRow[]> {
27
- return this.db
28
- .select()
29
- .from(webhooks)
30
- .where(and(eq(webhooks.spaceId, spaceId), eq(webhooks.enabled, true)));
31
- }
32
-
33
- async recordDelivery(data: WebhookDeliveryData): Promise<WebhookDeliveryRow> {
34
- const [row] = await this.db.insert(webhookDeliveries).values(data).returning();
35
- return row as WebhookDeliveryRow;
36
- }
37
-
38
- async deliveries(webhookId: string, limit = 50): Promise<WebhookDeliveryRow[]> {
39
- return this.db
40
- .select()
41
- .from(webhookDeliveries)
42
- .where(eq(webhookDeliveries.webhookId, webhookId))
43
- .orderBy(webhookDeliveries.createdAt)
44
- .limit(limit);
45
- }
46
- }
@@ -1,306 +0,0 @@
1
- import {
2
- type Loose,
3
- ManabloxError,
4
- type WorkflowCursor,
5
- type WorkflowRunContext,
6
- type WorkflowRunStatus,
7
- type WorkflowSelection,
8
- type WorkflowStep,
9
- type WorkflowStepLog,
10
- type WorkflowTrigger,
11
- } from '@manablox/core';
12
- import { and, desc, eq, gte, inArray, isNull, lt, lte, or, sql } from 'drizzle-orm';
13
- import type { Database } from '../client.js';
14
- import {
15
- type ContentRow,
16
- contents,
17
- type PushSubscriptionRow,
18
- pushSubscriptions,
19
- type WorkflowRow,
20
- type WorkflowRunRow,
21
- workflowRuns,
22
- workflows,
23
- } from '../schema/index.js';
24
-
25
- export interface WorkflowWriteData {
26
- id?: string | undefined;
27
- spaceId: string;
28
- name: string;
29
- description?: string | null | undefined;
30
- enabled?: boolean | undefined;
31
- trigger: WorkflowTrigger;
32
- steps: WorkflowStep[];
33
- }
34
-
35
- export interface WorkflowRunCreateData {
36
- workflowId: string;
37
- spaceId: string;
38
- trigger: string;
39
- context: WorkflowRunContext;
40
- }
41
-
42
- /** How many runs a workflow keeps; older ones are pruned as new ones are written. */
43
- export const RUNS_KEPT_PER_WORKFLOW = 200;
44
-
45
- export class WorkflowRepository {
46
- constructor(private readonly db: Database) {}
47
-
48
- // --- workflows -------------------------------------------------------------
49
-
50
- async listBySpace(spaceId: string): Promise<WorkflowRow[]> {
51
- return this.db
52
- .select()
53
- .from(workflows)
54
- .where(eq(workflows.spaceId, spaceId))
55
- .orderBy(workflows.name);
56
- }
57
-
58
- /** Every enabled workflow of a space, for the dispatcher; every enabled one at all for the scheduler. */
59
- async listEnabled(spaceId?: string): Promise<WorkflowRow[]> {
60
- return this.db
61
- .select()
62
- .from(workflows)
63
- .where(
64
- spaceId
65
- ? and(eq(workflows.enabled, true), eq(workflows.spaceId, spaceId))
66
- : eq(workflows.enabled, true),
67
- );
68
- }
69
-
70
- async findById(id: string): Promise<WorkflowRow | null> {
71
- const rows = await this.db.select().from(workflows).where(eq(workflows.id, id)).limit(1);
72
- return rows[0] ?? null;
73
- }
74
-
75
- async create(data: WorkflowWriteData): Promise<WorkflowRow> {
76
- const [row] = await this.db
77
- .insert(workflows)
78
- .values({
79
- ...(data.id ? { id: data.id } : {}),
80
- spaceId: data.spaceId,
81
- name: data.name,
82
- description: data.description ?? null,
83
- enabled: data.enabled ?? false,
84
- trigger: data.trigger,
85
- steps: data.steps,
86
- })
87
- .returning();
88
- if (!row) throw new ManabloxError('workflow.create.failed');
89
- return row;
90
- }
91
-
92
- async update(id: string, data: Loose<Omit<WorkflowWriteData, 'spaceId'>>): Promise<WorkflowRow> {
93
- const [row] = await this.db
94
- .update(workflows)
95
- .set({
96
- ...(data.name !== undefined ? { name: data.name } : {}),
97
- ...(data.description !== undefined ? { description: data.description } : {}),
98
- ...(data.enabled !== undefined ? { enabled: data.enabled } : {}),
99
- ...(data.trigger !== undefined ? { trigger: data.trigger } : {}),
100
- ...(data.steps !== undefined ? { steps: data.steps } : {}),
101
- updatedAt: new Date(),
102
- })
103
- .where(eq(workflows.id, id))
104
- .returning();
105
- if (!row) throw ManabloxError.notFound('workflow.notFound', { id });
106
- return row;
107
- }
108
-
109
- async delete(id: string): Promise<void> {
110
- await this.db.delete(workflows).where(eq(workflows.id, id));
111
- }
112
-
113
- /**
114
- * Claims a scheduled workflow for one minute. Returns false when another process got
115
- * there first — the update matches nothing once `lastScheduledAt` is already `minute`.
116
- */
117
- async claimSchedule(id: string, minute: Date): Promise<boolean> {
118
- const rows = await this.db
119
- .update(workflows)
120
- .set({ lastScheduledAt: minute })
121
- .where(
122
- and(
123
- eq(workflows.id, id),
124
- or(isNull(workflows.lastScheduledAt), lt(workflows.lastScheduledAt, minute)),
125
- ),
126
- )
127
- .returning({ id: workflows.id });
128
- return rows.length > 0;
129
- }
130
-
131
- async touchRun(id: string, at: Date): Promise<void> {
132
- await this.db.update(workflows).set({ lastRunAt: at }).where(eq(workflows.id, id));
133
- }
134
-
135
- // --- runs ------------------------------------------------------------------
136
-
137
- async createRun(data: WorkflowRunCreateData): Promise<WorkflowRunRow> {
138
- const [row] = await this.db
139
- .insert(workflowRuns)
140
- .values({
141
- workflowId: data.workflowId,
142
- spaceId: data.spaceId,
143
- trigger: data.trigger,
144
- context: data.context,
145
- status: 'queued',
146
- })
147
- .returning();
148
- if (!row) throw new ManabloxError('workflow.create.failed');
149
- await this.pruneRuns(data.workflowId);
150
- return row;
151
- }
152
-
153
- async findRun(id: string): Promise<WorkflowRunRow | null> {
154
- const rows = await this.db.select().from(workflowRuns).where(eq(workflowRuns.id, id)).limit(1);
155
- return rows[0] ?? null;
156
- }
157
-
158
- async listRuns(workflowId: string, limit = 50): Promise<WorkflowRunRow[]> {
159
- return this.db
160
- .select()
161
- .from(workflowRuns)
162
- .where(eq(workflowRuns.workflowId, workflowId))
163
- .orderBy(desc(workflowRuns.createdAt))
164
- .limit(limit);
165
- }
166
-
167
- /**
168
- * Moves a run from `queued` or `waiting` to `running`, or reports that it is not
169
- * there to be moved. The status check in the predicate is what keeps two workers off
170
- * the same run.
171
- */
172
- async claimRun(id: string): Promise<WorkflowRunRow | null> {
173
- const rows = await this.db
174
- .update(workflowRuns)
175
- .set({ status: 'running', startedAt: sql`coalesce(${workflowRuns.startedAt}, now())` })
176
- .where(and(eq(workflowRuns.id, id), inArray(workflowRuns.status, ['queued', 'waiting'])))
177
- .returning();
178
- return rows[0] ?? null;
179
- }
180
-
181
- /** Runs paused by a delay step whose time has come. */
182
- async dueRuns(now: Date, limit = 100): Promise<WorkflowRunRow[]> {
183
- return this.db
184
- .select()
185
- .from(workflowRuns)
186
- .where(and(eq(workflowRuns.status, 'waiting'), lte(workflowRuns.resumeAt, now)))
187
- .orderBy(workflowRuns.resumeAt)
188
- .limit(limit);
189
- }
190
-
191
- async saveRunProgress(
192
- id: string,
193
- data: {
194
- status: WorkflowRunStatus;
195
- cursor: WorkflowCursor;
196
- log: WorkflowStepLog[];
197
- error?: string | null | undefined;
198
- resumeAt?: Date | null | undefined;
199
- finished?: boolean | undefined;
200
- },
201
- ): Promise<void> {
202
- await this.db
203
- .update(workflowRuns)
204
- .set({
205
- status: data.status,
206
- cursor: data.cursor,
207
- log: data.log,
208
- error: data.error ?? null,
209
- resumeAt: data.resumeAt ?? null,
210
- ...(data.finished ? { finishedAt: new Date() } : {}),
211
- })
212
- .where(eq(workflowRuns.id, id));
213
- }
214
-
215
- private async pruneRuns(workflowId: string): Promise<void> {
216
- await this.db.execute(sql`
217
- delete from ${workflowRuns}
218
- where ${workflowRuns.workflowId} = ${workflowId}
219
- and ${workflowRuns.id} in (
220
- select id from ${workflowRuns}
221
- where ${workflowRuns.workflowId} = ${workflowId}
222
- order by ${workflowRuns.createdAt} desc
223
- offset ${RUNS_KEPT_PER_WORKFLOW}
224
- )
225
- `);
226
- }
227
-
228
- // --- documents for scheduled runs -----------------------------------------
229
-
230
- /** The documents a scheduled workflow's selection names, newest change first. */
231
- async selectDocuments(
232
- spaceId: string,
233
- selection: WorkflowSelection,
234
- limit = 500,
235
- ): Promise<ContentRow[]> {
236
- const predicates = [eq(contents.spaceId, spaceId)];
237
- if (selection.typeIds.length) predicates.push(inArray(contents.typeId, selection.typeIds));
238
- if (selection.status !== 'any') predicates.push(eq(contents.status, selection.status));
239
- if (selection.locale) predicates.push(eq(contents.locale, selection.locale));
240
- if (selection.changedWithinHours) {
241
- const since = new Date(Date.now() - selection.changedWithinHours * 3_600_000);
242
- predicates.push(gte(contents.updatedAt, since));
243
- }
244
- return this.db
245
- .select()
246
- .from(contents)
247
- .where(and(...predicates))
248
- .orderBy(desc(contents.updatedAt))
249
- .limit(limit);
250
- }
251
-
252
- // --- push subscriptions ----------------------------------------------------
253
-
254
- async subscriptionsFor(userIds: string[]): Promise<PushSubscriptionRow[]> {
255
- if (userIds.length === 0) return [];
256
- return this.db
257
- .select()
258
- .from(pushSubscriptions)
259
- .where(inArray(pushSubscriptions.userId, userIds));
260
- }
261
-
262
- async subscriptionsOf(userId: string): Promise<PushSubscriptionRow[]> {
263
- return this.db
264
- .select()
265
- .from(pushSubscriptions)
266
- .where(eq(pushSubscriptions.userId, userId))
267
- .orderBy(desc(pushSubscriptions.createdAt));
268
- }
269
-
270
- /** Upserts on the endpoint: a browser re-subscribing keeps one row, not two. */
271
- async subscribe(data: {
272
- userId: string;
273
- endpoint: string;
274
- keys: { p256dh: string; auth: string };
275
- userAgent: string | null;
276
- }): Promise<PushSubscriptionRow> {
277
- const [row] = await this.db
278
- .insert(pushSubscriptions)
279
- .values(data)
280
- .onConflictDoUpdate({
281
- target: pushSubscriptions.endpoint,
282
- set: { userId: data.userId, keys: data.keys, userAgent: data.userAgent },
283
- })
284
- .returning();
285
- if (!row) throw new ManabloxError('workflow.create.failed');
286
- return row;
287
- }
288
-
289
- async unsubscribe(userId: string, endpoint: string): Promise<void> {
290
- await this.db
291
- .delete(pushSubscriptions)
292
- .where(and(eq(pushSubscriptions.userId, userId), eq(pushSubscriptions.endpoint, endpoint)));
293
- }
294
-
295
- /** A push service answered 404/410: the browser is gone, and so is the row. */
296
- async dropSubscription(id: string): Promise<void> {
297
- await this.db.delete(pushSubscriptions).where(eq(pushSubscriptions.id, id));
298
- }
299
-
300
- async markSubscriptionUsed(id: string): Promise<void> {
301
- await this.db
302
- .update(pushSubscriptions)
303
- .set({ lastUsedAt: new Date() })
304
- .where(eq(pushSubscriptions.id, id));
305
- }
306
- }