@byline/db-mysql 4.18.0 → 5.0.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/index.d.ts +34 -0
- package/dist/database/schema/index.js +6 -1
- package/dist/database/schema/schema-pins.test.node.js +14 -0
- 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/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 +39 -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 +6 -6
|
@@ -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";
|
|
@@ -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),
|
|
@@ -448,6 +448,7 @@ describe('schema pins — scheduled publication', () => {
|
|
|
448
448
|
});
|
|
449
449
|
it('pins the bounded state and suspension-reason constraints', () => {
|
|
450
450
|
expect(cfg.checks.map((constraint) => constraint.name).sort()).toEqual([
|
|
451
|
+
'check_publish_schedules_authorized_revision',
|
|
451
452
|
'check_publish_schedules_state',
|
|
452
453
|
'check_publish_schedules_suspended_reason',
|
|
453
454
|
]);
|
|
@@ -485,3 +486,16 @@ describe('schema pins — singleton documents', () => {
|
|
|
485
486
|
expect(ownershipForeignKey?.onDelete).toBe('cascade');
|
|
486
487
|
});
|
|
487
488
|
});
|
|
489
|
+
describe('schema pins — document revisions', () => {
|
|
490
|
+
it('requires an explicit safe-range revision on every new document', () => {
|
|
491
|
+
expect(coreSchema.documents.revision.getSQLType()).toBe('bigint');
|
|
492
|
+
expect(coreSchema.documents.revision.notNull).toBe(true);
|
|
493
|
+
expect(coreSchema.documents.revision.hasDefault).toBe(false);
|
|
494
|
+
expect(getTableConfig(coreSchema.documents).checks.map((constraint) => constraint.name)).toContain('check_documents_revision');
|
|
495
|
+
});
|
|
496
|
+
it('allows legacy schedules to have no prior document authorization', () => {
|
|
497
|
+
expect(coreSchema.documentPublishSchedules.authorized_revision.getSQLType()).toBe('bigint');
|
|
498
|
+
expect(coreSchema.documentPublishSchedules.authorized_revision.notNull).toBe(false);
|
|
499
|
+
expect(coreSchema.documentPublishSchedules.authorized_revision.hasDefault).toBe(false);
|
|
500
|
+
});
|
|
501
|
+
});
|
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 {};
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { DBManagerImpl, TXManagerImpl } from './db-manager.js';
|
|
3
|
+
describe('adapter transaction ownership', () => {
|
|
4
|
+
it('does not leak one adapter transaction into another adapter pool', async () => {
|
|
5
|
+
const poolA = {};
|
|
6
|
+
const poolB = {};
|
|
7
|
+
const tx = {};
|
|
8
|
+
const a = new DBManagerImpl({ dbPool: poolA });
|
|
9
|
+
const b = new DBManagerImpl({ dbPool: poolB });
|
|
10
|
+
await a.runInTransaction(tx, async () => {
|
|
11
|
+
expect(a.get()).toBe(tx);
|
|
12
|
+
expect(a.isInTransaction()).toBe(true);
|
|
13
|
+
expect(b.get()).toBe(poolB);
|
|
14
|
+
expect(b.isInTransaction()).toBe(false);
|
|
15
|
+
expect(b.getTransactionScope()).toBeUndefined();
|
|
16
|
+
});
|
|
17
|
+
expect(a.get()).toBe(poolA);
|
|
18
|
+
expect(a.getTransactionScope()).toBeUndefined();
|
|
19
|
+
});
|
|
20
|
+
it('preserves root scope through savepoints and restores the parent executor', async () => {
|
|
21
|
+
const pool = {};
|
|
22
|
+
const outer = {};
|
|
23
|
+
const inner = {};
|
|
24
|
+
const manager = new DBManagerImpl({ dbPool: pool });
|
|
25
|
+
await manager.runInTransaction(outer, async () => {
|
|
26
|
+
const scope = manager.getTransactionScope();
|
|
27
|
+
await expect(manager.runInTransaction(inner, async () => {
|
|
28
|
+
expect(manager.get()).toBe(inner);
|
|
29
|
+
expect(manager.getTransactionScope()).toBe(scope);
|
|
30
|
+
throw new Error('rollback');
|
|
31
|
+
})).rejects.toThrow('rollback');
|
|
32
|
+
expect(manager.get()).toBe(outer);
|
|
33
|
+
expect(manager.getTransactionScope()).toBe(scope);
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
describe('owned transaction lock failures', () => {
|
|
38
|
+
const conflict = () => Object.assign(new Error('driver SQL detail'), { errno: 1213 });
|
|
39
|
+
for (const outcome of ['rollback', 'rollback failure', 'commit failure', 'nested']) {
|
|
40
|
+
it(`only marks confirmed whole rollback retryable: ${outcome}`, async () => {
|
|
41
|
+
const original = conflict();
|
|
42
|
+
const replacement = new Error('connection lost');
|
|
43
|
+
let rollbackCompleted = false;
|
|
44
|
+
const executor = {
|
|
45
|
+
transaction: async (fn) => {
|
|
46
|
+
let result;
|
|
47
|
+
try {
|
|
48
|
+
result = await fn(executor);
|
|
49
|
+
}
|
|
50
|
+
catch (error) {
|
|
51
|
+
if (outcome === 'rollback failure')
|
|
52
|
+
throw replacement;
|
|
53
|
+
rollbackCompleted = true;
|
|
54
|
+
throw error;
|
|
55
|
+
}
|
|
56
|
+
if (outcome === 'commit failure')
|
|
57
|
+
throw original;
|
|
58
|
+
return result;
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
const db = new DBManagerImpl({ dbPool: executor });
|
|
62
|
+
const manager = new TXManagerImpl({ db });
|
|
63
|
+
const operation = () => manager.withTransaction(async () => {
|
|
64
|
+
if (outcome !== 'commit failure')
|
|
65
|
+
throw original;
|
|
66
|
+
return 'ok';
|
|
67
|
+
});
|
|
68
|
+
const pending = outcome === 'nested' ? db.runInTransaction(executor, operation) : operation();
|
|
69
|
+
if (outcome === 'rollback') {
|
|
70
|
+
await expect(pending).rejects.toMatchObject({
|
|
71
|
+
code: 'ERR_LOCK_CONFLICT',
|
|
72
|
+
details: { reason: 'lock_conflict', rolledBack: true, retryable: true },
|
|
73
|
+
cause: original,
|
|
74
|
+
});
|
|
75
|
+
expect(rollbackCompleted).toBe(true);
|
|
76
|
+
}
|
|
77
|
+
else {
|
|
78
|
+
await expect(pending).rejects.toBe(outcome === 'rollback failure' ? replacement : original);
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
});
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { ERR_DATABASE } from '@byline/core';
|
|
2
|
+
import { sql } from 'drizzle-orm';
|
|
3
|
+
// Catalogue renderers add parentheses, casts, charset introducers, and escaped quotes.
|
|
4
|
+
// Compare complete normalized expressions, not just constraint names or bound substrings.
|
|
5
|
+
function normalize(clause) {
|
|
6
|
+
return clause
|
|
7
|
+
.toLowerCase()
|
|
8
|
+
.replace(/::(?:character varying|bigint|integer|text)(?:\[\])?/g, '')
|
|
9
|
+
.replace(/_utf8mb[34]/g, '')
|
|
10
|
+
.replace(/^check/, '')
|
|
11
|
+
.replace(/[\s()[\]`'"\\]/g, '');
|
|
12
|
+
}
|
|
13
|
+
export async function assertRevisionSchema(db) {
|
|
14
|
+
const upgrade = 'Byline document revision schema is incompatible. Fence all writers/workers, upgrade both packages and schema using 0005_document-revisions.sql, and rerun startup validation before reopening traffic.';
|
|
15
|
+
try {
|
|
16
|
+
const columnResult = await db.execute(sql `SELECT TABLE_NAME AS table_name, COLUMN_NAME AS column_name,
|
|
17
|
+
CASE WHEN DATA_TYPE = 'bigint' AND COLUMN_TYPE NOT LIKE '%unsigned%' AND COLUMN_DEFAULT IS NULL
|
|
18
|
+
AND IS_NULLABLE = CASE WHEN COLUMN_NAME = 'revision' THEN 'NO' ELSE 'YES' END
|
|
19
|
+
AND EXTRA = '' THEN 1 ELSE 0 END AS valid
|
|
20
|
+
FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND ((table_name = 'byline_documents' AND column_name = 'revision') OR (table_name = 'byline_document_publish_schedules' AND column_name = 'authorized_revision'))`);
|
|
21
|
+
const checkResult = await db.execute(sql `SELECT t.CONSTRAINT_NAME AS name, c.CHECK_CLAUSE AS clause, (t.ENFORCED = 'YES') AS valid
|
|
22
|
+
FROM information_schema.TABLE_CONSTRAINTS t JOIN information_schema.CHECK_CONSTRAINTS c
|
|
23
|
+
ON c.CONSTRAINT_SCHEMA = t.CONSTRAINT_SCHEMA AND c.CONSTRAINT_NAME = t.CONSTRAINT_NAME
|
|
24
|
+
WHERE t.CONSTRAINT_SCHEMA = DATABASE() AND t.TABLE_NAME IN ('byline_documents', 'byline_document_publish_schedules') AND t.CONSTRAINT_TYPE = 'CHECK'`);
|
|
25
|
+
const columns = columnResult[0];
|
|
26
|
+
const checks = checkResult[0];
|
|
27
|
+
if (columns.length !== 2 || columns.some((column) => Number(column.valid) !== 1))
|
|
28
|
+
throw new Error('Required BIGINT columns must have the correct nullability and no default.');
|
|
29
|
+
const required = [
|
|
30
|
+
[
|
|
31
|
+
'check_documents_revision',
|
|
32
|
+
[
|
|
33
|
+
'revision BETWEEN 1 AND 9007199254740991',
|
|
34
|
+
'revision >= 1 AND revision <= 9007199254740991',
|
|
35
|
+
],
|
|
36
|
+
],
|
|
37
|
+
[
|
|
38
|
+
'check_publish_schedules_authorized_revision',
|
|
39
|
+
[
|
|
40
|
+
'authorized_revision IS NULL OR authorized_revision BETWEEN 1 AND 9007199254740991',
|
|
41
|
+
'authorized_revision IS NULL OR authorized_revision >= 1 AND authorized_revision <= 9007199254740991',
|
|
42
|
+
],
|
|
43
|
+
],
|
|
44
|
+
[
|
|
45
|
+
'check_publish_schedules_suspended_reason',
|
|
46
|
+
[
|
|
47
|
+
"suspended_reason IS NULL OR suspended_reason IN ('content_edited', 'document_metadata_changed', 'upgrade_invalidated')",
|
|
48
|
+
"suspended_reason IS NULL OR suspended_reason = ANY (ARRAY['content_edited', 'document_metadata_changed', 'upgrade_invalidated'])",
|
|
49
|
+
],
|
|
50
|
+
],
|
|
51
|
+
];
|
|
52
|
+
for (const [name, expressions] of required) {
|
|
53
|
+
const check = checks.find((row) => row.name === name);
|
|
54
|
+
if (check == null ||
|
|
55
|
+
Number(check.valid) !== 1 ||
|
|
56
|
+
!expressions.some((expression) => normalize(expression) === normalize(check.clause)))
|
|
57
|
+
throw new Error(`Missing or incompatible enforced check: ${name}`);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
catch (cause) {
|
|
61
|
+
throw ERR_DATABASE({ message: upgrade, cause });
|
|
62
|
+
}
|
|
63
|
+
}
|
|
@@ -50,6 +50,9 @@ export function classifyError(err) {
|
|
|
50
50
|
const match = typeof e.message === 'string' ? e.message.match(/CONSTRAINT [`']([^`']+)[`']/) : null;
|
|
51
51
|
return { code: DbErrorCodes.FOREIGN_KEY_VIOLATION, constraint: match?.[1] };
|
|
52
52
|
}
|
|
53
|
+
if (e.errno === 1213 || e.errno === 1205) {
|
|
54
|
+
return { code: DbErrorCodes.LOCK_CONFLICT };
|
|
55
|
+
}
|
|
53
56
|
e = e.cause;
|
|
54
57
|
}
|
|
55
58
|
return { code: DbErrorCodes.UNKNOWN };
|
|
@@ -99,3 +99,11 @@ runClassifyErrorContract([
|
|
|
99
99
|
unrelatedError: { code: 'ER_DATA_TOO_LONG', errno: 1406, sqlState: '22001' },
|
|
100
100
|
},
|
|
101
101
|
]);
|
|
102
|
+
describe('lock conflict classification', () => {
|
|
103
|
+
it.each([1213, 1205])('classifies driver lock failure %s through a cause', (value) => {
|
|
104
|
+
expect(classifyError({ cause: { errno: value } })).toEqual({ code: 'DB_LOCK_CONFLICT' });
|
|
105
|
+
});
|
|
106
|
+
it('does not classify connection loss as safe contention', () => {
|
|
107
|
+
expect(classifyError({ code: 'ECONNRESET', errno: 2013 })).toEqual({ code: 'DB_UNKNOWN' });
|
|
108
|
+
});
|
|
109
|
+
});
|
|
@@ -0,0 +1,9 @@
|
|
|
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 { DBManager } from '../../lib/db-manager.js';
|
|
9
|
+
export declare function lockCollectionRegistration(dbManager: DBManager, collectionId: string, mode: 'shared' | 'exclusive'): Promise<void>;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This Source Code is subject to the terms of the Mozilla Public
|
|
3
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
4
|
+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
5
|
+
*
|
|
6
|
+
* Copyright (c) Infonomic Company Limited
|
|
7
|
+
*/
|
|
8
|
+
import { ERR_DATABASE, ERR_NOT_FOUND, ERR_VALIDATION } from '@byline/core';
|
|
9
|
+
import { sql } from 'drizzle-orm';
|
|
10
|
+
export async function lockCollectionRegistration(dbManager, collectionId, mode) {
|
|
11
|
+
if (mode !== 'shared' && mode !== 'exclusive') {
|
|
12
|
+
throw ERR_VALIDATION({ message: 'Invalid collection registration lock mode' });
|
|
13
|
+
}
|
|
14
|
+
if (!dbManager.isInTransaction()) {
|
|
15
|
+
throw ERR_DATABASE({
|
|
16
|
+
message: 'collection registration locks require an active transaction',
|
|
17
|
+
details: { collectionId },
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
const locked = await dbManager.get().execute(sql `
|
|
21
|
+
SELECT id FROM byline_collections
|
|
22
|
+
WHERE id = ${collectionId}
|
|
23
|
+
${mode === 'exclusive' ? sql `FOR UPDATE` : sql `FOR SHARE`}
|
|
24
|
+
`);
|
|
25
|
+
const rows = locked[0];
|
|
26
|
+
if (rows.length === 0) {
|
|
27
|
+
throw ERR_NOT_FOUND({
|
|
28
|
+
message: 'collection registration not found',
|
|
29
|
+
details: { collectionId },
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { type DocumentRevisionReceipt, type DocumentRevisionStore, type DocumentRevisionTarget, type DocumentStructuralState, type LockedDocumentRevision } from '@byline/core';
|
|
2
|
+
import type { DBManager } from '../../lib/db-manager.js';
|
|
3
|
+
export declare class DocumentRevisions implements DocumentRevisionStore {
|
|
4
|
+
private readonly manager;
|
|
5
|
+
private readonly issued;
|
|
6
|
+
private readonly lockOrder;
|
|
7
|
+
private readonly acquiring;
|
|
8
|
+
constructor(manager: DBManager);
|
|
9
|
+
readStructure(params: {
|
|
10
|
+
collectionId: string;
|
|
11
|
+
documentIds?: readonly string[];
|
|
12
|
+
parentDocumentId?: string;
|
|
13
|
+
}): Promise<DocumentStructuralState[]>;
|
|
14
|
+
isInTransaction(): boolean;
|
|
15
|
+
assertCompatibleSchema(): Promise<void>;
|
|
16
|
+
private scope;
|
|
17
|
+
lock(targets: readonly DocumentRevisionTarget[]): Promise<readonly LockedDocumentRevision[]>;
|
|
18
|
+
private lockSequentially;
|
|
19
|
+
advance(locked: LockedDocumentRevision): Promise<DocumentRevisionReceipt>;
|
|
20
|
+
}
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { assertDocumentVersionParent, documentRevisionFromDatabase, ERR_DATABASE, ERR_DOCUMENT_STALE, ERR_NOT_FOUND, ERR_VALIDATION, parseDocumentRevision, } from '@byline/core';
|
|
2
|
+
import { and, desc, eq, sql } from 'drizzle-orm';
|
|
3
|
+
import { documentAvailableLocales, documentPaths, documents, documentVersions, } from '../../database/schema/index.js';
|
|
4
|
+
import { assertRevisionSchema } from '../../lib/revision-schema.js';
|
|
5
|
+
export class DocumentRevisions {
|
|
6
|
+
manager;
|
|
7
|
+
issued = new WeakMap();
|
|
8
|
+
lockOrder = new WeakMap();
|
|
9
|
+
acquiring = new WeakSet();
|
|
10
|
+
constructor(manager) {
|
|
11
|
+
this.manager = manager;
|
|
12
|
+
}
|
|
13
|
+
async readStructure(params) {
|
|
14
|
+
this.scope();
|
|
15
|
+
const ids = params.documentIds ?? [];
|
|
16
|
+
const filtered = params.documentIds !== undefined || params.parentDocumentId !== undefined;
|
|
17
|
+
const selection = ids.length
|
|
18
|
+
? sql `d.id IN (${sql.join(ids.map((id) => sql `${id}`), sql `, `)})`
|
|
19
|
+
: sql `FALSE`;
|
|
20
|
+
const result = await this.manager.get().execute(sql `
|
|
21
|
+
SELECT d.id, d.revision, d.order_key,
|
|
22
|
+
r.child_document_id, r.parent_document_id, r.order_key AS tree_order_key,
|
|
23
|
+
EXISTS (SELECT 1 FROM byline_document_versions v WHERE v.document_id = d.id AND v.is_deleted = false) AS live
|
|
24
|
+
FROM byline_documents d
|
|
25
|
+
LEFT JOIN byline_document_relationships r ON r.child_document_id = d.id
|
|
26
|
+
WHERE d.collection_id = ${params.collectionId}
|
|
27
|
+
AND (${!filtered} OR ${selection} OR r.parent_document_id = ${params.parentDocumentId ?? null})
|
|
28
|
+
ORDER BY d.id
|
|
29
|
+
`);
|
|
30
|
+
const rows = result[0];
|
|
31
|
+
return rows.map((row) => ({
|
|
32
|
+
documentId: row.id,
|
|
33
|
+
revision: documentRevisionFromDatabase(row.revision),
|
|
34
|
+
orderKey: row.order_key,
|
|
35
|
+
placed: row.child_document_id !== null,
|
|
36
|
+
parentDocumentId: row.parent_document_id,
|
|
37
|
+
treeOrderKey: row.tree_order_key,
|
|
38
|
+
live: Boolean(row.live),
|
|
39
|
+
}));
|
|
40
|
+
}
|
|
41
|
+
isInTransaction() {
|
|
42
|
+
return this.manager.isInTransaction();
|
|
43
|
+
}
|
|
44
|
+
assertCompatibleSchema() {
|
|
45
|
+
return assertRevisionSchema(this.manager.get());
|
|
46
|
+
}
|
|
47
|
+
scope() {
|
|
48
|
+
const scope = this.manager.getTransactionScope();
|
|
49
|
+
if (scope == null)
|
|
50
|
+
throw ERR_DATABASE({
|
|
51
|
+
message: 'Document revision primitives require an active adapter transaction.',
|
|
52
|
+
});
|
|
53
|
+
return scope;
|
|
54
|
+
}
|
|
55
|
+
async lock(targets) {
|
|
56
|
+
const scope = this.scope();
|
|
57
|
+
if (this.acquiring.has(scope))
|
|
58
|
+
throw ERR_DATABASE({
|
|
59
|
+
message: 'Concurrent document lock requests inside one transaction are not allowed.',
|
|
60
|
+
});
|
|
61
|
+
this.acquiring.add(scope);
|
|
62
|
+
try {
|
|
63
|
+
return await this.lockSequentially(targets, scope);
|
|
64
|
+
}
|
|
65
|
+
finally {
|
|
66
|
+
this.acquiring.delete(scope);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
async lockSequentially(targets, scope) {
|
|
70
|
+
const token = this.manager.getTransactionToken();
|
|
71
|
+
// Validate all caller values before acquiring even the first lock.
|
|
72
|
+
const ordered = targets
|
|
73
|
+
.map((target) => {
|
|
74
|
+
if (![target.documentId, target.collectionId].every((id) => typeof id === 'string' &&
|
|
75
|
+
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(id))) {
|
|
76
|
+
throw ERR_VALIDATION({
|
|
77
|
+
message: 'Revision targets require valid document and collection UUIDs.',
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
return {
|
|
81
|
+
...target,
|
|
82
|
+
documentId: target.documentId.toLowerCase(),
|
|
83
|
+
collectionId: target.collectionId.toLowerCase(),
|
|
84
|
+
expectedRevision: parseDocumentRevision(target.expectedRevision),
|
|
85
|
+
};
|
|
86
|
+
})
|
|
87
|
+
.sort((a, b) => (a.documentId < b.documentId ? -1 : a.documentId > b.documentId ? 1 : 0));
|
|
88
|
+
if (new Set(ordered.map((target) => target.documentId)).size !== ordered.length) {
|
|
89
|
+
throw ERR_VALIDATION({ message: 'Duplicate document revision lock targets are not allowed.' });
|
|
90
|
+
}
|
|
91
|
+
const last = this.lockOrder.get(scope);
|
|
92
|
+
if (last != null && ordered.some((target) => target.documentId <= last)) {
|
|
93
|
+
throw ERR_DATABASE({
|
|
94
|
+
message: 'Document revision locks must be acquired once in increasing document identity order, including across savepoints.',
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
const executor = this.manager.get();
|
|
98
|
+
const locked = [];
|
|
99
|
+
for (const target of ordered) {
|
|
100
|
+
const [document] = await executor
|
|
101
|
+
.select({
|
|
102
|
+
revision: sql `${documents.revision}`,
|
|
103
|
+
sourceLocale: documents.source_locale,
|
|
104
|
+
})
|
|
105
|
+
.from(documents)
|
|
106
|
+
.where(and(eq(documents.id, target.documentId), eq(documents.collection_id, target.collectionId)))
|
|
107
|
+
.for('update');
|
|
108
|
+
this.lockOrder.set(scope, target.documentId);
|
|
109
|
+
if (document == null)
|
|
110
|
+
throw ERR_NOT_FOUND({ message: 'Document is unavailable.' });
|
|
111
|
+
const [current] = await executor
|
|
112
|
+
.select({ id: documentVersions.id, status: documentVersions.status })
|
|
113
|
+
.from(documentVersions)
|
|
114
|
+
.where(and(eq(documentVersions.document_id, target.documentId), eq(documentVersions.is_deleted, false)))
|
|
115
|
+
.orderBy(desc(documentVersions.id))
|
|
116
|
+
.limit(1);
|
|
117
|
+
if (current == null) {
|
|
118
|
+
const [anyVersion] = await executor
|
|
119
|
+
.select({ id: documentVersions.id })
|
|
120
|
+
.from(documentVersions)
|
|
121
|
+
.where(eq(documentVersions.document_id, target.documentId))
|
|
122
|
+
.limit(1);
|
|
123
|
+
if (anyVersion != null)
|
|
124
|
+
throw ERR_NOT_FOUND({ message: 'Document is unavailable.' });
|
|
125
|
+
}
|
|
126
|
+
const revision = documentRevisionFromDatabase(document.revision);
|
|
127
|
+
if (revision !== target.expectedRevision)
|
|
128
|
+
throw ERR_DOCUMENT_STALE({
|
|
129
|
+
message: 'This document has changed. Reload it before making further changes.',
|
|
130
|
+
details: {
|
|
131
|
+
reason: 'revision_mismatch',
|
|
132
|
+
documentId: target.documentId,
|
|
133
|
+
expectedRevision: target.expectedRevision,
|
|
134
|
+
currentRevision: revision,
|
|
135
|
+
},
|
|
136
|
+
});
|
|
137
|
+
assertDocumentVersionParent({
|
|
138
|
+
documentId: target.documentId,
|
|
139
|
+
locale: target.locale,
|
|
140
|
+
previousVersionId: target.previousVersionId,
|
|
141
|
+
currentVersionId: current?.id ?? null,
|
|
142
|
+
});
|
|
143
|
+
const [path] = await executor
|
|
144
|
+
.select({ path: documentPaths.path })
|
|
145
|
+
.from(documentPaths)
|
|
146
|
+
.where(and(eq(documentPaths.document_id, target.documentId), eq(documentPaths.locale, document.sourceLocale)))
|
|
147
|
+
.limit(1);
|
|
148
|
+
const locales = await executor
|
|
149
|
+
.select({ locale: documentAvailableLocales.locale })
|
|
150
|
+
.from(documentAvailableLocales)
|
|
151
|
+
.where(eq(documentAvailableLocales.document_id, target.documentId));
|
|
152
|
+
const observation = Object.freeze({
|
|
153
|
+
documentId: target.documentId,
|
|
154
|
+
collectionId: target.collectionId,
|
|
155
|
+
revision,
|
|
156
|
+
currentVersionId: current?.id ?? null,
|
|
157
|
+
status: current?.status ?? null,
|
|
158
|
+
sourceLocale: document.sourceLocale,
|
|
159
|
+
path: path?.path ?? null,
|
|
160
|
+
availableLocales: Object.freeze(locales.map((row) => row.locale).sort()),
|
|
161
|
+
});
|
|
162
|
+
this.issued.set(observation, {
|
|
163
|
+
scope,
|
|
164
|
+
token,
|
|
165
|
+
documentId: target.documentId,
|
|
166
|
+
collectionId: target.collectionId,
|
|
167
|
+
revision,
|
|
168
|
+
});
|
|
169
|
+
locked.push(observation);
|
|
170
|
+
}
|
|
171
|
+
return Object.freeze(locked);
|
|
172
|
+
}
|
|
173
|
+
async advance(locked) {
|
|
174
|
+
const scope = this.scope();
|
|
175
|
+
const original = this.issued.get(locked);
|
|
176
|
+
if (original == null ||
|
|
177
|
+
original.scope !== scope ||
|
|
178
|
+
!this.manager.isTransactionTokenActive(original.token))
|
|
179
|
+
throw ERR_DATABASE({
|
|
180
|
+
message: 'Document revision observation does not belong to this active transaction.',
|
|
181
|
+
});
|
|
182
|
+
if (original.revision === Number.MAX_SAFE_INTEGER)
|
|
183
|
+
throw ERR_DATABASE({
|
|
184
|
+
message: 'Document revision exhausted its safe integer range; the mutation was not committed.',
|
|
185
|
+
});
|
|
186
|
+
const next = original.revision + 1;
|
|
187
|
+
const executor = this.manager.get();
|
|
188
|
+
const predicate = and(eq(documents.id, original.documentId), eq(documents.collection_id, original.collectionId), eq(documents.revision, original.revision));
|
|
189
|
+
const [changed] = await executor.update(documents).set({ revision: next }).where(predicate);
|
|
190
|
+
const count = changed.affectedRows;
|
|
191
|
+
if (count !== 1)
|
|
192
|
+
throw ERR_DATABASE({
|
|
193
|
+
message: 'Locked document revision was already advanced or changed; roll back the mutation.',
|
|
194
|
+
});
|
|
195
|
+
return { documentId: original.documentId, revision: next };
|
|
196
|
+
}
|
|
197
|
+
}
|
|
@@ -15,6 +15,7 @@ export declare class DocumentPublishScheduleCommands implements IDocumentPublish
|
|
|
15
15
|
private dbManager;
|
|
16
16
|
constructor(dbManager: DBManager);
|
|
17
17
|
private get db();
|
|
18
|
+
lockDocuments(documentIds: readonly string[]): Promise<void>;
|
|
18
19
|
private lockCurrentVersion;
|
|
19
20
|
private lockSchedule;
|
|
20
21
|
private readSchedule;
|
|
@@ -22,6 +23,7 @@ export declare class DocumentPublishScheduleCommands implements IDocumentPublish
|
|
|
22
23
|
documentId: string;
|
|
23
24
|
collectionId: string;
|
|
24
25
|
expectedVersionId: string;
|
|
26
|
+
authorizedRevision: number;
|
|
25
27
|
publishAt: Date;
|
|
26
28
|
actorId: string | null;
|
|
27
29
|
}): Promise<ScheduleDocumentPublishResult>;
|
|
@@ -29,6 +31,7 @@ export declare class DocumentPublishScheduleCommands implements IDocumentPublish
|
|
|
29
31
|
documentId: string;
|
|
30
32
|
collectionId: string;
|
|
31
33
|
expectedVersionId: string;
|
|
34
|
+
authorizedRevision: number;
|
|
32
35
|
actorId: string | null;
|
|
33
36
|
}): Promise<ConfirmDocumentPublishScheduleResult>;
|
|
34
37
|
cancel(params: {
|
|
@@ -38,6 +41,7 @@ export declare class DocumentPublishScheduleCommands implements IDocumentPublish
|
|
|
38
41
|
suspendForContentEdit(params: {
|
|
39
42
|
documentId: string;
|
|
40
43
|
collectionId: string;
|
|
44
|
+
reason?: 'content_edited' | 'document_metadata_changed';
|
|
41
45
|
}): Promise<SuspendDocumentPublishScheduleResult>;
|
|
42
46
|
claimDue(params: {
|
|
43
47
|
batchSize: number;
|
|
@@ -52,6 +56,7 @@ export declare class DocumentPublishScheduleCommands implements IDocumentPublish
|
|
|
52
56
|
executionToken: string;
|
|
53
57
|
}): Promise<boolean>;
|
|
54
58
|
suspendClaimForContentEdit(params: {
|
|
59
|
+
reason?: 'content_edited' | 'document_metadata_changed';
|
|
55
60
|
documentId: string;
|
|
56
61
|
executionToken: string;
|
|
57
62
|
}): Promise<boolean>;
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* Copyright (c) Infonomic Company Limited
|
|
7
7
|
*/
|
|
8
8
|
import { randomUUID } from 'node:crypto';
|
|
9
|
+
import { documentRevisionFromDatabase, parseDocumentRevision } from '@byline/core';
|
|
9
10
|
import { sql } from 'drizzle-orm';
|
|
10
11
|
import { affectedRowCount, toDate } from './storage-utils.js';
|
|
11
12
|
const LAST_ERROR_MAX_LENGTH = 2_048;
|
|
@@ -17,6 +18,9 @@ function requiredDate(value, column) {
|
|
|
17
18
|
}
|
|
18
19
|
function toSchedule(row) {
|
|
19
20
|
return {
|
|
21
|
+
authorizedRevision: row.authorized_revision == null
|
|
22
|
+
? null
|
|
23
|
+
: documentRevisionFromDatabase(row.authorized_revision),
|
|
20
24
|
documentId: row.document_id,
|
|
21
25
|
collectionId: row.collection_id,
|
|
22
26
|
targetVersionId: row.target_version_id,
|
|
@@ -58,6 +62,13 @@ export class DocumentPublishScheduleCommands {
|
|
|
58
62
|
get db() {
|
|
59
63
|
return this.dbManager.get();
|
|
60
64
|
}
|
|
65
|
+
async lockDocuments(documentIds) {
|
|
66
|
+
if (!this.dbManager.isInTransaction())
|
|
67
|
+
throw new Error('Schedule locks require an active transaction');
|
|
68
|
+
for (const documentId of [...new Set(documentIds)].sort()) {
|
|
69
|
+
await this.db.execute(sql `SELECT document_id FROM byline_document_publish_schedules WHERE document_id = ${documentId} FOR UPDATE`);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
61
72
|
async lockCurrentVersion(params) {
|
|
62
73
|
const document = await this.db.execute(sql `
|
|
63
74
|
SELECT id
|
|
@@ -99,6 +110,7 @@ export class DocumentPublishScheduleCommands {
|
|
|
99
110
|
return toSchedule(row);
|
|
100
111
|
}
|
|
101
112
|
async schedule(params) {
|
|
113
|
+
params = { ...params, authorizedRevision: parseDocumentRevision(params.authorizedRevision) };
|
|
102
114
|
const currentVersionId = await this.lockCurrentVersion(params);
|
|
103
115
|
if (currentVersionId === null)
|
|
104
116
|
return { status: 'document_not_found' };
|
|
@@ -119,7 +131,7 @@ export class DocumentPublishScheduleCommands {
|
|
|
119
131
|
if (previous === null) {
|
|
120
132
|
await this.db.execute(sql `
|
|
121
133
|
INSERT INTO byline_document_publish_schedules (
|
|
122
|
-
document_id, collection_id, target_version_id, publish_at,
|
|
134
|
+
document_id, collection_id, target_version_id, authorized_revision, publish_at,
|
|
123
135
|
state, suspended_at, suspended_reason,
|
|
124
136
|
scheduled_by, last_authorized_by, last_authorized_at,
|
|
125
137
|
execution_token, execution_expires_at, last_attempt_at,
|
|
@@ -128,6 +140,7 @@ export class DocumentPublishScheduleCommands {
|
|
|
128
140
|
${params.documentId},
|
|
129
141
|
${params.collectionId},
|
|
130
142
|
${params.expectedVersionId},
|
|
143
|
+
${params.authorizedRevision},
|
|
131
144
|
${params.publishAt},
|
|
132
145
|
'armed', NULL, NULL,
|
|
133
146
|
${params.actorId}, ${params.actorId}, CURRENT_TIMESTAMP(6),
|
|
@@ -140,6 +153,7 @@ export class DocumentPublishScheduleCommands {
|
|
|
140
153
|
await this.db.execute(sql `
|
|
141
154
|
UPDATE byline_document_publish_schedules SET
|
|
142
155
|
target_version_id = ${params.expectedVersionId},
|
|
156
|
+
authorized_revision = ${params.authorizedRevision},
|
|
143
157
|
publish_at = ${params.publishAt},
|
|
144
158
|
state = 'armed',
|
|
145
159
|
suspended_at = NULL,
|
|
@@ -159,6 +173,7 @@ export class DocumentPublishScheduleCommands {
|
|
|
159
173
|
return { status: 'scheduled', schedule: await this.readSchedule(params.documentId), previous };
|
|
160
174
|
}
|
|
161
175
|
async confirm(params) {
|
|
176
|
+
params = { ...params, authorizedRevision: parseDocumentRevision(params.authorizedRevision) };
|
|
162
177
|
const currentVersionId = await this.lockCurrentVersion(params);
|
|
163
178
|
if (currentVersionId === null || currentVersionId !== params.expectedVersionId) {
|
|
164
179
|
return { status: 'version_mismatch' };
|
|
@@ -172,6 +187,7 @@ export class DocumentPublishScheduleCommands {
|
|
|
172
187
|
await this.db.execute(sql `
|
|
173
188
|
UPDATE byline_document_publish_schedules SET
|
|
174
189
|
target_version_id = ${params.expectedVersionId},
|
|
190
|
+
authorized_revision = ${params.authorizedRevision},
|
|
175
191
|
state = 'armed',
|
|
176
192
|
suspended_at = NULL,
|
|
177
193
|
suspended_reason = NULL,
|
|
@@ -214,7 +230,7 @@ export class DocumentPublishScheduleCommands {
|
|
|
214
230
|
UPDATE byline_document_publish_schedules SET
|
|
215
231
|
state = 'needs_reconfirm',
|
|
216
232
|
suspended_at = CURRENT_TIMESTAMP(6),
|
|
217
|
-
suspended_reason = 'content_edited',
|
|
233
|
+
suspended_reason = ${params.reason ?? 'content_edited'},
|
|
218
234
|
execution_token = NULL,
|
|
219
235
|
execution_expires_at = NULL,
|
|
220
236
|
updated_at = CURRENT_TIMESTAMP(6)
|
|
@@ -291,6 +307,8 @@ export class DocumentPublishScheduleCommands {
|
|
|
291
307
|
FROM byline_document_publish_schedules
|
|
292
308
|
WHERE document_id = ${params.documentId}
|
|
293
309
|
AND execution_token = ${params.executionToken}
|
|
310
|
+
AND state = 'armed'
|
|
311
|
+
AND execution_expires_at > CURRENT_TIMESTAMP(6)
|
|
294
312
|
FOR UPDATE
|
|
295
313
|
`);
|
|
296
314
|
const row = firstRow(result);
|
|
@@ -301,6 +319,7 @@ export class DocumentPublishScheduleCommands {
|
|
|
301
319
|
DELETE FROM byline_document_publish_schedules
|
|
302
320
|
WHERE document_id = ${params.documentId}
|
|
303
321
|
AND execution_token = ${params.executionToken}
|
|
322
|
+
AND state = 'armed' AND execution_expires_at > CURRENT_TIMESTAMP(6)
|
|
304
323
|
`);
|
|
305
324
|
return affectedRowCount(result) === 1;
|
|
306
325
|
}
|
|
@@ -309,7 +328,7 @@ export class DocumentPublishScheduleCommands {
|
|
|
309
328
|
UPDATE byline_document_publish_schedules SET
|
|
310
329
|
state = 'needs_reconfirm',
|
|
311
330
|
suspended_at = CURRENT_TIMESTAMP(6),
|
|
312
|
-
suspended_reason = 'content_edited',
|
|
331
|
+
suspended_reason = ${params.reason ?? 'content_edited'},
|
|
313
332
|
execution_token = NULL,
|
|
314
333
|
execution_expires_at = NULL,
|
|
315
334
|
last_attempt_at = NULL,
|
|
@@ -319,6 +338,7 @@ export class DocumentPublishScheduleCommands {
|
|
|
319
338
|
updated_at = CURRENT_TIMESTAMP(6)
|
|
320
339
|
WHERE document_id = ${params.documentId}
|
|
321
340
|
AND execution_token = ${params.executionToken}
|
|
341
|
+
AND state = 'armed' AND execution_expires_at > CURRENT_TIMESTAMP(6)
|
|
322
342
|
`);
|
|
323
343
|
return affectedRowCount(result) === 1;
|
|
324
344
|
}
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
import type { CollectionDefinition, IDbAdapter } from '@byline/core';
|
|
2
|
+
import type { DBExecutor } from '../../lib/db-manager.js';
|
|
3
|
+
export declare function createReadSnapshot(db: DBExecutor, collections: readonly CollectionDefinition[], defaultLocale: string): IDbAdapter['withReadSnapshot'];
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { runReadSnapshot } from '@byline/core';
|
|
2
|
+
import { DBManagerImpl } from '../../lib/db-manager.js';
|
|
3
|
+
import { createAuditQueries } from '../audit/audit-queries.js';
|
|
4
|
+
import { SingletonQueries } from './singletons.js';
|
|
5
|
+
import { createQueryBuilders } from './storage-queries.js';
|
|
6
|
+
export function createReadSnapshot(db, collections, defaultLocale) {
|
|
7
|
+
return (fn) => db.transaction(async (tx) => {
|
|
8
|
+
const manager = new DBManagerImpl({ dbPool: tx });
|
|
9
|
+
const queries = createQueryBuilders(tx, collections, defaultLocale, manager);
|
|
10
|
+
return runReadSnapshot({ ...queries, audit: createAuditQueries(tx), singletons: new SingletonQueries(manager) }, fn);
|
|
11
|
+
}, { isolationLevel: 'repeatable read', accessMode: 'read only' });
|
|
12
|
+
}
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
*
|
|
6
6
|
* Copyright (c) Infonomic Company Limited
|
|
7
7
|
*/
|
|
8
|
-
import {
|
|
8
|
+
import type { ISingletonCommands, ISingletonQueries } from '@byline/core';
|
|
9
9
|
import type { DBManager } from '../../lib/db-manager.js';
|
|
10
10
|
export declare class SingletonCommands implements ISingletonCommands {
|
|
11
11
|
private dbManager;
|
|
@@ -5,9 +5,9 @@
|
|
|
5
5
|
*
|
|
6
6
|
* Copyright (c) Infonomic Company Limited
|
|
7
7
|
*/
|
|
8
|
-
import {
|
|
9
|
-
import { eq, sql } from 'drizzle-orm';
|
|
8
|
+
import { eq } from 'drizzle-orm';
|
|
10
9
|
import { singletonDocuments } from '../../database/schema/index.js';
|
|
10
|
+
import { lockCollectionRegistration } from './collection-registration.js';
|
|
11
11
|
export class SingletonCommands {
|
|
12
12
|
dbManager;
|
|
13
13
|
constructor(dbManager) {
|
|
@@ -21,24 +21,7 @@ export class SingletonCommands {
|
|
|
21
21
|
return this.dbManager.get();
|
|
22
22
|
}
|
|
23
23
|
async lockSlot(collectionId) {
|
|
24
|
-
|
|
25
|
-
throw ERR_DATABASE({
|
|
26
|
-
message: 'singleton slot locks require an active transaction',
|
|
27
|
-
details: { collectionId },
|
|
28
|
-
});
|
|
29
|
-
}
|
|
30
|
-
const locked = await this.db.execute(sql `
|
|
31
|
-
SELECT id FROM byline_collections
|
|
32
|
-
WHERE id = ${collectionId}
|
|
33
|
-
FOR UPDATE
|
|
34
|
-
`);
|
|
35
|
-
const rows = locked[0];
|
|
36
|
-
if (rows.length === 0) {
|
|
37
|
-
throw ERR_NOT_FOUND({
|
|
38
|
-
message: 'singleton slot registration not found',
|
|
39
|
-
details: { collectionId },
|
|
40
|
-
});
|
|
41
|
-
}
|
|
24
|
+
await lockCollectionRegistration(this.dbManager, collectionId, 'exclusive');
|
|
42
25
|
}
|
|
43
26
|
async setMapping(collectionId, documentId) {
|
|
44
27
|
await this.db
|
|
@@ -20,6 +20,7 @@ import type { DBManager } from '../../lib/db-manager.js';
|
|
|
20
20
|
*/
|
|
21
21
|
export declare class CollectionCommands implements ICollectionCommands {
|
|
22
22
|
private dbManager;
|
|
23
|
+
lockCollectionRegistration(collectionId: string, mode: 'shared' | 'exclusive'): Promise<void>;
|
|
23
24
|
constructor(dbManager: DBManager);
|
|
24
25
|
/**
|
|
25
26
|
* The executor for this call — the ambient transaction when a
|
|
@@ -384,6 +385,7 @@ export declare class DocumentCommands implements IDocumentCommands {
|
|
|
384
385
|
*/
|
|
385
386
|
restoreSoftDeletedDocument(params: {
|
|
386
387
|
document_id: string;
|
|
388
|
+
expectedRevision: number;
|
|
387
389
|
}): Promise<number>;
|
|
388
390
|
private readDocumentVersionLiveness;
|
|
389
391
|
/**
|
|
@@ -5,11 +5,12 @@
|
|
|
5
5
|
*
|
|
6
6
|
* Copyright (c) Infonomic Company Limited
|
|
7
7
|
*/
|
|
8
|
-
import { DbErrorCodes, ERR_CONFLICT, ERR_NOT_FOUND, ERR_VALIDATION, flattenFieldSetData, generateKeyBetween, isSingleton, TREE_PLACEMENT_STALE_MARKER, } from '@byline/core';
|
|
8
|
+
import { assertDocumentVersionParent, DbErrorCodes, documentRevisionFromDatabase, ERR_CONFLICT, ERR_DOCUMENT_STALE, ERR_NOT_FOUND, ERR_VALIDATION, flattenFieldSetData, generateKeyBetween, isSingleton, parseDocumentRevision, TREE_PLACEMENT_STALE_MARKER, } from '@byline/core';
|
|
9
9
|
import { and, desc, eq, inArray, ne, notInArray, sql } from 'drizzle-orm';
|
|
10
10
|
import { v7 as uuidv7 } from 'uuid';
|
|
11
|
-
import { booleanStore, collections, currentDocumentsView, datetimeStore, documentAvailableLocales, documentPaths, documentRelationships, documents, documentVersions, fileStore, jsonStore, metaStore, numericStore, relationStore, textStore, } from '../../database/schema/index.js';
|
|
11
|
+
import { booleanStore, collections, currentDocumentsView, datetimeStore, documentAvailableLocales, documentPaths, documentPublishSchedules, documentRelationships, documents, documentVersions, fileStore, jsonStore, metaStore, numericStore, relationStore, textStore, } from '../../database/schema/index.js';
|
|
12
12
|
import { classifyError } from './classify-error.js';
|
|
13
|
+
import { lockCollectionRegistration } from './collection-registration.js';
|
|
13
14
|
import { DocumentPublishScheduleCommands } from './publish-schedules.js';
|
|
14
15
|
import { prepareFieldInsertBuckets } from './storage-insert.js';
|
|
15
16
|
import { affectedRowCount, getFirstOrThrow } from './storage-utils.js';
|
|
@@ -34,6 +35,9 @@ const staleTreePlacementMessage = (message) => `${TREE_PLACEMENT_STALE_MARKER} t
|
|
|
34
35
|
*/
|
|
35
36
|
export class CollectionCommands {
|
|
36
37
|
dbManager;
|
|
38
|
+
async lockCollectionRegistration(collectionId, mode) {
|
|
39
|
+
await lockCollectionRegistration(this.dbManager, collectionId, mode);
|
|
40
|
+
}
|
|
37
41
|
constructor(dbManager) {
|
|
38
42
|
this.dbManager = dbManager;
|
|
39
43
|
}
|
|
@@ -202,6 +206,7 @@ export class DocumentCommands {
|
|
|
202
206
|
sourceLocale = this.defaultContentLocale;
|
|
203
207
|
await tx.insert(documents).values({
|
|
204
208
|
id: documentId,
|
|
209
|
+
revision: 1,
|
|
205
210
|
collection_id: params.collectionId,
|
|
206
211
|
order_key: params.orderKey ?? null,
|
|
207
212
|
source_locale: sourceLocale,
|
|
@@ -221,6 +226,12 @@ export class DocumentCommands {
|
|
|
221
226
|
details: { documentId },
|
|
222
227
|
});
|
|
223
228
|
}
|
|
229
|
+
assertDocumentVersionParent({
|
|
230
|
+
documentId,
|
|
231
|
+
locale: params.locale,
|
|
232
|
+
previousVersionId: params.previousVersionId,
|
|
233
|
+
currentVersionId: versionLiveness.currentVersionId,
|
|
234
|
+
});
|
|
224
235
|
sourceLocale = existing.source_locale ?? this.defaultContentLocale;
|
|
225
236
|
}
|
|
226
237
|
// 2. Create the document version. The id is minted here (app-side
|
|
@@ -799,18 +810,30 @@ export class DocumentCommands {
|
|
|
799
810
|
* reconstructed by this storage primitive.
|
|
800
811
|
*/
|
|
801
812
|
async restoreSoftDeletedDocument(params) {
|
|
813
|
+
params = { ...params, expectedRevision: parseDocumentRevision(params.expectedRevision) };
|
|
802
814
|
const restoredAt = new Date();
|
|
803
815
|
return this.db.transaction(async (tx) => {
|
|
804
816
|
const collectionId = await this.lockDocumentCollection(tx, params.document_id);
|
|
805
817
|
if (collectionId == null)
|
|
806
818
|
return 0;
|
|
807
819
|
const [document] = await tx
|
|
808
|
-
.select({ id: documents.id })
|
|
820
|
+
.select({ id: documents.id, revision: documents.revision })
|
|
809
821
|
.from(documents)
|
|
810
822
|
.where(eq(documents.id, params.document_id))
|
|
811
823
|
.for('update');
|
|
812
824
|
if (document == null)
|
|
813
825
|
return 0;
|
|
826
|
+
const currentRevision = documentRevisionFromDatabase(document.revision);
|
|
827
|
+
if (currentRevision !== params.expectedRevision)
|
|
828
|
+
throw ERR_DOCUMENT_STALE({
|
|
829
|
+
message: 'This document has changed. Reload before restoring.',
|
|
830
|
+
details: {
|
|
831
|
+
reason: 'revision_mismatch',
|
|
832
|
+
documentId: params.document_id,
|
|
833
|
+
expectedRevision: params.expectedRevision,
|
|
834
|
+
currentRevision,
|
|
835
|
+
},
|
|
836
|
+
});
|
|
814
837
|
const versionLiveness = await this.readDocumentVersionLiveness(tx, params.document_id);
|
|
815
838
|
if (versionLiveness.total === 0 || versionLiveness.live > 0)
|
|
816
839
|
return 0;
|
|
@@ -821,6 +844,17 @@ export class DocumentCommands {
|
|
|
821
844
|
updated_at: restoredAt,
|
|
822
845
|
})
|
|
823
846
|
.where(eq(documentPaths.document_id, params.document_id));
|
|
847
|
+
// Internal restores cannot revive a schedule left by legacy or raw maintenance.
|
|
848
|
+
// Keep this on the same transaction executor, after the collection/document locks.
|
|
849
|
+
await tx
|
|
850
|
+
.delete(documentPublishSchedules)
|
|
851
|
+
.where(eq(documentPublishSchedules.document_id, params.document_id));
|
|
852
|
+
if (currentRevision === Number.MAX_SAFE_INTEGER)
|
|
853
|
+
throw ERR_VALIDATION({ message: 'Document revision exhausted' });
|
|
854
|
+
await tx
|
|
855
|
+
.update(documents)
|
|
856
|
+
.set({ revision: currentRevision + 1, updated_at: restoredAt })
|
|
857
|
+
.where(and(eq(documents.id, params.document_id), eq(documents.revision, currentRevision)));
|
|
824
858
|
const result = await tx
|
|
825
859
|
.update(documentVersions)
|
|
826
860
|
.set({
|
|
@@ -833,10 +867,12 @@ export class DocumentCommands {
|
|
|
833
867
|
}
|
|
834
868
|
async readDocumentVersionLiveness(tx, documentId) {
|
|
835
869
|
// Match the live views: only explicit `false` is live; legacy `NULL` is not.
|
|
870
|
+
// Their current version is likewise the greatest live UUIDv7 id.
|
|
836
871
|
return tx
|
|
837
872
|
.select({
|
|
838
873
|
total: sql `CAST(COUNT(*) AS SIGNED)`,
|
|
839
874
|
live: sql `CAST(COUNT(CASE WHEN ${documentVersions.is_deleted} = false THEN 1 END) AS SIGNED)`,
|
|
875
|
+
currentVersionId: sql `MAX(CASE WHEN ${documentVersions.is_deleted} = false THEN ${documentVersions.id} ELSE NULL END)`,
|
|
840
876
|
})
|
|
841
877
|
.from(documentVersions)
|
|
842
878
|
.where(eq(documentVersions.document_id, documentId))
|
|
@@ -75,6 +75,10 @@ export declare class CollectionQueries implements ICollectionQueries {
|
|
|
75
75
|
* DocumentQueries
|
|
76
76
|
*/
|
|
77
77
|
export declare class DocumentQueries implements IDocumentQueries {
|
|
78
|
+
getDocumentRevision(params: {
|
|
79
|
+
collection_id: string;
|
|
80
|
+
document_id: string;
|
|
81
|
+
}): Promise<number | null>;
|
|
78
82
|
readonly publishSchedules: DocumentPublishScheduleQueries;
|
|
79
83
|
private db;
|
|
80
84
|
private transactionDb;
|
|
@@ -62,6 +62,14 @@ export class CollectionQueries {
|
|
|
62
62
|
* DocumentQueries
|
|
63
63
|
*/
|
|
64
64
|
export class DocumentQueries {
|
|
65
|
+
async getDocumentRevision(params) {
|
|
66
|
+
const rows = await this.db
|
|
67
|
+
.select({ revision: documents.revision })
|
|
68
|
+
.from(documents)
|
|
69
|
+
.where(and(eq(documents.id, params.document_id), eq(documents.collection_id, params.collection_id)))
|
|
70
|
+
.limit(1);
|
|
71
|
+
return rows[0]?.revision ?? null;
|
|
72
|
+
}
|
|
65
73
|
publishSchedules;
|
|
66
74
|
db;
|
|
67
75
|
transactionDb;
|
|
@@ -1722,6 +1730,7 @@ export class DocumentQueries {
|
|
|
1722
1730
|
: sql `(d.order_key IS NULL) ASC, d.order_key ASC, d.created_at DESC`;
|
|
1723
1731
|
}
|
|
1724
1732
|
const columnMap = {
|
|
1733
|
+
document_id: 'd.document_id',
|
|
1725
1734
|
created_at: 'd.created_at',
|
|
1726
1735
|
updated_at: 'd.updated_at',
|
|
1727
1736
|
};
|
|
@@ -144,7 +144,10 @@ describe('live-path query resolution (MySQL)', () => {
|
|
|
144
144
|
previousVersionId: first.document.id,
|
|
145
145
|
});
|
|
146
146
|
await testDb.commandBuilders.documents.softDeleteDocument({ document_id: documentId });
|
|
147
|
-
await expect(testDb.commandBuilders.documents.restoreSoftDeletedDocument({
|
|
147
|
+
await expect(testDb.commandBuilders.documents.restoreSoftDeletedDocument({
|
|
148
|
+
expectedRevision: 1,
|
|
149
|
+
document_id: documentId,
|
|
150
|
+
})).resolves.toBe(2);
|
|
148
151
|
const restoredPaths = await testDb.db
|
|
149
152
|
.select({
|
|
150
153
|
path: documentPaths.path,
|
|
@@ -180,7 +183,10 @@ describe('live-path query resolution (MySQL)', () => {
|
|
|
180
183
|
.update(documentPaths)
|
|
181
184
|
.set({ deleted_at: null })
|
|
182
185
|
.where(eq(documentPaths.document_id, documentId));
|
|
183
|
-
await expect(testDb.commandBuilders.documents.restoreSoftDeletedDocument({
|
|
186
|
+
await expect(testDb.commandBuilders.documents.restoreSoftDeletedDocument({
|
|
187
|
+
expectedRevision: 2,
|
|
188
|
+
document_id: documentId,
|
|
189
|
+
})).resolves.toBe(0);
|
|
184
190
|
const legacyPaths = await testDb.db
|
|
185
191
|
.select({ deletedAt: documentPaths.deleted_at })
|
|
186
192
|
.from(documentPaths)
|
|
@@ -196,6 +202,7 @@ describe('live-path query resolution (MySQL)', () => {
|
|
|
196
202
|
const documentId = crypto.randomUUID();
|
|
197
203
|
const path = `versionless-${timestamp}`;
|
|
198
204
|
await testDb.db.insert(documents).values({
|
|
205
|
+
revision: 1,
|
|
199
206
|
id: documentId,
|
|
200
207
|
collection_id: collectionId,
|
|
201
208
|
source_locale: 'en',
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "@byline/db-mysql",
|
|
3
3
|
"private": false,
|
|
4
4
|
"license": "MPL-2.0",
|
|
5
|
-
"version": "
|
|
5
|
+
"version": "5.0.0",
|
|
6
6
|
"engines": {
|
|
7
7
|
"node": ">=20.9.0"
|
|
8
8
|
},
|
|
@@ -50,8 +50,8 @@
|
|
|
50
50
|
"drizzle-orm": "^0.45.2",
|
|
51
51
|
"mysql2": "^3.24.2",
|
|
52
52
|
"uuid": "^14.0.2",
|
|
53
|
-
"@byline/admin": "
|
|
54
|
-
"@byline/core": "
|
|
53
|
+
"@byline/admin": "5.0.0",
|
|
54
|
+
"@byline/core": "5.0.0"
|
|
55
55
|
},
|
|
56
56
|
"devDependencies": {
|
|
57
57
|
"@biomejs/biome": "2.5.10",
|
|
@@ -64,9 +64,9 @@
|
|
|
64
64
|
"tsc-alias": "^1.9.2",
|
|
65
65
|
"typescript": "^7.0.2",
|
|
66
66
|
"vitest": "^4.1.11",
|
|
67
|
-
"@byline/client": "
|
|
68
|
-
"@byline/
|
|
69
|
-
"@byline/
|
|
67
|
+
"@byline/client": "5.0.0",
|
|
68
|
+
"@byline/auth": "5.0.0",
|
|
69
|
+
"@byline/db-conformance": "0.0.10"
|
|
70
70
|
},
|
|
71
71
|
"publishConfig": {
|
|
72
72
|
"access": "public",
|