adapt-authoring-content 2.0.3 → 2.0.4
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/.github/workflows/releases.yml +1 -1
- package/.github/workflows/standardjs.yml +1 -1
- package/.github/workflows/tests.yml +1 -1
- package/lib/ContentModule.js +1 -1
- package/package.json +2 -19
- package/tests/ContentModule.spec.js +4 -4
- package/AUDIT.md +0 -205
- package/CONTENT_TREE_PROPOSAL.md +0 -506
package/lib/ContentModule.js
CHANGED
|
@@ -71,7 +71,7 @@ class ContentModule extends AbstractApiModule {
|
|
|
71
71
|
if (_type !== 'component') {
|
|
72
72
|
return _type === 'page' || _type === 'menu' ? 'contentobject' : _type
|
|
73
73
|
}
|
|
74
|
-
const
|
|
74
|
+
const component = await contentplugin.findOne({ name: _component }, { validate: false, strict: false })
|
|
75
75
|
return component ? `${component.targetAttribute.slice(1)}-component` : defaultSchemaName
|
|
76
76
|
}
|
|
77
77
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "adapt-authoring-content",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.4",
|
|
4
4
|
"description": "Module for managing Adapt content",
|
|
5
5
|
"homepage": "https://github.com/adapt-security/adapt-authoring-content",
|
|
6
6
|
"license": "GPL-3.0",
|
|
@@ -18,26 +18,9 @@
|
|
|
18
18
|
"adapt-authoring-authored": "^1.1.1",
|
|
19
19
|
"adapt-authoring-contentplugin": "^1.0.6",
|
|
20
20
|
"adapt-authoring-jsonschema": "^1.2.0",
|
|
21
|
-
"adapt-authoring-mongodb": "^
|
|
21
|
+
"adapt-authoring-mongodb": "^1.1.3",
|
|
22
22
|
"adapt-authoring-tags": "^1.0.2"
|
|
23
23
|
},
|
|
24
|
-
"peerDependenciesMeta": {
|
|
25
|
-
"adapt-authoring-authored": {
|
|
26
|
-
"optional": true
|
|
27
|
-
},
|
|
28
|
-
"adapt-authoring-contentplugin": {
|
|
29
|
-
"optional": true
|
|
30
|
-
},
|
|
31
|
-
"adapt-authoring-jsonschema": {
|
|
32
|
-
"optional": true
|
|
33
|
-
},
|
|
34
|
-
"adapt-authoring-mongodb": {
|
|
35
|
-
"optional": true
|
|
36
|
-
},
|
|
37
|
-
"adapt-authoring-tags": {
|
|
38
|
-
"optional": true
|
|
39
|
-
}
|
|
40
|
-
},
|
|
41
24
|
"devDependencies": {
|
|
42
25
|
"@semantic-release/git": "^10.0.1",
|
|
43
26
|
"conventional-changelog-eslint": "^6.0.0",
|
|
@@ -234,9 +234,8 @@ describe('ContentModule', () => {
|
|
|
234
234
|
|
|
235
235
|
it('should look up a component plugin schema for _type "component"', async () => {
|
|
236
236
|
const contentplugin = {
|
|
237
|
-
find: mock.fn(async () => [{
|
|
238
|
-
|
|
239
|
-
}])
|
|
237
|
+
find: mock.fn(async () => [{ targetAttribute: '_myPlugin' }]),
|
|
238
|
+
findOne: mock.fn(async () => ({ targetAttribute: '_myPlugin' }))
|
|
240
239
|
}
|
|
241
240
|
const getSchemaName = ContentModule.prototype.getSchemaName.bind({
|
|
242
241
|
...inst,
|
|
@@ -256,7 +255,8 @@ describe('ContentModule', () => {
|
|
|
256
255
|
|
|
257
256
|
it('should fall back to default if component plugin is not found', async () => {
|
|
258
257
|
const contentplugin = {
|
|
259
|
-
find: mock.fn(async () => [])
|
|
258
|
+
find: mock.fn(async () => []),
|
|
259
|
+
findOne: mock.fn(async () => undefined)
|
|
260
260
|
}
|
|
261
261
|
const getSchemaName = ContentModule.prototype.getSchemaName.bind({
|
|
262
262
|
...inst,
|
package/AUDIT.md
DELETED
|
@@ -1,205 +0,0 @@
|
|
|
1
|
-
# ContentModule.js Audit — General Improvements
|
|
2
|
-
|
|
3
|
-
Date: 2026-02-19
|
|
4
|
-
|
|
5
|
-
## Performance
|
|
6
|
-
|
|
7
|
-
### 1. `updateSortOrder()` fires on every insert and update (Lines 117, 127)
|
|
8
|
-
Each call fetches all siblings, then issues an `update` for every sibling whose sort order changed. On an article with 20 blocks, that's 20+ DB calls per insert/update.
|
|
9
|
-
|
|
10
|
-
**Suggestion:** Only update sort order when `_sortOrder` or `_parentId` has actually changed in the update data. Currently line 127 always calls it regardless. Additionally, the missing `return` on line 299 means the `super.update` promises are not awaited — see bug #1 below.
|
|
11
|
-
|
|
12
|
-
### 2. `updateEnabledPlugins()` fires on every insert and update unconditionally (Lines 118, 128)
|
|
13
|
-
This method fetches the `contentplugin` module, all content items for the course, all extensions, and potentially updates every content item of affected types. This is heavy for routine edits that don't change components.
|
|
14
|
-
|
|
15
|
-
**Suggestion:** Two changes: (1) Guard with a check — only run on insert/delete of components or when `_component`, `_menu`, `_theme`, or `_enabledPlugins` fields are being modified. (2) Accept an optional `ContentTree` parameter so callers that already have a tree (e.g. `delete`, `clone`) don't trigger a redundant full-course fetch.
|
|
16
|
-
|
|
17
|
-
### 3. `getSchemaName()` awaits `waitForModule('contentplugin')` on every call (Line 56)
|
|
18
|
-
`waitForModule` is called every time, even though the module reference never changes after init.
|
|
19
|
-
|
|
20
|
-
**Suggestion:** Cache the module references obtained in `init()` as instance properties (e.g. `this.contentplugin`, `this.jsonschema`, etc.) and reuse them throughout the class. This applies to all the `waitForModule` calls scattered across methods (lines 56, 79, 83, 104, 311).
|
|
21
|
-
|
|
22
|
-
### 4. `getSchema()` disables cache (`useCache: false`) on every call (Line 95)
|
|
23
|
-
This forces re-computation of the schema on every request.
|
|
24
|
-
|
|
25
|
-
**Suggestion:** Investigate whether caching can be enabled for schemas within a course context (keyed by `schemaName + courseId + enabledPlugins hash`).
|
|
26
|
-
|
|
27
|
-
### 5. `getSchema()` may fetch the same document twice (Lines 81, 84-85)
|
|
28
|
-
`getSchemaName(data)` at line 81 may call `this.find({ _id })` internally (line 61), and then line 84-85 may call `this.find({ _id: data._id })` again if `_courseId` is missing.
|
|
29
|
-
|
|
30
|
-
**Suggestion:** Pass the document fetched in `getSchemaName` back through the call chain to avoid the redundant query.
|
|
31
|
-
|
|
32
|
-
### 6. `enabledPluginSchemas` uses quadratic `.reduce` with spread (Line 91)
|
|
33
|
-
```js
|
|
34
|
-
pluginList.reduce((m, p) => [...m, ...contentplugin.getPluginSchemas(p)], [])
|
|
35
|
-
```
|
|
36
|
-
This creates a new array on every iteration via spread, giving O(n²) behavior.
|
|
37
|
-
|
|
38
|
-
**Suggestion:** Use `flatMap`:
|
|
39
|
-
```js
|
|
40
|
-
pluginList.flatMap(p => contentplugin.getPluginSchemas(p))
|
|
41
|
-
```
|
|
42
|
-
|
|
43
|
-
## Bugs / Correctness
|
|
44
|
-
|
|
45
|
-
### 1. Missing `return` in `updateSortOrder` — promises not awaited (Line 299)
|
|
46
|
-
```js
|
|
47
|
-
if (s._sortOrder !== _sortOrder) super.update({ _id: s._id }, { _sortOrder })
|
|
48
|
-
```
|
|
49
|
-
The `super.update()` call is missing a `return`. The `async` map callback resolves immediately with `undefined`, and the updates run as fire-and-forget. If any update fails, the error is silently swallowed as an unhandled rejection.
|
|
50
|
-
|
|
51
|
-
**Fix:** Add `return` before `super.update(...)`.
|
|
52
|
-
|
|
53
|
-
### 2. `clone()` accesses `._type` on a null document (Line 241)
|
|
54
|
-
```js
|
|
55
|
-
if (!originalDoc) {
|
|
56
|
-
throw this.app.errors.NOT_FOUND.setData({ type: originalDoc?._type, id: _id })
|
|
57
|
-
}
|
|
58
|
-
```
|
|
59
|
-
If `originalDoc` is falsy, `originalDoc?._type` evaluates to `undefined`, so the error data will always have `type: undefined`. This isn't a crash (thanks to optional chaining), but the error message is unhelpful.
|
|
60
|
-
|
|
61
|
-
**Fix:** Provide a meaningful fallback: `{ type: 'content', id: _id }`.
|
|
62
|
-
|
|
63
|
-
### 3. `delete()` calls `this.setDefaultOptions(options)` but `options` may be `undefined` (Line 135)
|
|
64
|
-
Unlike other overrides, `delete()` doesn't default `options` to `{}` in its signature. If called internally without options, `this.setDefaultOptions(undefined)` could behave unexpectedly (Lodash `_.defaults` on `undefined` returns `undefined`).
|
|
65
|
-
|
|
66
|
-
**Fix:** Add `options = {}` default parameter, consistent with the parent class.
|
|
67
|
-
|
|
68
|
-
### 4. `insertRecursive` doesn't handle missing parent gracefully (Line 203)
|
|
69
|
-
```js
|
|
70
|
-
parent = (await this.find({ _id: rootId }))[0]
|
|
71
|
-
```
|
|
72
|
-
If `rootId` is provided but no document is found, `parent` is `undefined`. The code continues into the `for` loop where `parent._type` (line 207) will throw an untyped `TypeError` rather than a proper API error.
|
|
73
|
-
|
|
74
|
-
**Fix:** Add a check after the find: `if (!parent) throw this.app.errors.NOT_FOUND.setData(...)`.
|
|
75
|
-
|
|
76
|
-
### 5. `updateSortOrder` splice logic may be incorrect for new items (Line 294)
|
|
77
|
-
```js
|
|
78
|
-
const newSO = item._sortOrder - 1 > -1 ? item._sortOrder - 1 : siblings.length
|
|
79
|
-
```
|
|
80
|
-
For a newly inserted item, `item._sortOrder` may be `undefined` (not yet set), making `undefined - 1 > -1` evaluate to `false`, which defaults to `siblings.length`. This works by accident (appends to end), but is fragile and unclear.
|
|
81
|
-
|
|
82
|
-
**Fix:** Explicitly handle the `undefined` case: `const newSO = item._sortOrder != null ? item._sortOrder - 1 : siblings.length`.
|
|
83
|
-
|
|
84
|
-
### 6. `INVALID_PARENT` error uses HTTP 500 (errors.json line 7)
|
|
85
|
-
A missing or invalid parent ID is a client error, not a server error.
|
|
86
|
-
|
|
87
|
-
**Fix:** Change `statusCode` to `400` (Bad Request).
|
|
88
|
-
|
|
89
|
-
## Reliability / Error Handling
|
|
90
|
-
|
|
91
|
-
### 7. `clone()` has no rollback on failure (Lines 237-279)
|
|
92
|
-
Unlike `insertRecursive` (which has a try/catch with cleanup), `clone()` has no rollback. If cloning fails partway through a recursive tree, orphaned partially-cloned documents are left in the database.
|
|
93
|
-
|
|
94
|
-
**Suggestion:** Wrap the clone operation in a try/catch that cleans up any documents created during the operation, similar to the pattern in `insertRecursive`.
|
|
95
|
-
|
|
96
|
-
### 8. Silent error swallowing in `getSchema` (Lines 82, 92)
|
|
97
|
-
```js
|
|
98
|
-
try { schemaName = await this.getSchemaName(data) } catch (e) {}
|
|
99
|
-
```
|
|
100
|
-
and
|
|
101
|
-
```js
|
|
102
|
-
try { ... } catch (e) {}
|
|
103
|
-
```
|
|
104
|
-
These silently swallow all errors, including genuine failures (DB connection issues, permission errors). A schema resolution failure will silently fall back to a less specific schema, potentially allowing invalid data through validation.
|
|
105
|
-
|
|
106
|
-
**Suggestion:** At minimum, log the error. Better: only catch expected error types.
|
|
107
|
-
|
|
108
|
-
### 9. `clone()` course workaround is fragile (Lines 262-270)
|
|
109
|
-
The comment explains that config doesn't exist when the course is created, so schema validation strips plugin data, and a second update restores it. This double-write pattern means:
|
|
110
|
-
- If the second `update` fails, the course is left with stripped configuration
|
|
111
|
-
- The `payload` object is mutated (`delete payload._id`, `delete payload._courseId`) after being passed to `insert`, which could cause subtle issues if `insert` stored a reference
|
|
112
|
-
|
|
113
|
-
**Suggestion:** Consider inserting the config first (or in the same transaction), then creating the course with full data. Or use `{ validate: false }` for the initial course insert to preserve all fields.
|
|
114
|
-
|
|
115
|
-
### 10. No input validation on `handleClone` body (Line 377)
|
|
116
|
-
The `_id` and `_parentId` are destructured directly from `req.body` with no validation that `_id` is present or is a valid ObjectId.
|
|
117
|
-
|
|
118
|
-
**Suggestion:** Validate required fields before proceeding.
|
|
119
|
-
|
|
120
|
-
## Code Quality
|
|
121
|
-
|
|
122
|
-
### 11. Inconsistent use of `this.find` vs `super.find` vs `this.findOne`
|
|
123
|
-
- `handleClone` uses `this.findOne` (line 378), but `clone` uses `this.find` and destructures (line 238)
|
|
124
|
-
- `updateEnabledPlugins` uses `super.find` (line 349) while other methods use `this.find`
|
|
125
|
-
- The distinction matters: `this.find` goes through schema validation/access checks; `super.find` bypasses them
|
|
126
|
-
|
|
127
|
-
**Suggestion:** Document the intent clearly, and be consistent about when validation/hooks should be bypassed.
|
|
128
|
-
|
|
129
|
-
### 12. `clone()` line 265 mutates `payload` after `insert`
|
|
130
|
-
`delete payload._id` and `delete payload._courseId` mutate the object after it was already used as insert data. While likely harmless, mutation of shared objects is error-prone.
|
|
131
|
-
|
|
132
|
-
**Suggestion:** Create a separate object for the update: `const updatePayload = { ...payload }; delete updatePayload._id; ...`
|
|
133
|
-
|
|
134
|
-
### 13. Missing `UNKNOWN_SCHEMA_NAME` usage
|
|
135
|
-
The error is defined in `errors.json` but never thrown anywhere in the code.
|
|
136
|
-
|
|
137
|
-
**Suggestion:** Either use it where appropriate (e.g., in `getSchemaName` when no schema is resolved) or remove it.
|
|
138
|
-
|
|
139
|
-
## Summary — Priority Order
|
|
140
|
-
|
|
141
|
-
| Priority | Issue | Category |
|
|
142
|
-
|----------|-------|----------|
|
|
143
|
-
| **High** | Missing `return` in `updateSortOrder` — fire-and-forget DB writes | Bug |
|
|
144
|
-
| **High** | No rollback in `clone()` leaves orphaned data on failure | Reliability |
|
|
145
|
-
| **High** | Silent error swallowing in `getSchema` | Reliability |
|
|
146
|
-
| **Medium** | `updateSortOrder`/`updateEnabledPlugins` fire unconditionally | Performance |
|
|
147
|
-
| **Medium** | `delete()` missing default `options = {}` parameter | Bug |
|
|
148
|
-
| **Medium** | `insertRecursive` no check for missing parent | Bug |
|
|
149
|
-
| **Medium** | `INVALID_PARENT` should be 400 not 500 | Correctness |
|
|
150
|
-
| **Medium** | `waitForModule` called repeatedly instead of caching refs | Performance |
|
|
151
|
-
| **Low** | Quadratic `.reduce` with spread in `getSchema` | Performance |
|
|
152
|
-
| **Low** | `clone()` error message has `type: undefined` | Correctness |
|
|
153
|
-
| **Low** | No input validation on `handleClone` body | Reliability |
|
|
154
|
-
| **Low** | Unused `UNKNOWN_SCHEMA_NAME` error definition | Code quality |
|
|
155
|
-
|
|
156
|
-
---
|
|
157
|
-
|
|
158
|
-
## MongoDB Projections
|
|
159
|
-
|
|
160
|
-
### Infrastructure
|
|
161
|
-
|
|
162
|
-
Projections are fully supported through the existing stack. The `mongoOptions` (third argument) to `find`/`findOne` is passed through `AbstractApiModule` → `DataCache` → `MongoDBModule` → native MongoDB driver unchanged. The `projection` key is a standard MongoDB `FindOptions` field.
|
|
163
|
-
|
|
164
|
-
Usage pattern:
|
|
165
|
-
```js
|
|
166
|
-
this.find({ _id }, { validate: false }, { projection: { _type: 1, _component: 1 } })
|
|
167
|
-
```
|
|
168
|
-
|
|
169
|
-
There is one existing use in the codebase (`RolesModule`) confirming this works in practice. The `DataCache` key includes `mongoOptions` in its hash, so projected and non-projected results are cached separately.
|
|
170
|
-
|
|
171
|
-
### Call-site analysis
|
|
172
|
-
|
|
173
|
-
| Line | Method | Fields returned | Fields used | Minimal projection | Safe? | Impact |
|
|
174
|
-
|------|--------|----------------|-------------|-------------------|-------|--------|
|
|
175
|
-
| 61 | `getSchemaName` | ~30+ | `_type`, `_component` | `{ _type: 1, _component: 1 }` | Yes | High — called on every write |
|
|
176
|
-
| 85 | `getSchema` | ~30+ | `_courseId` | `{ _courseId: 1 }` | Yes | High — called on every write |
|
|
177
|
-
| 89 | `getSchema` (config) | ~30+ | `_enabledPlugins` | `{ _enabledPlugins: 1 }` | Yes | High — config docs are large |
|
|
178
|
-
| 137 | `delete` | ~30+ | All (HTTP response) | None | N/A | Cannot project |
|
|
179
|
-
| 160 | `getDescendants` | ~30+ | All (HTTP response) | None | N/A | Cannot project (results returned to caller) |
|
|
180
|
-
| 238 | `clone` (original) | ~30+ | All (spread into clone) | None | N/A | Cannot project |
|
|
181
|
-
| 245 | `clone` (parent) | ~30+ | `_id`, `_type`, `_courseId` | `{ _id: 1, _type: 1, _courseId: 1 }` | Yes | Medium |
|
|
182
|
-
| 263 | `clone` (config) | ~30+ | `_id` only | `{ _id: 1 }` | Yes | High — config is large, re-fetched in recursive call |
|
|
183
|
-
| 272 | `clone` (children) | ~30+ | `_id` only | `{ _id: 1 }` | Yes | High — called per tree level, full docs wasted |
|
|
184
|
-
| 292 | `updateSortOrder` | ~30+ | `_id`, `_sortOrder` | `{ _id: 1, _sortOrder: 1 }` | Yes | High — called on every insert/update |
|
|
185
|
-
| 312 | `updateEnabledPlugins` | ~30+ | `_id`, `_type`, `_component`, `_enabledPlugins`, `_menu`, `_theme` | `{ _id:1, _type:1, _component:1, _enabledPlugins:1, _menu:1, _theme:1 }` | Yes | **Highest** — fetches entire course on every insert/update |
|
|
186
|
-
| 318 | `contentplugin.find` | ~15+ | `name` | `{ _id: 0, name: 1 }` | Yes | High — called on every insert/update |
|
|
187
|
-
| 349 | `super.find` (types to update) | ~30+ | `_id` only | `{ _id: 1 }` | Yes | High — `super.update` re-fetches internally |
|
|
188
|
-
|
|
189
|
-
### Hook safety
|
|
190
|
-
|
|
191
|
-
None of the projectable call sites pass their results to hooks. All hook observers (`authored`, `multilang`, `spoortracking`, `defaultplugins`, `courseassets`) receive documents from separate internal fetches within `super.insert`/`super.update`/`super.delete`, which always fetch full documents. Applying projections to the call sites above will not break any hook contract.
|
|
192
|
-
|
|
193
|
-
### Highest-impact projections
|
|
194
|
-
|
|
195
|
-
**1. `updateEnabledPlugins` line 312** — fetches every content item in the course to extract component names and config plugin list. Only 6 fields needed from each document. For a 100-item course, this could reduce data transfer from ~200KB to ~10KB. Called on every single insert and update.
|
|
196
|
-
|
|
197
|
-
**2. `updateSortOrder` line 292** — fetches all siblings to compare `_sortOrder` values. Only needs `_id` and `_sortOrder`. Called on every insert and update.
|
|
198
|
-
|
|
199
|
-
**3. `getSchema` lines 61, 85, 89** — called on every write request. Three separate finds that each return full documents when only 1-2 fields are needed.
|
|
200
|
-
|
|
201
|
-
**4. `clone` children query line 272** — fetches full documents for all children at each tree level, but only uses `_id` to pass to the next recursive call. With `ContentTree`, this is eliminated entirely; without it, a projection to `{ _id: 1 }` would still help significantly.
|
|
202
|
-
|
|
203
|
-
### Recommendation
|
|
204
|
-
|
|
205
|
-
Apply projections to all "Safe: Yes" call sites above. The changes are mechanical — adding a third argument to each `find` call — and carry zero risk since no hooks or return values are affected. The `updateEnabledPlugins` projection alone would meaningfully reduce the data flowing through the most frequently executed code path.
|
package/CONTENT_TREE_PROPOSAL.md
DELETED
|
@@ -1,506 +0,0 @@
|
|
|
1
|
-
# ContentTree Proposal
|
|
2
|
-
|
|
3
|
-
Date: 2026-02-19
|
|
4
|
-
|
|
5
|
-
## Related Audit Issues
|
|
6
|
-
|
|
7
|
-
These issues from the ContentModule.js audit are directly addressed or enabled by the ContentTree abstraction.
|
|
8
|
-
|
|
9
|
-
### `getDescendants()` full course fetch and O(n²) traversal (Lines 160-166)
|
|
10
|
-
`getDescendants()` calls `this.find({ _courseId })` which loads every content item in the course into memory, then walks the tree using nested `reduce`/`filter` with spread — O(n²) for both the scan and array allocation.
|
|
11
|
-
|
|
12
|
-
**Suggestion:** Replace the body with `ContentTree` (see proposal below). The full-course fetch is still needed, but the in-memory traversal drops from O(n²) to O(n) via the `byParent` Map. The `ContentTree` instance can also be shared with callers (`delete`, `clone`) to avoid redundant fetches.
|
|
13
|
-
|
|
14
|
-
### `delete()` issues N+1 individual delete calls (Lines 144-146)
|
|
15
|
-
Each descendant is deleted with a separate `super.delete({ _id })` call, each of which internally does a `find` then a `delete` in MongoDB. For a course with 200 items, that's ~400 DB round-trips.
|
|
16
|
-
|
|
17
|
-
**Suggestion:** Use `super.deleteMany({ _id: { $in: ids } })` if hook invocation per item isn't needed, or at minimum batch the MongoDB operations. A `ContentTree` built once in `delete()` can provide the full descendant ID list for `deleteMany`, and the same tree instance can be passed to `updateEnabledPlugins` and `updateSortOrder` to avoid their own fetches.
|
|
18
|
-
|
|
19
|
-
### `clone()` issues N+1 DB queries and double-fetches the source document (Lines 238, 272, 378)
|
|
20
|
-
`handleClone` calls `this.findOne({ _id })` for access checking (line 378), then `clone()` calls `this.find({ _id })` again (line 238). The recursive clone loop then issues one `find({ _parentId })` query per tree depth level (line 272).
|
|
21
|
-
|
|
22
|
-
**Suggestion:** Refactor `clone` to accept a pre-built `ContentTree`. The handler fetches the full course once, performs the access check against it, and the recursive loop uses `tree.getChildren(id)` instead of DB queries. This eliminates both the double-fetch and the N+1 pattern in a single change.
|
|
23
|
-
|
|
24
|
-
### Priority summary
|
|
25
|
-
|
|
26
|
-
| Priority | Issue | Category |
|
|
27
|
-
|----------|-------|----------|
|
|
28
|
-
| **High** | `delete()` N+1 individual deletes — O(n) DB round-trips | Performance |
|
|
29
|
-
| **High** | `clone()` N+1 queries + double-fetch | Performance |
|
|
30
|
-
| **Medium** | `getDescendants` O(n²) traversal | Performance |
|
|
31
|
-
|
|
32
|
-
---
|
|
33
|
-
|
|
34
|
-
## Deep Dive: Clone Operation
|
|
35
|
-
|
|
36
|
-
### DB query count for a typical course clone
|
|
37
|
-
|
|
38
|
-
Tested against a representative course structure: 1 course + 1 config + 2 pages + 4 articles + 8 blocks + 16 components = **32 items**.
|
|
39
|
-
|
|
40
|
-
Every `this.find()` hits MongoDB directly — the content module's `DataCache` is disabled (`enableCache` is not set in config).
|
|
41
|
-
|
|
42
|
-
#### What happens per cloned item
|
|
43
|
-
|
|
44
|
-
Each item cloned through `this.insert()` triggers a cascade of operations:
|
|
45
|
-
|
|
46
|
-
| Operation | DB reads | DB writes | Notes |
|
|
47
|
-
|-----------|----------|-----------|-------|
|
|
48
|
-
| `super.insert` → `mongodb.insert` | 0 | 1 | The actual write |
|
|
49
|
-
| `super.insert` → `validate` → `getSchema` | 1-2 | 0 | Fetches config for `_enabledPlugins`; components also fetch `contentplugin` for schema name |
|
|
50
|
-
| `preInsertHook` → authored `updateCourseTimestamp` | 1 | 1 | Reads config, writes `updatedAt` to it — **every single insert** |
|
|
51
|
-
| `preInsertHook` → spoor `insertTrackingId` | 0-1 | 0 | 1 read for blocks only (finds max `_trackingId`) |
|
|
52
|
-
| `preInsertHook` → defaultplugins | 0-1 | 0 | 1 read for config only |
|
|
53
|
-
| `postInsertHook` → multilang lookup | 1 | 0 | Checks multilang collection, exits for non-multilang courses |
|
|
54
|
-
| `updateSortOrder` | 1 | 0 | Reads siblings; 0 writes during clone (sort order preserved from original) |
|
|
55
|
-
| `updateEnabledPlugins` | 2 | 0 | Reads all course content + all extensions; early-exits when plugin list unchanged |
|
|
56
|
-
| `postCloneHook` → multilang lookup | 1 | 0 | Same as postInsertHook |
|
|
57
|
-
| **Total per non-course, non-config item** | **~10-12** | **~2** | |
|
|
58
|
-
|
|
59
|
-
Additional overhead for the course item itself: `this.update({ _id, _courseId })` to self-assign `_courseId` (adds ~7 reads, 2 writes). After config clone, another `this.update` restores plugin config data (~6 reads, 2 writes).
|
|
60
|
-
|
|
61
|
-
#### Total DB operations for a 32-item course clone
|
|
62
|
-
|
|
63
|
-
| Phase | Reads | Writes |
|
|
64
|
-
|-------|-------|--------|
|
|
65
|
-
| `handleClone`: access check `findOne` | 1 | 0 |
|
|
66
|
-
| Course clone: find + insert + self-update | 8 | 2 |
|
|
67
|
-
| Config clone: find + insert + hooks | 9 | 1 |
|
|
68
|
-
| Course update to restore plugin config | 6 | 2 |
|
|
69
|
-
| Find children at each level (5 levels) | 5 | 0 |
|
|
70
|
-
| 2 pages × ~10 reads, 2 writes | 20 | 4 |
|
|
71
|
-
| 4 articles × ~10 reads, 2 writes | 40 | 8 |
|
|
72
|
-
| 8 blocks × ~11 reads, 2 writes | 88 | 16 |
|
|
73
|
-
| 16 components × ~12 reads, 2 writes | 192 | 32 |
|
|
74
|
-
| **Total** | **~369** | **~65** |
|
|
75
|
-
|
|
76
|
-
**~434 total DB operations to clone a 32-item course.**
|
|
77
|
-
|
|
78
|
-
For a realistic production course (100-200 items), this scales to **1,000-3,000 DB operations**.
|
|
79
|
-
|
|
80
|
-
### Optimisation opportunities
|
|
81
|
-
|
|
82
|
-
Listed in order of estimated impact:
|
|
83
|
-
|
|
84
|
-
#### 1. Disable `updateEnabledPlugins` during clone — save ~62 reads
|
|
85
|
-
|
|
86
|
-
`ContentModule.insert` already has the option infrastructure:
|
|
87
|
-
```js
|
|
88
|
-
options.updateEnabledPlugins !== false && this.updateEnabledPlugins(doc)
|
|
89
|
-
```
|
|
90
|
-
The clone method just needs to pass the option:
|
|
91
|
-
```js
|
|
92
|
-
const newData = await this.insert(payload, { schemaName, updateEnabledPlugins: false })
|
|
93
|
-
```
|
|
94
|
-
Then run `updateEnabledPlugins` once at the end of the top-level clone. **Two-line fix.**
|
|
95
|
-
|
|
96
|
-
#### 2. Disable `updateSortOrder` during clone — save ~30 reads
|
|
97
|
-
|
|
98
|
-
Same pattern:
|
|
99
|
-
```js
|
|
100
|
-
const newData = await this.insert(payload, { schemaName, updateSortOrder: false })
|
|
101
|
-
```
|
|
102
|
-
Cloned items already carry the correct `_sortOrder` from the original. No recalculation needed.
|
|
103
|
-
|
|
104
|
-
#### 3. Pass parent doc through recursion — save ~30 reads
|
|
105
|
-
|
|
106
|
-
Currently each recursive `clone()` call does `this.find({ _id: _parentId })` just to read `_type` and `_courseId`. The parent is already known at the call site:
|
|
107
|
-
```js
|
|
108
|
-
// Current (line 273-275)
|
|
109
|
-
for (let i = 0; i < children.length; i++) {
|
|
110
|
-
await this.clone(userId, children[i]._id, newData._id)
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
// Proposed: pass newData as parent
|
|
114
|
-
for (let i = 0; i < children.length; i++) {
|
|
115
|
-
await this.clone(userId, children[i]._id, newData._id, {}, { parent: newData })
|
|
116
|
-
}
|
|
117
|
-
```
|
|
118
|
-
|
|
119
|
-
#### 4. Use ContentTree to eliminate per-level child queries — save ~5 reads per level
|
|
120
|
-
|
|
121
|
-
Pre-fetch the entire source course once, build a tree, and use `tree.getChildren(originalId)` instead of `this.find({ _parentId })` at each recursion level. Combined with passing parent docs (#3), this eliminates all read-only `find` calls within the recursive loop.
|
|
122
|
-
|
|
123
|
-
#### 5. Parallelise sibling clones — ~4-5x wall-clock improvement
|
|
124
|
-
|
|
125
|
-
The sequential `for` loop (line 273) can safely be replaced with `Promise.all`:
|
|
126
|
-
```js
|
|
127
|
-
const children = tree.getChildren(_id)
|
|
128
|
-
await Promise.all(children.map(child =>
|
|
129
|
-
this.clone(userId, child._id, newData._id, {}, { parent: newData, tree })
|
|
130
|
-
))
|
|
131
|
-
```
|
|
132
|
-
|
|
133
|
-
Sibling subtrees are independent — they write to different documents with different `_id` values. The only shared state is `updateEnabledPlugins` (which writes to config), but with that disabled during clone (#1 above), there are no write conflicts.
|
|
134
|
-
|
|
135
|
-
For a 5-level course, this changes wall-clock time from O(N × depth) to O(depth), as all siblings at each level clone in parallel.
|
|
136
|
-
|
|
137
|
-
#### 6. Skip schema validation during clone — save ~32 reads + CPU
|
|
138
|
-
|
|
139
|
-
Every `super.insert` runs `this.validate()` → `getSchema()` → fetches config for `_enabledPlugins` filtering. The data being cloned was already valid in the original course.
|
|
140
|
-
|
|
141
|
-
Pass `{ validate: false }` to the insert options during clone. This saves 1-2 DB reads per item (the config and contentplugin lookups in `getSchema`) and all the CPU cost of schema compilation with `useCache: false`.
|
|
142
|
-
|
|
143
|
-
**Risk:** If `customData` is passed to `clone()` (e.g. from the API handler), skipping validation could allow bad data through. Mitigate by only skipping validation for recursive child clones (not the root item).
|
|
144
|
-
|
|
145
|
-
#### 7. Batch or skip `updateCourseTimestamp` during clone — save ~60 reads + 60 writes
|
|
146
|
-
|
|
147
|
-
The authored module's `preInsertHook` updates the course config's `updatedAt` on every single insert. During a 32-item clone, the config timestamp is written 32 times to values ~1ms apart — 31 are wasted.
|
|
148
|
-
|
|
149
|
-
Options:
|
|
150
|
-
- Add an `options.skipTimestamp` flag to the authored module
|
|
151
|
-
- Or set the timestamp once at the end of the top-level clone
|
|
152
|
-
- Or accept it as an architectural cost of the hook system (least disruptive)
|
|
153
|
-
|
|
154
|
-
#### 8. Use projections for read-only lookups during clone — save ~30-50% data transfer
|
|
155
|
-
|
|
156
|
-
Several `find` calls in the clone path return full documents when only a few fields are needed. See the MongoDB Projections section in the general audit for specific call sites. The highest-impact projection during clone is the children query (line 272): currently fetches full documents but only uses `_id`.
|
|
157
|
-
|
|
158
|
-
### Projected savings summary
|
|
159
|
-
|
|
160
|
-
| Optimisation | Reads saved | Writes saved | Complexity |
|
|
161
|
-
|-------------|-------------|-------------|------------|
|
|
162
|
-
| Disable `updateEnabledPlugins` during clone | ~62 | 0 | Two-line fix |
|
|
163
|
-
| Disable `updateSortOrder` during clone | ~30 | 0 | Two-line fix |
|
|
164
|
-
| Pass parent doc through recursion | ~30 | 0 | Signature change |
|
|
165
|
-
| ContentTree for child lookups | ~5 | 0 | Moderate refactor |
|
|
166
|
-
| Parallelise sibling clones | 0 (wall-clock only) | 0 | Small refactor |
|
|
167
|
-
| Skip validation for child clones | ~32 | 0 | Option pass-through |
|
|
168
|
-
| Batch/skip `updateCourseTimestamp` | ~30 | ~30 | Cross-module change |
|
|
169
|
-
| **Total** | **~189** | **~30** | |
|
|
170
|
-
|
|
171
|
-
**Current: ~434 DB ops. After all optimisations: ~215 DB ops (~50% reduction) with ~4-5x wall-clock improvement from parallelism.**
|
|
172
|
-
|
|
173
|
-
The theoretical minimum (one bulk read + N inserts + one config update) is ~34 ops, but reaching that would require bypassing the hook system entirely, which would break authored timestamps, multilang, spoor tracking, and courseassets.
|
|
174
|
-
|
|
175
|
-
---
|
|
176
|
-
|
|
177
|
-
## Proposal: ContentTree Abstraction
|
|
178
|
-
|
|
179
|
-
### Problem
|
|
180
|
-
|
|
181
|
-
The content hierarchy (course -> page/menu -> article -> block -> component, plus config) is stored as a flat MongoDB collection with `_parentId` references. Multiple operations need to traverse or query this tree, but there is no shared abstraction — each caller re-implements tree walking with ad-hoc loops, filters, and repeated DB fetches.
|
|
182
|
-
|
|
183
|
-
Key pain points across the codebase:
|
|
184
|
-
|
|
185
|
-
| Operation | Current approach | DB cost |
|
|
186
|
-
|-----------|-----------------|---------|
|
|
187
|
-
| `getDescendants` (delete) | Full course fetch, BFS in JS with O(n²) filter loop | 1 full-course query, quadratic in-memory scan |
|
|
188
|
-
| `clone` recursion | One `find({ _parentId })` per tree level, sequential | N+1 queries (one per depth level) |
|
|
189
|
-
| `updateEnabledPlugins` | Full course fetch to scan component types | 1 full-course query per insert/update/delete |
|
|
190
|
-
| `AdaptFrameworkBuild.sortContentItems` | Walks `_parentId` chain per item to build sort string | 0 extra (already loaded), but O(n×d) where d=depth |
|
|
191
|
-
| `AdaptFrameworkImport.getSortedData` | Builds adjacency list, BFS level-sort | 0 extra (already loaded) |
|
|
192
|
-
| Frontend `useProjectContent` + `buildTree` | Bulk fetch all content, build tree client-side with Map | 0 (client-side after initial load) |
|
|
193
|
-
|
|
194
|
-
The import module (`AdaptFrameworkImport.getSortedData`) is the only place in the codebase that constructs a proper adjacency-list tree structure. Everywhere else, the tree is walked by scanning flat arrays.
|
|
195
|
-
|
|
196
|
-
### Proposal
|
|
197
|
-
|
|
198
|
-
Introduce a `ContentTree` utility class that takes a flat array of content items (from a single course) and provides efficient tree operations. This would be a pure data structure — no DB access — so it can be used both after a single `find({ _courseId })` call on the backend and potentially on the frontend too.
|
|
199
|
-
|
|
200
|
-
```js
|
|
201
|
-
class ContentTree {
|
|
202
|
-
constructor (items) {
|
|
203
|
-
this.items = items
|
|
204
|
-
this.byId = new Map() // _id -> item
|
|
205
|
-
this.byParent = new Map() // _parentId -> [children]
|
|
206
|
-
this.byType = new Map() // _type -> [items]
|
|
207
|
-
this.course = null
|
|
208
|
-
this.config = null
|
|
209
|
-
|
|
210
|
-
for (const item of items) {
|
|
211
|
-
const id = item._id.toString()
|
|
212
|
-
this.byId.set(id, item)
|
|
213
|
-
|
|
214
|
-
const parentId = item._parentId?.toString()
|
|
215
|
-
if (parentId) {
|
|
216
|
-
if (!this.byParent.has(parentId)) this.byParent.set(parentId, [])
|
|
217
|
-
this.byParent.get(parentId).push(item)
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
const type = item._type
|
|
221
|
-
if (!this.byType.has(type)) this.byType.set(type, [])
|
|
222
|
-
this.byType.get(type).push(item)
|
|
223
|
-
|
|
224
|
-
if (type === 'course') this.course = item
|
|
225
|
-
if (type === 'config') this.config = item
|
|
226
|
-
}
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
// O(1) lookup
|
|
230
|
-
getById (id) {
|
|
231
|
-
return this.byId.get(id.toString())
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
// O(1) children lookup (already sorted by _sortOrder if source was sorted)
|
|
235
|
-
getChildren (parentId) {
|
|
236
|
-
return this.byParent.get(parentId.toString()) ?? []
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
// O(1) type lookup
|
|
240
|
-
getByType (type) {
|
|
241
|
-
return this.byType.get(type) ?? []
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
// O(n) where n = number of descendants (not total course size)
|
|
245
|
-
getDescendants (rootId) {
|
|
246
|
-
const descendants = []
|
|
247
|
-
const queue = [rootId.toString()]
|
|
248
|
-
while (queue.length) {
|
|
249
|
-
const children = this.byParent.get(queue.shift()) ?? []
|
|
250
|
-
for (const child of children) {
|
|
251
|
-
descendants.push(child)
|
|
252
|
-
queue.push(child._id.toString())
|
|
253
|
-
}
|
|
254
|
-
}
|
|
255
|
-
return descendants
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
// O(d) where d = depth
|
|
259
|
-
getAncestors (itemId) {
|
|
260
|
-
const ancestors = []
|
|
261
|
-
let current = this.byId.get(itemId.toString())
|
|
262
|
-
while (current?._parentId) {
|
|
263
|
-
current = this.byId.get(current._parentId.toString())
|
|
264
|
-
if (current) ancestors.push(current)
|
|
265
|
-
}
|
|
266
|
-
return ancestors
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
// O(1)
|
|
270
|
-
getSiblings (itemId) {
|
|
271
|
-
const item = this.byId.get(itemId.toString())
|
|
272
|
-
if (!item?._parentId) return []
|
|
273
|
-
return this.getChildren(item._parentId).filter(c => c._id.toString() !== itemId.toString())
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
// O(1) — all components across the course
|
|
277
|
-
getComponentNames () {
|
|
278
|
-
return [...new Set(this.getByType('component').map(c => c._component))]
|
|
279
|
-
}
|
|
280
|
-
}
|
|
281
|
-
```
|
|
282
|
-
|
|
283
|
-
### Impact on existing code
|
|
284
|
-
|
|
285
|
-
**`getDescendants` (ContentModule.js:159-173)** — currently fetches the full course and does O(n²) BFS. Would become:
|
|
286
|
-
```js
|
|
287
|
-
async getDescendants (rootItem) {
|
|
288
|
-
const items = await this.find({ _courseId: rootItem._courseId })
|
|
289
|
-
const tree = new ContentTree(items)
|
|
290
|
-
const descendants = tree.getDescendants(rootItem._id)
|
|
291
|
-
if (rootItem._type === 'course' && tree.config) {
|
|
292
|
-
descendants.push(tree.config)
|
|
293
|
-
}
|
|
294
|
-
return descendants
|
|
295
|
-
}
|
|
296
|
-
```
|
|
297
|
-
Still one full-course fetch, but the in-memory traversal drops from O(n²) to O(n).
|
|
298
|
-
|
|
299
|
-
**`clone` (ContentModule.js:237-279)** — currently issues one `find({ _parentId })` query per depth level. Could pre-fetch the subtree once:
|
|
300
|
-
```js
|
|
301
|
-
const items = await this.find({ _courseId: originalDoc._courseId })
|
|
302
|
-
const tree = new ContentTree(items)
|
|
303
|
-
// then use tree.getChildren(id) in the recursive loop instead of this.find({ _parentId })
|
|
304
|
-
```
|
|
305
|
-
This eliminates N+1 DB queries down to 1.
|
|
306
|
-
|
|
307
|
-
**`updateEnabledPlugins` (ContentModule.js:310-352)** — currently fetches the full course to scan for component types. Could accept a pre-built tree or build one:
|
|
308
|
-
```js
|
|
309
|
-
const tree = new ContentTree(await this.find({ _courseId }))
|
|
310
|
-
const componentNames = tree.getComponentNames()
|
|
311
|
-
const config = tree.config
|
|
312
|
-
```
|
|
313
|
-
Same DB cost but cleaner code, and the tree could be passed from the caller if already available.
|
|
314
|
-
|
|
315
|
-
**`delete` (ContentModule.js:134-152)** — the biggest win from tree sharing. Currently `getDescendants`, `updateEnabledPlugins`, and `updateSortOrder` each fetch data independently. With a tree:
|
|
316
|
-
```js
|
|
317
|
-
async delete (query, options = {}, mongoOptions) {
|
|
318
|
-
this.setDefaultOptions(options)
|
|
319
|
-
const [targetDoc] = await this.find(query)
|
|
320
|
-
if (!targetDoc) {
|
|
321
|
-
throw this.app.errors.NOT_FOUND.setData({ type: options.schemaName, id: JSON.stringify(query) })
|
|
322
|
-
}
|
|
323
|
-
const items = await this.find({ _courseId: targetDoc._courseId })
|
|
324
|
-
const tree = new ContentTree(items)
|
|
325
|
-
const descendants = tree.getDescendants(targetDoc._id)
|
|
326
|
-
if (targetDoc._type === 'course' && tree.config) descendants.push(tree.config)
|
|
327
|
-
// single deleteMany instead of N individual deletes
|
|
328
|
-
await super.deleteMany({ _id: { $in: [...descendants, targetDoc].map(d => d._id) } })
|
|
329
|
-
// pass tree to avoid redundant fetches
|
|
330
|
-
await Promise.all([
|
|
331
|
-
this.updateEnabledPlugins(targetDoc, { tree }),
|
|
332
|
-
this.updateSortOrder(targetDoc)
|
|
333
|
-
])
|
|
334
|
-
return [targetDoc, ...descendants]
|
|
335
|
-
}
|
|
336
|
-
```
|
|
337
|
-
This reduces the delete path from ~2N+2 DB queries to ~3 (find target, find course, deleteMany).
|
|
338
|
-
|
|
339
|
-
**`AdaptFrameworkBuild.sortContentItems` (AdaptFrameworkBuild.js:335-354)** — currently builds a sort-order string by walking `_parentId` chain per item. Could use `tree.getAncestors()` for clarity, though the performance difference is marginal since it's already in-memory.
|
|
340
|
-
|
|
341
|
-
**`AdaptFrameworkImport.getSortedData` (AdaptFrameworkImport.js:734-753)** — already builds its own adjacency list. Could be replaced by `ContentTree` directly, consolidating the only existing tree-building code.
|
|
342
|
-
|
|
343
|
-
### What this does NOT replace
|
|
344
|
-
|
|
345
|
-
- DB queries themselves — `ContentTree` is a post-query optimisation, not a cache layer
|
|
346
|
-
- Schema resolution logic — that has its own concerns beyond tree structure
|
|
347
|
-
|
|
348
|
-
Note: the frontend (adapt-authoring-ui2) already implements its own `buildTree()` function in `useProjectContent.js` that constructs a nearly identical data structure (Map + nested children arrays) from the flat API response. A shared `ContentTree` class could potentially be used on both sides, or the server could return the tree pre-built via a dedicated endpoint (see Tree API Endpoint below).
|
|
349
|
-
|
|
350
|
-
### Trade-offs
|
|
351
|
-
|
|
352
|
-
**For:**
|
|
353
|
-
- Eliminates the N+1 query pattern in `clone` (biggest performance win)
|
|
354
|
-
- Replaces O(n²) in-memory scans with O(n) lookups via Maps
|
|
355
|
-
- Single place to encode tree semantics (parent-child, type hierarchy, config special-casing)
|
|
356
|
-
- Reusable across `delete`, `clone`, `updateEnabledPlugins`, and framework build/import
|
|
357
|
-
- Pure data structure with no side effects — easy to test
|
|
358
|
-
- Enables tree sharing within a single request (e.g. `delete` builds the tree once and passes it to `updateEnabledPlugins`, eliminating 1-2 redundant full-course fetches per operation)
|
|
359
|
-
|
|
360
|
-
**Against:**
|
|
361
|
-
- Adds a new abstraction to a small module (currently just 2 files in `lib/`)
|
|
362
|
-
- The tree is a snapshot — it goes stale if content is modified between construction and use (relevant during `clone` which inserts as it traverses)
|
|
363
|
-
- Some operations (like `updateSortOrder`) only need siblings, not the full tree — fetching the full course to build a tree may be overkill for targeted queries
|
|
364
|
-
- Risk of over-engineering if the module doesn't grow further
|
|
365
|
-
|
|
366
|
-
### Recommendation
|
|
367
|
-
|
|
368
|
-
**Worth implementing**, primarily for the `clone` N+1 elimination and `getDescendants` O(n²) fix. These are the two highest-impact changes and both become straightforward with a tree abstraction. The class itself is ~50 lines with no dependencies, so the abstraction cost is low.
|
|
369
|
-
|
|
370
|
-
Start with `getDescendants` and `clone` as the first consumers. Extend to `delete` (for tree sharing with `updateEnabledPlugins`) and the framework build/import only if those methods are being refactored anyway.
|
|
371
|
-
|
|
372
|
-
---
|
|
373
|
-
|
|
374
|
-
## Tree API Endpoint
|
|
375
|
-
|
|
376
|
-
### Current frontend architecture (adapt-authoring-ui2)
|
|
377
|
-
|
|
378
|
-
The frontend is a React 19 SPA using TanStack React Query v5 for data fetching/caching, MUI v7 for components, and React Router DOM v7 for routing. There is no Redux or other global state — React Query is the exclusive server-state layer.
|
|
379
|
-
|
|
380
|
-
The `useProjectContent(courseId)` hook loads **all content for a course** in a single bulk `POST /api/content/query` with `{ _courseId }`. Every field of every document is returned — there is no field projection. The flat array is then transformed client-side by `buildTree()` into a nested tree structure:
|
|
381
|
-
|
|
382
|
-
```js
|
|
383
|
-
// useProjectContent.js — buildTree()
|
|
384
|
-
function buildTree(data) {
|
|
385
|
-
const flatMap = new Map()
|
|
386
|
-
data.forEach(item => flatMap.set(item._id, { ...item, children: [] }))
|
|
387
|
-
const roots = []
|
|
388
|
-
flatMap.forEach(item => {
|
|
389
|
-
if (isRootType(item._type)) roots.push(item)
|
|
390
|
-
else if (item._parentId && flatMap.has(item._parentId))
|
|
391
|
-
flatMap.get(item._parentId).children.push(item)
|
|
392
|
-
})
|
|
393
|
-
// sort children by _sortOrder at each level
|
|
394
|
-
roots.forEach(sortChildren)
|
|
395
|
-
return { tree: roots, flatMap }
|
|
396
|
-
}
|
|
397
|
-
```
|
|
398
|
-
|
|
399
|
-
The hook returns both `tree` (nested roots with `.children` arrays) and `flatMap` (`Map<_id, item>` for O(1) lookup). This is rebuilt via `useMemo` on every data change.
|
|
400
|
-
|
|
401
|
-
**Staleness and refresh:** React Query's `staleTime` is 5 minutes, `cacheTime` is 30 minutes, `refetchOnWindowFocus` is false. After any mutation (add, edit, delete, move), `useApiMutation` calls `queryClient.invalidateQueries({ queryKey: ['content'] })`, which triggers a **full re-fetch of all content** for the course. There are no optimistic updates or partial invalidation.
|
|
402
|
-
|
|
403
|
-
### What the frontend actually uses for tree operations
|
|
404
|
-
|
|
405
|
-
| Operation | Fields needed |
|
|
406
|
-
|-----------|--------------|
|
|
407
|
-
| Tree rendering (`ContentCards`) | `_id`, `_parentId`, `_type`, `_sortOrder`, `title`, `displayTitle`, `_component`, `_layout` |
|
|
408
|
-
| Move / pick-and-place | `_id`, `_parentId`, `_sortOrder`, `_layout` |
|
|
409
|
-
| Breadcrumb navigation | `_id`, `_parentId`, `_type`, `title` |
|
|
410
|
-
| Extension management | `_type`, `_enabledPlugins`, `title` |
|
|
411
|
-
| Full editing (schema form open) | All fields (fetched separately via `api.get(id)`) |
|
|
412
|
-
|
|
413
|
-
The tree-structural fields are ~8 fields. A typical content document has 30-80+ fields (all plugin extension properties, body text, graphics, etc.). For a 100-item course, the full payload is ~300-500KB; a tree projection would be ~10-20KB — a **15-30x reduction**.
|
|
414
|
-
|
|
415
|
-
### Why a tree endpoint is particularly well-suited to this architecture
|
|
416
|
-
|
|
417
|
-
React Query's query key system is designed for exactly this kind of data splitting:
|
|
418
|
-
|
|
419
|
-
```js
|
|
420
|
-
// Tree data — lightweight, frequently read, rarely structurally changed
|
|
421
|
-
useQuery({ queryKey: ['content', 'tree', courseId], queryFn: () => api.get(`tree/${courseId}`) })
|
|
422
|
-
|
|
423
|
-
// Item detail — full document, fetched on demand when editing
|
|
424
|
-
useQuery({ queryKey: ['content', 'item', itemId], queryFn: () => api.get(itemId) })
|
|
425
|
-
```
|
|
426
|
-
|
|
427
|
-
This enables **selective invalidation**: editing a content item's body text only invalidates the `['content', 'item', itemId]` query — the tree stays cached because the structure hasn't changed. Only structural mutations (add, delete, move) would invalidate the tree query. Currently, every single edit re-fetches every document in the course.
|
|
428
|
-
|
|
429
|
-
The frontend already has the `buildTree()` function that constructs an adjacency-list tree identical in concept to the proposed server-side `ContentTree`. If the server returned a pre-built tree (or a projected flat array), the client-side `buildTree` would either be simplified or eliminated entirely.
|
|
430
|
-
|
|
431
|
-
### What the API currently supports
|
|
432
|
-
|
|
433
|
-
The `/query` endpoint supports `sort`, `limit`, `skip`, `page`, and `collation` as query params. It does **not** support field projection — the `queryHandler` only extracts those five options from the URL.
|
|
434
|
-
|
|
435
|
-
The MongoDB driver and `MongoDBModule` fully support projections internally, but there is no way to request them through the HTTP API.
|
|
436
|
-
|
|
437
|
-
### Two options
|
|
438
|
-
|
|
439
|
-
**Option A: Add projection support to the existing `/query` endpoint**
|
|
440
|
-
|
|
441
|
-
Add `projection` to the list of recognised query params in `AbstractApiModule.queryHandler()`. This is a small change that benefits all API modules:
|
|
442
|
-
|
|
443
|
-
```js
|
|
444
|
-
// In queryHandler, add 'projection' to the allowed params
|
|
445
|
-
if (['collation', 'limit', 'page', 'projection', 'skip', 'sort'].includes(key)) {
|
|
446
|
-
mongoOpts[key] = JSON.parse(req.apiData.query[key])
|
|
447
|
-
}
|
|
448
|
-
```
|
|
449
|
-
|
|
450
|
-
Usage: `POST /api/content/query?projection={"_id":1,"_parentId":1,"_type":1,"_sortOrder":1,"title":1}`
|
|
451
|
-
|
|
452
|
-
**For:** Generic, benefits all modules. No new routes or handlers. Frontend can adopt immediately.
|
|
453
|
-
**Against:** Exposes raw MongoDB projection syntax to API consumers. Harder to cache/optimise server-side. No semantic meaning — callers must know which fields they need.
|
|
454
|
-
|
|
455
|
-
**Option B: Dedicated `GET /api/content/tree/:_courseId` endpoint**
|
|
456
|
-
|
|
457
|
-
A purpose-built route that returns a tree-optimised projection:
|
|
458
|
-
|
|
459
|
-
```js
|
|
460
|
-
// In setValues()
|
|
461
|
-
this.routes.push({
|
|
462
|
-
route: '/tree/:_courseId',
|
|
463
|
-
handlers: { get: this.handleTree.bind(this) },
|
|
464
|
-
permissions: { get: ['read:content'] },
|
|
465
|
-
meta: apidefs.tree
|
|
466
|
-
})
|
|
467
|
-
|
|
468
|
-
// Handler
|
|
469
|
-
async handleTree (req, res, next) {
|
|
470
|
-
try {
|
|
471
|
-
const items = await this.find(
|
|
472
|
-
{ _courseId: req.params._courseId },
|
|
473
|
-
{ validate: false },
|
|
474
|
-
{ projection: { _id: 1, _parentId: 1, _courseId: 1, _type: 1, _sortOrder: 1, title: 1, displayTitle: 1, _component: 1, _layout: 1 } }
|
|
475
|
-
)
|
|
476
|
-
res.json(items)
|
|
477
|
-
} catch (e) {
|
|
478
|
-
return next(e)
|
|
479
|
-
}
|
|
480
|
-
}
|
|
481
|
-
```
|
|
482
|
-
|
|
483
|
-
**For:** Semantic, self-documenting. Easy to cache aggressively (tree structure changes less often than content). Could return a pre-built nested tree structure (eliminating client-side `buildTree`). Clear versioning boundary. Can be extended with `updatedAt` per item for fine-grained staleness checks.
|
|
484
|
-
**Against:** New endpoint to maintain. Requires coordinated frontend change.
|
|
485
|
-
|
|
486
|
-
### Where a tree endpoint would have real value
|
|
487
|
-
|
|
488
|
-
1. **Selective invalidation** — the highest-impact change. Currently every mutation invalidates and re-fetches all content (~300-500KB). With separate tree and item-detail queries, editing a field only re-fetches that one item (~1-5KB). Only structural changes (add/delete/move) invalidate the tree (~10-20KB). For a 100-item course, this reduces the data re-fetched per edit by ~99%.
|
|
489
|
-
|
|
490
|
-
2. **Faster initial load** — the tree endpoint returns ~10-20KB instead of ~300-500KB. The `ContentCards` tree view can render immediately. Full item data is fetched lazily when the user opens an edit form. This is the natural React Query pattern.
|
|
491
|
-
|
|
492
|
-
3. **Lightweight structural refresh** — instead of re-fetching all content after structural mutations, re-fetch only the tree. The frontend can diff the new tree against the cached one to identify which items changed (new, deleted, moved).
|
|
493
|
-
|
|
494
|
-
4. **Server-side outline** — useful for features that need a quick structural overview (analytics, course listing with item counts, a table-of-contents panel, multi-course operations).
|
|
495
|
-
|
|
496
|
-
5. **Alignment with existing client-side code** — `useProjectContent.buildTree()` already constructs this exact data structure client-side. A tree endpoint makes this server-authoritative rather than duplicated.
|
|
497
|
-
|
|
498
|
-
### Recommendation
|
|
499
|
-
|
|
500
|
-
**Both options should be implemented, in order:**
|
|
501
|
-
|
|
502
|
-
**Step 1: Option A (projection on `/query`)** — a small, low-risk change to `AbstractApiModule` that immediately benefits all internal projection use cases (see MongoDB Projections section in the general audit) and lets the frontend start requesting lighter payloads. This is useful even without a dedicated tree endpoint.
|
|
503
|
-
|
|
504
|
-
**Step 2: Option B (dedicated `/tree` endpoint)** — build this alongside the frontend refactor to split `useProjectContent` into two queries (tree + item detail). The server-side `ContentTree` class proposed above could serve double duty: used internally for `clone`/`delete`/`updateEnabledPlugins` optimisations, and also used to generate the tree endpoint response.
|
|
505
|
-
|
|
506
|
-
The frontend changes to consume the tree endpoint are straightforward — `useProjectContent` would be refactored into `useProjectTree` (lightweight, frequently refreshed) and `useContentItem(id)` (full document, fetched on demand). React Query's architecture makes this a natural split, and the `buildTree` function can be dropped or simplified since the server would return the data in tree-ready form.
|