| name | page-save-origin-semantics |
| description | Auto-invoked when modifying origin-based conflict detection, revision validation logic, or isUpdatable() method. Explains the two-stage origin check mechanism for conflict detection and its separation from diff detection. |
Page Save Origin Semantics
Problem
When modifying page save logic, it's easy to accidentally break the carefully designed origin-based conflict detection system. The system uses a two-stage check mechanism (frontend + backend) to determine when revision validation should be enforced vs. bypassed for collaborative editing (Yjs).
Key Insight: Conflict detection (revision check) and diff detection (hasDiffToPrev) serve different purposes and require separate logic.
Solution
Understanding the two-stage origin check mechanism:
Stage 1: Frontend Determines revisionId Requirement
const isRevisionIdRequiredForPageUpdate = currentPage?.revision?.origin === undefined;
const revisionId = isRevisionIdRequiredForPageUpdate
? currentRevisionId
: undefined;
Logic: Check the latest revision's origin on the page:
- If
origin === undefined (legacy/API save) → Send revisionId
- If
origin === "editor" or "view" → Do NOT send revisionId
Stage 2: Backend Determines Conflict Check Behavior
const ignoreLatestRevision =
origin === Origin.Editor &&
(latestRevisionOrigin === Origin.Editor || latestRevisionOrigin === Origin.View);
if (ignoreLatestRevision) {
return true;
}
if (revision != previousRevision) {
return false;
}
return true;
Logic: Check current request's origin AND latest revision's origin:
- If
origin === "editor" AND latest is "editor" or "view" → Bypass revision check
- Otherwise → Enforce strict revision ID matching
Origin Values
Three types of page update methods (called "origin"):
Origin.Editor = "editor" - Save from editor mode (collaborative editing via Yjs)
Origin.View = "view" - Save from view mode
- Examples: HandsontableModal, DrawioModal editing
undefined - API-based saves or legacy pages
Origin Strength (強弱)
Basic Rule: Page updates require the previous revision ID in the request. If the latest revision doesn't match, the server rejects the request.
Exception - Editor origin is stronger than View origin:
- UX Goal: Avoid
Posted param "revisionId" is outdated errors when multiple members are using the Editor and View changes interrupt them
- Special Case: When the latest revision's origin is View, Editor origin requests can update WITHOUT requiring revision ID
Origin Strength Matrix
| Latest Revision: Editor | Latest Revision: View | Latest Revision: API |
|---|
| Request: Editor | ⭕️ Bypass revision check | ⭕️ Bypass revision check | ❌ Strict check |
| Request: View | ❌ Strict check | ❌ Strict check | ❌ Strict check |
| Request: API | ❌ Strict check | ❌ Strict check | ❌ Strict check |
Reading the table:
- ⭕️ = Revision check bypassed (revisionId not required)
- ❌ = Strict revision check required (revisionId must match)
Behavior by Scenario
| Latest Revision Origin | Request Origin | revisionId Sent? | Revision Check | Use Case |
|---|
editor or view | editor | ❌ No | ✅ Bypassed | Normal Editor use (most common) |
undefined | editor | ✅ Yes | ✅ Enforced | Legacy page in Editor |
undefined | undefined (API) | ✅ Yes (required) | ✅ Enforced | API save |
Example: Server-Side Logic Respecting Origin Semantics
When adding server-side functionality that needs previous revision data:
let previousRevision: IRevisionHasId | null = null;
if (sanitizeRevisionId != null) {
previousRevision = await Revision.findById(sanitizeRevisionId);
}
if (previousRevision == null && currentPage.revision != null) {
previousRevision = await Revision.findById(currentPage.revision);
}
const previousBody = previousRevision?.body ?? null;
if (currentPage != null && !(await currentPage.isUpdatable(sanitizeRevisionId, origin))) {
}
updatedPage = await crowi.pageService.updatePage(
currentPage,
body,
previousBody,
req.user,
options,
);
const revisionId = currentRevisionId;
When to Apply
Always consider this pattern when:
- Modifying page save/update API handlers
- Adding functionality that needs previous revision data
- Working on conflict detection or revision validation logic
- Implementing features that interact with page history
- Debugging save operation issues
Key Principles:
- Do NOT modify frontend revisionId logic unless explicitly required for conflict detection
- Do NOT modify isUpdatable() logic unless fixing conflict detection bugs
- Separate concerns: Conflict detection ≠ Other revision-based features (diff detection, history, etc.)
- Server-side fallback: If you need previous revision data when revisionId is not provided, fetch from
currentPage.revision
Detailed Scenario Analysis
Scenario A: Normal Editor Mode (Most Common Case)
Latest revision has origin=editor:
-
Frontend Logic:
isRevisionIdRequiredForPageUpdate = false (latest revision origin is not undefined)
- Does NOT send
revisionId in request
- Sends
origin: Origin.Editor
-
API Layer:
previousRevision = await Revision.findById(undefined);
Result: No previousRevision fetched via revisionId
-
Backend Conflict Check (isUpdatable):
ignoreLatestRevision =
(Origin.Editor === Origin.Editor) &&
(latestRevisionOrigin === Origin.Editor || latestRevisionOrigin === Origin.View)
return true;
Result: ✅ Save succeeds without revision validation
-
Impact on Other Features:
- If you need previousRevision data (e.g., for diff detection), it won't be available unless you implement server-side fallback
- This is where
currentPage.revision fallback becomes necessary
Scenario B: Legacy Page in Editor Mode
Latest revision has origin=undefined:
-
Frontend Logic:
isRevisionIdRequiredForPageUpdate = true (latest revision origin is undefined)
- Sends
revisionId in request
- Sends
origin: Origin.Editor
-
API Layer:
previousRevision = await Revision.findById(sanitizeRevisionId);
Result: previousRevision fetched successfully
-
Backend Conflict Check (isUpdatable):
ignoreLatestRevision =
(Origin.Editor === Origin.Editor) &&
(latestRevisionOrigin === undefined)
if (revision != sanitizeRevisionId) {
return false;
}
return true;
Result: ✅ Save succeeds only if revisionId matches
-
Impact on Other Features:
- previousRevision data is available
- All revision-based features work correctly
Scenario C: API-Based Save
Request has origin=undefined or omitted:
-
Frontend: Not applicable (API client)
-
API Layer:
- API client MUST send
revisionId in request
previousRevision = await Revision.findById(sanitizeRevisionId)
-
Backend Conflict Check (isUpdatable):
ignoreLatestRevision =
(undefined === Origin.Editor) && ...
if (revision != sanitizeRevisionId) {
return false;
}
return true;
Result: Strict validation enforced
Root Cause: Why This Separation Matters
Historical Context: At some point, the frontend stopped sending previousRevision (revisionId) for certain scenarios to support Yjs collaborative editing. This broke features that relied on previousRevision data being available.
The Core Issue:
- Conflict detection needs to know "Is this save conflicting with another user's changes?" (Answered by revision check)
- Diff detection needs to know "Did the content actually change?" (Answered by comparing body)
- Current implementation conflates these: When conflict detection is bypassed, previousRevision is not fetched, breaking diff detection
The Solution Pattern:
let previousRevision: IRevisionHasId | null = null;
if (sanitizeRevisionId != null) {
previousRevision = await Revision.findById(sanitizeRevisionId);
} else if (currentPage.revision != null) {
previousRevision = await Revision.findById(currentPage.revision);
}
const previousBody = previousRevision?.body ?? null;
if (currentPage != null && !(await currentPage.isUpdatable(sanitizeRevisionId, origin))) {
}
Reference
Official Documentation:
Related Files:
- Frontend:
apps/app/src/client/components/PageEditor/PageEditor.tsx (lines 158, 240, 308-310)
- Backend:
apps/app/src/server/models/obsolete-page.js (lines 159-182, isUpdatable method)
- API:
apps/app/src/server/routes/apiv3/page/update-page.ts (lines 260-282, conflict check)
- Interface:
packages/core/src/interfaces/revision.ts (lines 6-11, Origin definition)
Common Pitfalls
- Assuming revisionId is always available: It's not! Editor mode with recent editor/view saves omits it by design.
- Conflating conflict detection with other features: They serve different purposes and need separate logic.
- Breaking Yjs collaborative editing: Forcing revisionId to always be sent breaks the bypass mechanism.
- Ignoring origin values: The system behavior changes significantly based on origin combinations.
Lessons Learned
This pattern was identified during the "improve-unchanged-revision" feature implementation, where the initial assumption was that frontend should always send revisionId for diff detection. Deep analysis revealed:
- The frontend logic is correct for conflict detection and should NOT be changed
- Server-side fallback is the correct approach to get previous revision data
- Two-stage checking is intentional and critical for Yjs collaborative editing
- Conflict detection and diff detection must be separated
Key Takeaway: Always understand the existing architectural patterns before proposing changes. What appears to be a "fix" might actually break carefully designed functionality.