@byline/db-postgres 3.13.3 → 3.15.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.
@@ -109,6 +109,22 @@ function* flattenFieldDataGen(field, data, locale, field_path) {
109
109
  yield* flattenFieldSetDataGen(block.fields, item_data, locale, item_path);
110
110
  }
111
111
  }
112
+ // Ordered multi-target relation: emit one relation row per item at an
113
+ // indexed path (`<field>.0`, `<field>.1`, …), mirroring the array path
114
+ // convention. `restoreFieldSetData` reassembles the ordered array.
115
+ // Relations are non-localized, so every row carries locale 'all'. Identity
116
+ // is the `targetDocumentId` (the widget dedups), so no `store_meta` rows are
117
+ // needed. Must intercept before the generic value branch, which would
118
+ // otherwise read the whole array as a single relation value.
119
+ else if (field.type === 'relation' && field.hasMany) {
120
+ const items = data;
121
+ for (let i = 0; i < items.length; i++) {
122
+ const item = items[i];
123
+ if (item == null)
124
+ continue;
125
+ yield flattenValueFieldData(field, [...field_path, `${i}`], item, 'all');
126
+ }
127
+ }
112
128
  // Handle localized fields separately.
113
129
  else if (field.localized) {
114
130
  // If locale is 'all', data is expected to be an object that maps locales to
@@ -1233,7 +1233,13 @@ export class DocumentQueries {
1233
1233
  // Text search across configured search fields via EXISTS on store_text.
1234
1234
  if (query) {
1235
1235
  const definition = await this.getDefinitionForCollection(collection_id);
1236
- const searchFields = definition.search?.fields ?? ['title'];
1236
+ // The admin list-view box matches store_text rows by field name. Derive
1237
+ // the names from the role-based `search.body` declaration (a field path
1238
+ // or `{ field, boost }`); fall back to the identity-ish `title`.
1239
+ const bodyDecls = definition.search?.body ?? [];
1240
+ const searchFields = bodyDecls.length > 0
1241
+ ? bodyDecls.map((decl) => (typeof decl === 'string' ? decl : decl.field))
1242
+ : ['title'];
1237
1243
  const searchConditions = searchFields.map((fieldName) => sql `(field_name = ${fieldName} AND value ILIKE ${`%${query}%`})`);
1238
1244
  conditions.push(sql `EXISTS (
1239
1245
  SELECT 1 FROM byline_store_text
@@ -39,9 +39,13 @@ export const restoreFieldSetData = (fields, flattenedData, resolveLocale) => {
39
39
  result[fieldName] = restoreFieldData(field, result[fieldName], item, 1, warnings, resolveLocale);
40
40
  }
41
41
  }
42
- // Array/blocks/group fixup.
42
+ // Array/blocks/group/hasMany-relation fixup — a required list-shaped field
43
+ // with no stored rows reconstructs as an empty array rather than absent.
43
44
  for (const field of fields) {
44
- if ((field.type === 'array' || field.type === 'blocks') && !field.optional) {
45
+ if ((field.type === 'array' ||
46
+ field.type === 'blocks' ||
47
+ (field.type === 'relation' && field.hasMany)) &&
48
+ !field.optional) {
45
49
  result[field.name] = result[field.name] || [];
46
50
  }
47
51
  else if (field.type === 'group' && !field.optional) {
@@ -60,6 +64,9 @@ const restoreFieldData = (field, target, data, pathIndex, warnings, resolveLocal
60
64
  else if (field.type === 'blocks') {
61
65
  return restoreBlocksFieldData(field, target, data, pathIndex, warnings, resolveLocale);
62
66
  }
67
+ else if (field.type === 'relation' && field.hasMany) {
68
+ return restoreMultiRelationFieldData(target, data, pathIndex, warnings);
69
+ }
63
70
  if (field.localized) {
64
71
  if (data.locale === 'all') {
65
72
  warnings.push(`Received non-localized data for localized field at path ${data.field_path.join('.')}`);
@@ -172,6 +179,22 @@ const restoreBlocksFieldData = (field, target, data, pathIndex, warnings, resolv
172
179
  target[arrayIndex][fieldName] = restoreFieldData(subField, target[arrayIndex][fieldName], data, pathIndex + 3, warnings, resolveLocale);
173
180
  return target;
174
181
  };
182
+ const restoreMultiRelationFieldData = (target, data, pathIndex, warnings) => {
183
+ // Path shape: `[...prefix, <field>, <index>]`. At `pathIndex` we have the
184
+ // ordered position; the row itself is a single relation value.
185
+ const arrayIndex = Number.parseInt(data.field_path[pathIndex] ?? '', 10);
186
+ if (Number.isNaN(arrayIndex) || arrayIndex < 0) {
187
+ warnings.push(`Invalid relation index '${data.field_path[pathIndex]}' in path ${data.field_path.join('.')}`);
188
+ return target;
189
+ }
190
+ if (data.field_type !== 'relation') {
191
+ warnings.push(`Expected relation row for hasMany relation but got ${data.field_type} at path ${data.field_path.join('.')}`);
192
+ return target;
193
+ }
194
+ target = target || [];
195
+ target[arrayIndex] = extractValueFieldData(data);
196
+ return target;
197
+ };
175
198
  const extractValueFieldData = (data) => {
176
199
  switch (data.field_type) {
177
200
  case 'text':
@@ -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,92 @@
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
+ * Storage round-trip for `hasMany` relations — an ordered list of relation
10
+ * values flattens to indexed `store_relation` rows (`<field>.0`, `<field>.1`,
11
+ * …) and reconstructs back into the same ordered array. Pure functions; no
12
+ * Postgres needed.
13
+ */
14
+ import { defineCollection } from '@byline/core';
15
+ import { describe, expect, it } from 'vitest';
16
+ import { flattenFieldSetData } from '../storage-flatten.js';
17
+ import { restoreFieldSetData } from '../storage-restore.js';
18
+ const Articles = defineCollection({
19
+ path: 'articles',
20
+ labels: { singular: 'Article', plural: 'Articles' },
21
+ fields: [
22
+ { name: 'title', type: 'text' },
23
+ {
24
+ name: 'authors',
25
+ type: 'relation',
26
+ targetCollection: 'people',
27
+ hasMany: true,
28
+ optional: true,
29
+ },
30
+ // A required hasMany to exercise the empty-list fixup.
31
+ {
32
+ name: 'tags',
33
+ type: 'relation',
34
+ targetCollection: 'tags',
35
+ hasMany: true,
36
+ },
37
+ ],
38
+ });
39
+ const Single = defineCollection({
40
+ path: 'single',
41
+ labels: { singular: 'Single', plural: 'Singles' },
42
+ fields: [{ name: 'lead', type: 'relation', targetCollection: 'people', optional: true }],
43
+ });
44
+ describe('hasMany relation storage round-trip', () => {
45
+ it('flattens an ordered list to indexed relation rows', () => {
46
+ const flat = flattenFieldSetData(Articles.fields, {
47
+ title: 'Hello',
48
+ authors: [
49
+ { targetDocumentId: 'a1', targetCollectionId: 'people' },
50
+ { targetDocumentId: 'a2', targetCollectionId: 'people' },
51
+ { targetDocumentId: 'a3', targetCollectionId: 'people' },
52
+ ],
53
+ tags: [],
54
+ }, 'all');
55
+ const relRows = flat.filter((r) => r.field_type === 'relation');
56
+ expect(relRows.map((r) => r.field_path.join('.'))).toEqual([
57
+ 'authors.0',
58
+ 'authors.1',
59
+ 'authors.2',
60
+ ]);
61
+ // Relations are non-localized — every row carries locale 'all'.
62
+ expect(relRows.every((r) => r.locale === 'all')).toBe(true);
63
+ });
64
+ it('reconstructs the ordered array (and preserves order)', () => {
65
+ const data = {
66
+ title: 'Hello',
67
+ authors: [
68
+ { targetDocumentId: 'a3', targetCollectionId: 'people' },
69
+ { targetDocumentId: 'a1', targetCollectionId: 'people' },
70
+ { targetDocumentId: 'a2', targetCollectionId: 'people' },
71
+ ],
72
+ tags: [{ targetDocumentId: 't1', targetCollectionId: 'tags' }],
73
+ };
74
+ const { data: restored, warnings } = restoreFieldSetData(Articles.fields, flattenFieldSetData(Articles.fields, data, 'all'));
75
+ expect(warnings).toEqual([]);
76
+ expect(restored.authors.map((a) => a.targetDocumentId)).toEqual(['a3', 'a1', 'a2']);
77
+ expect(restored.authors[0]).toMatchObject({
78
+ targetDocumentId: 'a3',
79
+ targetCollectionId: 'people',
80
+ });
81
+ expect(restored.tags.map((t) => t.targetDocumentId)).toEqual(['t1']);
82
+ });
83
+ it('reconstructs a required empty hasMany as []', () => {
84
+ const { data: restored } = restoreFieldSetData(Articles.fields, flattenFieldSetData(Articles.fields, { title: 'x', authors: [], tags: [] }, 'all'));
85
+ expect(restored.tags).toEqual([]);
86
+ });
87
+ it('leaves single (non-hasMany) relations as scalar values', () => {
88
+ const { data: restored } = restoreFieldSetData(Single.fields, flattenFieldSetData(Single.fields, { lead: { targetDocumentId: 'p1', targetCollectionId: 'people' } }, 'all'));
89
+ expect(Array.isArray(restored.lead)).toBe(false);
90
+ expect(restored.lead).toMatchObject({ targetDocumentId: 'p1', targetCollectionId: 'people' });
91
+ });
92
+ });
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@byline/db-postgres",
3
3
  "private": false,
4
4
  "license": "MPL-2.0",
5
- "version": "3.13.3",
5
+ "version": "3.15.0",
6
6
  "engines": {
7
7
  "node": ">=20.9.0"
8
8
  },
@@ -57,9 +57,9 @@
57
57
  "pg": "^8.21.0",
58
58
  "uuid": "^14.0.0",
59
59
  "zod": "^4.4.3",
60
- "@byline/admin": "3.13.3",
61
- "@byline/auth": "3.13.3",
62
- "@byline/core": "3.13.3"
60
+ "@byline/core": "3.15.0",
61
+ "@byline/admin": "3.15.0",
62
+ "@byline/auth": "3.15.0"
63
63
  },
64
64
  "devDependencies": {
65
65
  "@biomejs/biome": "2.4.15",