@byline/db-mysql 6.11.0 → 6.12.1

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.
@@ -3163,6 +3163,40 @@ export declare const fileStore: import("drizzle-orm/mysql-core").MySqlTableWithC
3163
3163
  identity: undefined;
3164
3164
  generated: undefined;
3165
3165
  }, {}, {}>;
3166
+ storage_validator_kind: import("drizzle-orm/mysql-core").MySqlColumn<{
3167
+ name: "storage_validator_kind";
3168
+ tableName: "byline_store_file";
3169
+ dataType: "string";
3170
+ columnType: "MySqlVarChar";
3171
+ data: string;
3172
+ driverParam: string | number;
3173
+ notNull: false;
3174
+ hasDefault: false;
3175
+ isPrimaryKey: false;
3176
+ isAutoincrement: false;
3177
+ hasRuntimeDefault: false;
3178
+ enumValues: [string, ...string[]];
3179
+ baseColumn: never;
3180
+ identity: undefined;
3181
+ generated: undefined;
3182
+ }, {}, {}>;
3183
+ storage_validator_value: import("drizzle-orm/mysql-core").MySqlColumn<{
3184
+ name: "storage_validator_value";
3185
+ tableName: "byline_store_file";
3186
+ dataType: "string";
3187
+ columnType: "MySqlVarChar";
3188
+ data: string;
3189
+ driverParam: string | number;
3190
+ notNull: false;
3191
+ hasDefault: false;
3192
+ isPrimaryKey: false;
3193
+ isAutoincrement: false;
3194
+ hasRuntimeDefault: false;
3195
+ enumValues: [string, ...string[]];
3196
+ baseColumn: never;
3197
+ identity: undefined;
3198
+ generated: undefined;
3199
+ }, {}, {}>;
3166
3200
  storage_provider: import("drizzle-orm/mysql-core").MySqlColumn<{
3167
3201
  name: "storage_provider";
3168
3202
  tableName: "byline_store_file";
@@ -678,7 +678,11 @@ export const fileStore = mysqlTable('byline_store_file', {
678
678
  // File metadata
679
679
  mime_type: varchar('mime_type', { length: 100 }).notNull(),
680
680
  file_size: bigint('file_size', { mode: 'number' }).notNull(), // Size in bytes
681
- file_hash: varchar('file_hash', { length: 64 }), // SHA-256 hash for deduplication
681
+ file_hash: varchar('file_hash', { length: 64 }), // SHA-256 content hash (lower-case hex)
682
+ // Storage validator of the stored version that file_hash was computed
683
+ // from (S3 version id / ETag, local stat). Written and read as a pair.
684
+ storage_validator_kind: varchar('storage_validator_kind', { length: 32 }),
685
+ storage_validator_value: varchar('storage_validator_value', { length: 1024 }),
682
686
  // Storage information
683
687
  storage_provider: varchar('storage_provider', { length: 50 }).notNull(), // 'local', 's3', 'gcs', etc.
684
688
  storage_path: text('storage_path').notNull(),
package/dist/index.js CHANGED
@@ -20,6 +20,7 @@ import { createReadSnapshot } from './modules/storage/read-snapshot.js';
20
20
  import { SingletonCommands, SingletonQueries } from './modules/storage/singletons.js';
21
21
  import { createCommandBuilders } from './modules/storage/storage-commands.js';
22
22
  import { createQueryBuilders } from './modules/storage/storage-queries.js';
23
+ import { StoredFileCommands, StoredFileQueries } from './modules/storage/stored-files.js';
23
24
  export const mysqlAdapter = ({ connectionString, collections, defaultContentLocale, connectionLimit = 20, idleTimeout = 2000, connectTimeout = 30000, maxIdle, }) => {
24
25
  const pool = mysql.createPool({
25
26
  uri: connectionString,
@@ -139,6 +140,7 @@ export const mysqlAdapter = ({ connectionString, collections, defaultContentLoca
139
140
  // see `./modules/audit/audit-commands.js`.
140
141
  audit: auditCommands,
141
142
  singletons: singletonCommands,
143
+ storedFiles: new StoredFileCommands(dbManager),
142
144
  },
143
145
  queries: {
144
146
  // `queryBuilders.collections` fully implements `ICollectionQueries`
@@ -151,6 +153,7 @@ export const mysqlAdapter = ({ connectionString, collections, defaultContentLoca
151
153
  // `./modules/audit/audit-queries.js`.
152
154
  audit: auditQueries,
153
155
  singletons: singletonQueries,
156
+ storedFiles: new StoredFileQueries(dbManager),
154
157
  },
155
158
  withTransaction: (fn) => txManager.withTransaction(fn),
156
159
  withReadSnapshot: createReadSnapshot(db, collections, defaultContentLocale),
@@ -102,6 +102,8 @@ export const prepareFieldInsertBuckets = (flattenedFields, document_version_id,
102
102
  storage_path: field.storage_path,
103
103
  storage_url: field.storage_url,
104
104
  file_hash: field.file_hash,
105
+ storage_validator_kind: field.storage_validator_kind,
106
+ storage_validator_value: field.storage_validator_value,
105
107
  image_width: field.image_width,
106
108
  image_height: field.image_height,
107
109
  image_format: field.image_format,
@@ -2056,6 +2056,8 @@ export class DocumentQueries {
2056
2056
  storage_path: row.storage_path,
2057
2057
  storage_url: row.storage_url,
2058
2058
  file_hash: row.file_hash,
2059
+ storage_validator_kind: row.storage_validator_kind,
2060
+ storage_validator_value: row.storage_validator_value,
2059
2061
  image_width: row.image_width,
2060
2062
  image_height: row.image_height,
2061
2063
  image_format: row.image_format,
@@ -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 { IStoredFileCommands, IStoredFileQueries, ListStoredFilePathsParams, StorageValidator, StoredFilePathGroup } from '@byline/core';
9
+ import type { DBManager } from '../../lib/db-manager.js';
10
+ export declare class StoredFileQueries implements IStoredFileQueries {
11
+ private dbManager;
12
+ constructor(dbManager: DBManager);
13
+ private get db();
14
+ listStoredFilePaths(params: ListStoredFilePathsParams): Promise<StoredFilePathGroup[]>;
15
+ }
16
+ export declare class StoredFileCommands implements IStoredFileCommands {
17
+ private dbManager;
18
+ constructor(dbManager: DBManager);
19
+ private get db();
20
+ setIdentityForPath(params: {
21
+ storageProvider: string;
22
+ storagePath: string;
23
+ collectionId?: string;
24
+ fileHash: string;
25
+ storageValidator: StorageValidator;
26
+ }): Promise<number>;
27
+ }
@@ -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
+ import { sql } from 'drizzle-orm';
9
+ // Stored-file identity maintenance (backfill / verify). Storage keys,
10
+ // provider names and recorded digests must compare exactly. The database
11
+ // default collation (utf8mb4_0900_ai_ci) folds case and accents, and even
12
+ // utf8mb4_bin is PAD SPACE (it equates 'a.pdf' and 'a.pdf '). utf8mb4_0900_bin
13
+ // is NO PAD but needs MySQL 8.0.17, above the adapter's 8.0.14 floor. So every
14
+ // comparison, DISTINCT, ORDER BY, join and GROUP BY here goes through
15
+ // CAST(... AS BINARY): byte equality, trailing spaces significant, byte order.
16
+ const binary = (expression) => sql `CAST(${expression} AS BINARY)`;
17
+ function scopeFilter(params, alias = '') {
18
+ const column = (name) => sql.raw(`${alias}${name}`);
19
+ const parts = [
20
+ sql `${binary(column('storage_provider'))} = ${binary(sql `${params.storageProvider}`)}`,
21
+ params.identity === 'missing'
22
+ ? sql `${column('file_hash')} IS NULL`
23
+ : sql `${column('file_hash')} IS NOT NULL`,
24
+ ];
25
+ if (params.collectionId !== undefined) {
26
+ parts.push(sql `${column('collection_id')} = ${params.collectionId}`);
27
+ }
28
+ return sql.join(parts, sql ` AND `);
29
+ }
30
+ function rowsOf(result) {
31
+ return result[0];
32
+ }
33
+ /** mysql2 returns binary-cast columns as Buffers; decode them back to text. */
34
+ function text(value) {
35
+ return Buffer.isBuffer(value) ? value.toString('utf8') : value;
36
+ }
37
+ export class StoredFileQueries {
38
+ dbManager;
39
+ constructor(dbManager) {
40
+ this.dbManager = dbManager;
41
+ }
42
+ get db() {
43
+ return this.dbManager.get();
44
+ }
45
+ async listStoredFilePaths(params) {
46
+ const after = params.afterPath === undefined
47
+ ? sql ``
48
+ : sql ` AND ${binary(sql `storage_path`)} > ${binary(sql `${params.afterPath}`)}`;
49
+ // A page is a bounded set of distinct paths; `limit` never counts rows.
50
+ const pathsQuery = sql `
51
+ SELECT DISTINCT ${binary(sql `storage_path`)} AS path_key
52
+ FROM byline_store_file
53
+ WHERE ${scopeFilter(params)}${after}
54
+ ORDER BY path_key
55
+ LIMIT ${params.limit}
56
+ `;
57
+ if (params.identity === 'missing') {
58
+ const result = await this.db.execute(pathsQuery);
59
+ return rowsOf(result).map((row) => ({
60
+ storagePath: text(row.path_key),
61
+ hashes: [],
62
+ }));
63
+ }
64
+ // Every recorded-hash group for each selected path, so a path's groups are
65
+ // never split across pages. Digests group by bytes, so differently spelled
66
+ // legacy values are reported separately.
67
+ const result = await this.db.execute(sql `
68
+ WITH paths AS (${pathsQuery})
69
+ SELECT p.path_key, ${binary(sql `f.file_hash`)} AS hash_key, COUNT(*) AS row_count
70
+ FROM byline_store_file f
71
+ JOIN paths p ON ${binary(sql `f.storage_path`)} = p.path_key
72
+ WHERE ${scopeFilter(params, 'f.')}
73
+ GROUP BY p.path_key, hash_key
74
+ ORDER BY p.path_key, hash_key
75
+ `);
76
+ const groups = [];
77
+ for (const row of rowsOf(result)) {
78
+ const storagePath = text(row.path_key);
79
+ let group = groups.at(-1);
80
+ if (!group || group.storagePath !== storagePath) {
81
+ group = { storagePath, hashes: [] };
82
+ groups.push(group);
83
+ }
84
+ group.hashes.push({ fileHash: text(row.hash_key), rowCount: Number(row.row_count) });
85
+ }
86
+ return groups;
87
+ }
88
+ }
89
+ export class StoredFileCommands {
90
+ dbManager;
91
+ constructor(dbManager) {
92
+ this.dbManager = dbManager;
93
+ }
94
+ get db() {
95
+ return this.dbManager.get();
96
+ }
97
+ async setIdentityForPath(params) {
98
+ const collection = params.collectionId === undefined ? sql `` : sql ` AND collection_id = ${params.collectionId}`;
99
+ const result = await this.db.execute(sql `
100
+ UPDATE byline_store_file
101
+ SET file_hash = ${params.fileHash},
102
+ storage_validator_kind = ${params.storageValidator.kind},
103
+ storage_validator_value = ${params.storageValidator.value},
104
+ updated_at = NOW()
105
+ WHERE ${binary(sql `storage_provider`)} = ${binary(sql `${params.storageProvider}`)}
106
+ AND ${binary(sql `storage_path`)} = ${binary(sql `${params.storagePath}`)}
107
+ AND file_hash IS NULL${collection}
108
+ `);
109
+ return result[0].affectedRows;
110
+ }
111
+ }
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": "6.11.0",
5
+ "version": "6.12.1",
6
6
  "engines": {
7
7
  "node": ">=22.22.2"
8
8
  },
@@ -47,24 +47,24 @@
47
47
  "dist"
48
48
  ],
49
49
  "dependencies": {
50
- "@byline/admin": "6.11.0",
51
- "@byline/core": "6.11.0",
50
+ "@byline/admin": "6.12.1",
51
+ "@byline/core": "6.12.1",
52
52
  "drizzle-orm": "^0.45.4",
53
53
  "mysql2": "^3.24.6",
54
54
  "uuid": "^14.0.3"
55
55
  },
56
56
  "devDependencies": {
57
- "@biomejs/biome": "2.6.2",
58
- "@byline/auth": "6.11.0",
59
- "@byline/client": "6.11.0",
57
+ "@biomejs/biome": "2.6.4",
58
+ "@byline/auth": "6.12.1",
59
+ "@byline/client": "6.12.1",
60
60
  "@byline/db-conformance": "0.0.10",
61
- "@types/node": "^26.6.5",
61
+ "@types/node": "^26.6.6",
62
62
  "chokidar": "^6.0.0",
63
63
  "chokidar-cli": "^3.0.0",
64
- "dotenv": "^18.0.8",
64
+ "dotenv": "^18.0.9",
65
65
  "drizzle-kit": "^0.31.11",
66
66
  "npm-run-all": "^4.1.5",
67
- "tsc-alias": "^1.9.8",
67
+ "tsc-alias": "^1.9.10",
68
68
  "typescript": "^7.0.2",
69
69
  "vitest": "^5.0.5"
70
70
  },
@@ -80,6 +80,8 @@
80
80
  "db:init": "cd src/database && ./db_init.sh",
81
81
  "drizzle:generate": "tsc -p tsconfig.json && drizzle-kit generate --config=drizzle.config.ts",
82
82
  "drizzle:migrate": "drizzle-kit migrate --config=drizzle.config.ts",
83
+ "drizzle:rekey": "node --env-file-if-exists=.env scripts/drizzle-rekey.mjs",
84
+ "drizzle:rekey:test": "node --env-file-if-exists=.env.test scripts/drizzle-rekey.mjs",
83
85
  "drizzle:push": "tsc -p tsconfig.json && drizzle-kit push --config=drizzle.config.ts",
84
86
  "drizzle:drop": "drizzle-kit drop --config=drizzle.config.ts",
85
87
  "test": "vitest run --mode=node",