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.migrate(); // emits before and after events, inside the lock
206
- await self.apos.global.insertIfMissing();
207
- await self.apos.page.implementParkAllInDefaultLocale();
208
- await self.apos.doc.replicate(); // emits beforeReplicate and afterReplicate events
209
- // Replicate will have created the parked pages across locales if needed, but we may
210
- // still need to reset parked properties
211
- await self.apos.page.implementParkAllInOtherLocales();
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
- for (const parked of self.apos.page.parked) {
1008
+ self.apos.page.parked.forEach(pushParkedPageAndParkedChildren);
1009
+ function pushParkedPageAndParkedChildren(page) {
1009
1010
  criteria.push({
1010
- parkedId: parked.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
- try {
237
- if (self.apos.isNew) {
238
- // Since the site is brand new (zero documents), we may assume
239
- // it requires no migrations. Mark them all as "done" but note
240
- // that they were skipped, just in case we decide that's an issue later
241
- const at = new Date();
242
- // Just in case the db has no documents but did
243
- // start to run migrations on a previous attempt,
244
- // which causes an occasional unique key error if not
245
- // corrected for here
246
- await self.db.removeMany({});
247
- await self.db.insertMany(self.migrations.map(migration => ({
248
- _id: migration.name,
249
- at,
250
- skipped: true
251
- })));
252
- } else {
253
- for (const migration of self.migrations) {
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "apostrophe",
3
- "version": "3.14.0",
3
+ "version": "3.14.1",
4
4
  "description": "The Apostrophe Content Management System.",
5
5
  "main": "index.js",
6
6
  "scripts": {
@@ -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
+ }