@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.
- package/LICENSE +373 -0
- package/README.md +283 -0
- package/dist/database/schema/auth.d.ts +951 -0
- package/dist/database/schema/auth.js +226 -0
- package/dist/database/schema/common.d.ts +168 -0
- package/dist/database/schema/common.js +130 -0
- package/dist/database/schema/index.d.ts +3419 -0
- package/dist/database/schema/index.js +895 -0
- package/dist/database/schema/schema-pins.test.node.d.ts +52 -0
- package/dist/database/schema/schema-pins.test.node.js +398 -0
- package/dist/index.d.ts +79 -0
- package/dist/index.js +151 -0
- package/dist/lib/boot-check.d.ts +22 -0
- package/dist/lib/boot-check.js +42 -0
- package/dist/lib/boot-check.test.node.d.ts +8 -0
- package/dist/lib/boot-check.test.node.js +46 -0
- package/dist/lib/db-manager.d.ts +54 -0
- package/dist/lib/db-manager.js +49 -0
- package/dist/lib/test-db.d.ts +37 -0
- package/dist/lib/test-db.js +111 -0
- package/dist/lib/test-helper.d.ts +33 -0
- package/dist/lib/test-helper.js +68 -0
- package/dist/modules/admin/admin-permissions-repository.d.ts +35 -0
- package/dist/modules/admin/admin-permissions-repository.js +101 -0
- package/dist/modules/admin/admin-preferences-repository.d.ts +62 -0
- package/dist/modules/admin/admin-preferences-repository.js +156 -0
- package/dist/modules/admin/admin-roles-repository.d.ts +11 -0
- package/dist/modules/admin/admin-roles-repository.js +209 -0
- package/dist/modules/admin/admin-store.d.ts +20 -0
- package/dist/modules/admin/admin-store.js +30 -0
- package/dist/modules/admin/admin-users-repository.d.ts +11 -0
- package/dist/modules/admin/admin-users-repository.js +303 -0
- package/dist/modules/admin/index.d.ts +27 -0
- package/dist/modules/admin/index.js +27 -0
- package/dist/modules/admin/refresh-tokens-repository.d.ts +34 -0
- package/dist/modules/admin/refresh-tokens-repository.js +160 -0
- package/dist/modules/audit/audit-commands.d.ts +29 -0
- package/dist/modules/audit/audit-commands.js +40 -0
- package/dist/modules/audit/audit-queries.d.ts +41 -0
- package/dist/modules/audit/audit-queries.js +169 -0
- package/dist/modules/counters/counters-commands.d.ts +54 -0
- package/dist/modules/counters/counters-commands.js +121 -0
- package/dist/modules/counters/tests/counters-concurrency.test.d.ts +8 -0
- package/dist/modules/counters/tests/counters-concurrency.test.js +111 -0
- package/dist/modules/storage/classify-error.d.ts +34 -0
- package/dist/modules/storage/classify-error.js +50 -0
- package/dist/modules/storage/classify-error.test.node.d.ts +8 -0
- package/dist/modules/storage/classify-error.test.node.js +89 -0
- package/dist/modules/storage/normalize-row.d.ts +55 -0
- package/dist/modules/storage/normalize-row.js +135 -0
- package/dist/modules/storage/normalize-row.test.node.d.ts +8 -0
- package/dist/modules/storage/normalize-row.test.node.js +89 -0
- package/dist/modules/storage/storage-commands.d.ts +443 -0
- package/dist/modules/storage/storage-commands.js +1263 -0
- package/dist/modules/storage/storage-insert.d.ts +21 -0
- package/dist/modules/storage/storage-insert.js +152 -0
- package/dist/modules/storage/storage-queries.d.ts +805 -0
- package/dist/modules/storage/storage-queries.js +1815 -0
- package/dist/modules/storage/storage-store-manifest.d.ts +77 -0
- package/dist/modules/storage/storage-store-manifest.js +168 -0
- package/dist/modules/storage/storage-utils.d.ts +49 -0
- package/dist/modules/storage/storage-utils.js +76 -0
- package/dist/modules/storage/tests/dialect-pins.integration.test.d.ts +8 -0
- package/dist/modules/storage/tests/dialect-pins.integration.test.js +266 -0
- package/dist/modules/storage/tests/storage-commands.test.d.ts +8 -0
- package/dist/modules/storage/tests/storage-commands.test.js +324 -0
- package/dist/modules/storage/tests/storage-document-paths.test.d.ts +8 -0
- package/dist/modules/storage/tests/storage-document-paths.test.js +214 -0
- package/dist/modules/storage/tests/storage-document-tree.test.d.ts +8 -0
- package/dist/modules/storage/tests/storage-document-tree.test.js +361 -0
- package/dist/modules/storage/tests/storage-queries.test.d.ts +8 -0
- package/dist/modules/storage/tests/storage-queries.test.js +685 -0
- package/dist/modules/storage/tests/storage-status-and-lifecycle.test.d.ts +8 -0
- package/dist/modules/storage/tests/storage-status-and-lifecycle.test.js +268 -0
- 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
|
+
]);
|