@byline/core 0.9.3
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/LICENSE +373 -0
- package/README.md +17 -0
- package/dist/@types/admin-types.d.ts +275 -0
- package/dist/@types/admin-types.d.ts.map +1 -0
- package/dist/@types/admin-types.js +18 -0
- package/dist/@types/admin-types.js.map +1 -0
- package/dist/@types/collection-types.d.ts +816 -0
- package/dist/@types/collection-types.d.ts.map +1 -0
- package/dist/@types/collection-types.js +217 -0
- package/dist/@types/collection-types.js.map +1 -0
- package/dist/@types/db-types.d.ts +463 -0
- package/dist/@types/db-types.d.ts.map +1 -0
- package/dist/@types/db-types.js +2 -0
- package/dist/@types/db-types.js.map +1 -0
- package/dist/@types/field-data-types.d.ts +147 -0
- package/dist/@types/field-data-types.d.ts.map +1 -0
- package/dist/@types/field-data-types.js +38 -0
- package/dist/@types/field-data-types.js.map +1 -0
- package/dist/@types/field-types.d.ts +579 -0
- package/dist/@types/field-types.d.ts.map +1 -0
- package/dist/@types/field-types.js +32 -0
- package/dist/@types/field-types.js.map +1 -0
- package/dist/@types/index.d.ts +18 -0
- package/dist/@types/index.d.ts.map +1 -0
- package/dist/@types/index.js +18 -0
- package/dist/@types/index.js.map +1 -0
- package/dist/@types/populate-types.d.ts +54 -0
- package/dist/@types/populate-types.d.ts.map +1 -0
- package/dist/@types/populate-types.js +9 -0
- package/dist/@types/populate-types.js.map +1 -0
- package/dist/@types/query-predicate.d.ts +74 -0
- package/dist/@types/query-predicate.d.ts.map +1 -0
- package/dist/@types/query-predicate.js +9 -0
- package/dist/@types/query-predicate.js.map +1 -0
- package/dist/@types/site-config.d.ts +212 -0
- package/dist/@types/site-config.d.ts.map +1 -0
- package/dist/@types/site-config.js +9 -0
- package/dist/@types/site-config.js.map +1 -0
- package/dist/@types/storage-types.d.ts +86 -0
- package/dist/@types/storage-types.d.ts.map +1 -0
- package/dist/@types/storage-types.js +9 -0
- package/dist/@types/storage-types.js.map +1 -0
- package/dist/@types/store-types.d.ts +134 -0
- package/dist/@types/store-types.d.ts.map +1 -0
- package/dist/@types/store-types.js +24 -0
- package/dist/@types/store-types.js.map +1 -0
- package/dist/@types/type-utils.d.ts +17 -0
- package/dist/@types/type-utils.d.ts.map +1 -0
- package/dist/@types/type-utils.js +9 -0
- package/dist/@types/type-utils.js.map +1 -0
- package/dist/auth/apply-before-read.d.ts +36 -0
- package/dist/auth/apply-before-read.d.ts.map +1 -0
- package/dist/auth/apply-before-read.js +68 -0
- package/dist/auth/apply-before-read.js.map +1 -0
- package/dist/auth/apply-before-read.test.node.d.ts +9 -0
- package/dist/auth/apply-before-read.test.node.d.ts.map +1 -0
- package/dist/auth/apply-before-read.test.node.js +144 -0
- package/dist/auth/apply-before-read.test.node.js.map +1 -0
- package/dist/auth/assert-actor-can-perform.d.ts +39 -0
- package/dist/auth/assert-actor-can-perform.d.ts.map +1 -0
- package/dist/auth/assert-actor-can-perform.js +64 -0
- package/dist/auth/assert-actor-can-perform.js.map +1 -0
- package/dist/auth/assert-actor-can-perform.test.node.d.ts +9 -0
- package/dist/auth/assert-actor-can-perform.test.node.d.ts.map +1 -0
- package/dist/auth/assert-actor-can-perform.test.node.js +119 -0
- package/dist/auth/assert-actor-can-perform.test.node.js.map +1 -0
- package/dist/auth/index.d.ts +11 -0
- package/dist/auth/index.d.ts.map +1 -0
- package/dist/auth/index.js +11 -0
- package/dist/auth/index.js.map +1 -0
- package/dist/auth/register-collection-abilities.d.ts +40 -0
- package/dist/auth/register-collection-abilities.d.ts.map +1 -0
- package/dist/auth/register-collection-abilities.js +87 -0
- package/dist/auth/register-collection-abilities.js.map +1 -0
- package/dist/auth/register-collection-abilities.test.node.d.ts +9 -0
- package/dist/auth/register-collection-abilities.test.node.d.ts.map +1 -0
- package/dist/auth/register-collection-abilities.test.node.js +124 -0
- package/dist/auth/register-collection-abilities.test.node.js.map +1 -0
- package/dist/config/config.d.ts +10 -0
- package/dist/config/config.d.ts.map +1 -0
- package/dist/config/config.js +108 -0
- package/dist/config/config.js.map +1 -0
- package/dist/config/routes.d.ts +16 -0
- package/dist/config/routes.d.ts.map +1 -0
- package/dist/config/routes.js +26 -0
- package/dist/config/routes.js.map +1 -0
- package/dist/config/validate-admin-configs.d.ts +33 -0
- package/dist/config/validate-admin-configs.d.ts.map +1 -0
- package/dist/config/validate-admin-configs.js +250 -0
- package/dist/config/validate-admin-configs.js.map +1 -0
- package/dist/config/validate-admin-configs.test.node.d.ts +9 -0
- package/dist/config/validate-admin-configs.test.node.d.ts.map +1 -0
- package/dist/config/validate-admin-configs.test.node.js +224 -0
- package/dist/config/validate-admin-configs.test.node.js.map +1 -0
- package/dist/config/validate-collections.d.ts +33 -0
- package/dist/config/validate-collections.d.ts.map +1 -0
- package/dist/config/validate-collections.js +70 -0
- package/dist/config/validate-collections.js.map +1 -0
- package/dist/config/validate-collections.test.node.d.ts +9 -0
- package/dist/config/validate-collections.test.node.d.ts.map +1 -0
- package/dist/config/validate-collections.test.node.js +149 -0
- package/dist/config/validate-collections.test.node.js.map +1 -0
- package/dist/core.d.ts +89 -0
- package/dist/core.d.ts.map +1 -0
- package/dist/core.js +99 -0
- package/dist/core.js.map +1 -0
- package/dist/defaults/default-values.d.ts +13 -0
- package/dist/defaults/default-values.d.ts.map +1 -0
- package/dist/defaults/default-values.js +60 -0
- package/dist/defaults/default-values.js.map +1 -0
- package/dist/index.d.ts +20 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +36 -0
- package/dist/index.js.map +1 -0
- package/dist/lib/errors.d.ts +98 -0
- package/dist/lib/errors.d.ts.map +1 -0
- package/dist/lib/errors.js +134 -0
- package/dist/lib/errors.js.map +1 -0
- package/dist/lib/logger.d.ts +62 -0
- package/dist/lib/logger.d.ts.map +1 -0
- package/dist/lib/logger.js +120 -0
- package/dist/lib/logger.js.map +1 -0
- package/dist/lib/registry.d.ts +65 -0
- package/dist/lib/registry.d.ts.map +1 -0
- package/dist/lib/registry.js +133 -0
- package/dist/lib/registry.js.map +1 -0
- package/dist/logger/index.d.ts +3 -0
- package/dist/logger/index.d.ts.map +1 -0
- package/dist/logger/index.js +3 -0
- package/dist/logger/index.js.map +1 -0
- package/dist/patches/apply-patches.d.ts +21 -0
- package/dist/patches/apply-patches.d.ts.map +1 -0
- package/dist/patches/apply-patches.js +357 -0
- package/dist/patches/apply-patches.js.map +1 -0
- package/dist/patches/index.d.ts +3 -0
- package/dist/patches/index.d.ts.map +1 -0
- package/dist/patches/index.js +4 -0
- package/dist/patches/index.js.map +1 -0
- package/dist/patches/patch-types.d.ts +82 -0
- package/dist/patches/patch-types.d.ts.map +1 -0
- package/dist/patches/patch-types.js +3 -0
- package/dist/patches/patch-types.js.map +1 -0
- package/dist/patches/patch.test.node.d.ts +2 -0
- package/dist/patches/patch.test.node.d.ts.map +1 -0
- package/dist/patches/patch.test.node.js +193 -0
- package/dist/patches/patch.test.node.js.map +1 -0
- package/dist/query/parse-where.d.ts +100 -0
- package/dist/query/parse-where.d.ts.map +1 -0
- package/dist/query/parse-where.js +352 -0
- package/dist/query/parse-where.js.map +1 -0
- package/dist/query/parse-where.test.node.d.ts +9 -0
- package/dist/query/parse-where.test.node.d.ts.map +1 -0
- package/dist/query/parse-where.test.node.js +581 -0
- package/dist/query/parse-where.test.node.js.map +1 -0
- package/dist/schemas/zod/builder.d.ts +466 -0
- package/dist/schemas/zod/builder.d.ts.map +1 -0
- package/dist/schemas/zod/builder.js +276 -0
- package/dist/schemas/zod/builder.js.map +1 -0
- package/dist/schemas/zod/cache.d.ts +14 -0
- package/dist/schemas/zod/cache.d.ts.map +1 -0
- package/dist/schemas/zod/cache.js +40 -0
- package/dist/schemas/zod/cache.js.map +1 -0
- package/dist/schemas/zod/index.d.ts +4 -0
- package/dist/schemas/zod/index.d.ts.map +1 -0
- package/dist/schemas/zod/index.js +4 -0
- package/dist/schemas/zod/index.js.map +1 -0
- package/dist/schemas/zod/types.d.ts +13 -0
- package/dist/schemas/zod/types.d.ts.map +1 -0
- package/dist/schemas/zod/types.js +2 -0
- package/dist/schemas/zod/types.js.map +1 -0
- package/dist/services/collection-bootstrap.d.ts +46 -0
- package/dist/services/collection-bootstrap.d.ts.map +1 -0
- package/dist/services/collection-bootstrap.js +108 -0
- package/dist/services/collection-bootstrap.js.map +1 -0
- package/dist/services/collection-bootstrap.test.node.d.ts +9 -0
- package/dist/services/collection-bootstrap.test.node.d.ts.map +1 -0
- package/dist/services/collection-bootstrap.test.node.js +208 -0
- package/dist/services/collection-bootstrap.test.node.js.map +1 -0
- package/dist/services/document-lifecycle.d.ts +245 -0
- package/dist/services/document-lifecycle.d.ts.map +1 -0
- package/dist/services/document-lifecycle.js +481 -0
- package/dist/services/document-lifecycle.js.map +1 -0
- package/dist/services/document-lifecycle.test.node.d.ts +9 -0
- package/dist/services/document-lifecycle.test.node.d.ts.map +1 -0
- package/dist/services/document-lifecycle.test.node.js +781 -0
- package/dist/services/document-lifecycle.test.node.js.map +1 -0
- package/dist/services/document-read.d.ts +26 -0
- package/dist/services/document-read.d.ts.map +1 -0
- package/dist/services/document-read.js +60 -0
- package/dist/services/document-read.js.map +1 -0
- package/dist/services/field-upload.d.ts +100 -0
- package/dist/services/field-upload.d.ts.map +1 -0
- package/dist/services/field-upload.js +328 -0
- package/dist/services/field-upload.js.map +1 -0
- package/dist/services/field-upload.test.node.d.ts +9 -0
- package/dist/services/field-upload.test.node.d.ts.map +1 -0
- package/dist/services/field-upload.test.node.js +337 -0
- package/dist/services/field-upload.test.node.js.map +1 -0
- package/dist/services/index.d.ts +10 -0
- package/dist/services/index.d.ts.map +1 -0
- package/dist/services/index.js +11 -0
- package/dist/services/index.js.map +1 -0
- package/dist/services/populate.d.ts +299 -0
- package/dist/services/populate.d.ts.map +1 -0
- package/dist/services/populate.js +484 -0
- package/dist/services/populate.js.map +1 -0
- package/dist/services/populate.test.node.d.ts +9 -0
- package/dist/services/populate.test.node.d.ts.map +1 -0
- package/dist/services/populate.test.node.js +910 -0
- package/dist/services/populate.test.node.js.map +1 -0
- package/dist/services/relation-projection.d.ts +52 -0
- package/dist/services/relation-projection.d.ts.map +1 -0
- package/dist/services/relation-projection.js +81 -0
- package/dist/services/relation-projection.js.map +1 -0
- package/dist/services/richtext-populate.d.ts +87 -0
- package/dist/services/richtext-populate.d.ts.map +1 -0
- package/dist/services/richtext-populate.js +189 -0
- package/dist/services/richtext-populate.js.map +1 -0
- package/dist/services/richtext-populate.test.node.d.ts +9 -0
- package/dist/services/richtext-populate.test.node.d.ts.map +1 -0
- package/dist/services/richtext-populate.test.node.js +197 -0
- package/dist/services/richtext-populate.test.node.js.map +1 -0
- package/dist/storage/collection-fingerprint.d.ts +21 -0
- package/dist/storage/collection-fingerprint.d.ts.map +1 -0
- package/dist/storage/collection-fingerprint.js +172 -0
- package/dist/storage/collection-fingerprint.js.map +1 -0
- package/dist/storage/collection-fingerprint.test.node.d.ts +9 -0
- package/dist/storage/collection-fingerprint.test.node.d.ts.map +1 -0
- package/dist/storage/collection-fingerprint.test.node.js +256 -0
- package/dist/storage/collection-fingerprint.test.node.js.map +1 -0
- package/dist/storage/field-store-map.d.ts +59 -0
- package/dist/storage/field-store-map.d.ts.map +1 -0
- package/dist/storage/field-store-map.js +75 -0
- package/dist/storage/field-store-map.js.map +1 -0
- package/dist/storage/field-store-map.test.node.d.ts +9 -0
- package/dist/storage/field-store-map.test.node.d.ts.map +1 -0
- package/dist/storage/field-store-map.test.node.js +117 -0
- package/dist/storage/field-store-map.test.node.js.map +1 -0
- package/dist/storage/index.d.ts +10 -0
- package/dist/storage/index.d.ts.map +1 -0
- package/dist/storage/index.js +10 -0
- package/dist/storage/index.js.map +1 -0
- package/dist/utils/normalise-dates.d.ts +15 -0
- package/dist/utils/normalise-dates.d.ts.map +1 -0
- package/dist/utils/normalise-dates.js +22 -0
- package/dist/utils/normalise-dates.js.map +1 -0
- package/dist/utils/slugify.d.ts +56 -0
- package/dist/utils/slugify.d.ts.map +1 -0
- package/dist/utils/slugify.js +91 -0
- package/dist/utils/slugify.js.map +1 -0
- package/dist/utils/slugify.test.node.d.ts +9 -0
- package/dist/utils/slugify.test.node.d.ts.map +1 -0
- package/dist/utils/slugify.test.node.js +86 -0
- package/dist/utils/slugify.test.node.js.map +1 -0
- package/dist/utils/storage-utils.d.ts +36 -0
- package/dist/utils/storage-utils.d.ts.map +1 -0
- package/dist/utils/storage-utils.js +38 -0
- package/dist/utils/storage-utils.js.map +1 -0
- package/dist/utils/utils.general.d.ts +64 -0
- package/dist/utils/utils.general.d.ts.map +1 -0
- package/dist/utils/utils.general.js +219 -0
- package/dist/utils/utils.general.js.map +1 -0
- package/dist/validation/index.d.ts +9 -0
- package/dist/validation/index.d.ts.map +1 -0
- package/dist/validation/index.js +9 -0
- package/dist/validation/index.js.map +1 -0
- package/dist/validation/shared.d.ts +36 -0
- package/dist/validation/shared.d.ts.map +1 -0
- package/dist/validation/shared.js +42 -0
- package/dist/validation/shared.js.map +1 -0
- package/dist/workflow/index.d.ts +2 -0
- package/dist/workflow/index.d.ts.map +1 -0
- package/dist/workflow/index.js +3 -0
- package/dist/workflow/index.js.map +1 -0
- package/dist/workflow/workflow.d.ts +40 -0
- package/dist/workflow/workflow.d.ts.map +1 -0
- package/dist/workflow/workflow.js +96 -0
- package/dist/workflow/workflow.js.map +1 -0
- package/dist/workflow/workflow.test.node.d.ts +2 -0
- package/dist/workflow/workflow.test.node.d.ts.map +1 -0
- package/dist/workflow/workflow.test.node.js +198 -0
- package/dist/workflow/workflow.test.node.js.map +1 -0
- package/package.json +88 -0
|
@@ -0,0 +1,910 @@
|
|
|
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, vi } from 'vitest';
|
|
9
|
+
import { BylineError, ErrorCodes } from '../lib/errors.js';
|
|
10
|
+
import { __internal, createReadContext, populateDocuments } from './populate.js';
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
// Fixtures
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
const postsCollection = {
|
|
15
|
+
path: 'posts',
|
|
16
|
+
labels: { singular: 'Post', plural: 'Posts' },
|
|
17
|
+
fields: [
|
|
18
|
+
{ name: 'title', type: 'text', label: 'Title' },
|
|
19
|
+
{
|
|
20
|
+
name: 'author',
|
|
21
|
+
type: 'relation',
|
|
22
|
+
label: 'Author',
|
|
23
|
+
targetCollection: 'authors',
|
|
24
|
+
optional: true,
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
name: 'secondaryAuthor',
|
|
28
|
+
type: 'relation',
|
|
29
|
+
label: 'Secondary Author',
|
|
30
|
+
targetCollection: 'authors',
|
|
31
|
+
optional: true,
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
name: 'related',
|
|
35
|
+
type: 'array',
|
|
36
|
+
label: 'Related',
|
|
37
|
+
fields: [
|
|
38
|
+
{
|
|
39
|
+
name: 'person',
|
|
40
|
+
type: 'relation',
|
|
41
|
+
label: 'Person',
|
|
42
|
+
targetCollection: 'authors',
|
|
43
|
+
optional: true,
|
|
44
|
+
},
|
|
45
|
+
],
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
name: 'meta',
|
|
49
|
+
type: 'group',
|
|
50
|
+
label: 'Meta',
|
|
51
|
+
fields: [
|
|
52
|
+
{
|
|
53
|
+
name: 'editor',
|
|
54
|
+
type: 'relation',
|
|
55
|
+
label: 'Editor',
|
|
56
|
+
targetCollection: 'authors',
|
|
57
|
+
optional: true,
|
|
58
|
+
},
|
|
59
|
+
],
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
name: 'content',
|
|
63
|
+
type: 'blocks',
|
|
64
|
+
label: 'Content',
|
|
65
|
+
blocks: [
|
|
66
|
+
{
|
|
67
|
+
blockType: 'quote',
|
|
68
|
+
fields: [
|
|
69
|
+
{ name: 'body', type: 'text', label: 'Body' },
|
|
70
|
+
{
|
|
71
|
+
name: 'attributedTo',
|
|
72
|
+
type: 'relation',
|
|
73
|
+
label: 'Attributed',
|
|
74
|
+
targetCollection: 'authors',
|
|
75
|
+
optional: true,
|
|
76
|
+
},
|
|
77
|
+
],
|
|
78
|
+
},
|
|
79
|
+
],
|
|
80
|
+
},
|
|
81
|
+
],
|
|
82
|
+
};
|
|
83
|
+
const authorsCollection = {
|
|
84
|
+
path: 'authors',
|
|
85
|
+
labels: { singular: 'Author', plural: 'Authors' },
|
|
86
|
+
fields: [
|
|
87
|
+
{ name: 'name', type: 'text', label: 'Name' },
|
|
88
|
+
{
|
|
89
|
+
name: 'employer',
|
|
90
|
+
type: 'relation',
|
|
91
|
+
label: 'Employer',
|
|
92
|
+
targetCollection: 'orgs',
|
|
93
|
+
optional: true,
|
|
94
|
+
},
|
|
95
|
+
],
|
|
96
|
+
};
|
|
97
|
+
const orgsCollection = {
|
|
98
|
+
path: 'orgs',
|
|
99
|
+
labels: { singular: 'Org', plural: 'Orgs' },
|
|
100
|
+
fields: [{ name: 'name', type: 'text', label: 'Name' }],
|
|
101
|
+
};
|
|
102
|
+
const allCollections = [postsCollection, authorsCollection, orgsCollection];
|
|
103
|
+
function relationRef(collectionId, documentId) {
|
|
104
|
+
return {
|
|
105
|
+
targetDocumentId: documentId,
|
|
106
|
+
targetCollectionId: collectionId,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Build the expected envelope shape for a successfully populated leaf.
|
|
111
|
+
* Mirrors `PopulatedRelationValue` — `leaf.value` metadata plus
|
|
112
|
+
* `_resolved: true` and the attached `document`.
|
|
113
|
+
*/
|
|
114
|
+
function populatedEnvelope(collectionId, documentId, document) {
|
|
115
|
+
return {
|
|
116
|
+
targetDocumentId: documentId,
|
|
117
|
+
targetCollectionId: collectionId,
|
|
118
|
+
_resolved: true,
|
|
119
|
+
document,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Build a mock IDbAdapter where `getDocumentsByDocumentIds` returns
|
|
124
|
+
* documents from a pre-seeded `store[collectionId][documentId]` map.
|
|
125
|
+
*
|
|
126
|
+
* An optional `pathByCollectionId` map simulates the production case
|
|
127
|
+
* where populate is called with DB UUIDs and must fall back to
|
|
128
|
+
* `getCollectionById(id)` to resolve them to a path.
|
|
129
|
+
*/
|
|
130
|
+
function makeMockAdapter(store = {}, pathByCollectionId = {}) {
|
|
131
|
+
const getDocumentsByDocumentIds = vi.fn(async (params) => {
|
|
132
|
+
const bucket = store[params.collection_id] ?? {};
|
|
133
|
+
return params.document_ids.map((id) => bucket[id]).filter((d) => d != null);
|
|
134
|
+
});
|
|
135
|
+
const getCollectionById = vi.fn(async (id) => {
|
|
136
|
+
const path = pathByCollectionId[id];
|
|
137
|
+
return path ? { id, path } : null;
|
|
138
|
+
});
|
|
139
|
+
const db = {
|
|
140
|
+
commands: {
|
|
141
|
+
collections: { create: vi.fn(), update: vi.fn(), delete: vi.fn() },
|
|
142
|
+
documents: {
|
|
143
|
+
createDocumentVersion: vi.fn(),
|
|
144
|
+
setDocumentStatus: vi.fn(),
|
|
145
|
+
archivePublishedVersions: vi.fn(),
|
|
146
|
+
softDeleteDocument: vi.fn(),
|
|
147
|
+
},
|
|
148
|
+
},
|
|
149
|
+
queries: {
|
|
150
|
+
collections: {
|
|
151
|
+
getAllCollections: vi.fn(),
|
|
152
|
+
getCollectionByPath: vi.fn(),
|
|
153
|
+
getCollectionById,
|
|
154
|
+
},
|
|
155
|
+
documents: {
|
|
156
|
+
getDocumentById: vi.fn(),
|
|
157
|
+
getCurrentVersionMetadata: vi.fn(),
|
|
158
|
+
getDocumentByPath: vi.fn(),
|
|
159
|
+
getDocumentByVersion: vi.fn(),
|
|
160
|
+
getDocumentsByVersionIds: vi.fn(),
|
|
161
|
+
getDocumentsByDocumentIds,
|
|
162
|
+
getDocumentHistory: vi.fn(),
|
|
163
|
+
getPublishedVersion: vi.fn(),
|
|
164
|
+
getPublishedDocumentIds: vi.fn(),
|
|
165
|
+
getDocumentCountsByStatus: vi.fn(),
|
|
166
|
+
findDocuments: vi.fn(),
|
|
167
|
+
},
|
|
168
|
+
},
|
|
169
|
+
};
|
|
170
|
+
return { db, getDocumentsByDocumentIds, getCollectionById };
|
|
171
|
+
}
|
|
172
|
+
function shapedDoc(collectionId, documentId, fields) {
|
|
173
|
+
return {
|
|
174
|
+
document_version_id: `ver:${documentId}`,
|
|
175
|
+
document_id: documentId,
|
|
176
|
+
path: documentId,
|
|
177
|
+
status: 'published',
|
|
178
|
+
created_at: new Date('2026-01-01'),
|
|
179
|
+
updated_at: new Date('2026-01-01'),
|
|
180
|
+
_collection_id: collectionId,
|
|
181
|
+
fields,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
// ---------------------------------------------------------------------------
|
|
185
|
+
// Internals
|
|
186
|
+
// ---------------------------------------------------------------------------
|
|
187
|
+
describe('matchesPopulate', () => {
|
|
188
|
+
const { matchesPopulate } = __internal;
|
|
189
|
+
it('returns true when populate is true', () => {
|
|
190
|
+
expect(matchesPopulate('anything', true)).toBe(true);
|
|
191
|
+
});
|
|
192
|
+
it("returns '*' when top-level populate is '*'", () => {
|
|
193
|
+
// Top-level '*' matches every relation name with the '*' sub-spec,
|
|
194
|
+
// so every leaf is fetched with the full document projection.
|
|
195
|
+
expect(matchesPopulate('anything', '*')).toBe('*');
|
|
196
|
+
});
|
|
197
|
+
it('returns the field value from a PopulateMap', () => {
|
|
198
|
+
expect(matchesPopulate('author', { author: true })).toBe(true);
|
|
199
|
+
expect(matchesPopulate('author', { author: { select: ['name'] } })).toEqual({
|
|
200
|
+
select: ['name'],
|
|
201
|
+
});
|
|
202
|
+
});
|
|
203
|
+
it("returns '*' when a field selects the full document", () => {
|
|
204
|
+
expect(matchesPopulate('author', { author: '*' })).toBe('*');
|
|
205
|
+
});
|
|
206
|
+
it('returns undefined for fields not in the map', () => {
|
|
207
|
+
expect(matchesPopulate('author', { editor: true })).toBeUndefined();
|
|
208
|
+
});
|
|
209
|
+
});
|
|
210
|
+
describe('collectRelationLeaves', () => {
|
|
211
|
+
const { collectRelationLeaves } = __internal;
|
|
212
|
+
it('finds top-level relations when populate: true', () => {
|
|
213
|
+
const fields = {
|
|
214
|
+
title: 'hi',
|
|
215
|
+
author: relationRef('authors', 'a1'),
|
|
216
|
+
};
|
|
217
|
+
const leaves = [];
|
|
218
|
+
collectRelationLeaves(fields, postsCollection.fields, true, leaves);
|
|
219
|
+
expect(leaves).toHaveLength(1);
|
|
220
|
+
expect(leaves[0].value.targetDocumentId).toBe('a1');
|
|
221
|
+
expect(leaves[0].sub).toBe(true);
|
|
222
|
+
});
|
|
223
|
+
it('only matches named relations in a PopulateMap', () => {
|
|
224
|
+
const fields = {
|
|
225
|
+
author: relationRef('authors', 'a1'),
|
|
226
|
+
secondaryAuthor: relationRef('authors', 'a2'),
|
|
227
|
+
};
|
|
228
|
+
const leaves = [];
|
|
229
|
+
collectRelationLeaves(fields, postsCollection.fields, { author: true }, leaves);
|
|
230
|
+
expect(leaves).toHaveLength(1);
|
|
231
|
+
expect(leaves[0].key).toBe('author');
|
|
232
|
+
});
|
|
233
|
+
it('recurses into group fields', () => {
|
|
234
|
+
const fields = {
|
|
235
|
+
meta: { editor: relationRef('authors', 'a3') },
|
|
236
|
+
};
|
|
237
|
+
const leaves = [];
|
|
238
|
+
collectRelationLeaves(fields, postsCollection.fields, true, leaves);
|
|
239
|
+
expect(leaves.map((l) => l.value.targetDocumentId)).toEqual(['a3']);
|
|
240
|
+
});
|
|
241
|
+
it('recurses into array items', () => {
|
|
242
|
+
const fields = {
|
|
243
|
+
related: [{ person: relationRef('authors', 'a4') }, { person: relationRef('authors', 'a5') }],
|
|
244
|
+
};
|
|
245
|
+
const leaves = [];
|
|
246
|
+
collectRelationLeaves(fields, postsCollection.fields, true, leaves);
|
|
247
|
+
expect(leaves.map((l) => l.value.targetDocumentId).sort()).toEqual(['a4', 'a5']);
|
|
248
|
+
});
|
|
249
|
+
it('recurses into blocks items, matching _type to blockType', () => {
|
|
250
|
+
const fields = {
|
|
251
|
+
content: [
|
|
252
|
+
{
|
|
253
|
+
_type: 'quote',
|
|
254
|
+
body: 'hello',
|
|
255
|
+
attributedTo: relationRef('authors', 'a6'),
|
|
256
|
+
},
|
|
257
|
+
],
|
|
258
|
+
};
|
|
259
|
+
const leaves = [];
|
|
260
|
+
collectRelationLeaves(fields, postsCollection.fields, true, leaves);
|
|
261
|
+
expect(leaves.map((l) => l.value.targetDocumentId)).toEqual(['a6']);
|
|
262
|
+
});
|
|
263
|
+
it('skips unknown block types silently', () => {
|
|
264
|
+
const fields = {
|
|
265
|
+
content: [
|
|
266
|
+
{
|
|
267
|
+
_type: 'nonExistent',
|
|
268
|
+
attributedTo: relationRef('authors', 'a7'),
|
|
269
|
+
},
|
|
270
|
+
],
|
|
271
|
+
};
|
|
272
|
+
const leaves = [];
|
|
273
|
+
collectRelationLeaves(fields, postsCollection.fields, true, leaves);
|
|
274
|
+
expect(leaves).toEqual([]);
|
|
275
|
+
});
|
|
276
|
+
it('skips leaves already replaced with resolved stubs', () => {
|
|
277
|
+
const fields = {
|
|
278
|
+
author: {
|
|
279
|
+
...relationRef('authors', 'a1'),
|
|
280
|
+
_resolved: false,
|
|
281
|
+
},
|
|
282
|
+
};
|
|
283
|
+
const leaves = [];
|
|
284
|
+
collectRelationLeaves(fields, postsCollection.fields, true, leaves);
|
|
285
|
+
expect(leaves).toEqual([]);
|
|
286
|
+
});
|
|
287
|
+
});
|
|
288
|
+
describe('buildBatchSelect', () => {
|
|
289
|
+
const { buildBatchSelect } = __internal;
|
|
290
|
+
const makeLeaf = (sub) => ({
|
|
291
|
+
sub,
|
|
292
|
+
value: relationRef('authors', 'x'),
|
|
293
|
+
parent: {},
|
|
294
|
+
key: 'k',
|
|
295
|
+
field: {},
|
|
296
|
+
});
|
|
297
|
+
it("returns undefined when any leaf is '*' (full document)", () => {
|
|
298
|
+
expect(buildBatchSelect([makeLeaf('*')], authorsCollection)).toBeUndefined();
|
|
299
|
+
});
|
|
300
|
+
it("'*' on any leaf dominates mixed inputs", () => {
|
|
301
|
+
// A '*' leaf in the batch forces full-document fetch even when a
|
|
302
|
+
// sibling leaf has an explicit select.
|
|
303
|
+
expect(buildBatchSelect([makeLeaf({ select: ['employer'] }), makeLeaf('*')], authorsCollection)).toBeUndefined();
|
|
304
|
+
});
|
|
305
|
+
it('returns identity-only for populate: true (default projection)', () => {
|
|
306
|
+
// A bare `true` sub contributes no selects; the only entry in the
|
|
307
|
+
// union comes from the target's identity field.
|
|
308
|
+
expect(buildBatchSelect([makeLeaf(true)], authorsCollection)).toEqual(['name']);
|
|
309
|
+
});
|
|
310
|
+
it('unions explicit selects and adds the identity field', () => {
|
|
311
|
+
const result = buildBatchSelect([makeLeaf({ select: ['employer'] }), makeLeaf({ select: ['employer'] })], authorsCollection);
|
|
312
|
+
expect(result?.sort()).toEqual(['employer', 'name']);
|
|
313
|
+
});
|
|
314
|
+
it('returns identity-only when sub has no select (just populate)', () => {
|
|
315
|
+
// { populate: {} } is scope+depth forwarding, not a projection opt-in —
|
|
316
|
+
// it should behave like `true` at this level.
|
|
317
|
+
expect(buildBatchSelect([makeLeaf({ populate: {} })], authorsCollection)).toEqual(['name']);
|
|
318
|
+
});
|
|
319
|
+
it('merges true + explicit select: identity plus the explicit field', () => {
|
|
320
|
+
const result = buildBatchSelect([makeLeaf(true), makeLeaf({ select: ['employer'] })], authorsCollection);
|
|
321
|
+
expect(result?.sort()).toEqual(['employer', 'name']);
|
|
322
|
+
});
|
|
323
|
+
it('uses useAsTitle when declared (preferred over first text field)', () => {
|
|
324
|
+
const def = {
|
|
325
|
+
...authorsCollection,
|
|
326
|
+
useAsTitle: 'employer',
|
|
327
|
+
};
|
|
328
|
+
// Identity resolves to `employer` (useAsTitle) instead of `name`
|
|
329
|
+
// (first text field).
|
|
330
|
+
expect(buildBatchSelect([makeLeaf(true)], def)).toEqual(['employer']);
|
|
331
|
+
});
|
|
332
|
+
});
|
|
333
|
+
// ---------------------------------------------------------------------------
|
|
334
|
+
// populateDocuments — behaviour
|
|
335
|
+
// ---------------------------------------------------------------------------
|
|
336
|
+
describe('populateDocuments', () => {
|
|
337
|
+
it('is a no-op when populate is omitted', async () => {
|
|
338
|
+
const { db, getDocumentsByDocumentIds } = makeMockAdapter();
|
|
339
|
+
const doc = shapedDoc('posts', 'p1', { author: relationRef('authors', 'a1') });
|
|
340
|
+
await populateDocuments({
|
|
341
|
+
db,
|
|
342
|
+
collections: allCollections,
|
|
343
|
+
collectionId: 'posts',
|
|
344
|
+
documents: [doc],
|
|
345
|
+
});
|
|
346
|
+
expect(getDocumentsByDocumentIds).not.toHaveBeenCalled();
|
|
347
|
+
expect(doc.fields.author).toEqual(relationRef('authors', 'a1'));
|
|
348
|
+
});
|
|
349
|
+
it('is a no-op when depth: 0', async () => {
|
|
350
|
+
const { db, getDocumentsByDocumentIds } = makeMockAdapter();
|
|
351
|
+
const doc = shapedDoc('posts', 'p1', { author: relationRef('authors', 'a1') });
|
|
352
|
+
await populateDocuments({
|
|
353
|
+
db,
|
|
354
|
+
collections: allCollections,
|
|
355
|
+
collectionId: 'posts',
|
|
356
|
+
documents: [doc],
|
|
357
|
+
populate: true,
|
|
358
|
+
depth: 0,
|
|
359
|
+
});
|
|
360
|
+
expect(getDocumentsByDocumentIds).not.toHaveBeenCalled();
|
|
361
|
+
expect(doc.fields.author).toEqual(relationRef('authors', 'a1'));
|
|
362
|
+
});
|
|
363
|
+
it('populates a single top-level relation at depth 1', async () => {
|
|
364
|
+
const author = shapedDoc('authors', 'a1', { name: 'Nora' });
|
|
365
|
+
const { db, getDocumentsByDocumentIds } = makeMockAdapter({ authors: { a1: author } });
|
|
366
|
+
const doc = shapedDoc('posts', 'p1', { author: relationRef('authors', 'a1') });
|
|
367
|
+
await populateDocuments({
|
|
368
|
+
db,
|
|
369
|
+
collections: allCollections,
|
|
370
|
+
collectionId: 'posts',
|
|
371
|
+
documents: [doc],
|
|
372
|
+
populate: { author: true },
|
|
373
|
+
depth: 1,
|
|
374
|
+
});
|
|
375
|
+
expect(getDocumentsByDocumentIds).toHaveBeenCalledTimes(1);
|
|
376
|
+
expect(doc.fields.author).toEqual(populatedEnvelope('authors', 'a1', author));
|
|
377
|
+
});
|
|
378
|
+
it('populated envelope preserves relationshipType and cascadeDelete', async () => {
|
|
379
|
+
// Link metadata on the original relation value (e.g. a weak-ref flag
|
|
380
|
+
// or cascade-delete directive) must survive the populate pass so
|
|
381
|
+
// callers can inspect or round-trip the relation.
|
|
382
|
+
const author = shapedDoc('authors', 'a1', { name: 'Nora' });
|
|
383
|
+
const { db } = makeMockAdapter({ authors: { a1: author } });
|
|
384
|
+
const doc = shapedDoc('posts', 'p1', {
|
|
385
|
+
author: {
|
|
386
|
+
...relationRef('authors', 'a1'),
|
|
387
|
+
relationshipType: 'weak',
|
|
388
|
+
cascadeDelete: true,
|
|
389
|
+
},
|
|
390
|
+
});
|
|
391
|
+
await populateDocuments({
|
|
392
|
+
db,
|
|
393
|
+
collections: allCollections,
|
|
394
|
+
collectionId: 'posts',
|
|
395
|
+
documents: [doc],
|
|
396
|
+
populate: { author: true },
|
|
397
|
+
});
|
|
398
|
+
expect(doc.fields.author).toEqual({
|
|
399
|
+
targetDocumentId: 'a1',
|
|
400
|
+
targetCollectionId: 'authors',
|
|
401
|
+
relationshipType: 'weak',
|
|
402
|
+
cascadeDelete: true,
|
|
403
|
+
_resolved: true,
|
|
404
|
+
document: author,
|
|
405
|
+
});
|
|
406
|
+
});
|
|
407
|
+
it('groups by target collection: one query per target per level', async () => {
|
|
408
|
+
const a1 = shapedDoc('authors', 'a1', { name: 'Nora' });
|
|
409
|
+
const a2 = shapedDoc('authors', 'a2', { name: 'Ava' });
|
|
410
|
+
const { db, getDocumentsByDocumentIds } = makeMockAdapter({
|
|
411
|
+
authors: { a1, a2 },
|
|
412
|
+
});
|
|
413
|
+
const doc = shapedDoc('posts', 'p1', {
|
|
414
|
+
author: relationRef('authors', 'a1'),
|
|
415
|
+
secondaryAuthor: relationRef('authors', 'a2'),
|
|
416
|
+
});
|
|
417
|
+
await populateDocuments({
|
|
418
|
+
db,
|
|
419
|
+
collections: allCollections,
|
|
420
|
+
collectionId: 'posts',
|
|
421
|
+
documents: [doc],
|
|
422
|
+
populate: true,
|
|
423
|
+
depth: 1,
|
|
424
|
+
});
|
|
425
|
+
// Both relations target 'authors' → single query with [a1, a2].
|
|
426
|
+
expect(getDocumentsByDocumentIds).toHaveBeenCalledTimes(1);
|
|
427
|
+
expect(getDocumentsByDocumentIds).toHaveBeenCalledWith(expect.objectContaining({
|
|
428
|
+
collection_id: 'authors',
|
|
429
|
+
document_ids: expect.arrayContaining(['a1', 'a2']),
|
|
430
|
+
}));
|
|
431
|
+
});
|
|
432
|
+
it('recurses at depth: 2 with nested populate', async () => {
|
|
433
|
+
const org = shapedDoc('orgs', 'o1', { name: 'Acme' });
|
|
434
|
+
const author = shapedDoc('authors', 'a1', {
|
|
435
|
+
name: 'Nora',
|
|
436
|
+
employer: relationRef('orgs', 'o1'),
|
|
437
|
+
});
|
|
438
|
+
const { db, getDocumentsByDocumentIds } = makeMockAdapter({
|
|
439
|
+
authors: { a1: author },
|
|
440
|
+
orgs: { o1: org },
|
|
441
|
+
});
|
|
442
|
+
const doc = shapedDoc('posts', 'p1', { author: relationRef('authors', 'a1') });
|
|
443
|
+
await populateDocuments({
|
|
444
|
+
db,
|
|
445
|
+
collections: allCollections,
|
|
446
|
+
collectionId: 'posts',
|
|
447
|
+
documents: [doc],
|
|
448
|
+
populate: { author: { populate: { employer: true } } },
|
|
449
|
+
depth: 2,
|
|
450
|
+
});
|
|
451
|
+
// One query per level.
|
|
452
|
+
expect(getDocumentsByDocumentIds).toHaveBeenCalledTimes(2);
|
|
453
|
+
expect(doc.fields.author.document).toBe(author);
|
|
454
|
+
expect(author.fields.employer.document).toBe(org);
|
|
455
|
+
});
|
|
456
|
+
it('populate: true recursively populates at depth 2', async () => {
|
|
457
|
+
const org = shapedDoc('orgs', 'o1', { name: 'Acme' });
|
|
458
|
+
const author = shapedDoc('authors', 'a1', {
|
|
459
|
+
name: 'Nora',
|
|
460
|
+
employer: relationRef('orgs', 'o1'),
|
|
461
|
+
});
|
|
462
|
+
const { db, getDocumentsByDocumentIds } = makeMockAdapter({
|
|
463
|
+
authors: { a1: author },
|
|
464
|
+
orgs: { o1: org },
|
|
465
|
+
});
|
|
466
|
+
const doc = shapedDoc('posts', 'p1', { author: relationRef('authors', 'a1') });
|
|
467
|
+
await populateDocuments({
|
|
468
|
+
db,
|
|
469
|
+
collections: allCollections,
|
|
470
|
+
collectionId: 'posts',
|
|
471
|
+
documents: [doc],
|
|
472
|
+
populate: true,
|
|
473
|
+
depth: 2,
|
|
474
|
+
});
|
|
475
|
+
expect(getDocumentsByDocumentIds).toHaveBeenCalledTimes(2);
|
|
476
|
+
expect(doc.fields.author.document).toBe(author);
|
|
477
|
+
expect(author.fields.employer.document).toBe(org);
|
|
478
|
+
});
|
|
479
|
+
it('marks deleted targets with _resolved: false', async () => {
|
|
480
|
+
const { db } = makeMockAdapter({ authors: {} }); // nothing there
|
|
481
|
+
const doc = shapedDoc('posts', 'p1', { author: relationRef('authors', 'gone') });
|
|
482
|
+
await populateDocuments({
|
|
483
|
+
db,
|
|
484
|
+
collections: allCollections,
|
|
485
|
+
collectionId: 'posts',
|
|
486
|
+
documents: [doc],
|
|
487
|
+
populate: { author: true },
|
|
488
|
+
});
|
|
489
|
+
expect(doc.fields.author).toEqual({
|
|
490
|
+
targetDocumentId: 'gone',
|
|
491
|
+
targetCollectionId: 'authors',
|
|
492
|
+
_resolved: false,
|
|
493
|
+
});
|
|
494
|
+
});
|
|
495
|
+
it('marks cycle targets with _cycle: true', async () => {
|
|
496
|
+
// A → B → A. At depth 2, populate reaches A, materialises it, walks its
|
|
497
|
+
// fields looking for further relations, finds a relation back to the
|
|
498
|
+
// source document (already in `visited` because it was the input doc),
|
|
499
|
+
// and replaces the leaf with the cycle marker instead of re-fetching.
|
|
500
|
+
const post = shapedDoc('posts', 'p1', {});
|
|
501
|
+
const author = shapedDoc('authors', 'a1', {
|
|
502
|
+
name: 'Nora',
|
|
503
|
+
// Synthetic cycle: author has a relation field into posts. Use the
|
|
504
|
+
// `employer` relation slot but point at posts instead of orgs to
|
|
505
|
+
// simulate the shape; the walker keys on whatever collection the
|
|
506
|
+
// value declares.
|
|
507
|
+
employer: relationRef('posts', 'p1'),
|
|
508
|
+
});
|
|
509
|
+
const { db } = makeMockAdapter({
|
|
510
|
+
authors: { a1: author },
|
|
511
|
+
posts: { p1: post },
|
|
512
|
+
});
|
|
513
|
+
post.fields.author = relationRef('authors', 'a1');
|
|
514
|
+
await populateDocuments({
|
|
515
|
+
db,
|
|
516
|
+
collections: allCollections,
|
|
517
|
+
collectionId: 'posts',
|
|
518
|
+
documents: [post],
|
|
519
|
+
populate: true,
|
|
520
|
+
depth: 3,
|
|
521
|
+
});
|
|
522
|
+
expect(post.fields.author.document).toBe(author);
|
|
523
|
+
expect(author.fields.employer).toEqual({
|
|
524
|
+
targetDocumentId: 'p1',
|
|
525
|
+
targetCollectionId: 'posts',
|
|
526
|
+
_resolved: true,
|
|
527
|
+
_cycle: true,
|
|
528
|
+
});
|
|
529
|
+
});
|
|
530
|
+
it('persists visited across calls that share a ReadContext', async () => {
|
|
531
|
+
// First call loads author a1; second call (sharing the same context)
|
|
532
|
+
// sees a1 as already-visited and renders the cycle marker.
|
|
533
|
+
const author = shapedDoc('authors', 'a1', { name: 'Nora' });
|
|
534
|
+
const { db, getDocumentsByDocumentIds } = makeMockAdapter({
|
|
535
|
+
authors: { a1: author },
|
|
536
|
+
});
|
|
537
|
+
const ctx = createReadContext();
|
|
538
|
+
const doc1 = shapedDoc('posts', 'p1', { author: relationRef('authors', 'a1') });
|
|
539
|
+
await populateDocuments({
|
|
540
|
+
db,
|
|
541
|
+
collections: allCollections,
|
|
542
|
+
collectionId: 'posts',
|
|
543
|
+
documents: [doc1],
|
|
544
|
+
populate: { author: true },
|
|
545
|
+
readContext: ctx,
|
|
546
|
+
});
|
|
547
|
+
expect(doc1.fields.author.document).toBe(author);
|
|
548
|
+
const doc2 = shapedDoc('posts', 'p2', { author: relationRef('authors', 'a1') });
|
|
549
|
+
await populateDocuments({
|
|
550
|
+
db,
|
|
551
|
+
collections: allCollections,
|
|
552
|
+
collectionId: 'posts',
|
|
553
|
+
documents: [doc2],
|
|
554
|
+
populate: { author: true },
|
|
555
|
+
readContext: ctx,
|
|
556
|
+
});
|
|
557
|
+
// Second call sees a1 already visited → skips fetch, renders cycle.
|
|
558
|
+
expect(getDocumentsByDocumentIds).toHaveBeenCalledTimes(1);
|
|
559
|
+
expect(doc2.fields.author).toEqual({
|
|
560
|
+
targetDocumentId: 'a1',
|
|
561
|
+
targetCollectionId: 'authors',
|
|
562
|
+
_resolved: true,
|
|
563
|
+
_cycle: true,
|
|
564
|
+
});
|
|
565
|
+
});
|
|
566
|
+
it('throws ERR_READ_BUDGET_EXCEEDED when maxReads is exceeded', async () => {
|
|
567
|
+
const author = shapedDoc('authors', 'a1', { name: 'Nora' });
|
|
568
|
+
const { db } = makeMockAdapter({ authors: { a1: author } });
|
|
569
|
+
const ctx = createReadContext({ maxReads: 0 });
|
|
570
|
+
const doc = shapedDoc('posts', 'p1', { author: relationRef('authors', 'a1') });
|
|
571
|
+
await expect(populateDocuments({
|
|
572
|
+
db,
|
|
573
|
+
collections: allCollections,
|
|
574
|
+
collectionId: 'posts',
|
|
575
|
+
documents: [doc],
|
|
576
|
+
populate: { author: true },
|
|
577
|
+
readContext: ctx,
|
|
578
|
+
})).rejects.toSatisfy((err) => err instanceof BylineError && err.code === ErrorCodes.READ_BUDGET_EXCEEDED);
|
|
579
|
+
});
|
|
580
|
+
it('clamps depth to readContext.maxDepth', async () => {
|
|
581
|
+
// maxDepth: 1 should stop after the first level even if depth: 5
|
|
582
|
+
// is requested.
|
|
583
|
+
const org = shapedDoc('orgs', 'o1', { name: 'Acme' });
|
|
584
|
+
const author = shapedDoc('authors', 'a1', {
|
|
585
|
+
name: 'Nora',
|
|
586
|
+
employer: relationRef('orgs', 'o1'),
|
|
587
|
+
});
|
|
588
|
+
const { db, getDocumentsByDocumentIds } = makeMockAdapter({
|
|
589
|
+
authors: { a1: author },
|
|
590
|
+
orgs: { o1: org },
|
|
591
|
+
});
|
|
592
|
+
const doc = shapedDoc('posts', 'p1', { author: relationRef('authors', 'a1') });
|
|
593
|
+
await populateDocuments({
|
|
594
|
+
db,
|
|
595
|
+
collections: allCollections,
|
|
596
|
+
collectionId: 'posts',
|
|
597
|
+
documents: [doc],
|
|
598
|
+
populate: true,
|
|
599
|
+
depth: 5,
|
|
600
|
+
readContext: createReadContext({ maxDepth: 1 }),
|
|
601
|
+
});
|
|
602
|
+
// Only one level: author fetched, but employer stays as a raw ref.
|
|
603
|
+
expect(getDocumentsByDocumentIds).toHaveBeenCalledTimes(1);
|
|
604
|
+
expect(doc.fields.author.document).toBe(author);
|
|
605
|
+
expect(author.fields.employer).toEqual(relationRef('orgs', 'o1'));
|
|
606
|
+
});
|
|
607
|
+
it("populate: '*' fetches full documents at every depth (recursive)", async () => {
|
|
608
|
+
// Top-level '*' = scope: all + full projection, transitive. At depth 2
|
|
609
|
+
// both the author and its employer should come back with no fields
|
|
610
|
+
// projection (fields: undefined → fetch all).
|
|
611
|
+
const org = shapedDoc('orgs', 'o1', { name: 'Acme' });
|
|
612
|
+
const author = shapedDoc('authors', 'a1', {
|
|
613
|
+
name: 'Nora',
|
|
614
|
+
employer: relationRef('orgs', 'o1'),
|
|
615
|
+
});
|
|
616
|
+
const { db, getDocumentsByDocumentIds } = makeMockAdapter({
|
|
617
|
+
authors: { a1: author },
|
|
618
|
+
orgs: { o1: org },
|
|
619
|
+
});
|
|
620
|
+
const doc = shapedDoc('posts', 'p1', { author: relationRef('authors', 'a1') });
|
|
621
|
+
await populateDocuments({
|
|
622
|
+
db,
|
|
623
|
+
collections: allCollections,
|
|
624
|
+
collectionId: 'posts',
|
|
625
|
+
documents: [doc],
|
|
626
|
+
populate: '*',
|
|
627
|
+
depth: 2,
|
|
628
|
+
});
|
|
629
|
+
expect(getDocumentsByDocumentIds).toHaveBeenCalledTimes(2);
|
|
630
|
+
// Both level-1 and level-2 calls fetch with no fields projection.
|
|
631
|
+
for (const call of getDocumentsByDocumentIds.mock.calls) {
|
|
632
|
+
expect(call[0]).toEqual(expect.objectContaining({ fields: undefined }));
|
|
633
|
+
}
|
|
634
|
+
expect(doc.fields.author.document).toBe(author);
|
|
635
|
+
expect(author.fields.employer.document).toBe(org);
|
|
636
|
+
});
|
|
637
|
+
it("{ author: '*' } propagates '*' to nested relations at deeper levels", async () => {
|
|
638
|
+
// Sub-spec '*' is symmetric with `true` — both propagate their
|
|
639
|
+
// projection choice to the next level when the caller doesn't
|
|
640
|
+
// specify explicit nested populate. So { author: '*' } at depth 2
|
|
641
|
+
// fetches author full AND author's own relations full.
|
|
642
|
+
const org = shapedDoc('orgs', 'o1', { name: 'Acme' });
|
|
643
|
+
const author = shapedDoc('authors', 'a1', {
|
|
644
|
+
name: 'Nora',
|
|
645
|
+
employer: relationRef('orgs', 'o1'),
|
|
646
|
+
});
|
|
647
|
+
const { db, getDocumentsByDocumentIds } = makeMockAdapter({
|
|
648
|
+
authors: { a1: author },
|
|
649
|
+
orgs: { o1: org },
|
|
650
|
+
});
|
|
651
|
+
const doc = shapedDoc('posts', 'p1', { author: relationRef('authors', 'a1') });
|
|
652
|
+
await populateDocuments({
|
|
653
|
+
db,
|
|
654
|
+
collections: allCollections,
|
|
655
|
+
collectionId: 'posts',
|
|
656
|
+
documents: [doc],
|
|
657
|
+
populate: { author: '*' },
|
|
658
|
+
depth: 2,
|
|
659
|
+
});
|
|
660
|
+
expect(getDocumentsByDocumentIds).toHaveBeenCalledTimes(2);
|
|
661
|
+
for (const call of getDocumentsByDocumentIds.mock.calls) {
|
|
662
|
+
expect(call[0]).toEqual(expect.objectContaining({ fields: undefined }));
|
|
663
|
+
}
|
|
664
|
+
expect(author.fields.employer.document).toBe(org);
|
|
665
|
+
});
|
|
666
|
+
it("'*' sub-spec fetches the full target document (no fields projection)", async () => {
|
|
667
|
+
const author = shapedDoc('authors', 'a1', { name: 'Nora' });
|
|
668
|
+
const { db, getDocumentsByDocumentIds } = makeMockAdapter({
|
|
669
|
+
authors: { a1: author },
|
|
670
|
+
});
|
|
671
|
+
const doc = shapedDoc('posts', 'p1', { author: relationRef('authors', 'a1') });
|
|
672
|
+
await populateDocuments({
|
|
673
|
+
db,
|
|
674
|
+
collections: allCollections,
|
|
675
|
+
collectionId: 'posts',
|
|
676
|
+
documents: [doc],
|
|
677
|
+
populate: { author: '*' },
|
|
678
|
+
});
|
|
679
|
+
expect(getDocumentsByDocumentIds).toHaveBeenCalledWith(expect.objectContaining({
|
|
680
|
+
collection_id: 'authors',
|
|
681
|
+
fields: undefined,
|
|
682
|
+
}));
|
|
683
|
+
expect(doc.fields.author.document).toBe(author);
|
|
684
|
+
});
|
|
685
|
+
it('default projection sends identity-only fields list', async () => {
|
|
686
|
+
// `populate: { author: true }` uses the default projection: the
|
|
687
|
+
// target's identity field (`name` for authorsCollection) — no full
|
|
688
|
+
// fetch, no explicit select.
|
|
689
|
+
const author = shapedDoc('authors', 'a1', { name: 'Nora' });
|
|
690
|
+
const { db, getDocumentsByDocumentIds } = makeMockAdapter({
|
|
691
|
+
authors: { a1: author },
|
|
692
|
+
});
|
|
693
|
+
const doc = shapedDoc('posts', 'p1', { author: relationRef('authors', 'a1') });
|
|
694
|
+
await populateDocuments({
|
|
695
|
+
db,
|
|
696
|
+
collections: allCollections,
|
|
697
|
+
collectionId: 'posts',
|
|
698
|
+
documents: [doc],
|
|
699
|
+
populate: { author: true },
|
|
700
|
+
});
|
|
701
|
+
expect(getDocumentsByDocumentIds).toHaveBeenCalledWith(expect.objectContaining({
|
|
702
|
+
collection_id: 'authors',
|
|
703
|
+
fields: ['name'],
|
|
704
|
+
}));
|
|
705
|
+
});
|
|
706
|
+
it('forwards nested select + adds first text field for display', async () => {
|
|
707
|
+
const author = shapedDoc('authors', 'a1', { name: 'Nora' });
|
|
708
|
+
const { db, getDocumentsByDocumentIds } = makeMockAdapter({
|
|
709
|
+
authors: { a1: author },
|
|
710
|
+
});
|
|
711
|
+
const doc = shapedDoc('posts', 'p1', { author: relationRef('authors', 'a1') });
|
|
712
|
+
await populateDocuments({
|
|
713
|
+
db,
|
|
714
|
+
collections: allCollections,
|
|
715
|
+
collectionId: 'posts',
|
|
716
|
+
documents: [doc],
|
|
717
|
+
populate: { author: { select: ['employer'] } },
|
|
718
|
+
});
|
|
719
|
+
expect(getDocumentsByDocumentIds).toHaveBeenCalledWith(expect.objectContaining({
|
|
720
|
+
collection_id: 'authors',
|
|
721
|
+
fields: expect.arrayContaining(['employer', 'name']),
|
|
722
|
+
}));
|
|
723
|
+
});
|
|
724
|
+
it('populates relations inside array items', async () => {
|
|
725
|
+
const a4 = shapedDoc('authors', 'a4', { name: 'Ivan' });
|
|
726
|
+
const { db, getDocumentsByDocumentIds } = makeMockAdapter({ authors: { a4 } });
|
|
727
|
+
const doc = shapedDoc('posts', 'p1', {
|
|
728
|
+
related: [{ person: relationRef('authors', 'a4') }],
|
|
729
|
+
});
|
|
730
|
+
await populateDocuments({
|
|
731
|
+
db,
|
|
732
|
+
collections: allCollections,
|
|
733
|
+
collectionId: 'posts',
|
|
734
|
+
documents: [doc],
|
|
735
|
+
populate: { person: true },
|
|
736
|
+
});
|
|
737
|
+
expect(getDocumentsByDocumentIds).toHaveBeenCalledTimes(1);
|
|
738
|
+
expect(doc.fields.related[0].person.document).toBe(a4);
|
|
739
|
+
});
|
|
740
|
+
it('populates relations inside blocks items', async () => {
|
|
741
|
+
const a6 = shapedDoc('authors', 'a6', { name: 'Quinn' });
|
|
742
|
+
const { db, getDocumentsByDocumentIds } = makeMockAdapter({ authors: { a6 } });
|
|
743
|
+
const doc = shapedDoc('posts', 'p1', {
|
|
744
|
+
content: [{ _type: 'quote', body: 'hello', attributedTo: relationRef('authors', 'a6') }],
|
|
745
|
+
});
|
|
746
|
+
await populateDocuments({
|
|
747
|
+
db,
|
|
748
|
+
collections: allCollections,
|
|
749
|
+
collectionId: 'posts',
|
|
750
|
+
documents: [doc],
|
|
751
|
+
populate: { attributedTo: true },
|
|
752
|
+
});
|
|
753
|
+
expect(getDocumentsByDocumentIds).toHaveBeenCalledTimes(1);
|
|
754
|
+
expect(doc.fields.content[0].attributedTo.document).toBe(a6);
|
|
755
|
+
});
|
|
756
|
+
it('populates relations inside group fields', async () => {
|
|
757
|
+
const a3 = shapedDoc('authors', 'a3', { name: 'Editor' });
|
|
758
|
+
const { db, getDocumentsByDocumentIds } = makeMockAdapter({ authors: { a3 } });
|
|
759
|
+
const doc = shapedDoc('posts', 'p1', {
|
|
760
|
+
meta: { editor: relationRef('authors', 'a3') },
|
|
761
|
+
});
|
|
762
|
+
await populateDocuments({
|
|
763
|
+
db,
|
|
764
|
+
collections: allCollections,
|
|
765
|
+
collectionId: 'posts',
|
|
766
|
+
documents: [doc],
|
|
767
|
+
populate: { editor: true },
|
|
768
|
+
});
|
|
769
|
+
expect(getDocumentsByDocumentIds).toHaveBeenCalledTimes(1);
|
|
770
|
+
expect(doc.fields.meta.editor.document).toBe(a3);
|
|
771
|
+
});
|
|
772
|
+
it('de-duplicates IDs at a single level (one fetch for two references)', async () => {
|
|
773
|
+
const a1 = shapedDoc('authors', 'a1', { name: 'Nora' });
|
|
774
|
+
const { db, getDocumentsByDocumentIds } = makeMockAdapter({ authors: { a1 } });
|
|
775
|
+
const doc = shapedDoc('posts', 'p1', {
|
|
776
|
+
author: relationRef('authors', 'a1'),
|
|
777
|
+
secondaryAuthor: relationRef('authors', 'a1'),
|
|
778
|
+
});
|
|
779
|
+
await populateDocuments({
|
|
780
|
+
db,
|
|
781
|
+
collections: allCollections,
|
|
782
|
+
collectionId: 'posts',
|
|
783
|
+
documents: [doc],
|
|
784
|
+
populate: true,
|
|
785
|
+
});
|
|
786
|
+
expect(getDocumentsByDocumentIds).toHaveBeenCalledTimes(1);
|
|
787
|
+
expect(getDocumentsByDocumentIds).toHaveBeenCalledWith(expect.objectContaining({ collection_id: 'authors', document_ids: ['a1'] }));
|
|
788
|
+
// Both leaves get their own envelope, each wrapping the same fetched doc.
|
|
789
|
+
expect(doc.fields.author.document).toBe(a1);
|
|
790
|
+
expect(doc.fields.secondaryAuthor.document).toBe(a1);
|
|
791
|
+
});
|
|
792
|
+
it('uses composite (collection, document) keys so same id across collections stays distinct', async () => {
|
|
793
|
+
// p1 is a post id; a different collection (authors) could theoretically
|
|
794
|
+
// have a document with id 'p1' too. They must not collide in visited.
|
|
795
|
+
const authorWithSameId = shapedDoc('authors', 'p1', { name: 'Nora' });
|
|
796
|
+
const { db } = makeMockAdapter({
|
|
797
|
+
authors: { p1: authorWithSameId },
|
|
798
|
+
});
|
|
799
|
+
const post = shapedDoc('posts', 'p1', { author: relationRef('authors', 'p1') });
|
|
800
|
+
await populateDocuments({
|
|
801
|
+
db,
|
|
802
|
+
collections: allCollections,
|
|
803
|
+
collectionId: 'posts',
|
|
804
|
+
documents: [post],
|
|
805
|
+
populate: { author: true },
|
|
806
|
+
});
|
|
807
|
+
// Post p1 was marked visited with key 'posts:p1'. Author p1 uses
|
|
808
|
+
// 'authors:p1' — distinct. Author populates normally.
|
|
809
|
+
expect(post.fields.author.document).toBe(authorWithSameId);
|
|
810
|
+
});
|
|
811
|
+
});
|
|
812
|
+
// ---------------------------------------------------------------------------
|
|
813
|
+
// Interaction with unknown target collections
|
|
814
|
+
// ---------------------------------------------------------------------------
|
|
815
|
+
// ---------------------------------------------------------------------------
|
|
816
|
+
// DB-UUID resolution — the production case
|
|
817
|
+
// ---------------------------------------------------------------------------
|
|
818
|
+
describe('populateDocuments — DB UUID → path resolution', () => {
|
|
819
|
+
it('falls back to getCollectionById when collectionId is a DB UUID', async () => {
|
|
820
|
+
// Production flow: admin server fn passes DB UUIDs as collectionId and
|
|
821
|
+
// targetCollectionId. The collections array carries CollectionDefinition
|
|
822
|
+
// objects keyed by path, not UUID. Without the DB fallback, populate
|
|
823
|
+
// early-exits because findDef can't resolve the UUID.
|
|
824
|
+
const postsUuid = '019d3acf-aaaa-aaaa-aaaa-000000000001';
|
|
825
|
+
const authorsUuid = '019d3acf-bbbb-bbbb-bbbb-000000000002';
|
|
826
|
+
const author = shapedDoc(authorsUuid, 'a1', { name: 'Nora' });
|
|
827
|
+
const { db, getDocumentsByDocumentIds, getCollectionById } = makeMockAdapter({ [authorsUuid]: { a1: author } }, { [postsUuid]: 'posts', [authorsUuid]: 'authors' });
|
|
828
|
+
const doc = shapedDoc(postsUuid, 'p1', {
|
|
829
|
+
author: { targetDocumentId: 'a1', targetCollectionId: authorsUuid },
|
|
830
|
+
});
|
|
831
|
+
await populateDocuments({
|
|
832
|
+
db,
|
|
833
|
+
collections: allCollections,
|
|
834
|
+
collectionId: postsUuid,
|
|
835
|
+
documents: [doc],
|
|
836
|
+
populate: { author: true },
|
|
837
|
+
});
|
|
838
|
+
// Both UUIDs got resolved via getCollectionById.
|
|
839
|
+
expect(getCollectionById).toHaveBeenCalledWith(postsUuid);
|
|
840
|
+
expect(getCollectionById).toHaveBeenCalledWith(authorsUuid);
|
|
841
|
+
// And the populated document is in place.
|
|
842
|
+
expect(doc.fields.author.document).toBe(author);
|
|
843
|
+
expect(getDocumentsByDocumentIds).toHaveBeenCalledTimes(1);
|
|
844
|
+
});
|
|
845
|
+
it('caches collection resolution across multiple leaves in one call', async () => {
|
|
846
|
+
const authorsUuid = '019d3acf-cccc-cccc-cccc-000000000003';
|
|
847
|
+
const a1 = shapedDoc(authorsUuid, 'a1', { name: 'Nora' });
|
|
848
|
+
const a2 = shapedDoc(authorsUuid, 'a2', { name: 'Ava' });
|
|
849
|
+
const { db, getCollectionById } = makeMockAdapter({ [authorsUuid]: { a1, a2 } }, { posts: 'posts', [authorsUuid]: 'authors' });
|
|
850
|
+
const doc = shapedDoc('posts', 'p1', {
|
|
851
|
+
author: { targetDocumentId: 'a1', targetCollectionId: authorsUuid },
|
|
852
|
+
secondaryAuthor: { targetDocumentId: 'a2', targetCollectionId: authorsUuid },
|
|
853
|
+
});
|
|
854
|
+
await populateDocuments({
|
|
855
|
+
db,
|
|
856
|
+
collections: allCollections,
|
|
857
|
+
collectionId: 'posts',
|
|
858
|
+
documents: [doc],
|
|
859
|
+
populate: true,
|
|
860
|
+
});
|
|
861
|
+
// 'posts' resolved via path match (no DB query). 'authorsUuid' resolved
|
|
862
|
+
// once via the DB fallback and reused for both leaves.
|
|
863
|
+
const authorCalls = getCollectionById.mock.calls.filter(([arg]) => arg === authorsUuid).length;
|
|
864
|
+
expect(authorCalls).toBe(1);
|
|
865
|
+
});
|
|
866
|
+
});
|
|
867
|
+
describe('populateDocuments — unknown target collection', () => {
|
|
868
|
+
it('renders an unresolved stub when target collection is unregistered', async () => {
|
|
869
|
+
// `weirdCollection` id has no matching CollectionDefinition.
|
|
870
|
+
const { db } = makeMockAdapter({
|
|
871
|
+
/* weirdCollection not present: batch will return empty */
|
|
872
|
+
weirdCollection: {},
|
|
873
|
+
});
|
|
874
|
+
const doc = shapedDoc('posts', 'p1', {
|
|
875
|
+
author: relationRef('weirdCollection', 'a1'),
|
|
876
|
+
});
|
|
877
|
+
await populateDocuments({
|
|
878
|
+
db,
|
|
879
|
+
collections: allCollections,
|
|
880
|
+
collectionId: 'posts',
|
|
881
|
+
documents: [doc],
|
|
882
|
+
populate: { author: true },
|
|
883
|
+
});
|
|
884
|
+
expect(doc.fields.author).toEqual({
|
|
885
|
+
targetDocumentId: 'a1',
|
|
886
|
+
targetCollectionId: 'weirdCollection',
|
|
887
|
+
_resolved: false,
|
|
888
|
+
});
|
|
889
|
+
});
|
|
890
|
+
});
|
|
891
|
+
// ---------------------------------------------------------------------------
|
|
892
|
+
// PopulateSpec type sanity (compile-time more than behaviour)
|
|
893
|
+
// ---------------------------------------------------------------------------
|
|
894
|
+
describe('PopulateSpec typing', () => {
|
|
895
|
+
it('accepts nested populate and select options', () => {
|
|
896
|
+
const spec = {
|
|
897
|
+
author: { select: ['name'], populate: { employer: true } },
|
|
898
|
+
editor: true,
|
|
899
|
+
};
|
|
900
|
+
expect(spec).toBeDefined();
|
|
901
|
+
});
|
|
902
|
+
it("accepts the '*' full-document shorthand at any leaf", () => {
|
|
903
|
+
const spec = {
|
|
904
|
+
author: '*',
|
|
905
|
+
editor: { populate: { employer: '*' } },
|
|
906
|
+
};
|
|
907
|
+
expect(spec).toBeDefined();
|
|
908
|
+
});
|
|
909
|
+
});
|
|
910
|
+
//# sourceMappingURL=populate.test.node.js.map
|