@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.
Files changed (283) hide show
  1. package/LICENSE +373 -0
  2. package/README.md +17 -0
  3. package/dist/@types/admin-types.d.ts +275 -0
  4. package/dist/@types/admin-types.d.ts.map +1 -0
  5. package/dist/@types/admin-types.js +18 -0
  6. package/dist/@types/admin-types.js.map +1 -0
  7. package/dist/@types/collection-types.d.ts +816 -0
  8. package/dist/@types/collection-types.d.ts.map +1 -0
  9. package/dist/@types/collection-types.js +217 -0
  10. package/dist/@types/collection-types.js.map +1 -0
  11. package/dist/@types/db-types.d.ts +463 -0
  12. package/dist/@types/db-types.d.ts.map +1 -0
  13. package/dist/@types/db-types.js +2 -0
  14. package/dist/@types/db-types.js.map +1 -0
  15. package/dist/@types/field-data-types.d.ts +147 -0
  16. package/dist/@types/field-data-types.d.ts.map +1 -0
  17. package/dist/@types/field-data-types.js +38 -0
  18. package/dist/@types/field-data-types.js.map +1 -0
  19. package/dist/@types/field-types.d.ts +579 -0
  20. package/dist/@types/field-types.d.ts.map +1 -0
  21. package/dist/@types/field-types.js +32 -0
  22. package/dist/@types/field-types.js.map +1 -0
  23. package/dist/@types/index.d.ts +18 -0
  24. package/dist/@types/index.d.ts.map +1 -0
  25. package/dist/@types/index.js +18 -0
  26. package/dist/@types/index.js.map +1 -0
  27. package/dist/@types/populate-types.d.ts +54 -0
  28. package/dist/@types/populate-types.d.ts.map +1 -0
  29. package/dist/@types/populate-types.js +9 -0
  30. package/dist/@types/populate-types.js.map +1 -0
  31. package/dist/@types/query-predicate.d.ts +74 -0
  32. package/dist/@types/query-predicate.d.ts.map +1 -0
  33. package/dist/@types/query-predicate.js +9 -0
  34. package/dist/@types/query-predicate.js.map +1 -0
  35. package/dist/@types/site-config.d.ts +212 -0
  36. package/dist/@types/site-config.d.ts.map +1 -0
  37. package/dist/@types/site-config.js +9 -0
  38. package/dist/@types/site-config.js.map +1 -0
  39. package/dist/@types/storage-types.d.ts +86 -0
  40. package/dist/@types/storage-types.d.ts.map +1 -0
  41. package/dist/@types/storage-types.js +9 -0
  42. package/dist/@types/storage-types.js.map +1 -0
  43. package/dist/@types/store-types.d.ts +134 -0
  44. package/dist/@types/store-types.d.ts.map +1 -0
  45. package/dist/@types/store-types.js +24 -0
  46. package/dist/@types/store-types.js.map +1 -0
  47. package/dist/@types/type-utils.d.ts +17 -0
  48. package/dist/@types/type-utils.d.ts.map +1 -0
  49. package/dist/@types/type-utils.js +9 -0
  50. package/dist/@types/type-utils.js.map +1 -0
  51. package/dist/auth/apply-before-read.d.ts +36 -0
  52. package/dist/auth/apply-before-read.d.ts.map +1 -0
  53. package/dist/auth/apply-before-read.js +68 -0
  54. package/dist/auth/apply-before-read.js.map +1 -0
  55. package/dist/auth/apply-before-read.test.node.d.ts +9 -0
  56. package/dist/auth/apply-before-read.test.node.d.ts.map +1 -0
  57. package/dist/auth/apply-before-read.test.node.js +144 -0
  58. package/dist/auth/apply-before-read.test.node.js.map +1 -0
  59. package/dist/auth/assert-actor-can-perform.d.ts +39 -0
  60. package/dist/auth/assert-actor-can-perform.d.ts.map +1 -0
  61. package/dist/auth/assert-actor-can-perform.js +64 -0
  62. package/dist/auth/assert-actor-can-perform.js.map +1 -0
  63. package/dist/auth/assert-actor-can-perform.test.node.d.ts +9 -0
  64. package/dist/auth/assert-actor-can-perform.test.node.d.ts.map +1 -0
  65. package/dist/auth/assert-actor-can-perform.test.node.js +119 -0
  66. package/dist/auth/assert-actor-can-perform.test.node.js.map +1 -0
  67. package/dist/auth/index.d.ts +11 -0
  68. package/dist/auth/index.d.ts.map +1 -0
  69. package/dist/auth/index.js +11 -0
  70. package/dist/auth/index.js.map +1 -0
  71. package/dist/auth/register-collection-abilities.d.ts +40 -0
  72. package/dist/auth/register-collection-abilities.d.ts.map +1 -0
  73. package/dist/auth/register-collection-abilities.js +87 -0
  74. package/dist/auth/register-collection-abilities.js.map +1 -0
  75. package/dist/auth/register-collection-abilities.test.node.d.ts +9 -0
  76. package/dist/auth/register-collection-abilities.test.node.d.ts.map +1 -0
  77. package/dist/auth/register-collection-abilities.test.node.js +124 -0
  78. package/dist/auth/register-collection-abilities.test.node.js.map +1 -0
  79. package/dist/config/config.d.ts +10 -0
  80. package/dist/config/config.d.ts.map +1 -0
  81. package/dist/config/config.js +108 -0
  82. package/dist/config/config.js.map +1 -0
  83. package/dist/config/routes.d.ts +16 -0
  84. package/dist/config/routes.d.ts.map +1 -0
  85. package/dist/config/routes.js +26 -0
  86. package/dist/config/routes.js.map +1 -0
  87. package/dist/config/validate-admin-configs.d.ts +33 -0
  88. package/dist/config/validate-admin-configs.d.ts.map +1 -0
  89. package/dist/config/validate-admin-configs.js +250 -0
  90. package/dist/config/validate-admin-configs.js.map +1 -0
  91. package/dist/config/validate-admin-configs.test.node.d.ts +9 -0
  92. package/dist/config/validate-admin-configs.test.node.d.ts.map +1 -0
  93. package/dist/config/validate-admin-configs.test.node.js +224 -0
  94. package/dist/config/validate-admin-configs.test.node.js.map +1 -0
  95. package/dist/config/validate-collections.d.ts +33 -0
  96. package/dist/config/validate-collections.d.ts.map +1 -0
  97. package/dist/config/validate-collections.js +70 -0
  98. package/dist/config/validate-collections.js.map +1 -0
  99. package/dist/config/validate-collections.test.node.d.ts +9 -0
  100. package/dist/config/validate-collections.test.node.d.ts.map +1 -0
  101. package/dist/config/validate-collections.test.node.js +149 -0
  102. package/dist/config/validate-collections.test.node.js.map +1 -0
  103. package/dist/core.d.ts +89 -0
  104. package/dist/core.d.ts.map +1 -0
  105. package/dist/core.js +99 -0
  106. package/dist/core.js.map +1 -0
  107. package/dist/defaults/default-values.d.ts +13 -0
  108. package/dist/defaults/default-values.d.ts.map +1 -0
  109. package/dist/defaults/default-values.js +60 -0
  110. package/dist/defaults/default-values.js.map +1 -0
  111. package/dist/index.d.ts +20 -0
  112. package/dist/index.d.ts.map +1 -0
  113. package/dist/index.js +36 -0
  114. package/dist/index.js.map +1 -0
  115. package/dist/lib/errors.d.ts +98 -0
  116. package/dist/lib/errors.d.ts.map +1 -0
  117. package/dist/lib/errors.js +134 -0
  118. package/dist/lib/errors.js.map +1 -0
  119. package/dist/lib/logger.d.ts +62 -0
  120. package/dist/lib/logger.d.ts.map +1 -0
  121. package/dist/lib/logger.js +120 -0
  122. package/dist/lib/logger.js.map +1 -0
  123. package/dist/lib/registry.d.ts +65 -0
  124. package/dist/lib/registry.d.ts.map +1 -0
  125. package/dist/lib/registry.js +133 -0
  126. package/dist/lib/registry.js.map +1 -0
  127. package/dist/logger/index.d.ts +3 -0
  128. package/dist/logger/index.d.ts.map +1 -0
  129. package/dist/logger/index.js +3 -0
  130. package/dist/logger/index.js.map +1 -0
  131. package/dist/patches/apply-patches.d.ts +21 -0
  132. package/dist/patches/apply-patches.d.ts.map +1 -0
  133. package/dist/patches/apply-patches.js +357 -0
  134. package/dist/patches/apply-patches.js.map +1 -0
  135. package/dist/patches/index.d.ts +3 -0
  136. package/dist/patches/index.d.ts.map +1 -0
  137. package/dist/patches/index.js +4 -0
  138. package/dist/patches/index.js.map +1 -0
  139. package/dist/patches/patch-types.d.ts +82 -0
  140. package/dist/patches/patch-types.d.ts.map +1 -0
  141. package/dist/patches/patch-types.js +3 -0
  142. package/dist/patches/patch-types.js.map +1 -0
  143. package/dist/patches/patch.test.node.d.ts +2 -0
  144. package/dist/patches/patch.test.node.d.ts.map +1 -0
  145. package/dist/patches/patch.test.node.js +193 -0
  146. package/dist/patches/patch.test.node.js.map +1 -0
  147. package/dist/query/parse-where.d.ts +100 -0
  148. package/dist/query/parse-where.d.ts.map +1 -0
  149. package/dist/query/parse-where.js +352 -0
  150. package/dist/query/parse-where.js.map +1 -0
  151. package/dist/query/parse-where.test.node.d.ts +9 -0
  152. package/dist/query/parse-where.test.node.d.ts.map +1 -0
  153. package/dist/query/parse-where.test.node.js +581 -0
  154. package/dist/query/parse-where.test.node.js.map +1 -0
  155. package/dist/schemas/zod/builder.d.ts +466 -0
  156. package/dist/schemas/zod/builder.d.ts.map +1 -0
  157. package/dist/schemas/zod/builder.js +276 -0
  158. package/dist/schemas/zod/builder.js.map +1 -0
  159. package/dist/schemas/zod/cache.d.ts +14 -0
  160. package/dist/schemas/zod/cache.d.ts.map +1 -0
  161. package/dist/schemas/zod/cache.js +40 -0
  162. package/dist/schemas/zod/cache.js.map +1 -0
  163. package/dist/schemas/zod/index.d.ts +4 -0
  164. package/dist/schemas/zod/index.d.ts.map +1 -0
  165. package/dist/schemas/zod/index.js +4 -0
  166. package/dist/schemas/zod/index.js.map +1 -0
  167. package/dist/schemas/zod/types.d.ts +13 -0
  168. package/dist/schemas/zod/types.d.ts.map +1 -0
  169. package/dist/schemas/zod/types.js +2 -0
  170. package/dist/schemas/zod/types.js.map +1 -0
  171. package/dist/services/collection-bootstrap.d.ts +46 -0
  172. package/dist/services/collection-bootstrap.d.ts.map +1 -0
  173. package/dist/services/collection-bootstrap.js +108 -0
  174. package/dist/services/collection-bootstrap.js.map +1 -0
  175. package/dist/services/collection-bootstrap.test.node.d.ts +9 -0
  176. package/dist/services/collection-bootstrap.test.node.d.ts.map +1 -0
  177. package/dist/services/collection-bootstrap.test.node.js +208 -0
  178. package/dist/services/collection-bootstrap.test.node.js.map +1 -0
  179. package/dist/services/document-lifecycle.d.ts +245 -0
  180. package/dist/services/document-lifecycle.d.ts.map +1 -0
  181. package/dist/services/document-lifecycle.js +481 -0
  182. package/dist/services/document-lifecycle.js.map +1 -0
  183. package/dist/services/document-lifecycle.test.node.d.ts +9 -0
  184. package/dist/services/document-lifecycle.test.node.d.ts.map +1 -0
  185. package/dist/services/document-lifecycle.test.node.js +781 -0
  186. package/dist/services/document-lifecycle.test.node.js.map +1 -0
  187. package/dist/services/document-read.d.ts +26 -0
  188. package/dist/services/document-read.d.ts.map +1 -0
  189. package/dist/services/document-read.js +60 -0
  190. package/dist/services/document-read.js.map +1 -0
  191. package/dist/services/field-upload.d.ts +100 -0
  192. package/dist/services/field-upload.d.ts.map +1 -0
  193. package/dist/services/field-upload.js +328 -0
  194. package/dist/services/field-upload.js.map +1 -0
  195. package/dist/services/field-upload.test.node.d.ts +9 -0
  196. package/dist/services/field-upload.test.node.d.ts.map +1 -0
  197. package/dist/services/field-upload.test.node.js +337 -0
  198. package/dist/services/field-upload.test.node.js.map +1 -0
  199. package/dist/services/index.d.ts +10 -0
  200. package/dist/services/index.d.ts.map +1 -0
  201. package/dist/services/index.js +11 -0
  202. package/dist/services/index.js.map +1 -0
  203. package/dist/services/populate.d.ts +299 -0
  204. package/dist/services/populate.d.ts.map +1 -0
  205. package/dist/services/populate.js +484 -0
  206. package/dist/services/populate.js.map +1 -0
  207. package/dist/services/populate.test.node.d.ts +9 -0
  208. package/dist/services/populate.test.node.d.ts.map +1 -0
  209. package/dist/services/populate.test.node.js +910 -0
  210. package/dist/services/populate.test.node.js.map +1 -0
  211. package/dist/services/relation-projection.d.ts +52 -0
  212. package/dist/services/relation-projection.d.ts.map +1 -0
  213. package/dist/services/relation-projection.js +81 -0
  214. package/dist/services/relation-projection.js.map +1 -0
  215. package/dist/services/richtext-populate.d.ts +87 -0
  216. package/dist/services/richtext-populate.d.ts.map +1 -0
  217. package/dist/services/richtext-populate.js +189 -0
  218. package/dist/services/richtext-populate.js.map +1 -0
  219. package/dist/services/richtext-populate.test.node.d.ts +9 -0
  220. package/dist/services/richtext-populate.test.node.d.ts.map +1 -0
  221. package/dist/services/richtext-populate.test.node.js +197 -0
  222. package/dist/services/richtext-populate.test.node.js.map +1 -0
  223. package/dist/storage/collection-fingerprint.d.ts +21 -0
  224. package/dist/storage/collection-fingerprint.d.ts.map +1 -0
  225. package/dist/storage/collection-fingerprint.js +172 -0
  226. package/dist/storage/collection-fingerprint.js.map +1 -0
  227. package/dist/storage/collection-fingerprint.test.node.d.ts +9 -0
  228. package/dist/storage/collection-fingerprint.test.node.d.ts.map +1 -0
  229. package/dist/storage/collection-fingerprint.test.node.js +256 -0
  230. package/dist/storage/collection-fingerprint.test.node.js.map +1 -0
  231. package/dist/storage/field-store-map.d.ts +59 -0
  232. package/dist/storage/field-store-map.d.ts.map +1 -0
  233. package/dist/storage/field-store-map.js +75 -0
  234. package/dist/storage/field-store-map.js.map +1 -0
  235. package/dist/storage/field-store-map.test.node.d.ts +9 -0
  236. package/dist/storage/field-store-map.test.node.d.ts.map +1 -0
  237. package/dist/storage/field-store-map.test.node.js +117 -0
  238. package/dist/storage/field-store-map.test.node.js.map +1 -0
  239. package/dist/storage/index.d.ts +10 -0
  240. package/dist/storage/index.d.ts.map +1 -0
  241. package/dist/storage/index.js +10 -0
  242. package/dist/storage/index.js.map +1 -0
  243. package/dist/utils/normalise-dates.d.ts +15 -0
  244. package/dist/utils/normalise-dates.d.ts.map +1 -0
  245. package/dist/utils/normalise-dates.js +22 -0
  246. package/dist/utils/normalise-dates.js.map +1 -0
  247. package/dist/utils/slugify.d.ts +56 -0
  248. package/dist/utils/slugify.d.ts.map +1 -0
  249. package/dist/utils/slugify.js +91 -0
  250. package/dist/utils/slugify.js.map +1 -0
  251. package/dist/utils/slugify.test.node.d.ts +9 -0
  252. package/dist/utils/slugify.test.node.d.ts.map +1 -0
  253. package/dist/utils/slugify.test.node.js +86 -0
  254. package/dist/utils/slugify.test.node.js.map +1 -0
  255. package/dist/utils/storage-utils.d.ts +36 -0
  256. package/dist/utils/storage-utils.d.ts.map +1 -0
  257. package/dist/utils/storage-utils.js +38 -0
  258. package/dist/utils/storage-utils.js.map +1 -0
  259. package/dist/utils/utils.general.d.ts +64 -0
  260. package/dist/utils/utils.general.d.ts.map +1 -0
  261. package/dist/utils/utils.general.js +219 -0
  262. package/dist/utils/utils.general.js.map +1 -0
  263. package/dist/validation/index.d.ts +9 -0
  264. package/dist/validation/index.d.ts.map +1 -0
  265. package/dist/validation/index.js +9 -0
  266. package/dist/validation/index.js.map +1 -0
  267. package/dist/validation/shared.d.ts +36 -0
  268. package/dist/validation/shared.d.ts.map +1 -0
  269. package/dist/validation/shared.js +42 -0
  270. package/dist/validation/shared.js.map +1 -0
  271. package/dist/workflow/index.d.ts +2 -0
  272. package/dist/workflow/index.d.ts.map +1 -0
  273. package/dist/workflow/index.js +3 -0
  274. package/dist/workflow/index.js.map +1 -0
  275. package/dist/workflow/workflow.d.ts +40 -0
  276. package/dist/workflow/workflow.d.ts.map +1 -0
  277. package/dist/workflow/workflow.js +96 -0
  278. package/dist/workflow/workflow.js.map +1 -0
  279. package/dist/workflow/workflow.test.node.d.ts +2 -0
  280. package/dist/workflow/workflow.test.node.d.ts.map +1 -0
  281. package/dist/workflow/workflow.test.node.js +198 -0
  282. package/dist/workflow/workflow.test.node.js.map +1 -0
  283. 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