Conversation
…refs #79) - Fix INVALID_PARENT error to return 400 instead of 500 - Fix clone() error message providing type: undefined - Fix delete() missing default options parameter - Fix insertRecursive not checking for missing parent - Fix updateSortOrder splice logic for undefined _sortOrder - Cache contentplugin, jsonschema, authored, tags module refs in init() - Replace quadratic reduce with spread with flatMap in getSchema - Use destructuring instead of payload mutation in clone() - Remove unused UNKNOWN_SCHEMA_NAME error definition - Simplify unit tests to pure logic only, move orchestration tests to adapt-authoring-integration-tests
…efs #79) Only call updateSortOrder when _sortOrder or _parentId changes, and updateEnabledPlugins when _component, _menu, _theme, or _enabledPlugins changes. Accept optional contentItems parameter in updateEnabledPlugins to skip redundant full-course fetch; used in delete() which already has a tree.
Reduces data transfer by projecting only needed fields: - getSchemaName: _type, _component - getSchema: _courseId and _enabledPlugins - updateSortOrder: _id, _sortOrder - updateEnabledPlugins: 6 fields instead of full docs - contentplugin.find: name only - clone parent lookup: _id, _type, _courseId
Use super.find in updateSortOrder and updateEnabledPlugins to bypass hooks for internal bookkeeping queries. Add class-level JSDoc documenting the convention: this.* for user-facing operations (triggers hooks), super.* for internal bookkeeping (avoids infinite recursion).
Replace find()[0] and [doc] = find() patterns with findOne() where a single result is expected. Use throwOnMissing: false where the caller handles the missing case, and default throwOnMissing: true where not-found should throw. Also replace deprecated strict option with throwOnMissing.
Replace N individual super.delete() calls for descendants with a single super.deleteMany() bulk operation. The target doc is still deleted via super.delete() to trigger the deleteHook middleware (required by multilang for peer cascade deletion). Pre/post delete hooks still fire per item via deleteMany for courseassets cleanup and authored timestamps. For a 200-item course delete, this reduces ~400 DB round-trips to ~4.
- Rollback: track created items during clone and clean up on failure, matching the pattern in insertRecursive - Input validation: validate _id is present in handleClone before proceeding - Performance: parallelise sibling clones with Promise.all instead of sequential loop Remaining items from #111 (require cross-module changes): - Batch updateCourseTimestamp during clone (authored module) - Fragile course double-write pattern (mitigated by validate: false)
- Remove silent error swallowing in getSchema — errors from getSchemaName and config lookup now propagate, preventing silent fallback to base schema which could strip plugin-specific fields - Eliminate double-fetch: getSchemaName now also fetches _courseId when looking up a document, attaching it to data so getSchema skips the redundant query - Add schema cache keyed by schemaName + sorted enabledPluginSchemas, avoiding expensive schema build/compile on repeated calls with the same plugin set. Cache is cleared on schema re-registration.
Replaces the non-atomic read-max-then-increment approach with an atomic counter using findOneAndUpdate/$inc, fixing duplicate _friendlyId errors during parallel clone inserts. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Content now imports parseObjectId directly, so it's a real dependency. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
… opt-out in delete
Replace queue.shift() with index pointer in BFS, cache toString() in getSiblings, and avoid intermediate array in getComponentNames.
Replace raw contentItems array with ContentTree for O(1) type lookups, use Set-based equality checks, add defensive nullish coalescing on config._enabledPlugins, simplify reduce chains with flatMap and Set loop, add optional chaining on schema lookup, document type guard relationship with callers, and batch updateSortOrder writes into a single bulkWrite.
…e (refs #109) Use super.find with projection in delete for tree building, defer updateSortOrder and updateEnabledPlugins to end of insertRecursive, remove redundant updateEnabledPlugins from top-level clone insert, and use deleteMany for clone error cleanup.
Removed unnecessary comments and simplified tree initialization.
- Extract formatFriendlyId, parseMaxSeq, contentTypeToSchemaName, computeSortOrderOps to lib/utils/ - Skip updateEnabledPlugins in clone for same-course copies - Add unit tests for all extracted utilities (30 tests)
Reserves a range of sequence numbers in a single atomic counter increment, enabling batch ID allocation for clone and insertRecursive operations.
Walk the source tree upfront to count items by type, then reserve all friendly IDs in one atomic counter increment per type. Eliminates per-item counter round-trips during recursive clone operations.
There was a problem hiding this comment.
Pull request overview
This PR introduces a new ContentTree abstraction and a lightweight /tree/:_courseId API to improve content traversal performance, while also refactoring content cloning/deletion to reduce DB round-trips and adding bulk-friendly ID generation plus _assetIds tracking.
Changes:
- Added
ContentTreewith O(1) indexes and tree operations, and exposed it publicly. - Added
GET /api/content/tree/:_courseIdwith projections +If-Modified-Sinceconditional 304 support. - Refactored
ContentModulefor performance (bulk clone, batched sort order updates, projections, counters, asset reference tracking) and extracted several utilities intolib/utils/.
Reviewed changes
Copilot reviewed 23 out of 23 changed files in this pull request and generated 9 comments.
Show a summary per file
| File | Description |
|---|---|
| lib/ContentModule.js | Major refactor: schema caching, bulk friendly IDs/counters, _assetIds tracking, optimized delete, bulk clone, new tree handler |
| lib/ContentTree.js | New tree/index abstraction over flat content arrays |
| lib/utils.js | Re-exports new utilities and ContentTree |
| lib/utils/computeSortOrderOps.js | New helper to compute bulkWrite ops for _sortOrder recalculation |
| lib/utils/contentTypeToSchemaName.js | Centralized _type → schema name mapping |
| lib/utils/formatFriendlyId.js | New friendly ID formatter utility |
| lib/utils/parseMaxSeq.js | New helper to parse max sequence from existing friendly IDs |
| lib/utils/extractAssetIds.js | New helper to derive _assetIds via schema walking |
| routes.json | Adds /tree/:_courseId route + OpenAPI-like metadata |
| schema/contentassets.schema.json | Adds schema extension for _assetIds |
| migrations/3.0.0.js | Backfills _friendlyId and _assetIds on existing content docs |
| errors/errors.json | Adjusts INVALID_PARENT; adds RESOURCE_IN_USE |
| package.json | Adds adapt-authoring-mongodb dependency; updates peers |
| index.js | Exports ContentTree from package entry |
| tests/* | Adds unit tests for new utilities and ContentTree; refactors ContentModule tests; removes getDescendants tests |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
lib/ContentModule.js
Outdated
| const topItem = newItems[0] | ||
| await Promise.all([ | ||
| this.updateSortOrder(topItem, topItem), | ||
| this.updateEnabledPlugins(topItem) |
There was a problem hiding this comment.
In insertRecursive(), the post-loop side effects run against topItem (often course for new courses, or article/page/menu for non-course roots). With the new guard in updateEnabledPlugins() (skips non-component/config unless forceUpdate), this call will typically no-op, leaving config _enabledPlugins stale even though a component/config was created. Consider invoking updateEnabledPlugins with { forceUpdate: true } and/or passing a doc whose _type is config/component (or removing the _type guard for this internal call).
| this.updateEnabledPlugins(topItem) | |
| this.updateEnabledPlugins(topItem, { forceUpdate: true }) |
lib/ContentModule.js
Outdated
| const nextPlugins = new Set([ | ||
| ...[...currentPlugins].filter(name => extensionNames.includes(name)), // only extensions, rest are calculated below | ||
| ...componentNames, | ||
| config._menu, | ||
| config._theme | ||
| ])) | ||
| ]) |
There was a problem hiding this comment.
nextPlugins is constructed with config._menu and config._theme unconditionally. If either is undefined/null, it will be included in the Set and written back into config._enabledPlugins, which can introduce invalid entries. Consider filtering out falsy values before adding menu/theme (and similarly ensure tree.getComponentNames() doesn’t contribute undefineds).
| delete payload._id | ||
| delete payload._courseId | ||
| await this.update({ _id: newData._id }, payload, { validate: false }) | ||
|
|
There was a problem hiding this comment.
Clone builds payloads directly and inserts via insertMany, bypassing insert() where _assetIds are computed/recomputed. If _assetIds is missing on the source docs (or if customData changes any asset-referencing fields), the cloned docs may end up with incorrect _assetIds, which can break the new asset deletion protection. Consider recomputing _assetIds for each payload (or at least the root payload when applying customData) before insert.
| // Recompute _assetIds for each cloned payload to ensure asset-deletion protection remains accurate | |
| for (const payload of payloads) { | |
| payload._assetIds = extractAssetIds(payload) | |
| } |
| async handleTree (req, res, next) { | ||
| try { | ||
| const _courseId = req.apiData.query._courseId | ||
| const course = await this.findOne( | ||
| { _type: 'course', _courseId }, | ||
| { validate: false }, | ||
| { projection: { updatedAt: 1 } } | ||
| ) | ||
| const lastModified = new Date(course.updatedAt) | ||
| const ifModifiedSince = req.headers['if-modified-since'] && new Date(req.headers['if-modified-since']) | ||
| if (ifModifiedSince && lastModified <= ifModifiedSince) { | ||
| return res.status(304).end() | ||
| } | ||
| const items = await this.find( | ||
| { _courseId }, | ||
| { validate: false }, | ||
| { projection: { _id: 1, _parentId: 1, _courseId: 1, _type: 1, _sortOrder: 1, title: 1, displayTitle: 1, _component: 1, _layout: 1, _menu: 1, _theme: 1, _enabledPlugins: 1, updatedAt: 1 } } | ||
| ) | ||
| const tree = new ContentTree(items) | ||
| res.set('Last-Modified', lastModified.toUTCString()) | ||
| res.json(items.map(item => ({ | ||
| ...item, | ||
| _children: tree.getChildren(item._id).map(c => c._id) | ||
| }))) |
There was a problem hiding this comment.
The new /tree/:_courseId endpoint handler isn’t covered by unit tests (including If-Modified-Since → 304 behavior, projection correctness, and _children construction). Given this is a new public API surface, consider adding unit tests to lock down response shape and conditional request semantics.
| const courseIds = [...new Set(usedBy.map(d => d._courseId?.toString()).filter(Boolean))] | ||
| const courses = (await this.find( | ||
| { _type: 'course', _id: { $in: courseIds } }, | ||
| { validate: false }, | ||
| { projection: { title: 1, displayTitle: 1 } } |
There was a problem hiding this comment.
The asset pre-delete check builds courseIds as strings (toString()), then queries courses via { _id: { $in: courseIds } }. If _id is stored as ObjectId (which this module often assumes via parseObjectId()), this query will return no courses and the error payload will be missing course titles. Consider converting courseIds to ObjectIds (e.g. via parseObjectId/convertObjectIds) before querying.
| let friendlyId | ||
| if (isCourse) friendlyId = item._friendlyId | ||
| else if (isConfig) friendlyId = formatFriendlyId('config') | ||
| else friendlyId = friendlyIds.get(item._type)?.shift() | ||
|
|
There was a problem hiding this comment.
Using Array.prototype.shift() to consume preallocated friendly IDs makes payload generation O(n²) per type (shift is linear). For large clones this can become a noticeable CPU cost. Consider tracking an index per type (or popping from the end) instead of shifting from the front.
| it('inserts at beginning when _sortOrder is 1', () => { | ||
| const siblings = [ | ||
| { _id: 'a', _sortOrder: 1 }, | ||
| { _id: 'b', _sortOrder: 2 } | ||
| ] | ||
| const item = { _id: 'new', _sortOrder: 0 } | ||
| const ops = computeSortOrderOps(siblings, item) | ||
| // _sortOrder - 1 = -1, which is not > -1, so appends to end |
There was a problem hiding this comment.
This test name doesn’t match the scenario/assertion: it says "inserts at beginning when _sortOrder is 1" but the item uses _sortOrder: 0 and the comment/assertion expects an append-to-end behavior. Consider renaming the test (or adjusting the input) so the intent matches the behavior being validated.
| it('inserts at beginning when _sortOrder is 1', () => { | |
| const siblings = [ | |
| { _id: 'a', _sortOrder: 1 }, | |
| { _id: 'b', _sortOrder: 2 } | |
| ] | |
| const item = { _id: 'new', _sortOrder: 0 } | |
| const ops = computeSortOrderOps(siblings, item) | |
| // _sortOrder - 1 = -1, which is not > -1, so appends to end | |
| it('appends item to end when _sortOrder is 0', () => { | |
| const siblings = [ | |
| { _id: 'a', _sortOrder: 1 }, | |
| { _id: 'b', _sortOrder: 2 } | |
| ] | |
| const item = { _id: 'new', _sortOrder: 0 } | |
| const ops = computeSortOrderOps(siblings, item) | |
| // _sortOrder 0 is treated as append-to-end: result [a, b, new] with new having _sortOrder 3 |
| async clone (userId, _id, _parentId, customData = {}, options = {}) { | ||
| const [originalDoc] = await this.find({ _id }) | ||
| let { tree, parent } = options | ||
|
|
||
| const originalDoc = tree | ||
| ? tree.getById(_id) | ||
| : await this.findOne({ _id }) | ||
| if (!originalDoc) { | ||
| throw this.app.errors.NOT_FOUND | ||
| .setData({ type: originalDoc?._type, id: _id }) | ||
| .setData({ type: 'content', id: _id }) | ||
| } | ||
| if (options.invokePreHook !== false) await this.preCloneHook.invoke(originalDoc) | ||
|
|
||
| const [parent] = _parentId ? await this.find({ _id: _parentId }) : [] | ||
|
|
||
| if (!parent && _parentId) { | ||
| parent = await this.findOne({ _id: _parentId }, { throwOnMissing: false }, { projection: { _id: 1, _type: 1, _courseId: 1 } }) | ||
| } | ||
| if (!parent && originalDoc._type !== 'course' && originalDoc._type !== 'config') { | ||
| throw this.app.errors.INVALID_PARENT.setData({ parentId: _parentId }) | ||
| } | ||
| const schemaName = originalDoc._type === 'menu' || originalDoc._type === 'page' ? 'contentobject' : originalDoc._type | ||
| const payload = stringifyValues({ | ||
| ...originalDoc, | ||
| _id: undefined, | ||
| _trackingId: undefined, | ||
| _friendlyId: originalDoc._type !== 'course' ? undefined : originalDoc._friendlyId, | ||
| _courseId: parent?._type === 'course' ? parent?._id : parent?._courseId, | ||
| _parentId, | ||
| createdBy: userId, | ||
| ...customData | ||
| if (!tree) { | ||
| const sourceItems = await this.mongodb.find(this.collectionName, { _courseId: originalDoc._courseId }) | ||
| tree = new ContentTree(sourceItems) | ||
| } | ||
|
|
||
| // Collect all items to clone: root, config (if course clone), then all descendants | ||
| const allItems = [originalDoc] | ||
| if (originalDoc._type === 'course' && tree.config) { | ||
| allItems.push(tree.config) | ||
| } | ||
| allItems.push(...tree.getDescendants(_id)) | ||
|
|
||
| if (options.invokePreHook !== false) { | ||
| for (const item of allItems) await this.preCloneHook.invoke(item) | ||
| } | ||
|
|
||
| // Pre-generate ObjectIds for every item (old _id → new _id) | ||
| const idMap = new Map() | ||
| for (const item of allItems) { | ||
| idMap.set(item._id.toString(), createObjectId()) | ||
| } | ||
|
|
||
| const newCourseId = originalDoc._type === 'course' | ||
| ? idMap.get(originalDoc._id.toString()).toString() | ||
| : (parent?._type === 'course' ? parent._id.toString() : parent._courseId.toString()) | ||
|
|
||
| // Pre-allocate friendly IDs in bulk per type | ||
| const typeCounts = new Map() | ||
| for (const item of allItems) { | ||
| if (item._type === 'course' || item._type === 'config') continue | ||
| typeCounts.set(item._type, (typeCounts.get(item._type) ?? 0) + 1) | ||
| } | ||
| const friendlyIds = new Map() | ||
| await Promise.all([...typeCounts].map(async ([_type, count]) => { | ||
| const ids = await this.generateFriendlyIds(_type, newCourseId, count) | ||
| friendlyIds.set(_type, ids) | ||
| })) | ||
|
|
||
| // Build all insert payloads with pre-mapped IDs and parent references | ||
| const rootId = _id.toString() | ||
| const payloads = allItems.map(item => { | ||
| const oldId = item._id.toString() | ||
| const newId = idMap.get(oldId) | ||
| const isCourse = item._type === 'course' | ||
| const isConfig = item._type === 'config' | ||
|
|
||
| let newParentId | ||
| if (oldId === rootId) newParentId = _parentId | ||
| else if (isConfig) newParentId = undefined | ||
| else newParentId = idMap.get(item._parentId?.toString())?.toString() | ||
|
|
||
| let friendlyId | ||
| if (isCourse) friendlyId = item._friendlyId | ||
| else if (isConfig) friendlyId = formatFriendlyId('config') | ||
| else friendlyId = friendlyIds.get(item._type)?.shift() | ||
|
|
||
| return stringifyValues({ | ||
| ...item, | ||
| _id: newId, | ||
| _trackingId: undefined, | ||
| _friendlyId: friendlyId, | ||
| _courseId: isCourse ? newId.toString() : newCourseId, | ||
| _parentId: newParentId, | ||
| createdBy: userId, | ||
| ...(oldId === rootId ? customData : {}) | ||
| }) | ||
| }) | ||
| const newData = await this.insert(payload, { schemaName, validate: false }) | ||
|
|
||
| if (originalDoc._type === 'course') { | ||
| const [config] = await this.find({ _type: 'config', _courseId: originalDoc._courseId }) | ||
| if (config) { | ||
| await this.clone(userId, config._id, undefined, { _courseId: newData._id.toString() }) | ||
| delete payload._id | ||
| delete payload._courseId | ||
| await this.update({ _id: newData._id }, payload, { validate: false }) | ||
|
|
||
| // Fire preInsertHook on each payload (allows observer modules to set timestamps etc.) | ||
| await Promise.all(payloads.map(payload => | ||
| this.preInsertHook.invoke(payload, { schemaName: contentTypeToSchemaName(payload._type), collectionName: this.collectionName }, {}) | ||
| )) | ||
|
|
||
| // Convert string IDs to ObjectId instances and bulk insert in a single round-trip | ||
| const allNewIds = allItems.map(item => idMap.get(item._id.toString())) | ||
| for (const payload of payloads) convertObjectIds(payload) | ||
|
|
||
| const collection = this.mongodb.getCollection(this.collectionName) | ||
| try { | ||
| await collection.insertMany(payloads, { ordered: false }) | ||
| } catch (e) { | ||
| await collection.deleteMany({ _id: { $in: allNewIds } }).catch(() => {}) | ||
| throw e | ||
| } |
There was a problem hiding this comment.
clone() has been substantially rewritten (bulk payload build + insertMany + rollback). There are currently no unit tests covering this new code path (including counter allocation, parent remapping, and rollback on insert failure), so regressions here would be hard to catch. Consider adding focused tests for clone’s core invariants and error cleanup behavior.
lib/utils/formatFriendlyId.js
Outdated
| export default function formatFriendlyId (_type, count, idInterval, _language) { | ||
| if (_type === 'course') return `course-${count}${_language ? `-${_language}` : ''}` | ||
| if (_type === 'config') return 'config' | ||
| return `${_type[0]}-${count * idInterval}` |
There was a problem hiding this comment.
formatFriendlyId() derives the prefix from _type[0], which will format menu IDs as m-{n}. Elsewhere in this PR (e.g. migration logic) menu IDs are treated like pages (p-{n}), so this will generate a different friendlyId format for menus and could break expectations/consistency. Consider using an explicit type→prefix mapping (page+menu→'p', article→'a', block→'b', component→'c') and add a test case for menu.
| export default function formatFriendlyId (_type, count, idInterval, _language) { | |
| if (_type === 'course') return `course-${count}${_language ? `-${_language}` : ''}` | |
| if (_type === 'config') return 'config' | |
| return `${_type[0]}-${count * idInterval}` | |
| const TYPE_PREFIXES = { | |
| page: 'p', | |
| menu: 'p', | |
| article: 'a', | |
| block: 'b', | |
| component: 'c' | |
| } | |
| export default function formatFriendlyId (_type, count, idInterval, _language) { | |
| if (_type === 'course') return `course-${count}${_language ? `-${_language}` : ''}` | |
| if (_type === 'config') return 'config' | |
| const prefix = TYPE_PREFIXES[_type] || _type[0] | |
| return `${prefix}-${count * idInterval}` |
Remove ID interval (was 5, now 1:1 mapping). Migration now imports shared formatFriendlyId and parseMaxSeq utilities instead of duplicating logic. Replace console.log with log function passed from migrations runner. Fix incorrect menu prefix (was 'p', now correctly uses _type[0]). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Pass forceUpdate to updateEnabledPlugins in insertRecursive to avoid no-op - Filter falsy values from nextPlugins Set to prevent undefined menu/theme entries - Fix misleading test name in computeSortOrderOps - Add unit tests for clone and handleTree Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
|
@copilot Thanks for the review. Here's a summary of how each comment was addressed: Actioned (40e2751):
Dismissed:
|
…nment to avoid App.instance side effect in tests Agent-Logs-Url: https://github.com/adapt-security/adapt-authoring-content/sessions/9952e7df-96fe-4af3-8034-bbbebd385aaa Co-authored-by: taylortom <1059083+taylortom@users.noreply.github.com>
Fixed in cbb24c9. The CI failures had two root causes:
|
Breaking
getDescendantsstandalone utility function (replaced byContentTree.getDescendants+ config handling inlined inContentModule.delete)New
GET /api/content/tree/:_courseIdendpoint returning lightweight projected content with_childrenIDs andupdatedAttimestampsIf-Modified-Sinceconditional requests — returns 304 with no body when the course hasn't changed, using a single projectedfindOnefor the staleness checkgenerateFriendlyIdsfor bulk counter allocation in a single atomic incrementlib/utils/:computeSortOrderOps,contentTypeToSchemaName,formatFriendlyId,parseMaxSeqUpdate
cloneas a flat bulk insert: pre-generates all_idvalues viacreateObjectIdand friendly IDs upfront, builds payloads with mapped parent references, firespreInsertHook/postInsertHookmanually, then does a singleinsertManyinstead of recursive per-item inserts (2 DB round-trips instead of 2N)convertObjectIdsinclone()with targeted_id/_courseId/_parentIdassignment usingnew ObjectId()directly — ObjectId instances are pre-computed inidMap, avoiding a full-document field scan and theApp.instanceside effect it triggers in test environmentsgenerateFriendlyId— single-ID callers now usegenerateFriendlyIdswith count 1updateSortOrderandupdateEnabledPluginsduring recursion, runningupdateEnabledPluginsonce at top level after all children existdeleteManyinstead of individual deletesupdateSortOrderandupdateEnabledPluginsto skip irrelevant content types_parentIdand_typequeriesfindcall sites to reduce document transfersuper.findwith projection for tree building, avoiding hooks and full-document fetchContentTreeof remaining items toupdateEnabledPluginsinstead of a raw arrayupdateEnabledPluginsaccepts a pre-builtContentTreeviaoptions.tree, usestree.configandtree.getComponentNames()for O(1) lookups instead of array scansupdateEnabledPluginsusesSet-based equality checks and filters instead of O(n²).includes()loopsupdateEnabledPluginsfilters out falsy values before constructing the enabled plugins Set, preventingundefined/null_menu/_themeentriesinsertRecursivepost-loop call toupdateEnabledPluginsnow passes{ forceUpdate: true }to ensure config_enabledPluginsis not skipped by the_typeguardupdateSortOrderbatches writes into a singlebulkWriteinstead of individualsuper.updatecallsinsertRecursivedefersupdateSortOrderandupdateEnabledPluginsto run once after the loop instead of per-itemthis.*vssuper.*convention —super.*for internal bookkeeping,this.*for user-facing operationsfind()[0]patterns withfindOneusingthrowOnMissingcontentplugin,jsonschema,authored,tags) ininit()adapt-authoring-mongodbto^3.1.0forpreserveIdsupportFix
getSchema— errors now propagate instead of silently falling back to less-specific schemasgetSchema/getSchemaName—_courseIdis now fetched alongside_type/_componentin a single query_idinhandleClonebefore proceedingdeleteManyfor descendants (1 bulk query) instead of N individual deletesadapt-authoring-mongodbmoved from peer to standard dependencyTesting
npm test— 122 unit tests pass (ContentModule + ContentTree + utils)npx standard— clean lintGET /api/content/tree/:courseIdreturns projected items with_childrenIf-Modified-Sinceheader, expect 304✨ Let Copilot coding agent set things up for you — coding agent works faster and does higher quality work when set up for your repo.