mirror of
https://github.com/nocodb/nocodb.git
synced 2026-06-02 00:22:02 +00:00
Merge pull request #13919 from nocodb/nc-feat/docs-rev-history
Nc feat : docs rev history
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
23
packages/nocodb-sdk/src/lib/DocumentRevision.ts
Normal file
23
packages/nocodb-sdk/src/lib/DocumentRevision.ts
Normal 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;
|
||||
}
|
||||
@@ -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>
|
||||
) =>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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.',
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user