Skip to content

Page Routes With Page and Line Class Refactor#461

Merged
thehabes merged 29 commits into
developmentfrom
459-page-router-and-class
Mar 5, 2026
Merged

Page Routes With Page and Line Class Refactor#461
thehabes merged 29 commits into
developmentfrom
459-page-router-and-class

Conversation

@thehabes
Copy link
Copy Markdown
Member

@thehabes thehabes commented Feb 26, 2026

Closes #459

Summary

Refactors page and line routes to use the Page and Line class methods instead of inline logic, and hardens RERUM interactions with proper error handling and hydration tracking.

Page Class (classes/Page/Page.js)

  • New #hydrated flag — Tracks whether the Page instance has been synced with RERUM, preventing redundant network calls on consecutive asJSON() invocations.
  • New #itemsResolved flag — Prevents #loadAnnotationPageDataFromRerum() from overwriting already-resolved annotation items with raw RERUM references.
  • New #loadAnnotationPageDataFromRerum() — Fetches and syncs all AnnotationPage properties from RERUM (target, items, creator, label, partOf, prev, next). Replaces inline RERUM fetching in route handlers.
  • New #loadAnnotationPageItemsFromRerum() — Resolves all annotation references in items by instantiating Line objects and calling asJSON(true). Replaces the standalone resolveReferences() utility.
  • New resolvePageItems() — Public method exposing #loadAnnotationPageItemsFromRerum() for the /resolved endpoint.
  • New asJSON(isLD) — Returns the Page as a W3C AnnotationPage (JSON-LD with @context/type when isLD=true, plain object otherwise). Hydrates from RERUM on first call if needed.
  • RERUM items are now minimal references#savePageToRerum() strips items to {id, type} only when writing to RERUM. The TPEN database (MongoDB project record) continues to store items with target intact.
  • Improved error handling in #savePageToRerum() — RERUM fetch failures now produce structured errors with status: 502 and descriptive messages instead of generic throws. Version conflicts (409) produce proper error objects with code: 'VERSION_CONFLICT'.
  • @context updated — Changed from http://www.w3.org/ns/anno.jsonld to http://iiif.io/api/presentation/3/context.json for IIIF Presentation API v3 conformance.

Line Class (classes/Line/Line.js)

  • New #hydrated flag — Replaces the body === undefined check in asJSON() and asTextBlob() with a proper hydration guard. Once hydrated (via load or save), subsequent calls skip RERUM fetches.
  • Improved error handling in #saveLineToRerum() — RERUM responses are now parsed with status-aware error messages (502 for RERUM failures). Non-existent lines (no id/@id in response) trigger a create fallback instead of silent failure.
  • Improved error handling in #loadAnnotationDataFromRerum() — Added .catch() for network errors and garbled-data detection, consistent with the Page class pattern.
  • asJSON(isLD) includes creator — When isLD=true, the creator is included in the JSON-LD output if present.

Page Routes (page/index.js)

  • GET /:pageId — Replaced inline AnnotationPage construction and conditional RERUM fetching with page.asJSON(true). The Page class handles hydration internally.
  • PUT /:pageId — Response now uses page.asJSON(true) instead of returning the raw Page instance.
  • GET /:pageId/resolved — Replaced resolveReferences() utility call with pageData.resolvePageItems() then pageData.asJSON(true).
  • Removed resolveReferences import — No longer needed; resolution logic lives in the Page class.
  • Removed redundant null checksfindPageById() now throws on not-found, so post-call null checks are unnecessary.

Line Routes (line/index.js)

  • Explicit status codes — All responses now use explicit .status(200) or .status(201) instead of relying on Express defaults.
  • Variable renamejsonObjlineJson for clarity.
  • Removed redundant null check — POST handler no longer checks if (!page) return after findPageById() since it throws on not-found.

Utilities (utilities/shared.js)

  • findPageById() simplified — Removed the rerum parameter and direct RERUM fetching. Always looks up the page in the project's layer data and returns a Page class instance. RERUM hydration is now the Page class's responsibility via asJSON().
  • Removed resolveReference() and resolveReferences() — Annotation resolution is now handled by Page.#loadAnnotationPageItemsFromRerum() using the Line class.
  • updatePageAndProject() error passthrough — RERUM errors (status: 502) are now re-thrown instead of being swallowed into a generic 500.

@thehabes thehabes self-assigned this Feb 26, 2026

This comment was marked as outdated.

This comment was marked as outdated.

This comment was marked as outdated.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 5 out of 5 changed files in this pull request and generated no new comments.

@thehabes thehabes linked an issue Feb 27, 2026 that may be closed by this pull request

This comment was marked as resolved.

@thehabes thehabes marked this pull request as ready for review March 4, 2026 18:31
@thehabes thehabes requested a review from cubap as a code owner March 4, 2026 18:31
Copy link
Copy Markdown
Member

@cubap cubap left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What a big lift! I have lots of remarks. Some preferences, some stronger opinions.
I did not directly test functionality because I could see that was generally intact. My comments are all code-sniff related.

Comment thread classes/Line/Line.js Outdated
Comment on lines +62 to +66
let rerumErrorMessage = `${resp.status ?? 500}: ${this.id} - `
try {
rerumErrorMessage += await resp.text()
} catch (err) {
rerumErrorMessage = undefined
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This block never sets the default at the top, right? It is only 64 or 66.

Copy link
Copy Markdown
Member Author

@thehabes thehabes Mar 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What we are saying here is that

let rerumErrorMessage = resp.status code from RERUM or a default 500 since it could not be obtained: The URI of the thing that errored - {anticipate resp.text() for error text}. Here we are presuming this is going to be an understandable error from RERUM but we won't really know until we try to resp.text()

try {
To do resp.text(), which checks if it is a real error response from rerum. If it fails make up the text.
}
catch (err) {
Could not even attempt resp.text(), so this is really bad. The connection may not have occurred at all. The rerumErrorMessage is undefined, we need to start from scratch on an error. We may not even have err.status.
}

const err = that good rerumErrorMessage we built but if it is undefined ?? we need to be real generic and say "500 from RERUM" because we cannot gleam a status code or error message at all..

The error status to send forward to the client is 502, with the descriptive or generic RERUM error message code: uri - text.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's how I read it.

Comment thread classes/Line/Line.js Outdated
rerumErrorMessage = undefined
}
throw new Error(`Failed to fetch existing Line from RERUM: ${err.message}`)
const err = new Error(rerumErrorMessage ?? `${resp.status ?? 500}: ${this.id} - A RERUM error occurred`)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Then down here the message is either used or the default that was just cleared is reinstated with more text at the end.

Copy link
Copy Markdown
Member Author

@thehabes thehabes Mar 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, because we cannot trust what we already had. We did not get a good error response from RERUM - perhaps no status code, definitely no error text. This happens on like "Connection Refused" (firewall), for example. So we need to recreate the text as something very generic-y. Here's your 502, and know that generically "500: https://devstore.rerum.io/v1/12345 - RERUM didn't work" even though I didn't get far enough to actually get that 500 from RERUM so I'm making it up (if resp.text() failed, you do not have a formatted RERUM error something else happened. You might have resp.status, you might not).

Comment thread classes/Line/Line.js
Comment thread classes/Line/Line.js
return this // Return without versioning
}

const action = this.#tinyAction === 'create' ? 'save' : this.#tinyAction
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is accommodating that "Create" is no longer in the tinyDriver and that it is called 'save'. I think this should just be changed to toggle save/update and avoid this weird trick.

Copy link
Copy Markdown
Member Author

@thehabes thehabes Mar 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not quite. Consider how Page.js does this

if (this.#tinyAction === 'create') {
    const saved = await databaseTiny.save(pageAsAnnotationPage)
        .catch(err => {
            console.error(err, pageAsAnnotationPage)
            throw new Error(`Failed to save Page to RERUM: ${err.message}`)
        })
    this.#tinyAction = 'update'
    this.#hydrated = true
    return this
}

It calls databaseTiny.save manually.

Now consider how Line.js was doing it

const newURI = await databaseTiny[action](updatedLine).then(res => res.id)
.catch(err => {
    throw new Error(`Failed to update Line in RERUM: ${err.message}`)
})

When databaseTiny[action] has "create" for action, databaseTiny.create is undefined and throws an error, because it is databaseTiny.save not databaseTiny.create. In this line, it determines if it is trying to "create" or "update" and I didn't want to refactor around it, I just made sure it called the right databaseTiny function when it means to.

Also note that databaseTiny error throughput !== RERUM error throughput. I did not adjust the databaseTiny error throughput in this PR.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This illustrates my point. The action is save/update and the #tinyAction is create/update. The whole existence of "create" seems extra. Those could both be simplified easily.

Comment thread classes/Line/Line.js
err.status = 502
throw err
})
.catch(err => {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this whole catch block repeated nearly exactly? It all looks very familiar and it might be something worth extracting, since we want to have it be consistent.

Copy link
Copy Markdown
Member Author

@thehabes thehabes Mar 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, it is repeated nearly exactly multiple times. The files already each had independent handling but it was irregular. Now that it is regular, you are noticing it.

Here is the regularization, which is not extracted as a utility or helper pattern. It was all instantiated in place of where the error were already being processed individually.

let data = await fetch(rerumURI).then(async (resp) => {
    if (resp.ok) return resp.json()
    Process RERUM error resp.text() and build rerumErrorMessage (but catch when resp.text() fails)
    throw TPEN Services 502 with message body "rerumErrorStatusCode: uri - rerumErrorMessage", make up "rerumErrorMessage" if you have to because resp.text() failed.  
})
.catch(err => {
    Was it that 502?  Ok, throw the err forward we are done
    Wasn't a 502?  Oh boy the connection to RERUM did not occur at all.  Gotta be generic
    Build that generic RERUM error message.
    throw TPEN Services 502 with message body "500: uri - genericErrorMessage"
})
if (!(data.id || data["@id"])) {
    Well we got a 200 or 201 from RERUM the but the item in the body does not have an id or @id.
    Build that generic RERUM error message.
   throw TPEN Services 502 with message body "500: uri - genericErrorMessage"
}

We can extract this and make it prettier, but this is what makes the error throughput work from RERUM through a Class and out as a TPEN Services response. I can show you with a demo, but this was those days I was getting in there in the back end and messing with the URIs to make them fail these ways to make sure we caught all the kinds of errors that could occur when attempting RERUM connections and communications.

To do much more than this is probably going down the #425 road

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This doesn't have to be in scope. I am just nervous about creating another situation we might update what seems to be off and miss the corners we haven't seen broken yet.

Comment thread classes/Page/Page.js Outdated
else return { id: item?.id ?? item, error: "Unrecognized Page item format" }
let line
try {
line = await new Line(lineRef).asJSON(true)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is already a promise we can .catch() which might be a lot easier to read.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Lines.asJSON(true) does not return a promise. It returns an object.

#loadAnnotationDataFromRerum() either throws an error (at the top of Line.asJSON()), or asJSON() proceeds forward with a JSON object to give back.

So it needs the try {} catch(){} pattern.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What are we awaiting?

Comment thread classes/Page/Page.js Outdated
let lineRef
// target is required by Line constructor but will be overwritten by RERUM data
// since #hydrated is false, Line.asJSON() always fetches from RERUM.
if (typeof item === "string") lineRef = { "id": item, "target":"pending-resolution" }
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think if you reordered this logic 2-1-3, it could be a lot less if/elseif/else-y

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

eh probably. we can change it, but then we have to test it and verify the change. could be worth it.

Comment thread classes/Page/Page.js
Comment thread classes/Page/Page.js
return this
} catch (err) {
if (err.status === 409) {
throw handleVersionConflict(null, err)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's the reason for this? The handler seems useful...

Copy link
Copy Markdown
Member Author

@thehabes thehabes Mar 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You must hand forward the Express response stream res to handleVersionConflict. This line of code errored out trying to use null. We also do not have the Express response stream here, so we were caught in a pickle. This now replicates what handleVersionConflict() would do if we were able to give it the Express Response stream.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just throwing here (with a 409, which it has already) would land here:

return handleVersionConflict(res, error)

and that doesn't need much data to reproduce this error:

export const handleVersionConflict = (res, error) => {
return res.status(409).json({
error: error.message,
currentVersion: error.currentVersion,
code: 'VERSION_CONFLICT',
details: 'The document was modified by another process.',
// Include additional context if available
...(error.pageId && { pageId: error.pageId }),
...(error.layerId && { layerId: error.layerId }),
...(error.lineId && { lineId: error.lineId })
})
}

so if the error back from Tiny (which I am not certain we have right) has err.currentVersion then we're golden.

https://github.com/CenterForDigitalHumanities/TinyPen/blob/ed4535eada10325d007f0d5a6deacbde82029b55/routes/overwrite.js#L46-L50

You just modify the err.message if you want and rethrow it. It is correct that you cannot go directly into handleVersionConflict() with it.

Comment thread line/index.js
@thehabes thehabes merged commit 2c7813f into development Mar 5, 2026
3 checks passed
@thehabes thehabes deleted the 459-page-router-and-class branch March 5, 2026 18:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

/page/:pageid Router & Page Class Update

3 participants