@byline/db-postgres 3.12.1 → 3.13.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/common.d.ts +0 -1
- package/dist/database/schema/common.js +0 -1
- package/dist/database/schema/index.d.ts +47 -9
- package/dist/database/schema/index.js +41 -23
- package/dist/modules/storage/storage-commands.d.ts +24 -0
- package/dist/modules/storage/storage-commands.js +144 -2
- package/dist/modules/storage/storage-queries.d.ts +71 -0
- package/dist/modules/storage/storage-queries.js +132 -2
- package/dist/modules/storage/tests/storage-document-tree.test.d.ts +8 -0
- package/dist/modules/storage/tests/storage-document-tree.test.js +434 -0
- package/package.json +4 -4
|
@@ -24,7 +24,6 @@ export declare const timestamps: {
|
|
|
24
24
|
/**
|
|
25
25
|
* `created_at` only — for tables whose rows are immutable once
|
|
26
26
|
* inserted (junction tables like `byline_admin_role_admin_user`,
|
|
27
|
-
* append-only relationship rows like `byline_document_relationships`,
|
|
28
27
|
* registry rows like `byline_counter_groups`).
|
|
29
28
|
*
|
|
30
29
|
* Spread into a `pgTable` definition the same way as `timestamps`.
|
|
@@ -35,7 +35,6 @@ export const timestamps = {
|
|
|
35
35
|
/**
|
|
36
36
|
* `created_at` only — for tables whose rows are immutable once
|
|
37
37
|
* inserted (junction tables like `byline_admin_role_admin_user`,
|
|
38
|
-
* append-only relationship rows like `byline_document_relationships`,
|
|
39
38
|
* registry rows like `byline_counter_groups`).
|
|
40
39
|
*
|
|
41
40
|
* Spread into a `pgTable` definition the same way as `timestamps`.
|
|
@@ -768,8 +768,25 @@ export declare const documentRelationships: import("drizzle-orm/pg-core").PgTabl
|
|
|
768
768
|
identity: undefined;
|
|
769
769
|
generated: undefined;
|
|
770
770
|
}, {}, {}>;
|
|
771
|
-
|
|
772
|
-
name:
|
|
771
|
+
updated_at: import("drizzle-orm/pg-core").PgColumn<{
|
|
772
|
+
name: string;
|
|
773
|
+
tableName: "byline_document_relationships";
|
|
774
|
+
dataType: "date";
|
|
775
|
+
columnType: "PgTimestamp";
|
|
776
|
+
data: Date;
|
|
777
|
+
driverParam: string;
|
|
778
|
+
notNull: true;
|
|
779
|
+
hasDefault: true;
|
|
780
|
+
isPrimaryKey: false;
|
|
781
|
+
isAutoincrement: false;
|
|
782
|
+
hasRuntimeDefault: false;
|
|
783
|
+
enumValues: undefined;
|
|
784
|
+
baseColumn: never;
|
|
785
|
+
identity: undefined;
|
|
786
|
+
generated: undefined;
|
|
787
|
+
}, {}, {}>;
|
|
788
|
+
child_document_id: import("drizzle-orm/pg-core").PgColumn<{
|
|
789
|
+
name: "child_document_id";
|
|
773
790
|
tableName: "byline_document_relationships";
|
|
774
791
|
dataType: "string";
|
|
775
792
|
columnType: "PgUUID";
|
|
@@ -785,14 +802,14 @@ export declare const documentRelationships: import("drizzle-orm/pg-core").PgTabl
|
|
|
785
802
|
identity: undefined;
|
|
786
803
|
generated: undefined;
|
|
787
804
|
}, {}, {}>;
|
|
788
|
-
|
|
789
|
-
name: "
|
|
805
|
+
parent_document_id: import("drizzle-orm/pg-core").PgColumn<{
|
|
806
|
+
name: "parent_document_id";
|
|
790
807
|
tableName: "byline_document_relationships";
|
|
791
808
|
dataType: "string";
|
|
792
809
|
columnType: "PgUUID";
|
|
793
810
|
data: string;
|
|
794
811
|
driverParam: string;
|
|
795
|
-
notNull:
|
|
812
|
+
notNull: false;
|
|
796
813
|
hasDefault: false;
|
|
797
814
|
isPrimaryKey: false;
|
|
798
815
|
isAutoincrement: false;
|
|
@@ -802,6 +819,25 @@ export declare const documentRelationships: import("drizzle-orm/pg-core").PgTabl
|
|
|
802
819
|
identity: undefined;
|
|
803
820
|
generated: undefined;
|
|
804
821
|
}, {}, {}>;
|
|
822
|
+
order_key: import("drizzle-orm/pg-core").PgColumn<{
|
|
823
|
+
name: "order_key";
|
|
824
|
+
tableName: "byline_document_relationships";
|
|
825
|
+
dataType: "custom";
|
|
826
|
+
columnType: "PgCustomColumn";
|
|
827
|
+
data: string;
|
|
828
|
+
driverParam: string;
|
|
829
|
+
notNull: true;
|
|
830
|
+
hasDefault: false;
|
|
831
|
+
isPrimaryKey: false;
|
|
832
|
+
isAutoincrement: false;
|
|
833
|
+
hasRuntimeDefault: false;
|
|
834
|
+
enumValues: undefined;
|
|
835
|
+
baseColumn: never;
|
|
836
|
+
identity: undefined;
|
|
837
|
+
generated: undefined;
|
|
838
|
+
}, {}, {
|
|
839
|
+
pgColumnBuilderBrand: "PgCustomColumnBuilderBrand";
|
|
840
|
+
}>;
|
|
805
841
|
};
|
|
806
842
|
dialect: "pg";
|
|
807
843
|
}>;
|
|
@@ -3268,8 +3304,6 @@ export declare const collectionsRelations: import("drizzle-orm").Relations<"byli
|
|
|
3268
3304
|
}>;
|
|
3269
3305
|
export declare const documentsRelations: import("drizzle-orm").Relations<"byline_document_versions", {
|
|
3270
3306
|
collection: import("drizzle-orm").One<"byline_collections", true>;
|
|
3271
|
-
parent_relationships: import("drizzle-orm").Many<"byline_document_relationships">;
|
|
3272
|
-
child_relationships: import("drizzle-orm").Many<"byline_document_relationships">;
|
|
3273
3307
|
text_values: import("drizzle-orm").Many<"byline_store_text">;
|
|
3274
3308
|
numeric_values: import("drizzle-orm").Many<"byline_store_numeric">;
|
|
3275
3309
|
boolean_values: import("drizzle-orm").Many<"byline_store_boolean">;
|
|
@@ -3279,8 +3313,12 @@ export declare const documentsRelations: import("drizzle-orm").Relations<"byline
|
|
|
3279
3313
|
json_values: import("drizzle-orm").Many<"byline_store_json">;
|
|
3280
3314
|
}>;
|
|
3281
3315
|
export declare const documentRelationshipsRelations: import("drizzle-orm").Relations<"byline_document_relationships", {
|
|
3282
|
-
parent: import("drizzle-orm").One<"
|
|
3283
|
-
child: import("drizzle-orm").One<"
|
|
3316
|
+
parent: import("drizzle-orm").One<"byline_documents", false>;
|
|
3317
|
+
child: import("drizzle-orm").One<"byline_documents", true>;
|
|
3318
|
+
}>;
|
|
3319
|
+
export declare const documentTreeRelations: import("drizzle-orm").Relations<"byline_documents", {
|
|
3320
|
+
tree_parent_edge: import("drizzle-orm").Many<"byline_document_relationships">;
|
|
3321
|
+
tree_child_edges: import("drizzle-orm").Many<"byline_document_relationships">;
|
|
3284
3322
|
}>;
|
|
3285
3323
|
export declare const textStoreRelations: import("drizzle-orm").Relations<"byline_store_text", {
|
|
3286
3324
|
document: import("drizzle-orm").One<"byline_document_versions", true>;
|
|
@@ -179,23 +179,35 @@ export const documentVersionLocales = pgTable('byline_document_version_locales',
|
|
|
179
179
|
.references(() => documentVersions.id, { onDelete: 'cascade' }),
|
|
180
180
|
locale: varchar('locale', { length: 10 }).notNull(),
|
|
181
181
|
}, (table) => [primaryKey({ columns: [table.document_version_id, table.locale] })]);
|
|
182
|
-
// Document
|
|
182
|
+
// Document Tree — single-parent ordered adjacency. See docs/DOCUMENT-TREE.md.
|
|
183
|
+
//
|
|
184
|
+
// A document-grain, unversioned hierarchy primitive for `tree: true`
|
|
185
|
+
// collections (self-referential, single collection). Rows reference the logical
|
|
186
|
+
// `document_id`, not the version `id`. Unlike most edge tables the FKs are
|
|
187
|
+
// load-bearing here:
|
|
188
|
+
// - child → cascade : when the document is deleted, its membership row
|
|
189
|
+
// disappears (the node leaves the tree).
|
|
190
|
+
// - parent → set null: when a parent document is deleted, its children's
|
|
191
|
+
// parent pointer clears — they promote to root.
|
|
183
192
|
export const documentRelationships = pgTable('byline_document_relationships', {
|
|
184
|
-
// Note: These reference the logical `document_id`, not the version `id`.
|
|
185
|
-
// Foreign key constraints are not used; integrity is handled at the application layer.
|
|
186
|
-
parent_document_id: uuid('parent_document_id')
|
|
187
|
-
.notNull()
|
|
188
|
-
.references(() => documents.id, { onDelete: 'cascade' }),
|
|
189
193
|
child_document_id: uuid('child_document_id')
|
|
190
194
|
.notNull()
|
|
191
195
|
.references(() => documents.id, { onDelete: 'cascade' }),
|
|
192
|
-
|
|
196
|
+
// Nullable = root node. `set null` promotes orphans to root on parent delete.
|
|
197
|
+
parent_document_id: uuid('parent_document_id').references(() => documents.id, {
|
|
198
|
+
onDelete: 'set null',
|
|
199
|
+
}),
|
|
200
|
+
// Per-parent sibling order (each parent is its own keyspace). COLLATE "C"
|
|
201
|
+
// so DB ordering matches the JS fractional-index algorithm, exactly like
|
|
202
|
+
// `byline_documents.order_key`.
|
|
203
|
+
order_key: varcharByteSorted('order_key', { length: 128 }).notNull(),
|
|
204
|
+
...timestamps,
|
|
193
205
|
}, (table) => [
|
|
194
|
-
//
|
|
195
|
-
unique().on(table.
|
|
196
|
-
//
|
|
197
|
-
|
|
198
|
-
index('
|
|
206
|
+
// Single-parent invariant: each document appears in at most one row.
|
|
207
|
+
unique('uq_document_relationships_child').on(table.child_document_id),
|
|
208
|
+
// Per-parent sibling read, in order — drives the authoring tree and the
|
|
209
|
+
// read-side flatten.
|
|
210
|
+
index('idx_document_relationships_parent_order').on(table.parent_document_id, table.order_key),
|
|
199
211
|
]);
|
|
200
212
|
// Current Documents View — latest version of each logical document via
|
|
201
213
|
// `ROW_NUMBER() OVER (PARTITION BY document_id ORDER BY id DESC)`.
|
|
@@ -522,11 +534,6 @@ export const documentsRelations = relations(documentVersions, ({ one, many }) =>
|
|
|
522
534
|
fields: [documentVersions.collection_id],
|
|
523
535
|
references: [collections.id],
|
|
524
536
|
}),
|
|
525
|
-
// Relations for parent/child documents
|
|
526
|
-
// A document can be a child in many relationships. This finds the links.
|
|
527
|
-
parent_relationships: many(documentRelationships, { relationName: 'child' }),
|
|
528
|
-
// A document can be a parent in many relationships. This finds the links.
|
|
529
|
-
child_relationships: many(documentRelationships, { relationName: 'parent' }),
|
|
530
537
|
// Relations for field values
|
|
531
538
|
text_values: many(textStore),
|
|
532
539
|
numeric_values: many(numericStore),
|
|
@@ -537,17 +544,28 @@ export const documentsRelations = relations(documentVersions, ({ one, many }) =>
|
|
|
537
544
|
json_values: many(jsonStore),
|
|
538
545
|
}));
|
|
539
546
|
export const documentRelationshipsRelations = relations(documentRelationships, ({ one }) => ({
|
|
540
|
-
parent: one(
|
|
547
|
+
parent: one(documents, {
|
|
541
548
|
fields: [documentRelationships.parent_document_id],
|
|
542
|
-
references: [
|
|
543
|
-
relationName: '
|
|
549
|
+
references: [documents.id],
|
|
550
|
+
relationName: 'tree_parent',
|
|
544
551
|
}),
|
|
545
|
-
child: one(
|
|
552
|
+
child: one(documents, {
|
|
546
553
|
fields: [documentRelationships.child_document_id],
|
|
547
|
-
references: [
|
|
548
|
-
relationName: '
|
|
554
|
+
references: [documents.id],
|
|
555
|
+
relationName: 'tree_child',
|
|
549
556
|
}),
|
|
550
557
|
}));
|
|
558
|
+
// Document-tree edges on the logical document. The tree read path itself is a
|
|
559
|
+
// recursive CTE (see docs/DOCUMENT-TREE.md), not the Drizzle query builder —
|
|
560
|
+
// these relations exist for completeness / ad-hoc joins.
|
|
561
|
+
export const documentTreeRelations = relations(documents, ({ many }) => ({
|
|
562
|
+
// The membership edge where this document is the child — its placement in the
|
|
563
|
+
// tree. 0..1 by the unique(child_document_id) constraint; modelled as `many`
|
|
564
|
+
// per Drizzle's inverse-side convention (the FK lives on the edge table).
|
|
565
|
+
tree_parent_edge: many(documentRelationships, { relationName: 'tree_child' }),
|
|
566
|
+
// Edges where this document is the parent — its ordered children.
|
|
567
|
+
tree_child_edges: many(documentRelationships, { relationName: 'tree_parent' }),
|
|
568
|
+
}));
|
|
551
569
|
// Field value relations
|
|
552
570
|
export const textStoreRelations = relations(textStore, ({ one }) => ({
|
|
553
571
|
document: one(documentVersions, {
|
|
@@ -390,6 +390,30 @@ export declare class DocumentCommands implements IDocumentCommands {
|
|
|
390
390
|
document_id: string;
|
|
391
391
|
order_key: string;
|
|
392
392
|
}): Promise<void>;
|
|
393
|
+
/**
|
|
394
|
+
* placeTreeNode — see {@link IDocumentCommands.placeTreeNode}.
|
|
395
|
+
*
|
|
396
|
+
* Single transaction: same-collection guard → cycle guard → resolve the
|
|
397
|
+
* target sibling group's neighbour keys → mint a fractional key → upsert the
|
|
398
|
+
* edge row (conflict on `child_document_id`, the single-parent invariant).
|
|
399
|
+
* Unversioned; touches only `byline_document_relationships`.
|
|
400
|
+
*/
|
|
401
|
+
placeTreeNode(params: {
|
|
402
|
+
collectionId: string;
|
|
403
|
+
documentId: string;
|
|
404
|
+
parentDocumentId: string | null;
|
|
405
|
+
beforeDocumentId?: string | null;
|
|
406
|
+
afterDocumentId?: string | null;
|
|
407
|
+
}): Promise<{
|
|
408
|
+
orderKey: string;
|
|
409
|
+
}>;
|
|
410
|
+
/**
|
|
411
|
+
* removeFromTree — see {@link IDocumentCommands.removeFromTree}.
|
|
412
|
+
* Single-row delete; no-op when the node is already unplaced.
|
|
413
|
+
*/
|
|
414
|
+
removeFromTree(params: {
|
|
415
|
+
documentId: string;
|
|
416
|
+
}): Promise<void>;
|
|
393
417
|
}
|
|
394
418
|
export declare function createCommandBuilders(dbManager: DBManager, defaultContentLocale: string): {
|
|
395
419
|
collections: CollectionCommands;
|
|
@@ -5,12 +5,20 @@
|
|
|
5
5
|
*
|
|
6
6
|
* Copyright (c) Infonomic Company Limited
|
|
7
7
|
*/
|
|
8
|
-
import {
|
|
8
|
+
import { ERR_NOT_FOUND, ERR_VALIDATION, generateKeyBetween } from '@byline/core';
|
|
9
|
+
import { and, desc, eq, inArray, ne, sql } from 'drizzle-orm';
|
|
9
10
|
import { v7 as uuidv7 } from 'uuid';
|
|
10
|
-
import { booleanStore, collections, datetimeStore, documentAvailableLocales, documentPaths, documents, documentVersionLocales, documentVersions, fileStore, jsonStore, metaStore, numericStore, relationStore, textStore, } from '../../database/schema/index.js';
|
|
11
|
+
import { booleanStore, collections, datetimeStore, documentAvailableLocales, documentPaths, documentRelationships, documents, documentVersionLocales, documentVersions, fileStore, jsonStore, metaStore, numericStore, relationStore, textStore, } from '../../database/schema/index.js';
|
|
11
12
|
import { flattenFieldSetData } from './storage-flatten.js';
|
|
12
13
|
import { prepareFieldInsertBuckets } from './storage-insert.js';
|
|
13
14
|
import { getFirstOrThrow } from './storage-utils.js';
|
|
15
|
+
/**
|
|
16
|
+
* Depth backstop for the document-tree recursive walks (cycle guard, ancestor
|
|
17
|
+
* walk). The write-path cycle guard prevents true cycles, so this only bounds
|
|
18
|
+
* recursion against pre-existing pathological state — far deeper than any real
|
|
19
|
+
* documentation hierarchy. See docs/DOCUMENT-TREE.md.
|
|
20
|
+
*/
|
|
21
|
+
const TREE_MAX_DEPTH = 10_000;
|
|
14
22
|
/**
|
|
15
23
|
* CollectionCommands
|
|
16
24
|
*/
|
|
@@ -853,6 +861,140 @@ export class DocumentCommands {
|
|
|
853
861
|
})
|
|
854
862
|
.where(eq(documents.id, params.document_id));
|
|
855
863
|
}
|
|
864
|
+
/**
|
|
865
|
+
* placeTreeNode — see {@link IDocumentCommands.placeTreeNode}.
|
|
866
|
+
*
|
|
867
|
+
* Single transaction: same-collection guard → cycle guard → resolve the
|
|
868
|
+
* target sibling group's neighbour keys → mint a fractional key → upsert the
|
|
869
|
+
* edge row (conflict on `child_document_id`, the single-parent invariant).
|
|
870
|
+
* Unversioned; touches only `byline_document_relationships`.
|
|
871
|
+
*/
|
|
872
|
+
async placeTreeNode(params) {
|
|
873
|
+
const { collectionId, documentId, parentDocumentId } = params;
|
|
874
|
+
const beforeDocumentId = params.beforeDocumentId ?? null;
|
|
875
|
+
const afterDocumentId = params.afterDocumentId ?? null;
|
|
876
|
+
if (parentDocumentId === documentId) {
|
|
877
|
+
throw ERR_VALIDATION({
|
|
878
|
+
message: 'a document cannot be its own parent in the document tree',
|
|
879
|
+
details: { documentId },
|
|
880
|
+
});
|
|
881
|
+
}
|
|
882
|
+
return await this.db.transaction(async (tx) => {
|
|
883
|
+
// Same-collection guard — both endpoints must live in `collectionId`.
|
|
884
|
+
const ids = parentDocumentId ? [documentId, parentDocumentId] : [documentId];
|
|
885
|
+
const docRows = await tx
|
|
886
|
+
.select({ id: documents.id, collection_id: documents.collection_id })
|
|
887
|
+
.from(documents)
|
|
888
|
+
.where(inArray(documents.id, ids));
|
|
889
|
+
const collectionById = new Map(docRows.map((r) => [r.id, r.collection_id]));
|
|
890
|
+
if (collectionById.get(documentId) == null) {
|
|
891
|
+
throw ERR_NOT_FOUND({ message: 'document not found', details: { documentId } });
|
|
892
|
+
}
|
|
893
|
+
if (collectionById.get(documentId) !== collectionId) {
|
|
894
|
+
throw ERR_VALIDATION({
|
|
895
|
+
message: 'document does not belong to the collection',
|
|
896
|
+
details: { documentId, collectionId },
|
|
897
|
+
});
|
|
898
|
+
}
|
|
899
|
+
if (parentDocumentId != null) {
|
|
900
|
+
if (collectionById.get(parentDocumentId) == null) {
|
|
901
|
+
throw ERR_NOT_FOUND({
|
|
902
|
+
message: 'parent document not found',
|
|
903
|
+
details: { parentDocumentId },
|
|
904
|
+
});
|
|
905
|
+
}
|
|
906
|
+
if (collectionById.get(parentDocumentId) !== collectionId) {
|
|
907
|
+
throw ERR_VALIDATION({
|
|
908
|
+
message: 'parent document is in a different collection',
|
|
909
|
+
details: { parentDocumentId, collectionId },
|
|
910
|
+
});
|
|
911
|
+
}
|
|
912
|
+
// Cycle guard — reject when `documentId` is the new parent itself or
|
|
913
|
+
// any of its ancestors (which would put the node below its own
|
|
914
|
+
// subtree). Walk upward from `parentDocumentId`; depth-bounded.
|
|
915
|
+
const cycle = await tx.execute(sql `
|
|
916
|
+
WITH RECURSIVE chain AS (
|
|
917
|
+
SELECT ${parentDocumentId}::uuid AS node_id, 0 AS depth
|
|
918
|
+
UNION ALL
|
|
919
|
+
SELECT r.parent_document_id, c.depth + 1
|
|
920
|
+
FROM byline_document_relationships r
|
|
921
|
+
JOIN chain c ON r.child_document_id = c.node_id
|
|
922
|
+
WHERE r.parent_document_id IS NOT NULL AND c.depth < ${TREE_MAX_DEPTH}
|
|
923
|
+
)
|
|
924
|
+
SELECT 1 FROM chain WHERE node_id = ${documentId}::uuid LIMIT 1
|
|
925
|
+
`);
|
|
926
|
+
if (cycle.rows.length > 0) {
|
|
927
|
+
throw ERR_VALIDATION({
|
|
928
|
+
message: 'move would create a cycle in the document tree',
|
|
929
|
+
details: { documentId, parentDocumentId },
|
|
930
|
+
});
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
// Resolve the neighbour keys within the target sibling group and mint a
|
|
934
|
+
// fractional key between them. Per-parent keyspace, so collisions across
|
|
935
|
+
// sibling groups are structurally impossible.
|
|
936
|
+
const neighbourIds = [];
|
|
937
|
+
if (beforeDocumentId)
|
|
938
|
+
neighbourIds.push(beforeDocumentId);
|
|
939
|
+
if (afterDocumentId)
|
|
940
|
+
neighbourIds.push(afterDocumentId);
|
|
941
|
+
let left = null;
|
|
942
|
+
let right = null;
|
|
943
|
+
if (neighbourIds.length > 0) {
|
|
944
|
+
const edgeRows = await tx
|
|
945
|
+
.select({
|
|
946
|
+
child: documentRelationships.child_document_id,
|
|
947
|
+
order_key: documentRelationships.order_key,
|
|
948
|
+
})
|
|
949
|
+
.from(documentRelationships)
|
|
950
|
+
.where(inArray(documentRelationships.child_document_id, neighbourIds));
|
|
951
|
+
const keyByChild = new Map(edgeRows.map((r) => [r.child, r.order_key]));
|
|
952
|
+
left = beforeDocumentId ? (keyByChild.get(beforeDocumentId) ?? null) : null;
|
|
953
|
+
right = afterDocumentId ? (keyByChild.get(afterDocumentId) ?? null) : null;
|
|
954
|
+
}
|
|
955
|
+
let orderKey;
|
|
956
|
+
try {
|
|
957
|
+
orderKey = generateKeyBetween(left, right);
|
|
958
|
+
}
|
|
959
|
+
catch (err) {
|
|
960
|
+
throw ERR_VALIDATION({
|
|
961
|
+
message: 'cannot generate order_key between the supplied tree neighbours',
|
|
962
|
+
details: {
|
|
963
|
+
documentId,
|
|
964
|
+
parentDocumentId,
|
|
965
|
+
left,
|
|
966
|
+
right,
|
|
967
|
+
cause: err instanceof Error ? err.message : String(err),
|
|
968
|
+
},
|
|
969
|
+
});
|
|
970
|
+
}
|
|
971
|
+
await tx
|
|
972
|
+
.insert(documentRelationships)
|
|
973
|
+
.values({
|
|
974
|
+
child_document_id: documentId,
|
|
975
|
+
parent_document_id: parentDocumentId,
|
|
976
|
+
order_key: orderKey,
|
|
977
|
+
})
|
|
978
|
+
.onConflictDoUpdate({
|
|
979
|
+
target: documentRelationships.child_document_id,
|
|
980
|
+
set: {
|
|
981
|
+
parent_document_id: parentDocumentId,
|
|
982
|
+
order_key: orderKey,
|
|
983
|
+
updated_at: new Date(),
|
|
984
|
+
},
|
|
985
|
+
});
|
|
986
|
+
return { orderKey };
|
|
987
|
+
});
|
|
988
|
+
}
|
|
989
|
+
/**
|
|
990
|
+
* removeFromTree — see {@link IDocumentCommands.removeFromTree}.
|
|
991
|
+
* Single-row delete; no-op when the node is already unplaced.
|
|
992
|
+
*/
|
|
993
|
+
async removeFromTree(params) {
|
|
994
|
+
await this.db
|
|
995
|
+
.delete(documentRelationships)
|
|
996
|
+
.where(eq(documentRelationships.child_document_id, params.documentId));
|
|
997
|
+
}
|
|
856
998
|
}
|
|
857
999
|
export function createCommandBuilders(dbManager, defaultContentLocale) {
|
|
858
1000
|
return {
|
|
@@ -449,6 +449,77 @@ export declare class DocumentQueries implements IDocumentQueries {
|
|
|
449
449
|
id: string;
|
|
450
450
|
order_key: string | null;
|
|
451
451
|
}>>;
|
|
452
|
+
/**
|
|
453
|
+
* getTreeAncestors — see {@link IDocumentQueries.getTreeAncestors}.
|
|
454
|
+
*
|
|
455
|
+
* Recursive CTE walking `parent_document_id` upward from the given node.
|
|
456
|
+
* Returns ancestors root-first (`ORDER BY depth DESC`), each with a 1-based
|
|
457
|
+
* depth (1 = immediate parent). Empty for a root or unplaced node.
|
|
458
|
+
*
|
|
459
|
+
* Status-at-edge: in `published` mode each hop must resolve in
|
|
460
|
+
* `byline_current_published_documents`, so the walk stops at the first
|
|
461
|
+
* unpublished ancestor rather than skipping it (a truncated spine the splat
|
|
462
|
+
* handler turns into a 404). `any` mode walks the raw edges unchanged.
|
|
463
|
+
*/
|
|
464
|
+
getTreeAncestors({ document_id, maxDepth, readMode, }: {
|
|
465
|
+
document_id: string;
|
|
466
|
+
maxDepth?: number;
|
|
467
|
+
readMode?: ReadMode;
|
|
468
|
+
}): Promise<Array<{
|
|
469
|
+
document_id: string;
|
|
470
|
+
depth: number;
|
|
471
|
+
}>>;
|
|
472
|
+
/**
|
|
473
|
+
* getTreeChildren — see {@link IDocumentQueries.getTreeChildren}.
|
|
474
|
+
*
|
|
475
|
+
* Immediate children of a node ordered by the per-parent `order_key`.
|
|
476
|
+
* `parentDocumentId: null` returns the collection's root nodes; the join to
|
|
477
|
+
* `byline_documents` scopes roots to the collection (they have no parent to
|
|
478
|
+
* scope by).
|
|
479
|
+
*/
|
|
480
|
+
getTreeChildren({ collectionId, parentDocumentId, }: {
|
|
481
|
+
collectionId: string;
|
|
482
|
+
parentDocumentId: string | null;
|
|
483
|
+
}): Promise<Array<{
|
|
484
|
+
document_id: string;
|
|
485
|
+
order_key: string;
|
|
486
|
+
}>>;
|
|
487
|
+
/**
|
|
488
|
+
* getTreeParent — see {@link IDocumentQueries.getTreeParent}.
|
|
489
|
+
*
|
|
490
|
+
* Single indexed lookup on the edge table by `child_document_id` (unique).
|
|
491
|
+
* No row → *unplaced*; a row with a null parent → *root*; a row with a parent
|
|
492
|
+
* → *child*. Distinguishes the unplaced/root states that `getTreeAncestors`
|
|
493
|
+
* (which returns `[]` for both) conflates.
|
|
494
|
+
*/
|
|
495
|
+
getTreeParent({ document_id, }: {
|
|
496
|
+
document_id: string;
|
|
497
|
+
}): Promise<{
|
|
498
|
+
placed: boolean;
|
|
499
|
+
parentDocumentId: string | null;
|
|
500
|
+
}>;
|
|
501
|
+
/**
|
|
502
|
+
* getTreeSubtree — see {@link IDocumentQueries.getTreeSubtree}.
|
|
503
|
+
*
|
|
504
|
+
* Recursive CTE descending from the requested root (or the collection's
|
|
505
|
+
* roots when `rootDocumentId` is null). Each row carries a `/`-joined path of
|
|
506
|
+
* ancestor `order_key`s; ordering by that path under `COLLATE "C"` yields a
|
|
507
|
+
* pre-order depth-first walk (a parent's path is a prefix of its children's,
|
|
508
|
+
* and `/` (0x2F) sorts below every key character). Status-at-edge: every node
|
|
509
|
+
* — anchor included — must exist in the chosen current-documents view, so an
|
|
510
|
+
* unpublished node and its whole subtree drop out in `published` mode.
|
|
511
|
+
*/
|
|
512
|
+
getTreeSubtree({ collectionId, rootDocumentId, maxDepth, readMode, }: {
|
|
513
|
+
collectionId: string;
|
|
514
|
+
rootDocumentId?: string | null;
|
|
515
|
+
maxDepth?: number;
|
|
516
|
+
readMode?: ReadMode;
|
|
517
|
+
}): Promise<Array<{
|
|
518
|
+
document_id: string;
|
|
519
|
+
parent_document_id: string | null;
|
|
520
|
+
depth: number;
|
|
521
|
+
order_key: string;
|
|
522
|
+
}>>;
|
|
452
523
|
/**
|
|
453
524
|
* getDocumentCountsByStatus
|
|
454
525
|
*
|
|
@@ -10,8 +10,8 @@
|
|
|
10
10
|
// logger. A future refactor could inject the logger at construction time by
|
|
11
11
|
// either deferring adapter construction or accepting a lazy logger parameter.
|
|
12
12
|
import { ERR_DATABASE, ERR_NOT_FOUND, getLogger, orderByContentLocale } from '@byline/core';
|
|
13
|
-
import { and, desc, eq, inArray, isNotNull, sql } from 'drizzle-orm';
|
|
14
|
-
import { collections, currentDocumentsView, currentPublishedDocumentsView, documentAvailableLocales, documentPaths, documents, documentVersionLocales, documentVersions, metaStore, } from '../../database/schema/index.js';
|
|
13
|
+
import { and, desc, eq, inArray, isNotNull, isNull, sql } from 'drizzle-orm';
|
|
14
|
+
import { collections, currentDocumentsView, currentPublishedDocumentsView, documentAvailableLocales, documentPaths, documentRelationships, documents, documentVersionLocales, documentVersions, metaStore, } from '../../database/schema/index.js';
|
|
15
15
|
import { extractFlattenedFieldValue, restoreFieldSetData } from './storage-restore.js';
|
|
16
16
|
import { allStoreTypes, storeSelectList, storeTableNames, } from './storage-store-manifest.js';
|
|
17
17
|
import { resolveStoreTypes } from './storage-utils.js';
|
|
@@ -899,6 +899,136 @@ export class DocumentQueries {
|
|
|
899
899
|
.orderBy(sql `${documents.order_key} ASC NULLS LAST`, desc(documents.created_at));
|
|
900
900
|
return rows;
|
|
901
901
|
}
|
|
902
|
+
/**
|
|
903
|
+
* getTreeAncestors — see {@link IDocumentQueries.getTreeAncestors}.
|
|
904
|
+
*
|
|
905
|
+
* Recursive CTE walking `parent_document_id` upward from the given node.
|
|
906
|
+
* Returns ancestors root-first (`ORDER BY depth DESC`), each with a 1-based
|
|
907
|
+
* depth (1 = immediate parent). Empty for a root or unplaced node.
|
|
908
|
+
*
|
|
909
|
+
* Status-at-edge: in `published` mode each hop must resolve in
|
|
910
|
+
* `byline_current_published_documents`, so the walk stops at the first
|
|
911
|
+
* unpublished ancestor rather than skipping it (a truncated spine the splat
|
|
912
|
+
* handler turns into a 404). `any` mode walks the raw edges unchanged.
|
|
913
|
+
*/
|
|
914
|
+
async getTreeAncestors({ document_id, maxDepth = 10_000, readMode = 'any', }) {
|
|
915
|
+
// Published mode gates every hop on the ancestor having a current published
|
|
916
|
+
// version; `any` mode imposes no such gate (raw edge walk).
|
|
917
|
+
const publishedGate = (parentColumn) => readMode === 'published'
|
|
918
|
+
? sql `AND EXISTS (
|
|
919
|
+
SELECT 1 FROM byline_current_published_documents v
|
|
920
|
+
WHERE v.document_id = ${sql.raw(parentColumn)}
|
|
921
|
+
)`
|
|
922
|
+
: sql ``;
|
|
923
|
+
const { rows } = await this.db.execute(sql `
|
|
924
|
+
WITH RECURSIVE ancestors AS (
|
|
925
|
+
SELECT parent_document_id AS ancestor_id, child_document_id AS node_id, 1 AS depth
|
|
926
|
+
FROM byline_document_relationships
|
|
927
|
+
WHERE child_document_id = ${document_id}::uuid AND parent_document_id IS NOT NULL
|
|
928
|
+
${publishedGate('parent_document_id')}
|
|
929
|
+
UNION ALL
|
|
930
|
+
SELECT r.parent_document_id, r.child_document_id, a.depth + 1
|
|
931
|
+
FROM byline_document_relationships r
|
|
932
|
+
JOIN ancestors a ON r.child_document_id = a.ancestor_id
|
|
933
|
+
WHERE r.parent_document_id IS NOT NULL AND a.depth < ${maxDepth}
|
|
934
|
+
${publishedGate('r.parent_document_id')}
|
|
935
|
+
)
|
|
936
|
+
SELECT ancestor_id AS document_id, depth FROM ancestors ORDER BY depth DESC
|
|
937
|
+
`);
|
|
938
|
+
return rows.map((r) => ({
|
|
939
|
+
document_id: r.document_id,
|
|
940
|
+
depth: Number(r.depth),
|
|
941
|
+
}));
|
|
942
|
+
}
|
|
943
|
+
/**
|
|
944
|
+
* getTreeChildren — see {@link IDocumentQueries.getTreeChildren}.
|
|
945
|
+
*
|
|
946
|
+
* Immediate children of a node ordered by the per-parent `order_key`.
|
|
947
|
+
* `parentDocumentId: null` returns the collection's root nodes; the join to
|
|
948
|
+
* `byline_documents` scopes roots to the collection (they have no parent to
|
|
949
|
+
* scope by).
|
|
950
|
+
*/
|
|
951
|
+
async getTreeChildren({ collectionId, parentDocumentId, }) {
|
|
952
|
+
const rows = await this.db
|
|
953
|
+
.select({
|
|
954
|
+
document_id: documentRelationships.child_document_id,
|
|
955
|
+
order_key: documentRelationships.order_key,
|
|
956
|
+
})
|
|
957
|
+
.from(documentRelationships)
|
|
958
|
+
.innerJoin(documents, eq(documents.id, documentRelationships.child_document_id))
|
|
959
|
+
.where(and(eq(documents.collection_id, collectionId), parentDocumentId == null
|
|
960
|
+
? isNull(documentRelationships.parent_document_id)
|
|
961
|
+
: eq(documentRelationships.parent_document_id, parentDocumentId)))
|
|
962
|
+
.orderBy(documentRelationships.order_key);
|
|
963
|
+
return rows;
|
|
964
|
+
}
|
|
965
|
+
/**
|
|
966
|
+
* getTreeParent — see {@link IDocumentQueries.getTreeParent}.
|
|
967
|
+
*
|
|
968
|
+
* Single indexed lookup on the edge table by `child_document_id` (unique).
|
|
969
|
+
* No row → *unplaced*; a row with a null parent → *root*; a row with a parent
|
|
970
|
+
* → *child*. Distinguishes the unplaced/root states that `getTreeAncestors`
|
|
971
|
+
* (which returns `[]` for both) conflates.
|
|
972
|
+
*/
|
|
973
|
+
async getTreeParent({ document_id, }) {
|
|
974
|
+
const rows = await this.db
|
|
975
|
+
.select({ parent_document_id: documentRelationships.parent_document_id })
|
|
976
|
+
.from(documentRelationships)
|
|
977
|
+
.where(eq(documentRelationships.child_document_id, document_id))
|
|
978
|
+
.limit(1);
|
|
979
|
+
const row = rows[0];
|
|
980
|
+
if (row == null)
|
|
981
|
+
return { placed: false, parentDocumentId: null };
|
|
982
|
+
return { placed: true, parentDocumentId: row.parent_document_id ?? null };
|
|
983
|
+
}
|
|
984
|
+
/**
|
|
985
|
+
* getTreeSubtree — see {@link IDocumentQueries.getTreeSubtree}.
|
|
986
|
+
*
|
|
987
|
+
* Recursive CTE descending from the requested root (or the collection's
|
|
988
|
+
* roots when `rootDocumentId` is null). Each row carries a `/`-joined path of
|
|
989
|
+
* ancestor `order_key`s; ordering by that path under `COLLATE "C"` yields a
|
|
990
|
+
* pre-order depth-first walk (a parent's path is a prefix of its children's,
|
|
991
|
+
* and `/` (0x2F) sorts below every key character). Status-at-edge: every node
|
|
992
|
+
* — anchor included — must exist in the chosen current-documents view, so an
|
|
993
|
+
* unpublished node and its whole subtree drop out in `published` mode.
|
|
994
|
+
*/
|
|
995
|
+
async getTreeSubtree({ collectionId, rootDocumentId = null, maxDepth = 10_000, readMode = 'any', }) {
|
|
996
|
+
const statusView = readMode === 'published' ? 'byline_current_published_documents' : 'byline_current_documents';
|
|
997
|
+
const rootCondition = rootDocumentId == null
|
|
998
|
+
? sql `r.parent_document_id IS NULL`
|
|
999
|
+
: sql `r.child_document_id = ${rootDocumentId}::uuid`;
|
|
1000
|
+
const { rows } = await this.db.execute(sql `
|
|
1001
|
+
WITH RECURSIVE subtree AS (
|
|
1002
|
+
SELECT r.child_document_id, r.parent_document_id, r.order_key,
|
|
1003
|
+
0 AS depth, r.order_key::text AS path
|
|
1004
|
+
FROM byline_document_relationships r
|
|
1005
|
+
JOIN byline_documents d ON d.id = r.child_document_id
|
|
1006
|
+
WHERE d.collection_id = ${collectionId}::uuid
|
|
1007
|
+
AND ${rootCondition}
|
|
1008
|
+
AND EXISTS (
|
|
1009
|
+
SELECT 1 FROM ${sql.raw(statusView)} v WHERE v.document_id = r.child_document_id
|
|
1010
|
+
)
|
|
1011
|
+
UNION ALL
|
|
1012
|
+
SELECT r.child_document_id, r.parent_document_id, r.order_key,
|
|
1013
|
+
s.depth + 1, s.path || '/' || r.order_key
|
|
1014
|
+
FROM byline_document_relationships r
|
|
1015
|
+
JOIN subtree s ON r.parent_document_id = s.child_document_id
|
|
1016
|
+
WHERE s.depth + 1 <= ${maxDepth}
|
|
1017
|
+
AND EXISTS (
|
|
1018
|
+
SELECT 1 FROM ${sql.raw(statusView)} v WHERE v.document_id = r.child_document_id
|
|
1019
|
+
)
|
|
1020
|
+
)
|
|
1021
|
+
SELECT child_document_id AS document_id, parent_document_id, depth, order_key
|
|
1022
|
+
FROM subtree
|
|
1023
|
+
ORDER BY path COLLATE "C"
|
|
1024
|
+
`);
|
|
1025
|
+
return rows.map((r) => ({
|
|
1026
|
+
document_id: r.document_id,
|
|
1027
|
+
parent_document_id: r.parent_document_id ?? null,
|
|
1028
|
+
depth: Number(r.depth),
|
|
1029
|
+
order_key: r.order_key,
|
|
1030
|
+
}));
|
|
1031
|
+
}
|
|
902
1032
|
/**
|
|
903
1033
|
* getDocumentCountsByStatus
|
|
904
1034
|
*
|
|
@@ -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,434 @@
|
|
|
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 { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
|
9
|
+
import { setupTestDB, teardownTestDB } from '../../../lib/test-helper.js';
|
|
10
|
+
let commandBuilders;
|
|
11
|
+
let queryBuilders;
|
|
12
|
+
const timestamp = Date.now();
|
|
13
|
+
const TreeCollectionConfig = {
|
|
14
|
+
path: `tree-${timestamp}`,
|
|
15
|
+
labels: { singular: 'TreeTest', plural: 'TreeTests' },
|
|
16
|
+
useAsPath: 'title',
|
|
17
|
+
fields: [{ name: 'title', type: 'text' }],
|
|
18
|
+
};
|
|
19
|
+
const OtherCollectionConfig = {
|
|
20
|
+
path: `tree-other-${timestamp}`,
|
|
21
|
+
labels: { singular: 'TreeOther', plural: 'TreeOthers' },
|
|
22
|
+
useAsPath: 'title',
|
|
23
|
+
fields: [{ name: 'title', type: 'text' }],
|
|
24
|
+
};
|
|
25
|
+
let treeCollection = {};
|
|
26
|
+
let otherCollection = {};
|
|
27
|
+
async function createDoc(collectionId, config, title, status = 'published') {
|
|
28
|
+
const created = await commandBuilders.documents.createDocumentVersion({
|
|
29
|
+
collectionId,
|
|
30
|
+
collectionVersion: 1,
|
|
31
|
+
collectionConfig: config,
|
|
32
|
+
action: 'create',
|
|
33
|
+
documentData: { title },
|
|
34
|
+
path: `${title.toLowerCase().replace(/\s+/g, '-')}-${timestamp}`,
|
|
35
|
+
locale: 'all',
|
|
36
|
+
status,
|
|
37
|
+
});
|
|
38
|
+
return created.document.document_id;
|
|
39
|
+
}
|
|
40
|
+
function childIds(rows) {
|
|
41
|
+
return rows.map((r) => r.document_id);
|
|
42
|
+
}
|
|
43
|
+
describe('document-tree commands', () => {
|
|
44
|
+
beforeAll(async () => {
|
|
45
|
+
const testDB = setupTestDB([TreeCollectionConfig, OtherCollectionConfig]);
|
|
46
|
+
commandBuilders = testDB.commandBuilders;
|
|
47
|
+
queryBuilders = testDB.queryBuilders;
|
|
48
|
+
const tree = await commandBuilders.collections.create(TreeCollectionConfig.path, TreeCollectionConfig);
|
|
49
|
+
const other = await commandBuilders.collections.create(OtherCollectionConfig.path, OtherCollectionConfig);
|
|
50
|
+
if (tree[0] == null || other[0] == null)
|
|
51
|
+
throw new Error('Failed to create test collections');
|
|
52
|
+
treeCollection = { id: tree[0].id };
|
|
53
|
+
otherCollection = { id: other[0].id };
|
|
54
|
+
});
|
|
55
|
+
afterAll(async () => {
|
|
56
|
+
try {
|
|
57
|
+
await commandBuilders.collections.delete(treeCollection.id);
|
|
58
|
+
await commandBuilders.collections.delete(otherCollection.id);
|
|
59
|
+
}
|
|
60
|
+
catch (error) {
|
|
61
|
+
console.error('Failed to cleanup test collections:', error);
|
|
62
|
+
}
|
|
63
|
+
await teardownTestDB();
|
|
64
|
+
});
|
|
65
|
+
it('places roots and children, ordered per-parent', async () => {
|
|
66
|
+
const a = await createDoc(treeCollection.id, TreeCollectionConfig, 'Root A');
|
|
67
|
+
const b = await createDoc(treeCollection.id, TreeCollectionConfig, 'Root B');
|
|
68
|
+
const c = await createDoc(treeCollection.id, TreeCollectionConfig, 'Child C');
|
|
69
|
+
const d = await createDoc(treeCollection.id, TreeCollectionConfig, 'Child D');
|
|
70
|
+
// Two roots, A then B.
|
|
71
|
+
await commandBuilders.documents.placeTreeNode({
|
|
72
|
+
collectionId: treeCollection.id,
|
|
73
|
+
documentId: a,
|
|
74
|
+
parentDocumentId: null,
|
|
75
|
+
});
|
|
76
|
+
await commandBuilders.documents.placeTreeNode({
|
|
77
|
+
collectionId: treeCollection.id,
|
|
78
|
+
documentId: b,
|
|
79
|
+
parentDocumentId: null,
|
|
80
|
+
beforeDocumentId: a,
|
|
81
|
+
});
|
|
82
|
+
const roots = await queryBuilders.documents.getTreeChildren({
|
|
83
|
+
collectionId: treeCollection.id,
|
|
84
|
+
parentDocumentId: null,
|
|
85
|
+
});
|
|
86
|
+
expect(childIds(roots)).toEqual([a, b]);
|
|
87
|
+
// Two children of A, C then D.
|
|
88
|
+
await commandBuilders.documents.placeTreeNode({
|
|
89
|
+
collectionId: treeCollection.id,
|
|
90
|
+
documentId: c,
|
|
91
|
+
parentDocumentId: a,
|
|
92
|
+
});
|
|
93
|
+
await commandBuilders.documents.placeTreeNode({
|
|
94
|
+
collectionId: treeCollection.id,
|
|
95
|
+
documentId: d,
|
|
96
|
+
parentDocumentId: a,
|
|
97
|
+
beforeDocumentId: c,
|
|
98
|
+
});
|
|
99
|
+
const aChildren = await queryBuilders.documents.getTreeChildren({
|
|
100
|
+
collectionId: treeCollection.id,
|
|
101
|
+
parentDocumentId: a,
|
|
102
|
+
});
|
|
103
|
+
expect(childIds(aChildren)).toEqual([c, d]);
|
|
104
|
+
});
|
|
105
|
+
it('reorders siblings without changing parent or count', async () => {
|
|
106
|
+
const a = await createDoc(treeCollection.id, TreeCollectionConfig, 'P A');
|
|
107
|
+
const x = await createDoc(treeCollection.id, TreeCollectionConfig, 'X');
|
|
108
|
+
const y = await createDoc(treeCollection.id, TreeCollectionConfig, 'Y');
|
|
109
|
+
await commandBuilders.documents.placeTreeNode({
|
|
110
|
+
collectionId: treeCollection.id,
|
|
111
|
+
documentId: a,
|
|
112
|
+
parentDocumentId: null,
|
|
113
|
+
});
|
|
114
|
+
await commandBuilders.documents.placeTreeNode({
|
|
115
|
+
collectionId: treeCollection.id,
|
|
116
|
+
documentId: x,
|
|
117
|
+
parentDocumentId: a,
|
|
118
|
+
});
|
|
119
|
+
await commandBuilders.documents.placeTreeNode({
|
|
120
|
+
collectionId: treeCollection.id,
|
|
121
|
+
documentId: y,
|
|
122
|
+
parentDocumentId: a,
|
|
123
|
+
beforeDocumentId: x,
|
|
124
|
+
});
|
|
125
|
+
let kids = await queryBuilders.documents.getTreeChildren({
|
|
126
|
+
collectionId: treeCollection.id,
|
|
127
|
+
parentDocumentId: a,
|
|
128
|
+
});
|
|
129
|
+
expect(childIds(kids)).toEqual([x, y]);
|
|
130
|
+
// Move Y before X.
|
|
131
|
+
await commandBuilders.documents.placeTreeNode({
|
|
132
|
+
collectionId: treeCollection.id,
|
|
133
|
+
documentId: y,
|
|
134
|
+
parentDocumentId: a,
|
|
135
|
+
afterDocumentId: x,
|
|
136
|
+
});
|
|
137
|
+
kids = await queryBuilders.documents.getTreeChildren({
|
|
138
|
+
collectionId: treeCollection.id,
|
|
139
|
+
parentDocumentId: a,
|
|
140
|
+
});
|
|
141
|
+
expect(childIds(kids)).toEqual([y, x]);
|
|
142
|
+
expect(kids.length, 'no duplicate row from upsert').toBe(2);
|
|
143
|
+
});
|
|
144
|
+
it('re-parents atomically and updates ancestors', async () => {
|
|
145
|
+
const a = await createDoc(treeCollection.id, TreeCollectionConfig, 'RA');
|
|
146
|
+
const b = await createDoc(treeCollection.id, TreeCollectionConfig, 'RB');
|
|
147
|
+
const c = await createDoc(treeCollection.id, TreeCollectionConfig, 'RC');
|
|
148
|
+
await commandBuilders.documents.placeTreeNode({
|
|
149
|
+
collectionId: treeCollection.id,
|
|
150
|
+
documentId: a,
|
|
151
|
+
parentDocumentId: null,
|
|
152
|
+
});
|
|
153
|
+
await commandBuilders.documents.placeTreeNode({
|
|
154
|
+
collectionId: treeCollection.id,
|
|
155
|
+
documentId: b,
|
|
156
|
+
parentDocumentId: null,
|
|
157
|
+
});
|
|
158
|
+
await commandBuilders.documents.placeTreeNode({
|
|
159
|
+
collectionId: treeCollection.id,
|
|
160
|
+
documentId: c,
|
|
161
|
+
parentDocumentId: a,
|
|
162
|
+
});
|
|
163
|
+
expect((await queryBuilders.documents.getTreeAncestors({ document_id: c })).map((r) => r.document_id)).toEqual([a]);
|
|
164
|
+
// Move C under B.
|
|
165
|
+
await commandBuilders.documents.placeTreeNode({
|
|
166
|
+
collectionId: treeCollection.id,
|
|
167
|
+
documentId: c,
|
|
168
|
+
parentDocumentId: b,
|
|
169
|
+
});
|
|
170
|
+
const aKids = await queryBuilders.documents.getTreeChildren({
|
|
171
|
+
collectionId: treeCollection.id,
|
|
172
|
+
parentDocumentId: a,
|
|
173
|
+
});
|
|
174
|
+
const bKids = await queryBuilders.documents.getTreeChildren({
|
|
175
|
+
collectionId: treeCollection.id,
|
|
176
|
+
parentDocumentId: b,
|
|
177
|
+
});
|
|
178
|
+
expect(childIds(aKids)).not.toContain(c);
|
|
179
|
+
expect(childIds(bKids)).toEqual([c]);
|
|
180
|
+
expect((await queryBuilders.documents.getTreeAncestors({ document_id: c })).map((r) => r.document_id)).toEqual([b]);
|
|
181
|
+
});
|
|
182
|
+
it('walks ancestors root-first with increasing depth', async () => {
|
|
183
|
+
const a = await createDoc(treeCollection.id, TreeCollectionConfig, 'GA');
|
|
184
|
+
const b = await createDoc(treeCollection.id, TreeCollectionConfig, 'GB');
|
|
185
|
+
const c = await createDoc(treeCollection.id, TreeCollectionConfig, 'GC');
|
|
186
|
+
await commandBuilders.documents.placeTreeNode({
|
|
187
|
+
collectionId: treeCollection.id,
|
|
188
|
+
documentId: a,
|
|
189
|
+
parentDocumentId: null,
|
|
190
|
+
});
|
|
191
|
+
await commandBuilders.documents.placeTreeNode({
|
|
192
|
+
collectionId: treeCollection.id,
|
|
193
|
+
documentId: b,
|
|
194
|
+
parentDocumentId: a,
|
|
195
|
+
});
|
|
196
|
+
await commandBuilders.documents.placeTreeNode({
|
|
197
|
+
collectionId: treeCollection.id,
|
|
198
|
+
documentId: c,
|
|
199
|
+
parentDocumentId: b,
|
|
200
|
+
});
|
|
201
|
+
const ancestors = await queryBuilders.documents.getTreeAncestors({ document_id: c });
|
|
202
|
+
expect(ancestors.map((r) => r.document_id)).toEqual([a, b]); // root-first
|
|
203
|
+
expect(ancestors.map((r) => r.depth)).toEqual([2, 1]); // a is 2 hops up, b is 1
|
|
204
|
+
// Root + unplaced nodes have no ancestors.
|
|
205
|
+
expect(await queryBuilders.documents.getTreeAncestors({ document_id: a })).toEqual([]);
|
|
206
|
+
});
|
|
207
|
+
it('getTreeParent distinguishes unplaced, root, and child', async () => {
|
|
208
|
+
const root = await createDoc(treeCollection.id, TreeCollectionConfig, 'TP Root');
|
|
209
|
+
const child = await createDoc(treeCollection.id, TreeCollectionConfig, 'TP Child');
|
|
210
|
+
const stray = await createDoc(treeCollection.id, TreeCollectionConfig, 'TP Stray');
|
|
211
|
+
// Stray is created but never placed → unplaced (no edge row). This is the
|
|
212
|
+
// state getTreeAncestors cannot tell apart from a root.
|
|
213
|
+
expect(await queryBuilders.documents.getTreeParent({ document_id: stray })).toEqual({
|
|
214
|
+
placed: false,
|
|
215
|
+
parentDocumentId: null,
|
|
216
|
+
});
|
|
217
|
+
await commandBuilders.documents.placeTreeNode({
|
|
218
|
+
collectionId: treeCollection.id,
|
|
219
|
+
documentId: root,
|
|
220
|
+
parentDocumentId: null,
|
|
221
|
+
});
|
|
222
|
+
await commandBuilders.documents.placeTreeNode({
|
|
223
|
+
collectionId: treeCollection.id,
|
|
224
|
+
documentId: child,
|
|
225
|
+
parentDocumentId: root,
|
|
226
|
+
});
|
|
227
|
+
// Root: placed with a null parent.
|
|
228
|
+
expect(await queryBuilders.documents.getTreeParent({ document_id: root })).toEqual({
|
|
229
|
+
placed: true,
|
|
230
|
+
parentDocumentId: null,
|
|
231
|
+
});
|
|
232
|
+
// Child: placed with its parent id.
|
|
233
|
+
expect(await queryBuilders.documents.getTreeParent({ document_id: child })).toEqual({
|
|
234
|
+
placed: true,
|
|
235
|
+
parentDocumentId: root,
|
|
236
|
+
});
|
|
237
|
+
// Both root and unplaced report empty ancestors — the conflation getTreeParent fixes.
|
|
238
|
+
expect(await queryBuilders.documents.getTreeAncestors({ document_id: root })).toEqual([]);
|
|
239
|
+
expect(await queryBuilders.documents.getTreeAncestors({ document_id: stray })).toEqual([]);
|
|
240
|
+
});
|
|
241
|
+
it('getTreeAncestors stops at the first unpublished ancestor in published mode', async () => {
|
|
242
|
+
// GP(pub) ─ MID(draft) ─ LEAF(pub)
|
|
243
|
+
const gp = await createDoc(treeCollection.id, TreeCollectionConfig, 'AE GP', 'published');
|
|
244
|
+
const mid = await createDoc(treeCollection.id, TreeCollectionConfig, 'AE Mid', 'draft');
|
|
245
|
+
const leaf = await createDoc(treeCollection.id, TreeCollectionConfig, 'AE Leaf', 'published');
|
|
246
|
+
await commandBuilders.documents.placeTreeNode({
|
|
247
|
+
collectionId: treeCollection.id,
|
|
248
|
+
documentId: gp,
|
|
249
|
+
parentDocumentId: null,
|
|
250
|
+
});
|
|
251
|
+
await commandBuilders.documents.placeTreeNode({
|
|
252
|
+
collectionId: treeCollection.id,
|
|
253
|
+
documentId: mid,
|
|
254
|
+
parentDocumentId: gp,
|
|
255
|
+
});
|
|
256
|
+
await commandBuilders.documents.placeTreeNode({
|
|
257
|
+
collectionId: treeCollection.id,
|
|
258
|
+
documentId: leaf,
|
|
259
|
+
parentDocumentId: mid,
|
|
260
|
+
});
|
|
261
|
+
// any-mode: the full chain, root-first.
|
|
262
|
+
expect((await queryBuilders.documents.getTreeAncestors({ document_id: leaf, readMode: 'any' })).map((r) => r.document_id)).toEqual([gp, mid]);
|
|
263
|
+
// published-mode: the walk stops at the draft MID — it does NOT skip to GP.
|
|
264
|
+
expect(await queryBuilders.documents.getTreeAncestors({ document_id: leaf, readMode: 'published' })).toEqual([]);
|
|
265
|
+
// A draft root above a published parent truncates at the root, keeping the
|
|
266
|
+
// published parent but never reaching the draft root.
|
|
267
|
+
const draftRoot = await createDoc(treeCollection.id, TreeCollectionConfig, 'AE DRoot', 'draft');
|
|
268
|
+
const pubMid = await createDoc(treeCollection.id, TreeCollectionConfig, 'AE PMid', 'published');
|
|
269
|
+
const pubLeaf = await createDoc(treeCollection.id, TreeCollectionConfig, 'AE PLeaf', 'published');
|
|
270
|
+
await commandBuilders.documents.placeTreeNode({
|
|
271
|
+
collectionId: treeCollection.id,
|
|
272
|
+
documentId: draftRoot,
|
|
273
|
+
parentDocumentId: null,
|
|
274
|
+
});
|
|
275
|
+
await commandBuilders.documents.placeTreeNode({
|
|
276
|
+
collectionId: treeCollection.id,
|
|
277
|
+
documentId: pubMid,
|
|
278
|
+
parentDocumentId: draftRoot,
|
|
279
|
+
});
|
|
280
|
+
await commandBuilders.documents.placeTreeNode({
|
|
281
|
+
collectionId: treeCollection.id,
|
|
282
|
+
documentId: pubLeaf,
|
|
283
|
+
parentDocumentId: pubMid,
|
|
284
|
+
});
|
|
285
|
+
expect((await queryBuilders.documents.getTreeAncestors({
|
|
286
|
+
document_id: pubLeaf,
|
|
287
|
+
readMode: 'published',
|
|
288
|
+
})).map((r) => r.document_id)).toEqual([pubMid]); // stops below the draft root, does not include it
|
|
289
|
+
});
|
|
290
|
+
it('rejects a self-parent and a cycle', async () => {
|
|
291
|
+
const a = await createDoc(treeCollection.id, TreeCollectionConfig, 'CA');
|
|
292
|
+
const c = await createDoc(treeCollection.id, TreeCollectionConfig, 'CC');
|
|
293
|
+
await commandBuilders.documents.placeTreeNode({
|
|
294
|
+
collectionId: treeCollection.id,
|
|
295
|
+
documentId: a,
|
|
296
|
+
parentDocumentId: null,
|
|
297
|
+
});
|
|
298
|
+
await commandBuilders.documents.placeTreeNode({
|
|
299
|
+
collectionId: treeCollection.id,
|
|
300
|
+
documentId: c,
|
|
301
|
+
parentDocumentId: a,
|
|
302
|
+
});
|
|
303
|
+
// Self-parent.
|
|
304
|
+
await expect(commandBuilders.documents.placeTreeNode({
|
|
305
|
+
collectionId: treeCollection.id,
|
|
306
|
+
documentId: a,
|
|
307
|
+
parentDocumentId: a,
|
|
308
|
+
})).rejects.toThrow();
|
|
309
|
+
// Cycle: A is C's ancestor, so making A a child of C is a cycle.
|
|
310
|
+
await expect(commandBuilders.documents.placeTreeNode({
|
|
311
|
+
collectionId: treeCollection.id,
|
|
312
|
+
documentId: a,
|
|
313
|
+
parentDocumentId: c,
|
|
314
|
+
})).rejects.toThrow();
|
|
315
|
+
});
|
|
316
|
+
it('rejects a cross-collection edge', async () => {
|
|
317
|
+
const a = await createDoc(treeCollection.id, TreeCollectionConfig, 'XA');
|
|
318
|
+
const foreign = await createDoc(otherCollection.id, OtherCollectionConfig, 'Foreign');
|
|
319
|
+
await commandBuilders.documents.placeTreeNode({
|
|
320
|
+
collectionId: treeCollection.id,
|
|
321
|
+
documentId: a,
|
|
322
|
+
parentDocumentId: null,
|
|
323
|
+
});
|
|
324
|
+
await expect(commandBuilders.documents.placeTreeNode({
|
|
325
|
+
collectionId: treeCollection.id,
|
|
326
|
+
documentId: a,
|
|
327
|
+
parentDocumentId: foreign,
|
|
328
|
+
})).rejects.toThrow();
|
|
329
|
+
});
|
|
330
|
+
it('removeFromTree returns a node to the unplaced state', async () => {
|
|
331
|
+
const a = await createDoc(treeCollection.id, TreeCollectionConfig, 'UA');
|
|
332
|
+
const c = await createDoc(treeCollection.id, TreeCollectionConfig, 'UC');
|
|
333
|
+
await commandBuilders.documents.placeTreeNode({
|
|
334
|
+
collectionId: treeCollection.id,
|
|
335
|
+
documentId: a,
|
|
336
|
+
parentDocumentId: null,
|
|
337
|
+
});
|
|
338
|
+
await commandBuilders.documents.placeTreeNode({
|
|
339
|
+
collectionId: treeCollection.id,
|
|
340
|
+
documentId: c,
|
|
341
|
+
parentDocumentId: a,
|
|
342
|
+
});
|
|
343
|
+
expect(childIds(await queryBuilders.documents.getTreeChildren({
|
|
344
|
+
collectionId: treeCollection.id,
|
|
345
|
+
parentDocumentId: a,
|
|
346
|
+
}))).toEqual([c]);
|
|
347
|
+
await commandBuilders.documents.removeFromTree({ documentId: c });
|
|
348
|
+
expect(await queryBuilders.documents.getTreeChildren({
|
|
349
|
+
collectionId: treeCollection.id,
|
|
350
|
+
parentDocumentId: a,
|
|
351
|
+
})).toEqual([]);
|
|
352
|
+
expect(await queryBuilders.documents.getTreeAncestors({ document_id: c })).toEqual([]);
|
|
353
|
+
// Idempotent — removing an already-unplaced node is a no-op.
|
|
354
|
+
await expect(commandBuilders.documents.removeFromTree({ documentId: c })).resolves.toBeUndefined();
|
|
355
|
+
});
|
|
356
|
+
it('getTreeSubtree returns a pre-order depth-first walk with 0-based depth', async () => {
|
|
357
|
+
// Build: SA ─ SB ─ SD
|
|
358
|
+
// └ SC
|
|
359
|
+
const sa = await createDoc(treeCollection.id, TreeCollectionConfig, 'SA');
|
|
360
|
+
const sb = await createDoc(treeCollection.id, TreeCollectionConfig, 'SB');
|
|
361
|
+
const sc = await createDoc(treeCollection.id, TreeCollectionConfig, 'SC');
|
|
362
|
+
const sd = await createDoc(treeCollection.id, TreeCollectionConfig, 'SD');
|
|
363
|
+
// beforeDocumentId = left neighbour → the node lands immediately AFTER it.
|
|
364
|
+
const place = (documentId, parentDocumentId, beforeDocumentId) => commandBuilders.documents.placeTreeNode({
|
|
365
|
+
collectionId: treeCollection.id,
|
|
366
|
+
documentId,
|
|
367
|
+
parentDocumentId,
|
|
368
|
+
beforeDocumentId,
|
|
369
|
+
});
|
|
370
|
+
await place(sa, null);
|
|
371
|
+
await place(sb, sa);
|
|
372
|
+
await place(sc, sa, sb); // SC lands after SB
|
|
373
|
+
await place(sd, sb); // SD under SB
|
|
374
|
+
const subtree = await queryBuilders.documents.getTreeSubtree({
|
|
375
|
+
collectionId: treeCollection.id,
|
|
376
|
+
rootDocumentId: sa,
|
|
377
|
+
});
|
|
378
|
+
// Pre-order: SA(0) → SB(1) → SD(2) → SC(1)
|
|
379
|
+
expect(subtree.map((n) => n.document_id)).toEqual([sa, sb, sd, sc]);
|
|
380
|
+
expect(subtree.map((n) => n.depth)).toEqual([0, 1, 2, 1]);
|
|
381
|
+
expect(subtree.find((n) => n.document_id === sd)?.parent_document_id).toBe(sb);
|
|
382
|
+
// Depth bound: only the root and its immediate children.
|
|
383
|
+
const shallow = await queryBuilders.documents.getTreeSubtree({
|
|
384
|
+
collectionId: treeCollection.id,
|
|
385
|
+
rootDocumentId: sa,
|
|
386
|
+
maxDepth: 1,
|
|
387
|
+
});
|
|
388
|
+
expect(shallow.map((n) => n.document_id)).toEqual([sa, sb, sc]);
|
|
389
|
+
});
|
|
390
|
+
it('status-at-edge hides an unpublished node and its whole subtree', async () => {
|
|
391
|
+
// PA(pub) ─ PB(pub)
|
|
392
|
+
// └ PC(draft) ─ PD(pub)
|
|
393
|
+
const pa = await createDoc(treeCollection.id, TreeCollectionConfig, 'PPA', 'published');
|
|
394
|
+
const pb = await createDoc(treeCollection.id, TreeCollectionConfig, 'PPB', 'published');
|
|
395
|
+
const pc = await createDoc(treeCollection.id, TreeCollectionConfig, 'PPC', 'draft');
|
|
396
|
+
const pd = await createDoc(treeCollection.id, TreeCollectionConfig, 'PPD', 'published');
|
|
397
|
+
await commandBuilders.documents.placeTreeNode({
|
|
398
|
+
collectionId: treeCollection.id,
|
|
399
|
+
documentId: pa,
|
|
400
|
+
parentDocumentId: null,
|
|
401
|
+
});
|
|
402
|
+
await commandBuilders.documents.placeTreeNode({
|
|
403
|
+
collectionId: treeCollection.id,
|
|
404
|
+
documentId: pb,
|
|
405
|
+
parentDocumentId: pa,
|
|
406
|
+
});
|
|
407
|
+
await commandBuilders.documents.placeTreeNode({
|
|
408
|
+
collectionId: treeCollection.id,
|
|
409
|
+
documentId: pc,
|
|
410
|
+
parentDocumentId: pa,
|
|
411
|
+
beforeDocumentId: pb, // PC lands after PB
|
|
412
|
+
});
|
|
413
|
+
await commandBuilders.documents.placeTreeNode({
|
|
414
|
+
collectionId: treeCollection.id,
|
|
415
|
+
documentId: pd,
|
|
416
|
+
parentDocumentId: pc,
|
|
417
|
+
});
|
|
418
|
+
// any-mode sees the whole subtree.
|
|
419
|
+
const any = await queryBuilders.documents.getTreeSubtree({
|
|
420
|
+
collectionId: treeCollection.id,
|
|
421
|
+
rootDocumentId: pa,
|
|
422
|
+
readMode: 'any',
|
|
423
|
+
});
|
|
424
|
+
expect(any.map((n) => n.document_id)).toEqual([pa, pb, pc, pd]);
|
|
425
|
+
// published-mode drops the draft PC, and PD (under PC) is unreachable —
|
|
426
|
+
// the spine is broken, so the whole subtree is hidden, not promoted.
|
|
427
|
+
const published = await queryBuilders.documents.getTreeSubtree({
|
|
428
|
+
collectionId: treeCollection.id,
|
|
429
|
+
rootDocumentId: pa,
|
|
430
|
+
readMode: 'published',
|
|
431
|
+
});
|
|
432
|
+
expect(published.map((n) => n.document_id)).toEqual([pa, pb]);
|
|
433
|
+
});
|
|
434
|
+
});
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "@byline/db-postgres",
|
|
3
3
|
"private": false,
|
|
4
4
|
"license": "MPL-2.0",
|
|
5
|
-
"version": "3.
|
|
5
|
+
"version": "3.13.0",
|
|
6
6
|
"engines": {
|
|
7
7
|
"node": ">=20.9.0"
|
|
8
8
|
},
|
|
@@ -57,9 +57,9 @@
|
|
|
57
57
|
"pg": "^8.21.0",
|
|
58
58
|
"uuid": "^14.0.0",
|
|
59
59
|
"zod": "^4.4.3",
|
|
60
|
-
"@byline/admin": "3.
|
|
61
|
-
"@byline/
|
|
62
|
-
"@byline/
|
|
60
|
+
"@byline/admin": "3.13.0",
|
|
61
|
+
"@byline/auth": "3.13.0",
|
|
62
|
+
"@byline/core": "3.13.0"
|
|
63
63
|
},
|
|
64
64
|
"devDependencies": {
|
|
65
65
|
"@biomejs/biome": "2.4.15",
|