@byline/db-mysql 4.19.0 → 5.1.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 (39) hide show
  1. package/dist/database/schema/auth.d.ts +202 -0
  2. package/dist/database/schema/auth.js +29 -0
  3. package/dist/database/schema/index.d.ts +35 -1
  4. package/dist/database/schema/index.js +7 -2
  5. package/dist/database/schema/schema-pins.test.node.js +18 -2
  6. package/dist/index.js +4 -0
  7. package/dist/lib/db-manager.d.ts +10 -0
  8. package/dist/lib/db-manager.js +73 -16
  9. package/dist/lib/db-manager.test.node.d.ts +1 -0
  10. package/dist/lib/db-manager.test.node.js +82 -0
  11. package/dist/lib/revision-schema.d.ts +2 -0
  12. package/dist/lib/revision-schema.js +63 -0
  13. package/dist/modules/admin/admin-store.js +30 -0
  14. package/dist/modules/admin/admin-users-repository.js +37 -6
  15. package/dist/modules/admin/login-sessions-repository.d.ts +11 -0
  16. package/dist/modules/admin/login-sessions-repository.js +38 -0
  17. package/dist/modules/admin/refresh-tokens-repository.js +18 -24
  18. package/dist/modules/admin/retry-deadlock.d.ts +2 -0
  19. package/dist/modules/admin/retry-deadlock.js +22 -0
  20. package/dist/modules/admin/sign-in-rate-limit-store.d.ts +11 -0
  21. package/dist/modules/admin/sign-in-rate-limit-store.js +37 -0
  22. package/dist/modules/storage/classify-error.js +3 -0
  23. package/dist/modules/storage/classify-error.test.node.js +8 -0
  24. package/dist/modules/storage/collection-registration.d.ts +9 -0
  25. package/dist/modules/storage/collection-registration.js +32 -0
  26. package/dist/modules/storage/document-revisions.d.ts +20 -0
  27. package/dist/modules/storage/document-revisions.js +197 -0
  28. package/dist/modules/storage/publish-schedules.d.ts +5 -0
  29. package/dist/modules/storage/publish-schedules.js +23 -3
  30. package/dist/modules/storage/read-snapshot.d.ts +3 -0
  31. package/dist/modules/storage/read-snapshot.js +12 -0
  32. package/dist/modules/storage/singletons.d.ts +1 -1
  33. package/dist/modules/storage/singletons.js +3 -20
  34. package/dist/modules/storage/storage-commands.d.ts +2 -0
  35. package/dist/modules/storage/storage-commands.js +31 -3
  36. package/dist/modules/storage/storage-queries.d.ts +4 -0
  37. package/dist/modules/storage/storage-queries.js +9 -0
  38. package/dist/modules/storage/tests/storage-live-path-queries.test.js +9 -2
  39. package/package.json +10 -10
@@ -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
+ session_version: import("drizzle-orm/mysql-core").MySqlColumn<{
64
+ name: "session_version";
65
+ tableName: "byline_admin_users";
66
+ dataType: "number";
67
+ columnType: "MySqlInt";
68
+ data: number;
69
+ driverParam: string | number;
70
+ notNull: true;
71
+ hasDefault: true;
72
+ isPrimaryKey: false;
73
+ isAutoincrement: false;
74
+ hasRuntimeDefault: false;
75
+ enumValues: undefined;
76
+ baseColumn: never;
77
+ identity: undefined;
78
+ generated: undefined;
79
+ }, {}, {}>;
63
80
  vid: import("drizzle-orm/mysql-core").MySqlColumn<{
64
81
  name: "vid";
65
82
  tableName: "byline_admin_users";
@@ -720,6 +737,98 @@ export declare const adminPermissions: import("drizzle-orm/mysql-core").MySqlTab
720
737
  * at the replacement row; presenting a rotated token is treated as replay
721
738
  * and revokes the whole chain.
722
739
  */
740
+ export declare const adminLoginSessions: import("drizzle-orm/mysql-core").MySqlTableWithColumns<{
741
+ name: "byline_admin_login_sessions";
742
+ schema: undefined;
743
+ columns: {
744
+ id: import("drizzle-orm/mysql-core").MySqlColumn<{
745
+ name: "id";
746
+ tableName: "byline_admin_login_sessions";
747
+ dataType: "custom";
748
+ columnType: "MySqlCustomColumn";
749
+ data: string;
750
+ driverParam: string;
751
+ notNull: true;
752
+ hasDefault: false;
753
+ isPrimaryKey: true;
754
+ isAutoincrement: false;
755
+ hasRuntimeDefault: false;
756
+ enumValues: undefined;
757
+ baseColumn: never;
758
+ identity: undefined;
759
+ generated: undefined;
760
+ }, {}, {}>;
761
+ admin_user_id: import("drizzle-orm/mysql-core").MySqlColumn<{
762
+ name: "admin_user_id";
763
+ tableName: "byline_admin_login_sessions";
764
+ dataType: "custom";
765
+ columnType: "MySqlCustomColumn";
766
+ data: string;
767
+ driverParam: string;
768
+ notNull: true;
769
+ hasDefault: false;
770
+ isPrimaryKey: false;
771
+ isAutoincrement: false;
772
+ hasRuntimeDefault: false;
773
+ enumValues: undefined;
774
+ baseColumn: never;
775
+ identity: undefined;
776
+ generated: undefined;
777
+ }, {}, {}>;
778
+ session_version: import("drizzle-orm/mysql-core").MySqlColumn<{
779
+ name: "session_version";
780
+ tableName: "byline_admin_login_sessions";
781
+ dataType: "number";
782
+ columnType: "MySqlInt";
783
+ data: number;
784
+ driverParam: string | number;
785
+ notNull: true;
786
+ hasDefault: false;
787
+ isPrimaryKey: false;
788
+ isAutoincrement: false;
789
+ hasRuntimeDefault: false;
790
+ enumValues: undefined;
791
+ baseColumn: never;
792
+ identity: undefined;
793
+ generated: undefined;
794
+ }, {}, {}>;
795
+ expires_at: import("drizzle-orm/mysql-core").MySqlColumn<{
796
+ name: "expires_at";
797
+ tableName: "byline_admin_login_sessions";
798
+ dataType: "date";
799
+ columnType: "MySqlDateTime";
800
+ data: Date;
801
+ driverParam: string | number;
802
+ notNull: true;
803
+ hasDefault: false;
804
+ isPrimaryKey: false;
805
+ isAutoincrement: false;
806
+ hasRuntimeDefault: false;
807
+ enumValues: undefined;
808
+ baseColumn: never;
809
+ identity: undefined;
810
+ generated: undefined;
811
+ }, {}, {}>;
812
+ revoked_at: import("drizzle-orm/mysql-core").MySqlColumn<{
813
+ name: "revoked_at";
814
+ tableName: "byline_admin_login_sessions";
815
+ dataType: "date";
816
+ columnType: "MySqlDateTime";
817
+ data: Date;
818
+ driverParam: string | number;
819
+ notNull: false;
820
+ hasDefault: false;
821
+ isPrimaryKey: false;
822
+ isAutoincrement: false;
823
+ hasRuntimeDefault: false;
824
+ enumValues: undefined;
825
+ baseColumn: never;
826
+ identity: undefined;
827
+ generated: undefined;
828
+ }, {}, {}>;
829
+ };
830
+ dialect: 'mysql';
831
+ }>;
723
832
  export declare const adminRefreshTokens: import("drizzle-orm/mysql-core").MySqlTableWithColumns<{
724
833
  name: "byline_admin_refresh_tokens";
725
834
  schema: undefined;
@@ -792,6 +901,23 @@ export declare const adminRefreshTokens: import("drizzle-orm/mysql-core").MySqlT
792
901
  identity: undefined;
793
902
  generated: undefined;
794
903
  }, {}, {}>;
904
+ sid: import("drizzle-orm/mysql-core").MySqlColumn<{
905
+ name: "sid";
906
+ tableName: "byline_admin_refresh_tokens";
907
+ dataType: "custom";
908
+ columnType: "MySqlCustomColumn";
909
+ data: string;
910
+ driverParam: string;
911
+ notNull: false;
912
+ hasDefault: false;
913
+ isPrimaryKey: false;
914
+ isAutoincrement: false;
915
+ hasRuntimeDefault: false;
916
+ enumValues: undefined;
917
+ baseColumn: never;
918
+ identity: undefined;
919
+ generated: undefined;
920
+ }, {}, {}>;
795
921
  token_hash: import("drizzle-orm/mysql-core").MySqlColumn<{
796
922
  name: "token_hash";
797
923
  tableName: "byline_admin_refresh_tokens";
@@ -809,6 +935,23 @@ export declare const adminRefreshTokens: import("drizzle-orm/mysql-core").MySqlT
809
935
  identity: undefined;
810
936
  generated: undefined;
811
937
  }, {}, {}>;
938
+ session_version: import("drizzle-orm/mysql-core").MySqlColumn<{
939
+ name: "session_version";
940
+ tableName: "byline_admin_refresh_tokens";
941
+ dataType: "number";
942
+ columnType: "MySqlInt";
943
+ data: number;
944
+ driverParam: string | number;
945
+ notNull: true;
946
+ hasDefault: true;
947
+ isPrimaryKey: false;
948
+ isAutoincrement: false;
949
+ hasRuntimeDefault: false;
950
+ enumValues: undefined;
951
+ baseColumn: never;
952
+ identity: undefined;
953
+ generated: undefined;
954
+ }, {}, {}>;
812
955
  issued_at: import("drizzle-orm/mysql-core").MySqlColumn<{
813
956
  name: "issued_at";
814
957
  tableName: "byline_admin_refresh_tokens";
@@ -949,3 +1092,62 @@ export declare const adminPermissionsRelations: import("drizzle-orm").Relations<
949
1092
  export declare const adminRefreshTokensRelations: import("drizzle-orm").Relations<"byline_admin_refresh_tokens", {
950
1093
  user: import("drizzle-orm").One<"byline_admin_users", true>;
951
1094
  }>;
1095
+ /** Shared, expiring fixed-window counters for password sign-in admission. */
1096
+ export declare const adminSignInRateLimits: import("drizzle-orm/mysql-core").MySqlTableWithColumns<{
1097
+ name: "byline_admin_sign_in_rate_limits";
1098
+ schema: undefined;
1099
+ columns: {
1100
+ key: import("drizzle-orm/mysql-core").MySqlColumn<{
1101
+ name: "key";
1102
+ tableName: "byline_admin_sign_in_rate_limits";
1103
+ dataType: "string";
1104
+ columnType: "MySqlVarChar";
1105
+ data: string;
1106
+ driverParam: string | number;
1107
+ notNull: true;
1108
+ hasDefault: false;
1109
+ isPrimaryKey: true;
1110
+ isAutoincrement: false;
1111
+ hasRuntimeDefault: false;
1112
+ enumValues: [string, ...string[]];
1113
+ baseColumn: never;
1114
+ identity: undefined;
1115
+ generated: undefined;
1116
+ }, {}, {}>;
1117
+ attempts: import("drizzle-orm/mysql-core").MySqlColumn<{
1118
+ name: "attempts";
1119
+ tableName: "byline_admin_sign_in_rate_limits";
1120
+ dataType: "number";
1121
+ columnType: "MySqlInt";
1122
+ data: number;
1123
+ driverParam: string | number;
1124
+ notNull: true;
1125
+ hasDefault: false;
1126
+ isPrimaryKey: false;
1127
+ isAutoincrement: false;
1128
+ hasRuntimeDefault: false;
1129
+ enumValues: undefined;
1130
+ baseColumn: never;
1131
+ identity: undefined;
1132
+ generated: undefined;
1133
+ }, {}, {}>;
1134
+ expires_at: import("drizzle-orm/mysql-core").MySqlColumn<{
1135
+ name: "expires_at";
1136
+ tableName: "byline_admin_sign_in_rate_limits";
1137
+ dataType: "date";
1138
+ columnType: "MySqlDateTime";
1139
+ data: Date;
1140
+ driverParam: string | number;
1141
+ notNull: true;
1142
+ hasDefault: false;
1143
+ isPrimaryKey: false;
1144
+ isAutoincrement: false;
1145
+ hasRuntimeDefault: false;
1146
+ enumValues: undefined;
1147
+ baseColumn: never;
1148
+ identity: undefined;
1149
+ generated: undefined;
1150
+ }, {}, {}>;
1151
+ };
1152
+ dialect: 'mysql';
1153
+ }>;
@@ -36,6 +36,7 @@ import { createdAt, timestamps, uuidChar } from './common.js';
36
36
  // ---------------------------------------------------------------------------
37
37
  export const adminUsers = mysqlTable('byline_admin_users', {
38
38
  id: uuidChar('id').primaryKey(),
39
+ session_version: int('session_version').notNull().default(0),
39
40
  vid: int('vid').notNull().default(1),
40
41
  given_name: varchar('given_name', { length: 100 }),
41
42
  family_name: varchar('family_name', { length: 100 }),
@@ -162,11 +163,27 @@ export const adminPermissions = mysqlTable('byline_admin_permissions', {
162
163
  * at the replacement row; presenting a rotated token is treated as replay
163
164
  * and revokes the whole chain.
164
165
  */
166
+ export const adminLoginSessions = mysqlTable('byline_admin_login_sessions', {
167
+ id: uuidChar('id').primaryKey(),
168
+ admin_user_id: uuidChar('admin_user_id').notNull(),
169
+ session_version: int('session_version').notNull(),
170
+ expires_at: datetime('expires_at', { fsp: 6 }).notNull(),
171
+ revoked_at: datetime('revoked_at', { fsp: 6 }),
172
+ }, (table) => [
173
+ foreignKey({
174
+ name: 'fk_admin_login_sessions_user',
175
+ columns: [table.admin_user_id],
176
+ foreignColumns: [adminUsers.id],
177
+ }).onDelete('cascade'),
178
+ index('idx_admin_login_sessions_user').on(table.admin_user_id),
179
+ ]);
165
180
  export const adminRefreshTokens = mysqlTable('byline_admin_refresh_tokens', {
166
181
  id: uuidChar('id').primaryKey(),
167
182
  admin_user_id: uuidChar('admin_user_id').notNull(),
168
183
  /** SHA-256 hex digest of the raw refresh-token string. 64 chars. */
184
+ sid: uuidChar('sid'),
169
185
  token_hash: varchar('token_hash', { length: 64 }).notNull().unique(),
186
+ session_version: int('session_version').notNull().default(-1),
170
187
  // fsp 6 throughout this table, matching pg — see `common.ts`'s
171
188
  // `auditTimestamp` docblock for why every temporal column in this
172
189
  // schema moved from fsp 3 to fsp 6.
@@ -183,11 +200,17 @@ export const adminRefreshTokens = mysqlTable('byline_admin_refresh_tokens', {
183
200
  ip: varchar('ip', { length: 45 }),
184
201
  ...timestamps,
185
202
  }, (table) => [
203
+ foreignKey({
204
+ name: 'fk_admin_refresh_tokens_sid',
205
+ columns: [table.sid],
206
+ foreignColumns: [adminLoginSessions.id],
207
+ }).onDelete('cascade'),
186
208
  foreignKey({
187
209
  name: 'fk_admin_refresh_tokens_admin_user_id',
188
210
  columns: [table.admin_user_id],
189
211
  foreignColumns: [adminUsers.id],
190
212
  }).onDelete('cascade'),
213
+ index('idx_admin_refresh_tokens_sid').on(table.sid),
191
214
  index('idx_byline_admin_refresh_tokens_user').on(table.admin_user_id),
192
215
  index('idx_byline_admin_refresh_tokens_token_hash').on(table.token_hash),
193
216
  ]);
@@ -224,3 +247,9 @@ export const adminRefreshTokensRelations = relations(adminRefreshTokens, ({ one
224
247
  references: [adminUsers.id],
225
248
  }),
226
249
  }));
250
+ /** Shared, expiring fixed-window counters for password sign-in admission. */
251
+ export const adminSignInRateLimits = mysqlTable('byline_admin_sign_in_rate_limits', {
252
+ key: varchar('key', { length: 64 }).primaryKey(),
253
+ attempts: int('attempts').notNull(),
254
+ expires_at: datetime('expires_at', { fsp: 3 }).notNull(),
255
+ }, (table) => [index('idx_admin_sign_in_rate_limits_expiry').on(table.expires_at)]);
@@ -271,6 +271,23 @@ export declare const documents: import("drizzle-orm/mysql-core").MySqlTableWithC
271
271
  identity: undefined;
272
272
  generated: undefined;
273
273
  }, {}, {}>;
274
+ revision: import("drizzle-orm/mysql-core").MySqlColumn<{
275
+ name: "revision";
276
+ tableName: "byline_documents";
277
+ dataType: "number";
278
+ columnType: "MySqlBigInt53";
279
+ data: number;
280
+ driverParam: string | number;
281
+ notNull: true;
282
+ hasDefault: false;
283
+ isPrimaryKey: false;
284
+ isAutoincrement: false;
285
+ hasRuntimeDefault: false;
286
+ enumValues: undefined;
287
+ baseColumn: never;
288
+ identity: undefined;
289
+ generated: undefined;
290
+ }, {}, {}>;
274
291
  };
275
292
  dialect: 'mysql';
276
293
  }>;
@@ -581,6 +598,23 @@ export declare const documentPublishSchedules: import("drizzle-orm/mysql-core").
581
598
  identity: undefined;
582
599
  generated: undefined;
583
600
  }, {}, {}>;
601
+ authorized_revision: import("drizzle-orm/mysql-core").MySqlColumn<{
602
+ name: "authorized_revision";
603
+ tableName: "byline_document_publish_schedules";
604
+ dataType: "number";
605
+ columnType: "MySqlBigInt53";
606
+ data: number;
607
+ driverParam: string | number;
608
+ notNull: false;
609
+ hasDefault: false;
610
+ isPrimaryKey: false;
611
+ isAutoincrement: false;
612
+ hasRuntimeDefault: false;
613
+ enumValues: undefined;
614
+ baseColumn: never;
615
+ identity: undefined;
616
+ generated: undefined;
617
+ }, {}, {}>;
584
618
  publish_at: import("drizzle-orm/mysql-core").MySqlColumn<{
585
619
  name: "publish_at";
586
620
  tableName: "byline_document_publish_schedules";
@@ -4068,4 +4102,4 @@ export declare const recurringTasks: import("drizzle-orm/mysql-core").MySqlTable
4068
4102
  };
4069
4103
  dialect: 'mysql';
4070
4104
  }>;
4071
- export { adminPermissions, adminPermissionsRelations, adminRefreshTokens, adminRefreshTokensRelations, adminRoleAdminUser, adminRoleAdminUserRelations, adminRoles, adminRolesRelations, adminUserPreferences, adminUsers, adminUsersRelations, } from './auth.js';
4105
+ export { adminLoginSessions, adminPermissions, adminPermissionsRelations, adminRefreshTokens, adminRefreshTokensRelations, adminRoleAdminUser, adminRoleAdminUserRelations, adminRoles, adminRolesRelations, adminSignInRateLimits, adminUserPreferences, adminUsers, adminUsersRelations, } from './auth.js';
@@ -65,6 +65,8 @@ export const documents = mysqlTable('byline_documents', {
65
65
  // `backfillSourceLocales()` (boot-auto via initBylineCore).
66
66
  //
67
67
  source_locale: varchar('source_locale', { length: 10 }).notNull(),
68
+ // Editorial concurrency counter; content versions remain independently immutable.
69
+ revision: bigint('revision', { mode: 'number' }).notNull(),
68
70
  ...timestamps,
69
71
  }, (table) => [
70
72
  foreignKey({
@@ -72,6 +74,7 @@ export const documents = mysqlTable('byline_documents', {
72
74
  columns: [table.collection_id],
73
75
  foreignColumns: [collections.id],
74
76
  }).onDelete('cascade'),
77
+ check('check_documents_revision', sql `${table.revision} BETWEEN 1 AND 9007199254740991`),
75
78
  index('idx_documents_collection').on(table.collection_id),
76
79
  index('idx_documents_collection_order').on(table.collection_id, table.order_key),
77
80
  unique('uq_documents_collection_id_id').on(table.collection_id, table.id),
@@ -137,6 +140,7 @@ export const documentPublishSchedules = mysqlTable('byline_document_publish_sche
137
140
  document_id: uuidChar('document_id').primaryKey(),
138
141
  collection_id: uuidChar('collection_id').notNull(),
139
142
  target_version_id: uuidChar('target_version_id').notNull(),
143
+ authorized_revision: bigint('authorized_revision', { mode: 'number' }),
140
144
  publish_at: datetime('publish_at', { fsp: 6 }).notNull(),
141
145
  state: varchar('state', { length: 32 }).notNull().default('armed'),
142
146
  suspended_at: datetime('suspended_at', { fsp: 6 }),
@@ -157,6 +161,7 @@ export const documentPublishSchedules = mysqlTable('byline_document_publish_sche
157
161
  // stack or document content.
158
162
  last_error: text('last_error'),
159
163
  }, (table) => [
164
+ check('check_publish_schedules_authorized_revision', sql `${table.authorized_revision} IS NULL OR ${table.authorized_revision} BETWEEN 1 AND 9007199254740991`),
160
165
  foreignKey({
161
166
  name: 'fk_publish_schedules_document',
162
167
  columns: [table.document_id],
@@ -173,7 +178,7 @@ export const documentPublishSchedules = mysqlTable('byline_document_publish_sche
173
178
  foreignColumns: [documentVersions.id],
174
179
  }).onDelete('cascade'),
175
180
  check('check_publish_schedules_state', sql `${table.state} IN ('armed', 'needs_reconfirm')`),
176
- check('check_publish_schedules_suspended_reason', sql `${table.suspended_reason} IS NULL OR ${table.suspended_reason} = 'content_edited'`),
181
+ check('check_publish_schedules_suspended_reason', sql `${table.suspended_reason} IS NULL OR ${table.suspended_reason} IN ('content_edited', 'document_metadata_changed', 'upgrade_invalidated')`),
177
182
  // MySQL has no partial indexes, so state is the leading discriminator.
178
183
  index('idx_document_publish_schedules_due').on(table.state, table.next_attempt_at, table.publish_at),
179
184
  index('idx_document_publish_schedules_execution_expiry').on(table.execution_expires_at),
@@ -991,4 +996,4 @@ export const recurringTasks = mysqlTable('byline_recurring_tasks', {
991
996
  // Auth schema — byline_admin_users, byline_admin_roles, etc.
992
997
  // See ./auth.ts for definitions and rationale.
993
998
  // ---------------------------------------------------------------------------
994
- export { adminPermissions, adminPermissionsRelations, adminRefreshTokens, adminRefreshTokensRelations, adminRoleAdminUser, adminRoleAdminUserRelations, adminRoles, adminRolesRelations, adminUserPreferences, adminUsers, adminUsersRelations, } from './auth.js';
999
+ export { adminLoginSessions, adminPermissions, adminPermissionsRelations, adminRefreshTokens, adminRefreshTokensRelations, adminRoleAdminUser, adminRoleAdminUserRelations, adminRoles, adminRolesRelations, adminSignInRateLimits, adminUserPreferences, adminUsers, adminUsersRelations, } from './auth.js';
@@ -348,8 +348,10 @@ describe('schema pins — timestamp precision (spec §F.3)', () => {
348
348
  it('finds datetime columns to check (schema is not empty)', () => {
349
349
  expect(instantColumns.length).toBeGreaterThan(0);
350
350
  });
351
- it.each(instantColumns.map((c) => [`${c.tableName}.${c.column.name}`, c.column]))('%s is datetime(6) — microsecond precision (pg parity)', (_label, column) => {
352
- expect(column.getSQLType()).toBe('datetime(6)');
351
+ it.each(instantColumns.map((c) => [`${c.tableName}.${c.column.name}`, c.column]))('%s uses the PostgreSQL companion precision', (label, column) => {
352
+ // Admission windows intentionally share JavaScript millisecond precision on both adapters.
353
+ const precision = label === 'byline_admin_sign_in_rate_limits.expires_at' ? 3 : 6;
354
+ expect(column.getSQLType()).toBe(`datetime(${precision})`);
353
355
  });
354
356
  // `MySqlDate` (the `date()` builder, used by `datetimeStore.value_date`)
355
357
  // is the one temporal type genuinely outside this pin's scope — a
@@ -448,6 +450,7 @@ describe('schema pins — scheduled publication', () => {
448
450
  });
449
451
  it('pins the bounded state and suspension-reason constraints', () => {
450
452
  expect(cfg.checks.map((constraint) => constraint.name).sort()).toEqual([
453
+ 'check_publish_schedules_authorized_revision',
451
454
  'check_publish_schedules_state',
452
455
  'check_publish_schedules_suspended_reason',
453
456
  ]);
@@ -485,3 +488,16 @@ describe('schema pins — singleton documents', () => {
485
488
  expect(ownershipForeignKey?.onDelete).toBe('cascade');
486
489
  });
487
490
  });
491
+ describe('schema pins — document revisions', () => {
492
+ it('requires an explicit safe-range revision on every new document', () => {
493
+ expect(coreSchema.documents.revision.getSQLType()).toBe('bigint');
494
+ expect(coreSchema.documents.revision.notNull).toBe(true);
495
+ expect(coreSchema.documents.revision.hasDefault).toBe(false);
496
+ expect(getTableConfig(coreSchema.documents).checks.map((constraint) => constraint.name)).toContain('check_documents_revision');
497
+ });
498
+ it('allows legacy schedules to have no prior document authorization', () => {
499
+ expect(coreSchema.documentPublishSchedules.authorized_revision.getSQLType()).toBe('bigint');
500
+ expect(coreSchema.documentPublishSchedules.authorized_revision.notNull).toBe(false);
501
+ expect(coreSchema.documentPublishSchedules.authorized_revision.hasDefault).toBe(false);
502
+ });
503
+ });
package/dist/index.js CHANGED
@@ -15,6 +15,8 @@ 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 { DocumentRevisions } from './modules/storage/document-revisions.js';
19
+ import { createReadSnapshot } from './modules/storage/read-snapshot.js';
18
20
  import { SingletonCommands, SingletonQueries } from './modules/storage/singletons.js';
19
21
  import { createCommandBuilders } from './modules/storage/storage-commands.js';
20
22
  import { createQueryBuilders } from './modules/storage/storage-queries.js';
@@ -151,6 +153,8 @@ export const mysqlAdapter = ({ connectionString, collections, defaultContentLoca
151
153
  singletons: singletonQueries,
152
154
  },
153
155
  withTransaction: (fn) => txManager.withTransaction(fn),
156
+ withReadSnapshot: createReadSnapshot(db, collections, defaultContentLocale),
157
+ revisions: new DocumentRevisions(dbManager),
154
158
  scheduler: schedulerStore,
155
159
  classifyError,
156
160
  drizzle: db,
@@ -27,14 +27,24 @@ export interface DBManager {
27
27
  get(): DBExecutor;
28
28
  /** Whether the current async context owns an ambient transaction. */
29
29
  isInTransaction(): boolean;
30
+ getTransactionScope(): object | undefined;
31
+ getTransactionToken(): object | undefined;
32
+ isTransactionTokenActive(token: object): boolean;
33
+ runInTransaction<T>(executor: DBExecutor, fn: () => Promise<T>): Promise<T>;
30
34
  }
31
35
  export declare class DBManagerImpl implements DBManager {
36
+ private readonly transactionALS;
37
+ private readonly activeTransactions;
32
38
  private readonly dbPool;
33
39
  constructor(deps: {
34
40
  dbPool: DBExecutor;
35
41
  });
36
42
  get(): DBExecutor;
37
43
  isInTransaction(): boolean;
44
+ getTransactionScope(): object | undefined;
45
+ getTransactionToken(): object | undefined;
46
+ isTransactionTokenActive(token: object): boolean;
47
+ runInTransaction<T>(executor: DBExecutor, fn: () => Promise<T>): Promise<T>;
38
48
  }
39
49
  export interface TXManager {
40
50
  /**
@@ -17,17 +17,42 @@
17
17
  * `withTransaction` capability on `IDbAdapter`, never the implementation.
18
18
  */
19
19
  import { AsyncLocalStorage } from 'node:async_hooks';
20
- const transactionALS = new AsyncLocalStorage();
20
+ import { DbErrorCodes, ERR_LOCK_CONFLICT } from '@byline/core';
21
+ import { classifyError } from '../modules/storage/classify-error.js';
21
22
  export class DBManagerImpl {
23
+ transactionALS = new AsyncLocalStorage();
24
+ activeTransactions = new WeakSet();
22
25
  dbPool;
23
26
  constructor(deps) {
24
27
  this.dbPool = deps.dbPool;
25
28
  }
26
29
  get() {
27
- return transactionALS.getStore() ?? this.dbPool;
30
+ return this.transactionALS.getStore()?.executor ?? this.dbPool;
28
31
  }
29
32
  isInTransaction() {
30
- return transactionALS.getStore() != null;
33
+ return this.getTransactionToken() != null;
34
+ }
35
+ getTransactionScope() {
36
+ return this.getTransactionToken() != null ? this.transactionALS.getStore()?.scope : undefined;
37
+ }
38
+ getTransactionToken() {
39
+ const frame = this.transactionALS.getStore();
40
+ return frame != null && this.activeTransactions.has(frame) ? frame : undefined;
41
+ }
42
+ isTransactionTokenActive(token) {
43
+ return this.activeTransactions.has(token);
44
+ }
45
+ async runInTransaction(executor, fn) {
46
+ // Share lock ordering with savepoints. Observations issued inside a savepoint
47
+ // expire when it ends: rollback can release the row locks it acquired.
48
+ const frame = { executor, scope: this.getTransactionScope() ?? {} };
49
+ this.activeTransactions.add(frame);
50
+ try {
51
+ return await this.transactionALS.run(frame, fn);
52
+ }
53
+ finally {
54
+ this.activeTransactions.delete(frame);
55
+ }
31
56
  }
32
57
  }
33
58
  export class TXManagerImpl {
@@ -35,18 +60,50 @@ export class TXManagerImpl {
35
60
  constructor(deps) {
36
61
  this.db = deps.db;
37
62
  }
38
- withTransaction(fn) {
39
- return this.db.get().transaction((tx) =>
40
- // `tx` is Drizzle's MySqlTransaction; it carries the full
41
- // query-builder surface every command uses. The cast bridges the
42
- // structural gap to MySql2Database the same way the pg adapter
43
- // bridges to NodePgDatabase. See docs/03-architecture/03-transactions.md.
44
- transactionALS.run(tx, fn), {
45
- // Spec decision: pin the isolation level to match the pg adapter's
46
- // assumptions (Postgres's default is READ COMMITTED; MySQL/InnoDB
47
- // defaults to REPEATABLE READ). Canonical adapters must behave
48
- // identically for the shared db-conformance suite.
49
- isolationLevel: 'read committed',
50
- });
63
+ async withTransaction(fn) {
64
+ const ownsTransaction = !this.db.isInTransaction();
65
+ let callbackError;
66
+ let callbackFailed = false;
67
+ const guarded = async () => {
68
+ try {
69
+ return await fn();
70
+ }
71
+ catch (error) {
72
+ callbackFailed = true;
73
+ callbackError = error;
74
+ throw error;
75
+ }
76
+ };
77
+ try {
78
+ return await this.db.get().transaction((tx) =>
79
+ // `tx` is Drizzle's MySqlTransaction; it carries the full
80
+ // query-builder surface every command uses. The cast bridges the
81
+ // structural gap to MySql2Database the same way the pg adapter
82
+ // bridges to NodePgDatabase. See docs/03-architecture/03-transactions.md.
83
+ this.db.runInTransaction(tx, guarded), {
84
+ // Spec decision: pin the isolation level to match the pg adapter's
85
+ // assumptions (Postgres's default is READ COMMITTED; MySQL/InnoDB
86
+ // defaults to REPEATABLE READ). Canonical adapters must behave
87
+ // identically for the shared db-conformance suite.
88
+ isolationLevel: 'read committed',
89
+ });
90
+ }
91
+ catch (error) {
92
+ // Drizzle rethrows the callback error only after ROLLBACK succeeds. A
93
+ // rollback failure replaces it; a COMMIT failure has no callback error.
94
+ // Never infer safe retry from either of those uncertain outcomes, nor
95
+ // from a savepoint rollback inside an externally owned transaction.
96
+ if (ownsTransaction &&
97
+ callbackFailed &&
98
+ error === callbackError &&
99
+ classifyError(error).code === DbErrorCodes.LOCK_CONFLICT) {
100
+ throw ERR_LOCK_CONFLICT({
101
+ message: 'This change could not be saved because another operation was using the document. Reload before trying again.',
102
+ cause: error,
103
+ details: { reason: 'lock_conflict', rolledBack: true, retryable: true },
104
+ });
105
+ }
106
+ throw error;
107
+ }
51
108
  }
52
109
  }
@@ -0,0 +1 @@
1
+ export {};