@byline/db-mysql 4.10.2 → 4.11.1
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 +36 -0
- package/dist/database/schema/index.js +8 -3
- package/dist/database/schema/schema-pins.test.node.d.ts +4 -2
- package/dist/database/schema/schema-pins.test.node.js +26 -4
- package/dist/modules/storage/storage-commands.d.ts +16 -3
- package/dist/modules/storage/storage-commands.js +73 -4
- package/dist/modules/storage/storage-queries.d.ts +5 -0
- package/dist/modules/storage/storage-queries.js +6 -0
- package/dist/modules/storage/tests/storage-live-path-queries.test.d.ts +8 -0
- package/dist/modules/storage/tests/storage-live-path-queries.test.js +222 -0
- package/package.json +6 -6
|
@@ -591,6 +591,42 @@ export declare const documentPaths: import("drizzle-orm/mysql-core").MySqlTableW
|
|
|
591
591
|
identity: undefined;
|
|
592
592
|
generated: undefined;
|
|
593
593
|
}, {}, {}>;
|
|
594
|
+
deleted_at: import("drizzle-orm/mysql-core").MySqlColumn<{
|
|
595
|
+
name: "deleted_at";
|
|
596
|
+
tableName: "byline_document_paths";
|
|
597
|
+
dataType: "date";
|
|
598
|
+
columnType: "MySqlDateTime";
|
|
599
|
+
data: Date;
|
|
600
|
+
driverParam: string | number;
|
|
601
|
+
notNull: false;
|
|
602
|
+
hasDefault: false;
|
|
603
|
+
isPrimaryKey: false;
|
|
604
|
+
isAutoincrement: false;
|
|
605
|
+
hasRuntimeDefault: false;
|
|
606
|
+
enumValues: undefined;
|
|
607
|
+
baseColumn: never;
|
|
608
|
+
identity: undefined;
|
|
609
|
+
generated: undefined;
|
|
610
|
+
}, {}, {}>;
|
|
611
|
+
alive: import("drizzle-orm/mysql-core").MySqlColumn<{
|
|
612
|
+
name: "alive";
|
|
613
|
+
tableName: "byline_document_paths";
|
|
614
|
+
dataType: "boolean";
|
|
615
|
+
columnType: "MySqlBoolean";
|
|
616
|
+
data: boolean;
|
|
617
|
+
driverParam: number | boolean;
|
|
618
|
+
notNull: false;
|
|
619
|
+
hasDefault: true;
|
|
620
|
+
isPrimaryKey: false;
|
|
621
|
+
isAutoincrement: false;
|
|
622
|
+
hasRuntimeDefault: false;
|
|
623
|
+
enumValues: undefined;
|
|
624
|
+
baseColumn: never;
|
|
625
|
+
identity: undefined;
|
|
626
|
+
generated: {
|
|
627
|
+
type: 'always';
|
|
628
|
+
};
|
|
629
|
+
}, {}, {}>;
|
|
594
630
|
};
|
|
595
631
|
dialect: 'mysql';
|
|
596
632
|
}>;
|
|
@@ -139,6 +139,11 @@ export const documentPaths = mysqlTable('byline_document_paths', {
|
|
|
139
139
|
// in `./common.ts` for why (case AND accent sensitivity, verified
|
|
140
140
|
// against live Thai/Devanagari/Hebrew slugs, not just Latin parity).
|
|
141
141
|
path: varcharCaseSensitive('path', { length: 255 }).notNull(),
|
|
142
|
+
// Whole-document soft delete releases the live namespace without
|
|
143
|
+
// discarding the retained path. `alive` is derived so callers cannot
|
|
144
|
+
// update the two liveness columns inconsistently.
|
|
145
|
+
deleted_at: datetime('deleted_at', { fsp: 6 }),
|
|
146
|
+
alive: boolean('alive').generatedAlwaysAs(sql `CASE WHEN \`deleted_at\` IS NULL THEN true ELSE NULL END`, { mode: 'stored' }),
|
|
142
147
|
...timestamps,
|
|
143
148
|
}, (table) => [
|
|
144
149
|
foreignKey({
|
|
@@ -153,9 +158,9 @@ export const documentPaths = mysqlTable('byline_document_paths', {
|
|
|
153
158
|
}).onDelete('cascade'),
|
|
154
159
|
// One path per (logical document, locale).
|
|
155
160
|
unique('unique_document_paths_document_locale').on(table.document_id, table.locale),
|
|
156
|
-
// Per-collection per-locale path uniqueness.
|
|
157
|
-
//
|
|
158
|
-
unique('idx_document_paths_collection_locale_path').on(table.collection_id, table.locale, table.path),
|
|
161
|
+
// Per-collection per-locale live-path uniqueness. NULL generated values
|
|
162
|
+
// allow any number of deleted rows to retain the same path.
|
|
163
|
+
unique('idx_document_paths_collection_locale_path').on(table.collection_id, table.locale, table.path, table.alive),
|
|
159
164
|
// Reverse lookup by document.
|
|
160
165
|
index('idx_document_paths_document_id').on(table.document_id),
|
|
161
166
|
]);
|
|
@@ -34,11 +34,13 @@
|
|
|
34
34
|
* user-authored field data, not statement-ordering timestamps, so the
|
|
35
35
|
* race that motivated the instant-column bump upstream doesn't apply
|
|
36
36
|
* to them.
|
|
37
|
-
* 4. The `byline_document_paths`
|
|
37
|
+
* 4. The `byline_document_paths` live-path-uniqueness index
|
|
38
38
|
* keeps the exact name `idx_document_paths_collection_locale_path`,
|
|
39
39
|
* because `packages/core/src/services/document-lifecycle/internals.ts`
|
|
40
40
|
* substring-matches this name against the adapter's `classifyError`
|
|
41
|
-
* constraint report to detect path collisions.
|
|
41
|
+
* constraint report to detect path collisions. Its generated `alive`
|
|
42
|
+
* discriminator is stored and nullable so deleted rows no longer
|
|
43
|
+
* occupy the live namespace.
|
|
42
44
|
* 5. `byline_document_paths.path` carries the `utf8mb4_bin` collation
|
|
43
45
|
* (project-owner ruling) so path uniqueness is case- and
|
|
44
46
|
* accent-sensitive on MySQL exactly like it already is on Postgres —
|
|
@@ -34,11 +34,13 @@
|
|
|
34
34
|
* user-authored field data, not statement-ordering timestamps, so the
|
|
35
35
|
* race that motivated the instant-column bump upstream doesn't apply
|
|
36
36
|
* to them.
|
|
37
|
-
* 4. The `byline_document_paths`
|
|
37
|
+
* 4. The `byline_document_paths` live-path-uniqueness index
|
|
38
38
|
* keeps the exact name `idx_document_paths_collection_locale_path`,
|
|
39
39
|
* because `packages/core/src/services/document-lifecycle/internals.ts`
|
|
40
40
|
* substring-matches this name against the adapter's `classifyError`
|
|
41
|
-
* constraint report to detect path collisions.
|
|
41
|
+
* constraint report to detect path collisions. Its generated `alive`
|
|
42
|
+
* discriminator is stored and nullable so deleted rows no longer
|
|
43
|
+
* occupy the live namespace.
|
|
42
44
|
* 5. `byline_document_paths.path` carries the `utf8mb4_bin` collation
|
|
43
45
|
* (project-owner ruling) so path uniqueness is case- and
|
|
44
46
|
* accent-sensitive on MySQL exactly like it already is on Postgres —
|
|
@@ -371,11 +373,31 @@ describe('schema pins — timestamp precision (spec §F.3)', () => {
|
|
|
371
373
|
});
|
|
372
374
|
});
|
|
373
375
|
describe('schema pins — document-paths unique index name (spec §E)', () => {
|
|
374
|
-
it('
|
|
376
|
+
it('pins the live-path and document-locale unique keys', () => {
|
|
375
377
|
const cfg = getTableConfig(coreSchema.documentPaths);
|
|
376
378
|
const pathKey = cfg.uniqueConstraints.find((uc) => uc.name === 'idx_document_paths_collection_locale_path');
|
|
377
379
|
expect(pathKey).toBeDefined();
|
|
378
|
-
expect(pathKey?.columns.map((c) => c.name)).toEqual([
|
|
380
|
+
expect(pathKey?.columns.map((c) => c.name)).toEqual([
|
|
381
|
+
'collection_id',
|
|
382
|
+
'locale',
|
|
383
|
+
'path',
|
|
384
|
+
'alive',
|
|
385
|
+
]);
|
|
386
|
+
const documentLocaleKey = cfg.uniqueConstraints.find((uc) => uc.name === 'unique_document_paths_document_locale');
|
|
387
|
+
expect(documentLocaleKey?.columns.map((c) => c.name)).toEqual(['document_id', 'locale']);
|
|
388
|
+
const width = (pathKey?.columns ?? []).reduce((sum, column) => sum +
|
|
389
|
+
indexColumnByteWidth('byline_document_paths', 'idx_document_paths_collection_locale_path', column), 0);
|
|
390
|
+
expect(width).toBe(1097);
|
|
391
|
+
});
|
|
392
|
+
it('pins nullable deleted_at and the stored generated alive discriminator', () => {
|
|
393
|
+
const cfg = getTableConfig(coreSchema.documentPaths);
|
|
394
|
+
const deletedAt = cfg.columns.find((column) => column.name === 'deleted_at');
|
|
395
|
+
const alive = cfg.columns.find((column) => column.name === 'alive');
|
|
396
|
+
expect(deletedAt?.getSQLType()).toBe('datetime(6)');
|
|
397
|
+
expect(deletedAt?.notNull).toBe(false);
|
|
398
|
+
expect(alive?.getSQLType()).toBe('boolean');
|
|
399
|
+
expect(alive?.notNull).toBe(false);
|
|
400
|
+
expect(alive?.generated).toMatchObject({ type: 'always', mode: 'stored' });
|
|
379
401
|
});
|
|
380
402
|
});
|
|
381
403
|
describe('schema pins — document-paths case-sensitive collation (project-owner ruling)', () => {
|
|
@@ -362,15 +362,28 @@ export declare class DocumentCommands implements IDocumentCommands {
|
|
|
362
362
|
/**
|
|
363
363
|
* softDeleteDocument
|
|
364
364
|
*
|
|
365
|
-
*
|
|
366
|
-
*
|
|
367
|
-
*
|
|
365
|
+
* Tombstone every path and version row for a document with one operation
|
|
366
|
+
* timestamp. Live views and path resolution filter these rows without
|
|
367
|
+
* physically removing retained content or path values.
|
|
368
368
|
*
|
|
369
369
|
* Returns the number of version rows marked as deleted.
|
|
370
370
|
*/
|
|
371
371
|
softDeleteDocument(params: {
|
|
372
372
|
document_id: string;
|
|
373
373
|
}): Promise<number>;
|
|
374
|
+
/**
|
|
375
|
+
* Restore every path and version tombstone for a fully soft-deleted document.
|
|
376
|
+
*
|
|
377
|
+
* Takes the same collection then document locks as soft delete. A missing,
|
|
378
|
+
* versionless, already-live, or legacy partially-live document is a no-op.
|
|
379
|
+
* Path uniqueness is revalidated by the live unique constraint; any conflict
|
|
380
|
+
* aborts both updates. Tree placement and search/cache projections are not
|
|
381
|
+
* reconstructed by this storage primitive.
|
|
382
|
+
*/
|
|
383
|
+
restoreSoftDeletedDocument(params: {
|
|
384
|
+
document_id: string;
|
|
385
|
+
}): Promise<number>;
|
|
386
|
+
private readDocumentVersionLiveness;
|
|
374
387
|
/**
|
|
375
388
|
* Write `order_key` on a single `byline_documents` row. Single-column
|
|
376
389
|
* metadata update — no new version row, no `documentVersions` touch.
|
|
@@ -209,7 +209,15 @@ export class DocumentCommands {
|
|
|
209
209
|
.select({ source_locale: documents.source_locale })
|
|
210
210
|
.from(documents)
|
|
211
211
|
.where(eq(documents.id, documentId))
|
|
212
|
+
.for('update')
|
|
212
213
|
.then(getFirstOrThrow('Failed to load document for new version'));
|
|
214
|
+
const versionLiveness = await this.readDocumentVersionLiveness(tx, documentId);
|
|
215
|
+
if (versionLiveness.total > 0 && versionLiveness.live === 0) {
|
|
216
|
+
throw ERR_CONFLICT({
|
|
217
|
+
message: 'cannot add a version to a soft-deleted document',
|
|
218
|
+
details: { documentId },
|
|
219
|
+
});
|
|
220
|
+
}
|
|
213
221
|
sourceLocale = existing.source_locale ?? this.defaultContentLocale;
|
|
214
222
|
}
|
|
215
223
|
// 2. Create the document version. The id is minted here (app-side
|
|
@@ -738,13 +746,14 @@ export class DocumentCommands {
|
|
|
738
746
|
/**
|
|
739
747
|
* softDeleteDocument
|
|
740
748
|
*
|
|
741
|
-
*
|
|
742
|
-
*
|
|
743
|
-
*
|
|
749
|
+
* Tombstone every path and version row for a document with one operation
|
|
750
|
+
* timestamp. Live views and path resolution filter these rows without
|
|
751
|
+
* physically removing retained content or path values.
|
|
744
752
|
*
|
|
745
753
|
* Returns the number of version rows marked as deleted.
|
|
746
754
|
*/
|
|
747
755
|
async softDeleteDocument(params) {
|
|
756
|
+
const deletedAt = new Date();
|
|
748
757
|
return this.db.transaction(async (tx) => {
|
|
749
758
|
// Tree placement takes this same collection lock before inspecting any
|
|
750
759
|
// endpoint state. Taking it before document/version locks makes direct
|
|
@@ -760,16 +769,76 @@ export class DocumentCommands {
|
|
|
760
769
|
.for('update');
|
|
761
770
|
if (document == null)
|
|
762
771
|
return 0;
|
|
772
|
+
await tx
|
|
773
|
+
.update(documentPaths)
|
|
774
|
+
.set({
|
|
775
|
+
deleted_at: deletedAt,
|
|
776
|
+
updated_at: deletedAt,
|
|
777
|
+
})
|
|
778
|
+
.where(eq(documentPaths.document_id, params.document_id));
|
|
763
779
|
const result = await tx
|
|
764
780
|
.update(documentVersions)
|
|
765
781
|
.set({
|
|
766
782
|
is_deleted: true,
|
|
767
|
-
updated_at:
|
|
783
|
+
updated_at: deletedAt,
|
|
768
784
|
})
|
|
769
785
|
.where(eq(documentVersions.document_id, params.document_id));
|
|
770
786
|
return affectedRowCount(result);
|
|
771
787
|
});
|
|
772
788
|
}
|
|
789
|
+
/**
|
|
790
|
+
* Restore every path and version tombstone for a fully soft-deleted document.
|
|
791
|
+
*
|
|
792
|
+
* Takes the same collection then document locks as soft delete. A missing,
|
|
793
|
+
* versionless, already-live, or legacy partially-live document is a no-op.
|
|
794
|
+
* Path uniqueness is revalidated by the live unique constraint; any conflict
|
|
795
|
+
* aborts both updates. Tree placement and search/cache projections are not
|
|
796
|
+
* reconstructed by this storage primitive.
|
|
797
|
+
*/
|
|
798
|
+
async restoreSoftDeletedDocument(params) {
|
|
799
|
+
const restoredAt = new Date();
|
|
800
|
+
return this.db.transaction(async (tx) => {
|
|
801
|
+
const collectionId = await this.lockDocumentCollection(tx, params.document_id);
|
|
802
|
+
if (collectionId == null)
|
|
803
|
+
return 0;
|
|
804
|
+
const [document] = await tx
|
|
805
|
+
.select({ id: documents.id })
|
|
806
|
+
.from(documents)
|
|
807
|
+
.where(eq(documents.id, params.document_id))
|
|
808
|
+
.for('update');
|
|
809
|
+
if (document == null)
|
|
810
|
+
return 0;
|
|
811
|
+
const versionLiveness = await this.readDocumentVersionLiveness(tx, params.document_id);
|
|
812
|
+
if (versionLiveness.total === 0 || versionLiveness.live > 0)
|
|
813
|
+
return 0;
|
|
814
|
+
await tx
|
|
815
|
+
.update(documentPaths)
|
|
816
|
+
.set({
|
|
817
|
+
deleted_at: null,
|
|
818
|
+
updated_at: restoredAt,
|
|
819
|
+
})
|
|
820
|
+
.where(eq(documentPaths.document_id, params.document_id));
|
|
821
|
+
const result = await tx
|
|
822
|
+
.update(documentVersions)
|
|
823
|
+
.set({
|
|
824
|
+
is_deleted: false,
|
|
825
|
+
updated_at: restoredAt,
|
|
826
|
+
})
|
|
827
|
+
.where(eq(documentVersions.document_id, params.document_id));
|
|
828
|
+
return affectedRowCount(result);
|
|
829
|
+
});
|
|
830
|
+
}
|
|
831
|
+
async readDocumentVersionLiveness(tx, documentId) {
|
|
832
|
+
// Match the live views: only explicit `false` is live; legacy `NULL` is not.
|
|
833
|
+
return tx
|
|
834
|
+
.select({
|
|
835
|
+
total: sql `CAST(COUNT(*) AS SIGNED)`,
|
|
836
|
+
live: sql `CAST(COUNT(CASE WHEN ${documentVersions.is_deleted} = false THEN 1 END) AS SIGNED)`,
|
|
837
|
+
})
|
|
838
|
+
.from(documentVersions)
|
|
839
|
+
.where(eq(documentVersions.document_id, documentId))
|
|
840
|
+
.then(getFirstOrThrow('Failed to read document version liveness'));
|
|
841
|
+
}
|
|
773
842
|
/**
|
|
774
843
|
* Write `order_key` on a single `byline_documents` row. Single-column
|
|
775
844
|
* metadata update — no new version row, no `documentVersions` touch.
|
|
@@ -182,6 +182,11 @@ export declare class DocumentQueries implements IDocumentQueries {
|
|
|
182
182
|
* reaches the `ORDER BY` has a genuine position, and ascending order
|
|
183
183
|
* therefore picks the earliest (highest-priority, i.e. most-requested)
|
|
184
184
|
* chain entry first, exactly matching `array_position`'s semantics.
|
|
185
|
+
*
|
|
186
|
+
* This is a projection by known document identity, not a live-namespace
|
|
187
|
+
* lookup. Deliberately do not filter `alive`: history and deleted-document
|
|
188
|
+
* administration must continue to display the retained path.
|
|
189
|
+
*
|
|
185
190
|
* Verified live against MySQL 9.7.1 (see the Task 10A report) and pinned
|
|
186
191
|
* by `storage-queries.test.ts`'s locale-chain-ordering test.
|
|
187
192
|
*/
|
|
@@ -275,6 +275,11 @@ export class DocumentQueries {
|
|
|
275
275
|
* reaches the `ORDER BY` has a genuine position, and ascending order
|
|
276
276
|
* therefore picks the earliest (highest-priority, i.e. most-requested)
|
|
277
277
|
* chain entry first, exactly matching `array_position`'s semantics.
|
|
278
|
+
*
|
|
279
|
+
* This is a projection by known document identity, not a live-namespace
|
|
280
|
+
* lookup. Deliberately do not filter `alive`: history and deleted-document
|
|
281
|
+
* administration must continue to display the retained path.
|
|
282
|
+
*
|
|
278
283
|
* Verified live against MySQL 9.7.1 (see the Task 10A report) and pinned
|
|
279
284
|
* by `storage-queries.test.ts`'s locale-chain-ordering test.
|
|
280
285
|
*/
|
|
@@ -307,6 +312,7 @@ export class DocumentQueries {
|
|
|
307
312
|
SELECT ${documentPaths.document_id} FROM ${documentPaths}
|
|
308
313
|
WHERE ${documentPaths.collection_id} = ${collection_id}
|
|
309
314
|
AND ${documentPaths.path} = ${path}
|
|
315
|
+
AND ${documentPaths.alive} = true
|
|
310
316
|
AND ${documentPaths.locale} IN (${chainSql})
|
|
311
317
|
ORDER BY FIELD(${documentPaths.locale}, ${chainSql})
|
|
312
318
|
LIMIT 1
|
|
@@ -0,0 +1,8 @@
|
|
|
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
|
+
export {};
|
|
@@ -0,0 +1,222 @@
|
|
|
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 { eq } from 'drizzle-orm';
|
|
9
|
+
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
|
10
|
+
import { documentPaths, documents, documentVersions } from '../../../database/schema/index.js';
|
|
11
|
+
import { setupTestDB, teardownTestDB } from '../../../lib/test-helper.js';
|
|
12
|
+
const timestamp = Date.now();
|
|
13
|
+
const PagesCollectionConfig = {
|
|
14
|
+
path: `live-path-queries-${timestamp}`,
|
|
15
|
+
labels: { singular: 'Page', plural: 'Pages' },
|
|
16
|
+
fields: [{ name: 'title', type: 'text' }],
|
|
17
|
+
};
|
|
18
|
+
describe('live-path query resolution (MySQL)', () => {
|
|
19
|
+
let testDb;
|
|
20
|
+
let collectionId;
|
|
21
|
+
beforeAll(async () => {
|
|
22
|
+
testDb = setupTestDB([PagesCollectionConfig]);
|
|
23
|
+
const [collection] = await testDb.commandBuilders.collections.create(PagesCollectionConfig.path, PagesCollectionConfig);
|
|
24
|
+
if (collection == null)
|
|
25
|
+
throw new Error('failed to create live-path test collection');
|
|
26
|
+
collectionId = collection.id;
|
|
27
|
+
});
|
|
28
|
+
afterAll(async () => {
|
|
29
|
+
await testDb.commandBuilders.collections.delete(collectionId);
|
|
30
|
+
await teardownTestDB();
|
|
31
|
+
});
|
|
32
|
+
it('skips a deleted requested-locale row and projects it only for known-document history', async () => {
|
|
33
|
+
const path = `shared-${timestamp}`;
|
|
34
|
+
const deleted = await testDb.commandBuilders.documents.createDocumentVersion({
|
|
35
|
+
collectionId,
|
|
36
|
+
collectionVersion: 1,
|
|
37
|
+
collectionConfig: PagesCollectionConfig,
|
|
38
|
+
action: 'create',
|
|
39
|
+
documentData: { title: 'Former French occupant' },
|
|
40
|
+
path: `former-source-${timestamp}`,
|
|
41
|
+
locale: 'all',
|
|
42
|
+
status: 'draft',
|
|
43
|
+
});
|
|
44
|
+
const deletedDocumentId = deleted.document.document_id;
|
|
45
|
+
await testDb.commandBuilders.documents.updateDocumentPath({
|
|
46
|
+
documentId: deletedDocumentId,
|
|
47
|
+
collectionId,
|
|
48
|
+
locale: 'fr',
|
|
49
|
+
path,
|
|
50
|
+
});
|
|
51
|
+
await testDb.commandBuilders.documents.createDocumentVersion({
|
|
52
|
+
documentId: deletedDocumentId,
|
|
53
|
+
collectionId,
|
|
54
|
+
collectionVersion: 1,
|
|
55
|
+
collectionConfig: PagesCollectionConfig,
|
|
56
|
+
action: 'update',
|
|
57
|
+
documentData: { title: 'Former French occupant, revised' },
|
|
58
|
+
locale: 'all',
|
|
59
|
+
status: 'draft',
|
|
60
|
+
previousVersionId: deleted.document.id,
|
|
61
|
+
});
|
|
62
|
+
await testDb.commandBuilders.documents.softDeleteDocument({
|
|
63
|
+
document_id: deletedDocumentId,
|
|
64
|
+
});
|
|
65
|
+
const pathRows = await testDb.db
|
|
66
|
+
.select({
|
|
67
|
+
deletedAt: documentPaths.deleted_at,
|
|
68
|
+
updatedAt: documentPaths.updated_at,
|
|
69
|
+
})
|
|
70
|
+
.from(documentPaths)
|
|
71
|
+
.where(eq(documentPaths.document_id, deletedDocumentId));
|
|
72
|
+
expect(pathRows).toHaveLength(2);
|
|
73
|
+
const deletedAt = pathRows[0]?.deletedAt;
|
|
74
|
+
expect(deletedAt).toBeInstanceOf(Date);
|
|
75
|
+
expect(pathRows.every((row) => row.deletedAt?.getTime() === deletedAt?.getTime())).toBe(true);
|
|
76
|
+
expect(pathRows.every((row) => row.updatedAt.getTime() === deletedAt?.getTime())).toBe(true);
|
|
77
|
+
const versionRows = await testDb.db
|
|
78
|
+
.select({
|
|
79
|
+
isDeleted: documentVersions.is_deleted,
|
|
80
|
+
updatedAt: documentVersions.updated_at,
|
|
81
|
+
})
|
|
82
|
+
.from(documentVersions)
|
|
83
|
+
.where(eq(documentVersions.document_id, deletedDocumentId));
|
|
84
|
+
expect(versionRows).toHaveLength(2);
|
|
85
|
+
expect(versionRows.every((row) => row.isDeleted)).toBe(true);
|
|
86
|
+
expect(versionRows.every((row) => row.updatedAt.getTime() === deletedAt?.getTime())).toBe(true);
|
|
87
|
+
const live = await testDb.commandBuilders.documents.createDocumentVersion({
|
|
88
|
+
collectionId,
|
|
89
|
+
collectionVersion: 1,
|
|
90
|
+
collectionConfig: PagesCollectionConfig,
|
|
91
|
+
action: 'create',
|
|
92
|
+
documentData: { title: 'Live English fallback' },
|
|
93
|
+
path,
|
|
94
|
+
locale: 'all',
|
|
95
|
+
status: 'draft',
|
|
96
|
+
});
|
|
97
|
+
const found = await testDb.queryBuilders.documents.getDocumentByPath({
|
|
98
|
+
collection_id: collectionId,
|
|
99
|
+
path,
|
|
100
|
+
locale: 'fr',
|
|
101
|
+
reconstruct: false,
|
|
102
|
+
});
|
|
103
|
+
expect(found?.document_id).toBe(live.document.document_id);
|
|
104
|
+
expect(found?.path).toBe(path);
|
|
105
|
+
expect(found).not.toHaveProperty('deleted_at');
|
|
106
|
+
expect(found).not.toHaveProperty('alive');
|
|
107
|
+
const history = await testDb.queryBuilders.documents.getDocumentHistory({
|
|
108
|
+
collection_id: collectionId,
|
|
109
|
+
document_id: deletedDocumentId,
|
|
110
|
+
locale: 'fr',
|
|
111
|
+
});
|
|
112
|
+
expect(history.documents).toHaveLength(2);
|
|
113
|
+
expect(history.documents[0]?.path).toBe(path);
|
|
114
|
+
});
|
|
115
|
+
it('restores every tombstone atomically and leaves a legacy partial state untouched', async () => {
|
|
116
|
+
const originalPath = `restore-source-${timestamp}`;
|
|
117
|
+
const frenchPath = `restore-fr-${timestamp}`;
|
|
118
|
+
const first = await testDb.commandBuilders.documents.createDocumentVersion({
|
|
119
|
+
collectionId,
|
|
120
|
+
collectionVersion: 1,
|
|
121
|
+
collectionConfig: PagesCollectionConfig,
|
|
122
|
+
action: 'create',
|
|
123
|
+
documentData: { title: 'Restored first version' },
|
|
124
|
+
path: originalPath,
|
|
125
|
+
locale: 'all',
|
|
126
|
+
status: 'draft',
|
|
127
|
+
});
|
|
128
|
+
const documentId = first.document.document_id;
|
|
129
|
+
await testDb.commandBuilders.documents.updateDocumentPath({
|
|
130
|
+
documentId,
|
|
131
|
+
collectionId,
|
|
132
|
+
locale: 'fr',
|
|
133
|
+
path: frenchPath,
|
|
134
|
+
});
|
|
135
|
+
const second = await testDb.commandBuilders.documents.createDocumentVersion({
|
|
136
|
+
documentId,
|
|
137
|
+
collectionId,
|
|
138
|
+
collectionVersion: 1,
|
|
139
|
+
collectionConfig: PagesCollectionConfig,
|
|
140
|
+
action: 'update',
|
|
141
|
+
documentData: { title: 'Restored second version' },
|
|
142
|
+
locale: 'all',
|
|
143
|
+
status: 'published',
|
|
144
|
+
previousVersionId: first.document.id,
|
|
145
|
+
});
|
|
146
|
+
await testDb.commandBuilders.documents.softDeleteDocument({ document_id: documentId });
|
|
147
|
+
await expect(testDb.commandBuilders.documents.restoreSoftDeletedDocument({ document_id: documentId })).resolves.toBe(2);
|
|
148
|
+
const restoredPaths = await testDb.db
|
|
149
|
+
.select({
|
|
150
|
+
path: documentPaths.path,
|
|
151
|
+
deletedAt: documentPaths.deleted_at,
|
|
152
|
+
updatedAt: documentPaths.updated_at,
|
|
153
|
+
})
|
|
154
|
+
.from(documentPaths)
|
|
155
|
+
.where(eq(documentPaths.document_id, documentId));
|
|
156
|
+
expect(restoredPaths).toHaveLength(2);
|
|
157
|
+
expect(restoredPaths.map((row) => row.path).sort()).toEqual([frenchPath, originalPath].sort());
|
|
158
|
+
expect(restoredPaths.every((row) => row.deletedAt == null)).toBe(true);
|
|
159
|
+
const restoredAt = restoredPaths[0]?.updatedAt;
|
|
160
|
+
expect(restoredPaths.every((row) => row.updatedAt.getTime() === restoredAt?.getTime())).toBe(true);
|
|
161
|
+
const restoredVersions = await testDb.db
|
|
162
|
+
.select({
|
|
163
|
+
id: documentVersions.id,
|
|
164
|
+
status: documentVersions.status,
|
|
165
|
+
isDeleted: documentVersions.is_deleted,
|
|
166
|
+
updatedAt: documentVersions.updated_at,
|
|
167
|
+
})
|
|
168
|
+
.from(documentVersions)
|
|
169
|
+
.where(eq(documentVersions.document_id, documentId));
|
|
170
|
+
expect(restoredVersions).toHaveLength(2);
|
|
171
|
+
expect(restoredVersions.map((row) => row.status).sort()).toEqual(['draft', 'published']);
|
|
172
|
+
expect(restoredVersions.every((row) => !row.isDeleted)).toBe(true);
|
|
173
|
+
expect(restoredVersions.every((row) => row.updatedAt.getTime() === restoredAt?.getTime())).toBe(true);
|
|
174
|
+
await testDb.commandBuilders.documents.softDeleteDocument({ document_id: documentId });
|
|
175
|
+
await testDb.db
|
|
176
|
+
.update(documentVersions)
|
|
177
|
+
.set({ is_deleted: false })
|
|
178
|
+
.where(eq(documentVersions.id, second.document.id));
|
|
179
|
+
await testDb.db
|
|
180
|
+
.update(documentPaths)
|
|
181
|
+
.set({ deleted_at: null })
|
|
182
|
+
.where(eq(documentPaths.document_id, documentId));
|
|
183
|
+
await expect(testDb.commandBuilders.documents.restoreSoftDeletedDocument({ document_id: documentId })).resolves.toBe(0);
|
|
184
|
+
const legacyPaths = await testDb.db
|
|
185
|
+
.select({ deletedAt: documentPaths.deleted_at })
|
|
186
|
+
.from(documentPaths)
|
|
187
|
+
.where(eq(documentPaths.document_id, documentId));
|
|
188
|
+
expect(legacyPaths.every((row) => row.deletedAt == null)).toBe(true);
|
|
189
|
+
const legacyVersions = await testDb.db
|
|
190
|
+
.select({ isDeleted: documentVersions.is_deleted })
|
|
191
|
+
.from(documentVersions)
|
|
192
|
+
.where(eq(documentVersions.document_id, documentId));
|
|
193
|
+
expect(legacyVersions.map((row) => row.isDeleted).sort()).toEqual([false, true]);
|
|
194
|
+
});
|
|
195
|
+
it('allows an existing versionless document to receive its first version', async () => {
|
|
196
|
+
const documentId = crypto.randomUUID();
|
|
197
|
+
const path = `versionless-${timestamp}`;
|
|
198
|
+
await testDb.db.insert(documents).values({
|
|
199
|
+
id: documentId,
|
|
200
|
+
collection_id: collectionId,
|
|
201
|
+
source_locale: 'en',
|
|
202
|
+
});
|
|
203
|
+
const created = await testDb.commandBuilders.documents.createDocumentVersion({
|
|
204
|
+
documentId,
|
|
205
|
+
collectionId,
|
|
206
|
+
collectionVersion: 1,
|
|
207
|
+
collectionConfig: PagesCollectionConfig,
|
|
208
|
+
action: 'create',
|
|
209
|
+
documentData: { title: 'Versionless bootstrap' },
|
|
210
|
+
path,
|
|
211
|
+
locale: 'all',
|
|
212
|
+
status: 'draft',
|
|
213
|
+
});
|
|
214
|
+
expect(created.document.document_id).toBe(documentId);
|
|
215
|
+
const found = await testDb.queryBuilders.documents.getDocumentByPath({
|
|
216
|
+
collection_id: collectionId,
|
|
217
|
+
path,
|
|
218
|
+
reconstruct: false,
|
|
219
|
+
});
|
|
220
|
+
expect(found?.document_id).toBe(documentId);
|
|
221
|
+
});
|
|
222
|
+
});
|
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": "4.
|
|
5
|
+
"version": "4.11.1",
|
|
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.23.1",
|
|
52
52
|
"uuid": "^14.0.1",
|
|
53
|
-
"@byline/admin": "4.
|
|
54
|
-
"@byline/core": "4.
|
|
53
|
+
"@byline/admin": "4.11.1",
|
|
54
|
+
"@byline/core": "4.11.1"
|
|
55
55
|
},
|
|
56
56
|
"devDependencies": {
|
|
57
57
|
"@biomejs/biome": "2.5.4",
|
|
@@ -65,9 +65,9 @@
|
|
|
65
65
|
"tsx": "^4.23.1",
|
|
66
66
|
"typescript": "^7.0.2",
|
|
67
67
|
"vitest": "^4.1.10",
|
|
68
|
-
"@byline/
|
|
69
|
-
"@byline/
|
|
70
|
-
"@byline/
|
|
68
|
+
"@byline/auth": "4.11.1",
|
|
69
|
+
"@byline/client": "4.11.1",
|
|
70
|
+
"@byline/db-conformance": "0.0.8"
|
|
71
71
|
},
|
|
72
72
|
"publishConfig": {
|
|
73
73
|
"access": "public",
|