@byline/db-postgres 1.7.7 → 1.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/database/schema/index.d.ts +113 -53
- package/dist/database/schema/index.js +33 -11
- package/dist/index.d.ts +9 -1
- package/dist/index.js +3 -3
- package/dist/lib/test-helper.js +2 -2
- package/dist/modules/storage/storage-commands.d.ts +10 -4
- package/dist/modules/storage/storage-commands.js +30 -5
- package/dist/modules/storage/storage-queries.d.ts +63 -2
- package/dist/modules/storage/storage-queries.js +186 -35
- package/dist/modules/storage/tests/storage-document-paths.test.d.ts +8 -0
- package/dist/modules/storage/tests/storage-document-paths.test.js +202 -0
- package/package.json +4 -4
|
@@ -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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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,
|
package/dist/lib/test-helper.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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
|
|
293
|
-
|
|
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
|
|
359
|
-
// single-doc lookup methods.
|
|
360
|
-
const filterLocale = locale === 'all' ?
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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,
|
|
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.
|
|
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, {
|
|
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
|
-
|
|
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.
|
|
5
|
+
"version": "1.8.0",
|
|
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.
|
|
56
|
-
"@byline/core": "1.
|
|
57
|
-
"@byline/admin": "1.
|
|
55
|
+
"@byline/auth": "1.8.0",
|
|
56
|
+
"@byline/core": "1.8.0",
|
|
57
|
+
"@byline/admin": "1.8.0"
|
|
58
58
|
},
|
|
59
59
|
"devDependencies": {
|
|
60
60
|
"@biomejs/biome": "2.4.14",
|