@byline/db-mysql 4.16.0 → 4.18.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.
@@ -274,6 +274,47 @@ export declare const documents: import("drizzle-orm/mysql-core").MySqlTableWithC
274
274
  };
275
275
  dialect: 'mysql';
276
276
  }>;
277
+ export declare const singletonDocuments: import("drizzle-orm/mysql-core").MySqlTableWithColumns<{
278
+ name: "byline_singleton_documents";
279
+ schema: undefined;
280
+ columns: {
281
+ collection_id: import("drizzle-orm/mysql-core").MySqlColumn<{
282
+ name: "collection_id";
283
+ tableName: "byline_singleton_documents";
284
+ dataType: "custom";
285
+ columnType: "MySqlCustomColumn";
286
+ data: string;
287
+ driverParam: string;
288
+ notNull: true;
289
+ hasDefault: false;
290
+ isPrimaryKey: true;
291
+ isAutoincrement: false;
292
+ hasRuntimeDefault: false;
293
+ enumValues: undefined;
294
+ baseColumn: never;
295
+ identity: undefined;
296
+ generated: undefined;
297
+ }, {}, {}>;
298
+ document_id: import("drizzle-orm/mysql-core").MySqlColumn<{
299
+ name: "document_id";
300
+ tableName: "byline_singleton_documents";
301
+ dataType: "custom";
302
+ columnType: "MySqlCustomColumn";
303
+ data: string;
304
+ driverParam: string;
305
+ notNull: true;
306
+ hasDefault: false;
307
+ isPrimaryKey: false;
308
+ isAutoincrement: false;
309
+ hasRuntimeDefault: false;
310
+ enumValues: undefined;
311
+ baseColumn: never;
312
+ identity: undefined;
313
+ generated: undefined;
314
+ }, {}, {}>;
315
+ };
316
+ dialect: 'mysql';
317
+ }>;
277
318
  export declare const documentVersions: import("drizzle-orm/mysql-core").MySqlTableWithColumns<{
278
319
  name: "byline_document_versions";
279
320
  schema: undefined;
@@ -74,6 +74,20 @@ export const documents = mysqlTable('byline_documents', {
74
74
  }).onDelete('cascade'),
75
75
  index('idx_documents_collection').on(table.collection_id),
76
76
  index('idx_documents_collection_order').on(table.collection_id, table.order_key),
77
+ unique('uq_documents_collection_id_id').on(table.collection_id, table.id),
78
+ ]);
79
+ // Singleton slot → document mapping. The composite foreign key proves that
80
+ // the mapped document belongs to the registered collection while the primary
81
+ // key enforces zero-or-one materialised document per singleton slot.
82
+ export const singletonDocuments = mysqlTable('byline_singleton_documents', {
83
+ collection_id: uuidChar('collection_id').primaryKey(),
84
+ document_id: uuidChar('document_id').notNull().unique(),
85
+ }, (table) => [
86
+ foreignKey({
87
+ name: 'fk_singleton_documents_document',
88
+ columns: [table.collection_id, table.document_id],
89
+ foreignColumns: [documents.collection_id, documents.id],
90
+ }).onDelete('cascade'),
77
91
  ]);
78
92
  // Document versions table
79
93
  export const documentVersions = mysqlTable('byline_document_versions', {
@@ -463,3 +463,25 @@ describe('schema pins — scheduled publication', () => {
463
463
  expect(expiry?.config.columns.map((column) => ('name' in column ? column.name : null))).toEqual(['execution_expires_at']);
464
464
  });
465
465
  });
466
+ describe('schema pins — singleton documents', () => {
467
+ it('pins the supporting document ownership key', () => {
468
+ const cfg = getTableConfig(coreSchema.documents);
469
+ const ownershipKey = cfg.uniqueConstraints.find((constraint) => constraint.name === 'uq_documents_collection_id_id');
470
+ expect(ownershipKey?.columns.map((column) => column.name)).toEqual(['collection_id', 'id']);
471
+ });
472
+ it('pins slot and document uniqueness plus collection ownership', () => {
473
+ const cfg = getTableConfig(coreSchema.singletonDocuments);
474
+ const collectionId = cfg.columns.find((column) => column.name === 'collection_id');
475
+ const documentId = cfg.columns.find((column) => column.name === 'document_id');
476
+ const ownershipForeignKey = cfg.foreignKeys.find((foreignKey) => foreignKey.getName() === 'fk_singleton_documents_document');
477
+ const reference = ownershipForeignKey?.reference();
478
+ expect(collectionId?.primary).toBe(true);
479
+ expect(documentId?.isUnique).toBe(true);
480
+ expect(reference?.columns.map((column) => column.name)).toEqual([
481
+ 'collection_id',
482
+ 'document_id',
483
+ ]);
484
+ expect(reference?.foreignColumns.map((column) => column.name)).toEqual(['collection_id', 'id']);
485
+ expect(ownershipForeignKey?.onDelete).toBe('cascade');
486
+ });
487
+ });
package/dist/index.js CHANGED
@@ -15,6 +15,7 @@ import { createAuditQueries } from './modules/audit/audit-queries.js';
15
15
  import { createCounterCommands } from './modules/counters/counters-commands.js';
16
16
  import { createSchedulerStore } from './modules/scheduler/scheduler-store.js';
17
17
  import { classifyError } from './modules/storage/classify-error.js';
18
+ import { SingletonCommands, SingletonQueries } from './modules/storage/singletons.js';
18
19
  import { createCommandBuilders } from './modules/storage/storage-commands.js';
19
20
  import { createQueryBuilders } from './modules/storage/storage-queries.js';
20
21
  export const mysqlAdapter = ({ connectionString, collections, defaultContentLocale, connectionLimit = 20, idleTimeout = 2000, connectTimeout = 30000, maxIdle, }) => {
@@ -87,6 +88,8 @@ export const mysqlAdapter = ({ connectionString, collections, defaultContentLoca
87
88
  const auditCommands = createAuditCommands(dbManager);
88
89
  const auditQueries = createAuditQueries(db);
89
90
  const schedulerStore = createSchedulerStore(db);
91
+ const singletonCommands = new SingletonCommands(dbManager);
92
+ const singletonQueries = new SingletonQueries(dbManager);
90
93
  // Boot check: run lazily on the pool's first physical connection rather
91
94
  // than eagerly here, because `mysqlAdapter` is synchronous (mirroring
92
95
  // `pgAdapter`) and cannot await a round trip before returning. mysql2
@@ -133,6 +136,7 @@ export const mysqlAdapter = ({ connectionString, collections, defaultContentLoca
133
136
  // `auditCommands` fully implements `IAuditCommands` as of Task 11 —
134
137
  // see `./modules/audit/audit-commands.js`.
135
138
  audit: auditCommands,
139
+ singletons: singletonCommands,
136
140
  },
137
141
  queries: {
138
142
  // `queryBuilders.collections` fully implements `ICollectionQueries`
@@ -144,6 +148,7 @@ export const mysqlAdapter = ({ connectionString, collections, defaultContentLoca
144
148
  // `auditQueries` fully implements `IAuditQueries` as of Task 11 — see
145
149
  // `./modules/audit/audit-queries.js`.
146
150
  audit: auditQueries,
151
+ singletons: singletonQueries,
147
152
  },
148
153
  withTransaction: (fn) => txManager.withTransaction(fn),
149
154
  scheduler: schedulerStore,
@@ -25,6 +25,8 @@ export interface DBManager {
25
25
  * boundary is open in this async context, otherwise the pool.
26
26
  */
27
27
  get(): DBExecutor;
28
+ /** Whether the current async context owns an ambient transaction. */
29
+ isInTransaction(): boolean;
28
30
  }
29
31
  export declare class DBManagerImpl implements DBManager {
30
32
  private readonly dbPool;
@@ -32,6 +34,7 @@ export declare class DBManagerImpl implements DBManager {
32
34
  dbPool: DBExecutor;
33
35
  });
34
36
  get(): DBExecutor;
37
+ isInTransaction(): boolean;
35
38
  }
36
39
  export interface TXManager {
37
40
  /**
@@ -26,6 +26,9 @@ export class DBManagerImpl {
26
26
  get() {
27
27
  return transactionALS.getStore() ?? this.dbPool;
28
28
  }
29
+ isInTransaction() {
30
+ return transactionALS.getStore() != null;
31
+ }
29
32
  }
30
33
  export class TXManagerImpl {
31
34
  db;
@@ -8,8 +8,8 @@
8
8
  import { type DbErrorClassification } from '@byline/core';
9
9
  /**
10
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
11
+ * `DrizzleQueryError` → the underlying mysql2 error) looking for duplicate-key
12
+ * and foreign-key errors and returns the carried constraint or index name. Mirrors
13
13
  * `packages/db-postgres/src/modules/storage/classify-error.ts`, whose
14
14
  * pg-anatomy docblock this docblock intentionally parallels.
15
15
  *
@@ -8,8 +8,8 @@
8
8
  import { DbErrorCodes } from '@byline/core';
9
9
  /**
10
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
11
+ * `DrizzleQueryError` → the underlying mysql2 error) looking for duplicate-key
12
+ * and foreign-key errors and returns the carried constraint or index name. Mirrors
13
13
  * `packages/db-postgres/src/modules/storage/classify-error.ts`, whose
14
14
  * pg-anatomy docblock this docblock intentionally parallels.
15
15
  *
@@ -44,6 +44,12 @@ export function classifyError(err) {
44
44
  : qualifiedName;
45
45
  return { code: DbErrorCodes.UNIQUE_VIOLATION, constraint };
46
46
  }
47
+ // InnoDB's table-level FK failures use 1451/1452 and carry a constraint
48
+ // name. Legacy 1216/1217 variants omit the name, so they remain UNKNOWN.
49
+ if (e.errno === 1451 || e.errno === 1452) {
50
+ const match = typeof e.message === 'string' ? e.message.match(/CONSTRAINT [`']([^`']+)[`']/) : null;
51
+ return { code: DbErrorCodes.FOREIGN_KEY_VIOLATION, constraint: match?.[1] };
52
+ }
47
53
  e = e.cause;
48
54
  }
49
55
  return { code: DbErrorCodes.UNKNOWN };
@@ -46,13 +46,21 @@ describe('classifyError (mysql)', () => {
46
46
  };
47
47
  expect(classifyError(err).code).toBe('DB_UNIQUE_VIOLATION');
48
48
  });
49
- it('returns DB_UNKNOWN for a non-duplicate-key error', () => {
49
+ it('classifies a foreign-key error with the constraint name', () => {
50
50
  expect(classifyError({
51
51
  code: 'ER_NO_REFERENCED_ROW_2',
52
52
  errno: 1452,
53
53
  sqlState: '23000',
54
- message: 'Cannot add or update a child row: a foreign key constraint fails',
55
- })).toEqual({ code: 'DB_UNKNOWN' });
54
+ message: 'Cannot add or update a child row: a foreign key constraint fails (`db`.`table`, CONSTRAINT `fk_document_owner` FOREIGN KEY (`document_id`))',
55
+ })).toEqual({
56
+ code: 'DB_FOREIGN_KEY_VIOLATION',
57
+ constraint: 'fk_document_owner',
58
+ });
59
+ });
60
+ it('returns DB_UNKNOWN for an unrelated database error', () => {
61
+ expect(classifyError({ code: 'ER_DATA_TOO_LONG', errno: 1406 })).toEqual({
62
+ code: 'DB_UNKNOWN',
63
+ });
56
64
  });
57
65
  it('returns DB_UNKNOWN for a non-error value', () => {
58
66
  expect(classifyError(undefined)).toEqual({ code: 'DB_UNKNOWN' });
@@ -79,11 +87,15 @@ runClassifyErrorContract([
79
87
  message: "Duplicate entry 'some-uuid' for key 'byline_document_paths.idx_document_paths_collection_locale_path'",
80
88
  },
81
89
  },
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',
90
+ foreignKeyViolationError: {
91
+ name: 'DrizzleQueryError',
92
+ cause: {
93
+ code: 'ER_NO_REFERENCED_ROW_2',
94
+ errno: 1452,
95
+ sqlState: '23000',
96
+ message: 'Cannot add or update a child row: a foreign key constraint fails (`db`.`table`, CONSTRAINT `fk_document_owner` FOREIGN KEY (`document_id`))',
97
+ },
87
98
  },
99
+ unrelatedError: { code: 'ER_DATA_TOO_LONG', errno: 1406, sqlState: '22001' },
88
100
  },
89
101
  ]);
@@ -0,0 +1,27 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ */
8
+ import { type ISingletonCommands, type ISingletonQueries } from '@byline/core';
9
+ import type { DBManager } from '../../lib/db-manager.js';
10
+ export declare class SingletonCommands implements ISingletonCommands {
11
+ private dbManager;
12
+ constructor(dbManager: DBManager);
13
+ /**
14
+ * Resolve the executor per access so an enclosing `withTransaction`
15
+ * boundary is honoured through the adapter's ambient transaction manager.
16
+ */
17
+ private get db();
18
+ lockSlot(collectionId: string): Promise<void>;
19
+ setMapping(collectionId: string, documentId: string): Promise<void>;
20
+ clearMapping(collectionId: string): Promise<void>;
21
+ }
22
+ export declare class SingletonQueries implements ISingletonQueries {
23
+ private dbManager;
24
+ constructor(dbManager: DBManager);
25
+ private get db();
26
+ getMappedDocumentId(collectionId: string): Promise<string | null>;
27
+ }
@@ -0,0 +1,70 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ */
8
+ import { ERR_DATABASE, ERR_NOT_FOUND, } from '@byline/core';
9
+ import { eq, sql } from 'drizzle-orm';
10
+ import { singletonDocuments } from '../../database/schema/index.js';
11
+ export class SingletonCommands {
12
+ dbManager;
13
+ constructor(dbManager) {
14
+ this.dbManager = dbManager;
15
+ }
16
+ /**
17
+ * Resolve the executor per access so an enclosing `withTransaction`
18
+ * boundary is honoured through the adapter's ambient transaction manager.
19
+ */
20
+ get db() {
21
+ return this.dbManager.get();
22
+ }
23
+ async lockSlot(collectionId) {
24
+ if (!this.dbManager.isInTransaction()) {
25
+ throw ERR_DATABASE({
26
+ message: 'singleton slot locks require an active transaction',
27
+ details: { collectionId },
28
+ });
29
+ }
30
+ const locked = await this.db.execute(sql `
31
+ SELECT id FROM byline_collections
32
+ WHERE id = ${collectionId}
33
+ FOR UPDATE
34
+ `);
35
+ const rows = locked[0];
36
+ if (rows.length === 0) {
37
+ throw ERR_NOT_FOUND({
38
+ message: 'singleton slot registration not found',
39
+ details: { collectionId },
40
+ });
41
+ }
42
+ }
43
+ async setMapping(collectionId, documentId) {
44
+ await this.db
45
+ .insert(singletonDocuments)
46
+ .values({ collection_id: collectionId, document_id: documentId });
47
+ }
48
+ async clearMapping(collectionId) {
49
+ await this.db
50
+ .delete(singletonDocuments)
51
+ .where(eq(singletonDocuments.collection_id, collectionId));
52
+ }
53
+ }
54
+ export class SingletonQueries {
55
+ dbManager;
56
+ constructor(dbManager) {
57
+ this.dbManager = dbManager;
58
+ }
59
+ get db() {
60
+ return this.dbManager.get();
61
+ }
62
+ async getMappedDocumentId(collectionId) {
63
+ const rows = await this.db
64
+ .select({ documentId: singletonDocuments.document_id })
65
+ .from(singletonDocuments)
66
+ .where(eq(singletonDocuments.collection_id, collectionId))
67
+ .limit(1);
68
+ return rows[0]?.documentId ?? null;
69
+ }
70
+ }
@@ -5,7 +5,7 @@
5
5
  *
6
6
  * Copyright (c) Infonomic Company Limited
7
7
  */
8
- import { DbErrorCodes, ERR_CONFLICT, ERR_NOT_FOUND, ERR_VALIDATION, flattenFieldSetData, generateKeyBetween, TREE_PLACEMENT_STALE_MARKER, } from '@byline/core';
8
+ import { DbErrorCodes, ERR_CONFLICT, ERR_NOT_FOUND, ERR_VALIDATION, flattenFieldSetData, generateKeyBetween, isSingleton, TREE_PLACEMENT_STALE_MARKER, } from '@byline/core';
9
9
  import { and, desc, eq, inArray, ne, notInArray, sql } from 'drizzle-orm';
10
10
  import { v7 as uuidv7 } from 'uuid';
11
11
  import { booleanStore, collections, currentDocumentsView, datetimeStore, documentAvailableLocales, documentPaths, documentRelationships, documents, documentVersions, fileStore, jsonStore, metaStore, numericStore, relationStore, textStore, } from '../../database/schema/index.js';
@@ -49,8 +49,8 @@ export class CollectionCommands {
49
49
  async create(path, config, opts) {
50
50
  const id = uuidv7();
51
51
  const version = opts?.version ?? 1;
52
- const singular = config.labels.singular || path;
53
- const plural = config.labels.plural || `${path}s`;
52
+ const singular = isSingleton(config) ? config.label : config.labels.singular || path;
53
+ const plural = isSingleton(config) ? config.label : config.labels.plural || `${path}s`;
54
54
  // `created_at`/`updated_at` are written explicitly rather than left to
55
55
  // the column's `DEFAULT CURRENT_TIMESTAMP(6)` — an earlier version of
56
56
  // this method approximated them with a request-time `Date` instead
@@ -356,8 +356,9 @@ export declare class DocumentQueries implements IDocumentQueries {
356
356
  /**
357
357
  * getDocumentByVersion — fetches a specific version and reconstructs its fields.
358
358
  */
359
- getDocumentByVersion({ document_version_id, locale, collection_id, filters, }: {
359
+ getDocumentByVersion({ document_version_id, document_id, locale, collection_id, filters, }: {
360
360
  document_version_id: string;
361
+ document_id?: string;
361
362
  locale?: string;
362
363
  collection_id?: string;
363
364
  filters?: DocumentFilter[];
@@ -652,10 +652,12 @@ export class DocumentQueries {
652
652
  /**
653
653
  * getDocumentByVersion — fetches a specific version and reconstructs its fields.
654
654
  */
655
- async getDocumentByVersion({ document_version_id, locale = 'all', collection_id, filters, }) {
655
+ async getDocumentByVersion({ document_version_id, document_id, locale = 'all', collection_id, filters, }) {
656
656
  const projectionLocale = locale === 'all' ? undefined : locale;
657
657
  const filterLocale = projectionLocale ?? this.defaultContentLocale;
658
658
  const conditions = [eq(documentVersions.id, document_version_id)];
659
+ if (document_id)
660
+ conditions.push(eq(documentVersions.document_id, document_id));
659
661
  if (collection_id)
660
662
  conditions.push(eq(documentVersions.collection_id, collection_id));
661
663
  if (filters?.length) {
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@byline/db-mysql",
3
3
  "private": false,
4
4
  "license": "MPL-2.0",
5
- "version": "4.16.0",
5
+ "version": "4.18.0",
6
6
  "engines": {
7
7
  "node": ">=20.9.0"
8
8
  },
@@ -48,26 +48,25 @@
48
48
  ],
49
49
  "dependencies": {
50
50
  "drizzle-orm": "^0.45.2",
51
- "mysql2": "^3.23.4",
51
+ "mysql2": "^3.24.2",
52
52
  "uuid": "^14.0.2",
53
- "@byline/admin": "4.16.0",
54
- "@byline/core": "4.16.0"
53
+ "@byline/admin": "4.18.0",
54
+ "@byline/core": "4.18.0"
55
55
  },
56
56
  "devDependencies": {
57
- "@biomejs/biome": "2.5.9",
58
- "@types/node": "^26.2.0",
57
+ "@biomejs/biome": "2.5.10",
58
+ "@types/node": "^26.4.0",
59
59
  "chokidar": "^5.0.0",
60
60
  "chokidar-cli": "^3.0.0",
61
61
  "dotenv": "^17.4.2",
62
62
  "drizzle-kit": "^0.31.10",
63
63
  "npm-run-all": "^4.1.5",
64
64
  "tsc-alias": "^1.9.2",
65
- "tsx": "^4.23.12",
66
65
  "typescript": "^7.0.2",
67
66
  "vitest": "^4.1.11",
68
- "@byline/client": "4.16.0",
67
+ "@byline/client": "4.18.0",
69
68
  "@byline/db-conformance": "0.0.10",
70
- "@byline/auth": "4.16.0"
69
+ "@byline/auth": "4.18.0"
71
70
  },
72
71
  "publishConfig": {
73
72
  "access": "public",