growilabs / page-save-origin-semantics

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.

0 views
0 installs

Skill Content

---
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

```typescript
// apps/app/src/client/components/PageEditor/PageEditor.tsx:158
const isRevisionIdRequiredForPageUpdate = currentPage?.revision?.origin === undefined;

// lines 308-310
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

```javascript
// apps/app/src/server/models/obsolete-page.js:167-172
const ignoreLatestRevision =
  origin === Origin.Editor &&
  (latestRevisionOrigin === Origin.Editor || latestRevisionOrigin === Origin.View);

if (ignoreLatestRevision) {
  return true;  // Bypass revision check
}

// Otherwise, enforce strict revision matching
if (revision != previousRevision) {
  return false;  // Reject save
}
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:

```typescript
// ✅ CORRECT: Separate concerns - conflict detection vs. diff detection
let previousRevision: IRevisionHasId | null = null;

// Priority 1: Use provided revisionId (for conflict detection)
if (sanitizeRevisionId != null) {
  previousRevision = await Revision.findById(sanitizeRevisionId);
}

// Priority 2: Fallback to currentPage.revision (for other purposes like diff detection)
if (previousRevision == null && currentPage.revision != null) {
  previousRevision = await Revision.findById(currentPage.revision);
}

const previousBody = previousRevision?.body ?? null;

// Continue with existing conflict detection logic (unchanged)
if (currentPage != null && !(await currentPage.isUpdatable(sanitizeRevisionId, origin))) {
  // ... return conflict error
}

// Use previousBody for diff detection or other purposes
updatedPage = await crowi.pageService.updatePage(
  currentPage,
  body,
  previousBody,  // ← Available regardless of conflict detection logic
  req.user,
  options,
);
```

```typescript
// ❌ WRONG: Forcing frontend to always send revisionId
const revisionId = currentRevisionId;  // Always send, regardless of origin
// This breaks Yjs collaborative editing semantics!
```

```typescript
// ❌ WRONG: Changing backend conflict detection logic
// Don't modify isUpdatable() unless you fully understand the implications
// for collaborative editing
```

## 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**:
1. **Do NOT modify frontend revisionId logic** unless explicitly required for conflict detection
2. **Do NOT modify isUpdatable() logic** unless fixing conflict detection bugs
3. **Separate concerns**: Conflict detection ≠ Other revision-based features (diff detection, history, etc.)
4. **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`**:

1. **Frontend Logic**:
   - `isRevisionIdRequiredForPageUpdate = false` (latest revision origin is not undefined)
   - Does NOT send `revisionId` in request
   - Sends `origin: Origin.Editor`

2. **API Layer**:
   ```typescript
   previousRevision = await Revision.findById(undefined);  // → null
   ```
   Result: No previousRevision fetched via revisionId

3. **Backend Conflict Check** (`isUpdatable`):
   ```javascript
   ignoreLatestRevision =
     (Origin.Editor === Origin.Editor) &&
     (latestRevisionOrigin === Origin.Editor || latestRevisionOrigin === Origin.View)
   // → true (latest revision is editor)
   return true;  // Bypass revision check
   ```
   Result: ✅ Save succeeds without revision validation

4. **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`**:

1. **Frontend Logic**:
   - `isRevisionIdRequiredForPageUpdate = true` (latest revision origin is undefined)
   - Sends `revisionId` in request
   - Sends `origin: Origin.Editor`

2. **API Layer**:
   ```typescript
   previousRevision = await Revision.findById(sanitizeRevisionId);  // → revision object
   ```
   Result: previousRevision fetched successfully

3. **Backend Conflict Check** (`isUpdatable`):
   ```javascript
   ignoreLatestRevision =
     (Origin.Editor === Origin.Editor) &&
     (latestRevisionOrigin === undefined)
   // → false (latest revision is undefined, not editor/view)

   // Strict revision check
   if (revision != sanitizeRevisionId) {
     return false;  // Reject if mismatch
   }
   return true;
   ```
   Result: ✅ Save succeeds only if revisionId matches

4. **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**:

1. **Frontend**: Not applicable (API client)

2. **API Layer**:
   - API client MUST send `revisionId` in request
   - `previousRevision = await Revision.findById(sanitizeRevisionId)`

3. **Backend Conflict Check** (`isUpdatable`):
   ```javascript
   ignoreLatestRevision =
     (undefined === Origin.Editor) && ...
   // → false

   // Strict revision check
   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**:
```typescript
// Separate the two concerns:

// 1. Fetch previousRevision for data purposes (diff detection, history, etc.)
let previousRevision: IRevisionHasId | null = null;
if (sanitizeRevisionId != null) {
  previousRevision = await Revision.findById(sanitizeRevisionId);
} else if (currentPage.revision != null) {
  previousRevision = await Revision.findById(currentPage.revision);  // Fallback
}

// 2. Use previousRevision data for your feature
const previousBody = previousRevision?.body ?? null;

// 3. Conflict detection happens independently via isUpdatable()
if (currentPage != null && !(await currentPage.isUpdatable(sanitizeRevisionId, origin))) {
  // Return conflict error
}
```

## Reference

**Official Documentation**:
- https://dev.growi.org/651a6f4a008fee2f99187431#origin-%E3%81%AE%E5%BC%B7%E5%BC%B1

**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

1. **Assuming revisionId is always available**: It's not! Editor mode with recent editor/view saves omits it by design.
2. **Conflating conflict detection with other features**: They serve different purposes and need separate logic.
3. **Breaking Yjs collaborative editing**: Forcing revisionId to always be sent breaks the bypass mechanism.
4. **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.