|
|
@@ -0,0 +1,302 @@
|
|
|
+---
|
|
|
+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.
|