@byline/db-mysql 4.8.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 (75) hide show
  1. package/LICENSE +373 -0
  2. package/README.md +283 -0
  3. package/dist/database/schema/auth.d.ts +951 -0
  4. package/dist/database/schema/auth.js +226 -0
  5. package/dist/database/schema/common.d.ts +168 -0
  6. package/dist/database/schema/common.js +130 -0
  7. package/dist/database/schema/index.d.ts +3419 -0
  8. package/dist/database/schema/index.js +895 -0
  9. package/dist/database/schema/schema-pins.test.node.d.ts +52 -0
  10. package/dist/database/schema/schema-pins.test.node.js +398 -0
  11. package/dist/index.d.ts +79 -0
  12. package/dist/index.js +151 -0
  13. package/dist/lib/boot-check.d.ts +22 -0
  14. package/dist/lib/boot-check.js +42 -0
  15. package/dist/lib/boot-check.test.node.d.ts +8 -0
  16. package/dist/lib/boot-check.test.node.js +46 -0
  17. package/dist/lib/db-manager.d.ts +54 -0
  18. package/dist/lib/db-manager.js +49 -0
  19. package/dist/lib/test-db.d.ts +37 -0
  20. package/dist/lib/test-db.js +111 -0
  21. package/dist/lib/test-helper.d.ts +33 -0
  22. package/dist/lib/test-helper.js +68 -0
  23. package/dist/modules/admin/admin-permissions-repository.d.ts +35 -0
  24. package/dist/modules/admin/admin-permissions-repository.js +101 -0
  25. package/dist/modules/admin/admin-preferences-repository.d.ts +62 -0
  26. package/dist/modules/admin/admin-preferences-repository.js +156 -0
  27. package/dist/modules/admin/admin-roles-repository.d.ts +11 -0
  28. package/dist/modules/admin/admin-roles-repository.js +209 -0
  29. package/dist/modules/admin/admin-store.d.ts +20 -0
  30. package/dist/modules/admin/admin-store.js +30 -0
  31. package/dist/modules/admin/admin-users-repository.d.ts +11 -0
  32. package/dist/modules/admin/admin-users-repository.js +303 -0
  33. package/dist/modules/admin/index.d.ts +27 -0
  34. package/dist/modules/admin/index.js +27 -0
  35. package/dist/modules/admin/refresh-tokens-repository.d.ts +34 -0
  36. package/dist/modules/admin/refresh-tokens-repository.js +160 -0
  37. package/dist/modules/audit/audit-commands.d.ts +29 -0
  38. package/dist/modules/audit/audit-commands.js +40 -0
  39. package/dist/modules/audit/audit-queries.d.ts +41 -0
  40. package/dist/modules/audit/audit-queries.js +169 -0
  41. package/dist/modules/counters/counters-commands.d.ts +54 -0
  42. package/dist/modules/counters/counters-commands.js +121 -0
  43. package/dist/modules/counters/tests/counters-concurrency.test.d.ts +8 -0
  44. package/dist/modules/counters/tests/counters-concurrency.test.js +111 -0
  45. package/dist/modules/storage/classify-error.d.ts +34 -0
  46. package/dist/modules/storage/classify-error.js +50 -0
  47. package/dist/modules/storage/classify-error.test.node.d.ts +8 -0
  48. package/dist/modules/storage/classify-error.test.node.js +89 -0
  49. package/dist/modules/storage/normalize-row.d.ts +55 -0
  50. package/dist/modules/storage/normalize-row.js +135 -0
  51. package/dist/modules/storage/normalize-row.test.node.d.ts +8 -0
  52. package/dist/modules/storage/normalize-row.test.node.js +89 -0
  53. package/dist/modules/storage/storage-commands.d.ts +443 -0
  54. package/dist/modules/storage/storage-commands.js +1263 -0
  55. package/dist/modules/storage/storage-insert.d.ts +21 -0
  56. package/dist/modules/storage/storage-insert.js +152 -0
  57. package/dist/modules/storage/storage-queries.d.ts +805 -0
  58. package/dist/modules/storage/storage-queries.js +1815 -0
  59. package/dist/modules/storage/storage-store-manifest.d.ts +77 -0
  60. package/dist/modules/storage/storage-store-manifest.js +168 -0
  61. package/dist/modules/storage/storage-utils.d.ts +49 -0
  62. package/dist/modules/storage/storage-utils.js +76 -0
  63. package/dist/modules/storage/tests/dialect-pins.integration.test.d.ts +8 -0
  64. package/dist/modules/storage/tests/dialect-pins.integration.test.js +266 -0
  65. package/dist/modules/storage/tests/storage-commands.test.d.ts +8 -0
  66. package/dist/modules/storage/tests/storage-commands.test.js +324 -0
  67. package/dist/modules/storage/tests/storage-document-paths.test.d.ts +8 -0
  68. package/dist/modules/storage/tests/storage-document-paths.test.js +214 -0
  69. package/dist/modules/storage/tests/storage-document-tree.test.d.ts +8 -0
  70. package/dist/modules/storage/tests/storage-document-tree.test.js +361 -0
  71. package/dist/modules/storage/tests/storage-queries.test.d.ts +8 -0
  72. package/dist/modules/storage/tests/storage-queries.test.js +685 -0
  73. package/dist/modules/storage/tests/storage-status-and-lifecycle.test.d.ts +8 -0
  74. package/dist/modules/storage/tests/storage-status-and-lifecycle.test.js +268 -0
  75. package/package.json +91 -0
@@ -0,0 +1,303 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ */
8
+ import { ERR_ADMIN_USER_VERSION_CONFLICT, } from '@byline/admin/admin-users';
9
+ import { and, asc, desc, eq, inArray, like, or, sql } from 'drizzle-orm';
10
+ import { v7 as uuidv7 } from 'uuid';
11
+ import { adminUsers } from '../../database/schema/auth.js';
12
+ import { affectedRowCount } from '../storage/storage-utils.js';
13
+ /**
14
+ * MySQL implementation of `AdminUsersRepository`. Ported from
15
+ * `packages/db-postgres/src/modules/admin/admin-users-repository.ts`.
16
+ *
17
+ * The DB column for the password hash is `password`; the public interface
18
+ * exposes it as `password_hash`. The mapping happens entirely inside this
19
+ * factory — callers speak the interface shape and never see the column
20
+ * name.
21
+ *
22
+ * Password hashing is *not* done here — the interface takes a pre-hashed
23
+ * PHC string. Callers (seed, admin-user commands) hash first via
24
+ * `hashPassword` from `@byline/admin/auth`.
25
+ *
26
+ * Divergences from the pg source (found live, not assumed):
27
+ * - `ilike` → `like`. `byline_admin_users` (like every table in this
28
+ * schema) uses the database's default `utf8mb4_0900_ai_ci` collation,
29
+ * which is itself accent- *and* case-insensitive — confirmed against
30
+ * the live container with a throwaway table (`'Alice@Example.com'`
31
+ * matched `LIKE '%alice%'`). So a plain `like()` already reproduces
32
+ * `ilike()`'s behaviour; MySQL has no `ILIKE` keyword to reach for.
33
+ * - `count(*)::int` → `CAST(COUNT(*) AS SIGNED)`, with a `Number()`
34
+ * normalisation at the result edge — mirrors the identical conversion
35
+ * in `storage-queries.ts` (mysql2 can return `BIGINT` aggregates as
36
+ * strings).
37
+ * - `.returning()` (4 sites: `create`, `update`, `setPasswordHash`,
38
+ * `delete`) → MySQL has no `RETURNING`. `create`'s id is app-generated
39
+ * UUIDv7 and every other column is either caller-supplied or a literal
40
+ * default, so the row is constructed in JS directly — the `created_at`/
41
+ * `updated_at` pair is captured once as `now` and used for *both* the
42
+ * `INSERT` values and the returned row, so there is no drift between
43
+ * what is persisted and what is returned (the failure mode the Task 9
44
+ * deferred-minor note warns about: two independent `new Date()` calls
45
+ * that can disagree). `update` and `setPasswordHash` re-`SELECT` the
46
+ * row after a successful guarded `UPDATE`, both statements sharing one
47
+ * `db.transaction()`, rather than merging the patch onto a pre-read
48
+ * snapshot — an earlier version of this file did the latter and was
49
+ * correct on accept/reject (the vid-guarded `UPDATE`'s affected-row
50
+ * count is still the sole accept/reject authority, exactly as pg's
51
+ * guarded `UPDATE … RETURNING` is) but not on return-value freshness:
52
+ * `recordLoginSuccess`/`recordLoginFailure` mutate `last_login`/
53
+ * `last_login_ip`/`failed_login_attempts` *without* bumping `vid` (by
54
+ * design, matching pg), so either racing against the pre-read left the
55
+ * returned object reporting stale values for those columns.
56
+ *
57
+ * A second version of this fix re-`SELECT`ed but as two separate
58
+ * statements outside a transaction — better, but still left a window: a
59
+ * `recordLoginSuccess`/`recordLoginFailure` committing *between* the
60
+ * guarded `UPDATE` and the re-`SELECT` would leak into the returned
61
+ * object. The transaction closes that too, and the mechanism is not a
62
+ * REPEATABLE-READ-snapshot argument (a snapshot would just as easily
63
+ * read the *pre*-mutation state) — it's that the guarded `UPDATE`
64
+ * acquires an exclusive lock on this row that InnoDB holds until the
65
+ * transaction commits, so `recordLoginSuccess`/`recordLoginFailure`
66
+ * issued against the same row block until after the re-`SELECT` has run
67
+ * and the transaction has committed. That is what makes "the re-`SELECT`
68
+ * closes the freshness gap entirely" actually true, rather than true
69
+ * modulo the specific window between the two statements. `delete` needs
70
+ * no read at all — it returns `void`, so the affected-row count is the
71
+ * entire contract.
72
+ */
73
+ const PUBLIC_COLUMNS = {
74
+ id: adminUsers.id,
75
+ vid: adminUsers.vid,
76
+ given_name: adminUsers.given_name,
77
+ family_name: adminUsers.family_name,
78
+ username: adminUsers.username,
79
+ email: adminUsers.email,
80
+ remember_me: adminUsers.remember_me,
81
+ last_login: adminUsers.last_login,
82
+ last_login_ip: adminUsers.last_login_ip,
83
+ failed_login_attempts: adminUsers.failed_login_attempts,
84
+ is_super_admin: adminUsers.is_super_admin,
85
+ is_enabled: adminUsers.is_enabled,
86
+ is_email_verified: adminUsers.is_email_verified,
87
+ preferred_locale: adminUsers.preferred_locale,
88
+ created_at: adminUsers.created_at,
89
+ updated_at: adminUsers.updated_at,
90
+ };
91
+ const ORDER_COLUMN = {
92
+ given_name: adminUsers.given_name,
93
+ family_name: adminUsers.family_name,
94
+ email: adminUsers.email,
95
+ username: adminUsers.username,
96
+ created_at: adminUsers.created_at,
97
+ updated_at: adminUsers.updated_at,
98
+ };
99
+ export function createAdminUsersRepository(db) {
100
+ return {
101
+ async create(input) {
102
+ const id = uuidv7();
103
+ const now = new Date();
104
+ const row = {
105
+ id,
106
+ vid: 1,
107
+ given_name: input.given_name ?? null,
108
+ family_name: input.family_name ?? null,
109
+ username: input.username ?? null,
110
+ email: input.email.toLowerCase(),
111
+ remember_me: false,
112
+ last_login: null,
113
+ last_login_ip: null,
114
+ failed_login_attempts: 0,
115
+ is_super_admin: input.is_super_admin ?? false,
116
+ is_enabled: input.is_enabled ?? false,
117
+ is_email_verified: input.is_email_verified ?? false,
118
+ preferred_locale: input.preferred_locale ?? null,
119
+ created_at: now,
120
+ updated_at: now,
121
+ };
122
+ await db.insert(adminUsers).values({
123
+ id: row.id,
124
+ email: row.email,
125
+ password: input.password_hash,
126
+ given_name: row.given_name,
127
+ family_name: row.family_name,
128
+ username: row.username,
129
+ is_super_admin: row.is_super_admin,
130
+ is_enabled: row.is_enabled,
131
+ is_email_verified: row.is_email_verified,
132
+ preferred_locale: row.preferred_locale,
133
+ created_at: now,
134
+ updated_at: now,
135
+ });
136
+ return row;
137
+ },
138
+ async getById(id) {
139
+ const [row] = await db.select(PUBLIC_COLUMNS).from(adminUsers).where(eq(adminUsers.id, id));
140
+ return row ?? null;
141
+ },
142
+ async getByIds(ids) {
143
+ if (ids.length === 0)
144
+ return [];
145
+ return db.select(PUBLIC_COLUMNS).from(adminUsers).where(inArray(adminUsers.id, ids));
146
+ },
147
+ async getByEmail(email) {
148
+ const [row] = await db
149
+ .select(PUBLIC_COLUMNS)
150
+ .from(adminUsers)
151
+ .where(eq(adminUsers.email, email.toLowerCase()));
152
+ return row ?? null;
153
+ },
154
+ async getByUsername(username) {
155
+ const [row] = await db
156
+ .select(PUBLIC_COLUMNS)
157
+ .from(adminUsers)
158
+ .where(eq(adminUsers.username, username));
159
+ return row ?? null;
160
+ },
161
+ async getByEmailForSignIn(email) {
162
+ const [row] = await db
163
+ .select({ ...PUBLIC_COLUMNS, password_hash: adminUsers.password })
164
+ .from(adminUsers)
165
+ .where(eq(adminUsers.email, email.toLowerCase()));
166
+ return row ?? null;
167
+ },
168
+ async getByIdForSignIn(id) {
169
+ const [row] = await db
170
+ .select({ ...PUBLIC_COLUMNS, password_hash: adminUsers.password })
171
+ .from(adminUsers)
172
+ .where(eq(adminUsers.id, id));
173
+ return row ?? null;
174
+ },
175
+ async list(options) {
176
+ const needle = options.query?.trim();
177
+ const filter = needle && needle.length > 0
178
+ ? or(like(adminUsers.email, `%${needle}%`), like(adminUsers.given_name, `%${needle}%`), like(adminUsers.family_name, `%${needle}%`), like(adminUsers.username, `%${needle}%`))
179
+ : undefined;
180
+ const sortCol = ORDER_COLUMN[options.order];
181
+ const orderExpr = options.desc ? desc(sortCol) : asc(sortCol);
182
+ const offset = Math.max(0, (options.page - 1) * options.pageSize);
183
+ const query = db.select(PUBLIC_COLUMNS).from(adminUsers);
184
+ const filtered = filter ? query.where(filter) : query;
185
+ return filtered.orderBy(orderExpr).limit(options.pageSize).offset(offset);
186
+ },
187
+ async count(options) {
188
+ const needle = options?.query?.trim();
189
+ const filter = needle && needle.length > 0
190
+ ? or(like(adminUsers.email, `%${needle}%`), like(adminUsers.given_name, `%${needle}%`), like(adminUsers.family_name, `%${needle}%`), like(adminUsers.username, `%${needle}%`))
191
+ : undefined;
192
+ const base = db.select({ value: sql `CAST(COUNT(*) AS SIGNED)` }).from(adminUsers);
193
+ const [row] = await (filter ? base.where(filter) : base);
194
+ return Number(row?.value ?? 0);
195
+ },
196
+ async update(id, expectedVid, patch) {
197
+ const now = new Date();
198
+ const updateSet = {
199
+ updated_at: now,
200
+ vid: sql `${adminUsers.vid} + 1`,
201
+ };
202
+ if (patch.given_name !== undefined)
203
+ updateSet.given_name = patch.given_name;
204
+ if (patch.family_name !== undefined)
205
+ updateSet.family_name = patch.family_name;
206
+ if (patch.username !== undefined)
207
+ updateSet.username = patch.username;
208
+ if (patch.email !== undefined)
209
+ updateSet.email = patch.email.toLowerCase();
210
+ if (patch.is_super_admin !== undefined)
211
+ updateSet.is_super_admin = patch.is_super_admin;
212
+ if (patch.is_enabled !== undefined)
213
+ updateSet.is_enabled = patch.is_enabled;
214
+ if (patch.is_email_verified !== undefined)
215
+ updateSet.is_email_verified = patch.is_email_verified;
216
+ if (patch.remember_me !== undefined)
217
+ updateSet.remember_me = patch.remember_me;
218
+ if (patch.preferred_locale !== undefined)
219
+ updateSet.preferred_locale = patch.preferred_locale;
220
+ // Guarded `UPDATE` + re-`SELECT` share one transaction — see this
221
+ // file's docblock for why the transaction boundary (not just the
222
+ // re-`SELECT`) is what actually closes the freshness gap.
223
+ return db.transaction(async (tx) => {
224
+ const result = await tx
225
+ .update(adminUsers)
226
+ .set(updateSet)
227
+ .where(and(eq(adminUsers.id, id), eq(adminUsers.vid, expectedVid)));
228
+ if (affectedRowCount(result) === 0)
229
+ throw ERR_ADMIN_USER_VERSION_CONFLICT();
230
+ const [fresh] = await tx
231
+ .select(PUBLIC_COLUMNS)
232
+ .from(adminUsers)
233
+ .where(eq(adminUsers.id, id));
234
+ if (!fresh)
235
+ throw ERR_ADMIN_USER_VERSION_CONFLICT();
236
+ return fresh;
237
+ });
238
+ },
239
+ async setPasswordHash(id, expectedVid, passwordHash) {
240
+ const now = new Date();
241
+ // Guarded `UPDATE` + re-`SELECT` share one transaction — see `update()`
242
+ // above and this file's docblock.
243
+ return db.transaction(async (tx) => {
244
+ const result = await tx
245
+ .update(adminUsers)
246
+ .set({
247
+ password: passwordHash,
248
+ updated_at: now,
249
+ vid: sql `${adminUsers.vid} + 1`,
250
+ })
251
+ .where(and(eq(adminUsers.id, id), eq(adminUsers.vid, expectedVid)));
252
+ if (affectedRowCount(result) === 0)
253
+ throw ERR_ADMIN_USER_VERSION_CONFLICT();
254
+ const [fresh] = await tx
255
+ .select(PUBLIC_COLUMNS)
256
+ .from(adminUsers)
257
+ .where(eq(adminUsers.id, id));
258
+ if (!fresh)
259
+ throw ERR_ADMIN_USER_VERSION_CONFLICT();
260
+ return fresh;
261
+ });
262
+ },
263
+ async setEnabled(id, enabled) {
264
+ await db
265
+ .update(adminUsers)
266
+ .set({ is_enabled: enabled, updated_at: new Date(), vid: sql `${adminUsers.vid} + 1` })
267
+ .where(eq(adminUsers.id, id));
268
+ },
269
+ async setPreferredLocale(id, locale) {
270
+ await db
271
+ .update(adminUsers)
272
+ .set({ preferred_locale: locale, updated_at: new Date(), vid: sql `${adminUsers.vid} + 1` })
273
+ .where(eq(adminUsers.id, id));
274
+ },
275
+ async recordLoginSuccess(id, ip) {
276
+ await db
277
+ .update(adminUsers)
278
+ .set({
279
+ last_login: new Date(),
280
+ last_login_ip: ip,
281
+ failed_login_attempts: 0,
282
+ updated_at: new Date(),
283
+ })
284
+ .where(eq(adminUsers.id, id));
285
+ },
286
+ async recordLoginFailure(id) {
287
+ await db
288
+ .update(adminUsers)
289
+ .set({
290
+ failed_login_attempts: sql `${adminUsers.failed_login_attempts} + 1`,
291
+ updated_at: new Date(),
292
+ })
293
+ .where(eq(adminUsers.id, id));
294
+ },
295
+ async delete(id, expectedVid) {
296
+ const result = await db
297
+ .delete(adminUsers)
298
+ .where(and(eq(adminUsers.id, id), eq(adminUsers.vid, expectedVid)));
299
+ if (affectedRowCount(result) === 0)
300
+ throw ERR_ADMIN_USER_VERSION_CONFLICT();
301
+ },
302
+ };
303
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ */
8
+ /**
9
+ * `@byline/db-mysql/admin` — MySQL implementations of the admin repository
10
+ * contracts declared in `@byline/admin`.
11
+ *
12
+ * Most callers want `createAdminStore(db)` — it bundles all five
13
+ * repositories into the `AdminStore` shape that `@byline/admin` consumers
14
+ * (the built-in `JwtSessionProvider`, `seedSuperAdmin`, admin-user and
15
+ * admin-role commands) expect. Individual factories remain exported for
16
+ * unusual cases (custom wiring, partial testing).
17
+ *
18
+ * No session-provider code lives here — `JwtSessionProvider` lives in
19
+ * `@byline/admin/auth`. This package only supplies the adapter-shaped
20
+ * pieces it implements. Mirrors `packages/db-postgres/src/modules/admin/index.ts`.
21
+ */
22
+ export { createAdminPermissionsRepository } from './admin-permissions-repository.js';
23
+ export { createAdminPreferencesRepository } from './admin-preferences-repository.js';
24
+ export { createAdminRolesRepository } from './admin-roles-repository.js';
25
+ export { createAdminStore } from './admin-store.js';
26
+ export { createAdminUsersRepository } from './admin-users-repository.js';
27
+ export { createRefreshTokensRepository } from './refresh-tokens-repository.js';
@@ -0,0 +1,27 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ */
8
+ /**
9
+ * `@byline/db-mysql/admin` — MySQL implementations of the admin repository
10
+ * contracts declared in `@byline/admin`.
11
+ *
12
+ * Most callers want `createAdminStore(db)` — it bundles all five
13
+ * repositories into the `AdminStore` shape that `@byline/admin` consumers
14
+ * (the built-in `JwtSessionProvider`, `seedSuperAdmin`, admin-user and
15
+ * admin-role commands) expect. Individual factories remain exported for
16
+ * unusual cases (custom wiring, partial testing).
17
+ *
18
+ * No session-provider code lives here — `JwtSessionProvider` lives in
19
+ * `@byline/admin/auth`. This package only supplies the adapter-shaped
20
+ * pieces it implements. Mirrors `packages/db-postgres/src/modules/admin/index.ts`.
21
+ */
22
+ export { createAdminPermissionsRepository } from './admin-permissions-repository.js';
23
+ export { createAdminPreferencesRepository } from './admin-preferences-repository.js';
24
+ export { createAdminRolesRepository } from './admin-roles-repository.js';
25
+ export { createAdminStore } from './admin-store.js';
26
+ export { createAdminUsersRepository } from './admin-users-repository.js';
27
+ export { createRefreshTokensRepository } from './refresh-tokens-repository.js';
@@ -0,0 +1,34 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ */
8
+ import type { RefreshTokensRepository } from '@byline/admin/auth';
9
+ import type { MySql2Database } from 'drizzle-orm/mysql2';
10
+ import type * as schema from '../../database/schema/index.js';
11
+ /**
12
+ * MySQL implementation of `RefreshTokensRepository`, backing the built-in
13
+ * `JwtSessionProvider`. Ported from
14
+ * `packages/db-postgres/src/modules/admin/refresh-tokens-repository.ts`.
15
+ *
16
+ * `issue()`'s `.returning()` has no MySQL equivalent. Every field
17
+ * `RefreshTokenRow` exposes is either caller-supplied (`id`, `admin_user_id`,
18
+ * `token_hash`, `expires_at`, `user_agent`, `ip`) or a fresh row's literal
19
+ * default (`revoked_at`/`rotated_to_id`/`last_used_at` all `null`) — except
20
+ * `issued_at`, which the schema defaults to `CURRENT_TIMESTAMP(6)`. Rather
21
+ * than let the insert rely on that default and approximate the returned
22
+ * value with a second, independent `new Date()` call (the drift the Task 9
23
+ * deferred-minor note flags), `issued_at` — and the underlying table's
24
+ * `created_at`/`updated_at`, which `RefreshTokenRow` doesn't expose but
25
+ * which exist on the row — are set explicitly from one `now` captured
26
+ * before the insert and reused for the returned row, so there is no gap
27
+ * between what is persisted and what is returned. No re-`SELECT` needed.
28
+ *
29
+ * `revokeAllForUser` and `purgeExpired` only ever consumed
30
+ * `.returning({ id })` for its `.length` (the affected-row count); MySQL's
31
+ * driver result surfaces that directly via `affectedRows` — no row
32
+ * reconstruction needed at all.
33
+ */
34
+ export declare function createRefreshTokensRepository(db: MySql2Database<typeof schema>): RefreshTokensRepository;
@@ -0,0 +1,160 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ */
8
+ import { and, eq, isNull, lt } from 'drizzle-orm';
9
+ import { adminRefreshTokens } from '../../database/schema/auth.js';
10
+ import { affectedRowCount } from '../storage/storage-utils.js';
11
+ /**
12
+ * MySQL implementation of `RefreshTokensRepository`, backing the built-in
13
+ * `JwtSessionProvider`. Ported from
14
+ * `packages/db-postgres/src/modules/admin/refresh-tokens-repository.ts`.
15
+ *
16
+ * `issue()`'s `.returning()` has no MySQL equivalent. Every field
17
+ * `RefreshTokenRow` exposes is either caller-supplied (`id`, `admin_user_id`,
18
+ * `token_hash`, `expires_at`, `user_agent`, `ip`) or a fresh row's literal
19
+ * default (`revoked_at`/`rotated_to_id`/`last_used_at` all `null`) — except
20
+ * `issued_at`, which the schema defaults to `CURRENT_TIMESTAMP(6)`. Rather
21
+ * than let the insert rely on that default and approximate the returned
22
+ * value with a second, independent `new Date()` call (the drift the Task 9
23
+ * deferred-minor note flags), `issued_at` — and the underlying table's
24
+ * `created_at`/`updated_at`, which `RefreshTokenRow` doesn't expose but
25
+ * which exist on the row — are set explicitly from one `now` captured
26
+ * before the insert and reused for the returned row, so there is no gap
27
+ * between what is persisted and what is returned. No re-`SELECT` needed.
28
+ *
29
+ * `revokeAllForUser` and `purgeExpired` only ever consumed
30
+ * `.returning({ id })` for its `.length` (the affected-row count); MySQL's
31
+ * driver result surfaces that directly via `affectedRows` — no row
32
+ * reconstruction needed at all.
33
+ */
34
+ export function createRefreshTokensRepository(db) {
35
+ return {
36
+ async issue(input) {
37
+ const now = new Date();
38
+ const row = {
39
+ id: input.id,
40
+ admin_user_id: input.admin_user_id,
41
+ token_hash: input.token_hash,
42
+ issued_at: now,
43
+ expires_at: input.expires_at,
44
+ revoked_at: null,
45
+ rotated_to_id: null,
46
+ last_used_at: null,
47
+ user_agent: input.user_agent ?? null,
48
+ ip: input.ip ?? null,
49
+ };
50
+ await db.insert(adminRefreshTokens).values({
51
+ id: row.id,
52
+ admin_user_id: row.admin_user_id,
53
+ token_hash: row.token_hash,
54
+ issued_at: now,
55
+ expires_at: row.expires_at,
56
+ user_agent: row.user_agent,
57
+ ip: row.ip,
58
+ created_at: now,
59
+ updated_at: now,
60
+ });
61
+ return row;
62
+ },
63
+ async findByHash(tokenHash) {
64
+ const [row] = await db
65
+ .select()
66
+ .from(adminRefreshTokens)
67
+ .where(eq(adminRefreshTokens.token_hash, tokenHash));
68
+ return row ?? null;
69
+ },
70
+ async findById(id) {
71
+ const [row] = await db.select().from(adminRefreshTokens).where(eq(adminRefreshTokens.id, id));
72
+ return row ?? null;
73
+ },
74
+ async touch(id, at = new Date()) {
75
+ await db
76
+ .update(adminRefreshTokens)
77
+ .set({ last_used_at: at, updated_at: new Date() })
78
+ .where(eq(adminRefreshTokens.id, id));
79
+ },
80
+ async markRotated(oldId, newId, at = new Date()) {
81
+ await db
82
+ .update(adminRefreshTokens)
83
+ .set({ revoked_at: at, rotated_to_id: newId, updated_at: new Date() })
84
+ .where(eq(adminRefreshTokens.id, oldId));
85
+ },
86
+ async revoke(id, at = new Date()) {
87
+ await db
88
+ .update(adminRefreshTokens)
89
+ .set({ revoked_at: at, updated_at: new Date() })
90
+ .where(and(eq(adminRefreshTokens.id, id), isNull(adminRefreshTokens.revoked_at)));
91
+ },
92
+ async revokeChain(startId, at = new Date()) {
93
+ let cursor = startId;
94
+ let touched = 0;
95
+ // Bounded walk — chains in practice are short; 1000 is a safety ceiling.
96
+ for (let step = 0; cursor != null && step < 1000; step++) {
97
+ const [row] = await db
98
+ .select({
99
+ id: adminRefreshTokens.id,
100
+ rotated_to_id: adminRefreshTokens.rotated_to_id,
101
+ revoked_at: adminRefreshTokens.revoked_at,
102
+ })
103
+ .from(adminRefreshTokens)
104
+ .where(eq(adminRefreshTokens.id, cursor));
105
+ if (!row)
106
+ break;
107
+ if (row.revoked_at == null) {
108
+ await db
109
+ .update(adminRefreshTokens)
110
+ .set({ revoked_at: at, updated_at: new Date() })
111
+ .where(eq(adminRefreshTokens.id, row.id));
112
+ touched++;
113
+ }
114
+ cursor = row.rotated_to_id;
115
+ }
116
+ return touched;
117
+ },
118
+ async revokeAllForUser(adminUserId, at = new Date()) {
119
+ const result = await db
120
+ .update(adminRefreshTokens)
121
+ .set({ revoked_at: at, updated_at: new Date() })
122
+ .where(and(eq(adminRefreshTokens.admin_user_id, adminUserId), isNull(adminRefreshTokens.revoked_at)));
123
+ return affectedRowCount(result);
124
+ },
125
+ async purgeExpired(now = new Date()) {
126
+ const result = await db
127
+ .delete(adminRefreshTokens)
128
+ .where(lt(adminRefreshTokens.expires_at, now));
129
+ return affectedRowCount(result);
130
+ },
131
+ async listActiveForUser(adminUserId) {
132
+ return db
133
+ .select()
134
+ .from(adminRefreshTokens)
135
+ .where(and(eq(adminRefreshTokens.admin_user_id, adminUserId), isNull(adminRefreshTokens.revoked_at)));
136
+ },
137
+ async listAllForUser(adminUserId) {
138
+ return db
139
+ .select()
140
+ .from(adminRefreshTokens)
141
+ .where(eq(adminRefreshTokens.admin_user_id, adminUserId))
142
+ .orderBy(adminRefreshTokens.issued_at);
143
+ },
144
+ async listRotationChain(startId) {
145
+ const chain = [];
146
+ let cursor = startId;
147
+ for (let step = 0; cursor != null && step < 1000; step++) {
148
+ const [row] = await db
149
+ .select()
150
+ .from(adminRefreshTokens)
151
+ .where(eq(adminRefreshTokens.id, cursor));
152
+ if (!row)
153
+ break;
154
+ chain.push(row);
155
+ cursor = row.rotated_to_id;
156
+ }
157
+ return chain;
158
+ },
159
+ };
160
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ */
8
+ /**
9
+ * Append-only audit-log writes (docs/06-auth-and-security/02-auditability.md — Workstream 2). A deliberately
10
+ * dumb command: it inserts one row and knows nothing about *which* changes
11
+ * warrant an audit entry — that policy lives in the lifecycle services, which
12
+ * wrap the mutation + this append in `withTransaction` so they commit
13
+ * atomically. Runs on the `DBManager` executor, so an `append` issued inside a
14
+ * `withTransaction` boundary joins the ambient transaction. Mirrors
15
+ * `packages/db-postgres/src/modules/audit/audit-commands.ts` exactly — the
16
+ * transaction-join split is the load-bearing property, not the dialect.
17
+ */
18
+ import type { AuditLogAppendInput, IAuditCommands } from '@byline/core';
19
+ import type { DBManager } from '../../lib/db-manager.js';
20
+ export declare class AuditCommands implements IAuditCommands {
21
+ private dbManager;
22
+ constructor(dbManager: DBManager);
23
+ /** Ambient transaction when a `withTransaction` boundary is open, else the pool. */
24
+ private get db();
25
+ append(input: AuditLogAppendInput): Promise<{
26
+ id: string;
27
+ }>;
28
+ }
29
+ export declare function createAuditCommands(dbManager: DBManager): AuditCommands;
@@ -0,0 +1,40 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ */
8
+ import { v7 as uuidv7 } from 'uuid';
9
+ import { auditLog } from '../../database/schema/index.js';
10
+ export class AuditCommands {
11
+ dbManager;
12
+ constructor(dbManager) {
13
+ this.dbManager = dbManager;
14
+ }
15
+ /** Ambient transaction when a `withTransaction` boundary is open, else the pool. */
16
+ get db() {
17
+ return this.dbManager.get();
18
+ }
19
+ async append(input) {
20
+ const id = uuidv7(); // time-ordered, so id ordering ≈ occurred_at ordering
21
+ await this.db.insert(auditLog).values({
22
+ id,
23
+ document_id: input.documentId ?? null,
24
+ collection_id: input.collectionId ?? null,
25
+ actor_id: input.actorId ?? null,
26
+ actor_realm: input.actorRealm,
27
+ action: input.action,
28
+ field: input.field ?? null,
29
+ // `?? null` only coerces undefined → SQL NULL; a real `false`/`0`/`''`
30
+ // before/after value is preserved.
31
+ before: input.before ?? null,
32
+ after: input.after ?? null,
33
+ // occurred_at defaults to CURRENT_TIMESTAMP(6) at the DB.
34
+ });
35
+ return { id };
36
+ }
37
+ }
38
+ export function createAuditCommands(dbManager) {
39
+ return new AuditCommands(dbManager);
40
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ */
8
+ /**
9
+ * Audit-log reads (docs/06-auth-and-security/02-auditability.md — Workstreams 3 & 4). Reads run on the pool
10
+ * directly — they never need to join an audit write's transaction — so this
11
+ * takes the raw `db` (drizzle over the pool) rather than the `DBManager`.
12
+ * Access scoping is the caller's responsibility (the document's own read
13
+ * gate); these queries do no scoping of their own. Mirrors
14
+ * `packages/db-postgres/src/modules/audit/audit-queries.ts` with the CAST /
15
+ * string-concat / result-shape adjustments the mysql2 driver requires — see
16
+ * inline comments at each divergence.
17
+ */
18
+ import type { AuditLogPage, IAuditQueries } from '@byline/core';
19
+ import type { MySql2Database } from 'drizzle-orm/mysql2';
20
+ import type * as schema from '../../database/schema/index.js';
21
+ type DatabaseConnection = MySql2Database<typeof schema>;
22
+ export declare class AuditQueries implements IAuditQueries {
23
+ private db;
24
+ constructor(db: DatabaseConnection);
25
+ getDocumentAuditLog(params: {
26
+ document_id: string;
27
+ page?: number;
28
+ page_size?: number;
29
+ }): Promise<AuditLogPage>;
30
+ findAuditLog(params: {
31
+ actorId?: string;
32
+ collectionId?: string;
33
+ action?: string;
34
+ from?: Date;
35
+ to?: Date;
36
+ page?: number;
37
+ page_size?: number;
38
+ }): Promise<AuditLogPage>;
39
+ }
40
+ export declare function createAuditQueries(db: DatabaseConnection): AuditQueries;
41
+ export {};