@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.
- package/dist/database/schema/auth.d.ts +202 -0
- package/dist/database/schema/auth.js +29 -0
- package/dist/database/schema/index.d.ts +35 -1
- package/dist/database/schema/index.js +7 -2
- package/dist/database/schema/schema-pins.test.node.js +18 -2
- package/dist/index.js +4 -0
- package/dist/lib/db-manager.d.ts +10 -0
- package/dist/lib/db-manager.js +73 -16
- package/dist/lib/db-manager.test.node.d.ts +1 -0
- package/dist/lib/db-manager.test.node.js +82 -0
- package/dist/lib/revision-schema.d.ts +2 -0
- package/dist/lib/revision-schema.js +63 -0
- package/dist/modules/admin/admin-store.js +30 -0
- package/dist/modules/admin/admin-users-repository.js +37 -6
- package/dist/modules/admin/login-sessions-repository.d.ts +11 -0
- package/dist/modules/admin/login-sessions-repository.js +38 -0
- package/dist/modules/admin/refresh-tokens-repository.js +18 -24
- package/dist/modules/admin/retry-deadlock.d.ts +2 -0
- package/dist/modules/admin/retry-deadlock.js +22 -0
- package/dist/modules/admin/sign-in-rate-limit-store.d.ts +11 -0
- package/dist/modules/admin/sign-in-rate-limit-store.js +37 -0
- package/dist/modules/storage/classify-error.js +3 -0
- package/dist/modules/storage/classify-error.test.node.js +8 -0
- package/dist/modules/storage/collection-registration.d.ts +9 -0
- package/dist/modules/storage/collection-registration.js +32 -0
- package/dist/modules/storage/document-revisions.d.ts +20 -0
- package/dist/modules/storage/document-revisions.js +197 -0
- package/dist/modules/storage/publish-schedules.d.ts +5 -0
- package/dist/modules/storage/publish-schedules.js +23 -3
- package/dist/modules/storage/read-snapshot.d.ts +3 -0
- package/dist/modules/storage/read-snapshot.js +12 -0
- package/dist/modules/storage/singletons.d.ts +1 -1
- package/dist/modules/storage/singletons.js +3 -20
- package/dist/modules/storage/storage-commands.d.ts +2 -0
- package/dist/modules/storage/storage-commands.js +31 -3
- package/dist/modules/storage/storage-queries.d.ts +4 -0
- package/dist/modules/storage/storage-queries.js +9 -0
- package/dist/modules/storage/tests/storage-live-path-queries.test.js +9 -2
- 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}
|
|
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
|
|
352
|
-
|
|
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,
|
package/dist/lib/db-manager.d.ts
CHANGED
|
@@ -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
|
/**
|
package/dist/lib/db-manager.js
CHANGED
|
@@ -17,17 +17,42 @@
|
|
|
17
17
|
* `withTransaction` capability on `IDbAdapter`, never the implementation.
|
|
18
18
|
*/
|
|
19
19
|
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
20
|
-
|
|
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
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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 {};
|