@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.
@@ -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
- parent_document_id: import("drizzle-orm/pg-core").PgColumn<{
772
- name: "parent_document_id";
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
- child_document_id: import("drizzle-orm/pg-core").PgColumn<{
789
- name: "child_document_id";
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: true;
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<"byline_document_versions", true>;
3283
- child: import("drizzle-orm").One<"byline_document_versions", true>;
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 Relationships (Parent/Child) - Many-to-Many
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
- ...createdAt,
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
- // Composite primary key to ensure a child is only parented once by the same parent.
195
- unique().on(table.parent_document_id, table.child_document_id),
196
- // Indexes for efficient lookups of children and parents.
197
- index('idx_document_relationships_parent').on(table.parent_document_id),
198
- index('idx_document_relationships_child').on(table.child_document_id),
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(documentVersions, {
547
+ parent: one(documents, {
541
548
  fields: [documentRelationships.parent_document_id],
542
- references: [documentVersions.document_id],
543
- relationName: 'parent',
549
+ references: [documents.id],
550
+ relationName: 'tree_parent',
544
551
  }),
545
- child: one(documentVersions, {
552
+ child: one(documents, {
546
553
  fields: [documentRelationships.child_document_id],
547
- references: [documentVersions.document_id],
548
- relationName: 'child',
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 { and, desc, eq, ne, sql } from 'drizzle-orm';
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.12.1",
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.12.1",
61
- "@byline/core": "3.12.1",
62
- "@byline/auth": "3.12.1"
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",