@byline/db-mysql 4.19.0 → 5.1.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 (39) hide show
  1. package/dist/database/schema/auth.d.ts +202 -0
  2. package/dist/database/schema/auth.js +29 -0
  3. package/dist/database/schema/index.d.ts +35 -1
  4. package/dist/database/schema/index.js +7 -2
  5. package/dist/database/schema/schema-pins.test.node.js +18 -2
  6. package/dist/index.js +4 -0
  7. package/dist/lib/db-manager.d.ts +10 -0
  8. package/dist/lib/db-manager.js +73 -16
  9. package/dist/lib/db-manager.test.node.d.ts +1 -0
  10. package/dist/lib/db-manager.test.node.js +82 -0
  11. package/dist/lib/revision-schema.d.ts +2 -0
  12. package/dist/lib/revision-schema.js +63 -0
  13. package/dist/modules/admin/admin-store.js +30 -0
  14. package/dist/modules/admin/admin-users-repository.js +37 -6
  15. package/dist/modules/admin/login-sessions-repository.d.ts +11 -0
  16. package/dist/modules/admin/login-sessions-repository.js +38 -0
  17. package/dist/modules/admin/refresh-tokens-repository.js +18 -24
  18. package/dist/modules/admin/retry-deadlock.d.ts +2 -0
  19. package/dist/modules/admin/retry-deadlock.js +22 -0
  20. package/dist/modules/admin/sign-in-rate-limit-store.d.ts +11 -0
  21. package/dist/modules/admin/sign-in-rate-limit-store.js +37 -0
  22. package/dist/modules/storage/classify-error.js +3 -0
  23. package/dist/modules/storage/classify-error.test.node.js +8 -0
  24. package/dist/modules/storage/collection-registration.d.ts +9 -0
  25. package/dist/modules/storage/collection-registration.js +32 -0
  26. package/dist/modules/storage/document-revisions.d.ts +20 -0
  27. package/dist/modules/storage/document-revisions.js +197 -0
  28. package/dist/modules/storage/publish-schedules.d.ts +5 -0
  29. package/dist/modules/storage/publish-schedules.js +23 -3
  30. package/dist/modules/storage/read-snapshot.d.ts +3 -0
  31. package/dist/modules/storage/read-snapshot.js +12 -0
  32. package/dist/modules/storage/singletons.d.ts +1 -1
  33. package/dist/modules/storage/singletons.js +3 -20
  34. package/dist/modules/storage/storage-commands.d.ts +2 -0
  35. package/dist/modules/storage/storage-commands.js +31 -3
  36. package/dist/modules/storage/storage-queries.d.ts +4 -0
  37. package/dist/modules/storage/storage-queries.js +9 -0
  38. package/dist/modules/storage/tests/storage-live-path-queries.test.js +9 -2
  39. package/package.json +10 -10
@@ -0,0 +1,82 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { DBManagerImpl, TXManagerImpl } from './db-manager.js';
3
+ describe('adapter transaction ownership', () => {
4
+ it('does not leak one adapter transaction into another adapter pool', async () => {
5
+ const poolA = {};
6
+ const poolB = {};
7
+ const tx = {};
8
+ const a = new DBManagerImpl({ dbPool: poolA });
9
+ const b = new DBManagerImpl({ dbPool: poolB });
10
+ await a.runInTransaction(tx, async () => {
11
+ expect(a.get()).toBe(tx);
12
+ expect(a.isInTransaction()).toBe(true);
13
+ expect(b.get()).toBe(poolB);
14
+ expect(b.isInTransaction()).toBe(false);
15
+ expect(b.getTransactionScope()).toBeUndefined();
16
+ });
17
+ expect(a.get()).toBe(poolA);
18
+ expect(a.getTransactionScope()).toBeUndefined();
19
+ });
20
+ it('preserves root scope through savepoints and restores the parent executor', async () => {
21
+ const pool = {};
22
+ const outer = {};
23
+ const inner = {};
24
+ const manager = new DBManagerImpl({ dbPool: pool });
25
+ await manager.runInTransaction(outer, async () => {
26
+ const scope = manager.getTransactionScope();
27
+ await expect(manager.runInTransaction(inner, async () => {
28
+ expect(manager.get()).toBe(inner);
29
+ expect(manager.getTransactionScope()).toBe(scope);
30
+ throw new Error('rollback');
31
+ })).rejects.toThrow('rollback');
32
+ expect(manager.get()).toBe(outer);
33
+ expect(manager.getTransactionScope()).toBe(scope);
34
+ });
35
+ });
36
+ });
37
+ describe('owned transaction lock failures', () => {
38
+ const conflict = () => Object.assign(new Error('driver SQL detail'), { errno: 1213 });
39
+ for (const outcome of ['rollback', 'rollback failure', 'commit failure', 'nested']) {
40
+ it(`only marks confirmed whole rollback retryable: ${outcome}`, async () => {
41
+ const original = conflict();
42
+ const replacement = new Error('connection lost');
43
+ let rollbackCompleted = false;
44
+ const executor = {
45
+ transaction: async (fn) => {
46
+ let result;
47
+ try {
48
+ result = await fn(executor);
49
+ }
50
+ catch (error) {
51
+ if (outcome === 'rollback failure')
52
+ throw replacement;
53
+ rollbackCompleted = true;
54
+ throw error;
55
+ }
56
+ if (outcome === 'commit failure')
57
+ throw original;
58
+ return result;
59
+ },
60
+ };
61
+ const db = new DBManagerImpl({ dbPool: executor });
62
+ const manager = new TXManagerImpl({ db });
63
+ const operation = () => manager.withTransaction(async () => {
64
+ if (outcome !== 'commit failure')
65
+ throw original;
66
+ return 'ok';
67
+ });
68
+ const pending = outcome === 'nested' ? db.runInTransaction(executor, operation) : operation();
69
+ if (outcome === 'rollback') {
70
+ await expect(pending).rejects.toMatchObject({
71
+ code: 'ERR_LOCK_CONFLICT',
72
+ details: { reason: 'lock_conflict', rolledBack: true, retryable: true },
73
+ cause: original,
74
+ });
75
+ expect(rollbackCompleted).toBe(true);
76
+ }
77
+ else {
78
+ await expect(pending).rejects.toBe(outcome === 'rollback failure' ? replacement : original);
79
+ }
80
+ });
81
+ }
82
+ });
@@ -0,0 +1,2 @@
1
+ import type { DBExecutor } from './db-manager.js';
2
+ export declare function assertRevisionSchema(db: DBExecutor): Promise<void>;
@@ -0,0 +1,63 @@
1
+ import { ERR_DATABASE } from '@byline/core';
2
+ import { sql } from 'drizzle-orm';
3
+ // Catalogue renderers add parentheses, casts, charset introducers, and escaped quotes.
4
+ // Compare complete normalized expressions, not just constraint names or bound substrings.
5
+ function normalize(clause) {
6
+ return clause
7
+ .toLowerCase()
8
+ .replace(/::(?:character varying|bigint|integer|text)(?:\[\])?/g, '')
9
+ .replace(/_utf8mb[34]/g, '')
10
+ .replace(/^check/, '')
11
+ .replace(/[\s()[\]`'"\\]/g, '');
12
+ }
13
+ export async function assertRevisionSchema(db) {
14
+ const upgrade = 'Byline document revision schema is incompatible. Fence all writers/workers, upgrade both packages and schema using 0005_document-revisions.sql, and rerun startup validation before reopening traffic.';
15
+ try {
16
+ const columnResult = await db.execute(sql `SELECT TABLE_NAME AS table_name, COLUMN_NAME AS column_name,
17
+ CASE WHEN DATA_TYPE = 'bigint' AND COLUMN_TYPE NOT LIKE '%unsigned%' AND COLUMN_DEFAULT IS NULL
18
+ AND IS_NULLABLE = CASE WHEN COLUMN_NAME = 'revision' THEN 'NO' ELSE 'YES' END
19
+ AND EXTRA = '' THEN 1 ELSE 0 END AS valid
20
+ FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND ((table_name = 'byline_documents' AND column_name = 'revision') OR (table_name = 'byline_document_publish_schedules' AND column_name = 'authorized_revision'))`);
21
+ const checkResult = await db.execute(sql `SELECT t.CONSTRAINT_NAME AS name, c.CHECK_CLAUSE AS clause, (t.ENFORCED = 'YES') AS valid
22
+ FROM information_schema.TABLE_CONSTRAINTS t JOIN information_schema.CHECK_CONSTRAINTS c
23
+ ON c.CONSTRAINT_SCHEMA = t.CONSTRAINT_SCHEMA AND c.CONSTRAINT_NAME = t.CONSTRAINT_NAME
24
+ WHERE t.CONSTRAINT_SCHEMA = DATABASE() AND t.TABLE_NAME IN ('byline_documents', 'byline_document_publish_schedules') AND t.CONSTRAINT_TYPE = 'CHECK'`);
25
+ const columns = columnResult[0];
26
+ const checks = checkResult[0];
27
+ if (columns.length !== 2 || columns.some((column) => Number(column.valid) !== 1))
28
+ throw new Error('Required BIGINT columns must have the correct nullability and no default.');
29
+ const required = [
30
+ [
31
+ 'check_documents_revision',
32
+ [
33
+ 'revision BETWEEN 1 AND 9007199254740991',
34
+ 'revision >= 1 AND revision <= 9007199254740991',
35
+ ],
36
+ ],
37
+ [
38
+ 'check_publish_schedules_authorized_revision',
39
+ [
40
+ 'authorized_revision IS NULL OR authorized_revision BETWEEN 1 AND 9007199254740991',
41
+ 'authorized_revision IS NULL OR authorized_revision >= 1 AND authorized_revision <= 9007199254740991',
42
+ ],
43
+ ],
44
+ [
45
+ 'check_publish_schedules_suspended_reason',
46
+ [
47
+ "suspended_reason IS NULL OR suspended_reason IN ('content_edited', 'document_metadata_changed', 'upgrade_invalidated')",
48
+ "suspended_reason IS NULL OR suspended_reason = ANY (ARRAY['content_edited', 'document_metadata_changed', 'upgrade_invalidated'])",
49
+ ],
50
+ ],
51
+ ];
52
+ for (const [name, expressions] of required) {
53
+ const check = checks.find((row) => row.name === name);
54
+ if (check == null ||
55
+ Number(check.valid) !== 1 ||
56
+ !expressions.some((expression) => normalize(expression) === normalize(check.clause)))
57
+ throw new Error(`Missing or incompatible enforced check: ${name}`);
58
+ }
59
+ }
60
+ catch (cause) {
61
+ throw ERR_DATABASE({ message: upgrade, cause });
62
+ }
63
+ }
@@ -5,11 +5,15 @@
5
5
  *
6
6
  * Copyright (c) Infonomic Company Limited
7
7
  */
8
+ import { eq } from 'drizzle-orm';
9
+ import { adminUsers } from '../../database/schema/auth.js';
8
10
  import { createAdminPermissionsRepository } from './admin-permissions-repository.js';
9
11
  import { createAdminPreferencesRepository } from './admin-preferences-repository.js';
10
12
  import { createAdminRolesRepository } from './admin-roles-repository.js';
11
13
  import { createAdminUsersRepository } from './admin-users-repository.js';
14
+ import { createLoginSessionsRepository } from './login-sessions-repository.js';
12
15
  import { createRefreshTokensRepository } from './refresh-tokens-repository.js';
16
+ import { createSignInRateLimitStore } from './sign-in-rate-limit-store.js';
13
17
  /**
14
18
  * Wire the five admin repositories against a Drizzle handle and return the
15
19
  * `AdminStore` bundle expected by `@byline/admin` — specifically by the
@@ -21,6 +25,32 @@ import { createRefreshTokensRepository } from './refresh-tokens-repository.js';
21
25
  */
22
26
  export function createAdminStore(db) {
23
27
  return {
28
+ async withSessionLock(adminUserId, work) {
29
+ return db.transaction(async (tx) => {
30
+ await tx
31
+ .select({ id: adminUsers.id })
32
+ .from(adminUsers)
33
+ .where(eq(adminUsers.id, adminUserId))
34
+ .for('update');
35
+ const scoped = createAdminStore(tx);
36
+ const user = await scoped.adminUsers.getByIdForSignIn(adminUserId);
37
+ return work(scoped, user);
38
+ });
39
+ },
40
+ async withSessionLocks(adminUserIds, work) {
41
+ return db.transaction(async (tx) => {
42
+ for (const id of [...new Set(adminUserIds)].sort()) {
43
+ await tx
44
+ .select({ id: adminUsers.id })
45
+ .from(adminUsers)
46
+ .where(eq(adminUsers.id, id))
47
+ .for('update');
48
+ }
49
+ return work(createAdminStore(tx));
50
+ });
51
+ },
52
+ loginSessions: createLoginSessionsRepository(db),
53
+ signInRateLimits: createSignInRateLimitStore(db),
24
54
  adminUsers: createAdminUsersRepository(db),
25
55
  adminRoles: createAdminRolesRepository(db),
26
56
  adminPermissions: createAdminPermissionsRepository(db),
@@ -10,6 +10,8 @@ import { and, asc, desc, eq, inArray, like, or, sql } from 'drizzle-orm';
10
10
  import { v7 as uuidv7 } from 'uuid';
11
11
  import { adminUsers } from '../../database/schema/auth.js';
12
12
  import { affectedRowCount } from '../storage/storage-utils.js';
13
+ import { createLoginSessionsRepository } from './login-sessions-repository.js';
14
+ import { createRefreshTokensRepository } from './refresh-tokens-repository.js';
13
15
  /**
14
16
  * MySQL implementation of `AdminUsersRepository`. Ported from
15
17
  * `packages/db-postgres/src/modules/admin/admin-users-repository.ts`.
@@ -160,14 +162,22 @@ export function createAdminUsersRepository(db) {
160
162
  },
161
163
  async getByEmailForSignIn(email) {
162
164
  const [row] = await db
163
- .select({ ...PUBLIC_COLUMNS, password_hash: adminUsers.password })
165
+ .select({
166
+ ...PUBLIC_COLUMNS,
167
+ password_hash: adminUsers.password,
168
+ session_version: adminUsers.session_version,
169
+ })
164
170
  .from(adminUsers)
165
171
  .where(eq(adminUsers.email, email.toLowerCase()));
166
172
  return row ?? null;
167
173
  },
168
174
  async getByIdForSignIn(id) {
169
175
  const [row] = await db
170
- .select({ ...PUBLIC_COLUMNS, password_hash: adminUsers.password })
176
+ .select({
177
+ ...PUBLIC_COLUMNS,
178
+ password_hash: adminUsers.password,
179
+ session_version: adminUsers.session_version,
180
+ })
171
181
  .from(adminUsers)
172
182
  .where(eq(adminUsers.id, id));
173
183
  return row ?? null;
@@ -211,6 +221,9 @@ export function createAdminUsersRepository(db) {
211
221
  updateSet.is_super_admin = patch.is_super_admin;
212
222
  if (patch.is_enabled !== undefined)
213
223
  updateSet.is_enabled = patch.is_enabled;
224
+ if (patch.is_enabled === false) {
225
+ updateSet.session_version = sql `${adminUsers.session_version} + 1`;
226
+ }
214
227
  if (patch.is_email_verified !== undefined)
215
228
  updateSet.is_email_verified = patch.is_email_verified;
216
229
  if (patch.remember_me !== undefined)
@@ -233,6 +246,10 @@ export function createAdminUsersRepository(db) {
233
246
  .where(eq(adminUsers.id, id));
234
247
  if (!fresh)
235
248
  throw ERR_ADMIN_USER_VERSION_CONFLICT();
249
+ if (patch.is_enabled === false) {
250
+ await createRefreshTokensRepository(tx).revokeAllForUser(id);
251
+ await createLoginSessionsRepository(tx).revokeAllForUser(id);
252
+ }
236
253
  return fresh;
237
254
  });
238
255
  },
@@ -245,6 +262,7 @@ export function createAdminUsersRepository(db) {
245
262
  .update(adminUsers)
246
263
  .set({
247
264
  password: passwordHash,
265
+ session_version: sql `${adminUsers.session_version} + 1`,
248
266
  updated_at: now,
249
267
  vid: sql `${adminUsers.vid} + 1`,
250
268
  })
@@ -257,14 +275,27 @@ export function createAdminUsersRepository(db) {
257
275
  .where(eq(adminUsers.id, id));
258
276
  if (!fresh)
259
277
  throw ERR_ADMIN_USER_VERSION_CONFLICT();
278
+ await createRefreshTokensRepository(tx).revokeAllForUser(id);
279
+ await createLoginSessionsRepository(tx).revokeAllForUser(id);
260
280
  return fresh;
261
281
  });
262
282
  },
263
283
  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));
284
+ await db.transaction(async (tx) => {
285
+ await tx
286
+ .update(adminUsers)
287
+ .set({
288
+ is_enabled: enabled,
289
+ updated_at: new Date(),
290
+ vid: sql `${adminUsers.vid} + 1`,
291
+ ...(!enabled ? { session_version: sql `${adminUsers.session_version} + 1` } : {}),
292
+ })
293
+ .where(eq(adminUsers.id, id));
294
+ if (!enabled) {
295
+ await createRefreshTokensRepository(tx).revokeAllForUser(id);
296
+ await createLoginSessionsRepository(tx).revokeAllForUser(id);
297
+ }
298
+ });
268
299
  },
269
300
  async setPreferredLocale(id, locale) {
270
301
  await db
@@ -0,0 +1,11 @@
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 { LoginSessionsRepository } from '@byline/admin/auth';
9
+ import type { MySql2Database } from 'drizzle-orm/mysql2';
10
+ import type * as schema from '../../database/schema/index.js';
11
+ export declare function createLoginSessionsRepository(db: MySql2Database<typeof schema>): LoginSessionsRepository;
@@ -0,0 +1,38 @@
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 } from 'drizzle-orm';
9
+ import { adminLoginSessions } from '../../database/schema/auth.js';
10
+ export function createLoginSessionsRepository(db) {
11
+ return {
12
+ async create(input) {
13
+ await db.insert(adminLoginSessions).values(input);
14
+ },
15
+ async findById(id) {
16
+ const [row] = await db.select().from(adminLoginSessions).where(eq(adminLoginSessions.id, id));
17
+ return row ?? null;
18
+ },
19
+ async extend(id, expiresAt) {
20
+ await db
21
+ .update(adminLoginSessions)
22
+ .set({ expires_at: expiresAt })
23
+ .where(and(eq(adminLoginSessions.id, id), isNull(adminLoginSessions.revoked_at)));
24
+ },
25
+ async revoke(id, at = new Date()) {
26
+ await db
27
+ .update(adminLoginSessions)
28
+ .set({ revoked_at: at })
29
+ .where(and(eq(adminLoginSessions.id, id), isNull(adminLoginSessions.revoked_at)));
30
+ },
31
+ async revokeAllForUser(adminUserId, at = new Date()) {
32
+ await db
33
+ .update(adminLoginSessions)
34
+ .set({ revoked_at: at })
35
+ .where(and(eq(adminLoginSessions.admin_user_id, adminUserId), isNull(adminLoginSessions.revoked_at)));
36
+ },
37
+ };
38
+ }
@@ -34,11 +34,16 @@ import { affectedRowCount } from '../storage/storage-utils.js';
34
34
  export function createRefreshTokensRepository(db) {
35
35
  return {
36
36
  async issue(input) {
37
+ if (!Number.isSafeInteger(input.session_version) || input.session_version < -1) {
38
+ throw new Error('issueRefreshToken: session_version must be an integer >= -1');
39
+ }
37
40
  const now = new Date();
38
41
  const row = {
39
42
  id: input.id,
40
43
  admin_user_id: input.admin_user_id,
41
44
  token_hash: input.token_hash,
45
+ session_version: input.session_version,
46
+ sid: input.sid ?? null,
42
47
  issued_at: now,
43
48
  expires_at: input.expires_at,
44
49
  revoked_at: null,
@@ -51,6 +56,8 @@ export function createRefreshTokensRepository(db) {
51
56
  id: row.id,
52
57
  admin_user_id: row.admin_user_id,
53
58
  token_hash: row.token_hash,
59
+ session_version: row.session_version,
60
+ sid: row.sid,
54
61
  issued_at: now,
55
62
  expires_at: row.expires_at,
56
63
  user_agent: row.user_agent,
@@ -90,30 +97,17 @@ export function createRefreshTokensRepository(db) {
90
97
  .where(and(eq(adminRefreshTokens.id, id), isNull(adminRefreshTokens.revoked_at)));
91
98
  },
92
99
  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;
100
+ const [member] = await db
101
+ .select()
102
+ .from(adminRefreshTokens)
103
+ .where(eq(adminRefreshTokens.id, startId));
104
+ if (!member?.sid)
105
+ return 0; // Legacy credentials fail closed in the provider.
106
+ const result = await db
107
+ .update(adminRefreshTokens)
108
+ .set({ revoked_at: at, updated_at: at })
109
+ .where(and(eq(adminRefreshTokens.sid, member.sid), isNull(adminRefreshTokens.revoked_at)));
110
+ return affectedRowCount(result);
117
111
  },
118
112
  async revokeAllForUser(adminUserId, at = new Date()) {
119
113
  const result = await db
@@ -0,0 +1,2 @@
1
+ /** Retry only InnoDB deadlock victims: error 1213 guarantees transaction rollback. */
2
+ export declare function retryDeadlock<T>(operation: () => Promise<T>): Promise<T>;
@@ -0,0 +1,22 @@
1
+ /** Retry only InnoDB deadlock victims: error 1213 guarantees transaction rollback. */
2
+ export async function retryDeadlock(operation) {
3
+ for (let attempt = 0;; attempt++) {
4
+ try {
5
+ return await operation();
6
+ }
7
+ catch (error) {
8
+ let cause = error;
9
+ let deadlock = false;
10
+ for (let depth = 0; depth < 8 && cause && typeof cause === 'object'; depth++) {
11
+ if ('errno' in cause && cause.errno === 1213) {
12
+ deadlock = true;
13
+ break;
14
+ }
15
+ cause = 'cause' in cause ? cause.cause : undefined;
16
+ }
17
+ if (!deadlock || attempt >= 2)
18
+ throw error;
19
+ await new Promise((resolve) => setTimeout(resolve, 10 * 2 ** attempt + Math.random() * 10));
20
+ }
21
+ }
22
+ }
@@ -0,0 +1,11 @@
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 { SignInRateLimitStore } from '@byline/admin/auth';
9
+ import type { MySql2Database } from 'drizzle-orm/mysql2';
10
+ import type * as schema from '../../database/schema/index.js';
11
+ export declare function createSignInRateLimitStore(db: MySql2Database<typeof schema>): SignInRateLimitStore;
@@ -0,0 +1,37 @@
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 { eq, sql } from 'drizzle-orm';
9
+ import { adminSignInRateLimits as counters } from '../../database/schema/auth.js';
10
+ import { retryDeadlock } from './retry-deadlock.js';
11
+ export function createSignInRateLimitStore(db) {
12
+ return {
13
+ async consume(key, limit, expiresAt) {
14
+ // The upsert holds the row lock until our read commits, including concurrent first inserts.
15
+ return retryDeadlock(() => db.transaction(async (tx) => {
16
+ await tx
17
+ .insert(counters)
18
+ .values({ key, attempts: 1, expires_at: expiresAt })
19
+ .onDuplicateKeyUpdate({
20
+ set: { attempts: sql `least(${counters.attempts} + 1, ${limit + 1})` },
21
+ });
22
+ const [row] = await tx
23
+ .select({ attempts: counters.attempts })
24
+ .from(counters)
25
+ .where(eq(counters.key, key))
26
+ .for('update');
27
+ if (!row)
28
+ throw new Error('Sign-in counter update returned no row');
29
+ return row.attempts <= limit;
30
+ }));
31
+ },
32
+ async purgeExpired(before) {
33
+ const [result] = await db.execute(sql `delete from ${counters} where ${counters.expires_at} < ${before.toISOString().slice(0, 23).replace('T', ' ')} limit 100`);
34
+ return result.affectedRows;
35
+ },
36
+ };
37
+ }
@@ -50,6 +50,9 @@ export function classifyError(err) {
50
50
  const match = typeof e.message === 'string' ? e.message.match(/CONSTRAINT [`']([^`']+)[`']/) : null;
51
51
  return { code: DbErrorCodes.FOREIGN_KEY_VIOLATION, constraint: match?.[1] };
52
52
  }
53
+ if (e.errno === 1213 || e.errno === 1205) {
54
+ return { code: DbErrorCodes.LOCK_CONFLICT };
55
+ }
53
56
  e = e.cause;
54
57
  }
55
58
  return { code: DbErrorCodes.UNKNOWN };
@@ -99,3 +99,11 @@ runClassifyErrorContract([
99
99
  unrelatedError: { code: 'ER_DATA_TOO_LONG', errno: 1406, sqlState: '22001' },
100
100
  },
101
101
  ]);
102
+ describe('lock conflict classification', () => {
103
+ it.each([1213, 1205])('classifies driver lock failure %s through a cause', (value) => {
104
+ expect(classifyError({ cause: { errno: value } })).toEqual({ code: 'DB_LOCK_CONFLICT' });
105
+ });
106
+ it('does not classify connection loss as safe contention', () => {
107
+ expect(classifyError({ code: 'ECONNRESET', errno: 2013 })).toEqual({ code: 'DB_UNKNOWN' });
108
+ });
109
+ });
@@ -0,0 +1,9 @@
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 { DBManager } from '../../lib/db-manager.js';
9
+ export declare function lockCollectionRegistration(dbManager: DBManager, collectionId: string, mode: 'shared' | 'exclusive'): Promise<void>;
@@ -0,0 +1,32 @@
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_DATABASE, ERR_NOT_FOUND, ERR_VALIDATION } from '@byline/core';
9
+ import { sql } from 'drizzle-orm';
10
+ export async function lockCollectionRegistration(dbManager, collectionId, mode) {
11
+ if (mode !== 'shared' && mode !== 'exclusive') {
12
+ throw ERR_VALIDATION({ message: 'Invalid collection registration lock mode' });
13
+ }
14
+ if (!dbManager.isInTransaction()) {
15
+ throw ERR_DATABASE({
16
+ message: 'collection registration locks require an active transaction',
17
+ details: { collectionId },
18
+ });
19
+ }
20
+ const locked = await dbManager.get().execute(sql `
21
+ SELECT id FROM byline_collections
22
+ WHERE id = ${collectionId}
23
+ ${mode === 'exclusive' ? sql `FOR UPDATE` : sql `FOR SHARE`}
24
+ `);
25
+ const rows = locked[0];
26
+ if (rows.length === 0) {
27
+ throw ERR_NOT_FOUND({
28
+ message: 'collection registration not found',
29
+ details: { collectionId },
30
+ });
31
+ }
32
+ }
@@ -0,0 +1,20 @@
1
+ import { type DocumentRevisionReceipt, type DocumentRevisionStore, type DocumentRevisionTarget, type DocumentStructuralState, type LockedDocumentRevision } from '@byline/core';
2
+ import type { DBManager } from '../../lib/db-manager.js';
3
+ export declare class DocumentRevisions implements DocumentRevisionStore {
4
+ private readonly manager;
5
+ private readonly issued;
6
+ private readonly lockOrder;
7
+ private readonly acquiring;
8
+ constructor(manager: DBManager);
9
+ readStructure(params: {
10
+ collectionId: string;
11
+ documentIds?: readonly string[];
12
+ parentDocumentId?: string;
13
+ }): Promise<DocumentStructuralState[]>;
14
+ isInTransaction(): boolean;
15
+ assertCompatibleSchema(): Promise<void>;
16
+ private scope;
17
+ lock(targets: readonly DocumentRevisionTarget[]): Promise<readonly LockedDocumentRevision[]>;
18
+ private lockSequentially;
19
+ advance(locked: LockedDocumentRevision): Promise<DocumentRevisionReceipt>;
20
+ }