apostrophe 3.14.0 → 3.14.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 3.14.1 (2022-02-25)
|
|
4
|
+
|
|
5
|
+
* Hotfix: fixed a bug in which replication across locales did not work properly for parked pages configured via the `_children` feature. A one-time migration is included to reconnect improperly replicated versions of the same parked pages. This runs automatically, no manual action is required. Thanks to [justyna1](https://github.com/justyna13) for identifying the issue.
|
|
6
|
+
|
|
3
7
|
## 3.14.0 (2022-02-22)
|
|
4
8
|
|
|
5
9
|
### Adds
|
package/index.js
CHANGED
|
@@ -202,13 +202,15 @@ module.exports = async function(options) {
|
|
|
202
202
|
await self.emit('modulesRegistered'); // formerly modulesReady
|
|
203
203
|
self.apos.schema.validateAllSchemas();
|
|
204
204
|
self.apos.schema.registerAllSchemas();
|
|
205
|
-
await self.apos.migration
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
205
|
+
await self.apos.lock.withLock('@apostrophecms/migration:migrate', async () => {
|
|
206
|
+
await self.apos.migration.migrate(); // emits before and after events, inside the lock
|
|
207
|
+
await self.apos.global.insertIfMissing();
|
|
208
|
+
await self.apos.page.implementParkAllInDefaultLocale();
|
|
209
|
+
await self.apos.doc.replicate(); // emits beforeReplicate and afterReplicate events
|
|
210
|
+
// Replicate will have created the parked pages across locales if needed, but we may
|
|
211
|
+
// still need to reset parked properties
|
|
212
|
+
await self.apos.page.implementParkAllInOtherLocales();
|
|
213
|
+
});
|
|
212
214
|
await self.emit('ready'); // formerly afterInit
|
|
213
215
|
if (self.taskRan) {
|
|
214
216
|
process.exit(0);
|
|
@@ -1005,10 +1005,12 @@ module.exports = {
|
|
|
1005
1005
|
async replicate() {
|
|
1006
1006
|
const localeNames = Object.keys(self.apos.i18n.locales);
|
|
1007
1007
|
const criteria = [];
|
|
1008
|
-
|
|
1008
|
+
self.apos.page.parked.forEach(pushParkedPageAndParkedChildren);
|
|
1009
|
+
function pushParkedPageAndParkedChildren(page) {
|
|
1009
1010
|
criteria.push({
|
|
1010
|
-
parkedId:
|
|
1011
|
+
parkedId: page.parkedId
|
|
1011
1012
|
});
|
|
1013
|
+
(page._children || []).forEach(pushParkedPageAndParkedChildren);
|
|
1012
1014
|
}
|
|
1013
1015
|
const pieceModules = Object.values(self.apos.modules).filter(module => self.apos.instanceOf(module, '@apostrophecms/piece-type') && module.options.replicate);
|
|
1014
1016
|
for (const module of pieceModules) {
|
|
@@ -231,41 +231,36 @@ module.exports = {
|
|
|
231
231
|
// Perform the actual migrations. Implementation of
|
|
232
232
|
// the @apostrophecms/migration:migrate task
|
|
233
233
|
async migrate(options) {
|
|
234
|
-
await self.apos.lock.lock(self.__meta.name);
|
|
235
234
|
await self.emit('before');
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
await self.runOne(migration);
|
|
255
|
-
}
|
|
235
|
+
if (self.apos.isNew) {
|
|
236
|
+
// Since the site is brand new (zero documents), we may assume
|
|
237
|
+
// it requires no migrations. Mark them all as "done" but note
|
|
238
|
+
// that they were skipped, just in case we decide that's an issue later
|
|
239
|
+
const at = new Date();
|
|
240
|
+
// Just in case the db has no documents but did
|
|
241
|
+
// start to run migrations on a previous attempt,
|
|
242
|
+
// which causes an occasional unique key error if not
|
|
243
|
+
// corrected for here
|
|
244
|
+
await self.db.removeMany({});
|
|
245
|
+
await self.db.insertMany(self.migrations.map(migration => ({
|
|
246
|
+
_id: migration.name,
|
|
247
|
+
at,
|
|
248
|
+
skipped: true
|
|
249
|
+
})));
|
|
250
|
+
} else {
|
|
251
|
+
for (const migration of self.migrations) {
|
|
252
|
+
await self.runOne(migration);
|
|
256
253
|
}
|
|
257
|
-
// In production, this event is emitted only at the end of the migrate command line task.
|
|
258
|
-
// In dev it is emitted at every startup after the automatic migration.
|
|
259
|
-
//
|
|
260
|
-
// Intentionally emitted regardless of whether the site is new or not.
|
|
261
|
-
//
|
|
262
|
-
// This is the right time to park pages, for instance, because the
|
|
263
|
-
// database is guaranteed to be in a stable state, whether because the
|
|
264
|
-
// site is new or because migrations ran successfully.
|
|
265
|
-
await self.emit('after');
|
|
266
|
-
} finally {
|
|
267
|
-
await self.apos.lock.unlock(self.__meta.name);
|
|
268
254
|
}
|
|
255
|
+
// In production, this event is emitted only at the end of the migrate command line task.
|
|
256
|
+
// In dev it is emitted at every startup after the automatic migration.
|
|
257
|
+
//
|
|
258
|
+
// Intentionally emitted regardless of whether the site is new or not.
|
|
259
|
+
//
|
|
260
|
+
// This is the right time to park pages, for instance, because the
|
|
261
|
+
// database is guaranteed to be in a stable state, whether because the
|
|
262
|
+
// site is new or because migrations ran successfully.
|
|
263
|
+
await self.emit('after');
|
|
269
264
|
},
|
|
270
265
|
async runOne(migration) {
|
|
271
266
|
const info = await self.db.findOne({ _id: migration.name });
|
|
@@ -59,6 +59,7 @@ module.exports = {
|
|
|
59
59
|
self.addEditorModal();
|
|
60
60
|
self.enableBrowserData();
|
|
61
61
|
self.addLegacyMigrations();
|
|
62
|
+
self.addMisreplicatedParkedPagesMigration();
|
|
62
63
|
await self.createIndexes();
|
|
63
64
|
},
|
|
64
65
|
restApiRoutes(self) {
|
|
@@ -2187,7 +2188,44 @@ database.`);
|
|
|
2187
2188
|
}
|
|
2188
2189
|
}
|
|
2189
2190
|
},
|
|
2190
|
-
...require('./lib/legacy-migrations')(self)
|
|
2191
|
+
...require('./lib/legacy-migrations')(self),
|
|
2192
|
+
addMisreplicatedParkedPagesMigration() {
|
|
2193
|
+
self.apos.migration.add('misreplicated-parked-pages', async () => {
|
|
2194
|
+
const parkedPages = await self.apos.doc.db.find({
|
|
2195
|
+
parkedId: {
|
|
2196
|
+
$exists: 1
|
|
2197
|
+
}
|
|
2198
|
+
}).toArray();
|
|
2199
|
+
const locales = [ self.apos.i18n.defaultLocale, ...Object.keys(self.apos.i18n.locales) ];
|
|
2200
|
+
const parkedIds = [ ...new Set(parkedPages.map(page => page.parkedId)) ];
|
|
2201
|
+
for (const parkedId of parkedIds) {
|
|
2202
|
+
let aposDocId;
|
|
2203
|
+
for (const locale of locales) {
|
|
2204
|
+
for (const mode of [ 'draft', 'published' ]) {
|
|
2205
|
+
const page = parkedPages.find(page => (page.parkedId === parkedId) && (page.aposLocale === `${locale}:${mode}`));
|
|
2206
|
+
if (!page) {
|
|
2207
|
+
continue;
|
|
2208
|
+
}
|
|
2209
|
+
if (!aposDocId) {
|
|
2210
|
+
aposDocId = page.aposDocId;
|
|
2211
|
+
} else {
|
|
2212
|
+
if (page.aposDocId !== aposDocId) {
|
|
2213
|
+
await self.apos.doc.db.removeOne({
|
|
2214
|
+
_id: page._id
|
|
2215
|
+
});
|
|
2216
|
+
await self.apos.doc.db.insertOne({
|
|
2217
|
+
...page,
|
|
2218
|
+
_id: `${aposDocId}:${locale}:${mode}`,
|
|
2219
|
+
aposDocId,
|
|
2220
|
+
path: page.path.replace(page.aposDocId, aposDocId)
|
|
2221
|
+
});
|
|
2222
|
+
}
|
|
2223
|
+
}
|
|
2224
|
+
}
|
|
2225
|
+
}
|
|
2226
|
+
}
|
|
2227
|
+
});
|
|
2228
|
+
}
|
|
2191
2229
|
};
|
|
2192
2230
|
},
|
|
2193
2231
|
helpers(self) {
|
package/package.json
CHANGED
package/test/parked-pages.js
CHANGED
|
@@ -1,7 +1,36 @@
|
|
|
1
1
|
const t = require('../test-lib/test.js');
|
|
2
2
|
const assert = require('assert');
|
|
3
3
|
|
|
4
|
-
let apos, apos2;
|
|
4
|
+
let apos, apos2, apos3, apos4, apos5, apos6;
|
|
5
|
+
|
|
6
|
+
const park2 = [
|
|
7
|
+
{
|
|
8
|
+
slug: '/',
|
|
9
|
+
parkedId: 'home',
|
|
10
|
+
_defaults: {
|
|
11
|
+
title: 'Home',
|
|
12
|
+
type: 'default-page'
|
|
13
|
+
},
|
|
14
|
+
_children: [
|
|
15
|
+
{
|
|
16
|
+
slug: '/default1',
|
|
17
|
+
parkedId: 'default1',
|
|
18
|
+
_defaults: {
|
|
19
|
+
type: 'default-page',
|
|
20
|
+
title: 'Default 1'
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
slug: '/default2',
|
|
25
|
+
parkedId: 'default2',
|
|
26
|
+
_defaults: {
|
|
27
|
+
type: 'default-page',
|
|
28
|
+
title: 'Default 2'
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
]
|
|
32
|
+
}
|
|
33
|
+
];
|
|
5
34
|
|
|
6
35
|
describe('Parked Pages', function() {
|
|
7
36
|
|
|
@@ -10,12 +39,21 @@ describe('Parked Pages', function() {
|
|
|
10
39
|
after(async function() {
|
|
11
40
|
await t.destroy(apos);
|
|
12
41
|
await t.destroy(apos2);
|
|
42
|
+
await t.destroy(apos3);
|
|
43
|
+
await t.destroy(apos4);
|
|
44
|
+
await t.destroy(apos5);
|
|
45
|
+
await t.destroy(apos6);
|
|
13
46
|
});
|
|
14
47
|
|
|
15
|
-
it('standard parked pages should be as expected', async function() {
|
|
48
|
+
it('standard and custom parked pages should be as expected', async function() {
|
|
49
|
+
this.timeout(20000);
|
|
16
50
|
apos = await t.create({
|
|
17
51
|
root: module,
|
|
18
|
-
modules: {
|
|
52
|
+
modules: {
|
|
53
|
+
'default-page': {
|
|
54
|
+
extend: '@apostrophecms/page-type'
|
|
55
|
+
}
|
|
56
|
+
}
|
|
19
57
|
});
|
|
20
58
|
const req = apos.task.getReq();
|
|
21
59
|
const home = await apos.page.find(req, { slug: '/' }).toObject();
|
|
@@ -29,21 +67,13 @@ describe('Parked Pages', function() {
|
|
|
29
67
|
});
|
|
30
68
|
|
|
31
69
|
it('overridden home page should work without disturbing archive', async function() {
|
|
70
|
+
this.timeout(20000);
|
|
32
71
|
apos2 = await t.create({
|
|
33
72
|
root: module,
|
|
34
73
|
modules: {
|
|
35
74
|
'@apostrophecms/page': {
|
|
36
75
|
options: {
|
|
37
|
-
park:
|
|
38
|
-
{
|
|
39
|
-
slug: '/',
|
|
40
|
-
parkedId: 'home',
|
|
41
|
-
_defaults: {
|
|
42
|
-
title: 'Home',
|
|
43
|
-
type: 'default-page'
|
|
44
|
-
}
|
|
45
|
-
}
|
|
46
|
-
]
|
|
76
|
+
park: park2
|
|
47
77
|
}
|
|
48
78
|
},
|
|
49
79
|
'default-page': {}
|
|
@@ -58,5 +88,246 @@ describe('Parked Pages', function() {
|
|
|
58
88
|
assert(archive);
|
|
59
89
|
assert(archive.parkedId === 'archive');
|
|
60
90
|
assert(archive.type === '@apostrophecms/archive-page');
|
|
91
|
+
await apos2.db.collection('verify').insertOne({
|
|
92
|
+
checkSameDb: true
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('all pages should have consistent aposDocId across draft and published', async function() {
|
|
97
|
+
this.timeout(20000);
|
|
98
|
+
await validate(apos2, [ '/', '/archive', '/default1', '/default2' ]);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('third apos object should park a third child correctly when spinning up later on existing db', async function() {
|
|
102
|
+
this.timeout(20000);
|
|
103
|
+
apos3 = await t.create({
|
|
104
|
+
root: module,
|
|
105
|
+
modules: {
|
|
106
|
+
'@apostrophecms/page': {
|
|
107
|
+
options: {
|
|
108
|
+
park: [
|
|
109
|
+
...park2,
|
|
110
|
+
{
|
|
111
|
+
slug: '/default3',
|
|
112
|
+
parkedId: 'default3',
|
|
113
|
+
_defaults: {
|
|
114
|
+
type: 'default-page',
|
|
115
|
+
title: 'Default 3'
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
]
|
|
119
|
+
}
|
|
120
|
+
},
|
|
121
|
+
'default-page': {}
|
|
122
|
+
},
|
|
123
|
+
shortName: apos2.options.shortName
|
|
124
|
+
});
|
|
125
|
+
// prove apos2 and apos3 are talking to the same db and it hasn't been erased
|
|
126
|
+
assert(await apos3.db.collection('verify').findOne({
|
|
127
|
+
checkSameDb: true
|
|
128
|
+
}));
|
|
61
129
|
});
|
|
130
|
+
|
|
131
|
+
it('all pages should have consistent aposDocId across draft and published', async function() {
|
|
132
|
+
await validate(apos3, [ '/', '/archive', '/default1', '/default2', '/default3' ]);
|
|
133
|
+
// Should be same db, make sure of that
|
|
134
|
+
await validate(apos2, [ '/', '/archive', '/default1', '/default2', '/default3' ]);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('nested parked children work', async function() {
|
|
138
|
+
this.timeout(20000);
|
|
139
|
+
apos4 = await t.create({
|
|
140
|
+
root: module,
|
|
141
|
+
modules: {
|
|
142
|
+
'@apostrophecms/page': {
|
|
143
|
+
options: {
|
|
144
|
+
park: [
|
|
145
|
+
...park2,
|
|
146
|
+
{
|
|
147
|
+
slug: '/default3',
|
|
148
|
+
parkedId: 'default3',
|
|
149
|
+
_defaults: {
|
|
150
|
+
type: 'default-page',
|
|
151
|
+
title: 'Default 3'
|
|
152
|
+
},
|
|
153
|
+
_children: [
|
|
154
|
+
{
|
|
155
|
+
slug: '/default3/child1',
|
|
156
|
+
parkedId: 'default3child1',
|
|
157
|
+
_defaults: {
|
|
158
|
+
type: 'default-page',
|
|
159
|
+
title: 'Default 3 Child 1'
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
]
|
|
163
|
+
}
|
|
164
|
+
]
|
|
165
|
+
}
|
|
166
|
+
},
|
|
167
|
+
'default-page': {}
|
|
168
|
+
},
|
|
169
|
+
shortName: apos2.options.shortName
|
|
170
|
+
});
|
|
171
|
+
await validate(apos4, [ '/', '/archive', '/default1', '/default2', '/default3', '/default3/child1' ]);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it('nested parked children work across locales if locales are added later', async function() {
|
|
175
|
+
this.timeout(20000);
|
|
176
|
+
apos5 = await t.create({
|
|
177
|
+
root: module,
|
|
178
|
+
modules: {
|
|
179
|
+
'@apostrophecms/i18n': {
|
|
180
|
+
options: {
|
|
181
|
+
locales: {
|
|
182
|
+
en: {
|
|
183
|
+
label: 'English',
|
|
184
|
+
hostname: 'en'
|
|
185
|
+
},
|
|
186
|
+
fr: {
|
|
187
|
+
label: 'French',
|
|
188
|
+
hostname: 'fr'
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
},
|
|
193
|
+
'@apostrophecms/page': {
|
|
194
|
+
options: {
|
|
195
|
+
park: [
|
|
196
|
+
...park2,
|
|
197
|
+
{
|
|
198
|
+
slug: '/default3',
|
|
199
|
+
parkedId: 'default3',
|
|
200
|
+
_defaults: {
|
|
201
|
+
type: 'default-page',
|
|
202
|
+
title: 'Default 3'
|
|
203
|
+
},
|
|
204
|
+
_children: [
|
|
205
|
+
{
|
|
206
|
+
slug: '/default3/child1',
|
|
207
|
+
parkedId: 'default3child1',
|
|
208
|
+
_defaults: {
|
|
209
|
+
type: 'default-page',
|
|
210
|
+
title: 'Default 3 Child 1'
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
]
|
|
214
|
+
}
|
|
215
|
+
]
|
|
216
|
+
}
|
|
217
|
+
},
|
|
218
|
+
'default-page': {}
|
|
219
|
+
},
|
|
220
|
+
shortName: apos4.options.shortName
|
|
221
|
+
});
|
|
222
|
+
await validate(apos5, [ '/', '/archive', '/default1', '/default2', '/default3', '/default3/child1' ]);
|
|
223
|
+
});
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it('nested parked children work across locales if locales are present from the start', async function() {
|
|
227
|
+
this.timeout(20000);
|
|
228
|
+
apos6 = await t.create({
|
|
229
|
+
root: module,
|
|
230
|
+
modules: {
|
|
231
|
+
'@apostrophecms/i18n': {
|
|
232
|
+
options: {
|
|
233
|
+
locales: {
|
|
234
|
+
en: {
|
|
235
|
+
label: 'English',
|
|
236
|
+
hostname: 'en'
|
|
237
|
+
},
|
|
238
|
+
fr: {
|
|
239
|
+
label: 'French',
|
|
240
|
+
hostname: 'fr'
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
},
|
|
245
|
+
'@apostrophecms/page': {
|
|
246
|
+
options: {
|
|
247
|
+
park: [
|
|
248
|
+
...park2,
|
|
249
|
+
{
|
|
250
|
+
slug: '/default3',
|
|
251
|
+
parkedId: 'default3',
|
|
252
|
+
_defaults: {
|
|
253
|
+
type: 'default-page',
|
|
254
|
+
title: 'Default 3'
|
|
255
|
+
},
|
|
256
|
+
_children: [
|
|
257
|
+
{
|
|
258
|
+
slug: '/default3/child1',
|
|
259
|
+
parkedId: 'default3child1',
|
|
260
|
+
_defaults: {
|
|
261
|
+
type: 'default-page',
|
|
262
|
+
title: 'Default 3 Child 1'
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
]
|
|
266
|
+
}
|
|
267
|
+
]
|
|
268
|
+
}
|
|
269
|
+
},
|
|
270
|
+
'default-page': {}
|
|
271
|
+
}
|
|
272
|
+
});
|
|
273
|
+
await validate(apos6, [ '/', '/archive', '/default1', '/default2', '/default3', '/default3/child1' ]);
|
|
62
274
|
});
|
|
275
|
+
|
|
276
|
+
async function validate(apos, expected) {
|
|
277
|
+
const locales = Object.keys(apos.i18n.locales);
|
|
278
|
+
const slugs = await apos.doc.db.distinct('slug', {
|
|
279
|
+
slug: /^\//
|
|
280
|
+
});
|
|
281
|
+
slugs.sort();
|
|
282
|
+
assert.deepStrictEqual(slugs, expected);
|
|
283
|
+
const pages = await apos.doc.db.find({
|
|
284
|
+
slug: /^\//
|
|
285
|
+
}).toArray();
|
|
286
|
+
assert(pages.length === slugs.length * 2 * locales.length);
|
|
287
|
+
for (const slug of slugs) {
|
|
288
|
+
const matches = pages.filter(page => page.slug === slug);
|
|
289
|
+
matches.sort((p1, p2) => {
|
|
290
|
+
if (p1.aposLocale < p2.aposLocale) {
|
|
291
|
+
return -1;
|
|
292
|
+
} else if (p1.aposLocale > p2.aposLocale) {
|
|
293
|
+
return 1;
|
|
294
|
+
} else {
|
|
295
|
+
return 0;
|
|
296
|
+
}
|
|
297
|
+
});
|
|
298
|
+
assert.strictEqual(matches.length, 2 * locales.length);
|
|
299
|
+
let i = 0;
|
|
300
|
+
let aposDocId;
|
|
301
|
+
for (const locale of locales) {
|
|
302
|
+
const draft = i;
|
|
303
|
+
const published = i + 1;
|
|
304
|
+
assert.strictEqual(matches[draft].aposLocale, `${locale}:draft`);
|
|
305
|
+
assert.strictEqual(matches[published].aposLocale, `${locale}:published`);
|
|
306
|
+
assert(matches[draft].aposDocId);
|
|
307
|
+
assert.strictEqual(matches[draft].aposDocId, matches[published].aposDocId);
|
|
308
|
+
if (!aposDocId) {
|
|
309
|
+
aposDocId = matches[draft].aposDocId;
|
|
310
|
+
} else {
|
|
311
|
+
assert.strictEqual(matches[draft].aposDocId, aposDocId);
|
|
312
|
+
}
|
|
313
|
+
assert.strictEqual(matches[draft]._id, `${aposDocId}:${locale}:draft`);
|
|
314
|
+
assert.strictEqual(matches[published]._id, `${aposDocId}:${locale}:published`);
|
|
315
|
+
i += 2;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
const home = await apos.page.find(apos.task.getReq(), {
|
|
319
|
+
slug: '/'
|
|
320
|
+
}).children({
|
|
321
|
+
depth: 2
|
|
322
|
+
}).toObject();
|
|
323
|
+
const children = expected.filter(slug => slug.startsWith('/default') && !slug.match(/\/.*\//));
|
|
324
|
+
assert.deepStrictEqual(home._children.map(child => child.slug), children);
|
|
325
|
+
const grandkids = expected.filter(slug => slug.match(/\/.*\//));
|
|
326
|
+
for (const grandkid of grandkids) {
|
|
327
|
+
const parentSlug = grandkid.replace(/\/[^/]+$/, '');
|
|
328
|
+
const parent = home._children.find(child => child.slug === parentSlug);
|
|
329
|
+
assert(parent);
|
|
330
|
+
assert(parent._children);
|
|
331
|
+
assert(parent._children.find(child => child.slug === grandkid));
|
|
332
|
+
}
|
|
333
|
+
}
|