@byline/db-mysql 4.10.1 → 4.11.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.
@@ -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. Column order matches the
157
- // resolution lookup pattern: WHERE collection_id = ? AND locale = ? AND path = ?.
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` per-collection path-uniqueness index
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` per-collection path-uniqueness index
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('byline_document_paths carries a unique key literally named idx_document_paths_collection_locale_path', () => {
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(['collection_id', 'locale', 'path']);
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
- * Mark ALL versions of a document as deleted by setting `is_deleted = true`.
366
- * The `current_documents` view filters these out, so the document disappears
367
- * from listings without physically removing data.
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
- * Mark ALL versions of a document as deleted by setting `is_deleted = true`.
742
- * The `current_documents` view filters these out, so the document disappears
743
- * from listings without physically removing data.
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: new Date(),
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.10.1",
5
+ "version": "4.11.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.23.1",
52
52
  "uuid": "^14.0.1",
53
- "@byline/core": "4.10.1",
54
- "@byline/admin": "4.10.1"
53
+ "@byline/admin": "4.11.0",
54
+ "@byline/core": "4.11.0"
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/client": "4.10.1",
69
- "@byline/auth": "4.10.1",
70
- "@byline/db-conformance": "0.0.5"
68
+ "@byline/auth": "4.11.0",
69
+ "@byline/client": "4.11.0",
70
+ "@byline/db-conformance": "0.0.7"
71
71
  },
72
72
  "publishConfig": {
73
73
  "access": "public",