-
Notifications
You must be signed in to change notification settings - Fork 653
Expand file tree
/
Copy pathindex.js
More file actions
1969 lines (1889 loc) · 72.7 KB
/
Copy pathindex.js
File metadata and controls
1969 lines (1889 loc) · 72.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
const _ = require('lodash');
const { createId } = require('@paralleldrive/cuid2');
const { SemanticAttributes } = require('@opentelemetry/semantic-conventions');
const { klona } = require('klona');
const legacyMigrations = require('./lib/legacy-migrations.js');
const migrations = require('./lib/migrations.js');
// This module is responsible for managing all of the documents (apostrophe
// "docs") in the `aposDocs` mongodb collection.
//
// The `getManager` method should be used to obtain a reference to the module
// that manages a particular doc type, so that you can benefit from behavior
// specific to that module. One method of this module that you may sometimes
// use directly is `apos.doc.find()`, which returns a
// query[query](server-@apostrophecms/query.html) for fetching documents of all
// types. This is useful when implementing something like the
// [@apostrophecms/search](../@apostrophecms/search/index.html) module.
//
// ## Options
//
// ** `advisoryLockTimeout`: Apostrophe locks documents while they are
// being edited so that another user, or another tab for the same user,
// does not inadvertently interfere. These locks are refreshed frequently
// by the browser while they are held. By default, if the browser
// is not heard from for 15 seconds, the lock expires. Note that
// the browser refreshes the lock every 5 seconds. This timeout should
// be quite short as there is no longer any reliable way to force a browser
// to unlock the document when leaving the page.
module.exports = {
options: {
alias: 'doc',
advisoryLockTimeout: 15
},
async init(self) {
self.managers = {};
self.contextOperations = [];
self.enableBrowserData();
await self.enableCollection();
self.apos.isNew = await self.detectNew();
await self.createIndexes();
self.addLegacyMigrations();
self.addMigrations();
},
restApiRoutes(self) {
return {
// GET /api/v1/@apostrophecms/doc/_id supports only the universal query
// features, but works for any document type. Simplifies browser-side
// logic for redirects to foreign documents. The frontend only has to
// know the doc _id.
//
// Since this API is solely for editing purposes you will receive
// a 404 if you request a document you cannot edit.
async getOne(req, _id) {
_id = self.apos.i18n.inferIdLocaleAndMode(req, _id);
const doc = await self.find(req, { _id }).permission('edit').toObject();
if (!doc) {
throw self.apos.error('notfound');
}
return doc;
}
};
},
apiRoutes(self) {
return {
post: {
async slugTaken(req) {
if (!req.user) {
throw self.apos.error('notfound');
}
const slug = self.apos.launder.string(req.body.slug);
const _id = self.apos.launder.id(req.body._id);
const criteria = { slug };
if (_id) {
criteria._id = { $ne: _id };
}
const doc = await self
.find(req, criteria)
.permission(false)
.archived(null)
.project({ slug: 1 })
.toObject();
if (doc) {
throw self.apos.error('conflict');
} else {
return {
available: true
};
}
},
// Fast bulk query for doc `ids` that the user is permitted to edit.
//
// IDs should be sent as an array in the `ids` property of the POST
// request.
//
// The response object contains an `editable` array made up of
// the ids of those documents in the original set that the user
// is actually permitted to edit. Those the user cannot edit
// are not included. The original order is preserved.
//
// This route is a POST route because large numbers of ids
// might not be accepted as a query string.
async editable(req) {
if (!req.user) {
throw self.apos.error('notfound');
}
const ids = self.apos.launder.ids(req.body.ids);
if (!ids.length) {
return {
editable: []
};
}
const found = await self.apos.doc.find(req, {
_id: {
$in: ids
}
}).project({ _id: 1 }).permission('edit').toArray();
return {
editable: self.apos.util.orderById(ids, found).map(doc => doc._id)
};
}
}
};
},
handlers(self) {
return {
'@apostrophecms/doc-type:beforeInsert': {
setLocaleAndMode(req, doc, options) {
const manager = self.getManager(doc.type);
if (!manager.isLocalized()) {
return;
}
if (doc._id) {
const [ _id, locale, mode ] = doc._id.split(':');
doc.aposLocale = `${locale}:${mode}`;
doc.aposMode = mode;
return;
}
const [ locale, mode ] = doc.aposLocale
? doc.aposLocale.split(':')
: [ req.locale, req.mode ];
doc.aposLocale = `${locale}:${mode}`;
doc.aposMode = mode;
},
testPermissionsAndAddIdAndCreatedAt(req, doc, options) {
self.testInsertPermissions(req, doc, options);
const manager = self.getManager(doc.type);
if (doc._id && manager.isLocalized()) {
if (!doc.aposDocId) {
const components = doc._id.split(':');
if (components.length < 3) {
throw new Error('If you supply your own _id it must end with :locale:mode, like :en:published');
}
doc.aposDocId = components[0];
doc.aposLocale = `${components[1]}:${components[2]}`;
}
}
if (!doc.aposDocId) {
doc.aposDocId = self.apos.util.generateId();
}
if (!doc._id) {
if (!doc.aposLocale) {
if (manager.isLocalized()) {
doc.aposLocale = `${req.locale}:${req.mode}`;
}
}
if (doc.aposLocale) {
doc._id = `${doc.aposDocId}:${doc.aposLocale}`;
} else {
doc._id = doc.aposDocId;
}
}
doc.metaType = 'doc';
doc.createdAt = new Date();
if (doc.archived == null) {
// Not always in the schema, so ensure it's true or false
// to simplify queries and indexing
doc.archived = false;
}
},
// Makes using our model APIs directly less tedious
ensureAreaAndWidgetIds(req, doc, options) {
self.apos.area.walk(doc, area => {
if (!area._id) {
area._id = self.apos.util.generateId();
}
for (const item of (area.items || [])) {
if (!item._id) {
item._id = self.apos.util.generateId();
}
}
});
}
},
'@apostrophecms/doc-type:beforeDelete': {
testPermissions(req, doc, options) {
if (!(options.permissions === false)) {
if (!self.apos.permission.can(req, 'delete', doc)) {
throw self.apos.error('forbidden');
}
}
}
},
'@apostrophecms/doc-type:beforePublish': {
testPermissions(req, info) {
if (info.options.permissions !== false) {
if (!self.apos.permission.can(req, info.options.autopublishing ? 'edit' : 'publish', info.draft)) {
throw self.apos.error('forbidden');
}
}
}
},
'@apostrophecms/doc-type:beforeSave': {
ensureSlugSortifyAndUpdatedAt(req, doc, options) {
const manager = self.getManager(doc.type);
manager.ensureSlug(doc);
_.each(manager.schema, function (field) {
if (field.sortify) {
doc[field.name + 'Sortified'] = self.apos.util.sortify(doc[field.name] ? doc[field.name] : '');
}
});
if (options.setUpdatedAtAndBy !== false) {
const date = new Date();
doc.updatedAt = date;
doc.cacheInvalidatedAt = date;
doc.updatedBy = req.user
? {
_id: req.user._id,
title: req.user.title || null,
username: req.user.username
}
: {
username: 'ApostropheCMS'
};
}
},
deduplicateWidgetIds(req, doc, options) {
this.deduplicateWidgetIds(doc);
}
},
'@apostrophecms/doc-type:afterInsert': {
async ensureDraftExists(req, doc, options) {
const manager = self.getManager(doc.type);
if (!manager.isLocalized()) {
return;
}
if (self.isDraft(doc)) {
return;
}
const draftLocale = doc.aposLocale.replace(':published', ':draft');
const draftId = `${doc.aposDocId}:${draftLocale}`;
if (await self.db.findOne({
_id: draftId
}, {
projection: {
_id: 1
}
})) {
return;
}
const lastPublishedAt = doc.createdAt || new Date();
const draft = {
...doc,
_id: draftId,
aposLocale: draftLocale,
lastPublishedAt
};
await manager.insertDraftOf(req, doc, draft, options);
// Published doc must know it is published, otherwise various bugs
// ensue
return self.apos.doc.db.updateOne({
_id: doc._id
}, {
$set: {
lastPublishedAt
}
});
}
},
fixUniqueError: {
async fixUniqueSlug(req, doc) {
doc.slug += Math.floor(Math.random() * 10).toString();
}
},
'@apostrophecms/doc-type:beforeUpdate': {
async checkPermissionsBeforeUpdate(req, doc, options) {
if (options.permissions !== false) {
if (!self.apos.permission.can(req, 'edit', doc)) {
throw new Error('forbidden');
}
}
}
},
'@apostrophecms/version:unversionedFields': {
baseUnversionedFields(req, doc, fields) {
fields.push('visibility');
}
},
'@apostrophecms/doc-type:afterDelete': {
// Deleting a draft implies deleting the document completely, since
// a draft must always exist. Deleting a published doc implies deleting
// the "previous" copy, since it only makes sense as a tool to revert
// the published doc's content. Note that deleting a draft recursively
// deletes both the published and previous docs.
async deleteOtherModes(req, doc, options) {
if (doc.aposLocale && doc.aposLocale.endsWith(':draft')) {
await cleanup('published');
await self.emit('afterAllModesDeleted', req, doc, options);
return;
}
if (doc.aposLocale && doc.aposLocale.endsWith(':published')) {
return cleanup('previous');
}
async function cleanup(mode) {
const peer = await self.apos.doc.db.findOne({
_id: doc._id.replace(/:[\w]+$/, `:${mode}`)
});
if (peer) {
const manager = peer.slug.startsWith('/') ? self.apos.page : self.getManager(peer.type);
await manager.delete(req, peer, options);
}
}
}
}
};
},
methods(self) {
return {
// `pairs` is an array of arrays, each containing an old _id
// and a new _id that should replace it.
//
// `aposDocId` is implicitly updated, `path` is updated if a page,
// and all references found in relationships are updated via reverse
// relationship id lookups, after which attachment references are updated.
// This is a slow operation, which is why this method should be called
// only by migrations and tasks that remedy an unexpected situation. _id
// is meant to be an immutable property, this method is a workaround for
// situations like a renamed locale or a replication bug fix.
//
// If `keep` is set to `'old'` the old document's content wins
// in the event of a conflict. If `keep` is set to `'new'` the
// new document's content wins in the event of a conflict.
// If `keep` is not set, a `conflict` error is thrown in the
// event of a conflict.
//
// If `skipReplace` is set to `true`, the method will not attempt to
// remove the old document, but will still update the new document. The
// new _id for each pair will be used for retrieving the "existing"
// document in this case.
async changeDocIds(pairs, { keep, skipReplace = false } = {}) {
let renamed = 0;
let kept = 0;
// Get page paths up front so we can avoid multiple queries when working
// on path changes
const pages = await self.apos.doc.db.find({
path: { $exists: 1 },
slug: /^\//
}).project({
path: 1
}).toArray();
for (const pair of pairs) {
const [ from, to ] = pair;
const oldAposDocId = from.split(':')[0];
const existing = await self.apos.doc.db
.findOne({ _id: skipReplace ? to : from });
if (!existing) {
throw self.apos.error('notfound');
}
const replacement = klona(existing);
if (!skipReplace) {
await self.apos.doc.db.removeOne({ _id: from });
}
replacement._id = to;
const parts = to.split(':');
replacement.aposDocId = parts[0];
// Watch out for nonlocalized types, don't set aposLocale for them
if (parts.length > 1) {
replacement.aposLocale = parts.slice(1).join(':');
}
const isPage = self.apos.page.isPage(existing);
if (isPage) {
replacement.path = existing.path.replace(
existing.aposDocId,
replacement.aposDocId
);
}
try {
if (!skipReplace) {
await self.apos.doc.db.insertOne(replacement);
renamed++;
}
} catch (e) {
// First reinsert old doc to prevent content loss on new doc insert
// failure
await self.apos.doc.db.insertOne(existing);
if (!self.apos.doc.isUniqueError(e)) {
// We cannot fix this error
throw e;
}
const existingReplacement = await self.apos.doc.db
.findOne({ _id: replacement._id });
if (!existingReplacement) {
// We don't know the cause of this error
throw e;
}
if (keep === 'new') {
// New content already exists in new locale, delete old locale
// and keep new
await self.apos.doc.db.removeOne({ _id: existing._id });
kept++;
} else if (keep === 'old') {
// We want to keep the old content, but with the new
// identifiers. Once again we need to remove the old doc first
// to cut down on conflicts
try {
await self.apos.doc.db.deleteOne({ _id: existing._id });
await self.apos.doc.db.deleteOne({ _id: replacement._id });
await self.apos.doc.db.insertOne(replacement);
renamed++;
} catch (e) {
// Reinsert old doc to prevent content loss on new doc insert
// failure
await self.apos.doc.db.insertOne(existing);
throw e;
}
kept++;
} else {
throw self.apos.error('conflict');
}
}
if (isPage && !skipReplace) {
for (const page of pages) {
if (page.path.includes(oldAposDocId)) {
await self.apos.doc.db.updateOne({
_id: page._id
}, {
$set: {
path: page.path.replace(oldAposDocId, replacement.aposDocId)
}
});
}
}
}
if (existing.relatedReverseIds?.length) {
const relatedDocs = await self.apos.doc.db.find({
aposDocId: { $in: existing.relatedReverseIds }
}).toArray();
for (const doc of relatedDocs) {
replaceId(doc, oldAposDocId, replacement.aposDocId);
await self.apos.doc.db.replaceOne({
_id: doc._id
}, doc);
}
}
}
await self.apos.attachment.recomputeAllDocReferences();
return {
renamed,
kept
};
function replaceId(obj, oldId, newId) {
if (obj == null) {
return;
}
if ((typeof obj) !== 'object') {
return;
}
for (const key of Object.keys(obj)) {
if (obj[key] === oldId) {
obj[key] = newId;
} else {
replaceId(obj[key], oldId, newId);
}
}
}
},
async enableCollection() {
self.db = await self.apos.db.collection('aposDocs');
},
// Detect whether the database is brand new (zero documents).
// This can't be done later because after this point init()
// functions are permitted to insert documents
async detectNew() {
const existing = await self.db.countDocuments();
return !existing;
},
async createSlugIndex() {
const params = self.getSlugIndexParams();
return self.db.createIndex(params, { unique: true });
},
getSlugIndexParams() {
return {
slug: 1,
aposLocale: 1
};
},
getPathLevelIndexParams() {
return {
path: 1,
level: 1,
aposLocale: 1
};
},
async createIndexes() {
await self.db.createIndex({
type: 1,
aposLocale: 1
}, {});
await self.createSlugIndex();
await self.db.createIndex({
titleSortified: 1,
aposLocale: 1
}, {});
await self.db.createIndex({
updatedAt: -1,
aposLocale: 1
}, {});
await self.db.createIndex({
relatedReverseIds: 1,
aposLocale: 1
}, {});
await self.db.createIndex({ 'advisoryLock._id': 1 }, {});
await self.createTextIndex();
await self.db.createIndex({ parkedId: 1 }, {});
await self.db.createIndex({
submitted: 1,
aposLocale: 1
});
await self.db.createIndex({
type: 1,
aposDocId: 1,
aposLocale: 1
});
await self.db.createIndex({
aposDocId: 1,
aposLocale: 1
});
await self.createPathLevelIndex();
},
async createTextIndex() {
try {
return await attempt();
} catch (e) {
// We are experiencing what may be a mongodb bug in which these
// indexes have different weights than expected and the createIndex
// call fails. If this happens drop and recreate the text index
if (e.toString().match(/different options/)) {
self.apos.util.warn('Text index has unexpected weights or other misconfiguration, reindexing');
await self.db.dropIndex('highSearchText_text_lowSearchText_text_title_text_searchBoost_text');
return await attempt();
} else {
throw e;
}
}
function attempt() {
return self.db.createIndex({
highSearchText: 'text',
lowSearchText: 'text',
title: 'text',
searchBoost: 'text'
}, {
default_language: self.options.searchLanguage || 'none',
weights: {
title: 100,
searchBoost: 150,
highSearchText: 10,
lowSearchText: 2
}
});
}
},
async createPathLevelIndex() {
const params = self.getPathLevelIndexParams();
return self.db.createIndex(params, {});
},
// Returns a query based on the permissions
// associated with the given request. You can then
// invoke chainable query builders like `.project()`,
// `limit()`, etc. to alter the query before ending
// the chain with an awaitable method like `toArray()`
// to obtain documents.
//
// `req` determines what documents the user is allowed
// to see. `criteria` is a MongoDB criteria object,
// see the MongoDB documentation for basics on this.
// If an `options` object is present, query builder
// methods with the same name as each property are
// invoked, with the value of that property. This is
// an alternative to chaining methods.
//
// This method returns a query, not docs! You
// need to chain it with toArray() or other
// query methods and await the result:
//
// await apos.doc.find(req, { type: 'foobar' }).toArray()
find(req, criteria = {}, options = {}) {
return self.apos.modules['@apostrophecms/any-doc-type']
.find(req, criteria, options);
},
// **Most often you will insert or update docs via the
// insert and update methods of the appropriate doc manager.**
// This method is for implementation use in those objects,
// and for times when you wish to explicitly bypass type-specific
// lifecycle events.
//
// Insert the given document. If the slug is not
// unique it is made unique. `beforeInsert`, `beforeSave`, `afterInsert`
// and `afterSave` events are emitted via the appropriate doc type
// manager, then awaited. They receive `(req, doc, options)`.
//
// Returns the inserted document.
//
// If the slug property is not set, the title
// property is converted to a slug. If neither
// property is set, an error is thrown.
//
// The `edit-type-name` permission is checked based on
// doc.type.
//
// If a unique key error occurs, the `@apostrophecms/doc:fixUniqueError`
// event is emitted and the doc is passed to all handlers.
// Modify the document to fix any properties that may need to be
// more unique due to a unique index you have added. It is
// not possible to know which property was responsible. This method
// takes care of the slug property directly.
//
// The `options` object may be omitted completely.
//
// If `options.permissions` is set explicitly to
// `false`, permissions checks are bypassed.
async insert(req, doc, options) {
const telemetry = self.apos.telemetry;
return telemetry.startActiveSpan(`model:${doc.type}:insert`, async (span) => {
span.setAttribute(SemanticAttributes.CODE_FUNCTION, 'insert');
span.setAttribute(SemanticAttributes.CODE_NAMESPACE, self.__meta.name);
span.setAttribute(telemetry.Attributes.TARGET_NAMESPACE, doc.type);
span.setAttribute(telemetry.Attributes.TARGET_FUNCTION, 'insert');
try {
options = options || {};
const m = self.getManager(doc.type);
await m.emit('beforeInsert', req, doc, options);
await m.emit('beforeSave', req, doc, options);
await telemetry.startActiveSpan(`db:${doc.type}:insert`, async (spanInsert) => {
spanInsert.setAttribute(SemanticAttributes.CODE_FUNCTION, 'insertBody');
spanInsert.setAttribute(
SemanticAttributes.CODE_NAMESPACE, self.__meta.name
);
spanInsert.setAttribute(telemetry.Attributes.TARGET_NAMESPACE, doc.type);
spanInsert.setAttribute(telemetry.Attributes.TARGET_FUNCTION, 'insert');
try {
const result = await self.insertBody(req, doc, options);
spanInsert.setStatus({ code: telemetry.api.SpanStatusCode.OK });
return result;
} catch (e) {
telemetry.handleError(spanInsert, e);
throw e;
} finally {
spanInsert.end();
}
}, span, {});
await m.emit('afterInsert', req, doc, options);
await m.emit('afterSave', req, doc, options);
// TODO: Remove `afterLoad` in next major version. Deprecated.
await m.emit('afterLoad', req, [ doc ]);
span.setStatus({ code: telemetry.api.SpanStatusCode.OK });
return doc;
} catch (err) {
telemetry.handleError(span, err);
throw err;
} finally {
span.end();
}
});
},
// Updates the given document. If the slug is not
// unique it is made unique. `beforeUpdate`, `beforeSave`,
// `afterUpdate` and `afterSave` events are emitted
// via the appropriate doc type manager.
//
// The second argument must be the document itself.
// `$set`, `$inc`, etc. are NOT available via
// this interface. This simplifies the implementation
// of permissions and workflow. If you need to
// update an object, find it first and then update it.
//
// Returns the updated doc.
//
// If a unique key error occurs, the `@apostrophecms/doc:fixUniqueError`
// event is emitted and the doc is passed to all handlers.
// Modify the document to fix any properties that may need to be
// more unique due to a unique index you have added. It is
// not possible to know which property was responsible. This method
// takes care of the slug property directly.
//
// The `options` object may be omitted completely.
//
// If `options.permissions` is set explicitly to
// `false`, permissions checks are bypassed.
async update(req, doc, options) {
const telemetry = self.apos.telemetry;
return telemetry.startActiveSpan(`model:${doc.type}:update`, async (span) => {
span.setAttribute(SemanticAttributes.CODE_FUNCTION, 'update');
span.setAttribute(SemanticAttributes.CODE_NAMESPACE, self.__meta.name);
span.setAttribute(telemetry.Attributes.TARGET_NAMESPACE, doc.type);
span.setAttribute(telemetry.Attributes.TARGET_FUNCTION, 'update');
try {
options = options || {};
const m = self.getManager(doc.type);
await m.emit('beforeUpdate', req, doc, options);
await m.emit('beforeSave', req, doc, options);
await telemetry.startActiveSpan(`db:${doc.type}:update`, async (spanUpdate) => {
spanUpdate.setAttribute(SemanticAttributes.CODE_FUNCTION, 'updateBody');
spanUpdate.setAttribute(
SemanticAttributes.CODE_NAMESPACE, self.__meta.name
);
spanUpdate.setAttribute(telemetry.Attributes.TARGET_NAMESPACE, doc.type);
spanUpdate.setAttribute(telemetry.Attributes.TARGET_FUNCTION, 'update');
try {
const result = await self.updateBody(req, doc, options);
spanUpdate.setStatus({ code: telemetry.api.SpanStatusCode.OK });
return result;
} catch (e) {
telemetry.handleError(spanUpdate, e);
throw e;
} finally {
spanUpdate.end();
}
}, span, {});
await m.emit('afterUpdate', req, doc, options);
await m.emit('afterSave', req, doc, options);
// TODO: Remove `afterLoad` in next major version. Deprecated.
await m.emit('afterLoad', req, [ doc ]);
span.setStatus({ code: telemetry.api.SpanStatusCode.OK });
return doc;
} catch (err) {
telemetry.handleError(span, err);
throw err;
} finally {
span.end();
}
});
},
// True delete. To place a document in the archive,
// update the archived property (for a piece) or move it
// to be a child of the archive (for a page). True delete
// cannot be undone.
//
// This operation ignores the locale and mode of `req`
// in favor of the actual document's locale and mode.
async delete(req, doc, options = {}) {
const m = self.getManager(doc.type);
await m.emit('beforeDelete', req, doc, options);
await self.deleteBody(req, doc, options);
await m.emit('afterDelete', req, doc, options);
},
// Publish the given draft. If `options.permissions` is explicitly
// set to `false`, permissions checks are bypassed.
async publish(req, draft, options = {}) {
const m = self.getManager(draft.type);
return m.publish(req, draft, options);
},
// Unpublish a given document.
async unpublish(req, doc) {
const m = self.getManager(doc.type);
return m.unpublish(req, doc);
},
// Revert to the previously published content, or if
// already equal to the previously published content, to the
// publication before that. Returns `false` if the draft
// cannot be reverted any further.
async revert(req, draft) {
const m = self.getManager(draft.type);
return m.revert(req, draft);
},
// Recursively visit every property of a doc,
// invoking an iterator function for each one. Optionally
// deletes properties.
//
// The `_originalWidgets` property and its subproperties
// are not walked because they are temporary information
// present only to preserve widgets during save operations
// performed by users without permissions for those widgets.
//
// The second argument must be a function that takes
// an object, a key, a value, a "dot path" and an
// array containing the ancestors of this property
// (beginning with the original `doc` and including
// "object") and explicitly returns `false` if that property
// should be discarded. If any other value is returned the
// property remains.
//
// Remember, keys can be numbers; toString() is
// your friend.
//
// If the original object looks like:
//
// { a: { b: 5 } }
//
// Then when the iterator is invoked for b, the
// object will be { b: 5 }, the key
// will be `b`, the value will be `5`, the dotPath
// will be the string `a.b`, and ancestors will be
// [ { a: { b: 5 } } ].
walk(doc, iterator) {
return walkBody(doc, iterator, undefined, []);
function walkBody(doc, iterator, _dotPath, _ancestors) {
if (_ancestors.includes(doc)) {
// No infinite loops on circular references
return;
}
// Don't use concat, doc can be an array in which case
// it is important to preserve the nesting
_ancestors = [ ..._ancestors, doc ];
if (_dotPath !== undefined) {
_dotPath += '.';
} else {
_dotPath = '';
}
const remove = [];
for (const key in doc) {
const __dotPath = _dotPath + key.toString();
const ow = '_originalWidgets';
if (__dotPath === ow || __dotPath.substring(0, ow.length) === ow + '.') {
continue;
}
if (iterator(doc, key, doc[key], __dotPath, _ancestors) === false) {
remove.push(key);
} else {
const val = doc[key];
if (typeof val === 'object') {
walkBody(val, iterator, __dotPath, _ancestors);
}
}
}
for (const key of remove) {
delete doc[key];
}
}
},
// Retry the given "actor" async function until it
// does not yield a MongoDB error related to
// unique indexes. The actor is not passed
// any arguments and it will be awaited. If
// an error related to uniqueness does occur, this module emits the
// `fixUniqueError` event with `req, doc` before
// the next retry. This is your opportunity to
// tweak properties relating to unique indexes
// this module does not know about.
//
// Passes on the return value of `actor`.
//
// Will make no more than 20 attempts, which statistically eliminates
// any chance we just didn't try hard enough while avoiding
// an infinite loop if the unique key error is due to a property
// there is no handling for.
async retryUntilUnique(req, doc, actor) {
const maxAttempts = 20;
let attempt = 0;
let firstError;
while (true) {
try {
return await actor();
} catch (err) {
if (!self.isUniqueError(err)) {
throw err;
}
if (!firstError) {
firstError = err;
}
attempt++;
if (attempt === maxAttempts) {
// Odds are now 1 in 100000000000000000000 that it is really due
// to a duplicate path or slug; a far more likely explanation is
// that another docFixUniqueError handler is needed to address
// an additional property that has to be unique. Report the
// original error to avoid confusion ("ZOMG, what are all these
// digits!")
firstError.aposAddendum = 'retryUntilUnique failed, most likely you need another docFixUniqueError method to handle another property that has a unique index, reporting original error';
throw firstError;
}
await self.emit('fixUniqueError', req, doc);
}
}
},
// Called by an `@apostrophecms/doc-type:insert` event handler to confirm
// that the user has the appropriate permissions for the doc's type and
// content.
testInsertPermissions(req, doc, options) {
if (options.permissions !== false) {
if (!self.apos.permission.can(req, 'create', doc)) {
throw self.apos.error('forbidden');
}
}
},
// Do not call this yourself, it is called
// by .update(). You will usually want to call the
// update method of the appropriate doc type manager instead:
//
// self.apos.doc.getManager(doc.type).update(...)
//
// You may override this method to change the implementation.
async updateBody(req, doc, options) {
const manager = self.apos.doc.getManager(doc.type);
if (manager.isLocalized(doc.type)) {
// Performance hit now at write time is better than inaccurate
// indicators of which docs are modified later (per Ben)
if (doc.aposLocale.endsWith(':draft') && (options.setModified !== false)) {
doc.modified = await manager.isModified(req, doc);
}
}
const result = await self.retryUntilUnique(req, doc, async () => {
return self.db.replaceOne({ _id: doc._id }, self.apos.util.clonePermanent(doc));
});
if (manager.isLocalized(doc.type)) {
if (doc.aposLocale.endsWith(':published')) {
// The reverse can happen too: published changes
// (for instance because a move operation gets
// repeated on it) and draft is no longer out of sync
const modified = await manager.isModified(req, doc);
await self.apos.doc.db.updateOne({
_id: doc._id.replace(':published', ':draft')
}, {
$set: {
modified
}
});
}
}
return result;
},
async deleteBody(req, doc, options) {
if ((options.permissions !== false) && (!self.apos.permission.can(req, 'delete', doc))) {
throw self.apos.error('forbidden');
}
return self.db.removeOne({
_id: doc._id
});
},
// Insert the given document. Called by `.insert()`. You will usually
// want to call the insert method of the appropriate doc type manager
// instead:
//
// ```javascript
// self.apos.doc.getManager(doc.type).insert(...)
// ```
//
// However you can override this method to alter the
// implementation.
async insertBody(req, doc, options) {
const manager = self.apos.doc.getManager(doc.type);
if (manager.isLocalized(doc.type) && doc.aposLocale.endsWith(':draft')) {
// We are inserting the draft for the first time so it is always
// different from the published, which won't exist yet. An exception
// is when the published doc is inserted first (like a parked page)
// in which case setModified: false will be passed in
if (options.setModified !== false) {
doc.modified = true;
}
}
if (!doc.visibility) {
// If the visibility property has been removed from the schema
// (images and files), make sure public queries can still match this
// type
doc.visibility = 'public';
}
return self.retryUntilUnique(req, doc, async function () {
return self.db.insertOne(self.apos.util.clonePermanent(doc));
});
},
// Set meta data for a given field, that will be live under `aposMeta`
// doc property. It returns the path to the meta property without the
// key. See `getMetaPath` method for more information.
//
// Signature:
// `apos.doc.setMeta(doc, namespace, [subobject], ...pathComponents, key,
// value);` where arguments are as follows: - `doc`: the document to
// attach the meta property to. - `namespace`: the namespace of the meta
// property, by convention the module name that is setting the meta
// property. - `subobject`: (optional) the name of the field subobject
// (e.g. array item, widget, or any other field type object that have