@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,77 @@
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
+ * Generated UNION ALL column projections for the EAV store tables.
9
+ *
10
+ * Instead of maintaining 7 hand-synchronized SQL fragments with 41
11
+ * positional columns each, we generate the SELECT list for each store
12
+ * table from a single column manifest. Adding a column or a new store
13
+ * table is a one-line change in the manifest.
14
+ *
15
+ * The manifest data (`storeColumnManifest`, `storeTableNames`) is dialect
16
+ * independent and lives in `@byline/core` — the same manifest
17
+ * `@byline/db-postgres` consumes. This module owns everything
18
+ * MySQL-specific: the generated `SQL` fragments, and `mysqlNullCast()`,
19
+ * which renders the manifest's abstract `nullCast` type names (`'uuid'`,
20
+ * `'boolean'`, …) as MySQL `CAST(NULL AS <type>)` expressions — MySQL has
21
+ * no `NULL::<type>` shorthand.
22
+ *
23
+ * The adapter-agnostic pieces (`StoreType`, `ALL_STORE_TYPES`,
24
+ * `fieldTypeToStore`, `fieldTypeToStoreType`) also live in `@byline/core`
25
+ * so `@byline/client` can consume the same mapping without taking a
26
+ * dependency on either adapter. Mirrors
27
+ * `packages/db-postgres/src/modules/storage/storage-store-manifest.ts`.
28
+ */
29
+ import { type StoreType } from '@byline/core';
30
+ import { type SQL } from 'drizzle-orm';
31
+ export { ALL_STORE_TYPES, fieldTypeToStore, fieldTypeToStoreType, type StoreType, storeTableNames, } from '@byline/core';
32
+ /** Short-form alias used by adapter-internal call sites. */
33
+ export declare const allStoreTypes: readonly StoreType[];
34
+ /** The field_type literal emitted for each store table in the UNION ALL. */
35
+ declare const fieldTypeLiterals: Record<StoreType, string>;
36
+ /**
37
+ * Map the manifest's abstract `nullCast` type name to its MySQL `CAST(NULL
38
+ * AS <type>)` expression. MySQL has no `NULL::<type>` shorthand (that's
39
+ * Postgres-only syntax), so every branch of the UNION ALL that doesn't
40
+ * provide a given column emits an explicit `CAST`.
41
+ *
42
+ * Each mapping was verified against the live MySQL 9.7.1 container (not
43
+ * just read from the docs) by UNIONing a `CAST(NULL AS …)` branch against a
44
+ * branch selecting a real, populated column of the corresponding store type
45
+ * and confirming the driver returns the real value unmodified (no
46
+ * truncation, no precision loss) — see the Task 10A report for the
47
+ * transcript. Precision-bearing casts (`TIME(3)`, `DATETIME(6)`,
48
+ * `DECIMAL(10,2)`) mirror the schema's own column precision
49
+ * (`packages/db-mysql/src/database/schema/index.ts`) exactly, rather than
50
+ * relying on MySQL's default (whole-second time, `DECIMAL(10,0)`), so a
51
+ * NULL-cast branch can never be the one that silently narrows the unified
52
+ * column's type. `DATETIME(6)` here specifically must track
53
+ * `value_timestamp_tz`'s own `fsp` (Task 11 moved it from 3 to 6, matching
54
+ * pg's microsecond precision — see `common.ts`'s `auditTimestamp` docblock)
55
+ * — re-verified live post-move via `storage-queries.test.ts`'s
56
+ * `datetime`-field round-trip test, which exercises exactly this branch
57
+ * (a text-typed sibling row's UNION leg lands on this `CAST`) and confirmed
58
+ * full microsecond fidelity survives.
59
+ */
60
+ export declare function mysqlNullCast(nullCast: string): string;
61
+ /** Number of columns in the unified output (base + field_type + type-specific). */
62
+ export declare const UNIFIED_COLUMN_COUNT: number;
63
+ /**
64
+ * Build the SELECT column list for a given store table type.
65
+ *
66
+ * Base columns (id, document_version_id, etc.) are passed through directly.
67
+ * The `field_type` column is emitted as a string literal.
68
+ * Type-specific columns are either mapped to the source expression or
69
+ * emitted as a typed NULL.
70
+ */
71
+ declare function buildSelectList(storeType: StoreType): string;
72
+ /**
73
+ * Get the Drizzle SQL fragment for a store type's SELECT list.
74
+ * Results are cached — the generation runs once at module load.
75
+ */
76
+ export declare function storeSelectList(storeType: StoreType): SQL;
77
+ export { buildSelectList, fieldTypeLiterals };
@@ -0,0 +1,168 @@
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
+ * Generated UNION ALL column projections for the EAV store tables.
9
+ *
10
+ * Instead of maintaining 7 hand-synchronized SQL fragments with 41
11
+ * positional columns each, we generate the SELECT list for each store
12
+ * table from a single column manifest. Adding a column or a new store
13
+ * table is a one-line change in the manifest.
14
+ *
15
+ * The manifest data (`storeColumnManifest`, `storeTableNames`) is dialect
16
+ * independent and lives in `@byline/core` — the same manifest
17
+ * `@byline/db-postgres` consumes. This module owns everything
18
+ * MySQL-specific: the generated `SQL` fragments, and `mysqlNullCast()`,
19
+ * which renders the manifest's abstract `nullCast` type names (`'uuid'`,
20
+ * `'boolean'`, …) as MySQL `CAST(NULL AS <type>)` expressions — MySQL has
21
+ * no `NULL::<type>` shorthand.
22
+ *
23
+ * The adapter-agnostic pieces (`StoreType`, `ALL_STORE_TYPES`,
24
+ * `fieldTypeToStore`, `fieldTypeToStoreType`) also live in `@byline/core`
25
+ * so `@byline/client` can consume the same mapping without taking a
26
+ * dependency on either adapter. Mirrors
27
+ * `packages/db-postgres/src/modules/storage/storage-store-manifest.ts`.
28
+ */
29
+ import { ALL_STORE_TYPES, storeColumnManifest } from '@byline/core';
30
+ import { sql } from 'drizzle-orm';
31
+ // Re-export for adapter-internal consumers.
32
+ export { ALL_STORE_TYPES, fieldTypeToStore, fieldTypeToStoreType, storeTableNames, } from '@byline/core';
33
+ /** Short-form alias used by adapter-internal call sites. */
34
+ export const allStoreTypes = ALL_STORE_TYPES;
35
+ // ---------------------------------------------------------------------------
36
+ // SQL generation
37
+ // ---------------------------------------------------------------------------
38
+ /** The field_type literal emitted for each store table in the UNION ALL. */
39
+ const fieldTypeLiterals = {
40
+ text: 'text',
41
+ numeric: 'numeric',
42
+ boolean: 'boolean',
43
+ datetime: 'datetime',
44
+ json: 'richText',
45
+ relation: 'relation',
46
+ file: 'file',
47
+ };
48
+ /**
49
+ * Map the manifest's abstract `nullCast` type name to its MySQL `CAST(NULL
50
+ * AS <type>)` expression. MySQL has no `NULL::<type>` shorthand (that's
51
+ * Postgres-only syntax), so every branch of the UNION ALL that doesn't
52
+ * provide a given column emits an explicit `CAST`.
53
+ *
54
+ * Each mapping was verified against the live MySQL 9.7.1 container (not
55
+ * just read from the docs) by UNIONing a `CAST(NULL AS …)` branch against a
56
+ * branch selecting a real, populated column of the corresponding store type
57
+ * and confirming the driver returns the real value unmodified (no
58
+ * truncation, no precision loss) — see the Task 10A report for the
59
+ * transcript. Precision-bearing casts (`TIME(3)`, `DATETIME(6)`,
60
+ * `DECIMAL(10,2)`) mirror the schema's own column precision
61
+ * (`packages/db-mysql/src/database/schema/index.ts`) exactly, rather than
62
+ * relying on MySQL's default (whole-second time, `DECIMAL(10,0)`), so a
63
+ * NULL-cast branch can never be the one that silently narrows the unified
64
+ * column's type. `DATETIME(6)` here specifically must track
65
+ * `value_timestamp_tz`'s own `fsp` (Task 11 moved it from 3 to 6, matching
66
+ * pg's microsecond precision — see `common.ts`'s `auditTimestamp` docblock)
67
+ * — re-verified live post-move via `storage-queries.test.ts`'s
68
+ * `datetime`-field round-trip test, which exercises exactly this branch
69
+ * (a text-typed sibling row's UNION leg lands on this `CAST`) and confirmed
70
+ * full microsecond fidelity survives.
71
+ */
72
+ export function mysqlNullCast(nullCast) {
73
+ switch (nullCast) {
74
+ case 'uuid':
75
+ return 'CAST(NULL AS CHAR(36))';
76
+ case 'boolean':
77
+ return 'CAST(NULL AS SIGNED)';
78
+ case 'timestamp':
79
+ // The manifest's abstract name for the `timestamptz`-shaped column
80
+ // (`value_timestamp_tz`); MySQL's storage-side type is `DATETIME(6)`
81
+ // (matching pg's microsecond precision — see `common.ts`'s
82
+ // `auditTimestamp` docblock), not a MySQL `TIMESTAMP` column.
83
+ return 'CAST(NULL AS DATETIME(6))';
84
+ case 'date':
85
+ return 'CAST(NULL AS DATE)';
86
+ case 'time':
87
+ return 'CAST(NULL AS TIME(3))';
88
+ case 'jsonb':
89
+ return 'CAST(NULL AS JSON)';
90
+ case 'text':
91
+ case 'varchar':
92
+ return 'CAST(NULL AS CHAR)';
93
+ case 'bigint':
94
+ case 'integer':
95
+ return 'CAST(NULL AS SIGNED)';
96
+ case 'decimal':
97
+ return 'CAST(NULL AS DECIMAL(10,2))';
98
+ case 'real':
99
+ return 'CAST(NULL AS FLOAT)';
100
+ case 'text[]':
101
+ // No MySQL array type — `object_keys` is stored as a JSON array of
102
+ // strings on this adapter (see the manifest's own comment at its
103
+ // `object_keys` entry), so the null cast matches that column's real
104
+ // MySQL type.
105
+ return 'CAST(NULL AS JSON)';
106
+ default:
107
+ throw new Error(`mysqlNullCast: unrecognised nullCast type '${nullCast}'`);
108
+ }
109
+ }
110
+ /** Number of columns in the unified output (base + field_type + type-specific). */
111
+ export const UNIFIED_COLUMN_COUNT = storeColumnManifest.length + 1; // +1 for field_type
112
+ /**
113
+ * Build the SELECT column list for a given store table type.
114
+ *
115
+ * Base columns (id, document_version_id, etc.) are passed through directly.
116
+ * The `field_type` column is emitted as a string literal.
117
+ * Type-specific columns are either mapped to the source expression or
118
+ * emitted as a typed NULL.
119
+ */
120
+ function buildSelectList(storeType) {
121
+ const parts = [];
122
+ for (const col of storeColumnManifest) {
123
+ // Base columns (no sources) are always passed through from the table.
124
+ if (!col.sources) {
125
+ parts.push(col.name);
126
+ continue;
127
+ }
128
+ const sourceExpr = col.sources[storeType];
129
+ if (sourceExpr) {
130
+ // This store provides this column — use the source expression.
131
+ // If the source column name differs from the output alias, add AS.
132
+ if (sourceExpr === col.name) {
133
+ parts.push(col.name);
134
+ }
135
+ else {
136
+ parts.push(`${sourceExpr} as \`${col.name}\``);
137
+ }
138
+ }
139
+ else {
140
+ // This store doesn't provide this column — emit typed NULL.
141
+ parts.push(`${mysqlNullCast(col.nullCast)} as \`${col.name}\``);
142
+ }
143
+ }
144
+ // Insert field_type after the base columns. The base columns are the first
145
+ // 7 entries (id through parent_path). field_type goes at position 3
146
+ // (after collection_id, before field_path) to match the original layout.
147
+ const fieldTypeLiteral = `'${fieldTypeLiterals[storeType]}' as \`field_type\``;
148
+ parts.splice(3, 0, fieldTypeLiteral);
149
+ return parts.join(',\n ');
150
+ }
151
+ // Pre-generate SQL fragments for each store type.
152
+ const selectListCache = new Map();
153
+ /**
154
+ * Get the Drizzle SQL fragment for a store type's SELECT list.
155
+ * Results are cached — the generation runs once at module load.
156
+ */
157
+ export function storeSelectList(storeType) {
158
+ let cached = selectListCache.get(storeType);
159
+ if (!cached) {
160
+ cached = sql.raw(buildSelectList(storeType));
161
+ selectListCache.set(storeType, cached);
162
+ }
163
+ return cached;
164
+ }
165
+ // ---------------------------------------------------------------------------
166
+ // Exported for testing
167
+ // ---------------------------------------------------------------------------
168
+ export { buildSelectList, fieldTypeLiterals };
@@ -0,0 +1,49 @@
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 declare const getFirstOrThrow: <T>(message: string) => (values: T[]) => T;
9
+ /**
10
+ * Coerce a raw driver `DATETIME`/`TIMESTAMP` value from a `db.execute(sql\`...\`)`
11
+ * call to a real `Date`. `drizzle-orm`'s mysql2 driver installs its own
12
+ * `typeCast` on every raw-execute call that has no schema-typed `fields`
13
+ * mapper — which every hand-written SQL read in this adapter is — that
14
+ * unconditionally returns those column types as **strings**
15
+ * (`'YYYY-MM-DD HH:MM:SS.ffffff'`, space-separated, no timezone marker),
16
+ * regardless of the pool's own `timezone` option. Confirmed live across
17
+ * three independent call sites in this adapter (Task 11's audit-log UNION,
18
+ * `normalizeRow`'s `value_timestamp_tz`, and `findDocuments`' main query —
19
+ * see the Task 11 report). Every `DATETIME`/`TIMESTAMP` column in this
20
+ * schema is UTC by convention, so appending `Z` after normalising the
21
+ * separator is the correct interpretation, not an assumed one. Tolerant of
22
+ * `null`/`undefined` and of the value already being a `Date` (defensive —
23
+ * not expected on this path, but cheap to allow and keeps callers simple).
24
+ *
25
+ * This is the sole gate between the raw driver row and three different
26
+ * public contract shapes (`normalizeRow`'s `value_timestamp_tz`,
27
+ * `findDocuments`'s `created_at`/`updated_at`, and the audit log's
28
+ * `occurredAt`), so a malformed value must not fall through as a silent
29
+ * `Invalid Date` — it throws instead (task 13b §M2; see
30
+ * `packages/db-postgres/src/modules/storage/normalize-row.ts`'s `toDate`
31
+ * for the pg-side equivalent of this guard). `context` is optional and
32
+ * purely cosmetic — it names which column the caller is coercing in the
33
+ * thrown message; omitted call sites still get a safe, if less specific,
34
+ * error instead of a silently wrong `Date`.
35
+ */
36
+ export declare function toDate(value: string | Date | null | undefined, context?: string): Date | null;
37
+ /**
38
+ * Read the affected-row count off a drizzle mysql2 `update()`/`delete()`
39
+ * result. MySQL has no `RETURNING`, so every guarded write in this adapter
40
+ * (optimistic-concurrency `UPDATE`/`DELETE` gates, bulk-mutation counts)
41
+ * uses this as its accept/reject or count signal instead. mysql2 resolves
42
+ * these to a `[ResultSetHeader, FieldPacket[]]` tuple — `affectedRows`
43
+ * lives on the first element, not `.rowCount` the way pg's driver shapes
44
+ * it (confirmed live against the test database; see
45
+ * `storage-commands.ts`'s `archivePublishedVersions` docblock for the
46
+ * original finding). Centralised here so the admin repositories (`../admin/
47
+ * *.ts`) share one cast instead of repeating it inline at every call site.
48
+ */
49
+ export declare function affectedRowCount(result: unknown): number;
@@ -0,0 +1,76 @@
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, getLogger } from '@byline/core';
9
+ // ------------------------------------------------------------------------------
10
+ // Misc
11
+ //
12
+ // `resolveStoreTypes` lives in `@byline/core` (`packages/core/src/storage/
13
+ // storage-utils.ts`) — it is dialect-independent. `getFirstOrThrow` stays
14
+ // here: it's a result helper tied to this adapter. Mirrors
15
+ // `packages/db-postgres/src/modules/storage/storage-utils.ts`.
16
+ // ------------------------------------------------------------------------------
17
+ export const getFirstOrThrow = (message) => (values) => {
18
+ const value = values[0];
19
+ if (value == null) {
20
+ throw ERR_DATABASE({ message }).log(getLogger());
21
+ }
22
+ return value;
23
+ };
24
+ /**
25
+ * Coerce a raw driver `DATETIME`/`TIMESTAMP` value from a `db.execute(sql\`...\`)`
26
+ * call to a real `Date`. `drizzle-orm`'s mysql2 driver installs its own
27
+ * `typeCast` on every raw-execute call that has no schema-typed `fields`
28
+ * mapper — which every hand-written SQL read in this adapter is — that
29
+ * unconditionally returns those column types as **strings**
30
+ * (`'YYYY-MM-DD HH:MM:SS.ffffff'`, space-separated, no timezone marker),
31
+ * regardless of the pool's own `timezone` option. Confirmed live across
32
+ * three independent call sites in this adapter (Task 11's audit-log UNION,
33
+ * `normalizeRow`'s `value_timestamp_tz`, and `findDocuments`' main query —
34
+ * see the Task 11 report). Every `DATETIME`/`TIMESTAMP` column in this
35
+ * schema is UTC by convention, so appending `Z` after normalising the
36
+ * separator is the correct interpretation, not an assumed one. Tolerant of
37
+ * `null`/`undefined` and of the value already being a `Date` (defensive —
38
+ * not expected on this path, but cheap to allow and keeps callers simple).
39
+ *
40
+ * This is the sole gate between the raw driver row and three different
41
+ * public contract shapes (`normalizeRow`'s `value_timestamp_tz`,
42
+ * `findDocuments`'s `created_at`/`updated_at`, and the audit log's
43
+ * `occurredAt`), so a malformed value must not fall through as a silent
44
+ * `Invalid Date` — it throws instead (task 13b §M2; see
45
+ * `packages/db-postgres/src/modules/storage/normalize-row.ts`'s `toDate`
46
+ * for the pg-side equivalent of this guard). `context` is optional and
47
+ * purely cosmetic — it names which column the caller is coercing in the
48
+ * thrown message; omitted call sites still get a safe, if less specific,
49
+ * error instead of a silently wrong `Date`.
50
+ */
51
+ export function toDate(value, context) {
52
+ if (value == null)
53
+ return null;
54
+ if (value instanceof Date)
55
+ return value;
56
+ const date = new Date(`${value.replace(' ', 'T')}Z`);
57
+ if (Number.isNaN(date.getTime())) {
58
+ throw new Error(`toDate: ${context ?? 'value'} is not a parseable timestamp — got ${JSON.stringify(value)}`);
59
+ }
60
+ return date;
61
+ }
62
+ /**
63
+ * Read the affected-row count off a drizzle mysql2 `update()`/`delete()`
64
+ * result. MySQL has no `RETURNING`, so every guarded write in this adapter
65
+ * (optimistic-concurrency `UPDATE`/`DELETE` gates, bulk-mutation counts)
66
+ * uses this as its accept/reject or count signal instead. mysql2 resolves
67
+ * these to a `[ResultSetHeader, FieldPacket[]]` tuple — `affectedRows`
68
+ * lives on the first element, not `.rowCount` the way pg's driver shapes
69
+ * it (confirmed live against the test database; see
70
+ * `storage-commands.ts`'s `archivePublishedVersions` docblock for the
71
+ * original finding). Centralised here so the admin repositories (`../admin/
72
+ * *.ts`) share one cast instead of repeating it inline at every call site.
73
+ */
74
+ export function affectedRowCount(result) {
75
+ return result[0]?.affectedRows ?? 0;
76
+ }
@@ -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,266 @@
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
+ * MySQL-specific dialect properties the shared `@byline/db-conformance`
10
+ * suite cannot express, because they pin behaviour the Postgres adapter
11
+ * either doesn't have (collation choices) or expresses differently (its own
12
+ * `COLLATE "C"` / native `numeric` type). Each pin below exists because a
13
+ * real, live-server-verified property depends on a specific schema choice
14
+ * in `packages/db-mysql/src/database/schema/{index,common}.ts` — see each
15
+ * test's comment for the column/config it pins and why.
16
+ */
17
+ import { generateKeyBetween, generateNKeysBetween } from '@byline/core';
18
+ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
19
+ import { setupTestDB, teardownTestDB } from '../../../lib/test-helper.js';
20
+ const timestamp = Date.now();
21
+ function first(rows) {
22
+ const row = rows[0];
23
+ if (row == null)
24
+ throw new Error('expected at least one row, got none');
25
+ return row;
26
+ }
27
+ async function queryRows(pool, sql, params) {
28
+ const [rows] = await pool.query(sql, params);
29
+ return rows;
30
+ }
31
+ describe('MySQL dialect pins (live database)', () => {
32
+ let testDb;
33
+ let rawPool;
34
+ beforeAll(() => {
35
+ testDb = setupTestDB();
36
+ rawPool = testDb.pool;
37
+ });
38
+ afterAll(async () => {
39
+ await teardownTestDB();
40
+ });
41
+ describe('order_key sort parity', () => {
42
+ // `byline_documents.order_key` is `varcharByteSorted` (`ascii_bin`)
43
+ // specifically so `ORDER BY order_key` in SQL agrees with plain
44
+ // JavaScript string comparison — the fractional-index algorithm in
45
+ // `@byline/core` (`generateKeyBetween`) is designed against byte-wise
46
+ // ordering, and MySQL's database-default collation
47
+ // (`utf8mb4_0900_ai_ci`) does not agree with JS on every key (e.g.
48
+ // `'Zz'` vs `'a0'` sort oppositely under the two collations). This test
49
+ // builds a realistic, mixed-structure key spread — not just a single
50
+ // initial batch, but keys inserted "between" existing ones the way a
51
+ // real drag-and-drop reorder would generate them — shuffles the insert
52
+ // order, and asserts the database's own `ORDER BY` agrees with the JS
53
+ // sort of the exact same key strings.
54
+ it('DB ORDER BY order_key matches JS string sort over the generateKeyBetween alphabet', async () => {
55
+ const testCollection = {
56
+ path: `order-key-pin-${timestamp}`,
57
+ labels: { singular: 'OrderKeyThing', plural: 'OrderKeyThings' },
58
+ fields: [{ name: 'title', type: 'text' }],
59
+ };
60
+ const collectionId = first(await testDb.commandBuilders.collections.create(testCollection.path, testCollection)).id;
61
+ // An initial batch of 8 keys (generateNKeysBetween bisects
62
+ // recursively, so this alone already mixes integer-part lengths and
63
+ // fraction lengths — a richer structure than a flat sequential
64
+ // batch), then interleave 7 more by generating a key strictly
65
+ // between each already-adjacent pair, the shape a real "drop between
66
+ // these two rows" reorder produces.
67
+ const initial = generateNKeysBetween(null, null, 8);
68
+ const interleaved = initial.slice(0, -1).map((key, i) => {
69
+ const next = initial[i + 1];
70
+ return generateKeyBetween(key, next);
71
+ });
72
+ const cleanKeys = [...initial, ...interleaved];
73
+ // Create one document per key, in shuffled order, so insertion order
74
+ // carries no information about sort order.
75
+ const shuffled = [...cleanKeys];
76
+ for (let i = shuffled.length - 1; i > 0; i--) {
77
+ const j = Math.floor(Math.random() * (i + 1));
78
+ const tmp = shuffled[i];
79
+ shuffled[i] = shuffled[j];
80
+ shuffled[j] = tmp;
81
+ }
82
+ for (const key of shuffled) {
83
+ const created = await testDb.commandBuilders.documents.createDocumentVersion({
84
+ collectionId,
85
+ collectionVersion: 1,
86
+ collectionConfig: testCollection,
87
+ action: 'create',
88
+ documentData: { title: key },
89
+ locale: 'all',
90
+ status: 'draft',
91
+ });
92
+ await testDb.commandBuilders.documents.setOrderKey({
93
+ document_id: created.document.document_id,
94
+ order_key: key,
95
+ });
96
+ }
97
+ const rows = await queryRows(rawPool, 'SELECT id, order_key FROM byline_documents WHERE collection_id = ? ORDER BY order_key ASC', [collectionId]);
98
+ const dbOrderedKeys = rows.map((r) => r.order_key);
99
+ const jsSortedKeys = [...cleanKeys].sort();
100
+ expect(dbOrderedKeys).toEqual(jsSortedKeys);
101
+ await testDb.commandBuilders.collections.delete(collectionId);
102
+ });
103
+ });
104
+ describe('ascii_bin id equality', () => {
105
+ // Every id/FK column uses `uuidChar` — `CHAR(36) CHARACTER SET ascii
106
+ // COLLATE ascii_bin` — precisely so id comparisons are byte-wise
107
+ // (case-sensitive), the MySQL analogue of Postgres's native `uuid`
108
+ // type (which has no notion of a case-insensitive collation at all).
109
+ // MySQL's database-default collation (`utf8mb4_0900_ai_ci`) folds
110
+ // case, so without `ascii_bin` a case-flipped id would wrongly compare
111
+ // equal.
112
+ it('a CHAR(36) id differing only in case does not match', async () => {
113
+ const testCollection = {
114
+ path: `ascii-bin-pin-${timestamp}`,
115
+ labels: { singular: 'AsciiBinThing', plural: 'AsciiBinThings' },
116
+ fields: [{ name: 'title', type: 'text' }],
117
+ };
118
+ const collectionId = first(await testDb.commandBuilders.collections.create(testCollection.path, testCollection)).id;
119
+ const created = await testDb.commandBuilders.documents.createDocumentVersion({
120
+ collectionId,
121
+ collectionVersion: 1,
122
+ collectionConfig: testCollection,
123
+ action: 'create',
124
+ documentData: { title: 'Ascii Bin' },
125
+ locale: 'all',
126
+ status: 'draft',
127
+ });
128
+ const documentId = created.document.document_id;
129
+ expect(documentId).not.toBe(documentId.toUpperCase());
130
+ const exactMatch = await queryRows(rawPool, 'SELECT id FROM byline_documents WHERE id = ?', [
131
+ documentId,
132
+ ]);
133
+ expect(exactMatch).toHaveLength(1);
134
+ const caseFlippedMatch = await queryRows(rawPool, 'SELECT id FROM byline_documents WHERE id = ?', [documentId.toUpperCase()]);
135
+ expect(caseFlippedMatch).toHaveLength(0);
136
+ await testDb.commandBuilders.collections.delete(collectionId);
137
+ });
138
+ });
139
+ describe('DATETIME(6) UTC round-trip', () => {
140
+ // The mysql2 pool opens with `timezone: 'Z'` (see `src/index.ts` and
141
+ // `test-helper.ts`) so `Date` values are sent and received as UTC,
142
+ // without local-timezone reinterpretation at the connection layer —
143
+ // MySQL's `DATETIME` has no timezone-aware storage the way Postgres's
144
+ // `TIMESTAMPTZ` does, so this pool option is the entire mechanism.
145
+ // `fsp: 6` on `value_timestamp_tz` (`common.ts`'s `auditTimestamp`
146
+ // discipline, mirrored on the store table) matches pg's `TIMESTAMPTZ(6)`
147
+ // resolution.
148
+ it('writes a Date and reads back the identical instant', async () => {
149
+ const testCollection = {
150
+ path: `datetime-utc-pin-${timestamp}`,
151
+ labels: { singular: 'DatetimeUtcThing', plural: 'DatetimeUtcThings' },
152
+ fields: [
153
+ { name: 'title', type: 'text' },
154
+ { name: 'atTime', type: 'datetime' },
155
+ ],
156
+ };
157
+ // `getDocumentById` resolves the collection definition from the set
158
+ // `queryBuilders` was constructed with — re-run `setupTestDB` with
159
+ // this test's collection so that lookup succeeds (mirrors the
160
+ // "Recreate queryBuilders when collections are provided" note in
161
+ // `test-helper.ts`).
162
+ testDb = setupTestDB([testCollection]);
163
+ const collectionId = first(await testDb.commandBuilders.collections.create(testCollection.path, testCollection)).id;
164
+ const expected = new Date('2026-05-04T03:02:01.456Z');
165
+ const created = await testDb.commandBuilders.documents.createDocumentVersion({
166
+ collectionId,
167
+ collectionVersion: 1,
168
+ collectionConfig: testCollection,
169
+ action: 'create',
170
+ documentData: { title: 'UTC round-trip', atTime: expected },
171
+ locale: 'all',
172
+ status: 'draft',
173
+ });
174
+ const doc = await testDb.queryBuilders.documents.getDocumentById({
175
+ collection_id: collectionId,
176
+ document_id: created.document.document_id,
177
+ locale: 'all',
178
+ });
179
+ const value = doc?.fields?.atTime;
180
+ expect(value).toBeInstanceOf(Date);
181
+ expect(value.getTime()).toBe(expected.getTime());
182
+ await testDb.commandBuilders.collections.delete(collectionId);
183
+ });
184
+ });
185
+ describe('DECIMAL precision preservation', () => {
186
+ // The pool opens with `decimalNumbers: false` (`src/index.ts`,
187
+ // `test-helper.ts`) so `DECIMAL` columns arrive as strings rather than
188
+ // JS `number`s — MySQL/JS float coercion would silently lose precision
189
+ // on money/decimal values otherwise, matching pg's `numeric` handling
190
+ // (node-postgres also returns `numeric` as a string by default).
191
+ it('returns a decimal field value as a string, not a JS number', async () => {
192
+ const testCollection = {
193
+ path: `decimal-pin-${timestamp}`,
194
+ labels: { singular: 'DecimalThing', plural: 'DecimalThings' },
195
+ fields: [
196
+ { name: 'title', type: 'text' },
197
+ { name: 'price', type: 'decimal' },
198
+ ],
199
+ };
200
+ testDb = setupTestDB([testCollection]);
201
+ const collectionId = first(await testDb.commandBuilders.collections.create(testCollection.path, testCollection)).id;
202
+ const created = await testDb.commandBuilders.documents.createDocumentVersion({
203
+ collectionId,
204
+ collectionVersion: 1,
205
+ collectionConfig: testCollection,
206
+ action: 'create',
207
+ documentData: { title: 'Decimal', price: '19.99' },
208
+ locale: 'all',
209
+ status: 'draft',
210
+ });
211
+ const doc = await testDb.queryBuilders.documents.getDocumentById({
212
+ collection_id: collectionId,
213
+ document_id: created.document.document_id,
214
+ locale: 'all',
215
+ });
216
+ expect(typeof doc?.fields?.price).toBe('string');
217
+ expect(doc?.fields?.price).toBe('19.99');
218
+ await testDb.commandBuilders.collections.delete(collectionId);
219
+ });
220
+ });
221
+ describe('LIKE case- and accent-insensitivity (elected divergence)', () => {
222
+ // The store `value` text columns keep the database's default
223
+ // `utf8mb4_0900_ai_ci` collation deliberately — unlike `order_key` /
224
+ // id columns, which are pinned to byte-wise collations for structural
225
+ // reasons, the *content* columns are left case- and accent-folding on
226
+ // purpose, so admin search (`LIKE`) matches more broadly than
227
+ // Postgres's `ILIKE` (case-only). This is an elected, spec-level
228
+ // divergence, not an oversight — do not "fix" it by pinning `value` to
229
+ // a binary collation. See `findDocuments`' query-search site and
230
+ // `packages/db-mysql/src/modules/storage/tests/storage-queries.test.ts`'s
231
+ // "query (LIKE admin search)" test for the full end-to-end behavioural
232
+ // pin through `findDocuments`.
233
+ //
234
+ // This test reads a real row through `byline_store_text.value` rather
235
+ // than comparing two SQL string literals — an earlier version of this
236
+ // test ran `SELECT ('Ünïcödé' LIKE ?) as matched`, which pins the
237
+ // *connection's* default collation (`collation_connection`, derived
238
+ // from the pool's `charset`), not the column's. Re-collating `value`
239
+ // to a binary collation would leave that version green while breaking
240
+ // the exact behaviour it claimed to pin — this version would catch it,
241
+ // because the comparison now happens against the actual indexed
242
+ // column.
243
+ it('LIKE matches a stored value in byline_store_text across case and accent variants', async () => {
244
+ const testCollection = {
245
+ path: `like-collation-pin-${timestamp}`,
246
+ labels: { singular: 'LikeCollationThing', plural: 'LikeCollationThings' },
247
+ fields: [{ name: 'title', type: 'text' }],
248
+ };
249
+ testDb = setupTestDB([testCollection]);
250
+ const collectionId = first(await testDb.commandBuilders.collections.create(testCollection.path, testCollection)).id;
251
+ await testDb.commandBuilders.documents.createDocumentVersion({
252
+ collectionId,
253
+ collectionVersion: 1,
254
+ collectionConfig: testCollection,
255
+ action: 'create',
256
+ documentData: { title: 'Ünïcödé Sample' },
257
+ locale: 'all',
258
+ status: 'draft',
259
+ });
260
+ const rows = await queryRows(rawPool, 'SELECT value FROM byline_store_text WHERE field_name = ? AND value LIKE ?', ['title', '%unicode sample%']);
261
+ expect(rows).toHaveLength(1);
262
+ expect(first(rows).value).toBe('Ünïcödé Sample');
263
+ await testDb.commandBuilders.collections.delete(collectionId);
264
+ });
265
+ });
266
+ });
@@ -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 {};