@byline/db-postgres 1.1.0 → 1.2.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.
|
@@ -29,8 +29,8 @@ interface FlattenedDateTimeFieldValue extends BaseFlattenedFieldData {
|
|
|
29
29
|
field_type: 'datetime';
|
|
30
30
|
date_type: 'datetime' | 'date' | 'time';
|
|
31
31
|
value_time?: string;
|
|
32
|
-
value_date?: Date;
|
|
33
|
-
value_timestamp_tz?: Date;
|
|
32
|
+
value_date?: Date | string;
|
|
33
|
+
value_timestamp_tz?: Date | string;
|
|
34
34
|
}
|
|
35
35
|
interface FlattenedFileFieldValue extends BaseFlattenedFieldData {
|
|
36
36
|
field_type: 'file';
|
|
@@ -63,9 +63,20 @@ export const prepareFieldInsertBuckets = (flattenedFields, document_version_id,
|
|
|
63
63
|
buckets.datetime.push({
|
|
64
64
|
...base,
|
|
65
65
|
date_type: field.date_type,
|
|
66
|
-
|
|
66
|
+
// The Postgres `date` column (drizzle default mode) round-trips as
|
|
67
|
+
// a 'YYYY-MM-DD' string, while admin form widgets produce `Date`
|
|
68
|
+
// objects. Both shapes need to flatten cleanly because restore
|
|
69
|
+
// (and any other path that re-feeds previously-read content)
|
|
70
|
+
// hands us the string form.
|
|
71
|
+
value_date: toDateOnlyString(field.value_date),
|
|
67
72
|
value_time: field.value_time,
|
|
68
|
-
value_timestamp_tz
|
|
73
|
+
// `value_timestamp_tz` arrives as a `Date` from form widgets but
|
|
74
|
+
// as a string from `getAllFieldValues` — the UNION ALL declares
|
|
75
|
+
// the column as `timestamp` (no TZ) via `storage-store-manifest`,
|
|
76
|
+
// and node-postgres maps non-TZ timestamps to strings. Drizzle's
|
|
77
|
+
// own `mapToDriverValue` calls `.toISOString()` unguarded on
|
|
78
|
+
// insert, so we coerce here.
|
|
79
|
+
value_timestamp_tz: toDate(field.value_timestamp_tz),
|
|
69
80
|
});
|
|
70
81
|
continue;
|
|
71
82
|
case 'file':
|
|
@@ -112,3 +123,34 @@ export const prepareFieldInsertBuckets = (flattenedFields, document_version_id,
|
|
|
112
123
|
}
|
|
113
124
|
return buckets;
|
|
114
125
|
};
|
|
126
|
+
/**
|
|
127
|
+
* Coerce a date-only field value to the 'YYYY-MM-DD' string shape the
|
|
128
|
+
* Postgres `date` column uses on read. Accepts:
|
|
129
|
+
* - `Date` → ISO date prefix
|
|
130
|
+
* - string → passed through (already in storage shape)
|
|
131
|
+
* - null / undefined → undefined (no insert)
|
|
132
|
+
*/
|
|
133
|
+
function toDateOnlyString(value) {
|
|
134
|
+
if (value == null)
|
|
135
|
+
return undefined;
|
|
136
|
+
if (value instanceof Date)
|
|
137
|
+
return value.toISOString().slice(0, 10);
|
|
138
|
+
return value;
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Coerce a timestamp-with-tz field value to a `Date` instance. Accepts:
|
|
142
|
+
* - `Date` → passed through
|
|
143
|
+
* - string → parsed via `new Date(...)` (ISO or PG `timestamp` shape)
|
|
144
|
+
* - null / undefined → undefined (no insert)
|
|
145
|
+
*
|
|
146
|
+
* Drizzle's `mapToDriverValue` for `timestamp({ withTimezone: true })` calls
|
|
147
|
+
* `.toISOString()` on the supplied value with no guard, so callers must
|
|
148
|
+
* present a `Date`.
|
|
149
|
+
*/
|
|
150
|
+
function toDate(value) {
|
|
151
|
+
if (value == null)
|
|
152
|
+
return undefined;
|
|
153
|
+
if (value instanceof Date)
|
|
154
|
+
return value;
|
|
155
|
+
return new Date(value);
|
|
156
|
+
}
|
|
@@ -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,159 @@
|
|
|
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 test for the "make current" / restore-version flow.
|
|
10
|
+
*
|
|
11
|
+
* Validates that the Postgres locale='all' round-trip
|
|
12
|
+
* (`getDocumentByVersion` → `createDocumentVersion`) preserves multi-locale
|
|
13
|
+
* content and stable block `_id`s — the core invariant
|
|
14
|
+
* `restoreDocumentVersion` in @byline/core depends on. Pure storage-layer:
|
|
15
|
+
* the lifecycle wrapper, auth, and hooks are covered by unit tests in
|
|
16
|
+
* `packages/core/src/services/document-lifecycle.test.node.ts`.
|
|
17
|
+
*/
|
|
18
|
+
import assert from 'node:assert';
|
|
19
|
+
import { after, before, describe, it } from 'node:test';
|
|
20
|
+
import { setupTestDB, teardownTestDB } from '../../../lib/test-helper.js';
|
|
21
|
+
let commandBuilders;
|
|
22
|
+
let queryBuilders;
|
|
23
|
+
const timestamp = Date.now();
|
|
24
|
+
const RestoreCollectionConfig = {
|
|
25
|
+
path: `restore-${timestamp}`,
|
|
26
|
+
labels: { singular: 'Restorable', plural: 'Restorables' },
|
|
27
|
+
fields: [
|
|
28
|
+
{ name: 'sku', type: 'text' },
|
|
29
|
+
{ name: 'title', type: 'text', localized: true },
|
|
30
|
+
{
|
|
31
|
+
name: 'sections',
|
|
32
|
+
type: 'array',
|
|
33
|
+
fields: [
|
|
34
|
+
{
|
|
35
|
+
name: 'sectionItem',
|
|
36
|
+
type: 'array',
|
|
37
|
+
fields: [
|
|
38
|
+
{ name: 'heading', type: 'text', localized: true },
|
|
39
|
+
{ name: 'body', type: 'text', localized: true },
|
|
40
|
+
],
|
|
41
|
+
},
|
|
42
|
+
],
|
|
43
|
+
},
|
|
44
|
+
],
|
|
45
|
+
};
|
|
46
|
+
let testCollection = {};
|
|
47
|
+
describe('Document version restore — storage round-trip', () => {
|
|
48
|
+
before(async () => {
|
|
49
|
+
const testDB = setupTestDB([RestoreCollectionConfig]);
|
|
50
|
+
commandBuilders = testDB.commandBuilders;
|
|
51
|
+
queryBuilders = testDB.queryBuilders;
|
|
52
|
+
const result = await commandBuilders.collections.create(RestoreCollectionConfig.path, RestoreCollectionConfig);
|
|
53
|
+
const collection = result[0];
|
|
54
|
+
if (collection == null)
|
|
55
|
+
throw new Error('Failed to create test collection');
|
|
56
|
+
testCollection = { id: collection.id };
|
|
57
|
+
});
|
|
58
|
+
after(async () => {
|
|
59
|
+
try {
|
|
60
|
+
await commandBuilders.collections.delete(testCollection.id);
|
|
61
|
+
}
|
|
62
|
+
catch (err) {
|
|
63
|
+
console.error('Failed to cleanup test collection:', err);
|
|
64
|
+
}
|
|
65
|
+
await teardownTestDB();
|
|
66
|
+
});
|
|
67
|
+
it('round-trips multi-locale fields and preserves block _ids when re-emitted via locale: "all"', async () => {
|
|
68
|
+
const v1Data = {
|
|
69
|
+
sku: `RESTORE-${timestamp}`,
|
|
70
|
+
title: { en: 'V1 EN', fr: 'V1 FR' },
|
|
71
|
+
sections: [
|
|
72
|
+
{
|
|
73
|
+
sectionItem: [
|
|
74
|
+
{ heading: { en: 'Intro EN', fr: 'Intro FR' }, body: { en: 'A', fr: 'A-fr' } },
|
|
75
|
+
{ heading: { en: 'Body EN', fr: 'Body FR' }, body: { en: 'B', fr: 'B-fr' } },
|
|
76
|
+
],
|
|
77
|
+
},
|
|
78
|
+
],
|
|
79
|
+
};
|
|
80
|
+
const v1 = await commandBuilders.documents.createDocumentVersion({
|
|
81
|
+
collectionId: testCollection.id,
|
|
82
|
+
collectionVersion: 1,
|
|
83
|
+
collectionConfig: RestoreCollectionConfig,
|
|
84
|
+
action: 'create',
|
|
85
|
+
documentData: v1Data,
|
|
86
|
+
path: v1Data.sku,
|
|
87
|
+
locale: 'all',
|
|
88
|
+
status: 'draft',
|
|
89
|
+
});
|
|
90
|
+
const documentId = v1.document.document_id;
|
|
91
|
+
const v1Id = v1.document.id;
|
|
92
|
+
// v1 reconstruct — captures the assigned _ids
|
|
93
|
+
const v1Read = await queryBuilders.documents.getDocumentByVersion({
|
|
94
|
+
document_version_id: v1Id,
|
|
95
|
+
locale: 'all',
|
|
96
|
+
});
|
|
97
|
+
const v1Sections = v1Read.fields.sections;
|
|
98
|
+
const v1ItemIds = v1Sections[0].sectionItem.map((item) => item._id);
|
|
99
|
+
assert.ok(v1ItemIds.every((id) => typeof id === 'string' && id.length > 0), 'v1 items should have stable _ids');
|
|
100
|
+
// Mutate to v2 (different content)
|
|
101
|
+
const v2Data = {
|
|
102
|
+
sku: v1Data.sku,
|
|
103
|
+
title: { en: 'V2 EN', fr: 'V2 FR' },
|
|
104
|
+
sections: [
|
|
105
|
+
{
|
|
106
|
+
sectionItem: [
|
|
107
|
+
{ heading: { en: 'Replaced EN', fr: 'Replaced FR' }, body: { en: 'X', fr: 'X-fr' } },
|
|
108
|
+
],
|
|
109
|
+
},
|
|
110
|
+
],
|
|
111
|
+
};
|
|
112
|
+
await commandBuilders.documents.createDocumentVersion({
|
|
113
|
+
documentId,
|
|
114
|
+
collectionId: testCollection.id,
|
|
115
|
+
collectionVersion: 1,
|
|
116
|
+
collectionConfig: RestoreCollectionConfig,
|
|
117
|
+
action: 'update',
|
|
118
|
+
documentData: v2Data,
|
|
119
|
+
path: v1Data.sku,
|
|
120
|
+
locale: 'all',
|
|
121
|
+
status: 'draft',
|
|
122
|
+
});
|
|
123
|
+
// Restore: read v1 with locale='all' and re-emit verbatim with locale='all'.
|
|
124
|
+
// This is what `restoreDocumentVersion` in @byline/core does.
|
|
125
|
+
const sourceFields = v1Read.fields;
|
|
126
|
+
const v3 = await commandBuilders.documents.createDocumentVersion({
|
|
127
|
+
documentId,
|
|
128
|
+
collectionId: testCollection.id,
|
|
129
|
+
collectionVersion: 1,
|
|
130
|
+
collectionConfig: RestoreCollectionConfig,
|
|
131
|
+
action: 'restore',
|
|
132
|
+
documentData: sourceFields,
|
|
133
|
+
path: v1Data.sku,
|
|
134
|
+
locale: 'all',
|
|
135
|
+
status: 'draft',
|
|
136
|
+
});
|
|
137
|
+
assert.strictEqual(v3.document.event_type, 'restore', 'event_type should be persisted as "restore"');
|
|
138
|
+
// Reconstruct v3. Multi-locale fields, block _ids, and per-item content
|
|
139
|
+
// should match v1 — not v2.
|
|
140
|
+
const v3Read = await queryBuilders.documents.getDocumentByVersion({
|
|
141
|
+
document_version_id: v3.document.id,
|
|
142
|
+
locale: 'all',
|
|
143
|
+
});
|
|
144
|
+
const v3Fields = v3Read.fields;
|
|
145
|
+
assert.deepStrictEqual(v3Fields.title, v1Data.title, 'restored title should match v1 across all locales');
|
|
146
|
+
const v3Sections = v3Fields.sections;
|
|
147
|
+
const v3ItemIds = v3Sections[0].sectionItem.map((item) => item._id);
|
|
148
|
+
assert.deepStrictEqual(v3ItemIds, v1ItemIds, 'restored block items should keep the v1 _ids verbatim (identity preserved across restore)');
|
|
149
|
+
assert.strictEqual(v3Sections[0].sectionItem.length, 2, "restored version should have v1's two items, not v2's single item");
|
|
150
|
+
assert.deepStrictEqual(v3Sections[0].sectionItem[0].heading, {
|
|
151
|
+
en: 'Intro EN',
|
|
152
|
+
fr: 'Intro FR',
|
|
153
|
+
});
|
|
154
|
+
assert.deepStrictEqual(v3Sections[0].sectionItem[1].heading, {
|
|
155
|
+
en: 'Body EN',
|
|
156
|
+
fr: 'Body FR',
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
});
|
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.1
|
|
5
|
+
"version": "1.2.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/core": "1.1
|
|
56
|
-
"@byline/auth": "1.1
|
|
57
|
-
"@byline/admin": "1.1
|
|
55
|
+
"@byline/core": "1.2.1",
|
|
56
|
+
"@byline/auth": "1.2.1",
|
|
57
|
+
"@byline/admin": "1.2.1"
|
|
58
58
|
},
|
|
59
59
|
"devDependencies": {
|
|
60
60
|
"@biomejs/biome": "2.4.14",
|