@byline/core 4.6.2 → 4.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.
Files changed (34) hide show
  1. package/dist/@types/db-types.d.ts +11 -0
  2. package/dist/@types/store-types.d.ts +6 -42
  3. package/dist/index.d.ts +1 -1
  4. package/dist/index.js +1 -1
  5. package/dist/lib/errors.d.ts +26 -0
  6. package/dist/lib/errors.js +19 -0
  7. package/dist/services/document-lifecycle/create.js +1 -1
  8. package/dist/services/document-lifecycle/duplicate.js +2 -2
  9. package/dist/services/document-lifecycle/internals.d.ts +9 -11
  10. package/dist/services/document-lifecycle/internals.js +16 -23
  11. package/dist/services/document-lifecycle/rethrow-path-conflict.test.node.d.ts +8 -0
  12. package/dist/services/document-lifecycle/rethrow-path-conflict.test.node.js +41 -0
  13. package/dist/services/document-lifecycle/system-fields.js +1 -1
  14. package/dist/services/document-lifecycle/update.js +2 -2
  15. package/dist/services/document-lifecycle.test.node.js +14 -1
  16. package/dist/storage/index.d.ts +5 -0
  17. package/dist/storage/index.js +4 -0
  18. package/dist/storage/multi-relation.test.node.d.ts +8 -0
  19. package/dist/storage/multi-relation.test.node.js +92 -0
  20. package/dist/storage/storage-flatten-reconstruct.test.node.d.ts +8 -0
  21. package/dist/storage/storage-flatten-reconstruct.test.node.js +408 -0
  22. package/dist/storage/storage-flatten.d.ts +18 -0
  23. package/dist/storage/storage-flatten.js +319 -0
  24. package/dist/storage/storage-restore.d.ts +28 -0
  25. package/dist/storage/storage-restore.js +370 -0
  26. package/dist/storage/storage-row-types.d.ts +115 -0
  27. package/dist/storage/storage-row-types.js +8 -0
  28. package/dist/storage/storage-utils.d.ts +21 -0
  29. package/dist/storage/storage-utils.js +60 -0
  30. package/dist/storage/store-manifest.d.ts +50 -0
  31. package/dist/storage/store-manifest.js +219 -0
  32. package/dist/storage/store-manifest.test.node.d.ts +8 -0
  33. package/dist/storage/store-manifest.test.node.js +128 -0
  34. package/package.json +3 -2
@@ -1,5 +1,6 @@
1
1
  import type { RequestContext } from '@byline/auth';
2
2
  import type { CollectionDefinition } from '@byline/core';
3
+ import type { DbErrorClassification } from '../lib/errors.js';
3
4
  import type { QueryPredicate } from './query-predicate.js';
4
5
  /**
5
6
  * Read mode for document queries.
@@ -245,6 +246,16 @@ export interface IDbAdapter {
245
246
  backfillSourceLocales?: () => Promise<{
246
247
  rowsUpdated: number;
247
248
  }>;
249
+ /**
250
+ * Classify a raw driver error into an adapter-agnostic, code-based shape so
251
+ * core can map database failures to domain errors without knowing driver
252
+ * anatomy. The error-side analogue of the storage `normalizeRow` seam.
253
+ *
254
+ * Optional: when absent, core treats every error as `DB_UNKNOWN` and rethrows
255
+ * it unchanged — the current behaviour for adapters that do not implement it.
256
+ * Canonical adapters (db-postgres, db-mysql) implement it.
257
+ */
258
+ classifyError?(err: unknown): DbErrorClassification;
248
259
  }
249
260
  /**
250
261
  * The realm of the actor that performed an audited change. `'admin'` for
@@ -5,6 +5,7 @@
5
5
  *
6
6
  * Copyright (c) Infonomic Company Limited
7
7
  */
8
+ import type { UnifiedFieldValue } from '../storage/storage-row-types.js';
8
9
  export interface BaseStore {
9
10
  field_path: string;
10
11
  field_name: string;
@@ -89,45 +90,8 @@ export declare function isRelationStore(fieldValue: FlattenedStore): fieldValue
89
90
  export declare function isJsonStore(fieldValue: FlattenedStore): fieldValue is JsonStore;
90
91
  export declare function isNumericStore(fieldValue: FlattenedStore): fieldValue is NumericStore;
91
92
  export declare function isDateTimeStore(fieldValue: FlattenedStore): fieldValue is DateTimeStore;
92
- export interface UnionRowValue {
93
- id: string;
94
- document_version_id: string;
95
- collection_id: string;
96
- field_type: string;
97
- field_path: string;
98
- field_name: string;
99
- locale: string;
100
- parent_path: string | null;
101
- text_value: string | null;
102
- boolean_value: boolean | null;
103
- json_value: any | null;
104
- date_type: string | null;
105
- value_date: Date | null;
106
- value_time: string | null;
107
- value_timestamp_tz: Date | null;
108
- file_id: string | null;
109
- filename: string | null;
110
- original_filename: string | null;
111
- mime_type: string | null;
112
- file_size: number | null;
113
- storage_provider: string | null;
114
- storage_path: string | null;
115
- storage_url: string | null;
116
- file_hash: string | null;
117
- image_width: number | null;
118
- image_height: number | null;
119
- image_format: string | null;
120
- processing_status: string | null;
121
- thumbnail_generated: boolean | null;
122
- variants: FileStoreVariant[] | null;
123
- target_document_id: string | null;
124
- target_collection_id: string | null;
125
- relationship_type: string | null;
126
- cascade_delete: boolean | null;
127
- json_schema: string | null;
128
- object_keys: string[] | null;
129
- number_type: string | null;
130
- value_integer: number | null;
131
- value_decimal: string | null;
132
- value_float: number | null;
133
- }
93
+ /**
94
+ * @deprecated Use {@link UnifiedFieldValue} from '@byline/core' — same shape;
95
+ * this alias remains for source compatibility.
96
+ */
97
+ export type UnionRowValue = UnifiedFieldValue;
package/dist/index.d.ts CHANGED
@@ -7,7 +7,7 @@ export { RESERVED_FIELD_NAMES } from './config/validate-collections.js';
7
7
  export { type BylineCore, getBylineCore, initBylineCore } from './core.js';
8
8
  export * from './defaults/default-values.js';
9
9
  export { getHostRequestBridge, type HostCookieSetOptions, type HostRequestBridge, registerHostRequestBridge, tryGetHostRequestBridge, } from './host/host-request-bridge.js';
10
- export { BylineError, ERR_AUDIT_UNSUPPORTED, ERR_CONFLICT, ERR_DATABASE, ERR_NOT_FOUND, ERR_READ_BUDGET_EXCEEDED, ERR_READ_RECURSION, ERR_STORAGE, ERR_TREE_HOOK_COMMITTED, ERR_UNHANDLED, ERR_VALIDATION, ErrorCodes, type ErrorReport, TREE_HOOK_COMMITTED_MARKER, TREE_PLACEMENT_STALE_MARKER, } from './lib/errors.js';
10
+ export { BylineError, type DbErrorClassification, type DbErrorCode, DbErrorCodes, ERR_AUDIT_UNSUPPORTED, ERR_CONFLICT, ERR_DATABASE, ERR_NOT_FOUND, ERR_READ_BUDGET_EXCEEDED, ERR_READ_RECURSION, ERR_STORAGE, ERR_TREE_HOOK_COMMITTED, ERR_UNHANDLED, ERR_VALIDATION, ErrorCodes, type ErrorReport, TREE_HOOK_COMMITTED_MARKER, TREE_PLACEMENT_STALE_MARKER, } from './lib/errors.js';
11
11
  export { generateKeyBetween, generateNKeysBetween, validateOrderKey, } from './lib/fractional-index.js';
12
12
  export { type BylineLogger, getLogger } from './lib/logger.js';
13
13
  export { AsyncRegistry, type RegisteredServices, Registry } from './lib/registry.js';
package/dist/index.js CHANGED
@@ -23,7 +23,7 @@ export { RESERVED_FIELD_NAMES } from './config/validate-collections.js';
23
23
  export { getBylineCore, initBylineCore } from './core.js';
24
24
  export * from './defaults/default-values.js';
25
25
  export { getHostRequestBridge, registerHostRequestBridge, tryGetHostRequestBridge, } from './host/host-request-bridge.js';
26
- export { BylineError, ERR_AUDIT_UNSUPPORTED, ERR_CONFLICT, ERR_DATABASE, ERR_NOT_FOUND, ERR_READ_BUDGET_EXCEEDED, ERR_READ_RECURSION, ERR_STORAGE, ERR_TREE_HOOK_COMMITTED, ERR_UNHANDLED, ERR_VALIDATION, ErrorCodes, TREE_HOOK_COMMITTED_MARKER, TREE_PLACEMENT_STALE_MARKER, } from './lib/errors.js';
26
+ export { BylineError, DbErrorCodes, ERR_AUDIT_UNSUPPORTED, ERR_CONFLICT, ERR_DATABASE, ERR_NOT_FOUND, ERR_READ_BUDGET_EXCEEDED, ERR_READ_RECURSION, ERR_STORAGE, ERR_TREE_HOOK_COMMITTED, ERR_UNHANDLED, ERR_VALIDATION, ErrorCodes, TREE_HOOK_COMMITTED_MARKER, TREE_PLACEMENT_STALE_MARKER, } from './lib/errors.js';
27
27
  export { generateKeyBetween, generateNKeysBetween, validateOrderKey, } from './lib/fractional-index.js';
28
28
  export { getLogger } from './lib/logger.js';
29
29
  export { AsyncRegistry, Registry } from './lib/registry.js';
@@ -127,3 +127,29 @@ export declare const ERR_AUDIT_UNSUPPORTED: (opts: BylineErrorOptions, errorCons
127
127
  * reconcile, while transports can distinguish it from a rolled-back mutation.
128
128
  */
129
129
  export declare const ERR_TREE_HOOK_COMMITTED: (opts: BylineErrorOptions, errorConstructor?: any) => BylineError;
130
+ /**
131
+ * Code-based classification of a raw database driver error, produced by
132
+ * `IDbAdapter.classifyError`. The error-side analogue of the storage
133
+ * `normalizeRow` seam: the adapter canonicalises driver anatomy into these
134
+ * codes so `@byline/core` can map DB failures to domain errors (e.g.
135
+ * `ERR_PATH_CONFLICT`) without knowing any driver's error shape.
136
+ *
137
+ * Distinct from `ErrorCodes` above: those are thrown `BylineError` codes;
138
+ * these are returned classification values. Extend with new codes (e.g. a
139
+ * future `STALE_RECORD` for optimistic-concurrency failures) as consumers
140
+ * need them.
141
+ */
142
+ export declare const DbErrorCodes: {
143
+ readonly UNIQUE_VIOLATION: 'DB_UNIQUE_VIOLATION';
144
+ readonly UNKNOWN: 'DB_UNKNOWN';
145
+ };
146
+ export type DbErrorCode = (typeof DbErrorCodes)[keyof typeof DbErrorCodes];
147
+ export interface DbErrorClassification {
148
+ code: DbErrorCode;
149
+ /**
150
+ * For `DB_UNIQUE_VIOLATION`: the violated constraint / index name when the
151
+ * driver exposes it (Postgres carries it structurally; MySQL parses it from
152
+ * the error message). Absent when the driver surfaces no name.
153
+ */
154
+ constraint?: string;
155
+ }
@@ -164,3 +164,22 @@ export const ERR_AUDIT_UNSUPPORTED = createErrorType(ErrorCodes.AUDIT_UNSUPPORTE
164
164
  * reconcile, while transports can distinguish it from a rolled-back mutation.
165
165
  */
166
166
  export const ERR_TREE_HOOK_COMMITTED = createErrorType(ErrorCodes.TREE_HOOK_COMMITTED, 'warn');
167
+ // ---------------------------------------------------------------------------
168
+ // Database error classification (adapter seam)
169
+ // ---------------------------------------------------------------------------
170
+ /**
171
+ * Code-based classification of a raw database driver error, produced by
172
+ * `IDbAdapter.classifyError`. The error-side analogue of the storage
173
+ * `normalizeRow` seam: the adapter canonicalises driver anatomy into these
174
+ * codes so `@byline/core` can map DB failures to domain errors (e.g.
175
+ * `ERR_PATH_CONFLICT`) without knowing any driver's error shape.
176
+ *
177
+ * Distinct from `ErrorCodes` above: those are thrown `BylineError` codes;
178
+ * these are returned classification values. Extend with new codes (e.g. a
179
+ * future `STALE_RECORD` for optimistic-concurrency failures) as consumers
180
+ * need them.
181
+ */
182
+ export const DbErrorCodes = {
183
+ UNIQUE_VIOLATION: 'DB_UNIQUE_VIOLATION',
184
+ UNKNOWN: 'DB_UNKNOWN',
185
+ };
@@ -86,7 +86,7 @@ export async function createDocument(ctx, params) {
86
86
  orderKey,
87
87
  createdBy: actorId(ctx),
88
88
  })
89
- .catch((err) => rethrowPathConflict(err, resolvedPath, defaultLocale));
89
+ .catch((err) => rethrowPathConflict(db, err, resolvedPath, defaultLocale));
90
90
  const documentId = extractDocumentId(result.document);
91
91
  const documentVersionId = extractVersionId(result.document);
92
92
  // `tree: true` collections place every document in the tree by default:
@@ -172,7 +172,7 @@ export async function duplicateDocument(ctx, params) {
172
172
  orderKey,
173
173
  createdBy: actorId(ctx),
174
174
  })
175
- .catch((err) => rethrowPathConflict(err, finalPath, defaultLocale));
175
+ .catch((err) => rethrowPathConflict(db, err, finalPath, defaultLocale));
176
176
  }
177
177
  catch (err) {
178
178
  if (!isPathConflictError(err)) {
@@ -197,7 +197,7 @@ export async function duplicateDocument(ctx, params) {
197
197
  orderKey,
198
198
  createdBy: actorId(ctx),
199
199
  })
200
- .catch((retryErr) => rethrowPathConflict(retryErr, finalPath, defaultLocale));
200
+ .catch((retryErr) => rethrowPathConflict(db, retryErr, finalPath, defaultLocale));
201
201
  }
202
202
  const newDocumentId = extractDocumentId(result.document);
203
203
  const newDocumentVersionId = extractVersionId(result.document);
@@ -11,7 +11,7 @@
11
11
  * the barrel (`index.ts`), so the package's public surface is unchanged
12
12
  * by the per-operation split.
13
13
  */
14
- import { type CollectionDefinition, type CollectionHookSlot } from '../../@types/index.js';
14
+ import { type CollectionDefinition, type CollectionHookSlot, type IDbAdapter } from '../../@types/index.js';
15
15
  import type { BylineLogger } from '../../lib/logger.js';
16
16
  import type { SlugifierFn } from '../../utils/slugify.js';
17
17
  import type { DocumentLifecycleContext } from './context.js';
@@ -64,20 +64,18 @@ export declare function extractVersionId(document: any): string;
64
64
  /** Extract the logical document id from the document object returned by `createDocumentVersion`. */
65
65
  export declare function extractDocumentId(document: any): string;
66
66
  /**
67
- * Detect a Postgres unique-constraint violation on
67
+ * Detect a unique-constraint violation on
68
68
  * `byline_document_paths(collection_id, locale, path)` and translate it
69
69
  * to `ERR_PATH_CONFLICT`. Any other error is rethrown unchanged.
70
70
  *
71
- * The Postgres SQLSTATE for unique violations is `23505`. Drivers carry
72
- * the constraint name on the error object (`constraint`); matching by
73
- * name keeps this targeted to the path constraint and avoids spuriously
74
- * rebranding unrelated unique violations as path conflicts.
75
- *
76
- * Drizzle wraps the underlying pg error in `DrizzleQueryError` with the
77
- * original attached as `cause`, so we walk a short cause chain to find
78
- * the carried `code` / `constraint`.
71
+ * Driver anatomy (SQLSTATE codes, `cause`-chain walking, constraint-name
72
+ * carriage) is delegated to `db.classifyError` the adapter seam that
73
+ * canonicalises a raw driver error into a `DbErrorClassification`. This
74
+ * function only knows the classification codes and the path constraint's
75
+ * name substring; it stays targeted to the path constraint so unrelated
76
+ * unique violations aren't spuriously rebranded as path conflicts.
79
77
  */
80
- export declare function rethrowPathConflict(err: unknown, path: string, locale: string): never;
78
+ export declare function rethrowPathConflict(db: IDbAdapter, err: unknown, path: string, locale: string): never;
81
79
  /**
82
80
  * Detect whether an error is the `ERR_PATH_CONFLICT` raised by
83
81
  * `rethrowPathConflict`. Used by `duplicateDocument`'s retry logic to
@@ -13,7 +13,7 @@
13
13
  */
14
14
  import { normalizeCollectionHook, } from '../../@types/index.js';
15
15
  import { getCollectionDefinition, getServerConfig } from '../../config/config.js';
16
- import { ERR_PATH_CONFLICT, ErrorCodes } from '../../lib/errors.js';
16
+ import { DbErrorCodes, ERR_PATH_CONFLICT, ErrorCodes } from '../../lib/errors.js';
17
17
  import { generateKeyBetween } from '../../lib/fractional-index.js';
18
18
  import { createReadContext } from '../populate.js';
19
19
  import { embedRichTextFields } from '../richtext-embed.js';
@@ -131,32 +131,25 @@ export function extractDocumentId(document) {
131
131
  return document?.document_id ?? '';
132
132
  }
133
133
  /**
134
- * Detect a Postgres unique-constraint violation on
134
+ * Detect a unique-constraint violation on
135
135
  * `byline_document_paths(collection_id, locale, path)` and translate it
136
136
  * to `ERR_PATH_CONFLICT`. Any other error is rethrown unchanged.
137
137
  *
138
- * The Postgres SQLSTATE for unique violations is `23505`. Drivers carry
139
- * the constraint name on the error object (`constraint`); matching by
140
- * name keeps this targeted to the path constraint and avoids spuriously
141
- * rebranding unrelated unique violations as path conflicts.
142
- *
143
- * Drizzle wraps the underlying pg error in `DrizzleQueryError` with the
144
- * original attached as `cause`, so we walk a short cause chain to find
145
- * the carried `code` / `constraint`.
138
+ * Driver anatomy (SQLSTATE codes, `cause`-chain walking, constraint-name
139
+ * carriage) is delegated to `db.classifyError` the adapter seam that
140
+ * canonicalises a raw driver error into a `DbErrorClassification`. This
141
+ * function only knows the classification codes and the path constraint's
142
+ * name substring; it stays targeted to the path constraint so unrelated
143
+ * unique violations aren't spuriously rebranded as path conflicts.
146
144
  */
147
- export function rethrowPathConflict(err, path, locale) {
148
- let e = err;
149
- // Walk at most a few `cause` hops — DrizzleQueryError → underlying pg error.
150
- for (let i = 0; i < 3 && e; i++) {
151
- if (e.code === '23505' &&
152
- typeof e.constraint === 'string' &&
153
- e.constraint.includes('document_paths_collection_locale_path')) {
154
- throw ERR_PATH_CONFLICT({
155
- message: `path "${path}" is already in use in this collection (locale: ${locale})`,
156
- details: { path, locale, constraint: e.constraint },
157
- });
158
- }
159
- e = e.cause;
145
+ export function rethrowPathConflict(db, err, path, locale) {
146
+ const classification = db.classifyError?.(err);
147
+ if (classification?.code === DbErrorCodes.UNIQUE_VIOLATION &&
148
+ classification.constraint?.includes('document_paths_collection_locale_path')) {
149
+ throw ERR_PATH_CONFLICT({
150
+ message: `path "${path}" is already in use in this collection (locale: ${locale})`,
151
+ details: { path, locale, constraint: classification.constraint },
152
+ });
160
153
  }
161
154
  throw err;
162
155
  }
@@ -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,41 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ */
8
+ import { describe, expect, it } from 'vitest';
9
+ import { ErrorCodes } from '../../lib/errors.js';
10
+ import { rethrowPathConflict } from './internals.js';
11
+ const adapterWith = (c) => ({ classifyError: c === undefined ? undefined : () => c });
12
+ describe('rethrowPathConflict', () => {
13
+ it('maps a unique violation on the path constraint to ERR_PATH_CONFLICT', () => {
14
+ const db = adapterWith({
15
+ code: 'DB_UNIQUE_VIOLATION',
16
+ constraint: 'byline_document_paths_document_paths_collection_locale_path',
17
+ });
18
+ try {
19
+ rethrowPathConflict(db, new Error('raw'), 'news/hello', 'en');
20
+ throw new Error('should have thrown');
21
+ }
22
+ catch (e) {
23
+ expect(e.code).toBe(ErrorCodes.PATH_CONFLICT);
24
+ }
25
+ });
26
+ it('rethrows raw when the unique violation is on a different constraint', () => {
27
+ const raw = new Error('raw');
28
+ const db = adapterWith({ code: 'DB_UNIQUE_VIOLATION', constraint: 'some_other_unique' });
29
+ expect(() => rethrowPathConflict(db, raw, 'p', 'en')).toThrow(raw);
30
+ });
31
+ it('rethrows raw for DB_UNKNOWN', () => {
32
+ const raw = new Error('raw');
33
+ const db = adapterWith({ code: 'DB_UNKNOWN' });
34
+ expect(() => rethrowPathConflict(db, raw, 'p', 'en')).toThrow(raw);
35
+ });
36
+ it('rethrows raw when the adapter has no classifyError', () => {
37
+ const raw = new Error('raw');
38
+ const db = adapterWith(undefined);
39
+ expect(() => rethrowPathConflict(db, raw, 'p', 'en')).toThrow(raw);
40
+ });
41
+ });
@@ -104,7 +104,7 @@ export async function updateDocumentSystemFields(ctx, params) {
104
104
  locale: sourceLocale,
105
105
  path: pathForCommand,
106
106
  })
107
- .catch((err) => rethrowPathConflict(err, pathForCommand, sourceLocale));
107
+ .catch((err) => rethrowPathConflict(db, err, pathForCommand, sourceLocale));
108
108
  await audit.append({
109
109
  documentId: params.documentId,
110
110
  collectionId,
@@ -91,7 +91,7 @@ export async function updateDocument(ctx, params) {
91
91
  previousVersionId: originalData.document_version_id,
92
92
  createdBy: actorId(ctx),
93
93
  })
94
- .catch((err) => rethrowPathConflict(err, pathForCommand ?? '', defaultLocale));
94
+ .catch((err) => rethrowPathConflict(db, err, pathForCommand ?? '', defaultLocale));
95
95
  const documentId = extractDocumentId(result.document) || params.documentId;
96
96
  const documentVersionId = extractVersionId(result.document);
97
97
  // Self-heal: re-root a genuinely-unplaced doc in a tree collection so any
@@ -210,7 +210,7 @@ export async function updateDocumentWithPatches(ctx, params) {
210
210
  previousVersionId: originalData.document_version_id,
211
211
  createdBy: actorId(ctx),
212
212
  })
213
- .catch((err) => rethrowPathConflict(err, pathForCommand ?? '', defaultLocale));
213
+ .catch((err) => rethrowPathConflict(db, err, pathForCommand ?? '', defaultLocale));
214
214
  const documentId = extractDocumentId(result.document) || params.documentId;
215
215
  const documentVersionId = extractVersionId(result.document);
216
216
  // Self-heal: re-root a genuinely-unplaced doc in a tree collection so any
@@ -7,7 +7,7 @@
7
7
  */
8
8
  import { AdminAuth, AuthError, AuthErrorCodes, createRequestContext, createSuperAdminContext, } from '@byline/auth';
9
9
  import { describe, expect, it, vi } from 'vitest';
10
- import { BylineError, ERR_PATH_CONFLICT, ErrorCodes } from '../lib/errors.js';
10
+ import { BylineError, DbErrorCodes, ERR_PATH_CONFLICT, ErrorCodes } from '../lib/errors.js';
11
11
  import { changeDocumentStatus, copyToLocale, createDocument, deleteDocument, duplicateDocument, restoreDocumentVersion, unpublishDocument, updateDocument, updateDocumentSystemFields, updateDocumentWithPatches, } from './document-lifecycle/index.js';
12
12
  // ---------------------------------------------------------------------------
13
13
  // Fixtures / Helpers
@@ -50,7 +50,20 @@ function createMockDb() {
50
50
  // records the calls so write-point tests can assert the audit rows emitted.
51
51
  const auditAppend = vi.fn().mockResolvedValue({ id: 'audit-1' });
52
52
  const withTransaction = vi.fn(async (fn) => fn());
53
+ // Fake classifier mirroring db-postgres's real one closely enough for
54
+ // rethrowPathConflict's consumption of the seam: a pg-shaped 23505 with a
55
+ // constraint name classifies as a unique violation; anything else is
56
+ // unknown. Individual tests inject pg-shaped errors on `createDocumentVersion`
57
+ // (see `translates a Postgres unique-constraint violation...` below).
58
+ const classifyError = vi.fn((err) => {
59
+ const e = err;
60
+ if (e?.code === '23505') {
61
+ return { code: DbErrorCodes.UNIQUE_VIOLATION, constraint: e.constraint };
62
+ }
63
+ return { code: DbErrorCodes.UNKNOWN };
64
+ });
53
65
  const db = {
66
+ classifyError,
54
67
  commands: {
55
68
  collections: {
56
69
  create: vi.fn(),
@@ -7,3 +7,8 @@
7
7
  */
8
8
  export { fingerprintCollection } from './collection-fingerprint.js';
9
9
  export { ALL_STORE_TYPES, type FieldStoreKind, type FieldStoreMapping, fieldTypeToStore, fieldTypeToStoreType, type StoreType, } from './field-store-map.js';
10
+ export { flattenFieldSetData } from './storage-flatten.js';
11
+ export { extractFlattenedFieldValue, type RestoreResult, restoreFieldSetData, } from './storage-restore.js';
12
+ export { resolveStoreTypes } from './storage-utils.js';
13
+ export { type ColumnDef, storeColumnManifest, storeTableNames } from './store-manifest.js';
14
+ export type { FlattenedFieldValue, UnifiedFieldValue } from './storage-row-types.js';
@@ -7,3 +7,7 @@
7
7
  */
8
8
  export { fingerprintCollection } from './collection-fingerprint.js';
9
9
  export { ALL_STORE_TYPES, fieldTypeToStore, fieldTypeToStoreType, } from './field-store-map.js';
10
+ export { flattenFieldSetData } from './storage-flatten.js';
11
+ export { extractFlattenedFieldValue, restoreFieldSetData, } from './storage-restore.js';
12
+ export { resolveStoreTypes } from './storage-utils.js';
13
+ export { storeColumnManifest, storeTableNames } from './store-manifest.js';
@@ -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 { describe, expect, it } from 'vitest';
15
+ import { defineCollection } from '../@types/index.js';
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
+ });
@@ -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 {};