Merge pull request #13919 from nocodb/nc-feat/docs-rev-history

Nc feat : docs rev history
This commit is contained in:
Ramesh Mane
2026-05-29 01:27:22 +05:30
committed by GitHub
16 changed files with 385 additions and 3 deletions

View File

@@ -232,6 +232,10 @@ export const useEeConfig = createSharedComposable(() => {
const showUpgradeToUseDocsExportPdf = (..._args: any[]) => {}
const revisionRetentionLadder = computed<{ title: string; days: number }[]>(() => [])
const requiredPlanForRevisionAge = (..._args: any[]): string | null => null
const showScriptPlanLimitExceededModal = (..._args: any[]) => {}
const showUpgradeToUseCalendarRange = (..._args: any[]) => {}
@@ -438,6 +442,8 @@ export const useEeConfig = createSharedComposable(() => {
showUpgradeToUseDocsInlineComments,
showUpgradeToUseDocsResolveComments,
showUpgradeToUseDocsExportPdf,
revisionRetentionLadder,
requiredPlanForRevisionAge,
showScriptPlanLimitExceededModal,
blockAddNewScript,
blockAddNewDashboard,

View File

@@ -1378,6 +1378,37 @@
"manyToOne": "Many to One"
},
"labels": {
"docHistory": {
"title": "Version history",
"lockedTitle": "{count} day page history",
"lockedDescWithPlan": "Please upgrade to the {plan} plan to access versions older than {count} day. | Please upgrade to the {plan} plan to access versions older than {count} days.",
"lockedDescGeneric": "Upgrade your plan to access versions older than {count} day. | Upgrade your plan to access versions older than {count} days.",
"lockedDescOnPrem": "Enter your license key to access versions older than {count} day. | Enter your license key to access versions older than {count} days.",
"upgradeNow": "Upgrade now",
"retentionByPlanTitle": "Version history by plan",
"retentionByPlanDays": "{count} day | {count} days",
"previewEmptyTitle": "Select a version to preview",
"previewEmptySubtitle": "Pick one from the list on the right to see the page as it was at that point. Changes against the current version are highlighted inline.",
"currentVersion": "Current version · {author}",
"loadOlder": "Load older",
"emptyTitle": "No versions saved yet",
"emptySubtitle": "A version is recorded each time the page is saved. Saves made within 2 minutes by the same author are merged into one.",
"restoreConfirmTitle": "Restore this version?",
"restoreConfirmContent": "Your current version will be saved in history before restoring.",
"restored": "Restored to selected version",
"noRestorePermission": "You do not have permission to restore revisions",
"authorFallback": "Someone",
"savedBy": {
"restored": "Restored by {author} · {when}",
"saved": "Saved by {author} · {when}",
"edited": "Edited by {author} · {when}"
},
"listAction": {
"restored": "{author} restored",
"saved": "{author} saved",
"edited": "{author} edited"
}
},
"changePassword": "Change Password",
"changeViewPassword": "Change View Password",
"changeDashboardPassword": "Change Dashboard Password",

View File

@@ -183,6 +183,7 @@ const rolePermissions = {
// Documents — editors can update and reorder, but NOT create/delete
documentUpdate: true,
documentReorder: true,
documentRevisionRestore: true,
},
},
[ProjectRoles.COMMENTER]: {
@@ -217,6 +218,8 @@ const rolePermissions = {
// Documents — read-only for viewers
documentList: true,
documentGet: true,
documentRevisionList: true,
documentRevisionGet: true,
// Document Comments — read-only for viewers
documentCommentList: true,

View File

@@ -296,9 +296,11 @@ export default defineNuxtConfig({
'markdown-it-regexp',
'markdown-it-task-lists',
'marked',
'mermaid',
'monaco-editor',
'monaco-editor/esm/vs/basic-languages/javascript/javascript',
'papaparse',
'prosemirror-changeset',
'rehype-sanitize',
'rehype-stringify',
'remark-parse',

View File

@@ -0,0 +1,23 @@
export enum DocRevisionSource {
AUTO = 'auto',
MANUAL = 'manual',
RESTORE = 'restore',
}
export interface DocumentRevisionType {
id?: string;
fk_doc_id?: string;
base_id?: string;
fk_workspace_id?: string;
version?: number;
/** ProseMirror JSON snapshot. Omitted in list responses; included in single-revision GET. */
content?: Record<string, any>;
title?: string;
created_by?: string;
fk_tab_id?: string;
source?: DocRevisionSource;
/** True when older than the plan's retention window — content is gated. */
locked?: boolean;
created_at?: string;
updated_at?: string;
}

View File

@@ -237,6 +237,8 @@ enum AuditV1OperationTypes {
DOCUMENT_UPDATE = 'DOCUMENT_UPDATE',
DOCUMENT_DELETE = 'DOCUMENT_DELETE',
DOCUMENT_REVISION_RESTORE = 'DOCUMENT_REVISION_RESTORE',
DOCUMENT_PUBLIC_SHARE_CREATE = 'DOCUMENT_PUBLIC_SHARE_CREATE',
DOCUMENT_PUBLIC_SHARE_UPDATE = 'DOCUMENT_PUBLIC_SHARE_UPDATE',
DOCUMENT_PUBLIC_SHARE_DELETE = 'DOCUMENT_PUBLIC_SHARE_DELETE',
@@ -1359,6 +1361,15 @@ export interface DocumentDeletePayload {
document_id: string;
}
export interface DocumentRevisionRestorePayload {
document_title: string;
document_id: string;
revision_id: string;
revision_created_at: string;
revision_author?: string | null;
revision_source: 'auto' | 'manual' | 'restore';
}
export interface DocumentPublicShareCreatePayload {
document_title: string;
document_id: string;
@@ -1871,6 +1882,10 @@ const descriptionTemplates = {
[AuditV1OperationTypes.DOCUMENT_DELETE]: (
audit: AuditV1<DocumentDeletePayload>
) => `Document '${audit.details.document_title}' has been deleted`,
[AuditV1OperationTypes.DOCUMENT_REVISION_RESTORE]: (
audit: AuditV1<DocumentRevisionRestorePayload>
) =>
`Document '${audit.details.document_title}' restored to the version from ${audit.details.revision_created_at}`,
[AuditV1OperationTypes.DOCUMENT_PUBLIC_SHARE_CREATE]: (
audit: AuditV1<DocumentPublicShareCreatePayload>
) =>

View File

@@ -359,6 +359,7 @@ export enum AppEvents {
DOCUMENT_UPDATE = 'document.update',
DOCUMENT_DELETE = 'document.delete',
DOCUMENT_USER_MENTION = 'document.user.mention',
DOCUMENT_REVISION_RESTORE = 'document.revision.restore',
DOCUMENT_PUBLIC_SHARE_CREATE = 'document.public_share.create',
DOCUMENT_PUBLIC_SHARE_UPDATE = 'document.public_share.update',

View File

@@ -112,6 +112,7 @@ export * from '~/lib/chat';
export * from '~/lib/v3';
export * from '~/lib/Document';
export * from '~/lib/DocumentComment';
export * from '~/lib/DocumentRevision';
export * from '~/lib/docs';
export * from '~/lib/entityNameValidation';
export * from '~/lib/smartText';

View File

@@ -32,6 +32,7 @@ export enum PlanLimitTypes {
LIMIT_RLS_POLICIES_PER_TABLE = 'limit_rls_policies_per_table',
LIMIT_DOCUMENT_PAGE_PER_BASE = 'limit_document_page_per_base',
LIMIT_DOCS_PAGE_SIZE_KB = 'limit_docs_page_size_kb',
LIMIT_DOC_REVISION_HISTORY_DAYS = 'limit_doc_revision_history_days',
LIMIT_WORKSPACE = 'limit_workspace',
LIMIT_TRASH_RETENTION = 'limit_trash_retention',
LIMIT_AI_INTEGRATIONS = 'limit_ai_integrations',
@@ -297,6 +298,8 @@ export const PlanLimitUpgradeMessages: Record<PlanLimitTypes, string> = {
'to add more document pages in a base.',
[PlanLimitTypes.LIMIT_DOCS_PAGE_SIZE_KB]:
'to increase the document page size limit.',
[PlanLimitTypes.LIMIT_DOC_REVISION_HISTORY_DAYS]:
'to keep document revision history for longer.',
[PlanLimitTypes.LIMIT_TRASH_RETENTION]: 'for extended trash retention.',
[PlanLimitTypes.LIMIT_AI_INTEGRATIONS]: 'to add more AI integrations.',
};

View File

@@ -1,8 +1,9 @@
import * as nc_001_init from '~/meta/migrations/docs-content/nc_001_init';
import * as nc_002_doc_revisions from '~/meta/migrations/docs-content/nc_002_doc_revisions';
export default class XcMigrationSourceDocsContent {
public getMigrations(): Promise<any> {
return Promise.resolve(['nc_001_init']);
return Promise.resolve(['nc_001_init', 'nc_002_doc_revisions']);
}
public getMigrationName(migration): string {
@@ -13,6 +14,8 @@ export default class XcMigrationSourceDocsContent {
switch (migration) {
case 'nc_001_init':
return nc_001_init;
case 'nc_002_doc_revisions':
return nc_002_doc_revisions;
}
}
}

View File

@@ -64,6 +64,7 @@ import * as nc_202605140000_operation_logs from './v0/nc_202605140000_operation_
import * as nc_202605160000_cleanup_orphan_base_users from './v0/nc_202605160000_cleanup_orphan_base_users';
import * as nc_202605181000_gantt_view from './v0/nc_202605181000_gantt_view';
import * as nc_202605271200_add_group_by_enabled from './v0/nc_202605271200_add_group_by_enabled';
import * as nc_202605281200_doc_revisions from './v0/nc_202605281200_doc_revisions';
// Create a custom migration source class
export default class XcMigrationSourcev0 {
@@ -139,6 +140,7 @@ export default class XcMigrationSourcev0 {
'nc_202605160000_cleanup_orphan_base_users',
'nc_202605181000_gantt_view',
'nc_202605271200_add_group_by_enabled',
'nc_202605281200_doc_revisions',
]);
}
@@ -280,6 +282,8 @@ export default class XcMigrationSourcev0 {
return nc_202605181000_gantt_view;
case 'nc_202605271200_add_group_by_enabled':
return nc_202605271200_add_group_by_enabled;
case 'nc_202605281200_doc_revisions':
return nc_202605281200_doc_revisions;
}
}
}

View File

@@ -0,0 +1,47 @@
import type { Knex } from 'knex';
import { MetaTable } from '~/utils/globals';
const up = async (knex: Knex) => {
await knex.schema.createTable(MetaTable.DOC_REVISIONS, (table) => {
table.string('id', 40).notNullable();
table.string('fk_doc_id', 20).notNullable();
table.string('base_id', 20).notNullable();
table.string('fk_workspace_id', 20);
table.integer('version').notNullable();
table.text('content');
table.string('title', 255);
table.string('created_by', 20);
// Per-tab UUID (x-nc-tab-id header) — discriminates same-author writes
// across browser tabs / devices so they don't coalesce into one row.
table.string('fk_tab_id', 36);
table.string('source', 16).notNullable().defaultTo('auto');
table.timestamps(true, true);
table.primary(['id']);
table.index(
['fk_doc_id', 'created_at'],
'nc_doc_revisions_v2_doc_created_idx',
);
table.index(
['base_id', 'fk_workspace_id'],
'nc_doc_revisions_v2_tenant_idx',
);
});
const isPg =
knex.client.config.client === 'pg' ||
knex.client.config.client === 'postgresql';
if (isPg) {
await knex.raw(
`ALTER TABLE ?? ALTER COLUMN content TYPE jsonb USING content::jsonb`,
[MetaTable.DOC_REVISIONS],
);
}
};
const down = async (knex: Knex) => {
await knex.schema.dropTableIfExists(MetaTable.DOC_REVISIONS);
};
export { up, down };

View File

@@ -0,0 +1,57 @@
import type { Knex } from 'knex';
import {
up as createDocRevisions,
down as dropDocRevisions,
} from '~/meta/migrations/docs-content/nc_002_doc_revisions';
import { MetaTable } from '~/utils/globals';
// Wrapper for the doc-revisions satellite migration. Same schema runs against
// NC_DOCS_DB when configured (revisions share the docs satellite DB). Also
// adds fk_revision_id to nc_file_references for revision-owned snapshot rows
// (meta DB only — file refs don't satellitize).
const up = async (knex: Knex) => {
await createDocRevisions(knex);
// 40 chars to fit uuidv7 revision ids (36) — matches nc_doc_revisions_v2.id.
await knex.schema.alterTable(MetaTable.FILE_REFERENCES, (table) => {
table.string('fk_revision_id', 40).nullable();
});
// Index supports lookups by revision (sync / bulk-delete of snapshot rows).
// On PG/SQLite use a partial index — every row has fk_revision_id IS NULL
// at deploy time, so the index is empty and the build lock is sub-second
// instead of 20s+ on large nc_file_references tables. The planner uses it
// for `fk_revision_id = ?` / `IN (...)` predicates (both imply NOT NULL).
// MySQL has no partial-index syntax — fall back to a full composite index.
const client = knex.client.config.client;
const isPartialIndexSupported =
client === 'pg' ||
client === 'postgresql' ||
client === 'sqlite3' ||
client === 'better-sqlite3';
if (isPartialIndexSupported) {
await knex.raw('CREATE INDEX ?? ON ?? (??, ??) WHERE ?? IS NOT NULL', [
'nc_fr_revision_idx',
MetaTable.FILE_REFERENCES,
'base_id',
'fk_revision_id',
'fk_revision_id',
]);
} else {
await knex.schema.alterTable(MetaTable.FILE_REFERENCES, (table) => {
table.index(['base_id', 'fk_revision_id'], 'nc_fr_revision_idx');
});
}
};
const down = async (knex: Knex) => {
await knex.schema.alterTable(MetaTable.FILE_REFERENCES, (table) => {
table.dropIndex(['base_id', 'fk_revision_id'], 'nc_fr_revision_idx');
table.dropColumn('fk_revision_id');
});
await dropDocRevisions(knex);
};
export { up, down };

View File

@@ -21,6 +21,7 @@ export default class FileReference {
fk_column_id: string;
fk_row_id: string;
fk_doc_id: string;
fk_revision_id: string;
fk_session_id: string;
is_external: boolean;
deleted: boolean;
@@ -48,6 +49,7 @@ export default class FileReference {
'fk_column_id',
'fk_row_id',
'fk_doc_id',
'fk_revision_id',
'fk_session_id',
'is_external',
'deleted',
@@ -91,6 +93,7 @@ export default class FileReference {
'fk_column_id',
'fk_row_id',
'fk_doc_id',
'fk_revision_id',
'fk_session_id',
'is_external',
'deleted',
@@ -133,6 +136,7 @@ export default class FileReference {
'fk_column_id',
'fk_row_id',
'fk_doc_id',
'fk_revision_id',
'fk_session_id',
'is_external',
'deleted',
@@ -436,8 +440,8 @@ export default class FileReference {
}
/**
* Return all active FileReference IDs for a doc.
* Uses nc_fr_doc_idx (base_id, fk_doc_id).
* Active FileReference IDs for a doc's live content. Excludes revision
* snapshots so reconcile only diffs against what's currently embedded.
*/
public static async listIdsForDoc(
context: NcContext,
@@ -451,11 +455,172 @@ export default class FileReference {
fk_doc_id: docId,
deleted: false,
})
.whereNull('fk_revision_id')
.select('id');
return rows.map((r: any) => r.id);
}
/**
* Sync the snapshot rows for a revision to match the attachments embedded
* in its content. Snapshot rows are keyed by (revision_id, file_url) and
* carry file_size=0 — the cleanup job groups by file_url and only purges
* when every row in the group is deleted, so a live snapshot pins the
* underlying file while the revision survives.
*
* Idempotent — safe on coalesce.
*/
public static async syncSnapshotForRevision(
context: NcContext,
params: {
docId: string;
revisionId: string;
attachmentIds: string[];
fkUserId?: string;
},
ncMeta = Noco.ncMeta,
): Promise<void> {
const uniqueIds = Array.from(new Set(params.attachmentIds ?? []));
const [liveRows, existingSnapshots]: [
{ id: string; storage: string; file_url: string; fk_user_id: string }[],
{ id: string; file_url: string }[],
] = await Promise.all([
uniqueIds.length
? ncMeta
.knexConnection(MetaTable.FILE_REFERENCES)
.where({ base_id: context.base_id, fk_doc_id: params.docId })
.whereNull('fk_revision_id')
.whereIn('id', uniqueIds)
.select('id', 'storage', 'file_url', 'fk_user_id')
: Promise.resolve([]),
ncMeta
.knexConnection(MetaTable.FILE_REFERENCES)
.where({
base_id: context.base_id,
fk_revision_id: params.revisionId,
})
.select('id', 'file_url'),
]);
const expectedFileUrls = new Set(liveRows.map((r) => r.file_url));
const seen = new Set(existingSnapshots.map((r) => r.file_url));
// Insert one row per new file_url. `seen` also dedupes when two live rows
// share a file_url (re-upload of the same physical file).
const snapshotObjs: Partial<FileReference>[] = [];
for (const r of liveRows) {
if (seen.has(r.file_url)) continue;
seen.add(r.file_url);
snapshotObjs.push({
storage: r.storage,
file_url: r.file_url,
file_size: 0,
fk_user_id: r.fk_user_id ?? params.fkUserId ?? 'anonymous',
fk_doc_id: params.docId,
fk_revision_id: params.revisionId,
deleted: false,
});
}
if (snapshotObjs.length) {
await this.bulkInsert(context, snapshotObjs, ncMeta);
}
// Hard-delete orphans (attachments that never settled in the revision's
// content). Soft-deleting would pin the file_url group forever.
const orphanIds = existingSnapshots
.filter((r) => !expectedFileUrls.has(r.file_url))
.map((r) => r.id);
if (orphanIds.length) {
await ncMeta
.knexConnection(MetaTable.FILE_REFERENCES)
.where({ base_id: context.base_id })
.whereIn('id', orphanIds)
.del();
}
}
/**
* True if any non-deleted FileReference under this doc (live row or
* revision snapshot) references the given file_url.
*/
public static async existsActiveByFileUrlInDoc(
context: NcContext,
docId: string,
fileUrl: string,
ncMeta = Noco.ncMeta,
): Promise<boolean> {
const row = await ncMeta
.knexConnection(MetaTable.FILE_REFERENCES)
.where({
base_id: context.base_id,
fk_doc_id: docId,
file_url: fileUrl,
deleted: false,
})
.select(ncMeta.knexConnection.raw('1'))
.first();
return !!row;
}
/**
* Un-delete doc-owned FileReferences whose IDs are being reintroduced by
* a revision restore. Without this, reconcileFileReferences leaves
* pre-existing IDs alone, so previously soft-deleted refs stay deleted
* after restore and the proxy 404s.
*/
public static async reviveForDoc(
context: NcContext,
docId: string,
ids: string[],
ncMeta = Noco.ncMeta,
): Promise<void> {
if (!ids?.length) return;
const query = () =>
ncMeta
.knexConnection(MetaTable.FILE_REFERENCES)
.where({ base_id: context.base_id, fk_doc_id: docId, deleted: true })
.whereNull('fk_revision_id')
.whereIn('id', ids);
let restoredSize = 0;
try {
restoredSize =
(await query().sum('file_size as total').first())?.total || 0;
} catch (error) {
restoredSize = -1;
logger.error('Error while summing file reference size');
logger.error(error);
}
await query().update({ deleted: false, soft_deleted: false });
await this.updateWorkspaceCache(context, restoredSize);
}
/**
* Soft-delete snapshot rows owned by the given revisions. file_size=0 so no
* workspace cache update needed.
*/
public static async bulkDeleteForRevisions(
context: NcContext,
revisionIds: string[],
ncMeta = Noco.ncMeta,
): Promise<void> {
if (!revisionIds?.length) return;
// Chunk to stay under PG's WHERE IN planner cliff (~32k).
const BATCH = 1000;
for (let i = 0; i < revisionIds.length; i += BATCH) {
await ncMeta
.knexConnection(MetaTable.FILE_REFERENCES)
.where({ base_id: context.base_id, deleted: false })
.whereIn('fk_revision_id', revisionIds.slice(i, i + BATCH))
.update({ deleted: true });
}
}
/**
* List non-deleted FileReference IDs for a SmartText cell (model + column + row).
* Uses nc_fr_row_idx (base_id, fk_column_id, fk_row_id).

View File

@@ -21126,6 +21126,24 @@
"in": "query",
"description": "URL or Path of the attachment",
"required": false
},
{
"schema": {
"type": "string"
},
"name": "docId",
"in": "query",
"description": "Document ID",
"required": false
},
{
"schema": {
"type": "string"
},
"name": "revisionId",
"in": "query",
"description": "Document Revision ID",
"required": false
}
]
},

View File

@@ -138,6 +138,7 @@ export enum MetaTable {
/** @deprecated Documents now live in nc_models_v2 (type='document'). Kept for legacy data cleanup. */
DOCS = 'nc_docs_v2',
DOC_CONTENT = 'nc_doc_content_v2',
DOC_REVISIONS = 'nc_doc_revisions_v2',
API_TOKEN_SCOPES = 'nc_api_token_scopes',
TRASH = 'nc_trash',
}
@@ -206,6 +207,7 @@ export const BaseRelatedMetaTables = [
MetaTable.DEPENDENCY_TRACKER,
MetaTable.DOCS,
MetaTable.DOC_CONTENT,
MetaTable.DOC_REVISIONS,
MetaTable.TRASH,
];
@@ -330,6 +332,7 @@ export const orderedMetaTables = [
MetaTable.MODEL_STAT,
MetaTable.CUSTOM_URLS,
MetaTable.MCP_TOKENS,
MetaTable.DOC_REVISIONS,
MetaTable.DOCS,
MetaTable.DOC_CONTENT,
MetaTable.MODELS,