@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,169 @@
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 { desc, eq, sql } from 'drizzle-orm';
9
+ import { auditLog, documentVersions } from '../../database/schema/index.js';
10
+ import { toDate } from '../storage/storage-utils.js';
11
+ function toEntry(row) {
12
+ return {
13
+ id: row.id,
14
+ documentId: row.document_id,
15
+ collectionId: row.collection_id,
16
+ actorId: row.actor_id,
17
+ actorRealm: row.actor_realm,
18
+ action: row.action,
19
+ field: row.field,
20
+ before: row.before,
21
+ after: row.after,
22
+ occurredAt: row.occurred_at,
23
+ };
24
+ }
25
+ export class AuditQueries {
26
+ db;
27
+ constructor(db) {
28
+ this.db = db;
29
+ }
30
+ async getDocumentAuditLog(params) {
31
+ const page = params.page ?? 1;
32
+ const pageSize = params.page_size ?? 20;
33
+ const offset = (page - 1) * pageSize;
34
+ // `count(*)::int` (pg) → `CAST(COUNT(*) AS SIGNED)` (design spec §2).
35
+ const totalResult = await this.db
36
+ .select({ count: sql `CAST(COUNT(*) AS SIGNED)` })
37
+ .from(auditLog)
38
+ .where(eq(auditLog.document_id, params.document_id));
39
+ const total = Number(totalResult[0]?.count) || 0;
40
+ const totalPages = Math.ceil(total / pageSize);
41
+ const rows = await this.db
42
+ .select()
43
+ .from(auditLog)
44
+ .where(eq(auditLog.document_id, params.document_id))
45
+ // id is UUIDv7 — DESC is newest-first without a separate sort column.
46
+ .orderBy(desc(auditLog.id))
47
+ .limit(pageSize)
48
+ .offset(offset);
49
+ return {
50
+ entries: rows.map(toEntry),
51
+ meta: { total, page, pageSize, totalPages },
52
+ };
53
+ }
54
+ async findAuditLog(params) {
55
+ const page = params.page ?? 1;
56
+ const pageSize = params.page_size ?? 20;
57
+ const offset = (page - 1) * pageSize;
58
+ // The activity feed is the UNION of two disjoint event sources, normalised
59
+ // onto the audit-log column shape (see IAuditQueries.findAuditLog):
60
+ //
61
+ // 1. byline_document_versions — content saves. event_type maps to a
62
+ // `document.created` / `document.updated` action; created_by/created_at
63
+ // become actor_id/occurred_at; field/before/after are null. Restricted
64
+ // to create+update so any legacy 'delete' version rows can't surface
65
+ // (deletions live only in the audit log — the union double-counts
66
+ // nothing).
67
+ // 2. byline_audit_log — path/locale/status changes, deletions, and
68
+ // future admin-realm events, used as-is.
69
+ //
70
+ // Filters and ordering apply to the unioned result; occurred_at is the only
71
+ // cross-source sort key (the per-source UUIDv7 ids are separate sequences).
72
+ //
73
+ // MySQL divergences from the pg source (design spec §2, confirmed live):
74
+ // - `NULL::varchar` / `NULL::jsonb` casts → `CAST(NULL AS CHAR(128))` /
75
+ // `CAST(NULL AS JSON)`. An untyped bare `NULL` in one UNION leg while
76
+ // the sibling leg has a real typed column is fine for MySQL's type
77
+ // resolution, but an explicit CAST keeps the two legs' declared types
78
+ // symmetric and self-documenting, matching the pg source's intent.
79
+ // - `'document.' || event_type` → `CONCAT('document.', event_type)` —
80
+ // MySQL's `||` is logical OR by default (PIPES_AS_CONCAT is not a
81
+ // baseline assumption for this adapter), so string concatenation must
82
+ // use `CONCAT`.
83
+ // - `before` backtick-quoted throughout — confirmed live:
84
+ // `BEFORE` is a reserved word in MySQL (trigger syntax, `CREATE
85
+ // TRIGGER ... BEFORE INSERT`), so an unquoted `before` column alias
86
+ // or bare column reference is a syntax error (`ER_PARSE_ERROR`) both
87
+ // as an `AS before` alias declaration and as a bare `SELECT ...,
88
+ // before, ...` reference in the outer wrapper. `after` is NOT
89
+ // reserved (confirmed live) but is backtick-quoted alongside it for
90
+ // symmetry.
91
+ const union = sql `
92
+ SELECT id, document_id, collection_id, actor_id, actor_realm, action, field, \`before\`, \`after\`, occurred_at FROM (
93
+ SELECT
94
+ ${documentVersions.id} AS id,
95
+ ${documentVersions.document_id} AS document_id,
96
+ ${documentVersions.collection_id} AS collection_id,
97
+ ${documentVersions.created_by} AS actor_id,
98
+ CASE WHEN ${documentVersions.created_by} IS NULL THEN 'system' ELSE 'admin' END AS actor_realm,
99
+ CASE ${documentVersions.event_type}
100
+ WHEN 'create' THEN 'document.created'
101
+ WHEN 'update' THEN 'document.updated'
102
+ ELSE CONCAT('document.', ${documentVersions.event_type})
103
+ END AS action,
104
+ CAST(NULL AS CHAR(128)) AS field,
105
+ CAST(NULL AS JSON) AS \`before\`,
106
+ CAST(NULL AS JSON) AS \`after\`,
107
+ ${documentVersions.created_at} AS occurred_at
108
+ FROM ${documentVersions}
109
+ WHERE ${documentVersions.event_type} IN ('create', 'update')
110
+ UNION ALL
111
+ SELECT
112
+ ${auditLog.id} AS id,
113
+ ${auditLog.document_id} AS document_id,
114
+ ${auditLog.collection_id} AS collection_id,
115
+ ${auditLog.actor_id} AS actor_id,
116
+ ${auditLog.actor_realm} AS actor_realm,
117
+ ${auditLog.action} AS action,
118
+ ${auditLog.field} AS field,
119
+ ${auditLog.before} AS \`before\`,
120
+ ${auditLog.after} AS \`after\`,
121
+ ${auditLog.occurred_at} AS occurred_at
122
+ FROM ${auditLog}
123
+ ) AS activity`;
124
+ const filters = [];
125
+ if (params.actorId)
126
+ filters.push(sql `actor_id = ${params.actorId}`);
127
+ if (params.collectionId)
128
+ filters.push(sql `collection_id = ${params.collectionId}`);
129
+ if (params.action)
130
+ filters.push(sql `action = ${params.action}`);
131
+ if (params.from)
132
+ filters.push(sql `occurred_at >= ${params.from}`);
133
+ if (params.to)
134
+ filters.push(sql `occurred_at <= ${params.to}`);
135
+ const whereClause = filters.length > 0 ? sql ` WHERE ${sql.join(filters, sql ` AND `)}` : sql ``;
136
+ // `db.execute()` on the mysql2 driver returns a `[rows, fields]` tuple
137
+ // (unlike pg's `{ rows }` result object) — see `storage-queries.ts` for
138
+ // the established pattern this mirrors.
139
+ const totalResult = (await this.db.execute(sql `SELECT CAST(COUNT(*) AS SIGNED) AS count FROM (${union}${whereClause}) AS filtered`));
140
+ const total = Number(totalResult[0][0]?.count) || 0;
141
+ const totalPages = Math.ceil(total / pageSize);
142
+ const result = (await this.db.execute(sql `${union}${whereClause} ORDER BY occurred_at DESC, id DESC LIMIT ${pageSize} OFFSET ${offset}`));
143
+ return {
144
+ entries: result[0].map((row) => toEntryFromActivityRow(row)),
145
+ meta: { total, page, pageSize, totalPages },
146
+ };
147
+ }
148
+ }
149
+ /** Like `toEntry`, but for a raw `ActivityRow` off the UNION — see `toDate` (`storage-utils.ts`). */
150
+ function toEntryFromActivityRow(row) {
151
+ return {
152
+ id: row.id,
153
+ documentId: row.document_id,
154
+ collectionId: row.collection_id,
155
+ actorId: row.actor_id,
156
+ actorRealm: row.actor_realm,
157
+ action: row.action,
158
+ field: row.field,
159
+ before: row.before,
160
+ after: row.after,
161
+ // `occurred_at` is NOT NULL on both UNION legs (`documentVersions.created_at`,
162
+ // `auditLog.occurred_at`), so the shared `toDate`'s `null` branch is
163
+ // unreachable here — the cast reflects that, not an assumption.
164
+ occurredAt: toDate(row.occurred_at, 'occurred_at'),
165
+ };
166
+ }
167
+ export function createAuditQueries(db) {
168
+ return new AuditQueries(db);
169
+ }
@@ -0,0 +1,54 @@
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 { ICounterCommands } from '@byline/core';
9
+ import type { Pool } from 'mysql2/promise';
10
+ /**
11
+ * Table-emulated counters (design spec §3 — "the one real design
12
+ * divergence"). MySQL has no `CREATE SEQUENCE`, so `byline_counter_groups`
13
+ * IS the allocator: `current_value` holds the counter's live state, advanced
14
+ * in place with the classic `LAST_INSERT_ID(expr)` idiom, which makes an
15
+ * atomic `UPDATE`/`INSERT ... ON DUPLICATE KEY UPDATE` return its own new
16
+ * value via a same-connection `SELECT LAST_INSERT_ID()` — no separate
17
+ * `SELECT ... FOR UPDATE` round trip needed.
18
+ *
19
+ * Static (`ensureCounterGroup` + `nextCounterValue`) and runtime-scoped
20
+ * (`nextScopedCounterValue`) counters deliberately share the one
21
+ * `byline_counter_groups` table, keyed by name — see the schema comment on
22
+ * `counterGroups` (`packages/db-mysql/src/database/schema/index.ts`) for why
23
+ * a second `byline_counter_scopes` table (as the plan's implementation note
24
+ * first sketched) cannot honour `ICounterCommands`'s documented contract
25
+ * that a scope self-registered via `nextScopedCounterValue` continues the
26
+ * very same count when subsequently read through `nextCounterValue` — two
27
+ * independently-incrementing tables cannot both be "the same sequence"
28
+ * without every scoped call also writing the static table to keep them in
29
+ * sync, at which point it is one table with extra steps.
30
+ *
31
+ * Every method here takes the raw mysql2 **pool**, not `DBManager` — counters
32
+ * must never run inside the ambient `withTransaction` (a long document-create
33
+ * transaction holding the counter row would serialise every other writer in
34
+ * that group; gaps on rollback are already contractual, see
35
+ * `ICounterCommands.nextCounterValue`'s docstring). `nextCounterValue` and
36
+ * `nextScopedCounterValue` additionally check out a single connection via
37
+ * `pool.getConnection()` for their two-statement bodies — `LAST_INSERT_ID()`
38
+ * is per-connection session state (confirmed live: a second, freshly-opened
39
+ * connection reads it back as `0` regardless of what a sibling connection
40
+ * just set it to), so issuing the two statements through `pool.execute()` /
41
+ * `pool.query()` directly would let the pool hand each one a different
42
+ * physical connection and silently return another session's value.
43
+ */
44
+ export declare class CounterCommands implements ICounterCommands {
45
+ private pool;
46
+ constructor(pool: Pool);
47
+ ensureCounterGroup(groupName: string): Promise<{
48
+ groupName: string;
49
+ sequenceName: string;
50
+ }>;
51
+ nextCounterValue(groupName: string): Promise<number>;
52
+ nextScopedCounterValue(scopeName: string): Promise<number>;
53
+ }
54
+ export declare function createCounterCommands(pool: Pool): ICounterCommands;
@@ -0,0 +1,121 @@
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
+ * Table-emulated counters (design spec §3 — "the one real design
10
+ * divergence"). MySQL has no `CREATE SEQUENCE`, so `byline_counter_groups`
11
+ * IS the allocator: `current_value` holds the counter's live state, advanced
12
+ * in place with the classic `LAST_INSERT_ID(expr)` idiom, which makes an
13
+ * atomic `UPDATE`/`INSERT ... ON DUPLICATE KEY UPDATE` return its own new
14
+ * value via a same-connection `SELECT LAST_INSERT_ID()` — no separate
15
+ * `SELECT ... FOR UPDATE` round trip needed.
16
+ *
17
+ * Static (`ensureCounterGroup` + `nextCounterValue`) and runtime-scoped
18
+ * (`nextScopedCounterValue`) counters deliberately share the one
19
+ * `byline_counter_groups` table, keyed by name — see the schema comment on
20
+ * `counterGroups` (`packages/db-mysql/src/database/schema/index.ts`) for why
21
+ * a second `byline_counter_scopes` table (as the plan's implementation note
22
+ * first sketched) cannot honour `ICounterCommands`'s documented contract
23
+ * that a scope self-registered via `nextScopedCounterValue` continues the
24
+ * very same count when subsequently read through `nextCounterValue` — two
25
+ * independently-incrementing tables cannot both be "the same sequence"
26
+ * without every scoped call also writing the static table to keep them in
27
+ * sync, at which point it is one table with extra steps.
28
+ *
29
+ * Every method here takes the raw mysql2 **pool**, not `DBManager` — counters
30
+ * must never run inside the ambient `withTransaction` (a long document-create
31
+ * transaction holding the counter row would serialise every other writer in
32
+ * that group; gaps on rollback are already contractual, see
33
+ * `ICounterCommands.nextCounterValue`'s docstring). `nextCounterValue` and
34
+ * `nextScopedCounterValue` additionally check out a single connection via
35
+ * `pool.getConnection()` for their two-statement bodies — `LAST_INSERT_ID()`
36
+ * is per-connection session state (confirmed live: a second, freshly-opened
37
+ * connection reads it back as `0` regardless of what a sibling connection
38
+ * just set it to), so issuing the two statements through `pool.execute()` /
39
+ * `pool.query()` directly would let the pool hand each one a different
40
+ * physical connection and silently return another session's value.
41
+ */
42
+ export class CounterCommands {
43
+ pool;
44
+ constructor(pool) {
45
+ this.pool = pool;
46
+ }
47
+ async ensureCounterGroup(groupName) {
48
+ if (!groupName || typeof groupName !== 'string') {
49
+ throw new Error(`ensureCounterGroup: groupName must be a non-empty string`);
50
+ }
51
+ // No DB sequence object exists on this dialect — the emulation row's own
52
+ // identity stands in for the sequence name the Postgres adapter reports.
53
+ const sequenceName = `byline_counter_groups:${groupName}`;
54
+ // `ON DUPLICATE KEY UPDATE group_name = group_name` is a race-safe no-op
55
+ // on an existing row (confirmed live: reports `ROW_COUNT() = 0` and
56
+ // leaves `current_value` untouched) — unlike Postgres's `CREATE SEQUENCE
57
+ // IF NOT EXISTS`, this single statement has no two-phase check-then-act
58
+ // window for two booting processes to race through, so there is no
59
+ // unique-violation to absorb here.
60
+ await this.pool.execute('INSERT INTO byline_counter_groups (group_name, sequence_name, current_value) VALUES (?, ?, 0) ON DUPLICATE KEY UPDATE group_name = group_name', [groupName, sequenceName]);
61
+ return { groupName, sequenceName };
62
+ }
63
+ async nextCounterValue(groupName) {
64
+ if (!groupName || typeof groupName !== 'string') {
65
+ throw new Error(`nextCounterValue: groupName must be a non-empty string`);
66
+ }
67
+ // Never `this.pool.execute()` here — see the class docblock. Both
68
+ // statements below must land on the one checked-out connection.
69
+ const connection = await this.pool.getConnection();
70
+ try {
71
+ const [result] = await connection.execute('UPDATE byline_counter_groups SET current_value = LAST_INSERT_ID(current_value + 1) WHERE group_name = ?', [groupName]);
72
+ // Zero affected rows means the group was never registered — surface
73
+ // that immediately rather than silently seeding it (mirrors pg's
74
+ // "unregistered group is a configuration error" honesty).
75
+ if (result.affectedRows === 0) {
76
+ throw new Error(`nextCounterValue: counter group "${groupName}" is not registered. ` +
77
+ `Call ensureCounterGroup at boot before any document create that uses it.`);
78
+ }
79
+ const [rows] = await connection.query('SELECT LAST_INSERT_ID() AS v');
80
+ const row = rows[0];
81
+ if (row === undefined) {
82
+ throw new Error(`nextCounterValue: LAST_INSERT_ID() returned no row for group "${groupName}"`);
83
+ }
84
+ return typeof row.v === 'number' ? row.v : Number(row.v);
85
+ }
86
+ finally {
87
+ connection.release();
88
+ }
89
+ }
90
+ async nextScopedCounterValue(scopeName) {
91
+ if (!scopeName || typeof scopeName !== 'string') {
92
+ throw new Error(`nextScopedCounterValue: scopeName must be a non-empty string`);
93
+ }
94
+ const sequenceName = `byline_counter_groups:${scopeName}`;
95
+ // One atomic ensure-then-allocate statement: the `VALUES` seed itself
96
+ // wraps `LAST_INSERT_ID(1)`, so the insert path (brand-new scope) also
97
+ // leaves `LAST_INSERT_ID()` reading `1` on this connection — confirmed
98
+ // live, including that a second concurrent insert attempt on the same
99
+ // scope_name correctly falls through to the `ON DUPLICATE KEY UPDATE`
100
+ // branch rather than erroring. Still needs the same-connection
101
+ // discipline as `nextCounterValue` — see the class docblock.
102
+ const connection = await this.pool.getConnection();
103
+ try {
104
+ await connection.execute('INSERT INTO byline_counter_groups (group_name, sequence_name, current_value) ' +
105
+ 'VALUES (?, ?, LAST_INSERT_ID(1)) ' +
106
+ 'ON DUPLICATE KEY UPDATE current_value = LAST_INSERT_ID(current_value + 1)', [scopeName, sequenceName]);
107
+ const [rows] = await connection.query('SELECT LAST_INSERT_ID() AS v');
108
+ const row = rows[0];
109
+ if (row === undefined) {
110
+ throw new Error(`nextScopedCounterValue: LAST_INSERT_ID() returned no row for scope "${scopeName}"`);
111
+ }
112
+ return typeof row.v === 'number' ? row.v : Number(row.v);
113
+ }
114
+ finally {
115
+ connection.release();
116
+ }
117
+ }
118
+ }
119
+ export function createCounterCommands(pool) {
120
+ return new CounterCommands(pool);
121
+ }
@@ -0,0 +1,8 @@
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
+ export {};
@@ -0,0 +1,111 @@
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
+ * Guards the concurrency design brief §C calls "the whole task": the
10
+ * two-statement `UPDATE ... SET current_value = LAST_INSERT_ID(current_value
11
+ * + 1)` + `SELECT LAST_INSERT_ID()` body of `nextCounterValue` MUST run on
12
+ * one checked-out connection (`LAST_INSERT_ID()` is per-connection state —
13
+ * confirmed live during Task 11: a fresh connection reads back `0` regardless
14
+ * of what a sibling connection just set). Nothing in the shared
15
+ * `@byline/db-conformance` `countersSuite` exercises this specific risk
16
+ * surface: its only parallel test (`counters.ts:87-98`) is 8-way on
17
+ * `nextScopedCounterValue`, whose body is a single atomic
18
+ * `INSERT ... ON DUPLICATE KEY UPDATE` statement — no same-connection
19
+ * discipline to get wrong, because there is only ever one statement.
20
+ * `nextCounterValue`'s two-statement body is the one method where a refactor
21
+ * could silently swap `connection.execute`/`connection.query` for
22
+ * `pool.execute`/`pool.query` (dropping the checked-out connection and
23
+ * letting the pool hand each statement to a different physical connection)
24
+ * and still pass every other test in this adapter — 280/280 unit, 138/138
25
+ * integration — because the resulting duplicate/zero counter values only
26
+ * show up under real concurrent load, which nothing else in the suite
27
+ * generates against this specific method. This test is that guard, and it
28
+ * belongs here (a MySQL-specific emulation detail — the shared conformance
29
+ * suite exercises `ICounterCommands`' contract behaviour, not this
30
+ * adapter's own connection-checkout implementation).
31
+ *
32
+ * Uses its own dedicated pool (not the shared singleton `test-helper.ts`
33
+ * hands other integration test files in this same worker process — vitest
34
+ * runs this suite with `fileParallelism: false` / `maxWorkers: 1`, so a
35
+ * monkey-patched shared pool would leak into whichever file runs next) so
36
+ * `pool.getConnection` can be instrumented without any cross-file risk.
37
+ */
38
+ import mysql from 'mysql2/promise';
39
+ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
40
+ import { assertTestDatabase, resetTestDatabase } from '../../../lib/test-db.js';
41
+ import { createCounterCommands } from '../counters-commands.js';
42
+ describe('CounterCommands connection discipline under concurrency (mysql, live database)', () => {
43
+ let pool;
44
+ let counters;
45
+ beforeAll(async () => {
46
+ const connectionString = process.env.BYLINE_DB_MYSQL_CONNECTION_STRING;
47
+ assertTestDatabase(connectionString);
48
+ await resetTestDatabase(connectionString);
49
+ // connectionLimit: 4 mirrors the test harness's own pool sizing
50
+ // (test-helper.ts) — small enough that 20 concurrent nextCounterValue
51
+ // calls genuinely contend for connections, which is the point.
52
+ pool = mysql.createPool({
53
+ uri: connectionString,
54
+ connectionLimit: 4,
55
+ timezone: 'Z',
56
+ decimalNumbers: false,
57
+ charset: 'UTF8MB4_0900_AI_CI',
58
+ });
59
+ counters = createCounterCommands(pool);
60
+ });
61
+ afterAll(async () => {
62
+ await pool.end();
63
+ });
64
+ it('20 parallel nextCounterValue calls on one group yield the expected contiguous set, genuinely contending a 4-connection pool', async () => {
65
+ const groupName = `concurrency-test-${Date.now()}`;
66
+ await counters.ensureCounterGroup(groupName);
67
+ // Instrument connection checkout/release to prove the calls genuinely
68
+ // overlapped rather than serialized. Distinctness alone does not prove
69
+ // this: 20 calls run one at a time (each getting its own connection in
70
+ // turn, or even all 20 on the SAME connection sequentially) would also
71
+ // return 20 distinct, contiguous values — the two-statement
72
+ // same-connection defect this test exists to catch only shows up under
73
+ // real overlap, so the test must force and observe that overlap
74
+ // directly rather than infer it from the results.
75
+ let activeConnections = 0;
76
+ let maxObservedConcurrency = 0;
77
+ const originalGetConnection = pool.getConnection.bind(pool);
78
+ pool.getConnection = async (...args) => {
79
+ const conn = await originalGetConnection(...args);
80
+ activeConnections++;
81
+ maxObservedConcurrency = Math.max(maxObservedConcurrency, activeConnections);
82
+ const originalRelease = conn.release.bind(conn);
83
+ conn.release = (...releaseArgs) => {
84
+ activeConnections--;
85
+ return originalRelease(...releaseArgs);
86
+ };
87
+ return conn;
88
+ };
89
+ let results;
90
+ try {
91
+ results = await Promise.all(Array.from({ length: 20 }, () => counters.nextCounterValue(groupName)));
92
+ }
93
+ finally {
94
+ pool.getConnection = originalGetConnection;
95
+ }
96
+ // Distinct AND the exact expected contiguous set {1..20} — a silently
97
+ // duplicated or zero return (the actual failure mode of a connection-
98
+ // discipline regression, per LAST_INSERT_ID()'s per-connection scoping)
99
+ // fails this, not just a weaker "no two values are equal" check.
100
+ const sorted = [...results].sort((a, b) => a - b);
101
+ expect(new Set(results).size).toBe(20);
102
+ expect(sorted).toEqual(Array.from({ length: 20 }, (_, i) => i + 1));
103
+ // Genuine contention, not accidental interleaving: with
104
+ // connectionLimit: 4 and 20 concurrent callers, more than one
105
+ // connection must have been checked out simultaneously at some point.
106
+ // A fully-serialized run — the shape a same-connection-checkout
107
+ // regression's fix-by-coincidence could produce — would never exceed 1
108
+ // here.
109
+ expect(maxObservedConcurrency).toBeGreaterThan(1);
110
+ });
111
+ });
@@ -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 DbErrorClassification } from '@byline/core';
9
+ /**
10
+ * Classify a MySQL driver error. Walks a short `cause` chain (Drizzle's
11
+ * `DrizzleQueryError` → the underlying mysql2 error) looking for a
12
+ * duplicate-key error and returns the carried index name. Mirrors
13
+ * `packages/db-postgres/src/modules/storage/classify-error.ts`, whose
14
+ * pg-anatomy docblock this docblock intentionally parallels.
15
+ *
16
+ * MySQL error anatomy (verified against a live MySQL 9.7.1 server, not
17
+ * assumed): a duplicate-key violation raises `ERROR 1062 (23000)`, and
18
+ * mysql2 surfaces it as `{ code: 'ER_DUP_ENTRY', errno: 1062, sqlState:
19
+ * '23000', message: "Duplicate entry '…' for key '<table>.<index>'" }`.
20
+ * Unlike Postgres, there is no structural `constraint` property — the index
21
+ * name must be parsed out of the message, and MySQL 8.0+ qualifies it with
22
+ * the table name. Matching on the numeric `errno` (not the `code` string)
23
+ * because it is stable across mysql2 versions and locales; the message text
24
+ * is not guaranteed to be, though the `for key '…'` shape has held since
25
+ * MySQL 5.x and is what we parse.
26
+ *
27
+ * Carriage semantics (binding, from the #45 review carry-forward): strip the
28
+ * `<table>.` qualifier and carry the **bare index name**, so both adapters'
29
+ * `classifyError` report the same shape for the same logical failure — core
30
+ * never learns driver anatomy. `packages/core/src/services/document-lifecycle
31
+ * /internals.ts` only substring-matches the constraint, so the qualified
32
+ * form would also have worked, but a contract is uniform or it is not one.
33
+ */
34
+ export declare function classifyError(err: unknown): DbErrorClassification;
@@ -0,0 +1,50 @@
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 { DbErrorCodes } from '@byline/core';
9
+ /**
10
+ * Classify a MySQL driver error. Walks a short `cause` chain (Drizzle's
11
+ * `DrizzleQueryError` → the underlying mysql2 error) looking for a
12
+ * duplicate-key error and returns the carried index name. Mirrors
13
+ * `packages/db-postgres/src/modules/storage/classify-error.ts`, whose
14
+ * pg-anatomy docblock this docblock intentionally parallels.
15
+ *
16
+ * MySQL error anatomy (verified against a live MySQL 9.7.1 server, not
17
+ * assumed): a duplicate-key violation raises `ERROR 1062 (23000)`, and
18
+ * mysql2 surfaces it as `{ code: 'ER_DUP_ENTRY', errno: 1062, sqlState:
19
+ * '23000', message: "Duplicate entry '…' for key '<table>.<index>'" }`.
20
+ * Unlike Postgres, there is no structural `constraint` property — the index
21
+ * name must be parsed out of the message, and MySQL 8.0+ qualifies it with
22
+ * the table name. Matching on the numeric `errno` (not the `code` string)
23
+ * because it is stable across mysql2 versions and locales; the message text
24
+ * is not guaranteed to be, though the `for key '…'` shape has held since
25
+ * MySQL 5.x and is what we parse.
26
+ *
27
+ * Carriage semantics (binding, from the #45 review carry-forward): strip the
28
+ * `<table>.` qualifier and carry the **bare index name**, so both adapters'
29
+ * `classifyError` report the same shape for the same logical failure — core
30
+ * never learns driver anatomy. `packages/core/src/services/document-lifecycle
31
+ * /internals.ts` only substring-matches the constraint, so the qualified
32
+ * form would also have worked, but a contract is uniform or it is not one.
33
+ */
34
+ export function classifyError(err) {
35
+ let e = err;
36
+ for (let i = 0; i < 3 && e != null && typeof e === 'object'; i++) {
37
+ if (e.errno === 1062) {
38
+ const match = typeof e.message === 'string' ? e.message.match(/for key '([^']+)'/) : null;
39
+ const qualifiedName = match?.[1];
40
+ // MySQL 8.0+ qualifies the index name as `<table>.<index>` — strip the
41
+ // table qualifier so the carried value is the bare index name only.
42
+ const constraint = qualifiedName?.includes('.')
43
+ ? qualifiedName.slice(qualifiedName.indexOf('.') + 1)
44
+ : qualifiedName;
45
+ return { code: DbErrorCodes.UNIQUE_VIOLATION, constraint };
46
+ }
47
+ e = e.cause;
48
+ }
49
+ return { code: DbErrorCodes.UNKNOWN };
50
+ }
@@ -0,0 +1,8 @@
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
+ export {};
@@ -0,0 +1,89 @@
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 { runClassifyErrorContract } from '@byline/db-conformance';
9
+ import { describe, expect, it } from 'vitest';
10
+ import { classifyError } from './classify-error.js';
11
+ describe('classifyError (mysql)', () => {
12
+ it('classifies a raw ER_DUP_ENTRY (errno 1062) as DB_UNIQUE_VIOLATION with the bare index name', () => {
13
+ const err = {
14
+ code: 'ER_DUP_ENTRY',
15
+ errno: 1062,
16
+ sqlState: '23000',
17
+ message: "Duplicate entry '1' for key 't_dup.idx_document_paths_collection_locale_path'",
18
+ };
19
+ expect(classifyError(err)).toEqual({
20
+ code: 'DB_UNIQUE_VIOLATION',
21
+ constraint: 'idx_document_paths_collection_locale_path',
22
+ });
23
+ });
24
+ it('walks a Drizzle-style cause chain to the underlying mysql2 error', () => {
25
+ const err = {
26
+ name: 'DrizzleQueryError',
27
+ cause: {
28
+ code: 'ER_DUP_ENTRY',
29
+ errno: 1062,
30
+ sqlState: '23000',
31
+ message: "Duplicate entry '2' for key 'byline_document_paths.some_other_unique'",
32
+ },
33
+ };
34
+ expect(classifyError(err)).toEqual({
35
+ code: 'DB_UNIQUE_VIOLATION',
36
+ constraint: 'some_other_unique',
37
+ });
38
+ });
39
+ it('matches on the numeric errno, not the code string', () => {
40
+ // Same errno, a code string that would not stringwise-match anything —
41
+ // classification still succeeds because the match is on `errno`.
42
+ const err = {
43
+ code: 'SOME_OTHER_CODE',
44
+ errno: 1062,
45
+ message: "Duplicate entry '1' for key 't.idx_x'",
46
+ };
47
+ expect(classifyError(err).code).toBe('DB_UNIQUE_VIOLATION');
48
+ });
49
+ it('returns DB_UNKNOWN for a non-duplicate-key error', () => {
50
+ expect(classifyError({
51
+ code: 'ER_NO_REFERENCED_ROW_2',
52
+ errno: 1452,
53
+ sqlState: '23000',
54
+ message: 'Cannot add or update a child row: a foreign key constraint fails',
55
+ })).toEqual({ code: 'DB_UNKNOWN' });
56
+ });
57
+ it('returns DB_UNKNOWN for a non-error value', () => {
58
+ expect(classifyError(undefined)).toEqual({ code: 'DB_UNKNOWN' });
59
+ expect(classifyError('boom')).toEqual({ code: 'DB_UNKNOWN' });
60
+ expect(classifyError(null)).toEqual({ code: 'DB_UNKNOWN' });
61
+ });
62
+ });
63
+ runClassifyErrorContract([
64
+ {
65
+ adapterName: 'mysql',
66
+ classifyError,
67
+ uniqueViolationError: {
68
+ code: 'ER_DUP_ENTRY',
69
+ errno: 1062,
70
+ sqlState: '23000',
71
+ message: "Duplicate entry 'some-uuid' for key 'byline_document_paths.idx_document_paths_collection_locale_path'",
72
+ },
73
+ nestedUniqueViolationError: {
74
+ name: 'DrizzleQueryError',
75
+ cause: {
76
+ code: 'ER_DUP_ENTRY',
77
+ errno: 1062,
78
+ sqlState: '23000',
79
+ message: "Duplicate entry 'some-uuid' for key 'byline_document_paths.idx_document_paths_collection_locale_path'",
80
+ },
81
+ },
82
+ unrelatedError: {
83
+ code: 'ER_NO_REFERENCED_ROW_2',
84
+ errno: 1452,
85
+ sqlState: '23000',
86
+ message: 'Cannot add or update a child row: a foreign key constraint fails',
87
+ },
88
+ },
89
+ ]);