@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
|
@@ -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 { assertDocumentVersionParent, 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,
|
|
@@ -805,18 +810,30 @@ export class DocumentCommands {
|
|
|
805
810
|
* reconstructed by this storage primitive.
|
|
806
811
|
*/
|
|
807
812
|
async restoreSoftDeletedDocument(params) {
|
|
813
|
+
params = { ...params, expectedRevision: parseDocumentRevision(params.expectedRevision) };
|
|
808
814
|
const restoredAt = new Date();
|
|
809
815
|
return this.db.transaction(async (tx) => {
|
|
810
816
|
const collectionId = await this.lockDocumentCollection(tx, params.document_id);
|
|
811
817
|
if (collectionId == null)
|
|
812
818
|
return 0;
|
|
813
819
|
const [document] = await tx
|
|
814
|
-
.select({ id: documents.id })
|
|
820
|
+
.select({ id: documents.id, revision: documents.revision })
|
|
815
821
|
.from(documents)
|
|
816
822
|
.where(eq(documents.id, params.document_id))
|
|
817
823
|
.for('update');
|
|
818
824
|
if (document == null)
|
|
819
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
|
+
});
|
|
820
837
|
const versionLiveness = await this.readDocumentVersionLiveness(tx, params.document_id);
|
|
821
838
|
if (versionLiveness.total === 0 || versionLiveness.live > 0)
|
|
822
839
|
return 0;
|
|
@@ -827,6 +844,17 @@ export class DocumentCommands {
|
|
|
827
844
|
updated_at: restoredAt,
|
|
828
845
|
})
|
|
829
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)));
|
|
830
858
|
const result = await tx
|
|
831
859
|
.update(documentVersions)
|
|
832
860
|
.set({
|
|
@@ -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.1.0",
|
|
6
6
|
"engines": {
|
|
7
7
|
"node": ">=20.9.0"
|
|
8
8
|
},
|
|
@@ -48,24 +48,24 @@
|
|
|
48
48
|
],
|
|
49
49
|
"dependencies": {
|
|
50
50
|
"drizzle-orm": "^0.45.2",
|
|
51
|
-
"mysql2": "^3.24.
|
|
51
|
+
"mysql2": "^3.24.4",
|
|
52
52
|
"uuid": "^14.0.2",
|
|
53
|
-
"@byline/admin": "
|
|
54
|
-
"@byline/core": "
|
|
53
|
+
"@byline/admin": "5.1.0",
|
|
54
|
+
"@byline/core": "5.1.0"
|
|
55
55
|
},
|
|
56
56
|
"devDependencies": {
|
|
57
|
-
"@biomejs/biome": "2.5.
|
|
58
|
-
"@types/node": "^26.
|
|
57
|
+
"@biomejs/biome": "2.5.12",
|
|
58
|
+
"@types/node": "^26.5.0",
|
|
59
59
|
"chokidar": "^5.0.0",
|
|
60
60
|
"chokidar-cli": "^3.0.0",
|
|
61
61
|
"dotenv": "^17.4.2",
|
|
62
62
|
"drizzle-kit": "^0.31.10",
|
|
63
63
|
"npm-run-all": "^4.1.5",
|
|
64
|
-
"tsc-alias": "^1.9.
|
|
64
|
+
"tsc-alias": "^1.9.4",
|
|
65
65
|
"typescript": "^7.0.2",
|
|
66
|
-
"vitest": "^
|
|
67
|
-
"@byline/auth": "
|
|
68
|
-
"@byline/client": "
|
|
66
|
+
"vitest": "^5.0.0",
|
|
67
|
+
"@byline/auth": "5.1.0",
|
|
68
|
+
"@byline/client": "5.1.0",
|
|
69
69
|
"@byline/db-conformance": "0.0.10"
|
|
70
70
|
},
|
|
71
71
|
"publishConfig": {
|