From 0cebde9bd7337e7cf26b9b6f0dbdf0b5139310ad Mon Sep 17 00:00:00 2001 From: Bryan Haberberger Date: Fri, 7 Aug 2026 13:00:41 -0500 Subject: [PATCH 01/48] A more generic back end expand via a new /v1/id/_:id/expanded/ endpoint --- controllers/crud.js | 167 ++++++++++++++++- controllers/gog.js | 69 +------ controllers/utils.js | 64 +++++++ database/__mocks__/index.js | 1 + db-controller.js | 3 +- openapi/contracts/core-provider.openapi.yaml | 93 ++++++++++ public/API.html | 186 +++++++++++++++++++ routes/id.js | 9 + 8 files changed, 528 insertions(+), 64 deletions(-) diff --git a/controllers/crud.js b/controllers/crud.js index 758a048a..bcadc537 100644 --- a/controllers/crud.js +++ b/controllers/crud.js @@ -6,7 +6,7 @@ */ import { newID, isValidID, db } from '../database/index.js' import utils from '../utils.js' -import { _contextid, idNegotiation, getPagination, generateSlugId, ObjectID, getAgentClaim, parseDocumentID } from './utils.js' +import { _contextid, idNegotiation, getPagination, generateSlugId, ObjectID, getAgentClaim, parseDocumentID, findLeafAnnotationsFor } from './utils.js' /** * Create a new Linked Open Data object in RERUM v1. @@ -127,8 +127,171 @@ const id = async function (req, res, next) { } } +/** + * The expand job always constrains the Annotations it gathers to the leaf versions, to the + * Annotation types, and to the entity in the request URI. A client cannot influence those, so + * these keys are dropped from a supplied filter body by exact name or dotted prefix. + * The dot matters -- 'targetCollection' is a real property on Gallery of Glosses data and must + * still be usable as a filter. + */ +const RESERVED_FILTER_KEYS = ["target", "type", "@type", "__rerum.history"] + +/** + * Identity and system properties an Annotation body must never overwrite. '@id' and '@context' + * are read by idNegotiation() and res.location() right after the merge, so clobbering them would + * break the response itself. '__proto__' is not data -- assigning it would re-point the response + * object's prototype instead of adding a property, and emitting it would hand a prototype + * pollution vector to every client that parses the response. + */ +const PROTECTED_EXPANSION_KEYS = new Set(["@id", "id", "_id", "__rerum", "__deleted", "@context", "__proto__"]) + +/** + * Reduce a supplied POST body to the literal MongoDB filter keys the expand job will honor. + * @param supplied The parsed JSON request body. + * @return An object of filter keys, minus the ones this endpoint owns. + */ +function sanitizeExpansionFilters(supplied) { + const filters = {} + for (const [key, value] of Object.entries(supplied)) { + if (RESERVED_FILTER_KEYS.some(reserved => key === reserved || key.startsWith(`${reserved}.`))) continue + filters[key] = value + } + return filters +} + +/** + * The [key, value] assertions an Annotation makes about the entity it targets. + * Only 'body' and 'bodyValue' are read -- an Annotation carrying neither is ignored, and no other + * property of the Annotation can leak onto the entity. + * + * Anticipates the likely Annotation body formats + * - bodyValue: 'text' the W3C shorthand, which has no key of its own + * - body: {'key': 'value'} a single assertion + * - body: {'key': {...}} a single assertion, value kept as-is + * - body: {'type':'TextualBody', 'value': 'text', ...} kept whole so 'format' and 'language' survive + * + * @param anno An Annotation document. + * @return An Array of [key, value] pairs to merge onto the entity. + */ +function assertionsFrom(anno) { + const assertions = [] + if (typeof anno.bodyValue === "string") assertions.push(["bodyValue", anno.bodyValue]) + const body = anno.body + // Skip Annotations carrying multiple bodies, and string bodies that are an IRI referencing an + // external resource with no embedded value to expand with. + if (!body || typeof body !== "object" || Array.isArray(body)) return assertions + if ((body.type ?? body["@type"]) === "TextualBody") { + assertions.push(["bodyValue", body]) + return assertions + } + const keys = Object.keys(body) + // Any other multi-key body is structural rather than assertional and cannot be attributed to a + // single entity property. This is what skips the Choice, Composite, and List multiplicity + // constructs, which are all shaped {type, items}. + if (keys.length !== 1) return assertions + assertions.push([keys[0], body[keys[0]]]) + return assertions +} + +/** + * Merge the assertions of the gathered Annotations onto a copy of the entity, as raw values. + * Unlike the Gallery of Glosses expand(), values are not wrapped and not unwrapped -- what the + * Annotation says is what the entity gets. When more than one current Annotation asserts the same + * key, or the entity already carries it, the values collect into an Array. + * @param primitiveEntity The unexpanded entity. + * @param annos The Annotations targeting it. + * @return A new, expanded entity object. + */ +function applyRawExpansion(primitiveEntity, annos) { + const expandedEntity = structuredClone(primitiveEntity) + // Hold __rerum aside so it can be re-appended after the merged properties. It is the + // last property on a stored object and should stay last on an expanded one. + const rerumProp = expandedEntity.__rerum + delete expandedEntity.__rerum + for (const anno of annos) { + for (const [key, value] of assertionsFrom(anno)) { + if (PROTECTED_EXPANSION_KEYS.has(key)) continue + if (!Object.hasOwn(expandedEntity, key)) { + expandedEntity[key] = value + continue + } + const existing = Array.isArray(expandedEntity[key]) ? expandedEntity[key] : [expandedEntity[key]] + expandedEntity[key] = Array.isArray(value) ? [...existing, ...value] : [...existing, value] + } + } + if (rerumProp !== undefined) expandedEntity.__rerum = rerumProp + return expandedEntity +} + +/** + * Query the MongoDB for the object with the _id or __rerum.slug provided in the request URL, then + * merge in the assertions of all the current Annotations targeting it. + * + * GET recognizes the '?generator=' and '?creator=' convenience parameters only. + * POST reads literal MongoDB filter keys from the JSON body and ignores URL parameters as filters. + * Both methods page the Annotation search with the usual '?limit=' and '?skip=' parameters. + * */ +const idExpanded = async function (req, res, next) { + res.set("Content-Type", "application/json; charset=utf-8") + const id = req.params["_id"] + const isPost = req.method === "POST" + //Paging is transport rather than a filter, so it comes off the URL for both methods. + //The default is generous because an expansion wants every Annotation it can get, and an + //entity with more than 200 targeting it is not expected. + const pagination = getPagination(req.query, 200) + let filters = {} + if (isPost) { + //Express leaves the body undefined when a POST supplies none. That is an unfiltered expand. + const supplied = req.body ?? {} + if (typeof supplied !== "object" || Array.isArray(supplied)) { + const err = { + "message": "The /expanded request body must be a JSON object of filter properties.", + "status": 400 + } + return next(utils.createExpressError(err)) + } + filters = sanitizeExpansionFilters(supplied) + } + else { + //Repeated query parameters arrive as an Array, which is not a filter value we support. + if (typeof req.query.generator === "string" && req.query.generator) filters["__rerum.generatedBy"] = req.query.generator + if (typeof req.query.creator === "string" && req.query.creator) filters.creator = req.query.creator + } + try { + const match = await db.findOne({"$or": [{"_id": id}, {"__rerum.slug": id}]}) + if (!match) { + const err = { + "message": `No RERUM object with id '${id}'`, + "status": 404 + } + return next(utils.createExpressError(err)) + } + res.set(utils.configureWebAnnoHeadersFor(match)) + //Support built in browser caching. A POST response is not cacheable. + if (!isPost) res.set("Cache-Control", "max-age=86400, must-revalidate") + // No Last-Modified here, unlike GET /v1/id/:_id. It would compare against the root entity + // before the targeting Annotations are merged in. + // Include current version for optimistic locking + res.set('Current-Overwritten-Version', match.__rerum?.isOverwritten ?? "") + // Annotations target the stored URI, so this must come off the raw match. idNegotiation() + // below rebuilds 'id' from RERUM_ID_PREFIX, which is not necessarily the stored host. + const targetId = match["@id"] ?? match.id + const annos = targetId ? await findLeafAnnotationsFor(targetId, filters, pagination) : [] + // Let clients detect a full page. When this equals the limit there may be more to gather, + // and the entity in hand is expanded from only part of its Annotations. + res.set('Annotations-Merged', String(annos.length)) + let expanded = applyRawExpansion(match, annos) + expanded = idNegotiation(expanded) + res.location(_contextid(expanded["@context"]) ? expanded.id : expanded["@id"]) + res.json(expanded) + } catch (error) { + return next(utils.createExpressError(error)) + } +} + export { create, query, - id + id, + idExpanded } diff --git a/controllers/gog.js b/controllers/gog.js index db69a3e5..bfbe1b7c 100644 --- a/controllers/gog.js +++ b/controllers/gog.js @@ -8,7 +8,7 @@ import { newID, isValidID, db } from '../database/index.js' import utils from '../utils.js' -import { _contextid, ObjectID, getAgentClaim, getPagination, parseDocumentID, idNegotiation } from './utils.js' +import { _contextid, ObjectID, getAgentClaim, getPagination, parseDocumentID, idNegotiation, findLeafAnnotationsFor } from './utils.js' // The Gallery of Glosses agents, by RERUM ObjectId. Prod (store) and dev (devstore) mint different // agents; only the trailing id is compared, so either host spelling matches. @@ -336,66 +336,13 @@ const expand = async function(primitiveEntity, GENERATOR=undefined, CREATOR=unde // An entity is expandable if it carries a URI under either '@id' or 'id'. if(!primitiveEntity?.["@id"] && !primitiveEntity?.id) return primitiveEntity const targetId = primitiveEntity["@id"] ?? primitiveEntity.id ?? "unknown" - // '$and' is always present so the GENERATOR and CREATOR blocks below can push into it from - // either branch. 'annoTypeConditions' is always pushed, so it is never the empty Array Mongo rejects. - let queryObj = { - "__rerum.history.next": { $exists: true, $size: 0 }, - "$and": [] - } - let targetPatterns = ["target", "target.@id", "target.id"] - let targetConditions = [] - let annoTypeConditions = [{"type": "Annotation"}, {"@type":"Annotation"}, {"@type":"oa:Annotation"}] - - if (targetId.startsWith("http")) { - for(const targetKey of targetPatterns){ - targetConditions.push({ [targetKey]: targetId.replace(/^https?/, "http") }) - targetConditions.push({ [targetKey]: targetId.replace(/^https?/, "https") }) - } - queryObj["$and"].push({"$or": targetConditions}, {"$or": annoTypeConditions}) - } - else{ - queryObj["$and"].push({"$or": annoTypeConditions}) - queryObj.target = targetId - } - - // Only expand with data from a specific app - if(GENERATOR) { - // Need to check http:// and https:// - const generatorConditions = [ - {"__rerum.generatedBy": GENERATOR.replace(/^https?/, "http")}, - {"__rerum.generatedBy": GENERATOR.replace(/^https?/, "https")} - ] - if (GENERATOR.startsWith("http")) { - queryObj["$and"].push({"$or": generatorConditions }) - } - else{ - // It should be a URI, but this can be a fallback. - queryObj["__rerum.generatedBy"] = GENERATOR - } - } - // Only expand with data from a specific creator - if(CREATOR) { - // Need to check http:// and https:// - const creatorConditions = [ - {"creator": CREATOR.replace(/^https?/, "http")}, - {"creator": CREATOR.replace(/^https?/, "https")} - ] - if (CREATOR.startsWith("http")) { - queryObj["$and"].push({"$or": creatorConditions }) - } - else{ - // It should be a URI, but this can be a fallback. - queryObj["creator"] = CREATOR - } - } - - // Get the Annotations targeting this Entity from the db. Remove _id property. - // Assuming we do not need paged query here - let matches = await db.find(queryObj).toArray() - matches = matches.map(o => { - delete o._id - return o - }) + // Only expand with data from a specific app and/or a specific creator. The shared helper + // applies the leaf, target, and Annotation type constraints and doubles these two URIs + // across the http/https spellings. + const filters = {} + if(GENERATOR) filters["__rerum.generatedBy"] = GENERATOR + if(CREATOR) filters.creator = CREATOR + const matches = await findLeafAnnotationsFor(targetId, filters) // Combine the Annotation bodies with the primitive object. // Mirror DEER's client-side expand() (deer-utils.js buildValueObject) diff --git a/controllers/utils.js b/controllers/utils.js index dd455d05..72e2614a 100644 --- a/controllers/utils.js +++ b/controllers/utils.js @@ -109,6 +109,69 @@ const generateSlugId = async function(slug_id="", next){ return slug_return } +// RERUM has minted these two under both 'http' and 'https' over the years, so a filter on either +// must match both spellings. Every other supplied filter key is applied exactly as given. +const URI_DOUBLED_FILTER_KEYS = new Set(["__rerum.generatedBy", "creator"]) + +/** + * Find the current (leaf) Annotations targeting an entity, for expansion. + * + * Anticipates the likely Annotation target formats + * - target: 'uri' + * - target: {'id':'uri'} + * - target: {'@id':'uri'} + * and the likely Annotation type formats + * - {"type": "Annotation"}, {"@type": "Annotation"}, {"@type": "oa:Annotation"} + * + * @param targetId The '@id' or 'id' URI of the entity being expanded. + * @param filters Literal MongoDB filter keys to AND into the query. Already sanitized by the + * caller -- the leaf, type, and target constraints here cannot be overruled. + * @param pagination A {limit, skip} pair from getPagination(). When supplied, the query is sorted + * by '_id' first -- Mongo's natural order is not stable across paged calls, so + * without a sort a client walking pages could miss or repeat Annotations. + * Omit it to fetch every match, which is the long standing expand() behavior. + * @return An Array of matching Annotation documents, with '_id' removed. + */ +const findLeafAnnotationsFor = async function (targetId, filters = {}, pagination = null) { + // '$and' is always present so the filter conditions below can push into it from either branch. + // 'annoTypeConditions' is always pushed, so it is never the empty Array Mongo rejects. + const queryObj = { + "__rerum.history.next": { $exists: true, $size: 0 }, + "$and": [] + } + const annoTypeConditions = [{"type": "Annotation"}, {"@type": "Annotation"}, {"@type": "oa:Annotation"}] + if (targetId.startsWith("http")) { + const targetConditions = [] + for (const targetKey of ["target", "target.@id", "target.id"]) { + targetConditions.push({ [targetKey]: targetId.replace(/^https?/, "http") }) + targetConditions.push({ [targetKey]: targetId.replace(/^https?/, "https") }) + } + queryObj["$and"].push({"$or": targetConditions}, {"$or": annoTypeConditions}) + } + else { + queryObj["$and"].push({"$or": annoTypeConditions}) + queryObj.target = targetId + } + for (const [key, value] of Object.entries(filters)) { + if (URI_DOUBLED_FILTER_KEYS.has(key) && typeof value === "string" && /^https?:\/\//.test(value)) { + queryObj["$and"].push({"$or": [ + { [key]: value.replace(/^https?/, "http") }, + { [key]: value.replace(/^https?/, "https") } + ]}) + continue + } + queryObj["$and"].push({ [key]: value }) + } + // Get the Annotations targeting this Entity from the db. Remove _id property. + let cursor = db.find(queryObj) + if (pagination) cursor = cursor.sort({ "_id": 1 }).limit(pagination.limit).skip(pagination.skip) + const matches = await cursor.toArray() + return matches.map(o => { + delete o._id + return o + }) +} + // Handle index actions const index = function (req, res, next) { res.json({ @@ -465,6 +528,7 @@ async function healReleasesTree(releasing) { export { _contextid, idNegotiation, + findLeafAnnotationsFor, getPagination, generateSlugId, index, diff --git a/database/__mocks__/index.js b/database/__mocks__/index.js index 51ec6a2c..b155179a 100644 --- a/database/__mocks__/index.js +++ b/database/__mocks__/index.js @@ -41,6 +41,7 @@ function createMockFunction(implementation = () => undefined) { function createCursor() { return { + sort: createMockFunction(function () { return this }), limit: createMockFunction(function () { return this }), skip: createMockFunction(function () { return this }), toArray: createMockFunction(() => Promise.resolve([])) diff --git a/db-controller.js b/db-controller.js index 7f161667..99f5c163 100644 --- a/db-controller.js +++ b/db-controller.js @@ -8,7 +8,7 @@ // Import controller modules import { index, idNegotiation, generateSlugId, remove } from './controllers/utils.js' -import { create, query, id } from './controllers/crud.js' +import { create, query, id, idExpanded } from './controllers/crud.js' import { searchAsWords, searchAsPhrase } from './controllers/search.js' import { deleteObj } from './controllers/delete.js' import { putUpdate, patchUpdate, patchSet, patchUnset, overwrite } from './controllers/update.js' @@ -32,6 +32,7 @@ export default { searchAsWords, searchAsPhrase, id, + idExpanded, bulkCreate, bulkUpdate, queryHeadRequest, diff --git a/openapi/contracts/core-provider.openapi.yaml b/openapi/contracts/core-provider.openapi.yaml index 7174b34a..fa603009 100644 --- a/openapi/contracts/core-provider.openapi.yaml +++ b/openapi/contracts/core-provider.openapi.yaml @@ -44,6 +44,99 @@ paths: description: Object headers '404': $ref: '#/components/responses/NotFound' + /id/{id}/expanded: + get: + summary: Read object by id with its current Annotations merged in + operationId: getExpandedObjectById + parameters: + - $ref: '#/components/parameters/ObjectId' + - in: query + name: generator + required: false + description: Only expand with Annotations generated by this registered app agent. + schema: + type: string + - in: query + name: creator + required: false + description: Only expand with Annotations attributed to this creator. + schema: + type: string + - in: query + name: limit + required: false + description: Maximum Annotations to gather. Defaults to 200. + schema: + type: integer + - in: query + name: skip + required: false + description: Annotations to skip before gathering. Defaults to 0. + schema: + type: integer + responses: + '200': + description: Expanded object payload + content: + application/json: + schema: + $ref: '#/components/schemas/GenericObject' + '404': + $ref: '#/components/responses/NotFound' + head: + summary: Read expanded object headers by id + operationId: headExpandedObjectById + parameters: + - $ref: '#/components/parameters/ObjectId' + responses: + '200': + description: Expanded object headers + '404': + $ref: '#/components/responses/NotFound' + post: + summary: Read object by id with its current Annotations merged in, filtered by the request body + operationId: postExpandedObjectById + description: >- + The request body is an object of literal MongoDB filter properties ANDed into the search for + Annotations targeting the entity. URL query parameters supply no filters, though 'limit' and + 'skip' still page the search. The leaf version, the Annotation type, and the target + constraints are applied automatically and cannot be overruled, so 'target', 'type', '@type', + and '__rerum.history' keys are ignored. + parameters: + - $ref: '#/components/parameters/ObjectId' + - in: query + name: limit + required: false + description: Maximum Annotations to gather. Defaults to 200. + schema: + type: integer + - in: query + name: skip + required: false + description: Annotations to skip before gathering. Defaults to 0. + schema: + type: integer + requestBody: + required: false + content: + application/json: + schema: + $ref: '#/components/schemas/GenericObject' + responses: + '200': + description: Expanded object payload + content: + application/json: + schema: + $ref: '#/components/schemas/GenericObject' + '400': + $ref: '#/components/responses/BadRequest' + '404': + $ref: '#/components/responses/NotFound' + '413': + $ref: '#/components/responses/PayloadTooLarge' + '415': + $ref: '#/components/responses/UnsupportedMediaType' /since/{id}: get: summary: Read updates since id diff --git a/public/API.html b/public/API.html index 38f2df93..a105fc3c 100644 --- a/public/API.html +++ b/public/API.html @@ -48,6 +48,7 @@

API (1.1.0)

  • GET @@ -61,6 +62,7 @@

    API (1.1.0)

  • Custom Query
  • Text Search
  • Phrase Search
  • +
  • Expanded record with filters
  • HTTP POST Method Override
  • @@ -149,6 +151,99 @@

    Single record by id

    This can be used directly in the browser. Try it to see what the response resp looks like. https://devstore.rerum.io/v1/id/11111

    +

    Expanded record by id

    + + + + + + + + + + + + + + + +
    PatternPayloadResponse
    /id/_id/expandedempty200 {JSON}
    +

    + Gather every current Annotation targeting the record and merge what those Annotations assert onto it. + This does the entity assembly that client applications otherwise perform with many + /query requests. +

    + +

    + Only the current (leaf) versions of Annotations are gathered. No token is required. + The response is a raw assembly—if your application needs the data in a particular shape, format the + response for your own internal needs. +

    +
    What gets merged
    + +

    +

    Javascript Example
    +
    
    +                const expanded = await fetch("https://devstore.rerum.io/v1/id/11111/expanded").then(resp => resp.json()).catch(err => {throw err})
    +            
    +

    +

    + This can be used directly in the browser. Try it to see what the response resp looks like. + https://devstore.rerum.io/v1/id/11111/expanded +

    History tree before this version

    @@ -748,6 +843,97 @@ Results are returned sorted by relevance score in descending order. The __rerum.score property indicates match quality.

    +

    Expanded record with filters

    +
    + + + + + + + + + + + + + + +
    PatternPayloadResponse
    /id/_id/expanded{JSON}200 {JSON}
    +

    + The same expansion as + GET /id/_id/expanded, + with the search for Annotations narrowed by the request body. Use this when the convenience parameters on + the GET are not enough. Unlike the GET, this response is not browser cached. +

    + +

    + Three constraints belong to the endpoint and cannot be overruled. Supplying them is not an error—they are + ignored, by exact name or as a dotted prefix. +

    + + + + + + + + + + + + + + + + + + + + + +
    IgnoredAlways applied instead
    target, + target.@id, + target.idAnnotations targeting the record at + _id
    type, + @typeAnnotations only
    __rerum.history.next, + __rerum.history.previous, + __rerum.history.primeCurrent (leaf) versions only
    +

    +

    Javascript Example
    +
    
    +                const expanded = await fetch("https://devstore.rerum.io/v1/id/11111/expanded", {
    +                    method: "POST",
    +                    headers: { "Content-Type": "application/json" },
    +                    body: JSON.stringify({
    +                        "__rerum.generatedBy": "https://devstore.rerum.io/v1/id/agent7",
    +                        "motivation": "describing"
    +                    })
    +                }).then(resp => resp.json()).catch(err => {throw err})
    +            
    +

    HTTP POST Method Override

    This section is non-normative.

    diff --git a/routes/id.js b/routes/id.js index fdfca44f..3e2069a1 100644 --- a/routes/id.js +++ b/routes/id.js @@ -2,6 +2,15 @@ import express from 'express' const router = express.Router() //This controller will handle all MongoDB interactions. import controller from '../db-controller.js' +import rest from '../rest.js' + +router.route('/:_id/expanded') + .get(controller.idExpanded) + .post(rest.verifyJsonContentType, controller.idExpanded) + .all((req, res, next) => { + res.statusMessage = 'Improper request method, please use GET or POST.' + res.status(405).end() + }) router.route('/:_id') .get(controller.id) From c060e50abcdf433e8a9d72a3a1d47b6434ad5fb7 Mon Sep 17 00:00:00 2001 From: Bryan Haberberger Date: Fri, 7 Aug 2026 13:40:52 -0500 Subject: [PATCH 02/48] Catch the W3C SpecificResource form target variants as well --- controllers/utils.js | 6 +++++- public/API.html | 14 +++++++++++--- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/controllers/utils.js b/controllers/utils.js index 72e2614a..8aeb499a 100644 --- a/controllers/utils.js +++ b/controllers/utils.js @@ -120,6 +120,8 @@ const URI_DOUBLED_FILTER_KEYS = new Set(["__rerum.generatedBy", "creator"]) * - target: 'uri' * - target: {'id':'uri'} * - target: {'@id':'uri'} + * - target: {'source':'uri', 'type':'SpecificResource'} the W3C SpecificResource + * - target: {'source':{'id':'uri'}} a SpecificResource with an embedded source * and the likely Annotation type formats * - {"type": "Annotation"}, {"@type": "Annotation"}, {"@type": "oa:Annotation"} * @@ -142,7 +144,9 @@ const findLeafAnnotationsFor = async function (targetId, filters = {}, paginatio const annoTypeConditions = [{"type": "Annotation"}, {"@type": "Annotation"}, {"@type": "oa:Annotation"}] if (targetId.startsWith("http")) { const targetConditions = [] - for (const targetKey of ["target", "target.@id", "target.id"]) { + // 'target.source' is the W3C SpecificResource, which is how an Annotation targets a + // fragment or a selected region of a resource rather than the whole of it. + for (const targetKey of ["target", "target.@id", "target.id", "target.source", "target.source.@id", "target.source.id"]) { targetConditions.push({ [targetKey]: targetId.replace(/^https?/, "http") }) targetConditions.push({ [targetKey]: targetId.replace(/^https?/, "https") }) } diff --git a/public/API.html b/public/API.html index a105fc3c..30c56868 100644 --- a/public/API.html +++ b/public/API.html @@ -225,7 +225,10 @@

    Expanded record by id

    _id, __rerum, __deleted, and - @context. + @context. An assertion naming + __proto__ is dropped for the same reason—it + is not data, and emitting it would hand a prototype pollution vector to every client that parses the + response.
  • Skipped for now, as future work: a body with more than one property, an Annotation with multiple bodies, the Choice, @@ -904,9 +907,14 @@

    Expanded record with filters

    target, target.@id, - target.id + target.id, + target.source, + target.source.@id, + target.source.id Annotations targeting the record at - _id + _id, including those + targeting it through a + SpecificResource type, From 1cb50865b180c21dbf47656bcb783921c897fa6b Mon Sep 17 00:00:00 2001 From: Bryan Haberberger Date: Tue, 11 Aug 2026 10:44:32 -0500 Subject: [PATCH 03/48] changes during review --- controllers/crud.js | 19 +++++++++++++++++-- controllers/gog.js | 13 ++++++++++--- controllers/utils.js | 24 ++++++++++++++++++++++-- public/API.html | 4 +++- 4 files changed, 52 insertions(+), 8 deletions(-) diff --git a/controllers/crud.js b/controllers/crud.js index bcadc537..5c7946f3 100644 --- a/controllers/crud.js +++ b/controllers/crud.js @@ -159,6 +159,14 @@ function sanitizeExpansionFilters(supplied) { return filters } +/** + * The Annotation body types whose value is kept whole rather than read as a single assertion. + * The OA prefixed spelling is honored for the Annotation type in findLeafAnnotationsFor(), so it + * is honored here too. Without it an 'oa:TextualBody' falls through to the single key check and + * is dropped, since a TextualBody always carries at least a type and a value. + */ +const TEXTUAL_BODY_TYPES = new Set(["TextualBody", "oa:TextualBody"]) + /** * The [key, value] assertions an Annotation makes about the entity it targets. * Only 'body' and 'bodyValue' are read -- an Annotation carrying neither is ignored, and no other @@ -169,6 +177,7 @@ function sanitizeExpansionFilters(supplied) { * - body: {'key': 'value'} a single assertion * - body: {'key': {...}} a single assertion, value kept as-is * - body: {'type':'TextualBody', 'value': 'text', ...} kept whole so 'format' and 'language' survive + * - body: {'@type':'oa:TextualBody', ...} the OA prefixed spelling of the same * * @param anno An Annotation document. * @return An Array of [key, value] pairs to merge onto the entity. @@ -180,7 +189,7 @@ function assertionsFrom(anno) { // Skip Annotations carrying multiple bodies, and string bodies that are an IRI referencing an // external resource with no embedded value to expand with. if (!body || typeof body !== "object" || Array.isArray(body)) return assertions - if ((body.type ?? body["@type"]) === "TextualBody") { + if (TEXTUAL_BODY_TYPES.has(body.type ?? body["@type"])) { assertions.push(["bodyValue", body]) return assertions } @@ -280,9 +289,15 @@ const idExpanded = async function (req, res, next) { // Let clients detect a full page. When this equals the limit there may be more to gather, // and the entity in hand is expanded from only part of its Annotations. res.set('Annotations-Merged', String(annos.length)) + // This deployment's '/expanded' URI, not the entity URI. The entity URI would hand back + // the unexpanded record, and it cannot be the base for this one either -- an entity minted + // by another RERUM carries that host in its stored '@id', and there is no guarantee the + // other host serves '/expanded' at all. RERUM_ID_PREFIX is how idNegotiation() mints ids, + // so this stays on the host actually answering the request. + const expandedLocation = `${process.env.RERUM_ID_PREFIX}${match._id}/expanded` let expanded = applyRawExpansion(match, annos) expanded = idNegotiation(expanded) - res.location(_contextid(expanded["@context"]) ? expanded.id : expanded["@id"]) + res.location(expandedLocation) res.json(expanded) } catch (error) { return next(utils.createExpressError(error)) diff --git a/controllers/gog.js b/controllers/gog.js index bfbe1b7c..e575910d 100644 --- a/controllers/gog.js +++ b/controllers/gog.js @@ -8,7 +8,7 @@ import { newID, isValidID, db } from '../database/index.js' import utils from '../utils.js' -import { _contextid, ObjectID, getAgentClaim, getPagination, parseDocumentID, idNegotiation, findLeafAnnotationsFor } from './utils.js' +import { ObjectID, getAgentClaim, getPagination, parseDocumentID, idNegotiation, findLeafAnnotationsFor } from './utils.js' // The Gallery of Glosses agents, by RERUM ObjectId. Prod (store) and dev (devstore) mint different // agents; only the trailing id is compared, so either host spelling matches. @@ -364,7 +364,9 @@ const expand = async function(primitiveEntity, GENERATOR=undefined, CREATOR=unde }, evidence: assertion?.evidence ?? anno.evidence ?? "" } - if(expandedEntity.hasOwnProperty(key)){ + // Object.hasOwn() rather than the method on the entity. A merged assertion named + // 'hasOwnProperty' would shadow the method and throw a TypeError on the next iteration. + if(Object.hasOwn(expandedEntity, key)){ expandedEntity[key] = Array.isArray(expandedEntity[key]) ? [...expandedEntity[key], valueObject] : [expandedEntity[key], valueObject] @@ -401,6 +403,11 @@ const expandedId = async function (req, res, next) { }) return next(utils.createExpressError(err)) } + // This '/gog/id' URI, not the entity URI. This response is the expanded representation, + // and the entity URI would hand back the unexpanded record instead. Built off + // RERUM_ID_PREFIX so the origin follows the deployment, and captured before expand() in + // case idNegotiation() reaches the match itself and drops '_id'. + const expandedLocation = new URL(`/gog/id/${match._id}`, process.env.RERUM_ID_PREFIX).href // Same browser-caching policy as GET /v1/id/:_id so this stable URI is cached (24h). res.set(utils.configureWebAnnoHeadersFor(match)) res.set("Cache-Control", "max-age=86400, must-revalidate") @@ -409,7 +416,7 @@ const expandedId = async function (req, res, next) { res.set("Current-Overwritten-Version", match.__rerum?.isOverwritten ?? "") let expanded = await expand(match, generator) expanded = idNegotiation(expanded) - res.location(_contextid(expanded["@context"]) ? expanded.id : expanded["@id"]) + res.location(expandedLocation) res.json(expanded) } catch (error) { return next(utils.createExpressError(error)) diff --git a/controllers/utils.js b/controllers/utils.js index 8aeb499a..4fbf0f2a 100644 --- a/controllers/utils.js +++ b/controllers/utils.js @@ -113,6 +113,19 @@ const generateSlugId = async function(slug_id="", next){ // must match both spellings. Every other supplied filter key is applied exactly as given. const URI_DOUBLED_FILTER_KEYS = new Set(["__rerum.generatedBy", "creator"]) +// The properties an Annotation can carry the URI of its target under. +const TARGET_KEYS = ["target", "target.@id", "target.id", "target.source", "target.source.@id", "target.source.id"] + +/** + * Escape the RegExp metacharacters in a literal so it can be embedded in a pattern and match only + * itself. A RERUM URI has at least the dots of its host to escape. + * @param literal A string to be matched literally. + * @return The same string, safe to concatenate into a RegExp source. + */ +function escapeRegex(literal) { + return literal.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") +} + /** * Find the current (leaf) Annotations targeting an entity, for expansion. * @@ -122,6 +135,7 @@ const URI_DOUBLED_FILTER_KEYS = new Set(["__rerum.generatedBy", "creator"]) * - target: {'@id':'uri'} * - target: {'source':'uri', 'type':'SpecificResource'} the W3C SpecificResource * - target: {'source':{'id':'uri'}} a SpecificResource with an embedded source + * - target: 'uri#xywh=0,0,100,100' a fragment of the resource * and the likely Annotation type formats * - {"type": "Annotation"}, {"@type": "Annotation"}, {"@type": "oa:Annotation"} * @@ -144,11 +158,17 @@ const findLeafAnnotationsFor = async function (targetId, filters = {}, paginatio const annoTypeConditions = [{"type": "Annotation"}, {"@type": "Annotation"}, {"@type": "oa:Annotation"}] if (targetId.startsWith("http")) { const targetConditions = [] + // Hanging a fragment off the URI is the other W3C way to target part of a resource rather + // than the whole of it, and an exact match will not catch one. Anchored at the front so + // the pattern can still use an index, and terminated by the '#' so it cannot spill onto a + // longer id. One pattern covers both spellings, since only the scheme is left unescaped. + const fragmentPattern = new RegExp(`^https?${escapeRegex(targetId.replace(/^https?/, ""))}#`) // 'target.source' is the W3C SpecificResource, which is how an Annotation targets a - // fragment or a selected region of a resource rather than the whole of it. - for (const targetKey of ["target", "target.@id", "target.id", "target.source", "target.source.@id", "target.source.id"]) { + // selected region of a resource rather than the whole of it. + for (const targetKey of TARGET_KEYS) { targetConditions.push({ [targetKey]: targetId.replace(/^https?/, "http") }) targetConditions.push({ [targetKey]: targetId.replace(/^https?/, "https") }) + targetConditions.push({ [targetKey]: fragmentPattern }) } queryObj["$and"].push({"$or": targetConditions}, {"$or": annoTypeConditions}) } diff --git a/public/API.html b/public/API.html index 30c56868..45838f66 100644 --- a/public/API.html +++ b/public/API.html @@ -914,7 +914,9 @@

    Expanded record with filters

    Annotations targeting the record at _id, including those targeting it through a - SpecificResource + SpecificResource or through a + fragment of its URI such as + #xywh=0,0,100,100 type, From e39ff2e5d684bb2b7d6957330deb4e38c4c4e3f2 Mon Sep 17 00:00:00 2001 From: Bryan Haberberger Date: Tue, 11 Aug 2026 11:21:55 -0500 Subject: [PATCH 04/48] changes during review --- controllers/gog.js | 7 ++++++- controllers/utils.js | 21 ++++++++++++++------- 2 files changed, 20 insertions(+), 8 deletions(-) diff --git a/controllers/gog.js b/controllers/gog.js index e575910d..166c02f4 100644 --- a/controllers/gog.js +++ b/controllers/gog.js @@ -323,6 +323,7 @@ const _gog_glosses_from_manuscript = async function (req, res, next) { * * Anticipate likely Annotation type formats * - {"type": "Annotation"} +* - {"type": "oa:Annotation"} * - {"@type": "Annotation"} * - {"@type": "oa:Annotation"} * @@ -350,7 +351,11 @@ const expand = async function(primitiveEntity, GENERATOR=undefined, CREATOR=unde let expandedEntity = structuredClone(primitiveEntity) for(const anno of matches){ const body = anno.body - if(!body || typeof body !== "object") continue + // Array.isArray() as well as the typeof check. An Array is a typeof 'object', and + // Object.keys() on a one element Array is ["0"] -- a length of 1 that would pass the + // single assertion check below and merge the body onto the entity under the key "0". + // Annotations carrying multiple bodies are not expanded with. + if(!body || typeof body !== "object" || Array.isArray(body)) continue const keys = Object.keys(body) if(keys.length !== 1) continue const key = keys[0] diff --git a/controllers/utils.js b/controllers/utils.js index 4fbf0f2a..a4e7a793 100644 --- a/controllers/utils.js +++ b/controllers/utils.js @@ -137,7 +137,8 @@ function escapeRegex(literal) { * - target: {'source':{'id':'uri'}} a SpecificResource with an embedded source * - target: 'uri#xywh=0,0,100,100' a fragment of the resource * and the likely Annotation type formats - * - {"type": "Annotation"}, {"@type": "Annotation"}, {"@type": "oa:Annotation"} + * - {"type": "Annotation"}, {"type": "oa:Annotation"} + * - {"@type": "Annotation"}, {"@type": "oa:Annotation"} * * @param targetId The '@id' or 'id' URI of the entity being expanded. * @param filters Literal MongoDB filter keys to AND into the query. Already sanitized by the @@ -155,20 +156,26 @@ const findLeafAnnotationsFor = async function (targetId, filters = {}, paginatio "__rerum.history.next": { $exists: true, $size: 0 }, "$and": [] } - const annoTypeConditions = [{"type": "Annotation"}, {"@type": "Annotation"}, {"@type": "oa:Annotation"}] + const annoTypeConditions = [ + {"type": "Annotation"}, {"type": "oa:Annotation"}, + {"@type": "Annotation"}, {"@type": "oa:Annotation"} + ] if (targetId.startsWith("http")) { const targetConditions = [] // Hanging a fragment off the URI is the other W3C way to target part of a resource rather - // than the whole of it, and an exact match will not catch one. Anchored at the front so - // the pattern can still use an index, and terminated by the '#' so it cannot spill onto a - // longer id. One pattern covers both spellings, since only the scheme is left unescaped. - const fragmentPattern = new RegExp(`^https?${escapeRegex(targetId.replace(/^https?/, ""))}#`) + // than the whole of it, and an exact match will not catch one. Anchored at the front and + // terminated by the '#' so the pattern cannot spill onto a longer id. One pattern per + // scheme rather than a single '^https?' -- Mongo bounds an index scan by the pattern's + // literal prefix, and '^https?' leaves it only 'http', which is every target URI stored. + const fragmentPatterns = ["http", "https"].map(scheme => + new RegExp(`^${escapeRegex(targetId.replace(/^https?/, scheme))}#`) + ) // 'target.source' is the W3C SpecificResource, which is how an Annotation targets a // selected region of a resource rather than the whole of it. for (const targetKey of TARGET_KEYS) { targetConditions.push({ [targetKey]: targetId.replace(/^https?/, "http") }) targetConditions.push({ [targetKey]: targetId.replace(/^https?/, "https") }) - targetConditions.push({ [targetKey]: fragmentPattern }) + for (const fragmentPattern of fragmentPatterns) targetConditions.push({ [targetKey]: fragmentPattern }) } queryObj["$and"].push({"$or": targetConditions}, {"$or": annoTypeConditions}) } From 780528a0a7c66ae46ed4b0a00b657286430260ca Mon Sep 17 00:00:00 2001 From: Bryan Haberberger Date: Mon, 17 Aug 2026 09:23:59 -0500 Subject: [PATCH 05/48] changes from review and testing --- controllers/crud.js | 28 +++++++++++++++++++++++----- controllers/utils.js | 18 +++++++++++++----- public/API.html | 8 +++++--- 3 files changed, 41 insertions(+), 13 deletions(-) diff --git a/controllers/crud.js b/controllers/crud.js index 5c7946f3..b3968766 100644 --- a/controllers/crud.js +++ b/controllers/crud.js @@ -167,6 +167,18 @@ function sanitizeExpansionFilters(supplied) { */ const TEXTUAL_BODY_TYPES = new Set(["TextualBody", "oa:TextualBody"]) +/** + * Whether an Annotation carries the W3C multiple bodies form, which is an Array of bodies. + * That form is future work, so such an Annotation asserts nothing an expansion can use. Both + * assertionsFrom() and the 'Annotations-Gathered' count read this, so the two cannot disagree + * about what does and does not contribute. + * @param anno An Annotation document. + * @return A boolean, true when the Annotation carries more than one body. + */ +function hasMultipleBodies(anno) { + return Array.isArray(anno.body) +} + /** * The [key, value] assertions an Annotation makes about the entity it targets. * Only 'body' and 'bodyValue' are read -- an Annotation carrying neither is ignored, and no other @@ -188,7 +200,7 @@ function assertionsFrom(anno) { const body = anno.body // Skip Annotations carrying multiple bodies, and string bodies that are an IRI referencing an // external resource with no embedded value to expand with. - if (!body || typeof body !== "object" || Array.isArray(body)) return assertions + if (hasMultipleBodies(anno) || !body || typeof body !== "object") return assertions if (TEXTUAL_BODY_TYPES.has(body.type ?? body["@type"])) { assertions.push(["bodyValue", body]) return assertions @@ -287,14 +299,20 @@ const idExpanded = async function (req, res, next) { const targetId = match["@id"] ?? match.id const annos = targetId ? await findLeafAnnotationsFor(targetId, filters, pagination) : [] // Let clients detect a full page. When this equals the limit there may be more to gather, - // and the entity in hand is expanded from only part of its Annotations. - res.set('Annotations-Merged', String(annos.length)) + // and the entity in hand is expanded from only part of its Annotations. This is the count + // gathered, not the count that changed the entity -- a gathered Annotation still asserts + // nothing when its body is protected or structural. Annotations carrying multiple bodies + // are the exception and are left out, since that form is not read at all yet and counting + // them would claim work this endpoint has not been built to do. + const gathered = annos.filter(anno => !hasMultipleBodies(anno)) + res.set('Annotations-Gathered', String(gathered.length)) // This deployment's '/expanded' URI, not the entity URI. The entity URI would hand back // the unexpanded record, and it cannot be the base for this one either -- an entity minted // by another RERUM carries that host in its stored '@id', and there is no guarantee the // other host serves '/expanded' at all. RERUM_ID_PREFIX is how idNegotiation() mints ids, - // so this stays on the host actually answering the request. - const expandedLocation = `${process.env.RERUM_ID_PREFIX}${match._id}/expanded` + // so this stays on the host actually answering the request. Resolved through 'new URL()', + // the same way gog.js builds its expanded Location. + const expandedLocation = new URL(`${match._id}/expanded`, process.env.RERUM_ID_PREFIX).href let expanded = applyRawExpansion(match, annos) expanded = idNegotiation(expanded) res.location(expandedLocation) diff --git a/controllers/utils.js b/controllers/utils.js index a4e7a793..f4df737a 100644 --- a/controllers/utils.js +++ b/controllers/utils.js @@ -116,6 +116,10 @@ const URI_DOUBLED_FILTER_KEYS = new Set(["__rerum.generatedBy", "creator"]) // The properties an Annotation can carry the URI of its target under. const TARGET_KEYS = ["target", "target.@id", "target.id", "target.source", "target.source.@id", "target.source.id"] +// The JSON-LD expanded form of the Annotation type, alongside the compact 'Annotation' and the +// 'oa:' prefixed spellings. +const OA_ANNOTATION_IRI = "http://www.w3.org/ns/oa#Annotation" + /** * Escape the RegExp metacharacters in a literal so it can be embedded in a pattern and match only * itself. A RERUM URI has at least the dots of its host to escape. @@ -137,8 +141,8 @@ function escapeRegex(literal) { * - target: {'source':{'id':'uri'}} a SpecificResource with an embedded source * - target: 'uri#xywh=0,0,100,100' a fragment of the resource * and the likely Annotation type formats - * - {"type": "Annotation"}, {"type": "oa:Annotation"} - * - {"@type": "Annotation"}, {"@type": "oa:Annotation"} + * - {"type": "Annotation"}, {"type": "oa:Annotation"}, {"type": "http://www.w3.org/ns/oa#Annotation"} + * - {"@type": "Annotation"}, {"@type": "oa:Annotation"}, {"@type": "http://www.w3.org/ns/oa#Annotation"} * * @param targetId The '@id' or 'id' URI of the entity being expanded. * @param filters Literal MongoDB filter keys to AND into the query. Already sanitized by the @@ -156,11 +160,15 @@ const findLeafAnnotationsFor = async function (targetId, filters = {}, paginatio "__rerum.history.next": { $exists: true, $size: 0 }, "$and": [] } + // The compact, the 'oa:' prefixed, and the JSON-LD expanded IRI spellings of the Annotation + // type. All three are valid ways to say the same thing, and RERUM stores whatever it is given. const annoTypeConditions = [ - {"type": "Annotation"}, {"type": "oa:Annotation"}, - {"@type": "Annotation"}, {"@type": "oa:Annotation"} + {"type": "Annotation"}, {"type": "oa:Annotation"}, {"type": OA_ANNOTATION_IRI}, + {"@type": "Annotation"}, {"@type": "oa:Annotation"}, {"@type": OA_ANNOTATION_IRI} ] - if (targetId.startsWith("http")) { + // The same scheme test the filter values below use. 'startsWith("http")' would also pass a URI + // like 'httpx://host/thing', which the scheme swap would then mangle into 'httpsx://host/thing'. + if (/^https?:\/\//.test(targetId)) { const targetConditions = [] // Hanging a fragment off the URI is the other W3C way to target part of a resource rather // than the whole of it, and an exact match will not catch one. Anchored at the front and diff --git a/public/API.html b/public/API.html index 45838f66..fdb78483 100644 --- a/public/API.html +++ b/public/API.html @@ -189,8 +189,10 @@

    Expanded record by id

    with identifier _id, plus the merged properties.
  • Response header - Annotations-Merged—how many - Annotations went into this expansion. When it equals your + Annotations-Gathered—how many + Annotations were gathered for this expansion. A gathered Annotation may still assert nothing + mergeable, so this is not a count of the properties you received. Annotations with multiple bodies + are not counted at all, as that form is not read yet. When it equals your limit there may be more, and the record you received was expanded from only part of its Annotations. Raise the limit or walk pages with @@ -881,7 +883,7 @@

    Expanded record with filters

    here—supply them in the body instead. Paging is not a filter, so ?limit and ?skip still work, and the - Annotations-Merged response header is set + Annotations-Gathered response header is set exactly as it is on the GET.
  • __rerum.generatedBy and creator are matched against both the From 967ee7fa8d9c5e7ceaedcb4cbc53aedf8044a682 Mon Sep 17 00:00:00 2001 From: Bryan Haberberger Date: Mon, 17 Aug 2026 11:51:35 -0500 Subject: [PATCH 06/48] changes from testing and review --- controllers/crud.js | 24 ++++++++++++++---------- controllers/utils.js | 1 + public/API.html | 23 +++++++++++++++-------- 3 files changed, 30 insertions(+), 18 deletions(-) diff --git a/controllers/crud.js b/controllers/crud.js index b3968766..9c21252a 100644 --- a/controllers/crud.js +++ b/controllers/crud.js @@ -165,12 +165,12 @@ function sanitizeExpansionFilters(supplied) { * is honored here too. Without it an 'oa:TextualBody' falls through to the single key check and * is dropped, since a TextualBody always carries at least a type and a value. */ -const TEXTUAL_BODY_TYPES = new Set(["TextualBody", "oa:TextualBody"]) +const TEXTUAL_BODY_TYPES = new Set(["TextualBody", "oa:TextualBody", "http://www.w3.org/ns/oa#TextualBody"]) /** * Whether an Annotation carries the W3C multiple bodies form, which is an Array of bodies. * That form is future work, so such an Annotation asserts nothing an expansion can use. Both - * assertionsFrom() and the 'Annotations-Gathered' count read this, so the two cannot disagree + * assertionsFrom() and the 'Annotations-Merged' count read this, so the two cannot disagree * about what does and does not contribute. * @param anno An Annotation document. * @return A boolean, true when the Annotation carries more than one body. @@ -299,20 +299,24 @@ const idExpanded = async function (req, res, next) { const targetId = match["@id"] ?? match.id const annos = targetId ? await findLeafAnnotationsFor(targetId, filters, pagination) : [] // Let clients detect a full page. When this equals the limit there may be more to gather, - // and the entity in hand is expanded from only part of its Annotations. This is the count - // gathered, not the count that changed the entity -- a gathered Annotation still asserts - // nothing when its body is protected or structural. Annotations carrying multiple bodies - // are the exception and are left out, since that form is not read at all yet and counting - // them would claim work this endpoint has not been built to do. - const gathered = annos.filter(anno => !hasMultipleBodies(anno)) - res.set('Annotations-Gathered', String(gathered.length)) + // and the entity in hand is expanded from only part of its Annotations. This has to be the + // raw page size -- subtracting the Annotations that assert nothing would let a full page + // read as a partial one and stop a client paging before it has everything. + res.set('Annotations-Gathered', String(annos.length)) + // How many of the gathered Annotations could contribute, which is a different number and + // gets its own header rather than quietly standing in for the one above. This is still not + // a count of the properties received -- a gathered Annotation asserts nothing when its body + // is protected or structural. Annotations carrying multiple bodies are left out here, + // since that form is not read at all yet. + const merged = annos.filter(anno => !hasMultipleBodies(anno)) + res.set('Annotations-Merged', String(merged.length)) // This deployment's '/expanded' URI, not the entity URI. The entity URI would hand back // the unexpanded record, and it cannot be the base for this one either -- an entity minted // by another RERUM carries that host in its stored '@id', and there is no guarantee the // other host serves '/expanded' at all. RERUM_ID_PREFIX is how idNegotiation() mints ids, // so this stays on the host actually answering the request. Resolved through 'new URL()', // the same way gog.js builds its expanded Location. - const expandedLocation = new URL(`${match._id}/expanded`, process.env.RERUM_ID_PREFIX).href + const expandedLocation = new URL(`/v1/id/${match._id}/expanded`, process.env.RERUM_ID_PREFIX).href let expanded = applyRawExpansion(match, annos) expanded = idNegotiation(expanded) res.location(expandedLocation) diff --git a/controllers/utils.js b/controllers/utils.js index f4df737a..e5886e7e 100644 --- a/controllers/utils.js +++ b/controllers/utils.js @@ -114,6 +114,7 @@ const generateSlugId = async function(slug_id="", next){ const URI_DOUBLED_FILTER_KEYS = new Set(["__rerum.generatedBy", "creator"]) // The properties an Annotation can carry the URI of its target under. +// Choice, Composite, and List target constructs, whose members sit in an 'items' Array. They are not supported here. const TARGET_KEYS = ["target", "target.@id", "target.id", "target.source", "target.source.@id", "target.source.id"] // The JSON-LD expanded form of the Annotation type, alongside the compact 'Annotation' and the diff --git a/public/API.html b/public/API.html index fdb78483..e1b95e30 100644 --- a/public/API.html +++ b/public/API.html @@ -148,7 +148,7 @@

    Single record by id

    - This can be used directly in the browser. Try it to see what the response resp looks like. + This can be used directly in the browser. Try it to see what the response resp looks like. https://devstore.rerum.io/v1/id/11111

    Expanded record by id

    @@ -190,13 +190,19 @@

    Expanded record by id

    properties.
  • Response header Annotations-Gathered—how many - Annotations were gathered for this expansion. A gathered Annotation may still assert nothing - mergeable, so this is not a count of the properties you received. Annotations with multiple bodies - are not counted at all, as that form is not read yet. When it equals your + Annotations this expansion gathered, which is the size of the page you received. When it equals your limit there may be more, and the record you received was expanded from only part of its Annotations. Raise the limit or walk pages with skip.
  • +
  • Response header + Annotations-Merged—how many of + those gathered Annotations could contribute to the record. It is always less than or equal to + Annotations-Gathered, and Annotations with + multiple bodies are not counted, as that form is not read yet. A counted Annotation may still assert + nothing mergeable, so this is not a count of the properties you received either. Use + Annotations-Gathered, not this one, to decide + whether to keep paging.
  • Only the current (leaf) versions of Annotations are gathered. No token is required. @@ -246,7 +252,7 @@

    Expanded record by id

    - This can be used directly in the browser. Try it to see what the response resp looks like. + This can be used directly in the browser. Try it to see what the response resp looks like. https://devstore.rerum.io/v1/id/11111/expanded

    History tree before this version

    @@ -285,7 +291,7 @@

    History tree before this version

    - This can be used directly in the browser. Try it to see what the response resp looks like. + This can be used directly in the browser. Try it to see what the response resp looks like. https://devstore.rerum.io/v1/history/11111

    History tree since this version

    @@ -883,8 +889,9 @@

    Expanded record with filters

    here—supply them in the body instead. Paging is not a filter, so ?limit and ?skip still work, and the - Annotations-Gathered response header is set - exactly as it is on the GET. + Annotations-Gathered and + Annotations-Merged response headers are set + exactly as they are on the GET.
  • __rerum.generatedBy and creator are matched against both the http and From 79dc5bd3d36fe15dd8fd7c9b5cbdfacec1b850fe Mon Sep 17 00:00:00 2001 From: Bryan Haberberger Date: Mon, 17 Aug 2026 13:41:27 -0500 Subject: [PATCH 07/48] comment cleanup --- controllers/crud.js | 64 ++++++++------------------------------------ controllers/gog.js | 9 ------- controllers/utils.js | 38 +++++++++----------------- 3 files changed, 24 insertions(+), 87 deletions(-) diff --git a/controllers/crud.js b/controllers/crud.js index 9c21252a..c108de83 100644 --- a/controllers/crud.js +++ b/controllers/crud.js @@ -131,17 +131,12 @@ const id = async function (req, res, next) { * The expand job always constrains the Annotations it gathers to the leaf versions, to the * Annotation types, and to the entity in the request URI. A client cannot influence those, so * these keys are dropped from a supplied filter body by exact name or dotted prefix. - * The dot matters -- 'targetCollection' is a real property on Gallery of Glosses data and must - * still be usable as a filter. */ const RESERVED_FILTER_KEYS = ["target", "type", "@type", "__rerum.history"] /** * Identity and system properties an Annotation body must never overwrite. '@id' and '@context' - * are read by idNegotiation() and res.location() right after the merge, so clobbering them would - * break the response itself. '__proto__' is not data -- assigning it would re-point the response - * object's prototype instead of adding a property, and emitting it would hand a prototype - * pollution vector to every client that parses the response. + * are read by idNegotiation() and res.location() */ const PROTECTED_EXPANSION_KEYS = new Set(["@id", "id", "_id", "__rerum", "__deleted", "@context", "__proto__"]) @@ -161,24 +156,9 @@ function sanitizeExpansionFilters(supplied) { /** * The Annotation body types whose value is kept whole rather than read as a single assertion. - * The OA prefixed spelling is honored for the Annotation type in findLeafAnnotationsFor(), so it - * is honored here too. Without it an 'oa:TextualBody' falls through to the single key check and - * is dropped, since a TextualBody always carries at least a type and a value. */ const TEXTUAL_BODY_TYPES = new Set(["TextualBody", "oa:TextualBody", "http://www.w3.org/ns/oa#TextualBody"]) -/** - * Whether an Annotation carries the W3C multiple bodies form, which is an Array of bodies. - * That form is future work, so such an Annotation asserts nothing an expansion can use. Both - * assertionsFrom() and the 'Annotations-Merged' count read this, so the two cannot disagree - * about what does and does not contribute. - * @param anno An Annotation document. - * @return A boolean, true when the Annotation carries more than one body. - */ -function hasMultipleBodies(anno) { - return Array.isArray(anno.body) -} - /** * The [key, value] assertions an Annotation makes about the entity it targets. * Only 'body' and 'bodyValue' are read -- an Annotation carrying neither is ignored, and no other @@ -200,15 +180,14 @@ function assertionsFrom(anno) { const body = anno.body // Skip Annotations carrying multiple bodies, and string bodies that are an IRI referencing an // external resource with no embedded value to expand with. - if (hasMultipleBodies(anno) || !body || typeof body !== "object") return assertions + if (Array.isArray(body) || !body || typeof body !== "object") return assertions if (TEXTUAL_BODY_TYPES.has(body.type ?? body["@type"])) { assertions.push(["bodyValue", body]) return assertions } const keys = Object.keys(body) // Any other multi-key body is structural rather than assertional and cannot be attributed to a - // single entity property. This is what skips the Choice, Composite, and List multiplicity - // constructs, which are all shaped {type, items}. + // single entity property. This is what skips the Choice, Composite, and List multiplicity constructs. if (keys.length !== 1) return assertions assertions.push([keys[0], body[keys[0]]]) return assertions @@ -216,17 +195,15 @@ function assertionsFrom(anno) { /** * Merge the assertions of the gathered Annotations onto a copy of the entity, as raw values. - * Unlike the Gallery of Glosses expand(), values are not wrapped and not unwrapped -- what the - * Annotation says is what the entity gets. When more than one current Annotation asserts the same - * key, or the entity already carries it, the values collect into an Array. + * When more than one current Annotation asserts the same key, or the entity already carries it, + * the values collect into an Array. * @param primitiveEntity The unexpanded entity. * @param annos The Annotations targeting it. * @return A new, expanded entity object. */ function applyRawExpansion(primitiveEntity, annos) { const expandedEntity = structuredClone(primitiveEntity) - // Hold __rerum aside so it can be re-appended after the merged properties. It is the - // last property on a stored object and should stay last on an expanded one. + // Hold __rerum aside so it can be re-appended after the merged properties. It will be the last property. const rerumProp = expandedEntity.__rerum delete expandedEntity.__rerum for (const anno of annos) { @@ -246,7 +223,7 @@ function applyRawExpansion(primitiveEntity, annos) { /** * Query the MongoDB for the object with the _id or __rerum.slug provided in the request URL, then - * merge in the assertions of all the current Annotations targeting it. + * merge in the assertions of all the current leaf Annotations targeting it. * * GET recognizes the '?generator=' and '?creator=' convenience parameters only. * POST reads literal MongoDB filter keys from the JSON body and ignores URL parameters as filters. @@ -256,9 +233,6 @@ const idExpanded = async function (req, res, next) { res.set("Content-Type", "application/json; charset=utf-8") const id = req.params["_id"] const isPost = req.method === "POST" - //Paging is transport rather than a filter, so it comes off the URL for both methods. - //The default is generous because an expansion wants every Annotation it can get, and an - //entity with more than 200 targeting it is not expected. const pagination = getPagination(req.query, 200) let filters = {} if (isPost) { @@ -290,32 +264,16 @@ const idExpanded = async function (req, res, next) { res.set(utils.configureWebAnnoHeadersFor(match)) //Support built in browser caching. A POST response is not cacheable. if (!isPost) res.set("Cache-Control", "max-age=86400, must-revalidate") - // No Last-Modified here, unlike GET /v1/id/:_id. It would compare against the root entity - // before the targeting Annotations are merged in. // Include current version for optimistic locking res.set('Current-Overwritten-Version', match.__rerum?.isOverwritten ?? "") - // Annotations target the stored URI, so this must come off the raw match. idNegotiation() - // below rebuilds 'id' from RERUM_ID_PREFIX, which is not necessarily the stored host. const targetId = match["@id"] ?? match.id const annos = targetId ? await findLeafAnnotationsFor(targetId, filters, pagination) : [] - // Let clients detect a full page. When this equals the limit there may be more to gather, - // and the entity in hand is expanded from only part of its Annotations. This has to be the - // raw page size -- subtracting the Annotations that assert nothing would let a full page - // read as a partial one and stop a client paging before it has everything. + // Let clients detect a full page. When this equals the limit there may be more to gather. res.set('Annotations-Gathered', String(annos.length)) - // How many of the gathered Annotations could contribute, which is a different number and - // gets its own header rather than quietly standing in for the one above. This is still not - // a count of the properties received -- a gathered Annotation asserts nothing when its body - // is protected or structural. Annotations carrying multiple bodies are left out here, - // since that form is not read at all yet. - const merged = annos.filter(anno => !hasMultipleBodies(anno)) + // How many of the gathered Annotations could contribute, which is a different number. + // Annotations carrying multiple bodies are left out here + const merged = annos.filter(anno => !Array.isArray(anno.body)) res.set('Annotations-Merged', String(merged.length)) - // This deployment's '/expanded' URI, not the entity URI. The entity URI would hand back - // the unexpanded record, and it cannot be the base for this one either -- an entity minted - // by another RERUM carries that host in its stored '@id', and there is no guarantee the - // other host serves '/expanded' at all. RERUM_ID_PREFIX is how idNegotiation() mints ids, - // so this stays on the host actually answering the request. Resolved through 'new URL()', - // the same way gog.js builds its expanded Location. const expandedLocation = new URL(`/v1/id/${match._id}/expanded`, process.env.RERUM_ID_PREFIX).href let expanded = applyRawExpansion(match, annos) expanded = idNegotiation(expanded) diff --git a/controllers/gog.js b/controllers/gog.js index 166c02f4..db011660 100644 --- a/controllers/gog.js +++ b/controllers/gog.js @@ -351,9 +351,6 @@ const expand = async function(primitiveEntity, GENERATOR=undefined, CREATOR=unde let expandedEntity = structuredClone(primitiveEntity) for(const anno of matches){ const body = anno.body - // Array.isArray() as well as the typeof check. An Array is a typeof 'object', and - // Object.keys() on a one element Array is ["0"] -- a length of 1 that would pass the - // single assertion check below and merge the body onto the entity under the key "0". // Annotations carrying multiple bodies are not expanded with. if(!body || typeof body !== "object" || Array.isArray(body)) continue const keys = Object.keys(body) @@ -369,8 +366,6 @@ const expand = async function(primitiveEntity, GENERATOR=undefined, CREATOR=unde }, evidence: assertion?.evidence ?? anno.evidence ?? "" } - // Object.hasOwn() rather than the method on the entity. A merged assertion named - // 'hasOwnProperty' would shadow the method and throw a TypeError on the next iteration. if(Object.hasOwn(expandedEntity, key)){ expandedEntity[key] = Array.isArray(expandedEntity[key]) ? [...expandedEntity[key], valueObject] @@ -408,10 +403,6 @@ const expandedId = async function (req, res, next) { }) return next(utils.createExpressError(err)) } - // This '/gog/id' URI, not the entity URI. This response is the expanded representation, - // and the entity URI would hand back the unexpanded record instead. Built off - // RERUM_ID_PREFIX so the origin follows the deployment, and captured before expand() in - // case idNegotiation() reaches the match itself and drops '_id'. const expandedLocation = new URL(`/gog/id/${match._id}`, process.env.RERUM_ID_PREFIX).href // Same browser-caching policy as GET /v1/id/:_id so this stable URI is cached (24h). res.set(utils.configureWebAnnoHeadersFor(match)) diff --git a/controllers/utils.js b/controllers/utils.js index e5886e7e..d3c7e12a 100644 --- a/controllers/utils.js +++ b/controllers/utils.js @@ -109,18 +109,18 @@ const generateSlugId = async function(slug_id="", next){ return slug_return } -// RERUM has minted these two under both 'http' and 'https' over the years, so a filter on either -// must match both spellings. Every other supplied filter key is applied exactly as given. +/** + * RERUM has minted these two under both 'http' and 'https' over the years, so a filter on either + * must match both spellings. Every other supplied filter key is applied exactly as given. + */ const URI_DOUBLED_FILTER_KEYS = new Set(["__rerum.generatedBy", "creator"]) -// The properties an Annotation can carry the URI of its target under. -// Choice, Composite, and List target constructs, whose members sit in an 'items' Array. They are not supported here. +/** + * The properties an Annotation can carry the URI of its target under. + * Choice, Composite, and List target constructs, whose members sit in an 'items' Array. They are not supported here. + */ const TARGET_KEYS = ["target", "target.@id", "target.id", "target.source", "target.source.@id", "target.source.id"] -// The JSON-LD expanded form of the Annotation type, alongside the compact 'Annotation' and the -// 'oa:' prefixed spellings. -const OA_ANNOTATION_IRI = "http://www.w3.org/ns/oa#Annotation" - /** * Escape the RegExp metacharacters in a literal so it can be embedded in a pattern and match only * itself. A RERUM URI has at least the dots of its host to escape. @@ -146,36 +146,24 @@ function escapeRegex(literal) { * - {"@type": "Annotation"}, {"@type": "oa:Annotation"}, {"@type": "http://www.w3.org/ns/oa#Annotation"} * * @param targetId The '@id' or 'id' URI of the entity being expanded. - * @param filters Literal MongoDB filter keys to AND into the query. Already sanitized by the - * caller -- the leaf, type, and target constraints here cannot be overruled. - * @param pagination A {limit, skip} pair from getPagination(). When supplied, the query is sorted - * by '_id' first -- Mongo's natural order is not stable across paged calls, so - * without a sort a client walking pages could miss or repeat Annotations. - * Omit it to fetch every match, which is the long standing expand() behavior. + * @param filters Literal MongoDB filter keys to AND into the query. + * @param pagination A {limit, skip} pair from getPagination(). * @return An Array of matching Annotation documents, with '_id' removed. */ const findLeafAnnotationsFor = async function (targetId, filters = {}, pagination = null) { // '$and' is always present so the filter conditions below can push into it from either branch. - // 'annoTypeConditions' is always pushed, so it is never the empty Array Mongo rejects. const queryObj = { "__rerum.history.next": { $exists: true, $size: 0 }, "$and": [] } - // The compact, the 'oa:' prefixed, and the JSON-LD expanded IRI spellings of the Annotation - // type. All three are valid ways to say the same thing, and RERUM stores whatever it is given. const annoTypeConditions = [ - {"type": "Annotation"}, {"type": "oa:Annotation"}, {"type": OA_ANNOTATION_IRI}, - {"@type": "Annotation"}, {"@type": "oa:Annotation"}, {"@type": OA_ANNOTATION_IRI} + {"type": "Annotation"}, {"type": "oa:Annotation"}, {"type": "http://www.w3.org/ns/oa#Annotation"}, + {"@type": "Annotation"}, {"@type": "oa:Annotation"}, {"@type": "http://www.w3.org/ns/oa#Annotation"} ] - // The same scheme test the filter values below use. 'startsWith("http")' would also pass a URI - // like 'httpx://host/thing', which the scheme swap would then mangle into 'httpsx://host/thing'. if (/^https?:\/\//.test(targetId)) { const targetConditions = [] // Hanging a fragment off the URI is the other W3C way to target part of a resource rather - // than the whole of it, and an exact match will not catch one. Anchored at the front and - // terminated by the '#' so the pattern cannot spill onto a longer id. One pattern per - // scheme rather than a single '^https?' -- Mongo bounds an index scan by the pattern's - // literal prefix, and '^https?' leaves it only 'http', which is every target URI stored. + // than the whole of it, and an exact match will not catch one. const fragmentPatterns = ["http", "https"].map(scheme => new RegExp(`^${escapeRegex(targetId.replace(/^https?/, scheme))}#`) ) From 635061b7c66075caddcd5979730e02c51561195f Mon Sep 17 00:00:00 2001 From: Bryan Haberberger Date: Mon, 17 Aug 2026 13:45:07 -0500 Subject: [PATCH 08/48] comment cleanup --- database/__mocks__/index.js | 1 - 1 file changed, 1 deletion(-) diff --git a/database/__mocks__/index.js b/database/__mocks__/index.js index b155179a..51ec6a2c 100644 --- a/database/__mocks__/index.js +++ b/database/__mocks__/index.js @@ -41,7 +41,6 @@ function createMockFunction(implementation = () => undefined) { function createCursor() { return { - sort: createMockFunction(function () { return this }), limit: createMockFunction(function () { return this }), skip: createMockFunction(function () { return this }), toArray: createMockFunction(() => Promise.resolve([])) From 749cc51ba15a3c7f9d6c627ffc808c50c3a9c368 Mon Sep 17 00:00:00 2001 From: Bryan Haberberger Date: Mon, 17 Aug 2026 15:52:12 -0500 Subject: [PATCH 09/48] cleanup during review --- controllers/gog.js | 12 ++---------- public/API.html | 3 +++ 2 files changed, 5 insertions(+), 10 deletions(-) diff --git a/controllers/gog.js b/controllers/gog.js index db011660..2eb4ecdf 100644 --- a/controllers/gog.js +++ b/controllers/gog.js @@ -316,16 +316,8 @@ const _gog_glosses_from_manuscript = async function (req, res, next) { * - anno.body * - anno.body.value * -* Anticipate likely Annotation target formats -* - target: 'uri' -* - target: {'id':'uri'} -* - target: {'@id':'uri'} -* -* Anticipate likely Annotation type formats -* - {"type": "Annotation"} -* - {"type": "oa:Annotation"} -* - {"@type": "Annotation"} -* - {"@type": "oa:Annotation"} +* Gathering the Annotations is findLeafAnnotationsFor() in ./utils.js -- see its docblock for the +* target and Annotation type forms that are recognized. This function only reads their bodies. * * @param primitiveEntity - An existing RERUM object * @param GENERATOR - A registered RERUM app's User Agent diff --git a/public/API.html b/public/API.html index e1b95e30..54f3bec3 100644 --- a/public/API.html +++ b/public/API.html @@ -227,6 +227,9 @@

    Expanded record by id

    language survive.
  • When several Annotations assert the same property, or the record already carries it, the values collect into an Array. Array order is not guaranteed.
  • +
  • A property merged from a single Annotation is that value as-is; the same property merged from two or + more is an Array. Treat every merged property as possibly-an-Array—the shape depends on how many + Annotations exist, which changes over the record's life.
  • Identity and system properties are never overwritten: @id, id, From 578fa9e77a3051456a089c6a82eed203459fcf2e Mon Sep 17 00:00:00 2001 From: Bryan Haberberger Date: Tue, 18 Aug 2026 10:56:14 -0500 Subject: [PATCH 10/48] Better to have location and @id match. We can consider whether the Location header and/or @id should be the /id/_id/expanded URI instead. However, downstream logic and behaviors expect the @id to come back for object validation. It is better to keep those aligned for now --- controllers/crud.js | 5 +++-- controllers/gog.js | 5 +++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/controllers/crud.js b/controllers/crud.js index c108de83..56e617c9 100644 --- a/controllers/crud.js +++ b/controllers/crud.js @@ -274,10 +274,11 @@ const idExpanded = async function (req, res, next) { // Annotations carrying multiple bodies are left out here const merged = annos.filter(anno => !Array.isArray(anno.body)) res.set('Annotations-Merged', String(merged.length)) - const expandedLocation = new URL(`/v1/id/${match._id}/expanded`, process.env.RERUM_ID_PREFIX).href let expanded = applyRawExpansion(match, annos) expanded = idNegotiation(expanded) - res.location(expandedLocation) + //const expandedLocation = new URL(`${match._id}/expanded`, process.env.RERUM_ID_PREFIX).href + //res.location(expandedLocation) + res.location(expanded["@id"] ?? expanded.id) res.json(expanded) } catch (error) { return next(utils.createExpressError(error)) diff --git a/controllers/gog.js b/controllers/gog.js index 2eb4ecdf..2f79dd6c 100644 --- a/controllers/gog.js +++ b/controllers/gog.js @@ -395,7 +395,6 @@ const expandedId = async function (req, res, next) { }) return next(utils.createExpressError(err)) } - const expandedLocation = new URL(`/gog/id/${match._id}`, process.env.RERUM_ID_PREFIX).href // Same browser-caching policy as GET /v1/id/:_id so this stable URI is cached (24h). res.set(utils.configureWebAnnoHeadersFor(match)) res.set("Cache-Control", "max-age=86400, must-revalidate") @@ -404,7 +403,9 @@ const expandedId = async function (req, res, next) { res.set("Current-Overwritten-Version", match.__rerum?.isOverwritten ?? "") let expanded = await expand(match, generator) expanded = idNegotiation(expanded) - res.location(expandedLocation) + //const expandedLocation = new URL(`/gog/id/${match._id}`, process.env.RERUM_PREFIX).href + //res.location(expandedLocation) + res.location(expanded["@id"] ?? expanded.id) res.json(expanded) } catch (error) { return next(utils.createExpressError(error)) From 6d6c4d439df82aec5aba975fb7d927145c84f891 Mon Sep 17 00:00:00 2001 From: Bryan Haberberger Date: Tue, 18 Aug 2026 11:12:41 -0500 Subject: [PATCH 11/48] Paginate on the back end for the /expanded endpoints. Clients/browsers cannot be expected to paginate simple GET requests. The response is now fully assembled. --- __tests__/utils.test.js | 83 +++++++++++++++++++- controllers/crud.js | 9 ++- controllers/utils.js | 37 ++++++--- database/__mocks__/index.js | 1 + openapi/contracts/core-provider.openapi.yaml | 33 ++------ public/API.html | 21 ++--- 6 files changed, 128 insertions(+), 56 deletions(-) diff --git a/__tests__/utils.test.js b/__tests__/utils.test.js index 900ca7c3..19fca238 100644 --- a/__tests__/utils.test.js +++ b/__tests__/utils.test.js @@ -9,7 +9,8 @@ import { parseDocumentID, _contextid, idNegotiation, - getPagination + getPagination, + findLeafAnnotationsFor } from '../controllers/utils.js' import { db, resetMocks } from '../database/index.js' @@ -288,3 +289,83 @@ describe('controllers/utils.js getPagination', () => { assert.ok(result.limit < huge, `limit should be clamped below ${huge}`) }) }) + +describe('controllers/utils.js findLeafAnnotationsFor', () => { + const targetURI = 'https://store.rerum.io/v1/id/entity' + + const buildAnnotations = (total) => Array.from({ length: total }, (_, i) => ({ + _id: `anno${String(i).padStart(5, '0')}`, + type: 'Annotation', + target: targetURI, + body: { position: i } + })) + + /** + * Stand in for the driver cursor so the gather can be watched round trip by round trip. + * Returns the {limit, skip} of each trip the gather actually made. + */ + const mockPagedFind = (stored) => { + const roundTrips = [] + db.find.mockImplementation(() => { + const state = { limit: stored.length, skip: 0 } + const cursor = { + sort: () => cursor, + limit: (value) => { state.limit = value; return cursor }, + skip: (value) => { state.skip = value; return cursor }, + toArray: async () => { + roundTrips.push({ limit: state.limit, skip: state.skip }) + return stored.slice(state.skip, state.skip + state.limit).map(anno => ({ ...anno })) + } + } + return cursor + }) + return roundTrips + } + + it('gathers every targeting Annotation, however many round trips that takes', async () => { + resetMocks() + const stored = buildAnnotations(1001) + const roundTrips = mockPagedFind(stored) + + const annos = await findLeafAnnotationsFor(targetURI) + + // The client asked once and is owed all 1001. There is no page to hand back. + assert.strictEqual(annos.length, stored.length) + assert.deepStrictEqual(annos.map(anno => anno.body.position), stored.map((_, i) => i)) + const batchSize = roundTrips[0].limit + assert.ok(batchSize < stored.length, 'this case must exceed one batch to exercise the walk') + assert.ok(roundTrips.length > 1, 'gathering more than a batch takes more than one round trip') + // Each trip skips past everything gathered so far, so nothing is read twice or missed. + const expectedSkips = roundTrips.map((_, i) => Math.min(i * batchSize, stored.length)) + assert.deepStrictEqual(roundTrips.map(trip => trip.skip), expectedSkips) + }) + + it('strips the internal _id from every gathered Annotation', async () => { + resetMocks() + mockPagedFind(buildAnnotations(3)) + + const annos = await findLeafAnnotationsFor(targetURI) + + assert.strictEqual(annos.length, 3) + assert.ok(annos.every(anno => !Object.hasOwn(anno, '_id')), '_id is internal and never returned') + }) + + it('makes a single round trip when the first batch is short', async () => { + resetMocks() + const roundTrips = mockPagedFind(buildAnnotations(3)) + + await findLeafAnnotationsFor(targetURI) + + assert.strictEqual(roundTrips.length, 1) + }) + + it('returns an empty Array when nothing targets the entity', async () => { + resetMocks() + const roundTrips = mockPagedFind([]) + + const annos = await findLeafAnnotationsFor(targetURI) + + assert.deepStrictEqual(annos, []) + assert.strictEqual(roundTrips.length, 1) + }) +}) diff --git a/controllers/crud.js b/controllers/crud.js index 56e617c9..e5212ac6 100644 --- a/controllers/crud.js +++ b/controllers/crud.js @@ -227,13 +227,13 @@ function applyRawExpansion(primitiveEntity, annos) { * * GET recognizes the '?generator=' and '?creator=' convenience parameters only. * POST reads literal MongoDB filter keys from the JSON body and ignores URL parameters as filters. - * Both methods page the Annotation search with the usual '?limit=' and '?skip=' parameters. + * Neither method pages. A client asks once and receives the entity assembled from every current + * Annotation targeting it -- findLeafAnnotationsFor() does whatever gathering that takes. * */ const idExpanded = async function (req, res, next) { res.set("Content-Type", "application/json; charset=utf-8") const id = req.params["_id"] const isPost = req.method === "POST" - const pagination = getPagination(req.query, 200) let filters = {} if (isPost) { //Express leaves the body undefined when a POST supplies none. That is an unfiltered expand. @@ -267,8 +267,9 @@ const idExpanded = async function (req, res, next) { // Include current version for optimistic locking res.set('Current-Overwritten-Version', match.__rerum?.isOverwritten ?? "") const targetId = match["@id"] ?? match.id - const annos = targetId ? await findLeafAnnotationsFor(targetId, filters, pagination) : [] - // Let clients detect a full page. When this equals the limit there may be more to gather. + const annos = targetId ? await findLeafAnnotationsFor(targetId, filters) : [] + // Informational only. Every current Annotation targeting the entity is gathered, so this + // is the whole count and never a partial one. res.set('Annotations-Gathered', String(annos.length)) // How many of the gathered Annotations could contribute, which is a different number. // Annotations carrying multiple bodies are left out here diff --git a/controllers/utils.js b/controllers/utils.js index d3c7e12a..67db1272 100644 --- a/controllers/utils.js +++ b/controllers/utils.js @@ -12,6 +12,13 @@ const ObjectID = newID const MAX_QUERY_LIMIT = Number.parseInt(process.env.RERUM_MAX_QUERY_LIMIT ?? 500, 10) const MAX_QUERY_SKIP = Number.parseInt(process.env.RERUM_MAX_QUERY_SKIP ?? 100000, 10) +/** + * How many Annotations findLeafAnnotationsFor() pulls per round trip while gathering. + * This is an internal batch size, not a client facing page size. The gather does not stop until + * the database has no more matches, so this only decides how many round trips that takes. + */ +const EXPANSION_BATCH_SIZE = 500 + function clampNonNegativeInt(value, fallback, max) { const parsed = Number.parseInt(value, 10) if (!Number.isFinite(parsed) || parsed <= 0) return fallback @@ -145,12 +152,15 @@ function escapeRegex(literal) { * - {"type": "Annotation"}, {"type": "oa:Annotation"}, {"type": "http://www.w3.org/ns/oa#Annotation"} * - {"@type": "Annotation"}, {"@type": "oa:Annotation"}, {"@type": "http://www.w3.org/ns/oa#Annotation"} * + * Every match is gathered. The caller expands with all of them, so this walks the result set in + * batches of EXPANSION_BATCH_SIZE until the database has no more to give. There is no page for a + * client to ask for and no truncation to report -- an expansion of 1000 Annotations gathers 1000. + * * @param targetId The '@id' or 'id' URI of the entity being expanded. * @param filters Literal MongoDB filter keys to AND into the query. - * @param pagination A {limit, skip} pair from getPagination(). - * @return An Array of matching Annotation documents, with '_id' removed. + * @return An Array of every matching Annotation document, with '_id' removed. */ -const findLeafAnnotationsFor = async function (targetId, filters = {}, pagination = null) { +const findLeafAnnotationsFor = async function (targetId, filters = {}) { // '$and' is always present so the filter conditions below can push into it from either branch. const queryObj = { "__rerum.history.next": { $exists: true, $size: 0 }, @@ -190,14 +200,19 @@ const findLeafAnnotationsFor = async function (targetId, filters = {}, paginatio } queryObj["$and"].push({ [key]: value }) } - // Get the Annotations targeting this Entity from the db. Remove _id property. - let cursor = db.find(queryObj) - if (pagination) cursor = cursor.sort({ "_id": 1 }).limit(pagination.limit).skip(pagination.skip) - const matches = await cursor.toArray() - return matches.map(o => { - delete o._id - return o - }) + // Get every Annotation targeting this Entity from the db. Remove _id property. + // Sorting on '_id' keeps the walk stable across the round trips, since a batch is skipped past + // by how many Annotations have already been gathered. + const matches = [] + let batch = [] + do { + batch = await db.find(queryObj).sort({ "_id": 1 }).limit(EXPANSION_BATCH_SIZE).skip(matches.length).toArray() + for (const anno of batch) { + delete anno._id + matches.push(anno) + } + } while (batch.length === EXPANSION_BATCH_SIZE) + return matches } // Handle index actions diff --git a/database/__mocks__/index.js b/database/__mocks__/index.js index 51ec6a2c..b155179a 100644 --- a/database/__mocks__/index.js +++ b/database/__mocks__/index.js @@ -41,6 +41,7 @@ function createMockFunction(implementation = () => undefined) { function createCursor() { return { + sort: createMockFunction(function () { return this }), limit: createMockFunction(function () { return this }), skip: createMockFunction(function () { return this }), toArray: createMockFunction(() => Promise.resolve([])) diff --git a/openapi/contracts/core-provider.openapi.yaml b/openapi/contracts/core-provider.openapi.yaml index fa603009..1807f701 100644 --- a/openapi/contracts/core-provider.openapi.yaml +++ b/openapi/contracts/core-provider.openapi.yaml @@ -47,6 +47,10 @@ paths: /id/{id}/expanded: get: summary: Read object by id with its current Annotations merged in + description: >- + Not paged. Every current Annotation targeting the entity is gathered server side before the + expanded entity is assembled, so a client makes a single request. The 'Annotations-Gathered' + and 'Annotations-Merged' response headers report complete counts. operationId: getExpandedObjectById parameters: - $ref: '#/components/parameters/ObjectId' @@ -62,18 +66,6 @@ paths: description: Only expand with Annotations attributed to this creator. schema: type: string - - in: query - name: limit - required: false - description: Maximum Annotations to gather. Defaults to 200. - schema: - type: integer - - in: query - name: skip - required: false - description: Annotations to skip before gathering. Defaults to 0. - schema: - type: integer responses: '200': description: Expanded object payload @@ -98,24 +90,13 @@ paths: operationId: postExpandedObjectById description: >- The request body is an object of literal MongoDB filter properties ANDed into the search for - Annotations targeting the entity. URL query parameters supply no filters, though 'limit' and - 'skip' still page the search. The leaf version, the Annotation type, and the target + Annotations targeting the entity. URL query parameters supply no filters. Like the GET, the + search is not paged -- every Annotation matching the filters is gathered server side before + the expanded entity is assembled. The leaf version, the Annotation type, and the target constraints are applied automatically and cannot be overruled, so 'target', 'type', '@type', and '__rerum.history' keys are ignored. parameters: - $ref: '#/components/parameters/ObjectId' - - in: query - name: limit - required: false - description: Maximum Annotations to gather. Defaults to 200. - schema: - type: integer - - in: query - name: skip - required: false - description: Annotations to skip before gathering. Defaults to 0. - schema: - type: integer requestBody: required: false content: diff --git a/public/API.html b/public/API.html index 54f3bec3..0691be91 100644 --- a/public/API.html +++ b/public/API.html @@ -182,27 +182,21 @@

    Expanded record by id

    https spellings of the URI are matched.
  • ?creator—optional. Only expand with Annotations attributed to this creator.
  • -
  • ?limit and - ?skip—optional. Page the - Annotation search. limit defaults to 200.
  • Response: {JSON}—The record with identifier _id, plus the merged properties.
  • Response header Annotations-Gathered—how many - Annotations this expansion gathered, which is the size of the page you received. When it equals your - limit there may be more, and the record you - received was expanded from only part of its Annotations. Raise the - limit or walk pages with - skip.
  • + Annotations this expansion gathered. There is no paging on this endpoint. RERUM gathers + every current Annotation targeting the record before it assembles the response, however many that is, so + this is a complete count and never a partial one. It is informational—a client makes one request and + receives the fully expanded record.
  • Response header Annotations-Merged—how many of those gathered Annotations could contribute to the record. It is always less than or equal to Annotations-Gathered, and Annotations with multiple bodies are not counted, as that form is not read yet. A counted Annotation may still assert - nothing mergeable, so this is not a count of the properties you received either. Use - Annotations-Gathered, not this one, to decide - whether to keep paging.
  • + nothing mergeable, so this is not a count of the properties you received either.

    Only the current (leaf) versions of Annotations are gathered. No token is required. @@ -889,9 +883,8 @@

    Expanded record with filters

  • URL parameters supply no filters. The ?generator and ?creator parameters of the GET have no effect - here—supply them in the body instead. Paging is not a filter, so - ?limit and - ?skip still work, and the + here—supply them in the body instead. Like the GET, this is not paged: every Annotation matching your + filters is gathered before the response is assembled, and the Annotations-Gathered and Annotations-Merged response headers are set exactly as they are on the GET.
  • From 6ef2db314fed2cb010779a65b47325df17cc5451 Mon Sep 17 00:00:00 2001 From: Bryan Haberberger Date: Tue, 18 Aug 2026 11:17:07 -0500 Subject: [PATCH 12/48] No tests yet --- __tests__/utils.test.js | 83 +---------------------------------------- 1 file changed, 1 insertion(+), 82 deletions(-) diff --git a/__tests__/utils.test.js b/__tests__/utils.test.js index 19fca238..900ca7c3 100644 --- a/__tests__/utils.test.js +++ b/__tests__/utils.test.js @@ -9,8 +9,7 @@ import { parseDocumentID, _contextid, idNegotiation, - getPagination, - findLeafAnnotationsFor + getPagination } from '../controllers/utils.js' import { db, resetMocks } from '../database/index.js' @@ -289,83 +288,3 @@ describe('controllers/utils.js getPagination', () => { assert.ok(result.limit < huge, `limit should be clamped below ${huge}`) }) }) - -describe('controllers/utils.js findLeafAnnotationsFor', () => { - const targetURI = 'https://store.rerum.io/v1/id/entity' - - const buildAnnotations = (total) => Array.from({ length: total }, (_, i) => ({ - _id: `anno${String(i).padStart(5, '0')}`, - type: 'Annotation', - target: targetURI, - body: { position: i } - })) - - /** - * Stand in for the driver cursor so the gather can be watched round trip by round trip. - * Returns the {limit, skip} of each trip the gather actually made. - */ - const mockPagedFind = (stored) => { - const roundTrips = [] - db.find.mockImplementation(() => { - const state = { limit: stored.length, skip: 0 } - const cursor = { - sort: () => cursor, - limit: (value) => { state.limit = value; return cursor }, - skip: (value) => { state.skip = value; return cursor }, - toArray: async () => { - roundTrips.push({ limit: state.limit, skip: state.skip }) - return stored.slice(state.skip, state.skip + state.limit).map(anno => ({ ...anno })) - } - } - return cursor - }) - return roundTrips - } - - it('gathers every targeting Annotation, however many round trips that takes', async () => { - resetMocks() - const stored = buildAnnotations(1001) - const roundTrips = mockPagedFind(stored) - - const annos = await findLeafAnnotationsFor(targetURI) - - // The client asked once and is owed all 1001. There is no page to hand back. - assert.strictEqual(annos.length, stored.length) - assert.deepStrictEqual(annos.map(anno => anno.body.position), stored.map((_, i) => i)) - const batchSize = roundTrips[0].limit - assert.ok(batchSize < stored.length, 'this case must exceed one batch to exercise the walk') - assert.ok(roundTrips.length > 1, 'gathering more than a batch takes more than one round trip') - // Each trip skips past everything gathered so far, so nothing is read twice or missed. - const expectedSkips = roundTrips.map((_, i) => Math.min(i * batchSize, stored.length)) - assert.deepStrictEqual(roundTrips.map(trip => trip.skip), expectedSkips) - }) - - it('strips the internal _id from every gathered Annotation', async () => { - resetMocks() - mockPagedFind(buildAnnotations(3)) - - const annos = await findLeafAnnotationsFor(targetURI) - - assert.strictEqual(annos.length, 3) - assert.ok(annos.every(anno => !Object.hasOwn(anno, '_id')), '_id is internal and never returned') - }) - - it('makes a single round trip when the first batch is short', async () => { - resetMocks() - const roundTrips = mockPagedFind(buildAnnotations(3)) - - await findLeafAnnotationsFor(targetURI) - - assert.strictEqual(roundTrips.length, 1) - }) - - it('returns an empty Array when nothing targets the entity', async () => { - resetMocks() - const roundTrips = mockPagedFind([]) - - const annos = await findLeafAnnotationsFor(targetURI) - - assert.deepStrictEqual(annos, []) - assert.strictEqual(roundTrips.length, 1) - }) -}) From 3c49b78c726585cb59a3b3b33a899efedd621fe8 Mon Sep 17 00:00:00 2001 From: Bryan Haberberger Date: Tue, 18 Aug 2026 11:18:12 -0500 Subject: [PATCH 13/48] No tests yet --- database/__mocks__/index.js | 1 - 1 file changed, 1 deletion(-) diff --git a/database/__mocks__/index.js b/database/__mocks__/index.js index b155179a..51ec6a2c 100644 --- a/database/__mocks__/index.js +++ b/database/__mocks__/index.js @@ -41,7 +41,6 @@ function createMockFunction(implementation = () => undefined) { function createCursor() { return { - sort: createMockFunction(function () { return this }), limit: createMockFunction(function () { return this }), skip: createMockFunction(function () { return this }), toArray: createMockFunction(() => Promise.resolve([])) From 145018391910db2d30ed56996b1e5aade1625b84 Mon Sep 17 00:00:00 2001 From: Bryan Haberberger Date: Tue, 18 Aug 2026 12:18:24 -0500 Subject: [PATCH 14/48] Manual cleanup --- controllers/crud.js | 44 +++++++++++++++++--------------------------- controllers/gog.js | 6 ++++-- controllers/utils.js | 8 +------- 3 files changed, 22 insertions(+), 36 deletions(-) diff --git a/controllers/crud.js b/controllers/crud.js index e5212ac6..b6160129 100644 --- a/controllers/crud.js +++ b/controllers/crud.js @@ -127,25 +127,18 @@ const id = async function (req, res, next) { } } -/** - * The expand job always constrains the Annotations it gathers to the leaf versions, to the - * Annotation types, and to the entity in the request URI. A client cannot influence those, so - * these keys are dropped from a supplied filter body by exact name or dotted prefix. - */ -const RESERVED_FILTER_KEYS = ["target", "type", "@type", "__rerum.history"] - -/** - * Identity and system properties an Annotation body must never overwrite. '@id' and '@context' - * are read by idNegotiation() and res.location() - */ -const PROTECTED_EXPANSION_KEYS = new Set(["@id", "id", "_id", "__rerum", "__deleted", "@context", "__proto__"]) - /** * Reduce a supplied POST body to the literal MongoDB filter keys the expand job will honor. * @param supplied The parsed JSON request body. * @return An object of filter keys, minus the ones this endpoint owns. */ function sanitizeExpansionFilters(supplied) { + /** + * The expand job always constrains the Annotations it gathers to the leaf versions, to the + * Annotation types, and to the entity in the request URI. A client cannot influence those, so + * these keys are dropped from a supplied filter body by exact name or dotted prefix. + */ + const RESERVED_FILTER_KEYS = ["target", "type", "@type", "__rerum.history"] const filters = {} for (const [key, value] of Object.entries(supplied)) { if (RESERVED_FILTER_KEYS.some(reserved => key === reserved || key.startsWith(`${reserved}.`))) continue @@ -154,11 +147,6 @@ function sanitizeExpansionFilters(supplied) { return filters } -/** - * The Annotation body types whose value is kept whole rather than read as a single assertion. - */ -const TEXTUAL_BODY_TYPES = new Set(["TextualBody", "oa:TextualBody", "http://www.w3.org/ns/oa#TextualBody"]) - /** * The [key, value] assertions an Annotation makes about the entity it targets. * Only 'body' and 'bodyValue' are read -- an Annotation carrying neither is ignored, and no other @@ -175,6 +163,10 @@ const TEXTUAL_BODY_TYPES = new Set(["TextualBody", "oa:TextualBody", "http://www * @return An Array of [key, value] pairs to merge onto the entity. */ function assertionsFrom(anno) { + /** + * The Annotation body types whose value is kept whole rather than read as a single assertion. + */ + const TEXTUAL_BODY_TYPES = new Set(["TextualBody", "oa:TextualBody", "http://www.w3.org/ns/oa#TextualBody"]) const assertions = [] if (typeof anno.bodyValue === "string") assertions.push(["bodyValue", anno.bodyValue]) const body = anno.body @@ -202,6 +194,8 @@ function assertionsFrom(anno) { * @return A new, expanded entity object. */ function applyRawExpansion(primitiveEntity, annos) { + //Identity and system properties an Annotation body must never overwrite. '@id' and '@context' + const PROTECTED_EXPANSION_KEYS = new Set(["@id", "id", "_id", "__rerum", "__deleted", "@context", "__proto__"]) const expandedEntity = structuredClone(primitiveEntity) // Hold __rerum aside so it can be re-appended after the merged properties. It will be the last property. const rerumProp = expandedEntity.__rerum @@ -248,7 +242,7 @@ const idExpanded = async function (req, res, next) { filters = sanitizeExpansionFilters(supplied) } else { - //Repeated query parameters arrive as an Array, which is not a filter value we support. + // Repeated query parameters arrive as an Array, which is not a filter value we will apply. if (typeof req.query.generator === "string" && req.query.generator) filters["__rerum.generatedBy"] = req.query.generator if (typeof req.query.creator === "string" && req.query.creator) filters.creator = req.query.creator } @@ -268,17 +262,13 @@ const idExpanded = async function (req, res, next) { res.set('Current-Overwritten-Version', match.__rerum?.isOverwritten ?? "") const targetId = match["@id"] ?? match.id const annos = targetId ? await findLeafAnnotationsFor(targetId, filters) : [] - // Informational only. Every current Annotation targeting the entity is gathered, so this - // is the whole count and never a partial one. + // Every leaf Annotation matching the filter is gathered. This is the count. res.set('Annotations-Gathered', String(annos.length)) - // How many of the gathered Annotations could contribute, which is a different number. - // Annotations carrying multiple bodies are left out here - const merged = annos.filter(anno => !Array.isArray(anno.body)) + // How many of the Annotations contribute an assertion. May be less than annotations gathered. + const merged = annos.filter(anno => assertionsFrom(anno).length > 0) res.set('Annotations-Merged', String(merged.length)) - let expanded = applyRawExpansion(match, annos) + let expanded = applyRawExpansion(match, merged) expanded = idNegotiation(expanded) - //const expandedLocation = new URL(`${match._id}/expanded`, process.env.RERUM_ID_PREFIX).href - //res.location(expandedLocation) res.location(expanded["@id"] ?? expanded.id) res.json(expanded) } catch (error) { diff --git a/controllers/gog.js b/controllers/gog.js index 2f79dd6c..0d3a3c9b 100644 --- a/controllers/gog.js +++ b/controllers/gog.js @@ -341,6 +341,9 @@ const expand = async function(primitiveEntity, GENERATOR=undefined, CREATOR=unde // Mirror DEER's client-side expand() (deer-utils.js buildValueObject) // When more than one current Annotation asserts the same key, collect the values into an Array. let expandedEntity = structuredClone(primitiveEntity) + // Hold __rerum aside so it can be re-appended after the merged properties. It will be the last property. + const rerumProp = expandedEntity.__rerum + delete expandedEntity.__rerum for(const anno of matches){ const body = anno.body // Annotations carrying multiple bodies are not expanded with. @@ -367,6 +370,7 @@ const expand = async function(primitiveEntity, GENERATOR=undefined, CREATOR=unde expandedEntity[key] = valueObject } } + if(rerumProp !== undefined) expandedEntity.__rerum = rerumProp return expandedEntity } @@ -403,8 +407,6 @@ const expandedId = async function (req, res, next) { res.set("Current-Overwritten-Version", match.__rerum?.isOverwritten ?? "") let expanded = await expand(match, generator) expanded = idNegotiation(expanded) - //const expandedLocation = new URL(`/gog/id/${match._id}`, process.env.RERUM_PREFIX).href - //res.location(expandedLocation) res.location(expanded["@id"] ?? expanded.id) res.json(expanded) } catch (error) { diff --git a/controllers/utils.js b/controllers/utils.js index 67db1272..607a0602 100644 --- a/controllers/utils.js +++ b/controllers/utils.js @@ -12,13 +12,6 @@ const ObjectID = newID const MAX_QUERY_LIMIT = Number.parseInt(process.env.RERUM_MAX_QUERY_LIMIT ?? 500, 10) const MAX_QUERY_SKIP = Number.parseInt(process.env.RERUM_MAX_QUERY_SKIP ?? 100000, 10) -/** - * How many Annotations findLeafAnnotationsFor() pulls per round trip while gathering. - * This is an internal batch size, not a client facing page size. The gather does not stop until - * the database has no more matches, so this only decides how many round trips that takes. - */ -const EXPANSION_BATCH_SIZE = 500 - function clampNonNegativeInt(value, fallback, max) { const parsed = Number.parseInt(value, 10) if (!Number.isFinite(parsed) || parsed <= 0) return fallback @@ -161,6 +154,7 @@ function escapeRegex(literal) { * @return An Array of every matching Annotation document, with '_id' removed. */ const findLeafAnnotationsFor = async function (targetId, filters = {}) { + const EXPANSION_BATCH_SIZE = 200 // '$and' is always present so the filter conditions below can push into it from either branch. const queryObj = { "__rerum.history.next": { $exists: true, $size: 0 }, From 85f1b6b9c570ff61586fe1e776a8c2b2b44f0d86 Mon Sep 17 00:00:00 2001 From: Bryan Haberberger Date: Tue, 18 Aug 2026 13:02:50 -0500 Subject: [PATCH 15/48] Changes during review. Supporting Annotations whose body is an Array but only has one {} in it, as we can treat that the same way as a body that is just {} for expanding purposes. --- controllers/crud.js | 20 +++++++++++--------- controllers/gog.js | 7 +++++-- controllers/utils.js | 19 ++++++++++++++++--- public/API.html | 7 ++++++- 4 files changed, 38 insertions(+), 15 deletions(-) diff --git a/controllers/crud.js b/controllers/crud.js index b6160129..837c5ebc 100644 --- a/controllers/crud.js +++ b/controllers/crud.js @@ -6,7 +6,7 @@ */ import { newID, isValidID, db } from '../database/index.js' import utils from '../utils.js' -import { _contextid, idNegotiation, getPagination, generateSlugId, ObjectID, getAgentClaim, parseDocumentID, findLeafAnnotationsFor } from './utils.js' +import { _contextid, idNegotiation, getPagination, generateSlugId, ObjectID, getAgentClaim, parseDocumentID, findLeafAnnotationsFor, PROTECTED_EXPANSION_KEYS } from './utils.js' /** * Create a new Linked Open Data object in RERUM v1. @@ -158,6 +158,7 @@ function sanitizeExpansionFilters(supplied) { * - body: {'key': {...}} a single assertion, value kept as-is * - body: {'type':'TextualBody', 'value': 'text', ...} kept whole so 'format' and 'language' survive * - body: {'@type':'oa:TextualBody', ...} the OA prefixed spelling of the same + * - body: [{'key': 'value'}] one body, serialized as a JSON-LD Array * * @param anno An Annotation document. * @return An Array of [key, value] pairs to merge onto the entity. @@ -169,7 +170,9 @@ function assertionsFrom(anno) { const TEXTUAL_BODY_TYPES = new Set(["TextualBody", "oa:TextualBody", "http://www.w3.org/ns/oa#TextualBody"]) const assertions = [] if (typeof anno.bodyValue === "string") assertions.push(["bodyValue", anno.bodyValue]) - const body = anno.body + // In JSON-LD a one-element Array and the bare value are the same body, so unwrap it first. The + // check below is about how many bodies an Annotation carries, not how they were serialized. + const body = Array.isArray(anno.body) && anno.body.length === 1 ? anno.body[0] : anno.body // Skip Annotations carrying multiple bodies, and string bodies that are an IRI referencing an // external resource with no embedded value to expand with. if (Array.isArray(body) || !body || typeof body !== "object") return assertions @@ -190,18 +193,16 @@ function assertionsFrom(anno) { * When more than one current Annotation asserts the same key, or the entity already carries it, * the values collect into an Array. * @param primitiveEntity The unexpanded entity. - * @param annos The Annotations targeting it. + * @param annoAssertions An Array holding the [key, value] assertions read from each Annotation. * @return A new, expanded entity object. */ -function applyRawExpansion(primitiveEntity, annos) { - //Identity and system properties an Annotation body must never overwrite. '@id' and '@context' - const PROTECTED_EXPANSION_KEYS = new Set(["@id", "id", "_id", "__rerum", "__deleted", "@context", "__proto__"]) +function applyRawExpansion(primitiveEntity, annoAssertions) { const expandedEntity = structuredClone(primitiveEntity) // Hold __rerum aside so it can be re-appended after the merged properties. It will be the last property. const rerumProp = expandedEntity.__rerum delete expandedEntity.__rerum - for (const anno of annos) { - for (const [key, value] of assertionsFrom(anno)) { + for (const assertions of annoAssertions) { + for (const [key, value] of assertions) { if (PROTECTED_EXPANSION_KEYS.has(key)) continue if (!Object.hasOwn(expandedEntity, key)) { expandedEntity[key] = value @@ -264,8 +265,9 @@ const idExpanded = async function (req, res, next) { const annos = targetId ? await findLeafAnnotationsFor(targetId, filters) : [] // Every leaf Annotation matching the filter is gathered. This is the count. res.set('Annotations-Gathered', String(annos.length)) + // Read each Annotation once. These assertions are both the merged count and the merge itself. // How many of the Annotations contribute an assertion. May be less than annotations gathered. - const merged = annos.filter(anno => assertionsFrom(anno).length > 0) + const merged = annos.map(anno => assertionsFrom(anno)).filter(assertions => assertions.length > 0) res.set('Annotations-Merged', String(merged.length)) let expanded = applyRawExpansion(match, merged) expanded = idNegotiation(expanded) diff --git a/controllers/gog.js b/controllers/gog.js index 0d3a3c9b..ad2c550d 100644 --- a/controllers/gog.js +++ b/controllers/gog.js @@ -8,7 +8,7 @@ import { newID, isValidID, db } from '../database/index.js' import utils from '../utils.js' -import { ObjectID, getAgentClaim, getPagination, parseDocumentID, idNegotiation, findLeafAnnotationsFor } from './utils.js' +import { ObjectID, getAgentClaim, getPagination, parseDocumentID, idNegotiation, findLeafAnnotationsFor, PROTECTED_EXPANSION_KEYS } from './utils.js' // The Gallery of Glosses agents, by RERUM ObjectId. Prod (store) and dev (devstore) mint different // agents; only the trailing id is compared, so either host spelling matches. @@ -345,12 +345,15 @@ const expand = async function(primitiveEntity, GENERATOR=undefined, CREATOR=unde const rerumProp = expandedEntity.__rerum delete expandedEntity.__rerum for(const anno of matches){ - const body = anno.body + // In JSON-LD a one-element Array and the bare value are the same body, so unwrap it first. + const body = Array.isArray(anno.body) && anno.body.length === 1 ? anno.body[0] : anno.body // Annotations carrying multiple bodies are not expanded with. if(!body || typeof body !== "object" || Array.isArray(body)) continue const keys = Object.keys(body) if(keys.length !== 1) continue const key = keys[0] + // An Annotation body cannot overwrite the entity's identity or its system properties. + if(PROTECTED_EXPANSION_KEYS.has(key)) continue const assertion = body[key] const valueObject = { value: assertion?.value ?? assertion, diff --git a/controllers/utils.js b/controllers/utils.js index 607a0602..9f3a1f04 100644 --- a/controllers/utils.js +++ b/controllers/utils.js @@ -121,6 +121,13 @@ const URI_DOUBLED_FILTER_KEYS = new Set(["__rerum.generatedBy", "creator"]) */ const TARGET_KEYS = ["target", "target.@id", "target.id", "target.source", "target.source.@id", "target.source.id"] +/** + * Identity and system properties an Annotation body must never overwrite when its assertions are + * merged onto an entity. '__proto__' is not data -- emitting it would hand a prototype pollution + * vector to every client that parses the response. + */ +const PROTECTED_EXPANSION_KEYS = new Set(["@id", "id", "_id", "__rerum", "__deleted", "@context", "__proto__"]) + /** * Escape the RegExp metacharacters in a literal so it can be embedded in a pattern and match only * itself. A RERUM URI has at least the dots of its host to escape. @@ -195,12 +202,17 @@ const findLeafAnnotationsFor = async function (targetId, filters = {}) { queryObj["$and"].push({ [key]: value }) } // Get every Annotation targeting this Entity from the db. Remove _id property. - // Sorting on '_id' keeps the walk stable across the round trips, since a batch is skipped past - // by how many Annotations have already been gathered. + // Sorting on '_id' keeps the walk stable across the round trips, and each batch resumes from + // the last '_id' seen rather than skipping past the ones already gathered. Skipping makes the + // db re-walk the whole prefix every round trip; resuming reads each Annotation exactly once. const matches = [] let batch = [] + let resumeAfter = null do { - batch = await db.find(queryObj).sort({ "_id": 1 }).limit(EXPANSION_BATCH_SIZE).skip(matches.length).toArray() + const batchQuery = resumeAfter === null ? queryObj : { ...queryObj, "_id": { $gt: resumeAfter } } + batch = await db.find(batchQuery).sort({ "_id": 1 }).limit(EXPANSION_BATCH_SIZE).toArray() + // Read the resume point before '_id' is dropped from the document. + if (batch.length > 0) resumeAfter = batch.at(-1)._id for (const anno of batch) { delete anno._id matches.push(anno) @@ -566,6 +578,7 @@ export { _contextid, idNegotiation, findLeafAnnotationsFor, + PROTECTED_EXPANSION_KEYS, getPagination, generateSlugId, index, diff --git a/public/API.html b/public/API.html index 0691be91..a9e7dc1e 100644 --- a/public/API.html +++ b/public/API.html @@ -212,7 +212,12 @@

    Expanded record by id

    that property. Values are passed through exactly as they appear—a body of {"text": {"value": "hello"}} puts {"value": "hello"} on the entity, not - "hello". + "hello". A + body serialized as a one-element Array is + the same single body in JSON-LD and is read the same way— + [{"text": {"value": "hello"}}] and + {"text": {"value": "hello"}} are + equivalent.
  • A bodyValue string, and a TextualBody body, both land under the property bodyValue. The From a62b63cc656032e43f1ca8c0ba36e4d910b3684c Mon Sep 17 00:00:00 2001 From: Bryan Haberberger Date: Tue, 18 Aug 2026 13:10:00 -0500 Subject: [PATCH 16/48] comment cleanup --- controllers/crud.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/controllers/crud.js b/controllers/crud.js index 837c5ebc..b91d762f 100644 --- a/controllers/crud.js +++ b/controllers/crud.js @@ -231,7 +231,7 @@ const idExpanded = async function (req, res, next) { const isPost = req.method === "POST" let filters = {} if (isPost) { - //Express leaves the body undefined when a POST supplies none. That is an unfiltered expand. + // Express leaves the body undefined when a POST supplies none. That is an unfiltered expand. const supplied = req.body ?? {} if (typeof supplied !== "object" || Array.isArray(supplied)) { const err = { @@ -257,7 +257,7 @@ const idExpanded = async function (req, res, next) { return next(utils.createExpressError(err)) } res.set(utils.configureWebAnnoHeadersFor(match)) - //Support built in browser caching. A POST response is not cacheable. + // Support built in browser caching. A POST response is not cacheable. if (!isPost) res.set("Cache-Control", "max-age=86400, must-revalidate") // Include current version for optimistic locking res.set('Current-Overwritten-Version', match.__rerum?.isOverwritten ?? "") From 5af111e559478188415b626976f4d3ffc106d9e5 Mon Sep 17 00:00:00 2001 From: Bryan Haberberger Date: Tue, 18 Aug 2026 13:36:30 -0500 Subject: [PATCH 17/48] changes during review --- controllers/crud.js | 9 +++++++-- controllers/gog.js | 2 +- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/controllers/crud.js b/controllers/crud.js index b91d762f..49f8c7aa 100644 --- a/controllers/crud.js +++ b/controllers/crud.js @@ -6,7 +6,7 @@ */ import { newID, isValidID, db } from '../database/index.js' import utils from '../utils.js' -import { _contextid, idNegotiation, getPagination, generateSlugId, ObjectID, getAgentClaim, parseDocumentID, findLeafAnnotationsFor, PROTECTED_EXPANSION_KEYS } from './utils.js' +import { _contextid, idNegotiation, getPagination, generateSlugId, ObjectID, getAgentClaim, findLeafAnnotationsFor, PROTECTED_EXPANSION_KEYS } from './utils.js' /** * Create a new Linked Open Data object in RERUM v1. @@ -158,6 +158,7 @@ function sanitizeExpansionFilters(supplied) { * - body: {'key': {...}} a single assertion, value kept as-is * - body: {'type':'TextualBody', 'value': 'text', ...} kept whole so 'format' and 'language' survive * - body: {'@type':'oa:TextualBody', ...} the OA prefixed spelling of the same + * - body: {'type':['TextualBody'], ...} the same body, type serialized as a JSON-LD Array * - body: [{'key': 'value'}] one body, serialized as a JSON-LD Array * * @param anno An Annotation document. @@ -176,7 +177,11 @@ function assertionsFrom(anno) { // Skip Annotations carrying multiple bodies, and string bodies that are an IRI referencing an // external resource with no embedded value to expand with. if (Array.isArray(body) || !body || typeof body !== "object") return assertions - if (TEXTUAL_BODY_TYPES.has(body.type ?? body["@type"])) { + // In JSON-LD a type is either a single value or an Array of them. The query in + // findLeafAnnotationsFor() matches both spellings, so both are read here too. + const bodyType = body.type ?? body["@type"] + const bodyTypes = Array.isArray(bodyType) ? bodyType : [bodyType] + if (bodyTypes.some(t => TEXTUAL_BODY_TYPES.has(t))) { assertions.push(["bodyValue", body]) return assertions } diff --git a/controllers/gog.js b/controllers/gog.js index ad2c550d..9a491145 100644 --- a/controllers/gog.js +++ b/controllers/gog.js @@ -8,7 +8,7 @@ import { newID, isValidID, db } from '../database/index.js' import utils from '../utils.js' -import { ObjectID, getAgentClaim, getPagination, parseDocumentID, idNegotiation, findLeafAnnotationsFor, PROTECTED_EXPANSION_KEYS } from './utils.js' +import { getAgentClaim, getPagination, idNegotiation, findLeafAnnotationsFor, PROTECTED_EXPANSION_KEYS } from './utils.js' // The Gallery of Glosses agents, by RERUM ObjectId. Prod (store) and dev (devstore) mint different // agents; only the trailing id is compared, so either host spelling matches. From 4986623de21986dfcdad2a8953633983b15635b6 Mon Sep 17 00:00:00 2001 From: Bryan Haberberger Date: Tue, 18 Aug 2026 13:51:02 -0500 Subject: [PATCH 18/48] changes during review and comment cleanup --- controllers/crud.js | 9 ++++----- controllers/gog.js | 15 ++------------- controllers/utils.js | 13 ++++++------- 3 files changed, 12 insertions(+), 25 deletions(-) diff --git a/controllers/crud.js b/controllers/crud.js index 49f8c7aa..b85ecde5 100644 --- a/controllers/crud.js +++ b/controllers/crud.js @@ -201,7 +201,7 @@ function assertionsFrom(anno) { * @param annoAssertions An Array holding the [key, value] assertions read from each Annotation. * @return A new, expanded entity object. */ -function applyRawExpansion(primitiveEntity, annoAssertions) { +function applyExpansionAnnotations(primitiveEntity, annoAssertions) { const expandedEntity = structuredClone(primitiveEntity) // Hold __rerum aside so it can be re-appended after the merged properties. It will be the last property. const rerumProp = expandedEntity.__rerum @@ -227,8 +227,7 @@ function applyRawExpansion(primitiveEntity, annoAssertions) { * * GET recognizes the '?generator=' and '?creator=' convenience parameters only. * POST reads literal MongoDB filter keys from the JSON body and ignores URL parameters as filters. - * Neither method pages. A client asks once and receives the entity assembled from every current - * Annotation targeting it -- findLeafAnnotationsFor() does whatever gathering that takes. + * Neither method pages. A client asks once and receives the entity assembled. * */ const idExpanded = async function (req, res, next) { res.set("Content-Type", "application/json; charset=utf-8") @@ -274,9 +273,9 @@ const idExpanded = async function (req, res, next) { // How many of the Annotations contribute an assertion. May be less than annotations gathered. const merged = annos.map(anno => assertionsFrom(anno)).filter(assertions => assertions.length > 0) res.set('Annotations-Merged', String(merged.length)) - let expanded = applyRawExpansion(match, merged) + let expanded = applyExpansionAnnotations(match, merged) expanded = idNegotiation(expanded) - res.location(expanded["@id"] ?? expanded.id) + res.location(_contextid(expanded["@context"]) ? expanded.id : expanded["@id"]) res.json(expanded) } catch (error) { return next(utils.createExpressError(error)) diff --git a/controllers/gog.js b/controllers/gog.js index 9a491145..17408e4a 100644 --- a/controllers/gog.js +++ b/controllers/gog.js @@ -8,7 +8,7 @@ import { newID, isValidID, db } from '../database/index.js' import utils from '../utils.js' -import { getAgentClaim, getPagination, idNegotiation, findLeafAnnotationsFor, PROTECTED_EXPANSION_KEYS } from './utils.js' +import { _contextid, getAgentClaim, getPagination, idNegotiation, findLeafAnnotationsFor, PROTECTED_EXPANSION_KEYS } from './utils.js' // The Gallery of Glosses agents, by RERUM ObjectId. Prod (store) and dev (devstore) mint different // agents; only the trailing id is compared, so either host spelling matches. @@ -312,13 +312,6 @@ const _gog_glosses_from_manuscript = async function (req, res, next) { * Find relevant Annotations targeting a primitive RERUM entity. This is a 'full' expand. * Add the descriptive information in the Annotation bodies to the primitive object. * -* Anticipate likely Annotation body formats -* - anno.body -* - anno.body.value -* -* Gathering the Annotations is findLeafAnnotationsFor() in ./utils.js -- see its docblock for the -* target and Annotation type forms that are recognized. This function only reads their bodies. -* * @param primitiveEntity - An existing RERUM object * @param GENERATOR - A registered RERUM app's User Agent * @param CREATOR - Some kind of string representing a specific user. Often combined with GENERATOR. @@ -329,16 +322,12 @@ const expand = async function(primitiveEntity, GENERATOR=undefined, CREATOR=unde // An entity is expandable if it carries a URI under either '@id' or 'id'. if(!primitiveEntity?.["@id"] && !primitiveEntity?.id) return primitiveEntity const targetId = primitiveEntity["@id"] ?? primitiveEntity.id ?? "unknown" - // Only expand with data from a specific app and/or a specific creator. The shared helper - // applies the leaf, target, and Annotation type constraints and doubles these two URIs - // across the http/https spellings. const filters = {} if(GENERATOR) filters["__rerum.generatedBy"] = GENERATOR if(CREATOR) filters.creator = CREATOR const matches = await findLeafAnnotationsFor(targetId, filters) // Combine the Annotation bodies with the primitive object. - // Mirror DEER's client-side expand() (deer-utils.js buildValueObject) // When more than one current Annotation asserts the same key, collect the values into an Array. let expandedEntity = structuredClone(primitiveEntity) // Hold __rerum aside so it can be re-appended after the merged properties. It will be the last property. @@ -410,7 +399,7 @@ const expandedId = async function (req, res, next) { res.set("Current-Overwritten-Version", match.__rerum?.isOverwritten ?? "") let expanded = await expand(match, generator) expanded = idNegotiation(expanded) - res.location(expanded["@id"] ?? expanded.id) + res.location(_contextid(expanded["@context"]) ? expanded.id : expanded["@id"]) res.json(expanded) } catch (error) { return next(utils.createExpressError(error)) diff --git a/controllers/utils.js b/controllers/utils.js index 9f3a1f04..56d1e991 100644 --- a/controllers/utils.js +++ b/controllers/utils.js @@ -152,9 +152,12 @@ function escapeRegex(literal) { * - {"type": "Annotation"}, {"type": "oa:Annotation"}, {"type": "http://www.w3.org/ns/oa#Annotation"} * - {"@type": "Annotation"}, {"@type": "oa:Annotation"}, {"@type": "http://www.w3.org/ns/oa#Annotation"} * - * Every match is gathered. The caller expands with all of them, so this walks the result set in - * batches of EXPANSION_BATCH_SIZE until the database has no more to give. There is no page for a - * client to ask for and no truncation to report -- an expansion of 1000 Annotations gathers 1000. + * Every match is gathered. This walks the result set in batches of EXPANSION_BATCH_SIZE + * until the database has no more to give. An expansion of 1000 Annotations gathers 1000. + * + * Sorting on '_id' keeps the walk stable across the round trips, and each batch resumes from + * the last '_id' seen rather than skipping past the ones already gathered. Skipping makes the + * db re-walk the whole prefix every round trip; resuming reads each Annotation exactly once. * * @param targetId The '@id' or 'id' URI of the entity being expanded. * @param filters Literal MongoDB filter keys to AND into the query. @@ -201,10 +204,6 @@ const findLeafAnnotationsFor = async function (targetId, filters = {}) { } queryObj["$and"].push({ [key]: value }) } - // Get every Annotation targeting this Entity from the db. Remove _id property. - // Sorting on '_id' keeps the walk stable across the round trips, and each batch resumes from - // the last '_id' seen rather than skipping past the ones already gathered. Skipping makes the - // db re-walk the whole prefix every round trip; resuming reads each Annotation exactly once. const matches = [] let batch = [] let resumeAfter = null From 15e20d02563fa129ce974237f67bb91f4a4fce63 Mon Sep 17 00:00:00 2001 From: Bryan Haberberger Date: Tue, 18 Aug 2026 14:43:19 -0500 Subject: [PATCH 19/48] Changes during review and API.html simplification --- controllers/crud.js | 10 ++-- controllers/gog.js | 6 +- public/API.html | 136 ++++++++------------------------------------ 3 files changed, 32 insertions(+), 120 deletions(-) diff --git a/controllers/crud.js b/controllers/crud.js index b85ecde5..0cfc9025 100644 --- a/controllers/crud.js +++ b/controllers/crud.js @@ -177,8 +177,6 @@ function assertionsFrom(anno) { // Skip Annotations carrying multiple bodies, and string bodies that are an IRI referencing an // external resource with no embedded value to expand with. if (Array.isArray(body) || !body || typeof body !== "object") return assertions - // In JSON-LD a type is either a single value or an Array of them. The query in - // findLeafAnnotationsFor() matches both spellings, so both are read here too. const bodyType = body.type ?? body["@type"] const bodyTypes = Array.isArray(bodyType) ? bodyType : [bodyType] if (bodyTypes.some(t => TEXTUAL_BODY_TYPES.has(t))) { @@ -261,10 +259,6 @@ const idExpanded = async function (req, res, next) { return next(utils.createExpressError(err)) } res.set(utils.configureWebAnnoHeadersFor(match)) - // Support built in browser caching. A POST response is not cacheable. - if (!isPost) res.set("Cache-Control", "max-age=86400, must-revalidate") - // Include current version for optimistic locking - res.set('Current-Overwritten-Version', match.__rerum?.isOverwritten ?? "") const targetId = match["@id"] ?? match.id const annos = targetId ? await findLeafAnnotationsFor(targetId, filters) : [] // Every leaf Annotation matching the filter is gathered. This is the count. @@ -275,6 +269,10 @@ const idExpanded = async function (req, res, next) { res.set('Annotations-Merged', String(merged.length)) let expanded = applyExpansionAnnotations(match, merged) expanded = idNegotiation(expanded) + // Same browser-caching policy as GET /v1/id/:_id so this stable URI is cached (24h). + if (!isPost) res.set("Cache-Control", "max-age=86400, must-revalidate") + // Include current version for optimistic locking + res.set('Current-Overwritten-Version', match.__rerum?.isOverwritten ?? "") res.location(_contextid(expanded["@context"]) ? expanded.id : expanded["@id"]) res.json(expanded) } catch (error) { diff --git a/controllers/gog.js b/controllers/gog.js index 17408e4a..9c3cb466 100644 --- a/controllers/gog.js +++ b/controllers/gog.js @@ -391,14 +391,14 @@ const expandedId = async function (req, res, next) { }) return next(utils.createExpressError(err)) } - // Same browser-caching policy as GET /v1/id/:_id so this stable URI is cached (24h). res.set(utils.configureWebAnnoHeadersFor(match)) + let expanded = await expand(match, generator) + expanded = idNegotiation(expanded) + // Same browser-caching policy as GET /v1/id/:_id so this stable URI is cached (24h). res.set("Cache-Control", "max-age=86400, must-revalidate") // No Last-Modified here, unlike GET /v1/id/:_id. It would compare against the root entity // before expand() merges the targeting Annotations. res.set("Current-Overwritten-Version", match.__rerum?.isOverwritten ?? "") - let expanded = await expand(match, generator) - expanded = idNegotiation(expanded) res.location(_contextid(expanded["@context"]) ? expanded.id : expanded["@id"]) res.json(expanded) } catch (error) { diff --git a/public/API.html b/public/API.html index a9e7dc1e..90918538 100644 --- a/public/API.html +++ b/public/API.html @@ -185,68 +185,12 @@

    Expanded record by id

  • Response: {JSON}—The record with identifier _id, plus the merged properties.
  • -
  • Response header - Annotations-Gathered—how many - Annotations this expansion gathered. There is no paging on this endpoint. RERUM gathers - every current Annotation targeting the record before it assembles the response, however many that is, so - this is a complete count and never a partial one. It is informational—a client makes one request and - receives the fully expanded record.
  • -
  • Response header - Annotations-Merged—how many of - those gathered Annotations could contribute to the record. It is always less than or equal to - Annotations-Gathered, and Annotations with - multiple bodies are not counted, as that form is not read yet. A counted Annotation may still assert - nothing mergeable, so this is not a count of the properties you received either.
  • Only the current (leaf) versions of Annotations are gathered. No token is required. The response is a raw assembly—if your application needs the data in a particular shape, format the response for your own internal needs.

    -
    What gets merged
    -
      -
    • Only the Annotation's body and - bodyValue are read. Every other property of - the Annotation is ignored, and an Annotation carrying neither is ignored entirely.
    • -
    • A body with exactly one property contributes - that property. Values are passed through exactly as they appear—a body of - {"text": {"value": "hello"}} puts - {"value": "hello"} on the entity, not - "hello". A - body serialized as a one-element Array is - the same single body in JSON-LD and is read the same way— - [{"text": {"value": "hello"}}] and - {"text": {"value": "hello"}} are - equivalent.
    • -
    • A bodyValue string, and a - TextualBody body, both land under the property - bodyValue. The - TextualBody is kept whole so its - format and - language survive.
    • -
    • When several Annotations assert the same property, or the record already carries it, the values collect - into an Array. Array order is not guaranteed.
    • -
    • A property merged from a single Annotation is that value as-is; the same property merged from two or - more is an Array. Treat every merged property as possibly-an-Array—the shape depends on how many - Annotations exist, which changes over the record's life.
    • -
    • Identity and system properties are never overwritten: - @id, - id, - _id, - __rerum, - __deleted, and - @context. An assertion naming - __proto__ is dropped for the same reason—it - is not data, and emitting it would hand a prototype pollution vector to every client that parses the - response.
    • -
    • Skipped for now, as future work: a body with - more than one property, an Annotation with multiple bodies, the - Choice, - Composite, and - List constructs, and a - body that is a URI referencing an external - resource.
    • -

    Javascript Example
    
    @@ -885,62 +829,8 @@ 

    Expanded record with filters

    the real ones stored on an Annotation, so filtering by generating application means supplying __rerum.generatedBy. An empty or absent body expands without any filter. -
  • URL parameters supply no filters. The - ?generator and - ?creator parameters of the GET have no effect - here—supply them in the body instead. Like the GET, this is not paged: every Annotation matching your - filters is gathered before the response is assembled, and the - Annotations-Gathered and - Annotations-Merged response headers are set - exactly as they are on the GET.
  • -
  • __rerum.generatedBy and - creator are matched against both the - http and - https spellings of the URI you provide. Every - other property is applied exactly as supplied.
  • -
  • The Content-Type header must be - application/json. A body that is not a JSON - object is a 400.
  • +
  • -

    - Three constraints belong to the endpoint and cannot be overruled. Supplying them is not an error—they are - ignored, by exact name or as a dotted prefix. -

    - - - - - - - - - - - - - - - - - - - - - -
    IgnoredAlways applied instead
    target, - target.@id, - target.id, - target.source, - target.source.@id, - target.source.idAnnotations targeting the record at - _id, including those - targeting it through a - SpecificResource or through a - fragment of its URI such as - #xywh=0,0,100,100
    type, - @typeAnnotations only
    __rerum.history.next, - __rerum.history.previous, - __rerum.history.primeCurrent (leaf) versions only

    Javascript Example
    
    @@ -1715,6 +1605,30 @@ 

    __rerum Property Explained

    existing vocabularies, but for now the applications accessing RERUM will need to interpret this data if it is relevant.

    +

    Entity Expansion

    +

    + Only the Annotation's body and bodyValue are read. Every other property of the Annotation is ignored, and an Annotation carrying neither is ignored entirely. +

    +

    + A body with exactly one property contributes that property. Values are passed through exactly as they appear—a body of + {"text": {"value": "hello"}} puts + {"value": "hello"} on the entity, not + "hello". + A body serialized as a one-element Array is the same single body in JSON-LD and is read the same way— + [{"text": {"value": "hello"}}] and + {"text": {"value": "hello"}} are equivalent. +

    +

    + A bodyValue string, and a + TextualBody body, both land under the property + bodyValue. The + TextualBody is kept whole so its + format and + language survive. +

    +

    + When several Annotations assert the same property, or the record already carries it, the values collect into an Array. Array order is not guaranteed. +

    History

    History is stored through pointers that create a B-Tree. All nodes in the B-tree know the root node, the previous node, and the next node(s).

    From 0fa58093caea4ddb6c689b22708574c00d5fc82e Mon Sep 17 00:00:00 2001 From: Bryan Haberberger Date: Tue, 18 Aug 2026 15:11:10 -0500 Subject: [PATCH 20/48] Changes during review --- controllers/crud.js | 10 +++++++-- controllers/utils.js | 51 +++++++++++++++++++++++++++++--------------- public/API.html | 9 +++++++- 3 files changed, 50 insertions(+), 20 deletions(-) diff --git a/controllers/crud.js b/controllers/crud.js index 0cfc9025..ce7e7918 100644 --- a/controllers/crud.js +++ b/controllers/crud.js @@ -157,7 +157,7 @@ function sanitizeExpansionFilters(supplied) { * - body: {'key': 'value'} a single assertion * - body: {'key': {...}} a single assertion, value kept as-is * - body: {'type':'TextualBody', 'value': 'text', ...} kept whole so 'format' and 'language' survive - * - body: {'@type':'oa:TextualBody', ...} the OA prefixed spelling of the same + * - body: {'@type':'oa:TextualBody', ...} the 'oa:' prefixed spelling of the same W3C class * - body: {'type':['TextualBody'], ...} the same body, type serialized as a JSON-LD Array * - body: [{'key': 'value'}] one body, serialized as a JSON-LD Array * @@ -260,7 +260,13 @@ const idExpanded = async function (req, res, next) { } res.set(utils.configureWebAnnoHeadersFor(match)) const targetId = match["@id"] ?? match.id - const annos = targetId ? await findLeafAnnotationsFor(targetId, filters) : [] + // A record minted with a Slug also resolves at '/id/', so an Annotation may + // target it by that URI instead of by its '@id'. The slug URI is built off the entity's own + // URI rather than RERUM_ID_PREFIX so a record minted under a legacy host keeps that host. + const slug = match.__rerum?.slug + const lastSlash = targetId?.lastIndexOf("/") ?? -1 + const slugTargetId = slug && lastSlash !== -1 ? targetId.slice(0, lastSlash + 1) + slug : undefined + const annos = await findLeafAnnotationsFor([targetId, slugTargetId], filters) // Every leaf Annotation matching the filter is gathered. This is the count. res.set('Annotations-Gathered', String(annos.length)) // Read each Annotation once. These assertions are both the merged count and the merge itself. diff --git a/controllers/utils.js b/controllers/utils.js index 56d1e991..a6338efa 100644 --- a/controllers/utils.js +++ b/controllers/utils.js @@ -149,8 +149,19 @@ function escapeRegex(literal) { * - target: {'source':{'id':'uri'}} a SpecificResource with an embedded source * - target: 'uri#xywh=0,0,100,100' a fragment of the resource * and the likely Annotation type formats - * - {"type": "Annotation"}, {"type": "oa:Annotation"}, {"type": "http://www.w3.org/ns/oa#Annotation"} - * - {"@type": "Annotation"}, {"@type": "oa:Annotation"}, {"@type": "http://www.w3.org/ns/oa#Annotation"} + * - {"type": "Annotation"}, {"type": "http://www.w3.org/ns/oa#Annotation"} + * - {"@type": "Annotation"}, {"@type": "http://www.w3.org/ns/oa#Annotation"} + * + * Only Web Annotation Data Model era Annotations are expanded with. 'oa:Annotation' is the Open + * Annotation era spelling carried by IIIF Presentation 2.1 data, which names its target under 'on' + * and its body under 'resource', so there is nothing here that could read one. The full + * 'http://www.w3.org/ns/oa#Annotation' IRI is kept because that is the W3C class itself, what a + * fully expanded JSON-LD Web Annotation carries. + * + * An entity can answer to more than one URI. A record minted with a Slug resolves at both + * '/id/<_id>' and '/id/', and an Annotation may legitimately target it by + * either one, so a caller hands over every URI the entity is known by. An Annotation matching any + * of them targets this entity and is gathered. * * Every match is gathered. This walks the result set in batches of EXPANSION_BATCH_SIZE * until the database has no more to give. An expansion of 1000 Annotations gathers 1000. @@ -159,41 +170,47 @@ function escapeRegex(literal) { * the last '_id' seen rather than skipping past the ones already gathered. Skipping makes the * db re-walk the whole prefix every round trip; resuming reads each Annotation exactly once. * - * @param targetId The '@id' or 'id' URI of the entity being expanded. + * @param targetIds The '@id' or 'id' URI of the entity being expanded, or an Array of the URIs it + * is known by when it answers to more than one. * @param filters Literal MongoDB filter keys to AND into the query. * @return An Array of every matching Annotation document, with '_id' removed. */ -const findLeafAnnotationsFor = async function (targetId, filters = {}) { +const findLeafAnnotationsFor = async function (targetIds, filters = {}) { const EXPANSION_BATCH_SIZE = 200 - // '$and' is always present so the filter conditions below can push into it from either branch. + const targetURIs = (Array.isArray(targetIds) ? targetIds : [targetIds]).filter(Boolean) + // Nothing to target is nothing to gather. An empty '$or' is a MongoDB error, not an empty result. + if (targetURIs.length === 0) return [] + // '$and' is always present so the target, type, and filter conditions can push into it. const queryObj = { "__rerum.history.next": { $exists: true, $size: 0 }, "$and": [] } const annoTypeConditions = [ - {"type": "Annotation"}, {"type": "oa:Annotation"}, {"type": "http://www.w3.org/ns/oa#Annotation"}, - {"@type": "Annotation"}, {"@type": "oa:Annotation"}, {"@type": "http://www.w3.org/ns/oa#Annotation"} + {"type": "Annotation"}, {"type": "http://www.w3.org/ns/oa#Annotation"}, + {"@type": "Annotation"}, {"@type": "http://www.w3.org/ns/oa#Annotation"} ] - if (/^https?:\/\//.test(targetId)) { - const targetConditions = [] + // Every URI the entity answers to contributes its conditions to the same '$or'. + const targetConditions = [] + for (const targetURI of targetURIs) { + if (!/^https?:\/\//.test(targetURI)) { + // Not a URI, so there is no http/https spelling or fragment of it to anticipate. + targetConditions.push({ "target": targetURI }) + continue + } // Hanging a fragment off the URI is the other W3C way to target part of a resource rather // than the whole of it, and an exact match will not catch one. const fragmentPatterns = ["http", "https"].map(scheme => - new RegExp(`^${escapeRegex(targetId.replace(/^https?/, scheme))}#`) + new RegExp(`^${escapeRegex(targetURI.replace(/^https?/, scheme))}#`) ) // 'target.source' is the W3C SpecificResource, which is how an Annotation targets a // selected region of a resource rather than the whole of it. for (const targetKey of TARGET_KEYS) { - targetConditions.push({ [targetKey]: targetId.replace(/^https?/, "http") }) - targetConditions.push({ [targetKey]: targetId.replace(/^https?/, "https") }) + targetConditions.push({ [targetKey]: targetURI.replace(/^https?/, "http") }) + targetConditions.push({ [targetKey]: targetURI.replace(/^https?/, "https") }) for (const fragmentPattern of fragmentPatterns) targetConditions.push({ [targetKey]: fragmentPattern }) } - queryObj["$and"].push({"$or": targetConditions}, {"$or": annoTypeConditions}) - } - else { - queryObj["$and"].push({"$or": annoTypeConditions}) - queryObj.target = targetId } + queryObj["$and"].push({"$or": targetConditions}, {"$or": annoTypeConditions}) for (const [key, value] of Object.entries(filters)) { if (URI_DOUBLED_FILTER_KEYS.has(key) && typeof value === "string" && /^https?:\/\//.test(value)) { queryObj["$and"].push({"$or": [ diff --git a/public/API.html b/public/API.html index 90918538..22869ba2 100644 --- a/public/API.html +++ b/public/API.html @@ -187,7 +187,14 @@

    Expanded record by id

    properties.
  • - Only the current (leaf) versions of Annotations are gathered. No token is required. + Only Annotations of the + W3C Web Annotation Data Model are + gathered, and only their current (leaf) versions. The Open Annotation era + oa:Annotation carried by IIIF Presentation API + 2.1⚠️ + data names its target under on and its body under + resource, so it is not expanded with. No token is + required. The response is a raw assembly—if your application needs the data in a particular shape, format the response for your own internal needs.

    From dfb0e1d813ed985c817a99de4af9872c5d0a692d Mon Sep 17 00:00:00 2001 From: Bryan Haberberger Date: Tue, 18 Aug 2026 16:53:11 -0500 Subject: [PATCH 21/48] Changes during review --- controllers/utils.js | 21 ++++++++++++--------- public/API.html | 20 ++++++++++++++------ 2 files changed, 26 insertions(+), 15 deletions(-) diff --git a/controllers/utils.js b/controllers/utils.js index a6338efa..577bb2ac 100644 --- a/controllers/utils.js +++ b/controllers/utils.js @@ -149,14 +149,17 @@ function escapeRegex(literal) { * - target: {'source':{'id':'uri'}} a SpecificResource with an embedded source * - target: 'uri#xywh=0,0,100,100' a fragment of the resource * and the likely Annotation type formats - * - {"type": "Annotation"}, {"type": "http://www.w3.org/ns/oa#Annotation"} - * - {"@type": "Annotation"}, {"@type": "http://www.w3.org/ns/oa#Annotation"} + * - {"type": "Annotation"}, {"type": "oa:Annotation"}, {"type": "http://www.w3.org/ns/oa#Annotation"} + * - {"@type": "Annotation"}, {"@type": "oa:Annotation"}, {"@type": "http://www.w3.org/ns/oa#Annotation"} * - * Only Web Annotation Data Model era Annotations are expanded with. 'oa:Annotation' is the Open - * Annotation era spelling carried by IIIF Presentation 2.1 data, which names its target under 'on' - * and its body under 'resource', so there is nothing here that could read one. The full - * 'http://www.w3.org/ns/oa#Annotation' IRI is kept because that is the W3C class itself, what a - * fully expanded JSON-LD Web Annotation carries. + * All three spellings are the same class. The W3C Web Annotation context defines the 'oa' prefix as + * 'http://www.w3.org/ns/oa#' and the term 'Annotation' as 'oa:Annotation', so a compacted, a prefixed, + * and a fully expanded JSON-LD Web Annotation all name 'http://www.w3.org/ns/oa#Annotation'. + * + * Only 'target' and 'body' are read, whatever the type spelling says. That is what keeps Open + * Annotation era data out on its own -- IIIF Presentation 2.1 carries 'oa:Annotation' but names its + * target under 'on' and its body under 'resource', neither of which is read here, so such a record + * matches nothing and expands with nothing. * * An entity can answer to more than one URI. A record minted with a Slug resolves at both * '/id/<_id>' and '/id/', and an Annotation may legitimately target it by @@ -186,8 +189,8 @@ const findLeafAnnotationsFor = async function (targetIds, filters = {}) { "$and": [] } const annoTypeConditions = [ - {"type": "Annotation"}, {"type": "http://www.w3.org/ns/oa#Annotation"}, - {"@type": "Annotation"}, {"@type": "http://www.w3.org/ns/oa#Annotation"} + {"type": "Annotation"}, {"type": "oa:Annotation"}, {"type": "http://www.w3.org/ns/oa#Annotation"}, + {"@type": "Annotation"}, {"@type": "oa:Annotation"}, {"@type": "http://www.w3.org/ns/oa#Annotation"} ] // Every URI the entity answers to contributes its conditions to the same '$or'. const targetConditions = [] diff --git a/public/API.html b/public/API.html index 22869ba2..00468919 100644 --- a/public/API.html +++ b/public/API.html @@ -189,11 +189,17 @@

    Expanded record by id

    Only Annotations of the W3C Web Annotation Data Model are - gathered, and only their current (leaf) versions. The Open Annotation era - oa:Annotation carried by IIIF Presentation API + gathered, and only their current (leaf) versions. An Annotation is recognized by + Annotation, + oa:Annotation, or + http://www.w3.org/ns/oa#Annotation under either + type or + @type—the W3C context defines all three as + the same class. Only target and + body are read, so IIIF Presentation API 2.1⚠️ - data names its target under on and its body under - resource, so it is not expanded with. No token is + data, which names its target under on and its body + under resource, is not expanded with. No token is required. The response is a raw assembly—if your application needs the data in a particular shape, format the response for your own internal needs. @@ -836,7 +842,9 @@

    Expanded record with filters

    the real ones stored on an Annotation, so filtering by generating application means supplying __rerum.generatedBy. An empty or absent body expands without any filter. -
  • +
  • Response: {JSON}—The + record with identifier _id, plus the merged + properties from the Annotations matching the supplied filters.
  • Javascript Example
    @@ -1612,7 +1620,7 @@

    __rerum Property Explained

    existing vocabularies, but for now the applications accessing RERUM will need to interpret this data if it is relevant.

    -

    Entity Expansion

    +

    Entity Expansion

    Only the Annotation's body and bodyValue are read. Every other property of the Annotation is ignored, and an Annotation carrying neither is ignored entirely.

    From a1cd2448dffbb4460ede38ab9a421891212e1d70 Mon Sep 17 00:00:00 2001 From: Bryan Haberberger Date: Tue, 18 Aug 2026 17:20:09 -0500 Subject: [PATCH 22/48] Code documentation and API documentation simplification --- controllers/crud.js | 1 - controllers/gog.js | 4 +--- controllers/utils.js | 22 ++++------------------ public/API.html | 24 ++++++------------------ 4 files changed, 11 insertions(+), 40 deletions(-) diff --git a/controllers/crud.js b/controllers/crud.js index ce7e7918..9aa9c502 100644 --- a/controllers/crud.js +++ b/controllers/crud.js @@ -201,7 +201,6 @@ function assertionsFrom(anno) { */ function applyExpansionAnnotations(primitiveEntity, annoAssertions) { const expandedEntity = structuredClone(primitiveEntity) - // Hold __rerum aside so it can be re-appended after the merged properties. It will be the last property. const rerumProp = expandedEntity.__rerum delete expandedEntity.__rerum for (const assertions of annoAssertions) { diff --git a/controllers/gog.js b/controllers/gog.js index 9c3cb466..e61decda 100644 --- a/controllers/gog.js +++ b/controllers/gog.js @@ -330,7 +330,6 @@ const expand = async function(primitiveEntity, GENERATOR=undefined, CREATOR=unde // Combine the Annotation bodies with the primitive object. // When more than one current Annotation asserts the same key, collect the values into an Array. let expandedEntity = structuredClone(primitiveEntity) - // Hold __rerum aside so it can be re-appended after the merged properties. It will be the last property. const rerumProp = expandedEntity.__rerum delete expandedEntity.__rerum for(const anno of matches){ @@ -396,8 +395,7 @@ const expandedId = async function (req, res, next) { expanded = idNegotiation(expanded) // Same browser-caching policy as GET /v1/id/:_id so this stable URI is cached (24h). res.set("Cache-Control", "max-age=86400, must-revalidate") - // No Last-Modified here, unlike GET /v1/id/:_id. It would compare against the root entity - // before expand() merges the targeting Annotations. + // Include current version for optimistic locking res.set("Current-Overwritten-Version", match.__rerum?.isOverwritten ?? "") res.location(_contextid(expanded["@context"]) ? expanded.id : expanded["@id"]) res.json(expanded) diff --git a/controllers/utils.js b/controllers/utils.js index 577bb2ac..df5a648a 100644 --- a/controllers/utils.js +++ b/controllers/utils.js @@ -152,26 +152,12 @@ function escapeRegex(literal) { * - {"type": "Annotation"}, {"type": "oa:Annotation"}, {"type": "http://www.w3.org/ns/oa#Annotation"} * - {"@type": "Annotation"}, {"@type": "oa:Annotation"}, {"@type": "http://www.w3.org/ns/oa#Annotation"} * - * All three spellings are the same class. The W3C Web Annotation context defines the 'oa' prefix as - * 'http://www.w3.org/ns/oa#' and the term 'Annotation' as 'oa:Annotation', so a compacted, a prefixed, - * and a fully expanded JSON-LD Web Annotation all name 'http://www.w3.org/ns/oa#Annotation'. + * Only 'target' and 'body' are read, whatever the type spelling says. * - * Only 'target' and 'body' are read, whatever the type spelling says. That is what keeps Open - * Annotation era data out on its own -- IIIF Presentation 2.1 carries 'oa:Annotation' but names its - * target under 'on' and its body under 'resource', neither of which is read here, so such a record - * matches nothing and expands with nothing. - * - * An entity can answer to more than one URI. A record minted with a Slug resolves at both + * Any entity can answer to more than one URI. A record minted with a Slug resolves at both * '/id/<_id>' and '/id/', and an Annotation may legitimately target it by - * either one, so a caller hands over every URI the entity is known by. An Annotation matching any - * of them targets this entity and is gathered. - * - * Every match is gathered. This walks the result set in batches of EXPANSION_BATCH_SIZE - * until the database has no more to give. An expansion of 1000 Annotations gathers 1000. - * - * Sorting on '_id' keeps the walk stable across the round trips, and each batch resumes from - * the last '_id' seen rather than skipping past the ones already gathered. Skipping makes the - * db re-walk the whole prefix every round trip; resuming reads each Annotation exactly once. + * either one. Every match is gathered. This walks the result set in batches of EXPANSION_BATCH_SIZE + * until the database has no more to give. * * @param targetIds The '@id' or 'id' URI of the entity being expanded, or an Array of the URIs it * is known by when it answers to more than one. diff --git a/public/API.html b/public/API.html index 00468919..a2d984ee 100644 --- a/public/API.html +++ b/public/API.html @@ -169,9 +169,7 @@

    Expanded record by id

    - Gather every current Annotation targeting the record and merge what those Annotations assert onto it. - This does the entity assembly that client applications otherwise perform with many - /query requests. + Gather every current Annotation targeting the record and merge what those Annotations assert onto it. This does the entity assembly that client applications otherwise perform with many /query requests.

    • _id—the id of the record in @@ -186,21 +184,10 @@

      Expanded record by id

      with identifier _id, plus the merged properties.
    +

    + Annotation objects must use target and body properties to be gathered. See Entity Expansion for more information. +

    - Only Annotations of the - W3C Web Annotation Data Model are - gathered, and only their current (leaf) versions. An Annotation is recognized by - Annotation, - oa:Annotation, or - http://www.w3.org/ns/oa#Annotation under either - type or - @type—the W3C context defines all three as - the same class. Only target and - body are read, so IIIF Presentation API - 2.1⚠️ - data, which names its target under on and its body - under resource, is not expanded with. No token is - required. The response is a raw assembly—if your application needs the data in a particular shape, format the response for your own internal needs.

    @@ -1621,8 +1608,9 @@

    __rerum Property Explained

    need to interpret this data if it is relevant.

    Entity Expansion

    +

    - Only the Annotation's body and bodyValue are read. Every other property of the Annotation is ignored, and an Annotation carrying neither is ignored entirely. + Only Annotations of the W3C Web Annotation Data Model are gathered, and only their latest (leaf) versions from history. Only the Annotation's body and bodyValue are read. Every other property of the Annotation is ignored, and an Annotation carrying neither is ignored entirely.

    A body with exactly one property contributes that property. Values are passed through exactly as they appear—a body of From 82c7e274a486710c5f7e696f4b1d9e686537d5c5 Mon Sep 17 00:00:00 2001 From: Bryan Haberberger Date: Wed, 19 Aug 2026 10:29:29 -0500 Subject: [PATCH 23/48] API file fixes --- public/API.html | 60 ++++++++++++++++++++++++------------------------- 1 file changed, 30 insertions(+), 30 deletions(-) diff --git a/public/API.html b/public/API.html index a2d984ee..2a9dd008 100644 --- a/public/API.html +++ b/public/API.html @@ -41,7 +41,7 @@

    API (1.1.0)

      -
    • API (1.1.0) +
    • API (1.1.0)

      - Annotation objects must use target and body properties to be gathered. See Entity Expansion for more information. + Annotation objects must use a target property to be gathered, and a body or bodyValue property to contribute. See Entity Expansion for more information.

      The response is a raw assembly—if your application needs the data in a particular shape, format the @@ -362,9 +362,9 @@

      Create

      Add a completely new object to RERUM and receive the Location URI as a response header and the complete RERUM record as the response body. Accepts only single JSON objects in the request body. -

      - The __rerum, @id and _id properties are ignored. In cases where the Linked Data @context property maps '@id' to 'id', the id property is also ignored.

      +

      + The __rerum, @id and _id properties are ignored. In cases where the Linked Data @context property maps '@id' to 'id', the id property is also ignored.

      Javascript Example
      @@ -372,7 +372,7 @@

      Create

      const saved_obj = await fetch("https://devstore.rerum.io/v1/api/create", { method: "POST", headers:{ - "Authorization": "Bearer eyJz93a...k4laUWw" + "Authorization": "Bearer eyJz93a...k4laUWw", "Content-Type": "application/json; charset=utf-8" }, body: JSON.stringify({ @@ -380,7 +380,7 @@

      Create

      }) }) .then(resp => resp.json()) - .catch(err => {throw err}) + .catch(err => {throw err})

    @@ -407,8 +407,7 @@

    Bulk Create

    /bulkCreate [{JSON}] - 201 - [{JSON}] + 201 [{JSON}] @@ -432,16 +431,16 @@

    Bulk Create

    const saved_objs = await fetch("https://devstore.rerum.io/v1/api/bulkCreate", { method: "POST", headers:{ - "Authorization": "Bearer eyJz93a...k4laUWw" + "Authorization": "Bearer eyJz93a...k4laUWw", "Content-Type": "application/json; charset=utf-8" }, - body: JSON.stringify([ + body: JSON.stringify([ {"hello": "sun"}, {"goodbye": "moon"} ]) }) .then(resp => resp.json()) - .catch(err => {throw err}) + .catch(err => {throw err})

    @@ -998,8 +997,7 @@

    Bulk Update

    /bulkUpdate [{JSON}] - 200 - [{JSON}] + 200 [{JSON}] @@ -1022,9 +1020,10 @@

    Bulk Update

    const updated_objs = await fetch("https://devstore.rerum.io/v1/api/bulkUpdate", { method: "PUT", headers:{ - "Authorization": "Bearer eyJz93a...k4laUWw" + "Authorization": "Bearer eyJz93a...k4laUWw", "Content-Type": "application/json; charset=utf-8" - body: JSON.stringify([{ + }, + body: JSON.stringify([{ "@id": "https://devstore.rerum.io/v1/id/abcdef1234567890", "hello": "new world" }, @@ -1032,8 +1031,9 @@

    Bulk Update

    "@id": "https://devstore.rerum.io/v1/id/1234567890abcdef", "goodbye": "old planet" }]) + }) .then(resp => resp.json()) - .catch(err => {throw err}) + .catch(err => {throw err})

    @@ -1055,7 +1055,7 @@

    Bulk Update

    Overwrite

    - RERUM allows the Generator of a record to overwrite that record. An error will be returned if the agent encoded in the request "Authorization" access token does not match the agent of the existing record. + RERUM allows the Generator of a record to overwrite that record. An error will be returned if the agent encoded in the request "Authorization" access token does not match the __rerum.generatedBy agent of the existing record. Replace a record using a reference to its internal RERUM id and receive the Location URI for the resulting record as a response header and the complete record as the response body. This will have the effects of update, set, and unset actions. New keys will be created and keys not present in the request will not be present in the resulting record. The record is replaced in place, or overwritten, and so the @id or id will not change and the history connected with this record will not be altered. The __rerum.isOverwritten property will be set to the date and time of the overwrite. @@ -1220,10 +1220,10 @@

    Patch Update

    }

    - Note that resp.headers.get("location") will return "https://devstore.rerum.io/v1/id/1234567890abcdef" + Note that resp.headers.get("location") will return "https://devstore.rerum.io/v1/id/1234567890abcdef"

    - Note that the original record "https://devstore.rerum.io/v1/id/abcdef1234567890" now has + Note that the original record "https://devstore.rerum.io/v1/id/abcdef1234567890" now has __rerum.history.next : ["https://devstore.rerum.io/v1/id/1234567890abcdef"] @@ -1299,10 +1299,10 @@

    Add Properties

    }

    - Note that resp.headers.get("location") will return "https://devstore.rerum.io/v1/id/1234567890abcdef" + Note that resp.headers.get("location") will return "https://devstore.rerum.io/v1/id/1234567890abcdef"

    - Note that the original record "https://devstore.rerum.io/v1/id/abcdef1234567890" now has + Note that the original record "https://devstore.rerum.io/v1/id/abcdef1234567890" now has __rerum.history.next : ["https://devstore.rerum.io/v1/id/1234567890abcdef"] @@ -1378,10 +1378,10 @@

    Remove Properties

    }

    - Note that resp.headers.get("location") will return "https://devstore.rerum.io/v1/id/1234567890abcdef" + Note that resp.headers.get("location") will return "https://devstore.rerum.io/v1/id/1234567890abcdef"

    - Note that the original record "https://devstore.rerum.io/v1/id/abcdef1234567890" now has + Note that the original record "https://devstore.rerum.io/v1/id/abcdef1234567890" now has __rerum.history.next : ["https://devstore.rerum.io/v1/id/1234567890abcdef"] @@ -1444,13 +1444,13 @@

    RERUM released

    }

    - Note that resp.headers.get("location") will return "https://devstore.rerum.io/v1/id/abcdef1234567890" + Note that resp.headers.get("location") will return "https://devstore.rerum.io/v1/id/abcdef1234567890"

    DELETE

    - RERUM allows the Generator of a record to delete that record. An error will be returned if the agent encoded in the request "Authorization" access token does not match the agent of the existing record. - RERUM DELETE does not remove anything from the server. Deleted records are only marked as deleted. + RERUM allows the Generator of a record to delete that record. An error will be returned if the agent encoded in the request "Authorization" access token does not match the __rerum.generatedBy agent of the existing record. + RERUM DELETE does not remove anything from the server. Deleted records are only marked as deleted. Records marked as deleted do not return in query results and may only be directly retrieved by @id or id.

    Deleted records are removed from history trees. RERUM will do this automatically when a record is deleted. This cannot be undone. @@ -1631,11 +1631,11 @@

    Entity Expansion

    When several Annotations assert the same property, or the record already carries it, the values collect into an Array. Array order is not guaranteed. -

    +

    History

    History is stored through pointers that create a B-Tree. All nodes in the B-tree know the root node, the previous node, and the next node(s).

    -

    You can ask for all descendants or all ancestors from any given node so long as you know the node’s @id or id and the node has not been deleted. See history parents and history +

    You can ask for all descendants or all ancestors from any given node so long as you know the node’s @id or id and the node has not been deleted. See history parents and history children for more details about this process.

    Deleted records are not present in any B-Tree, but do exist as separate nodes that can be requested by the URI directly. A snapshot of their position at the time of deletion persists in these deleted nodes.

    @@ -1678,12 +1678,12 @@

    Web Annotation

    RERUM Responses

    The intention of the API is to follow RESTful practices. These practices drive what requests we accept and what responses we have to the various scenarios around those requests. What it means to be RESTful varies - wildly, but our efforts follow the guidelines at https://www.restapitutorial.com/resources.html

    + wildly, but our efforts follow the guidelines at REST API Tutorial.

    RERUM follows REST, IIIF and Web Annotation standards to form its responses to users. For more information about why RERUM chose a certain HTTP status code see the graph below.

    alt text

    If you are confused as to what type of requests give what response, review the Web - Annotation and RESTful standards.

    + Annotation and RESTful standards.