@byline/db-mysql 6.7.2 → 6.9.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.
@@ -60,6 +60,23 @@ export declare const adminUsers: import("drizzle-orm/mysql-core").MySqlTableWith
60
60
  identity: undefined;
61
61
  generated: undefined;
62
62
  }, {}, {}>;
63
+ webauthn_user_handle: import("drizzle-orm/mysql-core").MySqlColumn<{
64
+ name: "webauthn_user_handle";
65
+ tableName: "byline_admin_users";
66
+ dataType: "custom";
67
+ columnType: "MySqlCustomColumn";
68
+ data: string;
69
+ driverParam: Buffer<ArrayBufferLike>;
70
+ notNull: false;
71
+ hasDefault: false;
72
+ isPrimaryKey: false;
73
+ isAutoincrement: false;
74
+ hasRuntimeDefault: false;
75
+ enumValues: undefined;
76
+ baseColumn: never;
77
+ identity: undefined;
78
+ generated: undefined;
79
+ }, {}, {}>;
63
80
  session_version: import("drizzle-orm/mysql-core").MySqlColumn<{
64
81
  name: "session_version";
65
82
  tableName: "byline_admin_users";
@@ -1151,3 +1168,450 @@ export declare const adminSignInRateLimits: import("drizzle-orm/mysql-core").MyS
1151
1168
  };
1152
1169
  dialect: 'mysql';
1153
1170
  }>;
1171
+ /** Credential identities survive revocation and account deletion as tombstones. */
1172
+ export declare const adminPasskeyCredentials: import("drizzle-orm/mysql-core").MySqlTableWithColumns<{
1173
+ name: "byline_admin_passkey_credentials";
1174
+ schema: undefined;
1175
+ columns: {
1176
+ id: import("drizzle-orm/mysql-core").MySqlColumn<{
1177
+ name: "id";
1178
+ tableName: "byline_admin_passkey_credentials";
1179
+ dataType: "custom";
1180
+ columnType: "MySqlCustomColumn";
1181
+ data: string;
1182
+ driverParam: string;
1183
+ notNull: true;
1184
+ hasDefault: false;
1185
+ isPrimaryKey: true;
1186
+ isAutoincrement: false;
1187
+ hasRuntimeDefault: false;
1188
+ enumValues: undefined;
1189
+ baseColumn: never;
1190
+ identity: undefined;
1191
+ generated: undefined;
1192
+ }, {}, {}>;
1193
+ admin_user_id: import("drizzle-orm/mysql-core").MySqlColumn<{
1194
+ name: "admin_user_id";
1195
+ tableName: "byline_admin_passkey_credentials";
1196
+ dataType: "custom";
1197
+ columnType: "MySqlCustomColumn";
1198
+ data: string;
1199
+ driverParam: string;
1200
+ notNull: false;
1201
+ hasDefault: false;
1202
+ isPrimaryKey: false;
1203
+ isAutoincrement: false;
1204
+ hasRuntimeDefault: false;
1205
+ enumValues: undefined;
1206
+ baseColumn: never;
1207
+ identity: undefined;
1208
+ generated: undefined;
1209
+ }, {}, {}>;
1210
+ credential_id: import("drizzle-orm/mysql-core").MySqlColumn<{
1211
+ name: "credential_id";
1212
+ tableName: "byline_admin_passkey_credentials";
1213
+ dataType: "custom";
1214
+ columnType: "MySqlCustomColumn";
1215
+ data: string;
1216
+ driverParam: Buffer<ArrayBufferLike>;
1217
+ notNull: true;
1218
+ hasDefault: false;
1219
+ isPrimaryKey: false;
1220
+ isAutoincrement: false;
1221
+ hasRuntimeDefault: false;
1222
+ enumValues: undefined;
1223
+ baseColumn: never;
1224
+ identity: undefined;
1225
+ generated: undefined;
1226
+ }, {}, {}>;
1227
+ public_key: import("drizzle-orm/mysql-core").MySqlColumn<{
1228
+ name: "public_key";
1229
+ tableName: "byline_admin_passkey_credentials";
1230
+ dataType: "string";
1231
+ columnType: "MySqlText";
1232
+ data: string;
1233
+ driverParam: string;
1234
+ notNull: true;
1235
+ hasDefault: false;
1236
+ isPrimaryKey: false;
1237
+ isAutoincrement: false;
1238
+ hasRuntimeDefault: false;
1239
+ enumValues: [string, ...string[]];
1240
+ baseColumn: never;
1241
+ identity: undefined;
1242
+ generated: undefined;
1243
+ }, {}, {}>;
1244
+ counter: import("drizzle-orm/mysql-core").MySqlColumn<{
1245
+ name: "counter";
1246
+ tableName: "byline_admin_passkey_credentials";
1247
+ dataType: "number";
1248
+ columnType: "MySqlBigInt53";
1249
+ data: number;
1250
+ driverParam: string | number;
1251
+ notNull: true;
1252
+ hasDefault: false;
1253
+ isPrimaryKey: false;
1254
+ isAutoincrement: false;
1255
+ hasRuntimeDefault: false;
1256
+ enumValues: undefined;
1257
+ baseColumn: never;
1258
+ identity: undefined;
1259
+ generated: undefined;
1260
+ }, {}, {}>;
1261
+ transports: import("drizzle-orm/mysql-core").MySqlColumn<{
1262
+ name: "transports";
1263
+ tableName: "byline_admin_passkey_credentials";
1264
+ dataType: "json";
1265
+ columnType: "MySqlJson";
1266
+ data: string[];
1267
+ driverParam: string;
1268
+ notNull: true;
1269
+ hasDefault: false;
1270
+ isPrimaryKey: false;
1271
+ isAutoincrement: false;
1272
+ hasRuntimeDefault: false;
1273
+ enumValues: undefined;
1274
+ baseColumn: never;
1275
+ identity: undefined;
1276
+ generated: undefined;
1277
+ }, {}, {
1278
+ $type: string[];
1279
+ }>;
1280
+ backup_eligible: import("drizzle-orm/mysql-core").MySqlColumn<{
1281
+ name: "backup_eligible";
1282
+ tableName: "byline_admin_passkey_credentials";
1283
+ dataType: "boolean";
1284
+ columnType: "MySqlBoolean";
1285
+ data: boolean;
1286
+ driverParam: number | boolean;
1287
+ notNull: true;
1288
+ hasDefault: false;
1289
+ isPrimaryKey: false;
1290
+ isAutoincrement: false;
1291
+ hasRuntimeDefault: false;
1292
+ enumValues: undefined;
1293
+ baseColumn: never;
1294
+ identity: undefined;
1295
+ generated: undefined;
1296
+ }, {}, {}>;
1297
+ backed_up: import("drizzle-orm/mysql-core").MySqlColumn<{
1298
+ name: "backed_up";
1299
+ tableName: "byline_admin_passkey_credentials";
1300
+ dataType: "boolean";
1301
+ columnType: "MySqlBoolean";
1302
+ data: boolean;
1303
+ driverParam: number | boolean;
1304
+ notNull: true;
1305
+ hasDefault: false;
1306
+ isPrimaryKey: false;
1307
+ isAutoincrement: false;
1308
+ hasRuntimeDefault: false;
1309
+ enumValues: undefined;
1310
+ baseColumn: never;
1311
+ identity: undefined;
1312
+ generated: undefined;
1313
+ }, {}, {}>;
1314
+ name: import("drizzle-orm/mysql-core").MySqlColumn<{
1315
+ name: "name";
1316
+ tableName: "byline_admin_passkey_credentials";
1317
+ dataType: "string";
1318
+ columnType: "MySqlVarChar";
1319
+ data: string;
1320
+ driverParam: string | number;
1321
+ notNull: true;
1322
+ hasDefault: false;
1323
+ isPrimaryKey: false;
1324
+ isAutoincrement: false;
1325
+ hasRuntimeDefault: false;
1326
+ enumValues: [string, ...string[]];
1327
+ baseColumn: never;
1328
+ identity: undefined;
1329
+ generated: undefined;
1330
+ }, {}, {}>;
1331
+ created_at: import("drizzle-orm/mysql-core").MySqlColumn<{
1332
+ name: "created_at";
1333
+ tableName: "byline_admin_passkey_credentials";
1334
+ dataType: "date";
1335
+ columnType: "MySqlDateTime";
1336
+ data: Date;
1337
+ driverParam: string | number;
1338
+ notNull: true;
1339
+ hasDefault: false;
1340
+ isPrimaryKey: false;
1341
+ isAutoincrement: false;
1342
+ hasRuntimeDefault: false;
1343
+ enumValues: undefined;
1344
+ baseColumn: never;
1345
+ identity: undefined;
1346
+ generated: undefined;
1347
+ }, {}, {}>;
1348
+ last_used_at: import("drizzle-orm/mysql-core").MySqlColumn<{
1349
+ name: "last_used_at";
1350
+ tableName: "byline_admin_passkey_credentials";
1351
+ dataType: "date";
1352
+ columnType: "MySqlDateTime";
1353
+ data: Date;
1354
+ driverParam: string | number;
1355
+ notNull: false;
1356
+ hasDefault: false;
1357
+ isPrimaryKey: false;
1358
+ isAutoincrement: false;
1359
+ hasRuntimeDefault: false;
1360
+ enumValues: undefined;
1361
+ baseColumn: never;
1362
+ identity: undefined;
1363
+ generated: undefined;
1364
+ }, {}, {}>;
1365
+ revoked_at: import("drizzle-orm/mysql-core").MySqlColumn<{
1366
+ name: "revoked_at";
1367
+ tableName: "byline_admin_passkey_credentials";
1368
+ dataType: "date";
1369
+ columnType: "MySqlDateTime";
1370
+ data: Date;
1371
+ driverParam: string | number;
1372
+ notNull: false;
1373
+ hasDefault: false;
1374
+ isPrimaryKey: false;
1375
+ isAutoincrement: false;
1376
+ hasRuntimeDefault: false;
1377
+ enumValues: undefined;
1378
+ baseColumn: never;
1379
+ identity: undefined;
1380
+ generated: undefined;
1381
+ }, {}, {}>;
1382
+ };
1383
+ dialect: 'mysql';
1384
+ }>;
1385
+ /** No FK cascades: short-lived authority is checked against live account/login state. */
1386
+ export declare const adminPasskeyAttempts: import("drizzle-orm/mysql-core").MySqlTableWithColumns<{
1387
+ name: "byline_admin_passkey_attempts";
1388
+ schema: undefined;
1389
+ columns: {
1390
+ token_hash: import("drizzle-orm/mysql-core").MySqlColumn<{
1391
+ name: "token_hash";
1392
+ tableName: "byline_admin_passkey_attempts";
1393
+ dataType: "custom";
1394
+ columnType: "MySqlCustomColumn";
1395
+ data: string;
1396
+ driverParam: Buffer<ArrayBufferLike>;
1397
+ notNull: true;
1398
+ hasDefault: false;
1399
+ isPrimaryKey: true;
1400
+ isAutoincrement: false;
1401
+ hasRuntimeDefault: false;
1402
+ enumValues: undefined;
1403
+ baseColumn: never;
1404
+ identity: undefined;
1405
+ generated: undefined;
1406
+ }, {}, {}>;
1407
+ purpose: import("drizzle-orm/mysql-core").MySqlColumn<{
1408
+ name: "purpose";
1409
+ tableName: "byline_admin_passkey_attempts";
1410
+ dataType: "string";
1411
+ columnType: "MySqlVarChar";
1412
+ data: "enrollment" | "fresh-authentication" | "grant" | "sign-in";
1413
+ driverParam: string | number;
1414
+ notNull: true;
1415
+ hasDefault: false;
1416
+ isPrimaryKey: false;
1417
+ isAutoincrement: false;
1418
+ hasRuntimeDefault: false;
1419
+ enumValues: [string, ...string[]];
1420
+ baseColumn: never;
1421
+ identity: undefined;
1422
+ generated: undefined;
1423
+ }, {}, {
1424
+ $type: "enrollment" | "fresh-authentication" | "grant" | "sign-in";
1425
+ }>;
1426
+ challenge: import("drizzle-orm/mysql-core").MySqlColumn<{
1427
+ name: "challenge";
1428
+ tableName: "byline_admin_passkey_attempts";
1429
+ dataType: "string";
1430
+ columnType: "MySqlVarChar";
1431
+ data: string;
1432
+ driverParam: string | number;
1433
+ notNull: false;
1434
+ hasDefault: false;
1435
+ isPrimaryKey: false;
1436
+ isAutoincrement: false;
1437
+ hasRuntimeDefault: false;
1438
+ enumValues: [string, ...string[]];
1439
+ baseColumn: never;
1440
+ identity: undefined;
1441
+ generated: undefined;
1442
+ }, {}, {}>;
1443
+ actor_user_id: import("drizzle-orm/mysql-core").MySqlColumn<{
1444
+ name: "actor_user_id";
1445
+ tableName: "byline_admin_passkey_attempts";
1446
+ dataType: "custom";
1447
+ columnType: "MySqlCustomColumn";
1448
+ data: string;
1449
+ driverParam: string;
1450
+ notNull: false;
1451
+ hasDefault: false;
1452
+ isPrimaryKey: false;
1453
+ isAutoincrement: false;
1454
+ hasRuntimeDefault: false;
1455
+ enumValues: undefined;
1456
+ baseColumn: never;
1457
+ identity: undefined;
1458
+ generated: undefined;
1459
+ }, {}, {}>;
1460
+ actor_login_id: import("drizzle-orm/mysql-core").MySqlColumn<{
1461
+ name: "actor_login_id";
1462
+ tableName: "byline_admin_passkey_attempts";
1463
+ dataType: "custom";
1464
+ columnType: "MySqlCustomColumn";
1465
+ data: string;
1466
+ driverParam: string;
1467
+ notNull: false;
1468
+ hasDefault: false;
1469
+ isPrimaryKey: false;
1470
+ isAutoincrement: false;
1471
+ hasRuntimeDefault: false;
1472
+ enumValues: undefined;
1473
+ baseColumn: never;
1474
+ identity: undefined;
1475
+ generated: undefined;
1476
+ }, {}, {}>;
1477
+ actor_generation: import("drizzle-orm/mysql-core").MySqlColumn<{
1478
+ name: "actor_generation";
1479
+ tableName: "byline_admin_passkey_attempts";
1480
+ dataType: "number";
1481
+ columnType: "MySqlInt";
1482
+ data: number;
1483
+ driverParam: string | number;
1484
+ notNull: false;
1485
+ hasDefault: false;
1486
+ isPrimaryKey: false;
1487
+ isAutoincrement: false;
1488
+ hasRuntimeDefault: false;
1489
+ enumValues: undefined;
1490
+ baseColumn: never;
1491
+ identity: undefined;
1492
+ generated: undefined;
1493
+ }, {}, {}>;
1494
+ action: import("drizzle-orm/mysql-core").MySqlColumn<{
1495
+ name: "action";
1496
+ tableName: "byline_admin_passkey_attempts";
1497
+ dataType: "string";
1498
+ columnType: "MySqlVarChar";
1499
+ data: "enroll" | "remove";
1500
+ driverParam: string | number;
1501
+ notNull: false;
1502
+ hasDefault: false;
1503
+ isPrimaryKey: false;
1504
+ isAutoincrement: false;
1505
+ hasRuntimeDefault: false;
1506
+ enumValues: [string, ...string[]];
1507
+ baseColumn: never;
1508
+ identity: undefined;
1509
+ generated: undefined;
1510
+ }, {}, {
1511
+ $type: "enroll" | "remove";
1512
+ }>;
1513
+ target_user_id: import("drizzle-orm/mysql-core").MySqlColumn<{
1514
+ name: "target_user_id";
1515
+ tableName: "byline_admin_passkey_attempts";
1516
+ dataType: "custom";
1517
+ columnType: "MySqlCustomColumn";
1518
+ data: string;
1519
+ driverParam: string;
1520
+ notNull: false;
1521
+ hasDefault: false;
1522
+ isPrimaryKey: false;
1523
+ isAutoincrement: false;
1524
+ hasRuntimeDefault: false;
1525
+ enumValues: undefined;
1526
+ baseColumn: never;
1527
+ identity: undefined;
1528
+ generated: undefined;
1529
+ }, {}, {}>;
1530
+ credential_record_id: import("drizzle-orm/mysql-core").MySqlColumn<{
1531
+ name: "credential_record_id";
1532
+ tableName: "byline_admin_passkey_attempts";
1533
+ dataType: "custom";
1534
+ columnType: "MySqlCustomColumn";
1535
+ data: string;
1536
+ driverParam: string;
1537
+ notNull: false;
1538
+ hasDefault: false;
1539
+ isPrimaryKey: false;
1540
+ isAutoincrement: false;
1541
+ hasRuntimeDefault: false;
1542
+ enumValues: undefined;
1543
+ baseColumn: never;
1544
+ identity: undefined;
1545
+ generated: undefined;
1546
+ }, {}, {}>;
1547
+ proof_credential_id: import("drizzle-orm/mysql-core").MySqlColumn<{
1548
+ name: "proof_credential_id";
1549
+ tableName: "byline_admin_passkey_attempts";
1550
+ dataType: "custom";
1551
+ columnType: "MySqlCustomColumn";
1552
+ data: string;
1553
+ driverParam: string;
1554
+ notNull: false;
1555
+ hasDefault: false;
1556
+ isPrimaryKey: false;
1557
+ isAutoincrement: false;
1558
+ hasRuntimeDefault: false;
1559
+ enumValues: undefined;
1560
+ baseColumn: never;
1561
+ identity: undefined;
1562
+ generated: undefined;
1563
+ }, {}, {}>;
1564
+ created_at: import("drizzle-orm/mysql-core").MySqlColumn<{
1565
+ name: "created_at";
1566
+ tableName: "byline_admin_passkey_attempts";
1567
+ dataType: "date";
1568
+ columnType: "MySqlDateTime";
1569
+ data: Date;
1570
+ driverParam: string | number;
1571
+ notNull: true;
1572
+ hasDefault: false;
1573
+ isPrimaryKey: false;
1574
+ isAutoincrement: false;
1575
+ hasRuntimeDefault: false;
1576
+ enumValues: undefined;
1577
+ baseColumn: never;
1578
+ identity: undefined;
1579
+ generated: undefined;
1580
+ }, {}, {}>;
1581
+ expires_at: import("drizzle-orm/mysql-core").MySqlColumn<{
1582
+ name: "expires_at";
1583
+ tableName: "byline_admin_passkey_attempts";
1584
+ dataType: "date";
1585
+ columnType: "MySqlDateTime";
1586
+ data: Date;
1587
+ driverParam: string | number;
1588
+ notNull: true;
1589
+ hasDefault: false;
1590
+ isPrimaryKey: false;
1591
+ isAutoincrement: false;
1592
+ hasRuntimeDefault: false;
1593
+ enumValues: undefined;
1594
+ baseColumn: never;
1595
+ identity: undefined;
1596
+ generated: undefined;
1597
+ }, {}, {}>;
1598
+ consumed_at: import("drizzle-orm/mysql-core").MySqlColumn<{
1599
+ name: "consumed_at";
1600
+ tableName: "byline_admin_passkey_attempts";
1601
+ dataType: "date";
1602
+ columnType: "MySqlDateTime";
1603
+ data: Date;
1604
+ driverParam: string | number;
1605
+ notNull: false;
1606
+ hasDefault: false;
1607
+ isPrimaryKey: false;
1608
+ isAutoincrement: false;
1609
+ hasRuntimeDefault: false;
1610
+ enumValues: undefined;
1611
+ baseColumn: never;
1612
+ identity: undefined;
1613
+ generated: undefined;
1614
+ }, {}, {}>;
1615
+ };
1616
+ dialect: 'mysql';
1617
+ }>;
@@ -24,7 +24,7 @@
24
24
  * reference.
25
25
  */
26
26
  import { relations, sql } from 'drizzle-orm';
27
- import { boolean, datetime, foreignKey, index, int, json, mysqlTable, primaryKey, text, unique, varchar, } from 'drizzle-orm/mysql-core';
27
+ import { bigint, boolean, customType, datetime, foreignKey, index, int, json, mysqlTable, primaryKey, text, unique, varchar, } from 'drizzle-orm/mysql-core';
28
28
  import { createdAt, timestamps, uuidChar } from './common.js';
29
29
  // Foreign keys below use the table-level `foreignKey()` builder with an
30
30
  // explicit, short `fk_<table>_<column>` name rather than the Postgres
@@ -34,8 +34,14 @@ import { createdAt, timestamps, uuidChar } from './common.js';
34
34
  // ---------------------------------------------------------------------------
35
35
  // byline_admin_users
36
36
  // ---------------------------------------------------------------------------
37
+ const passkeyBytes = customType({
38
+ dataType: (config) => `varbinary(${config?.length ?? 1366})`,
39
+ toDriver: (value) => Buffer.from(value, 'ascii'),
40
+ fromDriver: (value) => value.toString('ascii'),
41
+ });
37
42
  export const adminUsers = mysqlTable('byline_admin_users', {
38
43
  id: uuidChar('id').primaryKey(),
44
+ webauthn_user_handle: passkeyBytes('webauthn_user_handle', { length: 43 }).unique(),
39
45
  session_version: int('session_version').notNull().default(0),
40
46
  vid: int('vid').notNull().default(1),
41
47
  given_name: varchar('given_name', { length: 100 }),
@@ -176,6 +182,8 @@ export const adminLoginSessions = mysqlTable('byline_admin_login_sessions', {
176
182
  foreignColumns: [adminUsers.id],
177
183
  }).onDelete('cascade'),
178
184
  index('idx_admin_login_sessions_user').on(table.admin_user_id),
185
+ index('idx_admin_login_sessions_expiry').on(table.expires_at),
186
+ index('idx_admin_login_sessions_revoked').on(table.revoked_at),
179
187
  ]);
180
188
  export const adminRefreshTokens = mysqlTable('byline_admin_refresh_tokens', {
181
189
  id: uuidChar('id').primaryKey(),
@@ -211,6 +219,7 @@ export const adminRefreshTokens = mysqlTable('byline_admin_refresh_tokens', {
211
219
  foreignColumns: [adminUsers.id],
212
220
  }).onDelete('cascade'),
213
221
  index('idx_admin_refresh_tokens_sid').on(table.sid),
222
+ index('idx_admin_refresh_tokens_legacy_expiry').on(table.sid, table.expires_at),
214
223
  index('idx_byline_admin_refresh_tokens_user').on(table.admin_user_id),
215
224
  index('idx_byline_admin_refresh_tokens_token_hash').on(table.token_hash),
216
225
  ]);
@@ -253,3 +262,48 @@ export const adminSignInRateLimits = mysqlTable('byline_admin_sign_in_rate_limit
253
262
  attempts: int('attempts').notNull(),
254
263
  expires_at: datetime('expires_at', { fsp: 3 }).notNull(),
255
264
  }, (table) => [index('idx_admin_sign_in_rate_limits_expiry').on(table.expires_at)]);
265
+ /** Credential identities survive revocation and account deletion as tombstones. */
266
+ export const adminPasskeyCredentials = mysqlTable('byline_admin_passkey_credentials', {
267
+ id: uuidChar('id').primaryKey(),
268
+ admin_user_id: uuidChar('admin_user_id'),
269
+ credential_id: passkeyBytes('credential_id', { length: 1366 }).notNull().unique(),
270
+ public_key: text('public_key').notNull(),
271
+ counter: bigint('counter', { mode: 'number' }).notNull(),
272
+ transports: json('transports').$type().notNull(),
273
+ backup_eligible: boolean('backup_eligible').notNull(),
274
+ backed_up: boolean('backed_up').notNull(),
275
+ name: varchar('name', { length: 80 }).notNull(),
276
+ created_at: datetime('created_at', { fsp: 6 }).notNull(),
277
+ last_used_at: datetime('last_used_at', { fsp: 6 }),
278
+ revoked_at: datetime('revoked_at', { fsp: 6 }),
279
+ }, (table) => [
280
+ foreignKey({
281
+ name: 'fk_admin_passkeys_user',
282
+ columns: [table.admin_user_id],
283
+ foreignColumns: [adminUsers.id],
284
+ }).onDelete('set null'),
285
+ index('idx_admin_passkeys_user').on(table.admin_user_id),
286
+ ]);
287
+ /** No FK cascades: short-lived authority is checked against live account/login state. */
288
+ export const adminPasskeyAttempts = mysqlTable('byline_admin_passkey_attempts', {
289
+ token_hash: passkeyBytes('token_hash', { length: 64 }).primaryKey(),
290
+ purpose: varchar('purpose', { length: 24 })
291
+ .$type()
292
+ .notNull(),
293
+ // Compared only by the verifier in application code, never by SQL collation.
294
+ challenge: varchar('challenge', { length: 43 }),
295
+ actor_user_id: uuidChar('actor_user_id'),
296
+ actor_login_id: uuidChar('actor_login_id'),
297
+ actor_generation: int('actor_generation'),
298
+ action: varchar('action', { length: 8 }).$type(),
299
+ target_user_id: uuidChar('target_user_id'),
300
+ credential_record_id: uuidChar('credential_record_id'),
301
+ proof_credential_id: uuidChar('proof_credential_id'),
302
+ created_at: datetime('created_at', { fsp: 6 }).notNull(),
303
+ expires_at: datetime('expires_at', { fsp: 6 }).notNull(),
304
+ consumed_at: datetime('consumed_at', { fsp: 6 }),
305
+ }, (table) => [
306
+ index('idx_admin_passkey_attempts_expiry').on(table.expires_at),
307
+ index('idx_admin_passkey_attempts_consumed').on(table.consumed_at),
308
+ index('idx_admin_passkey_attempts_login').on(table.actor_login_id),
309
+ ]);
@@ -4102,4 +4102,4 @@ export declare const recurringTasks: import("drizzle-orm/mysql-core").MySqlTable
4102
4102
  };
4103
4103
  dialect: 'mysql';
4104
4104
  }>;
4105
- export { adminLoginSessions, adminPermissions, adminPermissionsRelations, adminRefreshTokens, adminRefreshTokensRelations, adminRoleAdminUser, adminRoleAdminUserRelations, adminRoles, adminRolesRelations, adminSignInRateLimits, adminUserPreferences, adminUsers, adminUsersRelations, } from './auth.js';
4105
+ export { adminLoginSessions, adminPasskeyAttempts, adminPasskeyCredentials, adminPermissions, adminPermissionsRelations, adminRefreshTokens, adminRefreshTokensRelations, adminRoleAdminUser, adminRoleAdminUserRelations, adminRoles, adminRolesRelations, adminSignInRateLimits, adminUserPreferences, adminUsers, adminUsersRelations, } from './auth.js';
@@ -996,4 +996,4 @@ export const recurringTasks = mysqlTable('byline_recurring_tasks', {
996
996
  // Auth schema — byline_admin_users, byline_admin_roles, etc.
997
997
  // See ./auth.ts for definitions and rationale.
998
998
  // ---------------------------------------------------------------------------
999
- export { adminLoginSessions, adminPermissions, adminPermissionsRelations, adminRefreshTokens, adminRefreshTokensRelations, adminRoleAdminUser, adminRoleAdminUserRelations, adminRoles, adminRolesRelations, adminSignInRateLimits, adminUserPreferences, adminUsers, adminUsersRelations, } from './auth.js';
999
+ export { adminLoginSessions, adminPasskeyAttempts, adminPasskeyCredentials, adminPermissions, adminPermissionsRelations, adminRefreshTokens, adminRefreshTokensRelations, adminRoleAdminUser, adminRoleAdminUserRelations, adminRoles, adminRolesRelations, adminSignInRateLimits, adminUserPreferences, adminUsers, adminUsersRelations, } from './auth.js';
@@ -131,6 +131,9 @@ function columnByteWidth(columnName, sqlType) {
131
131
  const typeName = typeMatch?.[1]?.toLowerCase();
132
132
  const args = typeMatch?.[2];
133
133
  switch (typeName) {
134
+ case 'binary':
135
+ case 'varbinary':
136
+ return Number(args);
134
137
  case 'boolean':
135
138
  case 'tinyint':
136
139
  return 1;
@@ -11,7 +11,9 @@ import { createAdminPermissionsRepository } from './admin-permissions-repository
11
11
  import { createAdminPreferencesRepository } from './admin-preferences-repository.js';
12
12
  import { createAdminRolesRepository } from './admin-roles-repository.js';
13
13
  import { createAdminUsersRepository } from './admin-users-repository.js';
14
+ import { createAuthMaintenanceRepository } from './auth-maintenance-repository.js';
14
15
  import { createLoginSessionsRepository } from './login-sessions-repository.js';
16
+ import { createPasskeysRepository } from './passkeys-repository.js';
15
17
  import { createRefreshTokensRepository } from './refresh-tokens-repository.js';
16
18
  import { createSignInRateLimitStore } from './sign-in-rate-limit-store.js';
17
19
  /**
@@ -24,6 +26,9 @@ import { createSignInRateLimitStore } from './sign-in-rate-limit-store.js';
24
26
  * Construct once per process, alongside the `mysqlAdapter` call.
25
27
  */
26
28
  export function createAdminStore(db) {
29
+ return createScopedAdminStore(db, null);
30
+ }
31
+ function createScopedAdminStore(db, lockedAccounts) {
27
32
  return {
28
33
  async withSessionLock(adminUserId, work) {
29
34
  return db.transaction(async (tx) => {
@@ -32,7 +37,7 @@ export function createAdminStore(db) {
32
37
  .from(adminUsers)
33
38
  .where(eq(adminUsers.id, adminUserId))
34
39
  .for('update');
35
- const scoped = createAdminStore(tx);
40
+ const scoped = createScopedAdminStore(tx, [adminUserId]);
36
41
  const user = await scoped.adminUsers.getByIdForSignIn(adminUserId);
37
42
  return work(scoped, user);
38
43
  });
@@ -46,9 +51,11 @@ export function createAdminStore(db) {
46
51
  .where(eq(adminUsers.id, id))
47
52
  .for('update');
48
53
  }
49
- return work(createAdminStore(tx));
54
+ return work(createScopedAdminStore(tx, adminUserIds));
50
55
  });
51
56
  },
57
+ authMaintenance: createAuthMaintenanceRepository(db, lockedAccounts),
58
+ passkeys: createPasskeysRepository(db, lockedAccounts),
52
59
  loginSessions: createLoginSessionsRepository(db),
53
60
  signInRateLimits: createSignInRateLimitStore(db),
54
61
  adminUsers: createAdminUsersRepository(db),
@@ -0,0 +1,11 @@
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 { AuthMaintenanceRepository } from '@byline/admin/auth';
9
+ import type { MySql2Database } from 'drizzle-orm/mysql2';
10
+ import type * as schema from '../../database/schema/index.js';
11
+ export declare function createAuthMaintenanceRepository(db: MySql2Database<typeof schema>, lockedAccounts: readonly string[] | null): AuthMaintenanceRepository;
@@ -0,0 +1,94 @@
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 { and, eq, inArray, isNull, lte, notInArray } from 'drizzle-orm';
9
+ import { adminPasskeyAttempts as attempts, adminLoginSessions as logins, adminRefreshTokens as refresh, } from '../../database/schema/auth.js';
10
+ export function createAuthMaintenanceRepository(db, lockedAccounts) {
11
+ return {
12
+ async purgeAttempts(before) {
13
+ // Separate indexed scans avoid sorting or materializing an unbounded OR result.
14
+ const expired = await db
15
+ .select({ id: attempts.token_hash })
16
+ .from(attempts)
17
+ .where(lte(attempts.expires_at, before))
18
+ .limit(100);
19
+ const ids = expired.map((row) => row.id);
20
+ if (ids.length < 100) {
21
+ const consumed = await db
22
+ .select({ id: attempts.token_hash })
23
+ .from(attempts)
24
+ .where(and(lte(attempts.consumed_at, before), ids.length ? notInArray(attempts.token_hash, ids) : undefined))
25
+ .limit(100 - ids.length);
26
+ ids.push(...consumed.map((row) => row.id));
27
+ }
28
+ if (!ids.length)
29
+ return 0;
30
+ const [removed] = await db.delete(attempts).where(inArray(attempts.token_hash, ids));
31
+ return removed.affectedRows;
32
+ },
33
+ async findTerminalLogin(before) {
34
+ const projection = { id: logins.id, adminUserId: logins.admin_user_id };
35
+ const [expired] = await db
36
+ .select(projection)
37
+ .from(logins)
38
+ .where(lte(logins.expires_at, before))
39
+ .limit(1);
40
+ if (expired)
41
+ return expired;
42
+ const [revoked] = await db
43
+ .select(projection)
44
+ .from(logins)
45
+ .where(lte(logins.revoked_at, before))
46
+ .limit(1);
47
+ return revoked ?? null;
48
+ },
49
+ async pruneTerminalLogin({ id, adminUserId, before }) {
50
+ if (!lockedAccounts?.includes(adminUserId))
51
+ throw new Error('Login cleanup requires the owning account lock');
52
+ const [login] = await db
53
+ .select()
54
+ .from(logins)
55
+ .where(and(eq(logins.id, id), eq(logins.admin_user_id, adminUserId)))
56
+ .for('update');
57
+ if (!login || (login.expires_at > before && (!login.revoked_at || login.revoked_at > before)))
58
+ return { refreshDeleted: 0, loginDeleted: false };
59
+ const children = await db
60
+ .select({ id: refresh.id })
61
+ .from(refresh)
62
+ .where(eq(refresh.sid, id))
63
+ .limit(100);
64
+ let refreshDeleted = 0;
65
+ if (children.length) {
66
+ const [removed] = await db.delete(refresh).where(inArray(refresh.id, children.map((row) => row.id)));
67
+ refreshDeleted = removed.affectedRows;
68
+ }
69
+ const [remaining] = await db
70
+ .select({ id: refresh.id })
71
+ .from(refresh)
72
+ .where(eq(refresh.sid, id))
73
+ .limit(1);
74
+ if (remaining)
75
+ return { refreshDeleted, loginDeleted: false };
76
+ // The account and login-row locks prevent native renewal or issuance from
77
+ // adding children between this empty check and deletion. The FK cascade
78
+ // has no work to do; the task never relies on it to drain a token family.
79
+ await db.delete(logins).where(eq(logins.id, id));
80
+ return { refreshDeleted, loginDeleted: true };
81
+ },
82
+ async purgeLegacyRefreshTokens(before) {
83
+ const rows = await db
84
+ .select({ id: refresh.id })
85
+ .from(refresh)
86
+ .where(and(isNull(refresh.sid), lte(refresh.expires_at, before)))
87
+ .limit(100);
88
+ if (!rows.length)
89
+ return 0;
90
+ const [removed] = await db.delete(refresh).where(and(inArray(refresh.id, rows.map((row) => row.id)), isNull(refresh.sid), lte(refresh.expires_at, before)));
91
+ return removed.affectedRows;
92
+ },
93
+ };
94
+ }
@@ -0,0 +1,11 @@
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 PasskeysRepository } from '@byline/admin/auth';
9
+ import type { MySql2Database } from 'drizzle-orm/mysql2';
10
+ import type * as schema from '../../database/schema/index.js';
11
+ export declare function createPasskeysRepository(db: MySql2Database<typeof schema>, lockedAccounts: readonly string[] | null): PasskeysRepository;
@@ -0,0 +1,165 @@
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 { assertPasskeyAttempt, assertPasskeyBytes, assertPasskeyClaim, assertPasskeyTokenHash, } from '@byline/admin/auth';
9
+ import { and, count, eq, gt, isNull, sql } from 'drizzle-orm';
10
+ import { v7 as uuidv7 } from 'uuid';
11
+ import { adminLoginSessions, adminRefreshTokens, adminUsers, adminPasskeyAttempts as attempts, adminPasskeyCredentials as credentials, } from '../../database/schema/auth.js';
12
+ import { auditLog } from '../../database/schema/index.js';
13
+ export function createPasskeysRepository(db, lockedAccounts) {
14
+ function requireLock(id) {
15
+ if (id === null || !lockedAccounts?.includes(id))
16
+ throw new Error('Passkey write requires the owning account lock');
17
+ }
18
+ async function requireCredentialLock(id) {
19
+ const [row] = await db
20
+ .select({ owner: credentials.admin_user_id })
21
+ .from(credentials)
22
+ .where(eq(credentials.id, id));
23
+ if (!row)
24
+ throw new Error('Passkey credential not found');
25
+ requireLock(row.owner);
26
+ }
27
+ return {
28
+ async invalidateAccountLogins(adminUserId, at) {
29
+ requireLock(adminUserId);
30
+ await db
31
+ .update(adminUsers)
32
+ .set({ session_version: sql `${adminUsers.session_version} + 1` })
33
+ .where(eq(adminUsers.id, adminUserId));
34
+ await db
35
+ .update(adminLoginSessions)
36
+ .set({ revoked_at: at })
37
+ .where(and(eq(adminLoginSessions.admin_user_id, adminUserId), isNull(adminLoginSessions.revoked_at)));
38
+ await db
39
+ .update(adminRefreshTokens)
40
+ .set({ revoked_at: at })
41
+ .where(and(eq(adminRefreshTokens.admin_user_id, adminUserId), isNull(adminRefreshTokens.revoked_at)));
42
+ },
43
+ async appendRemovalAudit(input) {
44
+ if (input.outcome === 'success') {
45
+ requireLock(input.actorId);
46
+ requireLock(input.targetId);
47
+ }
48
+ await db.insert(auditLog).values({
49
+ id: uuidv7(),
50
+ actor_id: input.actorId,
51
+ actor_realm: 'admin',
52
+ action: 'admin.passkey.remove',
53
+ after: {
54
+ targetUserId: input.targetId,
55
+ credentialRecordId: input.credentialId,
56
+ outcome: input.outcome,
57
+ },
58
+ });
59
+ },
60
+ async getUserHandle(adminUserId) {
61
+ const [row] = await db
62
+ .select({ handle: adminUsers.webauthn_user_handle })
63
+ .from(adminUsers)
64
+ .where(eq(adminUsers.id, adminUserId));
65
+ return row?.handle ?? null;
66
+ },
67
+ async ensureUserHandle(adminUserId, candidate) {
68
+ requireLock(adminUserId);
69
+ assertPasskeyBytes(candidate, 32, 32);
70
+ await db
71
+ .update(adminUsers)
72
+ .set({ webauthn_user_handle: candidate })
73
+ .where(and(eq(adminUsers.id, adminUserId), isNull(adminUsers.webauthn_user_handle)));
74
+ const [row] = await db
75
+ .select({ handle: adminUsers.webauthn_user_handle })
76
+ .from(adminUsers)
77
+ .where(eq(adminUsers.id, adminUserId));
78
+ if (!row?.handle)
79
+ throw new Error('Passkey account not found');
80
+ return row.handle;
81
+ },
82
+ async findCredential(id) {
83
+ const [row] = await db.select().from(credentials).where(eq(credentials.id, id));
84
+ return row ?? null;
85
+ },
86
+ async findCredentialByCredentialId(credentialId) {
87
+ assertPasskeyBytes(credentialId, 1024);
88
+ const [row] = await db
89
+ .select()
90
+ .from(credentials)
91
+ .where(eq(credentials.credential_id, credentialId));
92
+ return row ?? null;
93
+ },
94
+ async listCredentials(adminUserId) {
95
+ return db
96
+ .select()
97
+ .from(credentials)
98
+ .where(and(eq(credentials.admin_user_id, adminUserId), isNull(credentials.revoked_at)));
99
+ },
100
+ async createCredential(input) {
101
+ requireLock(input.admin_user_id);
102
+ assertPasskeyBytes(input.credential_id, 1024);
103
+ assertPasskeyBytes(input.public_key, 8192);
104
+ await db.insert(credentials).values(input);
105
+ },
106
+ async updateCredential(id, patch) {
107
+ await requireCredentialLock(id);
108
+ await db
109
+ .update(credentials)
110
+ .set(patch)
111
+ .where(and(eq(credentials.id, id), isNull(credentials.revoked_at)));
112
+ },
113
+ async revokeCredential(id, at) {
114
+ await requireCredentialLock(id);
115
+ await db
116
+ .update(credentials)
117
+ .set({ revoked_at: at })
118
+ .where(and(eq(credentials.id, id), isNull(credentials.revoked_at)));
119
+ },
120
+ async createAttempt(input) {
121
+ assertPasskeyAttempt(input);
122
+ if (input.purpose !== 'sign-in')
123
+ requireLock(input.actor_user_id);
124
+ await db.insert(attempts).values(input);
125
+ },
126
+ async findAttempt(tokenHash) {
127
+ assertPasskeyTokenHash(tokenHash);
128
+ const [row] = await db.select().from(attempts).where(eq(attempts.token_hash, tokenHash));
129
+ return row ?? null;
130
+ },
131
+ async countPendingForLogin(loginId, now) {
132
+ const [row] = await db
133
+ .select({ total: count() })
134
+ .from(attempts)
135
+ .where(and(eq(attempts.actor_login_id, loginId), isNull(attempts.consumed_at), gt(attempts.expires_at, now)));
136
+ return Number(row?.total ?? 0);
137
+ },
138
+ async claimAttempt(input) {
139
+ if (lockedAccounts !== null)
140
+ throw new Error('Passkey claims require a root store, outside account transactions');
141
+ assertPasskeyClaim(input);
142
+ const binding = input.binding;
143
+ const match = and(eq(attempts.token_hash, input.tokenHash), eq(attempts.purpose, input.purpose), isNull(attempts.consumed_at), gt(attempts.expires_at, input.now), binding.actor_user_id === null
144
+ ? isNull(attempts.actor_user_id)
145
+ : eq(attempts.actor_user_id, binding.actor_user_id), binding.actor_login_id === null
146
+ ? isNull(attempts.actor_login_id)
147
+ : eq(attempts.actor_login_id, binding.actor_login_id), binding.actor_generation === null
148
+ ? isNull(attempts.actor_generation)
149
+ : eq(attempts.actor_generation, binding.actor_generation), input.purpose === 'sign-in' ? isNull(attempts.action) : eq(attempts.action, input.action));
150
+ // The transaction promise resolves only after commit. No retry/savepoint path.
151
+ return db.transaction(async (tx) => {
152
+ const [result] = await tx.update(attempts).set({ consumed_at: input.now }).where(match);
153
+ if (result.affectedRows !== 1)
154
+ return null;
155
+ const [row] = await tx
156
+ .select()
157
+ .from(attempts)
158
+ .where(eq(attempts.token_hash, input.tokenHash));
159
+ if (!row)
160
+ throw new Error('Claimed passkey attempt disappeared');
161
+ return row;
162
+ });
163
+ },
164
+ };
165
+ }
@@ -5,9 +5,10 @@
5
5
  *
6
6
  * Copyright (c) Infonomic Company Limited
7
7
  */
8
- import { and, eq, isNull, lt } from 'drizzle-orm';
8
+ import { and, eq, isNull } from 'drizzle-orm';
9
9
  import { adminRefreshTokens } from '../../database/schema/auth.js';
10
10
  import { affectedRowCount } from '../storage/storage-utils.js';
11
+ import { createAuthMaintenanceRepository } from './auth-maintenance-repository.js';
11
12
  /**
12
13
  * MySQL implementation of `RefreshTokensRepository`, backing the built-in
13
14
  * `JwtSessionProvider`. Ported from
@@ -117,10 +118,8 @@ export function createRefreshTokensRepository(db) {
117
118
  return affectedRowCount(result);
118
119
  },
119
120
  async purgeExpired(now = new Date()) {
120
- const result = await db
121
- .delete(adminRefreshTokens)
122
- .where(lt(adminRefreshTokens.expires_at, now));
123
- return affectedRowCount(result);
121
+ // Preserve every member of a native login, including expired predecessors.
122
+ return createAuthMaintenanceRepository(db, null).purgeLegacyRefreshTokens(new Date(now.getTime() - 30 * 86_400_000));
124
123
  },
125
124
  async listActiveForUser(adminUserId) {
126
125
  return db
@@ -23,6 +23,7 @@
23
23
  * `implements IDocumentQueries`.
24
24
  */
25
25
  import type { CollectionDefinition, DocumentFilter, FieldFilterOperator, FieldSort, FlattenedStore, ICollectionQueries, IDocumentQueries, LocaleVisibility, MissingLocalePolicy, ReadMode } from '@byline/core';
26
+ import { type UnavailableBlock } from '@byline/core';
26
27
  import type { MySql2Database } from 'drizzle-orm/mysql2';
27
28
  import { DocumentPublishScheduleQueries } from './publish-schedules.js';
28
29
  import type * as schema from '../../database/schema/index.js';
@@ -334,6 +335,7 @@ export declare class DocumentQueries implements IDocumentQueries {
334
335
  availableLocales: string[];
335
336
  _availableVersionLocales: string[];
336
337
  _localeAgnostic: boolean;
338
+ unavailableBlocks?: UnavailableBlock[] | undefined;
337
339
  restoreWarnings?: string[] | undefined;
338
340
  } | {
339
341
  document_version_id: string;
@@ -377,11 +379,8 @@ export declare class DocumentQueries implements IDocumentQueries {
377
379
  availableLocales: string[];
378
380
  _availableVersionLocales: string[];
379
381
  _localeAgnostic: boolean;
382
+ unavailableBlocks?: UnavailableBlock[] | undefined;
380
383
  } | {
381
- resolved_locale?: undefined;
382
- availableLocales?: undefined;
383
- _availableVersionLocales?: undefined;
384
- _localeAgnostic?: undefined;
385
384
  document_version_id: string;
386
385
  document_id: string;
387
386
  path: string;
@@ -512,7 +512,7 @@ export class DocumentQueries {
512
512
  * _id and _type for blocks and array items inline. Pure JS/core-delegate
513
513
  * code — ports unchanged from pg.
514
514
  */
515
- reconstructFromUnifiedRows(unifiedFieldValues, definition, locale, metaRows, lenient = false, onMissingLocale, sourceLocale, localeContext) {
515
+ reconstructFromUnifiedRows(unifiedFieldValues, definition, locale, metaRows, lenient = false, onMissingLocale, sourceLocale, localeContext, identity) {
516
516
  const flattenedData = unifiedFieldValues.map((row) => extractFlattenedFieldValue(row));
517
517
  if (metaRows) {
518
518
  for (const meta of metaRows) {
@@ -561,14 +561,20 @@ export class DocumentQueries {
561
561
  rows = flattenedData.filter((row) => row.locale === 'all' || row.field_type === 'meta');
562
562
  }
563
563
  }
564
- const { data, warnings } = restoreFieldSetData(definition.fields, rows, resolveLocale);
564
+ const { data, warnings, unavailableBlocks } = restoreFieldSetData(definition.fields, rows, resolveLocale, identity ? { collectionPath: definition.path, ...identity } : undefined);
565
565
  if (!lenient && warnings.length > 0) {
566
566
  throw ERR_DATABASE({
567
567
  message: `document reconstruction failed with ${warnings.length} warnings`,
568
568
  details: { warnings },
569
569
  }).log(getLogger());
570
570
  }
571
- return { fields: data, warnings, resolvedLocale, localeAgnostic: ledger.localeAgnostic };
571
+ return {
572
+ fields: data,
573
+ warnings,
574
+ unavailableBlocks,
575
+ resolvedLocale,
576
+ localeAgnostic: ledger.localeAgnostic,
577
+ };
572
578
  }
573
579
  /**
574
580
  * getCurrentVersionMetadata — narrow metadata fetch for the current version.
@@ -686,7 +692,7 @@ export class DocumentQueries {
686
692
  .where(eq(metaStore.document_version_id, document.id));
687
693
  const availability = (await this.getAvailableLocalesByVersion([document.id])).get(document.id);
688
694
  const advertised = (await this.getAdvertisedLocalesByDocument([document.document_id])).get(document.document_id);
689
- const { fields, warnings, resolvedLocale, localeAgnostic } = this.reconstructFromUnifiedRows(unifiedFieldValues, definition, locale, metaRows, lenient, onMissingLocale, document.source_locale, { visibility: localeVisibility, ledger: availability, availableLocales: advertised });
695
+ const { fields, warnings, unavailableBlocks, resolvedLocale, localeAgnostic } = this.reconstructFromUnifiedRows(unifiedFieldValues, definition, locale, metaRows, lenient, onMissingLocale, document.source_locale, { visibility: localeVisibility, ledger: availability, availableLocales: advertised }, { documentId: document.document_id, versionId: document.id });
690
696
  return {
691
697
  document_version_id: document.id,
692
698
  document_id: document.document_id,
@@ -702,6 +708,7 @@ export class DocumentQueries {
702
708
  availableLocales: advertised ?? [],
703
709
  _availableVersionLocales: availability?.availableLocales ?? [],
704
710
  _localeAgnostic: localeAgnostic,
711
+ ...(unavailableBlocks.length > 0 ? { unavailableBlocks } : {}),
705
712
  ...(lenient && warnings.length > 0 ? { restoreWarnings: warnings } : {}),
706
713
  };
707
714
  }
@@ -781,7 +788,7 @@ export class DocumentQueries {
781
788
  .where(eq(metaStore.document_version_id, document.id));
782
789
  const availability = (await this.getAvailableLocalesByVersion([document.id])).get(document.id);
783
790
  const advertised = (await this.getAdvertisedLocalesByDocument([document.document_id])).get(document.document_id);
784
- const { fields, resolvedLocale, localeAgnostic } = this.reconstructFromUnifiedRows(unifiedFieldValues, definition, locale, metaRows, false, onMissingLocale, document.source_locale, { visibility: localeVisibility, ledger: availability, availableLocales: advertised });
791
+ const { fields, unavailableBlocks, resolvedLocale, localeAgnostic } = this.reconstructFromUnifiedRows(unifiedFieldValues, definition, locale, metaRows, false, onMissingLocale, document.source_locale, { visibility: localeVisibility, ledger: availability, availableLocales: advertised }, { documentId: document.document_id, versionId: document.id });
785
792
  return {
786
793
  document_version_id: document.id,
787
794
  document_id: document.document_id,
@@ -797,6 +804,7 @@ export class DocumentQueries {
797
804
  availableLocales: advertised ?? [],
798
805
  _availableVersionLocales: availability?.availableLocales ?? [],
799
806
  _localeAgnostic: localeAgnostic,
807
+ ...(unavailableBlocks.length > 0 ? { unavailableBlocks } : {}),
800
808
  };
801
809
  }
802
810
  const fieldValues = this.convertUnionRowToFlattenedStores(unifiedFieldValues);
@@ -853,7 +861,7 @@ export class DocumentQueries {
853
861
  .from(metaStore)
854
862
  .where(eq(metaStore.document_version_id, document.id));
855
863
  const ledger = (await this.getAvailableLocalesByVersion([document.id])).get(document.id);
856
- const { fields, resolvedLocale, localeAgnostic } = this.reconstructFromUnifiedRows(unifiedFieldValues, definition, locale, metaRows, false, undefined, document.source_locale, { ledger });
864
+ const { fields, unavailableBlocks, resolvedLocale, localeAgnostic } = this.reconstructFromUnifiedRows(unifiedFieldValues, definition, locale, metaRows, false, undefined, document.source_locale, { ledger }, { documentId: document.document_id, versionId: document.id });
857
865
  return {
858
866
  document_version_id: document.id,
859
867
  document_id: document.document_id,
@@ -865,6 +873,7 @@ export class DocumentQueries {
865
873
  fields,
866
874
  resolved_locale: resolvedLocale,
867
875
  _localeAgnostic: localeAgnostic,
876
+ ...(unavailableBlocks.length > 0 ? { unavailableBlocks } : {}),
868
877
  };
869
878
  }
870
879
  /**
@@ -1429,11 +1438,11 @@ export class DocumentQueries {
1429
1438
  for (const doc of docs) {
1430
1439
  const versionFieldValues = fieldValuesByVersion.get(doc.id) || [];
1431
1440
  const docMetaRows = (metaByVersion.get(doc.id) ?? []);
1432
- const { fields, resolvedLocale, localeAgnostic } = this.reconstructFromUnifiedRows(versionFieldValues, definition, locale, docMetaRows, false, onMissingLocale, doc.source_locale, {
1441
+ const { fields, unavailableBlocks, resolvedLocale, localeAgnostic } = this.reconstructFromUnifiedRows(versionFieldValues, definition, locale, docMetaRows, false, onMissingLocale, doc.source_locale, {
1433
1442
  visibility: localeVisibility,
1434
1443
  ledger: versionLedgers?.get(doc.id),
1435
1444
  availableLocales: documentAdvertised?.get(doc.document_id),
1436
- });
1445
+ }, { documentId: doc.document_id, versionId: doc.id });
1437
1446
  // When specific fields were requested, trim the reconstructed object
1438
1447
  // to only those fields. Store-level filtering avoids querying unused
1439
1448
  // tables, but fields sharing a store (e.g. price + views in numeric)
@@ -1454,6 +1463,7 @@ export class DocumentQueries {
1454
1463
  fields: trimmedFields,
1455
1464
  resolved_locale: resolvedLocale,
1456
1465
  _localeAgnostic: localeAgnostic,
1466
+ ...(unavailableBlocks.length > 0 ? { unavailableBlocks } : {}),
1457
1467
  };
1458
1468
  result.push(documentWithFields);
1459
1469
  }
@@ -216,7 +216,7 @@ describe('fallback locale selection under a changed default (mysql, live databas
216
216
  const listed = documents.find((d) => d.document_id === id);
217
217
  expect(byPath?.fields.title).toBe(byId?.fields.title);
218
218
  expect(listed?.fields.title).toBe(byId?.fields.title);
219
- expect(byPath?.resolved_locale).toBe(byId?.resolved_locale);
219
+ expect(byPath && 'resolved_locale' in byPath ? byPath.resolved_locale : undefined).toBe(byId?.resolved_locale);
220
220
  expect(listed?.resolved_locale).toBe(byId?.resolved_locale);
221
221
  }
222
222
  });
@@ -326,7 +326,7 @@ describe('fallback locale selection under a changed default (mysql, live databas
326
326
  });
327
327
  expect(byPath?.document_id).toBe(id);
328
328
  expect(byPath?.fields.title, 'the incomplete translation is not delivered').toBe('Slug EN');
329
- expect(byPath?.resolved_locale).toBe('en');
329
+ expect(byPath && 'resolved_locale' in byPath ? byPath.resolved_locale : undefined).toBe('en');
330
330
  });
331
331
  it('a projected list falls back to the document source, not the changed default', async () => {
332
332
  const richText = (text) => ({
package/package.json CHANGED
@@ -2,9 +2,9 @@
2
2
  "name": "@byline/db-mysql",
3
3
  "private": false,
4
4
  "license": "MPL-2.0",
5
- "version": "6.7.2",
5
+ "version": "6.9.0",
6
6
  "engines": {
7
- "node": ">=20.9.0"
7
+ "node": ">=22.22.2"
8
8
  },
9
9
  "description": "Byline CMS db mysql package",
10
10
  "keywords": [
@@ -47,16 +47,16 @@
47
47
  "dist"
48
48
  ],
49
49
  "dependencies": {
50
- "@byline/admin": "6.7.2",
51
- "@byline/core": "6.7.2",
50
+ "@byline/admin": "6.9.0",
51
+ "@byline/core": "6.9.0",
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
57
  "@biomejs/biome": "2.5.15",
58
- "@byline/auth": "6.7.2",
59
- "@byline/client": "6.7.2",
58
+ "@byline/auth": "6.9.0",
59
+ "@byline/client": "6.9.0",
60
60
  "@byline/db-conformance": "0.0.10",
61
61
  "@types/node": "^26.6.5",
62
62
  "chokidar": "^5.0.0",