@byline/db-postgres 1.7.7 → 1.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -316,25 +316,6 @@ export declare const documentVersions: import("drizzle-orm/pg-core").PgTableWith
316
316
  identity: undefined;
317
317
  generated: undefined;
318
318
  }, {}, {}>;
319
- path: import("drizzle-orm/pg-core").PgColumn<{
320
- name: "path";
321
- tableName: "byline_document_versions";
322
- dataType: "string";
323
- columnType: "PgVarchar";
324
- data: string;
325
- driverParam: string;
326
- notNull: true;
327
- hasDefault: false;
328
- isPrimaryKey: false;
329
- isAutoincrement: false;
330
- hasRuntimeDefault: false;
331
- enumValues: [string, ...string[]];
332
- baseColumn: never;
333
- identity: undefined;
334
- generated: undefined;
335
- }, {}, {
336
- length: 255;
337
- }>;
338
319
  doc: import("drizzle-orm/pg-core").PgColumn<{
339
320
  name: "doc";
340
321
  tableName: "byline_document_versions";
@@ -478,6 +459,119 @@ export declare const documentVersions: import("drizzle-orm/pg-core").PgTableWith
478
459
  };
479
460
  dialect: "pg";
480
461
  }>;
462
+ export declare const documentPaths: import("drizzle-orm/pg-core").PgTableWithColumns<{
463
+ name: "byline_document_paths";
464
+ schema: undefined;
465
+ columns: {
466
+ document_id: import("drizzle-orm/pg-core").PgColumn<{
467
+ name: "document_id";
468
+ tableName: "byline_document_paths";
469
+ dataType: "string";
470
+ columnType: "PgUUID";
471
+ data: string;
472
+ driverParam: string;
473
+ notNull: true;
474
+ hasDefault: false;
475
+ isPrimaryKey: false;
476
+ isAutoincrement: false;
477
+ hasRuntimeDefault: false;
478
+ enumValues: undefined;
479
+ baseColumn: never;
480
+ identity: undefined;
481
+ generated: undefined;
482
+ }, {}, {}>;
483
+ locale: import("drizzle-orm/pg-core").PgColumn<{
484
+ name: "locale";
485
+ tableName: "byline_document_paths";
486
+ dataType: "string";
487
+ columnType: "PgVarchar";
488
+ data: string;
489
+ driverParam: string;
490
+ notNull: true;
491
+ hasDefault: false;
492
+ isPrimaryKey: false;
493
+ isAutoincrement: false;
494
+ hasRuntimeDefault: false;
495
+ enumValues: [string, ...string[]];
496
+ baseColumn: never;
497
+ identity: undefined;
498
+ generated: undefined;
499
+ }, {}, {
500
+ length: 10;
501
+ }>;
502
+ collection_id: import("drizzle-orm/pg-core").PgColumn<{
503
+ name: "collection_id";
504
+ tableName: "byline_document_paths";
505
+ dataType: "string";
506
+ columnType: "PgUUID";
507
+ data: string;
508
+ driverParam: string;
509
+ notNull: true;
510
+ hasDefault: false;
511
+ isPrimaryKey: false;
512
+ isAutoincrement: false;
513
+ hasRuntimeDefault: false;
514
+ enumValues: undefined;
515
+ baseColumn: never;
516
+ identity: undefined;
517
+ generated: undefined;
518
+ }, {}, {}>;
519
+ path: import("drizzle-orm/pg-core").PgColumn<{
520
+ name: "path";
521
+ tableName: "byline_document_paths";
522
+ dataType: "string";
523
+ columnType: "PgVarchar";
524
+ data: string;
525
+ driverParam: string;
526
+ notNull: true;
527
+ hasDefault: false;
528
+ isPrimaryKey: false;
529
+ isAutoincrement: false;
530
+ hasRuntimeDefault: false;
531
+ enumValues: [string, ...string[]];
532
+ baseColumn: never;
533
+ identity: undefined;
534
+ generated: undefined;
535
+ }, {}, {
536
+ length: 255;
537
+ }>;
538
+ created_at: import("drizzle-orm/pg-core").PgColumn<{
539
+ name: "created_at";
540
+ tableName: "byline_document_paths";
541
+ dataType: "date";
542
+ columnType: "PgTimestamp";
543
+ data: Date;
544
+ driverParam: string;
545
+ notNull: false;
546
+ hasDefault: true;
547
+ isPrimaryKey: false;
548
+ isAutoincrement: false;
549
+ hasRuntimeDefault: false;
550
+ enumValues: undefined;
551
+ baseColumn: never;
552
+ identity: undefined;
553
+ generated: undefined;
554
+ }, {}, {}>;
555
+ updated_at: import("drizzle-orm/pg-core").PgColumn<{
556
+ name: "updated_at";
557
+ tableName: "byline_document_paths";
558
+ dataType: "date";
559
+ columnType: "PgTimestamp";
560
+ data: Date;
561
+ driverParam: string;
562
+ notNull: false;
563
+ hasDefault: true;
564
+ isPrimaryKey: false;
565
+ isAutoincrement: false;
566
+ hasRuntimeDefault: false;
567
+ enumValues: undefined;
568
+ baseColumn: never;
569
+ identity: undefined;
570
+ generated: undefined;
571
+ }, {}, {}>;
572
+ };
573
+ dialect: "pg";
574
+ }>;
481
575
  export declare const documentRelationships: import("drizzle-orm/pg-core").PgTableWithColumns<{
482
576
  name: "byline_document_relationships";
483
577
  schema: undefined;
@@ -605,23 +699,6 @@ export declare const currentDocumentsView: import("drizzle-orm/pg-core").PgViewW
605
699
  identity: undefined;
606
700
  generated: undefined;
607
701
  }, {}, {}>;
608
- path: import("drizzle-orm/pg-core").PgColumn<{
609
- name: "path";
610
- tableName: "byline_current_documents";
611
- dataType: "string";
612
- columnType: "PgVarchar";
613
- data: string;
614
- driverParam: string;
615
- notNull: true;
616
- hasDefault: false;
617
- isPrimaryKey: false;
618
- isAutoincrement: false;
619
- hasRuntimeDefault: false;
620
- enumValues: [string, ...string[]];
621
- baseColumn: never;
622
- identity: undefined;
623
- generated: undefined;
624
- }, {}, {}>;
625
702
  event_type: import("drizzle-orm/pg-core").PgColumn<{
626
703
  name: "event_type";
627
704
  tableName: "byline_current_documents";
@@ -811,23 +888,6 @@ export declare const currentPublishedDocumentsView: import("drizzle-orm/pg-core"
811
888
  identity: undefined;
812
889
  generated: undefined;
813
890
  }, {}, {}>;
814
- path: import("drizzle-orm/pg-core").PgColumn<{
815
- name: "path";
816
- tableName: "byline_current_published_documents";
817
- dataType: "string";
818
- columnType: "PgVarchar";
819
- data: string;
820
- driverParam: string;
821
- notNull: true;
822
- hasDefault: false;
823
- isPrimaryKey: false;
824
- isAutoincrement: false;
825
- hasRuntimeDefault: false;
826
- enumValues: [string, ...string[]];
827
- baseColumn: never;
828
- identity: undefined;
829
- generated: undefined;
830
- }, {}, {}>;
831
891
  event_type: import("drizzle-orm/pg-core").PgColumn<{
832
892
  name: "event_type";
833
893
  tableName: "byline_current_published_documents";
@@ -49,7 +49,6 @@ export const documentVersions = pgTable('byline_document_versions', {
49
49
  // shapes. Phase 1 records the number; no composite FK yet — that
50
50
  // anchors in Phase 2 alongside the history table.
51
51
  collection_version: integer('collection_version').notNull(),
52
- path: varchar('path', { length: 255 }).notNull(), // Can change between versions
53
52
  doc: jsonb('doc'), // optionally store the original document
54
53
  event_type: varchar('event_type', { length: 20 }).notNull().default('create'), // 'create', 'update', 'delete'
55
54
  status: varchar('status', { length: 50 }).default('draft'),
@@ -61,8 +60,6 @@ export const documentVersions = pgTable('byline_document_versions', {
61
60
  }, (table) => [
62
61
  // Index for finding all versions of a logical document
63
62
  index('idx_documents_document_id').on(table.document_id),
64
- // Index for current document lookup by path
65
- index('idx_documents_collection_path_deleted').on(table.collection_id, table.path, table.is_deleted),
66
63
  // Index for current document lookup by logical document ID
67
64
  index('idx_documents_collection_document_deleted').on(table.collection_id, table.document_id, table.is_deleted),
68
65
  // Index to optimize the current documents view
@@ -72,10 +69,34 @@ export const documentVersions = pgTable('byline_document_versions', {
72
69
  index('idx_documents_created_at').on(table.created_at),
73
70
  // Ensure logical document belongs to only one collection
74
71
  index('idx_documents_document_collection').on(table.document_id, table.collection_id),
75
- // Per-collection path uniqueness is a DEFERRED design decision — it
76
- // depends on a collision-handling policy (reject vs auto-suffix) and
77
- // is planned to land alongside preview-link UX. See
78
- // `docs/analysis/DOCUMENT-PATH-ANALYSIS.md` § "Path uniqueness".
72
+ ]);
73
+ // Document paths one row per (logical document, content locale).
74
+ // Promotes `path` out of the version row so per-collection uniqueness can
75
+ // be enforced at the DB layer without colliding with the sticky
76
+ // carry-forward of path across versions. Phase 1 only ever writes the
77
+ // installation's default content locale; per-locale UI is a future phase
78
+ // that adds rows for additional locales without reshaping the schema.
79
+ // History is intentionally not preserved here — path rows are updated in
80
+ // place. See `docs/DOCUMENT-PATHS.md` § "Path uniqueness".
81
+ export const documentPaths = pgTable('byline_document_paths', {
82
+ document_id: uuid('document_id')
83
+ .notNull()
84
+ .references(() => documents.id, { onDelete: 'cascade' }),
85
+ locale: varchar('locale', { length: 10 }).notNull(),
86
+ collection_id: uuid('collection_id')
87
+ .notNull()
88
+ .references(() => collections.id, { onDelete: 'cascade' }),
89
+ path: varchar('path', { length: 255 }).notNull(),
90
+ created_at: timestamp('created_at').defaultNow(),
91
+ updated_at: timestamp('updated_at').defaultNow(),
92
+ }, (table) => [
93
+ // One path per (logical document, locale).
94
+ unique('unique_document_paths_document_locale').on(table.document_id, table.locale),
95
+ // Per-collection per-locale path uniqueness. Column order matches the
96
+ // resolution lookup pattern: WHERE collection_id = ? AND locale = ? AND path = ?.
97
+ unique('idx_document_paths_collection_locale_path').on(table.collection_id, table.locale, table.path),
98
+ // Reverse lookup by document.
99
+ index('idx_document_paths_document_id').on(table.document_id),
79
100
  ]);
80
101
  // Document Relationships (Parent/Child) - Many-to-Many
81
102
  export const documentRelationships = pgTable('byline_document_relationships', {
@@ -99,6 +120,11 @@ export const documentRelationships = pgTable('byline_document_relationships', {
99
120
  // `ROW_NUMBER() OVER (PARTITION BY document_id ORDER BY id DESC)`.
100
121
  // `selectDistinct` is not an option here: it distincts on the whole row,
101
122
  // not on `document_id`.
123
+ //
124
+ // `path` is intentionally NOT projected here. Path resolution is locale-
125
+ // aware and lives in the storage adapter's read functions, which join
126
+ // `byline_document_paths` with the requested locale + default-locale
127
+ // fallback. See docs/DOCUMENT-PATHS.md.
102
128
  export const currentDocumentsView = pgView('byline_current_documents').as((qb) => {
103
129
  const sq = qb.$with('sq').as(qb
104
130
  .select({
@@ -106,7 +132,6 @@ export const currentDocumentsView = pgView('byline_current_documents').as((qb) =
106
132
  document_id: documentVersions.document_id,
107
133
  collection_id: documentVersions.collection_id,
108
134
  collection_version: documentVersions.collection_version,
109
- path: documentVersions.path,
110
135
  event_type: documentVersions.event_type,
111
136
  status: documentVersions.status,
112
137
  is_deleted: documentVersions.is_deleted,
@@ -125,7 +150,6 @@ export const currentDocumentsView = pgView('byline_current_documents').as((qb) =
125
150
  document_id: sq.document_id,
126
151
  collection_id: sq.collection_id,
127
152
  collection_version: sq.collection_version,
128
- path: sq.path,
129
153
  event_type: sq.event_type,
130
154
  status: sq.status,
131
155
  is_deleted: sq.is_deleted,
@@ -149,7 +173,6 @@ export const currentPublishedDocumentsView = pgView('byline_current_published_do
149
173
  document_id: documentVersions.document_id,
150
174
  collection_id: documentVersions.collection_id,
151
175
  collection_version: documentVersions.collection_version,
152
- path: documentVersions.path,
153
176
  event_type: documentVersions.event_type,
154
177
  status: documentVersions.status,
155
178
  is_deleted: documentVersions.is_deleted,
@@ -168,7 +191,6 @@ export const currentPublishedDocumentsView = pgView('byline_current_published_do
168
191
  document_id: sq.document_id,
169
192
  collection_id: sq.collection_id,
170
193
  collection_version: sq.collection_version,
171
- path: sq.path,
172
194
  event_type: sq.event_type,
173
195
  status: sq.status,
174
196
  is_deleted: sq.is_deleted,
package/dist/index.d.ts CHANGED
@@ -24,7 +24,15 @@ export interface PgAdapter extends IDbAdapter {
24
24
  /** The pg connection pool — exposed for housekeeping and teardown. */
25
25
  pool: pg.Pool;
26
26
  }
27
- export declare const pgAdapter: ({ connectionString, collections, }: {
27
+ export declare const pgAdapter: ({ connectionString, collections, defaultContentLocale, }: {
28
28
  connectionString: string;
29
29
  collections: CollectionDefinition[];
30
+ /**
31
+ * The installation's default content locale, sourced from
32
+ * `ServerConfig.i18n.content.defaultLocale`. Used by the storage layer
33
+ * for path resolution: read functions build a `[requested, default]`
34
+ * fallback chain when looking up `byline_document_paths`, and write
35
+ * functions tag default-locale path rows with this value.
36
+ */
37
+ defaultContentLocale: string;
30
38
  }) => PgAdapter;
package/dist/index.js CHANGED
@@ -10,7 +10,7 @@ import pg from 'pg';
10
10
  import * as schema from './database/schema/index.js';
11
11
  import { createCommandBuilders } from './modules/storage/storage-commands.js';
12
12
  import { createQueryBuilders } from './modules/storage/storage-queries.js';
13
- export const pgAdapter = ({ connectionString, collections, }) => {
13
+ export const pgAdapter = ({ connectionString, collections, defaultContentLocale, }) => {
14
14
  const pool = new pg.Pool({
15
15
  connectionString: connectionString,
16
16
  max: 20,
@@ -18,8 +18,8 @@ export const pgAdapter = ({ connectionString, collections, }) => {
18
18
  connectionTimeoutMillis: 1000,
19
19
  });
20
20
  const db = drizzle(pool, { schema });
21
- const commandBuilders = createCommandBuilders(db);
22
- const queryBuilders = createQueryBuilders(db, collections);
21
+ const commandBuilders = createCommandBuilders(db, defaultContentLocale);
22
+ const queryBuilders = createQueryBuilders(db, collections, defaultContentLocale);
23
23
  return {
24
24
  commands: commandBuilders,
25
25
  queries: queryBuilders,
@@ -28,11 +28,11 @@ export function setupTestDB(collections = []) {
28
28
  db = drizzle(pool, { schema });
29
29
  }
30
30
  if (!commandBuilders) {
31
- commandBuilders = createCommandBuilders(db);
31
+ commandBuilders = createCommandBuilders(db, 'en');
32
32
  }
33
33
  // Recreate queryBuilders when collections are provided so that
34
34
  // DocumentQueries can resolve collection definitions by path.
35
- queryBuilders = createQueryBuilders(db, collections);
35
+ queryBuilders = createQueryBuilders(db, collections, 'en');
36
36
  return { pool, db, commandBuilders, queryBuilders };
37
37
  }
38
38
  export async function teardownTestDB() {
@@ -51,7 +51,8 @@ export declare class CollectionCommands implements ICollectionCommands {
51
51
  */
52
52
  export declare class DocumentCommands implements IDocumentCommands {
53
53
  private db;
54
- constructor(db: DatabaseConnection);
54
+ private defaultContentLocale;
55
+ constructor(db: DatabaseConnection, defaultContentLocale: string);
55
56
  /**
56
57
  * createDocumentVersion
57
58
  *
@@ -67,7 +68,13 @@ export declare class DocumentCommands implements IDocumentCommands {
67
68
  collectionConfig: CollectionDefinition;
68
69
  action: string;
69
70
  documentData: any;
70
- path: string;
71
+ /**
72
+ * Optional. When provided, upserts a row into byline_document_paths
73
+ * keyed by (document_id, this.defaultContentLocale). Omitted by the
74
+ * lifecycle for non-default-locale (translation) saves so the
75
+ * existing path row is left untouched.
76
+ */
77
+ path?: string;
71
78
  locale?: string;
72
79
  status?: string;
73
80
  createdBy?: string;
@@ -77,7 +84,6 @@ export declare class DocumentCommands implements IDocumentCommands {
77
84
  id: string;
78
85
  created_at: Date | null;
79
86
  updated_at: Date | null;
80
- path: string;
81
87
  collection_id: string;
82
88
  document_id: string;
83
89
  collection_version: number;
@@ -128,7 +134,7 @@ export declare class DocumentCommands implements IDocumentCommands {
128
134
  document_id: string;
129
135
  }): Promise<number>;
130
136
  }
131
- export declare function createCommandBuilders(db: DatabaseConnection): {
137
+ export declare function createCommandBuilders(db: DatabaseConnection, defaultContentLocale: string): {
132
138
  collections: CollectionCommands;
133
139
  documents: DocumentCommands;
134
140
  };
@@ -7,7 +7,7 @@
7
7
  */
8
8
  import { and, eq, ne, sql } from 'drizzle-orm';
9
9
  import { v7 as uuidv7 } from 'uuid';
10
- import { booleanStore, collections, datetimeStore, documents, documentVersions, fileStore, jsonStore, metaStore, numericStore, relationStore, textStore, } from '../../database/schema/index.js';
10
+ import { booleanStore, collections, datetimeStore, documentPaths, documents, documentVersions, fileStore, jsonStore, metaStore, numericStore, relationStore, textStore, } from '../../database/schema/index.js';
11
11
  import { flattenFieldSetData } from './storage-flatten.js';
12
12
  import { prepareFieldInsertBuckets } from './storage-insert.js';
13
13
  import { getFirstOrThrow } from './storage-utils.js';
@@ -52,8 +52,10 @@ export class CollectionCommands {
52
52
  */
53
53
  export class DocumentCommands {
54
54
  db;
55
- constructor(db) {
55
+ defaultContentLocale;
56
+ constructor(db, defaultContentLocale) {
56
57
  this.db = db;
58
+ this.defaultContentLocale = defaultContentLocale;
57
59
  }
58
60
  /**
59
61
  * createDocumentVersion
@@ -86,12 +88,35 @@ export class DocumentCommands {
86
88
  document_id: documentId,
87
89
  collection_id: params.collectionId,
88
90
  collection_version: params.collectionVersion,
89
- path: params.path,
90
91
  event_type: params.action ?? 'create',
91
92
  status: params.status ?? 'draft',
92
93
  })
93
94
  .returning()
94
95
  .then(getFirstOrThrow('Failed to create document version'));
96
+ // 2a. Upsert the document_paths row when a path is supplied. Path
97
+ // is only ever written under the installation's default content
98
+ // locale in phase 1; the lifecycle layer skips this param for
99
+ // non-default-locale (translation) saves. Unique-constraint
100
+ // violations on (collection_id, locale, path) bubble up as a
101
+ // Postgres error which the lifecycle wraps as ERR_PATH_CONFLICT.
102
+ if (params.path !== undefined) {
103
+ await tx
104
+ .insert(documentPaths)
105
+ .values({
106
+ document_id: documentId,
107
+ locale: this.defaultContentLocale,
108
+ collection_id: params.collectionId,
109
+ path: params.path,
110
+ })
111
+ .onConflictDoUpdate({
112
+ target: [documentPaths.document_id, documentPaths.locale],
113
+ set: {
114
+ path: params.path,
115
+ collection_id: params.collectionId,
116
+ updated_at: new Date(),
117
+ },
118
+ });
119
+ }
95
120
  // 3. Flatten the document data to field values
96
121
  const flattenedFields = flattenFieldSetData(params.collectionConfig.fields, params.documentData, params.locale ?? 'all');
97
122
  // 4. Batch-insert all field values, grouped by store type
@@ -263,9 +288,9 @@ export class DocumentCommands {
263
288
  return result.rowCount ?? 0;
264
289
  }
265
290
  }
266
- export function createCommandBuilders(db) {
291
+ export function createCommandBuilders(db, defaultContentLocale) {
267
292
  return {
268
293
  collections: new CollectionCommands(db),
269
- documents: new DocumentCommands(db),
294
+ documents: new DocumentCommands(db, defaultContentLocale),
270
295
  };
271
296
  }
@@ -55,8 +55,9 @@ export declare class CollectionQueries implements ICollectionQueries {
55
55
  export declare class DocumentQueries implements IDocumentQueries {
56
56
  private db;
57
57
  private collections;
58
+ private defaultContentLocale;
58
59
  private collectionPathCache;
59
- constructor(db: DatabaseConnection, collections: CollectionDefinition[]);
60
+ constructor(db: DatabaseConnection, collections: CollectionDefinition[], defaultContentLocale: string);
60
61
  /**
61
62
  * Resolve a collection UUID to its CollectionDefinition by looking up the
62
63
  * collection's path in the DB and matching it against the injected array.
@@ -76,6 +77,66 @@ export declare class DocumentQueries implements IDocumentQueries {
76
77
  * drop-in substitutable at every select/where site.
77
78
  */
78
79
  private pickCurrentView;
80
+ /**
81
+ * Build the locale priority chain for path resolution:
82
+ * `[requested, default]`, deduplicated when both are the same.
83
+ *
84
+ * Used by every read path that touches `byline_document_paths`. In
85
+ * phase 1 only the default-locale row is ever populated, so a non-
86
+ * default `requested` locale always falls through to the default —
87
+ * but the chain shape is correct for phase 2 when per-locale rows
88
+ * begin to exist.
89
+ */
90
+ private buildLocaleChain;
91
+ /**
92
+ * Emit a SQL fragment that resolves the path string for a document via
93
+ * the locale priority chain. Used as a projected column expression
94
+ * inside `SELECT` lists.
95
+ *
96
+ * ```sql
97
+ * (SELECT path FROM byline_document_paths
98
+ * WHERE document_id = <docIdSql>
99
+ * AND locale = ANY(<chain>)
100
+ * ORDER BY array_position(<chain>, locale)
101
+ * LIMIT 1)
102
+ * ```
103
+ */
104
+ private pathProjection;
105
+ /**
106
+ * Project list for `current_documents` / `current_published_documents`
107
+ * reads, with `path` resolved through the locale priority chain. Used
108
+ * everywhere a read previously did `.select()` (which auto-pulls every
109
+ * view column) — `path` is no longer projected by the views, so call
110
+ * sites must list the projection explicitly. This helper keeps the
111
+ * shape consistent and the call sites tidy.
112
+ */
113
+ private viewProjection;
114
+ /**
115
+ * Project list for direct `byline_document_versions` reads (history,
116
+ * version-by-id lookups). Mirrors `viewProjection` but against the
117
+ * underlying table — `path` is sourced from `byline_document_paths` via
118
+ * the locale priority chain, since it no longer lives on the version row.
119
+ */
120
+ private documentVersionsProjection;
121
+ /**
122
+ * Emit a SQL fragment that resolves a `(collection_id, path)` tuple to
123
+ * a `document_id` via the locale priority chain. Used inside `WHERE`
124
+ * clauses for findByPath-style lookups:
125
+ *
126
+ * ```sql
127
+ * WHERE document_id = (
128
+ * SELECT document_id FROM byline_document_paths
129
+ * WHERE collection_id = ? AND path = ?
130
+ * AND locale = ANY(<chain>)
131
+ * ORDER BY array_position(<chain>, locale)
132
+ * LIMIT 1
133
+ * )
134
+ * ```
135
+ *
136
+ * Returns NULL when no row matches in any locale, which makes the
137
+ * outer `=` predicate fail cleanly (no document found).
138
+ */
139
+ private resolveDocumentIdByPath;
79
140
  /**
80
141
  * Reconstruct document fields from unified row values using schema-aware
81
142
  * restoration. Meta rows (from store_meta) are converted to
@@ -383,7 +444,7 @@ export declare class DocumentQueries implements IDocumentQueries {
383
444
  */
384
445
  private convertUnionRowToFlattenedStores;
385
446
  }
386
- export declare function createQueryBuilders(db: DatabaseConnection, collections: CollectionDefinition[]): {
447
+ export declare function createQueryBuilders(db: DatabaseConnection, collections: CollectionDefinition[], defaultContentLocale: string): {
387
448
  collections: CollectionQueries;
388
449
  documents: DocumentQueries;
389
450
  };
@@ -11,7 +11,7 @@
11
11
  // either deferring adapter construction or accepting a lazy logger parameter.
12
12
  import { ERR_DATABASE, ERR_NOT_FOUND, getLogger } from '@byline/core';
13
13
  import { and, eq, inArray, sql } from 'drizzle-orm';
14
- import { collections, currentDocumentsView, currentPublishedDocumentsView, documentVersions, metaStore, } from '../../database/schema/index.js';
14
+ import { collections, currentDocumentsView, currentPublishedDocumentsView, documentPaths, 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';
@@ -39,10 +39,12 @@ export class CollectionQueries {
39
39
  export class DocumentQueries {
40
40
  db;
41
41
  collections;
42
+ defaultContentLocale;
42
43
  collectionPathCache = new Map();
43
- constructor(db, collections) {
44
+ constructor(db, collections, defaultContentLocale) {
44
45
  this.db = db;
45
46
  this.collections = collections;
47
+ this.defaultContentLocale = defaultContentLocale;
46
48
  }
47
49
  /**
48
50
  * Resolve a collection UUID to its CollectionDefinition by looking up the
@@ -88,6 +90,126 @@ export class DocumentQueries {
88
90
  pickCurrentView(readMode) {
89
91
  return readMode === 'published' ? currentPublishedDocumentsView : currentDocumentsView;
90
92
  }
93
+ /**
94
+ * Build the locale priority chain for path resolution:
95
+ * `[requested, default]`, deduplicated when both are the same.
96
+ *
97
+ * Used by every read path that touches `byline_document_paths`. In
98
+ * phase 1 only the default-locale row is ever populated, so a non-
99
+ * default `requested` locale always falls through to the default —
100
+ * but the chain shape is correct for phase 2 when per-locale rows
101
+ * begin to exist.
102
+ */
103
+ buildLocaleChain(requestedLocale) {
104
+ const requested = requestedLocale ?? this.defaultContentLocale;
105
+ return requested === this.defaultContentLocale
106
+ ? [requested]
107
+ : [requested, this.defaultContentLocale];
108
+ }
109
+ /**
110
+ * Emit a SQL fragment that resolves the path string for a document via
111
+ * the locale priority chain. Used as a projected column expression
112
+ * inside `SELECT` lists.
113
+ *
114
+ * ```sql
115
+ * (SELECT path FROM byline_document_paths
116
+ * WHERE document_id = <docIdSql>
117
+ * AND locale = ANY(<chain>)
118
+ * ORDER BY array_position(<chain>, locale)
119
+ * LIMIT 1)
120
+ * ```
121
+ */
122
+ pathProjection(documentIdCol, requestedLocale) {
123
+ const chain = this.buildLocaleChain(requestedLocale);
124
+ // Build a `ARRAY[$1, $2, ...]::text[]` literal so each locale is its
125
+ // own parameter. Passing a JS array as a single `${chain}` placeholder
126
+ // serialises as a scalar string (`'en'`), which Postgres rejects when
127
+ // cast to `text[]` ("malformed array literal").
128
+ const chainSql = sql.join(chain.map((l) => sql `${l}`), sql `, `);
129
+ return sql `(
130
+ SELECT ${documentPaths.path} FROM ${documentPaths}
131
+ WHERE ${documentPaths.document_id} = ${documentIdCol}
132
+ AND ${documentPaths.locale} = ANY(ARRAY[${chainSql}]::text[])
133
+ ORDER BY array_position(ARRAY[${chainSql}]::text[], ${documentPaths.locale})
134
+ LIMIT 1
135
+ )`;
136
+ }
137
+ /**
138
+ * Project list for `current_documents` / `current_published_documents`
139
+ * reads, with `path` resolved through the locale priority chain. Used
140
+ * everywhere a read previously did `.select()` (which auto-pulls every
141
+ * view column) — `path` is no longer projected by the views, so call
142
+ * sites must list the projection explicitly. This helper keeps the
143
+ * shape consistent and the call sites tidy.
144
+ */
145
+ viewProjection(view, requestedLocale) {
146
+ return {
147
+ id: view.id,
148
+ document_id: view.document_id,
149
+ collection_id: view.collection_id,
150
+ collection_version: view.collection_version,
151
+ event_type: view.event_type,
152
+ status: view.status,
153
+ is_deleted: view.is_deleted,
154
+ created_at: view.created_at,
155
+ updated_at: view.updated_at,
156
+ created_by: view.created_by,
157
+ change_summary: view.change_summary,
158
+ path: this.pathProjection(sql `${view.document_id}`, requestedLocale),
159
+ };
160
+ }
161
+ /**
162
+ * Project list for direct `byline_document_versions` reads (history,
163
+ * version-by-id lookups). Mirrors `viewProjection` but against the
164
+ * underlying table — `path` is sourced from `byline_document_paths` via
165
+ * the locale priority chain, since it no longer lives on the version row.
166
+ */
167
+ documentVersionsProjection(requestedLocale) {
168
+ return {
169
+ id: documentVersions.id,
170
+ document_id: documentVersions.document_id,
171
+ collection_id: documentVersions.collection_id,
172
+ collection_version: documentVersions.collection_version,
173
+ event_type: documentVersions.event_type,
174
+ status: documentVersions.status,
175
+ is_deleted: documentVersions.is_deleted,
176
+ created_at: documentVersions.created_at,
177
+ updated_at: documentVersions.updated_at,
178
+ created_by: documentVersions.created_by,
179
+ change_summary: documentVersions.change_summary,
180
+ path: this.pathProjection(sql `${documentVersions.document_id}`, requestedLocale),
181
+ };
182
+ }
183
+ /**
184
+ * Emit a SQL fragment that resolves a `(collection_id, path)` tuple to
185
+ * a `document_id` via the locale priority chain. Used inside `WHERE`
186
+ * clauses for findByPath-style lookups:
187
+ *
188
+ * ```sql
189
+ * WHERE document_id = (
190
+ * SELECT document_id FROM byline_document_paths
191
+ * WHERE collection_id = ? AND path = ?
192
+ * AND locale = ANY(<chain>)
193
+ * ORDER BY array_position(<chain>, locale)
194
+ * LIMIT 1
195
+ * )
196
+ * ```
197
+ *
198
+ * Returns NULL when no row matches in any locale, which makes the
199
+ * outer `=` predicate fail cleanly (no document found).
200
+ */
201
+ resolveDocumentIdByPath(collection_id, path, requestedLocale) {
202
+ const chain = this.buildLocaleChain(requestedLocale);
203
+ const chainSql = sql.join(chain.map((l) => sql `${l}`), sql `, `);
204
+ return sql `(
205
+ SELECT ${documentPaths.document_id} FROM ${documentPaths}
206
+ WHERE ${documentPaths.collection_id} = ${collection_id}
207
+ AND ${documentPaths.path} = ${path}
208
+ AND ${documentPaths.locale} = ANY(ARRAY[${chainSql}]::text[])
209
+ ORDER BY array_position(ARRAY[${chainSql}]::text[], ${documentPaths.locale})
210
+ LIMIT 1
211
+ )`;
212
+ }
91
213
  /**
92
214
  * Reconstruct document fields from unified row values using schema-aware
93
215
  * restoration. Meta rows (from store_meta) are converted to
@@ -137,7 +259,10 @@ export class DocumentQueries {
137
259
  document_version_id: currentDocumentsView.id,
138
260
  document_id: currentDocumentsView.document_id,
139
261
  collection_id: currentDocumentsView.collection_id,
140
- path: currentDocumentsView.path,
262
+ // Lifecycle metadata fetch — always reads the default-locale path.
263
+ // This is internal lifecycle plumbing (status changes, delete checks),
264
+ // not a user-facing read, so the request locale never applies.
265
+ path: this.pathProjection(sql `${currentDocumentsView.document_id}`),
141
266
  status: currentDocumentsView.status,
142
267
  created_at: currentDocumentsView.created_at,
143
268
  updated_at: currentDocumentsView.updated_at,
@@ -151,7 +276,7 @@ export class DocumentQueries {
151
276
  document_version_id: row.document_version_id,
152
277
  document_id: row.document_id,
153
278
  collection_id: row.collection_id ?? '',
154
- path: row.path,
279
+ path: row.path ?? '',
155
280
  status: row.status ?? 'draft',
156
281
  created_at: row.created_at ?? new Date(),
157
282
  updated_at: row.updated_at ?? new Date(),
@@ -176,14 +301,14 @@ export class DocumentQueries {
176
301
  const outerScope = {
177
302
  docVersionId: sql `${view.id}`,
178
303
  status: sql `${view.status}`,
179
- path: sql `${view.path}`,
304
+ path: this.pathProjection(sql `${view.document_id}`, locale),
180
305
  };
181
306
  for (const f of filters) {
182
307
  baseConditions.push(this.buildFilterExists(f, locale, outerScope, readMode, 0));
183
308
  }
184
309
  }
185
310
  const [document] = await this.db
186
- .select()
311
+ .select(this.viewProjection(view, locale))
187
312
  .from(view)
188
313
  .where(and(...baseConditions));
189
314
  if (document == null) {
@@ -207,7 +332,7 @@ export class DocumentQueries {
207
332
  return {
208
333
  document_version_id: document.id,
209
334
  document_id: document.document_id,
210
- path: document.path,
335
+ path: document.path ?? '',
211
336
  status: document.status,
212
337
  created_at: document.created_at,
213
338
  updated_at: document.updated_at,
@@ -220,7 +345,7 @@ export class DocumentQueries {
220
345
  return {
221
346
  document_version_id: document.id,
222
347
  document_id: document.document_id,
223
- path: document.path,
348
+ path: document.path ?? '',
224
349
  status: document.status,
225
350
  created_at: document.created_at,
226
351
  updated_at: document.updated_at,
@@ -230,19 +355,27 @@ export class DocumentQueries {
230
355
  async getDocumentByPath({ collection_id, path, locale = 'en', reconstruct = true, readMode, filters, }) {
231
356
  const view = this.pickCurrentView(readMode);
232
357
  // 1. Get current version (or current published version, per readMode)
233
- const baseConditions = [eq(view.collection_id, collection_id), eq(view.path, path)];
358
+ //
359
+ // findByPath: resolve `(collection_id, path, locale-chain)` to a
360
+ // document_id via the document_paths subquery, then look up the
361
+ // current version by that id. Returns NULL when no path matches in
362
+ // any locale, which makes the outer `=` predicate fail cleanly.
363
+ const baseConditions = [
364
+ eq(view.collection_id, collection_id),
365
+ sql `${view.document_id} = ${this.resolveDocumentIdByPath(collection_id, path, locale)}`,
366
+ ];
234
367
  if (filters?.length) {
235
368
  const outerScope = {
236
369
  docVersionId: sql `${view.id}`,
237
370
  status: sql `${view.status}`,
238
- path: sql `${view.path}`,
371
+ path: this.pathProjection(sql `${view.document_id}`, locale),
239
372
  };
240
373
  for (const f of filters) {
241
374
  baseConditions.push(this.buildFilterExists(f, locale, outerScope, readMode, 0));
242
375
  }
243
376
  }
244
377
  const [document] = await this.db
245
- .select()
378
+ .select(this.viewProjection(view, locale))
246
379
  .from(view)
247
380
  .where(and(...baseConditions));
248
381
  if (document == null) {
@@ -266,7 +399,7 @@ export class DocumentQueries {
266
399
  return {
267
400
  document_version_id: document.id,
268
401
  document_id: document.document_id,
269
- path: document.path,
402
+ path: document.path ?? '',
270
403
  status: document.status,
271
404
  created_at: document.created_at,
272
405
  updated_at: document.updated_at,
@@ -278,7 +411,7 @@ export class DocumentQueries {
278
411
  return {
279
412
  document_version_id: document.id,
280
413
  document_id: document.document_id,
281
- path: document.path,
414
+ path: document.path ?? '',
282
415
  status: document.status,
283
416
  created_at: document.created_at,
284
417
  updated_at: document.updated_at,
@@ -289,9 +422,11 @@ export class DocumentQueries {
289
422
  * getDocumentByVersion — fetches a specific version and reconstructs its fields.
290
423
  */
291
424
  async getDocumentByVersion({ document_version_id, locale = 'all', }) {
292
- const document = await this.db.query.documentVersions.findFirst({
293
- where: eq(documentVersions.id, document_version_id),
294
- });
425
+ const projectionLocale = locale === 'all' ? undefined : locale;
426
+ const [document] = await this.db
427
+ .select(this.documentVersionsProjection(projectionLocale))
428
+ .from(documentVersions)
429
+ .where(eq(documentVersions.id, document_version_id));
295
430
  if (document == null) {
296
431
  throw ERR_NOT_FOUND({
297
432
  message: `no current version found for document ${document_version_id}`,
@@ -313,7 +448,7 @@ export class DocumentQueries {
313
448
  const documentWithFields = {
314
449
  document_version_id: document.id,
315
450
  document_id: document.document_id,
316
- path: document.path,
451
+ path: document.path ?? '',
317
452
  status: document.status,
318
453
  created_at: document.created_at,
319
454
  updated_at: document.updated_at,
@@ -330,7 +465,7 @@ export class DocumentQueries {
330
465
  if (document_version_ids.length === 0)
331
466
  return [];
332
467
  const docs = await this.db
333
- .select()
468
+ .select(this.documentVersionsProjection(locale === 'all' ? undefined : locale))
334
469
  .from(documentVersions)
335
470
  .where(inArray(documentVersions.id, document_version_ids));
336
471
  return this.reconstructDocuments({ documents: docs, locale });
@@ -355,9 +490,9 @@ export class DocumentQueries {
355
490
  // The locale used to compile filter EXISTS subqueries should resolve
356
491
  // values from a real locale, even when the surrounding read uses the
357
492
  // sentinel `'all'` (populate batches that span every locale do this).
358
- // Falling back to `'en'` here matches the default used by the
359
- // single-doc lookup methods.
360
- const filterLocale = locale === 'all' ? 'en' : locale;
493
+ // Falling back to the installation default here matches the default
494
+ // used by the single-doc lookup methods.
495
+ const filterLocale = locale === 'all' ? this.defaultContentLocale : locale;
361
496
  const baseConditions = [
362
497
  eq(view.collection_id, collection_id),
363
498
  inArray(view.document_id, document_ids),
@@ -366,14 +501,14 @@ export class DocumentQueries {
366
501
  const outerScope = {
367
502
  docVersionId: sql `${view.id}`,
368
503
  status: sql `${view.status}`,
369
- path: sql `${view.path}`,
504
+ path: this.pathProjection(sql `${view.document_id}`, filterLocale),
370
505
  };
371
506
  for (const f of filters) {
372
507
  baseConditions.push(this.buildFilterExists(f, filterLocale, outerScope, readMode, 0));
373
508
  }
374
509
  }
375
510
  const docs = await this.db
376
- .select()
511
+ .select(this.viewProjection(view, filterLocale))
377
512
  .from(view)
378
513
  .where(and(...baseConditions));
379
514
  return this.reconstructDocuments({ documents: docs, locale, fields });
@@ -401,10 +536,14 @@ export class DocumentQueries {
401
536
  const total = Number(totalResult[0]?.count) || 0;
402
537
  const total_pages = Math.ceil(total / page_size);
403
538
  const offset = (page - 1) * page_size;
404
- const orderColumn = order === 'path' ? documentVersions.path : documentVersions.created_at;
539
+ // History is per-document; path is sticky so every version row has the
540
+ // same value. `order === 'path'` is degenerate and was removed when
541
+ // path moved to byline_document_paths — fall back to created_at.
542
+ const orderColumn = documentVersions.created_at;
405
543
  const orderFunc = desc === true ? sql `DESC` : sql `ASC`;
544
+ const projectionLocale = locale === 'all' ? undefined : locale;
406
545
  const result = await this.db
407
- .select()
546
+ .select(this.documentVersionsProjection(projectionLocale))
408
547
  .from(documentVersions)
409
548
  .where(and(eq(documentVersions.collection_id, collection_id), eq(documentVersions.document_id, document_id)))
410
549
  .orderBy(sql `${orderColumn} ${orderFunc}`)
@@ -479,10 +618,10 @@ export class DocumentQueries {
479
618
  const outerScope = {
480
619
  docVersionId: sql `${currentDocumentsView.id}`,
481
620
  status: sql `${currentDocumentsView.status}`,
482
- path: sql `${currentDocumentsView.path}`,
621
+ path: this.pathProjection(sql `${currentDocumentsView.document_id}`, this.defaultContentLocale),
483
622
  };
484
623
  for (const f of filters) {
485
- conditions.push(this.buildFilterExists(f, 'en', outerScope, undefined, 0));
624
+ conditions.push(this.buildFilterExists(f, this.defaultContentLocale, outerScope, undefined, 0));
486
625
  }
487
626
  }
488
627
  const rows = await this.db
@@ -564,7 +703,7 @@ export class DocumentQueries {
564
703
  const documentWithFields = {
565
704
  document_version_id: doc.id,
566
705
  document_id: doc.document_id,
567
- path: doc.path,
706
+ path: doc.path ?? '',
568
707
  status: doc.status,
569
708
  created_at: doc.created_at,
570
709
  updated_at: doc.updated_at,
@@ -635,7 +774,7 @@ export class DocumentQueries {
635
774
  conditions.push(sql `d.status = ${status}`);
636
775
  }
637
776
  if (pathFilter) {
638
- conditions.push(this.buildDocumentLevelCondition('d.path', pathFilter.operator, pathFilter.value));
777
+ conditions.push(this.buildFilterCondition(this.pathProjection(sql `d.document_id`, locale), pathFilter.operator, pathFilter.value));
639
778
  }
640
779
  // Text search across configured search fields via EXISTS on store_text.
641
780
  if (query) {
@@ -653,7 +792,11 @@ export class DocumentQueries {
653
792
  // introduces its own alias scope (`r${depth}`, `td${depth}`) so nested
654
793
  // EXISTS clauses don't shadow their outer relation's aliases.
655
794
  for (const filter of filters) {
656
- conditions.push(this.buildFilterExists(filter, locale, { docVersionId: sql `d.id`, status: sql `d.status`, path: sql `d.path` }, readMode, 0));
795
+ conditions.push(this.buildFilterExists(filter, locale, {
796
+ docVersionId: sql `d.id`,
797
+ status: sql `d.status`,
798
+ path: this.pathProjection(sql `d.document_id`, locale),
799
+ }, readMode, 0));
657
800
  }
658
801
  const whereClause = sql.join(conditions, sql ` AND `);
659
802
  // -- Build ORDER BY -------------------------------------------------------
@@ -697,8 +840,14 @@ export class DocumentQueries {
697
840
  return { documents: [], total: 0 };
698
841
  }
699
842
  // -- Main query -----------------------------------------------------------
843
+ //
844
+ // `d.*` no longer includes `path` (it lives in byline_document_paths
845
+ // keyed by document_id + locale). Project it via the locale-aware
846
+ // subquery so the result rows still carry `path` for the in-memory
847
+ // Document shape.
848
+ const pathProjectionSql = this.pathProjection(sql `d.document_id`, locale);
700
849
  const mainQuery = sql `
701
- SELECT d.*
850
+ SELECT d.*, ${pathProjectionSql} AS path
702
851
  FROM ${sourceTable} d
703
852
  ${sortJoin}
704
853
  WHERE ${whereClause}
@@ -712,7 +861,7 @@ export class DocumentQueries {
712
861
  document_id: row.document_id,
713
862
  collection_id: row.collection_id,
714
863
  collection_version: row.collection_version,
715
- path: row.path,
864
+ path: row.path ?? null,
716
865
  event_type: row.event_type,
717
866
  status: row.status,
718
867
  is_deleted: row.is_deleted,
@@ -830,7 +979,9 @@ export class DocumentQueries {
830
979
  const innerScope = {
831
980
  docVersionId: sql.raw(`td${depth}.id`),
832
981
  status: sql.raw(`td${depth}.status`),
833
- path: sql.raw(`td${depth}.path`),
982
+ // `td${depth}.path` no longer exists on the view; resolve via the
983
+ // locale priority chain against byline_document_paths instead.
984
+ path: this.pathProjection(sql.raw(`td${depth}.document_id`), locale),
834
985
  };
835
986
  const nestedConditions = filter.nested.map((nested) => this.buildFilterExists(nested, locale, innerScope, readMode, depth + 1));
836
987
  const nestedAnd = nestedConditions.length > 0 ? sql ` AND ${sql.join(nestedConditions, sql ` AND `)}` : sql ``;
@@ -987,9 +1138,9 @@ export class DocumentQueries {
987
1138
  });
988
1139
  }
989
1140
  }
990
- export function createQueryBuilders(db, collections) {
1141
+ export function createQueryBuilders(db, collections, defaultContentLocale) {
991
1142
  return {
992
1143
  collections: new CollectionQueries(db),
993
- documents: new DocumentQueries(db, collections),
1144
+ documents: new DocumentQueries(db, collections, defaultContentLocale),
994
1145
  };
995
1146
  }
@@ -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,202 @@
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
+ /**
9
+ * Integration tests for the byline_document_paths layer.
10
+ *
11
+ * Exercises the storage adapter directly (not the lifecycle) so each test
12
+ * isolates one storage-level invariant:
13
+ *
14
+ * - per-(collection, locale) path uniqueness — the second insert with
15
+ * the same `(collection_id, locale, path)` triggers Postgres SQLSTATE
16
+ * 23505 on `idx_document_paths_collection_locale_path`.
17
+ * - locale fallback in reads — `getDocumentByPath` with a non-default
18
+ * `locale` resolves through the priority chain `[requested, default]`
19
+ * and finds the default-locale row when no row exists for the
20
+ * requested locale.
21
+ * - upsert-on-self — re-issuing `createDocumentVersion` with the same
22
+ * `path` for the same `documentId` succeeds (the conflict target is
23
+ * `(document_id, locale)`, so the existing row is updated in place).
24
+ */
25
+ import assert from 'node:assert';
26
+ import { after, before, describe, it } from 'node:test';
27
+ import { setupTestDB, teardownTestDB } from '../../../lib/test-helper.js';
28
+ let commandBuilders;
29
+ let queryBuilders;
30
+ const timestamp = Date.now();
31
+ const PathsCollectionConfig = {
32
+ path: `paths-${timestamp}`,
33
+ labels: { singular: 'PathsTest', plural: 'PathsTests' },
34
+ fields: [{ name: 'title', type: 'text' }],
35
+ };
36
+ let testCollection = {};
37
+ describe('byline_document_paths integration', () => {
38
+ before(async () => {
39
+ const testDB = setupTestDB([PathsCollectionConfig]);
40
+ commandBuilders = testDB.commandBuilders;
41
+ queryBuilders = testDB.queryBuilders;
42
+ const result = await commandBuilders.collections.create(PathsCollectionConfig.path, PathsCollectionConfig);
43
+ const collection = result[0];
44
+ if (collection == null) {
45
+ throw new Error('Failed to create test collection');
46
+ }
47
+ testCollection = { id: collection.id, name: collection.path };
48
+ });
49
+ after(async () => {
50
+ try {
51
+ await commandBuilders.collections.delete(testCollection.id);
52
+ }
53
+ catch (error) {
54
+ console.error('Failed to cleanup test collection:', error);
55
+ }
56
+ await teardownTestDB();
57
+ });
58
+ it('rejects a second create with the same (collection_id, locale, path)', async () => {
59
+ const sharedPath = `dup-${Date.now()}`;
60
+ // First create succeeds — no row yet under (collection, 'en', path).
61
+ await commandBuilders.documents.createDocumentVersion({
62
+ collectionId: testCollection.id,
63
+ collectionVersion: 1,
64
+ collectionConfig: PathsCollectionConfig,
65
+ action: 'create',
66
+ documentData: { title: 'First' },
67
+ path: sharedPath,
68
+ locale: 'all',
69
+ status: 'draft',
70
+ });
71
+ // Second create — different document, same path — collides on the
72
+ // unique index `idx_document_paths_collection_locale_path`.
73
+ let caught = null;
74
+ try {
75
+ await commandBuilders.documents.createDocumentVersion({
76
+ collectionId: testCollection.id,
77
+ collectionVersion: 1,
78
+ collectionConfig: PathsCollectionConfig,
79
+ action: 'create',
80
+ documentData: { title: 'Second' },
81
+ path: sharedPath,
82
+ locale: 'all',
83
+ status: 'draft',
84
+ });
85
+ }
86
+ catch (err) {
87
+ caught = err;
88
+ }
89
+ assert.ok(caught, 'expected unique-constraint violation on duplicate path');
90
+ // Drizzle wraps pg errors in DrizzleQueryError with the original error
91
+ // attached as `cause`. The lifecycle layer's rethrowPathConflict reads
92
+ // both the wrapper and the cause to detect 23505 + the path constraint
93
+ // name; mirror that here.
94
+ const original = caught.cause ?? caught;
95
+ assert.strictEqual(original.code, '23505', `expected SQLSTATE 23505, got ${original?.code}`);
96
+ assert.match(String(original.constraint ?? ''), /document_paths_collection_locale_path/, `constraint name should reference the path index, got ${original?.constraint}`);
97
+ });
98
+ it('upserts in place when the same document re-saves the same path', async () => {
99
+ const sharedPath = `same-doc-${Date.now()}`;
100
+ const first = await commandBuilders.documents.createDocumentVersion({
101
+ collectionId: testCollection.id,
102
+ collectionVersion: 1,
103
+ collectionConfig: PathsCollectionConfig,
104
+ action: 'create',
105
+ documentData: { title: 'V1' },
106
+ path: sharedPath,
107
+ locale: 'all',
108
+ status: 'draft',
109
+ });
110
+ const documentId = first.document.document_id;
111
+ // Same path on the same logical document — the conflict target is
112
+ // (document_id, locale), so onConflictDoUpdate handles this.
113
+ const second = await commandBuilders.documents.createDocumentVersion({
114
+ documentId,
115
+ collectionId: testCollection.id,
116
+ collectionVersion: 1,
117
+ collectionConfig: PathsCollectionConfig,
118
+ action: 'update',
119
+ documentData: { title: 'V2' },
120
+ path: sharedPath,
121
+ locale: 'all',
122
+ status: 'draft',
123
+ previousVersionId: first.document.id,
124
+ });
125
+ assert.strictEqual(second.document.document_id, documentId, 'same logical document');
126
+ });
127
+ it('updates the path row in place when a document changes its path', async () => {
128
+ const originalPath = `original-${Date.now()}`;
129
+ const updatedPath = `updated-${Date.now()}`;
130
+ const first = await commandBuilders.documents.createDocumentVersion({
131
+ collectionId: testCollection.id,
132
+ collectionVersion: 1,
133
+ collectionConfig: PathsCollectionConfig,
134
+ action: 'create',
135
+ documentData: { title: 'X' },
136
+ path: originalPath,
137
+ locale: 'all',
138
+ status: 'draft',
139
+ });
140
+ const documentId = first.document.document_id;
141
+ await commandBuilders.documents.createDocumentVersion({
142
+ documentId,
143
+ collectionId: testCollection.id,
144
+ collectionVersion: 1,
145
+ collectionConfig: PathsCollectionConfig,
146
+ action: 'update',
147
+ documentData: { title: 'X' },
148
+ path: updatedPath,
149
+ locale: 'all',
150
+ status: 'draft',
151
+ previousVersionId: first.document.id,
152
+ });
153
+ // The new path resolves; the old one no longer does.
154
+ const found = await queryBuilders.documents.getDocumentByPath({
155
+ collection_id: testCollection.id,
156
+ path: updatedPath,
157
+ reconstruct: false,
158
+ });
159
+ assert.ok(found, 'updated path should resolve');
160
+ assert.strictEqual(found.document_id, documentId);
161
+ const oldNotFound = await queryBuilders.documents.getDocumentByPath({
162
+ collection_id: testCollection.id,
163
+ path: originalPath,
164
+ reconstruct: false,
165
+ });
166
+ assert.strictEqual(oldNotFound, null, 'original path no longer resolves');
167
+ });
168
+ it('falls back to the default-locale path row when the requested locale has no row', async () => {
169
+ const onlyDefaultPath = `default-only-${Date.now()}`;
170
+ const first = await commandBuilders.documents.createDocumentVersion({
171
+ collectionId: testCollection.id,
172
+ collectionVersion: 1,
173
+ collectionConfig: PathsCollectionConfig,
174
+ action: 'create',
175
+ documentData: { title: 'EN-Only' },
176
+ path: onlyDefaultPath,
177
+ locale: 'all',
178
+ status: 'draft',
179
+ });
180
+ const documentId = first.document.document_id;
181
+ // No 'fr' row exists for this document; the read still resolves via
182
+ // the [requested, default] priority chain.
183
+ const found = await queryBuilders.documents.getDocumentByPath({
184
+ collection_id: testCollection.id,
185
+ path: onlyDefaultPath,
186
+ locale: 'fr',
187
+ reconstruct: false,
188
+ });
189
+ assert.ok(found, 'fallback chain should resolve via the en row');
190
+ assert.strictEqual(found.document_id, documentId);
191
+ assert.strictEqual(found.path, onlyDefaultPath);
192
+ });
193
+ it('returns null on getDocumentByPath when no row matches in any locale', async () => {
194
+ const result = await queryBuilders.documents.getDocumentByPath({
195
+ collection_id: testCollection.id,
196
+ path: `does-not-exist-${Date.now()}`,
197
+ locale: 'fr',
198
+ reconstruct: false,
199
+ });
200
+ assert.strictEqual(result, null);
201
+ });
202
+ });
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": "1.7.7",
5
+ "version": "1.8.1",
6
6
  "engines": {
7
7
  "node": ">=20.9.0"
8
8
  },
@@ -52,9 +52,9 @@
52
52
  "pg": "^8.20.0",
53
53
  "uuid": "^14.0.0",
54
54
  "zod": "^4.4.2",
55
- "@byline/auth": "1.7.7",
56
- "@byline/core": "1.7.7",
57
- "@byline/admin": "1.7.7"
55
+ "@byline/auth": "1.8.1",
56
+ "@byline/core": "1.8.1",
57
+ "@byline/admin": "1.8.1"
58
58
  },
59
59
  "devDependencies": {
60
60
  "@biomejs/biome": "2.4.14",