mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
fix(docsync): make the admin switch pause a binding, and retry a failed update
The check that was meant to stand a binding down when its provider is switched off asked AddonsService.isAddonEnabled(link.provider_id). The addons table has no rows for paperless, papra and the other document providers, so it answered "off" for every binding, and every run came back disabled before it listed anything. The unit test stubbed that answer and the e2e never runs a sync, so nothing caught it. isSwitchedOff() now asks the two things an admin actually switches: the Documents addon, and document_providers.enabled for the binding's provider. dueLinks leaves such bindings out in SQL, since one that never runs never gets a last_sync_at and twenty of them would take every slot of a tick. A run, the webhook on arrival and when its timer fires, "Sync now" and the MCP sync tool all refuse before anything is written, so a paused binding keeps its state and its documents' attempt counters and resumes where it stopped. The REST route answers 409 provider_disabled, the MCP tool reports the same code, status() carries providerOff per link, and both shells show one line saying the binding is paused because its provider is switched off, in all 23 languages. A failed pull_update also left the row stuck. upsertItem wrote the listing's version, size and time next to the old bytes, so the next run saw no upstream change, planned touch, which leaves error alone, and the document stayed in error on stale bytes for good, "Sync now" included. A failed transfer now leaves the pairing describing the copy TREK holds; a new row keeps what pairs it but no version, so it is fetched again.
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
// FE-DOCSYNC-BIND-001 to FE-DOCSYNC-BIND-025
|
||||
// FE-DOCSYNC-BIND-001 to FE-DOCSYNC-BIND-026
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { render, screen, fireEvent, waitFor, within } from '../../../../tests/helpers/render'
|
||||
import DocSyncBinding from './DocSyncBinding'
|
||||
@@ -158,6 +158,15 @@ describe('DocSyncBinding: what it says about itself', () => {
|
||||
expect(screen.getByText('not run yet')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-DOCSYNC-BIND-026: a binding whose provider an admin switched off says it is paused, not what the last run said', () => {
|
||||
// The server leaves the binding's own state as the last run wrote it, so
|
||||
// without this the card went on reporting an old failure, or nothing.
|
||||
renderCard({ link: { providerOff: true, lastSyncState: 'failed', lastSyncError: 'unauthorized' } })
|
||||
|
||||
expect(screen.getByText('Paused: an administrator has switched this provider off. Syncing resumes once it is back on.')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Failed · The credentials were refused.')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-DOCSYNC-BIND-010: a paused binding is badged, an active one is not', () => {
|
||||
const { unmount } = renderCard({ link: { syncEnabled: false } })
|
||||
expect(screen.getByText('Paused')).toBeInTheDocument()
|
||||
|
||||
@@ -9,7 +9,7 @@ import { useTranslation } from '../../../i18n/TranslationContext'
|
||||
import { DOCUMENT_PROVIDER_ICONS } from '../../shared/DocumentProviderIcons'
|
||||
import DocSyncFlow, { type SyncDirection } from './DocSyncFlow'
|
||||
import { Badge, CONFLICT_POLICIES, conflictPolicyKey, LastRun, StateBadge } from './DocSyncBits'
|
||||
import type { DocSyncLink, useDocSync } from './useDocSync'
|
||||
import { bindingNotice, type DocSyncLink, type useDocSync } from './useDocSync'
|
||||
|
||||
/**
|
||||
* One binding: where this trip's documents live, which way they move, and what
|
||||
@@ -36,7 +36,7 @@ export default function DocSyncBinding({
|
||||
const [copied, setCopied] = useState(false)
|
||||
const Icon = DOCUMENT_PROVIDER_ICONS[link.providerId]
|
||||
const busy = sync.busy === `sync-${link.id}`
|
||||
const needsAttention = link.lastSyncState !== 'ok' && link.lastSyncState !== 'never'
|
||||
const notice = bindingNotice(link, t)
|
||||
|
||||
const copyWebhook = async () => {
|
||||
if (!link.webhookUrl) return
|
||||
@@ -112,13 +112,10 @@ export default function DocSyncBinding({
|
||||
</span>
|
||||
</header>
|
||||
|
||||
{needsAttention && (
|
||||
{notice && (
|
||||
<p className="mx-4 mb-3 flex items-start gap-2 rounded-lg bg-warning-soft px-3 py-2 text-caption text-warning">
|
||||
<AlertTriangle size={13} className="mt-px shrink-0" />
|
||||
<span>
|
||||
{t(`docsync.linkState.${link.lastSyncState}`)}
|
||||
{link.lastSyncError ? ` · ${t(`docsync.error.${link.lastSyncError}`)}` : ''}
|
||||
</span>
|
||||
<span>{notice}</span>
|
||||
</p>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-DOCSYNC-HOOK-001 to FE-DOCSYNC-HOOK-024
|
||||
// FE-DOCSYNC-HOOK-001 to FE-DOCSYNC-HOOK-026
|
||||
|
||||
/**
|
||||
* The document-sync hook.
|
||||
@@ -147,6 +147,16 @@ describe('useDocSync initial load', () => {
|
||||
expect(result.current.links[1].holdings).toBeUndefined()
|
||||
})
|
||||
|
||||
it('FE-DOCSYNC-HOOK-025: carries the provider switch from the status route onto its binding', async () => {
|
||||
listLinksApi.mockResolvedValue([link(), link({ id: 2 }), link({ id: 3 })])
|
||||
statusApi.mockResolvedValue({ items: {}, links: [{ id: 1, providerOff: true }, { id: 2, providerOff: false }] })
|
||||
|
||||
const { result } = await mountLoaded()
|
||||
|
||||
// The third is one the status route never mentions: running, not paused.
|
||||
expect(result.current.links.map(l => l.providerOff)).toEqual([true, false, false])
|
||||
})
|
||||
|
||||
it('FE-DOCSYNC-HOOK-003: a status body without an items map leaves the counts empty', async () => {
|
||||
statusApi.mockResolvedValue({})
|
||||
|
||||
@@ -372,6 +382,17 @@ describe('useDocSync run and save feedback', () => {
|
||||
expect(result.current.error).toBe('link_orphaned')
|
||||
})
|
||||
|
||||
it('FE-DOCSYNC-HOOK-026: a run refused because an admin switched the provider off keeps the code', async () => {
|
||||
// Both shells render the error as docsync.error.<code>, so the code has to
|
||||
// arrive untouched for the sentence to be the right one.
|
||||
syncNowApi.mockRejectedValueOnce({ response: { status: 409, data: { error: 'provider_disabled' } } })
|
||||
const { result } = await mountLoaded()
|
||||
|
||||
await act(async () => { await result.current.syncNow(1) })
|
||||
|
||||
expect(result.current.error).toBe('provider_disabled')
|
||||
})
|
||||
|
||||
it('FE-DOCSYNC-HOOK-018: a refused save reports failure and surfaces the server reason', async () => {
|
||||
saveConnectionApi.mockRejectedValueOnce({ response: { data: { message: 'docsync.badCredentials' } } })
|
||||
const { result } = await mountLoaded()
|
||||
|
||||
@@ -72,6 +72,12 @@ export interface DocSyncLink {
|
||||
providerName?: string
|
||||
/** Standing counts per side, from the status route. */
|
||||
holdings?: { inTrek: number; atProvider: number; paired: number; missing: number }
|
||||
/**
|
||||
* An admin has switched this binding's provider off. The server leaves the
|
||||
* binding as it was, so `lastSyncState` still describes the last run and
|
||||
* this is what says why nothing moves now. From the status route.
|
||||
*/
|
||||
providerOff?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -86,6 +92,25 @@ export function storeName(link: DocSyncLink, providers: readonly DocSyncProvider
|
||||
return providers.find(p => p.id === link.providerId)?.name || link.providerName || link.providerId
|
||||
}
|
||||
|
||||
/**
|
||||
* The line a binding card shows under its header, or null when there is
|
||||
* nothing to say.
|
||||
*
|
||||
* Both shells render it, so a phone and a laptop cannot disagree about why a
|
||||
* binding stands still. A provider an admin switched off comes first: the
|
||||
* server leaves the binding's state as the last run wrote it, which would
|
||||
* otherwise report an old failure, or nothing, for a binding that is paused.
|
||||
*/
|
||||
export function bindingNotice(
|
||||
link: Pick<DocSyncLink, 'providerOff' | 'lastSyncState' | 'lastSyncError'>,
|
||||
t: (key: string) => string,
|
||||
): string | null {
|
||||
if (link.providerOff) return t('docsync.error.provider_disabled')
|
||||
if (link.lastSyncState === 'ok' || link.lastSyncState === 'never') return null
|
||||
const state = t(`docsync.linkState.${link.lastSyncState}`)
|
||||
return link.lastSyncError ? `${state} · ${t(`docsync.error.${link.lastSyncError}`)}` : state
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this person may change a sync binding.
|
||||
*
|
||||
@@ -149,11 +174,16 @@ export function useDocSync(tripId: number | string, enabled: boolean) {
|
||||
if (mine !== generation.current) return
|
||||
setProviders(p as DocSyncProvider[])
|
||||
setConnections(c as DocSyncConnection[])
|
||||
// The status route carries the holdings; the links route does not, so the
|
||||
// two are merged here rather than asking every caller to join them.
|
||||
// The status route carries the holdings and the provider switch; the
|
||||
// links route does not, so the two are merged here rather than asking
|
||||
// every caller to join them.
|
||||
const status = s as { items?: Record<string, number>; links?: DocSyncLink[] }
|
||||
const holdingsById = new Map((status.links ?? []).map(x => [x.id, x.holdings]))
|
||||
setLinks((l as DocSyncLink[]).map(x => ({ ...x, holdings: holdingsById.get(x.id) })))
|
||||
const statusById = new Map((status.links ?? []).map(x => [x.id, x]))
|
||||
setLinks((l as DocSyncLink[]).map(x => ({
|
||||
...x,
|
||||
holdings: statusById.get(x.id)?.holdings,
|
||||
providerOff: statusById.get(x.id)?.providerOff === true,
|
||||
})))
|
||||
setItemCounts(status.items || {})
|
||||
// Only a load failure is cleared here. A write that just failed reloads to
|
||||
// put the server's answer back on screen, and clearing unconditionally
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-DOCSYNC-MOBILE-001 to FE-DOCSYNC-MOBILE-017
|
||||
// FE-DOCSYNC-MOBILE-001 to FE-DOCSYNC-MOBILE-018
|
||||
import type { ComponentProps } from 'react'
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { render, screen, fireEvent, waitFor, within } from '../../../../../tests/helpers/render'
|
||||
@@ -340,6 +340,25 @@ describe('MDocSyncSheet: what the owner may do', () => {
|
||||
expect(screen.queryByRole('button', { name: 'Sync now' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-DOCSYNC-MOBILE-018: a binding whose provider an admin switched off says so, and a refused run leaves it at that', async () => {
|
||||
let off = false
|
||||
vi.spyOn(docsyncApi, 'status').mockImplementation(async () => ({
|
||||
items: {},
|
||||
links: links.map(l => ({ id: l.id, providerOff: off, holdings: { inTrek: 4, atProvider: 2, paired: 2, missing: 0 } })),
|
||||
}))
|
||||
vi.spyOn(docsyncApi, 'syncNow').mockImplementation(async () => {
|
||||
off = true
|
||||
throw { response: { status: 409, data: { error: 'provider_disabled' } } }
|
||||
})
|
||||
renderSheet()
|
||||
await openDetail()
|
||||
expect(screen.queryByText('Paused: an administrator has switched this provider off. Syncing resumes once it is back on.')).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Sync now' }))
|
||||
|
||||
expect(await screen.findByText('Paused: an administrator has switched this provider off. Syncing resumes once it is back on.')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-DOCSYNC-MOBILE-014: Sync now runs that binding', async () => {
|
||||
renderSheet()
|
||||
await openDetail()
|
||||
|
||||
@@ -12,7 +12,7 @@ import { useTranslation } from '../../../../i18n'
|
||||
import { DOCUMENT_PROVIDER_ICONS } from '../../../../components/shared/DocumentProviderIcons'
|
||||
import TrekIcon from '../../../../components/shared/TrekIcon'
|
||||
import {
|
||||
storeName, useDocSync, type DocSyncLink, type DocSyncProvider,
|
||||
bindingNotice, storeName, useDocSync, type DocSyncLink, type DocSyncProvider,
|
||||
} from '../../../../components/Files/docsync/useDocSync'
|
||||
import { useConnectForm } from '../../../../components/Files/docsync/useConnectForm'
|
||||
import { conflictPolicyKey, nextConflictPolicy } from '../../../../components/Files/docsync/DocSyncBits'
|
||||
@@ -258,6 +258,7 @@ function DetailView({
|
||||
}
|
||||
|
||||
const ranAt = link.lastSyncAt ? Date.parse(link.lastSyncAt) : NaN
|
||||
const notice = bindingNotice(link, t)
|
||||
|
||||
return (
|
||||
<div className="mt-3 flex flex-col gap-3">
|
||||
@@ -298,10 +299,9 @@ function DetailView({
|
||||
value={Number.isNaN(ranAt) ? t('docsync.binding.neverRun') : relativeTime(ranAt, language)}
|
||||
/>
|
||||
|
||||
{link.lastSyncState !== 'ok' && link.lastSyncState !== 'never' && (
|
||||
{notice && (
|
||||
<p className="rounded-2xl bg-[color:var(--m-st-warn-bg,var(--m-ic))] px-3 py-2 font-geist text-[0.6875rem] text-[color:var(--m-st-warn,var(--m-muted))]">
|
||||
{t(`docsync.linkState.${link.lastSyncState}`)}
|
||||
{link.lastSyncError ? ` · ${t(`docsync.error.${link.lastSyncError}`)}` : ''}
|
||||
{notice}
|
||||
</p>
|
||||
)}
|
||||
|
||||
|
||||
@@ -2,11 +2,9 @@ import { Controller, HttpCode, OnModuleDestroy, Param, Post, Req } from '@nestjs
|
||||
import type { Request } from 'express';
|
||||
import crypto from 'crypto';
|
||||
import { Public } from '../auth/public.decorator';
|
||||
import { ADDON_IDS } from '../../addons';
|
||||
import { AddonsService } from '../addons/addons.service';
|
||||
import { DatabaseService } from '../database/database.service';
|
||||
import { SETTING_SYNC_ENABLED, WEBHOOK_NUDGE_DEBOUNCE_SECONDS } from './doc-sync.constants';
|
||||
import { DocSyncConfigService } from './doc-sync-config.service';
|
||||
import { DocSyncConfigService, type LinkRow } from './doc-sync-config.service';
|
||||
import { DocSyncService } from './doc-sync.service';
|
||||
|
||||
/**
|
||||
@@ -42,12 +40,12 @@ export class DocSyncWebhookController implements OnModuleDestroy {
|
||||
constructor(
|
||||
private readonly config: DocSyncConfigService,
|
||||
private readonly sync: DocSyncService,
|
||||
private readonly addons: AddonsService,
|
||||
private readonly db: DatabaseService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* The same two switches the scheduler obeys.
|
||||
* The same switches the scheduler obeys: the Documents addon, the binding's
|
||||
* provider, and the instance-wide kill switch.
|
||||
*
|
||||
* Without them "switch document sync off" meant "stop the poll", while every
|
||||
* provider holding a webhook kept driving full runs: an admin turning the
|
||||
@@ -55,8 +53,8 @@ export class DocSyncWebhookController implements OnModuleDestroy {
|
||||
* well as on arrival, so a switch thrown during the debounce window still
|
||||
* takes effect.
|
||||
*/
|
||||
private syncIsOn(): boolean {
|
||||
if (!this.addons.isAddonEnabled(ADDON_IDS.DOCUMENTS)) return false;
|
||||
private syncIsOn(link: LinkRow): boolean {
|
||||
if (this.sync.isSwitchedOff(link)) return false;
|
||||
const killSwitch = this.db.get<{ value: string }>(
|
||||
'SELECT value FROM app_settings WHERE key = ?', SETTING_SYNC_ENABLED,
|
||||
)?.value;
|
||||
@@ -78,7 +76,7 @@ export class DocSyncWebhookController implements OnModuleDestroy {
|
||||
// Always 200, even for an unknown token: a 404 here would let anyone probe
|
||||
// which tokens exist, and a provider that gets an error will retry anyway.
|
||||
if (!link || link.sync_enabled !== 1) return { received: true };
|
||||
if (!this.syncIsOn()) return { received: true };
|
||||
if (!this.syncIsOn(link)) return { received: true };
|
||||
|
||||
const secret = this.config.webhookSecret(link);
|
||||
if (secret && !this.secretMatches(req, secret)) return { received: true };
|
||||
@@ -106,7 +104,7 @@ export class DocSyncWebhookController implements OnModuleDestroy {
|
||||
this.pending.delete(linkId);
|
||||
const fresh = reload();
|
||||
if (!fresh || fresh.sync_enabled !== 1) return;
|
||||
if (!this.syncIsOn()) return;
|
||||
if (!this.syncIsOn(fresh)) return;
|
||||
void this.sync.syncLink(fresh).then((res) => {
|
||||
// A run that was already in flight answers `busy`, and the changes this
|
||||
// nudge was about may have landed after that run read the folder. Ask
|
||||
|
||||
@@ -74,6 +74,16 @@ export const WEBHOOK_NUDGE_DEBOUNCE_SECONDS = 5;
|
||||
/** Documents pulled or pushed per run, so one huge trip cannot starve others. */
|
||||
export const MAX_TRANSFERS_PER_RUN = 25;
|
||||
|
||||
/**
|
||||
* What a manual run answers for a binding an admin has switched off, through
|
||||
* its provider or through the Documents addon.
|
||||
*
|
||||
* Not one of the shared DocsyncErrorCodes: those say what went wrong between
|
||||
* TREK and a provider, and here nothing has. It is never written to the binding
|
||||
* either, which is what lets the binding resume as it was left.
|
||||
*/
|
||||
export const PROVIDER_DISABLED = 'provider_disabled';
|
||||
|
||||
/** app_settings keys, read per tick so an admin toggle needs no restart. */
|
||||
export const SETTING_POLL_INTERVAL = 'docsync_poll_interval_seconds';
|
||||
export const SETTING_SYNC_ENABLED = 'docsync_sync_enabled';
|
||||
|
||||
@@ -27,6 +27,7 @@ import { docFailed } from './document-provider';
|
||||
import { DocSyncConfigService, type LinkRow } from './doc-sync-config.service';
|
||||
import { DocSyncService } from './doc-sync.service';
|
||||
import { DocumentProviderRegistry } from './document-provider.registry';
|
||||
import { PROVIDER_DISABLED } from './doc-sync.constants';
|
||||
import {
|
||||
DocsyncConnectionDto,
|
||||
DocsyncConnectionTestDto,
|
||||
@@ -359,6 +360,12 @@ export class DocSyncController {
|
||||
if (link.last_sync_state === 'orphaned') {
|
||||
throw new HttpException({ error: 'This binding lost its owner and has to be reconnected' }, 409);
|
||||
}
|
||||
// Refused before the shelved rows are touched, so a binding an admin
|
||||
// switched off stays exactly as it was and resumes where it stopped. A code
|
||||
// rather than a sentence: the client says it in the reader's language.
|
||||
if (this.sync.isSwitchedOff(link)) {
|
||||
throw new HttpException({ error: PROVIDER_DISABLED }, 409);
|
||||
}
|
||||
// A person asking for a run is also asking for the rows that gave up to be
|
||||
// tried once more; the scheduler gets no such reprieve.
|
||||
this.sync.retryShelvedItems(link.id);
|
||||
|
||||
@@ -15,6 +15,7 @@ import { AddonsService } from '../addons/addons.service';
|
||||
import { FilesService } from '../files/files.service';
|
||||
import { DocSyncConfigService } from './doc-sync-config.service';
|
||||
import { DocSyncService } from './doc-sync.service';
|
||||
import { PROVIDER_DISABLED } from './doc-sync.constants';
|
||||
|
||||
const documentsAddonOn = addonGate(ADDON_IDS.DOCUMENTS);
|
||||
|
||||
@@ -97,6 +98,13 @@ export class DocSyncMcp {
|
||||
}
|
||||
const results = [];
|
||||
for (const link of links) {
|
||||
// Refused as the REST route refuses it, before the shelved rows below are
|
||||
// touched: a binding an admin switched off stays exactly as it was, so it
|
||||
// resumes where it stopped once the provider is back on.
|
||||
if (this.sync.isSwitchedOff(link)) {
|
||||
results.push({ linkId: link.id, provider: link.provider_id, state: 'disabled', errorCode: PROVIDER_DISABLED });
|
||||
continue;
|
||||
}
|
||||
// Asking for a run by hand means "try again", including the documents
|
||||
// that were shelved after too many failures. The REST route does the
|
||||
// same thing before its run; a tool that skipped it would answer "in
|
||||
@@ -104,6 +112,11 @@ export class DocSyncMcp {
|
||||
this.sync.retryShelvedItems(link.id);
|
||||
results.push({ linkId: link.id, provider: link.provider_id, ...(await this.sync.syncLink(link, { full: full === true })) });
|
||||
}
|
||||
if (results.every((r) => r.errorCode === PROVIDER_DISABLED)) {
|
||||
return errorResult(
|
||||
`${PROVIDER_DISABLED}: an administrator has switched off the document store this trip is bound to, so nothing was synced. The binding and its documents are kept, and syncing resumes once the store is switched back on.`,
|
||||
);
|
||||
}
|
||||
return ok({ runs: results });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import { Readable } from 'node:stream';
|
||||
import type { DocsyncErrorCode } from '@trek/shared';
|
||||
import { DatabaseService } from '../database/database.service';
|
||||
import { AddonsService } from '../addons/addons.service';
|
||||
import type { AddonId } from '../../addons';
|
||||
import { ADDON_IDS } from '../../addons';
|
||||
import { StorageService } from '../storage/storage.service';
|
||||
import { FilesService } from '../files/files.service';
|
||||
import { AllowedFileTypesService } from '../files/allowed-file-types.service';
|
||||
@@ -102,6 +102,10 @@ export class DocSyncService {
|
||||
* was never synced automatically at all. Found on a dev database with 170
|
||||
* bindings, where a freshly created one was still untouched minutes later
|
||||
* while the first twenty ran again and again.
|
||||
*
|
||||
* A binding whose provider an admin switched off is left out here rather
|
||||
* than stood down per run. It never runs, so it never gets a `last_sync_at`,
|
||||
* and twenty of them would sort first on every tick and take every slot.
|
||||
*/
|
||||
dueLinks(limit = 20): LinkRow[] {
|
||||
return this.db.connection
|
||||
@@ -110,6 +114,7 @@ export class DocSyncService {
|
||||
WHERE sync_enabled = 1
|
||||
AND failure_count < ?
|
||||
AND (next_attempt_at IS NULL OR next_attempt_at <= CURRENT_TIMESTAMP)
|
||||
AND provider_id IN (SELECT id FROM document_providers WHERE enabled = 1)
|
||||
ORDER BY COALESCE(next_attempt_at, '1970-01-01') ASC,
|
||||
COALESCE(last_sync_at, '1970-01-01') ASC,
|
||||
id ASC
|
||||
@@ -118,6 +123,20 @@ export class DocSyncService {
|
||||
.all(LINK_CIRCUIT_OPEN_AFTER, limit) as LinkRow[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an admin has closed the door on this binding: the Documents addon
|
||||
* is off, or the provider it runs against is.
|
||||
*
|
||||
* A kill switch, not a failure. Nothing about the binding is written while it
|
||||
* is off, not its state and not its documents' attempt counters, so it
|
||||
* resumes exactly where it stopped once the switch is back on. Public because
|
||||
* a manual run has to refuse before it un-shelves anything.
|
||||
*/
|
||||
isSwitchedOff(link: Pick<LinkRow, 'provider_id'>): boolean {
|
||||
if (!this.addons.isAddonEnabled(ADDON_IDS.DOCUMENTS)) return true;
|
||||
return !this.config.enabledProviderIds().includes(link.provider_id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Run one link.
|
||||
*
|
||||
@@ -151,6 +170,12 @@ export class DocSyncService {
|
||||
private async runLink(link: LinkRow, opts: { full?: boolean }): Promise<{
|
||||
state: string; pulled: number; pushed: number; conflicts: number; missing: number; errorCode?: DocsyncErrorCode;
|
||||
}> {
|
||||
// First, before anything that records a failure: a binding whose provider
|
||||
// is switched off must come back as it was left. The webhook and a first
|
||||
// run after binding reach this without asking beforehand.
|
||||
if (this.isSwitchedOff(link)) {
|
||||
return { state: 'disabled', pulled: 0, pushed: 0, conflicts: 0, missing: 0 };
|
||||
}
|
||||
const conn = this.config.getConnection(link.connection_id);
|
||||
if (!conn) {
|
||||
this.recordLinkFailure(link, 'not_found');
|
||||
@@ -161,13 +186,6 @@ export class DocSyncService {
|
||||
this.recordLinkFailure(link, 'provider_error');
|
||||
return { state: 'failed', pulled: 0, pushed: 0, conflicts: 0, missing: 0, errorCode: 'provider_error' };
|
||||
}
|
||||
// Switching a provider off in the admin panel stopped new connections and
|
||||
// left every existing binding running, which is not what "off" means to the
|
||||
// person who switched it. Not recorded as a failure: nothing is wrong with
|
||||
// the binding, an admin has closed the door.
|
||||
if (!this.addons.isAddonEnabled(link.provider_id as AddonId)) {
|
||||
return { state: 'disabled', pulled: 0, pushed: 0, conflicts: 0, missing: 0 };
|
||||
}
|
||||
|
||||
const ref = this.config.toRef(conn);
|
||||
const scope = this.config.toScopeRef(link);
|
||||
@@ -747,11 +765,6 @@ export class DocSyncService {
|
||||
trekDocUid?: string;
|
||||
},
|
||||
): void {
|
||||
const remoteId = patch.remoteIdOverride ?? patch.remote?.remoteId ?? null;
|
||||
const remoteVersion = patch.remoteVersion ?? patch.remote?.remoteVersion ?? null;
|
||||
const remoteName = patch.remoteNameOverride ?? patch.remote?.name ?? null;
|
||||
const remoteSize = patch.remoteSize ?? patch.remote?.size ?? null;
|
||||
const remoteModifiedAt = patch.remoteModifiedAt ?? patch.remote?.remoteModifiedAt ?? null;
|
||||
const failed = patch.state === 'error';
|
||||
|
||||
if (patch.itemId !== null) {
|
||||
@@ -771,6 +784,26 @@ export class DocSyncService {
|
||||
.prepare('SELECT attempts FROM document_sync_items WHERE id = ?')
|
||||
.get(patch.itemId) as { attempts: number } | undefined)?.attempts ?? 0;
|
||||
const attempts = failed ? before + 1 : 0;
|
||||
/**
|
||||
* A failed transfer leaves the pairing describing the copy TREK holds.
|
||||
*
|
||||
* The listing entry is the copy that did not arrive. Its version marker
|
||||
* written next to the old bytes meant the next run saw no upstream change,
|
||||
* planned `touch`, which leaves `error` alone, and the row stayed in error
|
||||
* on stale bytes for good, "Sync now" included. Size and time go with it:
|
||||
* where the id is the path they are how a moved copy is recognised as the
|
||||
* same bytes, and a match there adopts the listing's version as well.
|
||||
*
|
||||
* A new row keeps the listing's name, size and time, because they are what
|
||||
* pairs it and follows it to be fetched again, but no version: TREK holds
|
||||
* none yet, and the planner retries it for having no file.
|
||||
*/
|
||||
const described = failed && patch.itemId !== null ? undefined : patch.remote;
|
||||
const remoteId = patch.remoteIdOverride ?? patch.remote?.remoteId ?? null;
|
||||
const remoteVersion = patch.remoteVersion ?? (failed ? null : patch.remote?.remoteVersion) ?? null;
|
||||
const remoteName = patch.remoteNameOverride ?? described?.name ?? null;
|
||||
const remoteSize = patch.remoteSize ?? described?.size ?? null;
|
||||
const remoteModifiedAt = patch.remoteModifiedAt ?? described?.remoteModifiedAt ?? null;
|
||||
|
||||
this.db.connection
|
||||
.prepare(
|
||||
@@ -1050,3 +1083,6 @@ export class DocSyncService {
|
||||
function isTransfer(action: PlanAction): boolean {
|
||||
return action.kind === 'pull' || action.kind === 'pull_update' || action.kind === 'push' || action.kind === 'push_update';
|
||||
}
|
||||
// Paused rather than failed, so the card can say why nothing moves
|
||||
// without the binding's own state being touched.
|
||||
providerOff: this.isSwitchedOff(l),
|
||||
|
||||
@@ -16,7 +16,6 @@ vi.mock('../../../../src/db/database', () => ({
|
||||
import { DocSyncWebhookController } from '../../../../src/nest/doc-sync/doc-sync-webhook.controller';
|
||||
import type { DocSyncConfigService, LinkRow } from '../../../../src/nest/doc-sync/doc-sync-config.service';
|
||||
import type { DocSyncService } from '../../../../src/nest/doc-sync/doc-sync.service';
|
||||
import type { AddonsService } from '../../../../src/nest/addons/addons.service';
|
||||
import type { DatabaseService } from '../../../../src/nest/database/database.service';
|
||||
|
||||
/**
|
||||
@@ -72,7 +71,6 @@ const config = {
|
||||
};
|
||||
|
||||
const settings = new Map<string, string>();
|
||||
const addons = { isAddonEnabled: vi.fn(() => true) };
|
||||
const db = {
|
||||
get: vi.fn((_sql: string, key?: unknown) => {
|
||||
const value = settings.get(String(key));
|
||||
@@ -82,12 +80,14 @@ const db = {
|
||||
|
||||
const sync = {
|
||||
syncLink: vi.fn(async (_link: LinkRow) => ({ state: 'ok', pulled: 0, pushed: 0, conflicts: 0, missing: 0 })),
|
||||
// The Documents addon and the binding's provider, as one answer. What goes
|
||||
// into it is the service's business and tested there.
|
||||
isSwitchedOff: vi.fn((_link: LinkRow) => false),
|
||||
};
|
||||
|
||||
const controller = new DocSyncWebhookController(
|
||||
config as unknown as DocSyncConfigService,
|
||||
sync as unknown as DocSyncService,
|
||||
addons as unknown as AddonsService,
|
||||
db as unknown as DatabaseService,
|
||||
);
|
||||
|
||||
@@ -119,7 +119,7 @@ beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.useFakeTimers();
|
||||
settings.clear();
|
||||
addons.isAddonEnabled.mockReturnValue(true);
|
||||
sync.isSwitchedOff.mockReturnValue(false);
|
||||
config.getLinkByToken.mockImplementation((token: string) => (token === 'tok-live' ? link() : undefined));
|
||||
config.getLink.mockImplementation((id: number) => (id === 4 ? link() : undefined));
|
||||
config.webhookSecret.mockReturnValue(SECRET);
|
||||
@@ -339,15 +339,17 @@ describe('a burst of nudges', () => {
|
||||
/**
|
||||
* The switches an admin expects to mean "off".
|
||||
*
|
||||
* The scheduler obeys both the Documents addon and the app_settings kill
|
||||
* switch; the webhook obeyed neither, so switching document sync off stopped
|
||||
* the poll while every provider holding a webhook carried on driving full runs.
|
||||
* The scheduler obeys the Documents addon, the binding's provider and the
|
||||
* app_settings kill switch; the webhook obeyed none of them, so switching
|
||||
* document sync off stopped the poll while every provider holding a webhook
|
||||
* carried on driving full runs.
|
||||
*/
|
||||
describe('the admin switches', () => {
|
||||
it('does nothing while the Documents addon is off', () => {
|
||||
addons.isAddonEnabled.mockReturnValue(false);
|
||||
it('does nothing while the addon or the provider of the binding is switched off', () => {
|
||||
sync.isSwitchedOff.mockReturnValue(true);
|
||||
expect(controller.nudge('tok-live', makeReq({ 'x-trek-docsync-secret': SECRET }))).toEqual({ received: true });
|
||||
settle();
|
||||
expect(sync.isSwitchedOff.mock.calls[0][0]).toMatchObject({ id: 4, provider_id: 'papra' });
|
||||
expect(sync.syncLink).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -358,9 +360,9 @@ describe('the admin switches', () => {
|
||||
expect(sync.syncLink).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('drops a scheduled run when the addon goes off inside the window', () => {
|
||||
it('drops a scheduled run when the provider goes off inside the window', () => {
|
||||
controller.nudge('tok-live', makeReq({ 'x-trek-docsync-secret': SECRET }));
|
||||
addons.isAddonEnabled.mockReturnValue(false);
|
||||
sync.isSwitchedOff.mockReturnValue(true);
|
||||
settle();
|
||||
expect(sync.syncLink).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -117,6 +117,7 @@ const nextcloud = fakeProvider('nextcloud');
|
||||
const sync = {
|
||||
syncLink: vi.fn(async (_link: LinkRow, _opts?: { full?: boolean }) => ({ state: 'ok', pulled: 0, pushed: 0, conflicts: 0, missing: 0 })),
|
||||
retryShelvedItems: vi.fn((_linkId: number) => {}),
|
||||
isSwitchedOff: vi.fn((_link: LinkRow) => false),
|
||||
status: vi.fn(() => ({ links: [], items: {} })),
|
||||
resolveConflict: vi.fn(async () => true),
|
||||
};
|
||||
@@ -649,6 +650,24 @@ describe('a manual run', () => {
|
||||
expect(err.getStatus()).toBe(404);
|
||||
expect(sync.syncLink).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('refuses a binding an admin switched off with a code, and touches nothing', async () => {
|
||||
// A code rather than a sentence so the client can say it in the reader's
|
||||
// language; the MCP tool answers with the same one. Refused before the
|
||||
// shelved rows are cleared, so the binding resumes exactly as it was.
|
||||
const conn = await storedPaperless();
|
||||
const created = (await controller.createLink(String(tripId), owner, linkBody(conn.id), makeReq())) as { id: number };
|
||||
sync.syncLink.mockClear();
|
||||
sync.isSwitchedOff.mockReturnValueOnce(true);
|
||||
|
||||
const err = await thrown(() => controller.syncNow(String(tripId), String(created.id), { full: false }));
|
||||
|
||||
expect(err.getStatus()).toBe(409);
|
||||
expect(err.getResponse()).toEqual({ error: 'provider_disabled' });
|
||||
expect(sync.isSwitchedOff.mock.calls[0][0]).toMatchObject({ id: created.id });
|
||||
expect(sync.retryShelvedItems).not.toHaveBeenCalled();
|
||||
expect(sync.syncLink).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('the document list', () => {
|
||||
|
||||
@@ -9,8 +9,12 @@
|
||||
* Dawarich one visible. The interval is typed by a human into app_settings, so
|
||||
* '5' and '999999' both have to land somewhere the cron parser accepts. And one
|
||||
* unreachable NAS must not take the other trips' bindings down with it.
|
||||
*
|
||||
* The last block is the exception: an admin's provider switch lives in the
|
||||
* service's query, so it runs the tick over the real service and a database.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll, afterEach } from 'vitest';
|
||||
import Database from 'better-sqlite3';
|
||||
|
||||
const log = vi.hoisted(() => ({
|
||||
LOG_LEVEL: 'error',
|
||||
@@ -24,11 +28,20 @@ vi.mock('../../../../src/nest/audit/audit-log.logger', () => log);
|
||||
import { ADDON_IDS } from '../../../../src/addons';
|
||||
import { DocSyncJob } from '../../../../src/nest/doc-sync/doc-sync.job';
|
||||
import { SETTING_POLL_INTERVAL, SETTING_SYNC_ENABLED } from '../../../../src/nest/doc-sync/doc-sync.constants';
|
||||
import type { DocSyncConfigService, LinkRow } from '../../../../src/nest/doc-sync/doc-sync-config.service';
|
||||
import type { DocSyncService } from '../../../../src/nest/doc-sync/doc-sync.service';
|
||||
import { DocSyncConfigService, type LinkRow } from '../../../../src/nest/doc-sync/doc-sync-config.service';
|
||||
import { DocSyncService } from '../../../../src/nest/doc-sync/doc-sync.service';
|
||||
import { DocumentProviderRegistry } from '../../../../src/nest/doc-sync/document-provider.registry';
|
||||
import type { DocumentProvider } from '../../../../src/nest/doc-sync/document-provider';
|
||||
import type { AddonsService } from '../../../../src/nest/addons/addons.service';
|
||||
import type { DatabaseService } from '../../../../src/nest/database/database.service';
|
||||
import { DatabaseService } from '../../../../src/nest/database/database.service';
|
||||
import type { CronRegistrarService } from '../../../../src/nest/scheduling/cron-registrar.service';
|
||||
import { AllowedFileTypesService } from '../../../../src/nest/files/allowed-file-types.service';
|
||||
import type { FilesService } from '../../../../src/nest/files/files.service';
|
||||
import type { StorageService } from '../../../../src/nest/storage/storage.service';
|
||||
import type { RealtimeService } from '../../../../src/nest/realtime/realtime.service';
|
||||
import { createTables } from '../../../../src/db/schema';
|
||||
import { runMigrations } from '../../../../src/db/migrations';
|
||||
import { createTrip, createUser } from '../../../helpers/factories';
|
||||
|
||||
const link = (id: number): LinkRow => ({ id, provider_id: 'paperless' } as LinkRow);
|
||||
|
||||
@@ -378,3 +391,102 @@ describe('DocSyncJob due-ness', () => {
|
||||
expect(sync.syncLink).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* A provider an admin switched off, over the real service and a real database.
|
||||
*
|
||||
* The job leaves what is due to `dueLinks` (see "leaves the choice of what is
|
||||
* due to the sync service" above), so the switch is a property of that query
|
||||
* and only shows with the query running. A stubbed `dueLinks` would restate
|
||||
* whatever this file told it.
|
||||
*/
|
||||
describe('DocSyncJob and a provider switched off in the admin panel', () => {
|
||||
const testDb = new Database(':memory:');
|
||||
const dbs = new DatabaseService(testDb);
|
||||
let paperlessLink: number;
|
||||
let nextcloudLink: number;
|
||||
|
||||
const fakeProvider = (id: string) => ({
|
||||
id,
|
||||
capabilities: () => ({
|
||||
push: 'none', stableId: true, remoteTrash: true, replaceInPlace: true, contentHashInListing: true,
|
||||
maxUploadBytes: null, acceptedMimeTypes: null, canCreateScope: true,
|
||||
}),
|
||||
resolveScope: vi.fn(async () => ({ success: true, data: { scopeKey: 'tag:1', label: 'Japan', remoteRootId: '1', remoteRootPath: null } })),
|
||||
list: vi.fn(async () => ({ success: true, data: { documents: [], cursor: null, cursorUnchanged: false, truncated: false } })),
|
||||
});
|
||||
const paperless = fakeProvider('paperless');
|
||||
const nextcloud = fakeProvider('nextcloud');
|
||||
|
||||
const addons = { isAddonEnabled: vi.fn(() => true) } as unknown as AddonsService;
|
||||
const registry = new DocumentProviderRegistry([paperless, nextcloud] as unknown as DocumentProvider[]);
|
||||
const config = new DocSyncConfigService(dbs, registry);
|
||||
const service = new DocSyncService(
|
||||
dbs, config, registry,
|
||||
{} as StorageService, {} as FilesService, new AllowedFileTypesService(dbs),
|
||||
{ broadcast: vi.fn() } as unknown as RealtimeService,
|
||||
addons,
|
||||
);
|
||||
const registrar = { isEnabled: () => false } as unknown as CronRegistrarService;
|
||||
/** A job of its own per pass, so the interval never decides whether a tick runs. */
|
||||
const tick = () => new DocSyncJob(dbs, service, config, addons, registrar).tick();
|
||||
|
||||
const switchProvider = (id: string, on: boolean) =>
|
||||
testDb.prepare('UPDATE document_providers SET enabled = ? WHERE id = ?').run(on ? 1 : 0, id);
|
||||
const linkRow = (id: number) => testDb.prepare('SELECT * FROM trip_document_links WHERE id = ?').get(id);
|
||||
|
||||
beforeAll(() => {
|
||||
createTables(testDb);
|
||||
runMigrations(testDb);
|
||||
const ownerId = createUser(testDb, { username: 'owner', email: 'owner@docsync-job.test' }).user.id;
|
||||
const tripId = createTrip(testDb, ownerId, { title: 'Japan' }).id;
|
||||
const bind = (providerId: string) => {
|
||||
const conn = testDb
|
||||
.prepare(
|
||||
`INSERT INTO document_connections (trip_id, provider_id, owner_user_id, base_url, secrets, settings)
|
||||
VALUES (?, ?, ?, 'https://docs.example.com', NULL, '{}')`,
|
||||
)
|
||||
.run(tripId, providerId, ownerId);
|
||||
return Number(testDb
|
||||
.prepare(
|
||||
`INSERT INTO trip_document_links
|
||||
(trip_id, connection_id, provider_id, remote_scope_key, remote_label, direction, delete_policy,
|
||||
conflict_policy, sync_enabled, created_by)
|
||||
VALUES (?, ?, ?, 'tag:1', 'Japan', 'both', 'unlink', 'manual', 1, ?)`,
|
||||
)
|
||||
.run(tripId, conn.lastInsertRowid, providerId, ownerId).lastInsertRowid);
|
||||
};
|
||||
paperlessLink = bind('paperless');
|
||||
nextcloudLink = bind('nextcloud');
|
||||
});
|
||||
|
||||
afterAll(() => testDb.close());
|
||||
|
||||
beforeEach(() => {
|
||||
switchProvider('paperless', false);
|
||||
switchProvider('nextcloud', true);
|
||||
});
|
||||
|
||||
it('runs the bindings that may run and leaves the switched-off one exactly as it was', async () => {
|
||||
const before = linkRow(paperlessLink);
|
||||
|
||||
await tick();
|
||||
|
||||
expect(nextcloud.list).toHaveBeenCalledTimes(1);
|
||||
expect(paperless.resolveScope).not.toHaveBeenCalled();
|
||||
expect(paperless.list).not.toHaveBeenCalled();
|
||||
expect(linkRow(paperlessLink)).toEqual(before);
|
||||
expect(linkRow(nextcloudLink)).toMatchObject({ last_sync_state: 'ok' });
|
||||
});
|
||||
|
||||
it('picks the binding up again on the next tick once the provider is back on', async () => {
|
||||
await tick();
|
||||
expect(paperless.list).not.toHaveBeenCalled();
|
||||
|
||||
switchProvider('paperless', true);
|
||||
await tick();
|
||||
|
||||
expect(paperless.list).toHaveBeenCalledTimes(1);
|
||||
expect(linkRow(paperlessLink)).toMatchObject({ last_sync_state: 'ok', failure_count: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -39,10 +39,12 @@ interface Setup {
|
||||
status: Record<string, unknown>;
|
||||
issues: Array<Record<string, unknown>>;
|
||||
addonOn: boolean;
|
||||
/** Bindings whose provider an admin has switched off. */
|
||||
off: number[];
|
||||
}
|
||||
|
||||
function makeMcp(over: Partial<Setup> = {}) {
|
||||
const setup: Setup = { access: true, links: [], status: {}, issues: [], addonOn: true, ...over };
|
||||
const setup: Setup = { access: true, links: [], status: {}, issues: [], addonOn: true, off: [], ...over };
|
||||
const files = { verifyTripAccess: vi.fn(() => (setup.access ? { id: 3, user_id: 7 } : undefined)) };
|
||||
const config = { listLinks: vi.fn(() => setup.links) };
|
||||
const sync = {
|
||||
@@ -50,6 +52,7 @@ function makeMcp(over: Partial<Setup> = {}) {
|
||||
issues: vi.fn(() => setup.issues),
|
||||
syncLink: vi.fn(async () => RUN),
|
||||
retryShelvedItems: vi.fn(),
|
||||
isSwitchedOff: vi.fn((l: LinkRow) => setup.off.includes(l.id)),
|
||||
};
|
||||
const addons = { isAddonEnabled: vi.fn(() => setup.addonOn) };
|
||||
const mcp = new DocSyncMcp(
|
||||
@@ -231,6 +234,28 @@ describe('sync_trip_documents', () => {
|
||||
expect(sync.retryShelvedItems.mock.calls).toEqual([[1], [2]]);
|
||||
});
|
||||
|
||||
it('refuses with the same code as the REST route when every binding is switched off', async () => {
|
||||
const { mcp, sync } = makeMcp({ links: [link(1)], off: [1] });
|
||||
const text = refusal(await mcp.syncNow({ tripId: 3 }, ctx));
|
||||
expect(text.startsWith('provider_disabled:')).toBe(true);
|
||||
expect(text).toContain('switched back on');
|
||||
// Nothing un-shelved, nothing run: the binding resumes as it was left.
|
||||
expect(sync.retryShelvedItems).not.toHaveBeenCalled();
|
||||
expect(sync.syncLink).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('runs the bindings that are on and reports the switched-off one by its code', async () => {
|
||||
const links = [link(1, 'paperless'), link(2, 'nextcloud')];
|
||||
const { mcp, sync } = makeMcp({ links, off: [1] });
|
||||
const body = payload(await mcp.syncNow({ tripId: 3 }, ctx));
|
||||
expect(body.runs).toEqual([
|
||||
{ linkId: 1, provider: 'paperless', state: 'disabled', errorCode: 'provider_disabled' },
|
||||
{ linkId: 2, provider: 'nextcloud', ...RUN },
|
||||
]);
|
||||
expect(sync.retryShelvedItems.mock.calls).toEqual([[2]]);
|
||||
expect(sync.syncLink).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('asks for the cheap incremental run unless the caller says otherwise', async () => {
|
||||
const { mcp, sync } = makeMcp({ links: [link(1)] });
|
||||
await mcp.syncNow({ tripId: 3 }, ctx);
|
||||
|
||||
@@ -199,11 +199,12 @@ function makeLink(over: {
|
||||
.prepare(
|
||||
`INSERT INTO trip_document_links
|
||||
(trip_id, connection_id, provider_id, remote_scope_key, remote_root_id, remote_root_path, remote_label,
|
||||
providerId?: string;
|
||||
direction, delete_policy, conflict_policy, sync_enabled, failure_count, next_attempt_at, created_by)
|
||||
VALUES (?, ?, 'paperless', ?, '1', '/TREK/japan', 'Japan 2026', ?, ?, ?, ?, ?, ?, ?)`,
|
||||
VALUES (?, ?, ?, ?, '1', '/TREK/japan', 'Japan 2026', ?, ?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(
|
||||
tripId, connectionId, `tag:${scopeSeq}`,
|
||||
tripId, connectionId, over.providerId ?? 'paperless', `tag:${scopeSeq}`,
|
||||
over.direction ?? 'both', over.deletePolicy ?? 'unlink', over.conflictPolicy ?? 'manual',
|
||||
over.syncEnabled ?? 1, over.failureCount ?? 0, over.nextAttemptAt ?? null, ownerId,
|
||||
);
|
||||
@@ -280,8 +281,8 @@ const sqlTime = (modifier: string): string =>
|
||||
(testDb.prepare("SELECT datetime('now', ?) AS t").get(modifier) as { t: string }).t;
|
||||
|
||||
/**
|
||||
* The provider's own addon switch. Every binding asks it before it runs, so a
|
||||
* double that always says yes would hide the one case worth a test.
|
||||
* The Documents addon. Every binding asks it before it runs, so a double that
|
||||
* always says yes would hide the one case worth a test.
|
||||
*/
|
||||
const addons = { isAddonEnabled: vi.fn(() => true) };
|
||||
|
||||
@@ -291,6 +292,10 @@ describe('DocSyncService', () => {
|
||||
beforeAll(() => {
|
||||
createTables(testDb);
|
||||
runMigrations(testDb);
|
||||
/** The admin's per-provider switch, which is a real row rather than a double. */
|
||||
const switchProvider = (id: string, on: boolean) =>
|
||||
testDb.prepare('UPDATE document_providers SET enabled = ? WHERE id = ?').run(on ? 1 : 0, id);
|
||||
|
||||
spoolDir = fs.mkdtempSync(path.join(os.tmpdir(), 'trek-docsync-'));
|
||||
|
||||
ownerId = createUser(testDb, { username: 'owner', email: 'owner@docsync.test' }).user.id;
|
||||
@@ -337,6 +342,9 @@ describe('DocSyncService', () => {
|
||||
describe('dueLinks', () => {
|
||||
it('offers only bindings that are switched on, uncircuited and actually due', () => {
|
||||
const dueNow = makeLink();
|
||||
// Providers ship switched off; the fake one stands in for Paperless.
|
||||
testDb.prepare('UPDATE document_providers SET enabled = 0').run();
|
||||
switchProvider('paperless', true);
|
||||
const overdue = makeLink({ nextAttemptAt: sqlTime('-1 hour') });
|
||||
makeLink({ nextAttemptAt: sqlTime('+1 hour') });
|
||||
makeLink({ syncEnabled: 0 });
|
||||
@@ -598,21 +606,6 @@ describe('DocSyncService', () => {
|
||||
expect(blocked?.remote_id).toBeNull();
|
||||
});
|
||||
|
||||
it('stands down while the provider is switched off in the admin panel', async () => {
|
||||
// Switching a provider off stopped new connections and left every
|
||||
// existing binding running, which is not what "off" means to the person
|
||||
// who switched it. Not a failure either: nothing is wrong with the
|
||||
// binding, an admin has closed the door.
|
||||
const link = makeLink();
|
||||
addons.isAddonEnabled.mockReturnValueOnce(false);
|
||||
|
||||
const res = await service.syncLink(link);
|
||||
|
||||
expect(res.state).toBe('disabled');
|
||||
expect(provider.list).not.toHaveBeenCalled();
|
||||
expect(linkRow(link.id).failure_count).toBe(0);
|
||||
});
|
||||
|
||||
it('counts a failure and puts the binding off until later', async () => {
|
||||
const link = makeLink();
|
||||
provider.list.mockResolvedValueOnce(fail('unreachable'));
|
||||
@@ -853,14 +846,6 @@ describe('DocSyncService', () => {
|
||||
expect(service.issues(tripId)).toHaveLength(1);
|
||||
});
|
||||
|
||||
/**
|
||||
* Pins what happens today, and it is wrong. The failed pull already wrote
|
||||
* the provider's version marker into the pairing, so the next run sees no
|
||||
* change and emits `touch`, and `touch` promotes `error` to `synced`. The
|
||||
* document is then booked as synced with no `file_id`, out of the issues
|
||||
* list and never retried. Reported rather than fixed here; this is the test
|
||||
* that turns red once it is.
|
||||
*/
|
||||
it('keeps a failed download visible as an error instead of booking it as synced', async () => {
|
||||
// The failure path used to write the provider's version marker, so the
|
||||
// next run saw no change, produced a `touch`, and touch lifted `error` to
|
||||
@@ -965,6 +950,7 @@ describe('DocSyncService', () => {
|
||||
expect(res.state).toBe('failed');
|
||||
expect(res.errorCode).toBe('provider_error');
|
||||
expect(provider.resolveScope).not.toHaveBeenCalled();
|
||||
switchProvider('papra', true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -974,6 +960,113 @@ describe('DocSyncService', () => {
|
||||
seedItem(link, { remoteId: 'r1', state: 'synced' });
|
||||
seedItem(link, { remoteId: 'r2', state: 'synced' });
|
||||
seedItem(link, { remoteId: 'r3', state: 'conflict' });
|
||||
/**
|
||||
* An administrator switching a provider off, or the Documents addon.
|
||||
*
|
||||
* The check that was meant to do this asked the addon table about a provider
|
||||
* id. Providers are not addons, so it answered "off" for every binding on
|
||||
* every run, and the one test of it could not tell: it stubbed the answer.
|
||||
* The switch is a kill switch, not a failure: nothing about the binding is
|
||||
* written while it is off, so it resumes exactly where it stopped.
|
||||
*/
|
||||
describe('an administrator switch', () => {
|
||||
it('lets a binding run while its provider and the addon are on', async () => {
|
||||
const link = makeLink();
|
||||
|
||||
const res = await service.syncLink(link);
|
||||
|
||||
expect(res.state).toBe('ok');
|
||||
expect(provider.list).toHaveBeenCalledTimes(1);
|
||||
expect(addons.isAddonEnabled).toHaveBeenCalledWith('documents');
|
||||
});
|
||||
|
||||
it('leaves a binding whose provider is off out of the due list', () => {
|
||||
makeLink();
|
||||
const on = makeLink({ providerId: 'nextcloud' });
|
||||
switchProvider('paperless', false);
|
||||
switchProvider('nextcloud', true);
|
||||
|
||||
expect(service.dueLinks().map((l) => l.id)).toEqual([on.id]);
|
||||
});
|
||||
|
||||
it('does not let switched-off bindings take the slots of the ones that may run', () => {
|
||||
// They never run, so they never get a last_sync_at and would sort first
|
||||
// on every tick for good.
|
||||
for (let i = 0; i < 25; i += 1) makeLink({ lastSyncAt: null });
|
||||
const on = makeLink({ providerId: 'nextcloud', lastSyncAt: sqlTime('-1 minute') });
|
||||
switchProvider('paperless', false);
|
||||
switchProvider('nextcloud', true);
|
||||
|
||||
expect(service.dueLinks().map((l) => l.id)).toEqual([on.id]);
|
||||
});
|
||||
|
||||
it('stands down without touching the binding or its documents while the provider is off', async () => {
|
||||
const link = makeLink({ failureCount: 2 });
|
||||
testDb.prepare("UPDATE trip_document_links SET last_sync_state = 'partial', last_sync_error = 'timeout' WHERE id = ?")
|
||||
.run(link.id);
|
||||
const itemId = seedItem(link, { remoteId: 'r1', remoteVersion: 'v1', state: 'error' });
|
||||
testDb.prepare('UPDATE document_sync_items SET attempts = 6 WHERE id = ?').run(itemId);
|
||||
const linkBefore = linkRow(link.id);
|
||||
const itemBefore = itemRow(itemId);
|
||||
switchProvider('paperless', false);
|
||||
|
||||
const res = await service.syncLink(config.getLink(link.id));
|
||||
|
||||
expect(res.state).toBe('disabled');
|
||||
expect(provider.resolveScope).not.toHaveBeenCalled();
|
||||
expect(provider.list).not.toHaveBeenCalled();
|
||||
expect(linkRow(link.id)).toEqual(linkBefore);
|
||||
expect(itemRow(itemId)).toEqual(itemBefore);
|
||||
});
|
||||
|
||||
it('stands down the same way while the Documents addon is off', async () => {
|
||||
const link = makeLink();
|
||||
const before = linkRow(link.id);
|
||||
addons.isAddonEnabled.mockReturnValue(false);
|
||||
|
||||
const res = await service.syncLink(link);
|
||||
|
||||
expect(res.state).toBe('disabled');
|
||||
expect(provider.list).not.toHaveBeenCalled();
|
||||
expect(linkRow(link.id)).toEqual(before);
|
||||
});
|
||||
|
||||
it('stands down before it looks for the connection, so a gone one is not a failure either', async () => {
|
||||
const link = { ...makeLink(), connection_id: 999999 };
|
||||
switchProvider('paperless', false);
|
||||
|
||||
expect((await service.syncLink(link)).state).toBe('disabled');
|
||||
expect(linkRow(link.id).failure_count).toBe(0);
|
||||
});
|
||||
|
||||
it('picks up where it stopped once the provider is back on', async () => {
|
||||
const link = makeLink();
|
||||
provider.list.mockResolvedValue(ok(listing([remoteDoc()])));
|
||||
switchProvider('paperless', false);
|
||||
await service.syncLink(link);
|
||||
expect(fileRows()).toHaveLength(0);
|
||||
|
||||
switchProvider('paperless', true);
|
||||
expect(service.dueLinks().map((l) => l.id)).toEqual([link.id]);
|
||||
const res = await service.syncLink(config.getLink(link.id));
|
||||
|
||||
expect(res).toMatchObject({ state: 'ok', pulled: 1 });
|
||||
expect(fileRows()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('answers the same question for any caller that has to refuse up front', () => {
|
||||
const link = makeLink();
|
||||
expect(service.isSwitchedOff(link)).toBe(false);
|
||||
|
||||
switchProvider('paperless', false);
|
||||
expect(service.isSwitchedOff(link)).toBe(true);
|
||||
|
||||
switchProvider('paperless', true);
|
||||
addons.isAddonEnabled.mockReturnValue(false);
|
||||
expect(service.isSwitchedOff(link)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
const status = service.status(tripId);
|
||||
|
||||
@@ -986,6 +1079,18 @@ describe('DocSyncService', () => {
|
||||
/** A conflicted pairing: the provider moved on, and TREK holds its own copy. */
|
||||
function seedConflict(link: LinkRow): { itemId: number; fileId: number } {
|
||||
const fileId = makeFile({ name: 'boarding.pdf' });
|
||||
|
||||
it('says which bindings are paused because their provider is switched off', () => {
|
||||
const off = makeLink();
|
||||
const on = makeLink({ providerId: 'nextcloud' });
|
||||
switchProvider('paperless', false);
|
||||
switchProvider('nextcloud', true);
|
||||
|
||||
const links = service.status(tripId).links as Array<{ id: number; providerOff: boolean }>;
|
||||
|
||||
expect(links.find((l) => l.id === off.id)?.providerOff).toBe(true);
|
||||
expect(links.find((l) => l.id === on.id)?.providerOff).toBe(false);
|
||||
});
|
||||
const itemId = seedItem(link, {
|
||||
remoteId: 'r1',
|
||||
remoteName: 'boarding.pdf',
|
||||
@@ -1260,6 +1365,100 @@ describe('DocSyncService', () => {
|
||||
* The backoff was always computed at step 1, so three of the curve's four
|
||||
* steps were unreachable and a provider that was down got asked again at the
|
||||
* same short interval. And the counter only ever grew: a row that failed once
|
||||
/**
|
||||
* A download that fails leaves the pairing where it was.
|
||||
*
|
||||
* The failed update used to write the provider's new version marker next to
|
||||
* the old bytes. The run after that saw no upstream change, planned `touch`,
|
||||
* which leaves `error` alone, and the row sat in error on stale bytes for
|
||||
* good. "Sync now" did not help: it only clears the backoff, and the plan was
|
||||
* the same.
|
||||
*/
|
||||
describe('a download that fails', () => {
|
||||
const settleBackoff = () => testDb.prepare('UPDATE document_sync_items SET next_attempt_at = NULL').run();
|
||||
|
||||
function seedHeld(link: LinkRow): { itemId: number; fileId: number } {
|
||||
const fileId = makeFile({ name: 'invoice.pdf' });
|
||||
const itemId = seedItem(link, {
|
||||
remoteId: 'r1', remoteName: 'invoice.pdf', remoteVersion: 'v1', fileId, state: 'synced',
|
||||
contentSha256: 'held-hash', remoteSize: 512, remoteModifiedAt: '2026-09-01T08:00:00Z',
|
||||
});
|
||||
return { itemId, fileId };
|
||||
}
|
||||
|
||||
const edited = () => remoteDoc({
|
||||
remoteId: 'r1', name: 'invoice.pdf', remoteVersion: 'v2', size: 2048, remoteModifiedAt: '2026-09-18T10:00:00Z',
|
||||
});
|
||||
|
||||
it('keeps the version TREK holds on record, not the one it failed to get', async () => {
|
||||
const link = makeLink();
|
||||
const { itemId, fileId } = seedHeld(link);
|
||||
provider.list.mockResolvedValue(ok(listing([edited()])));
|
||||
provider.fetch.mockResolvedValueOnce(fail('timeout'));
|
||||
|
||||
await service.syncLink(link);
|
||||
|
||||
expect(itemRow(itemId)).toMatchObject({
|
||||
state: 'error', error_code: 'timeout', file_id: fileId, content_sha256: 'held-hash',
|
||||
remote_version: 'v1', remote_size: 512, remote_modified_at: '2026-09-01T08:00:00Z',
|
||||
});
|
||||
});
|
||||
|
||||
it('fetches the update again once the backoff is over and ends synced with the new bytes', async () => {
|
||||
const link = makeLink();
|
||||
const { itemId, fileId } = seedHeld(link);
|
||||
provider.list.mockResolvedValue(ok(listing([edited()])));
|
||||
provider.fetch.mockResolvedValueOnce(fail('timeout'));
|
||||
await service.syncLink(link);
|
||||
|
||||
settleBackoff();
|
||||
const res = await service.syncLink(config.getLink(link.id));
|
||||
|
||||
expect(res).toMatchObject({ state: 'ok', pulled: 1 });
|
||||
expect(provider.fetch).toHaveBeenCalledTimes(2);
|
||||
const row = itemRow(itemId);
|
||||
expect(row).toMatchObject({
|
||||
state: 'synced', error_code: null, attempts: 0, remote_version: 'v2', content_sha256: FILE_SHA,
|
||||
remote_size: 2048, remote_modified_at: '2026-09-18T10:00:00Z',
|
||||
});
|
||||
const live = fileRows().filter(r => r.deleted_at === null);
|
||||
expect(live).toHaveLength(1);
|
||||
expect(Number(live[0].id)).toBe(Number(row.file_id));
|
||||
expect(Number(row.file_id)).not.toBe(fileId);
|
||||
expect(service.issues(tripId)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('is fetched again by "Sync now" without waiting out the backoff', async () => {
|
||||
const link = makeLink();
|
||||
const { itemId } = seedHeld(link);
|
||||
provider.list.mockResolvedValue(ok(listing([edited()])));
|
||||
provider.fetch.mockResolvedValueOnce(fail('provider_error'));
|
||||
await service.syncLink(link);
|
||||
|
||||
service.retryShelvedItems(link.id);
|
||||
await service.syncLink(config.getLink(link.id));
|
||||
|
||||
expect(provider.fetch).toHaveBeenCalledTimes(2);
|
||||
expect(itemRow(itemId)).toMatchObject({ state: 'synced', remote_version: 'v2', content_sha256: FILE_SHA });
|
||||
});
|
||||
|
||||
it('still retries a first download, which has no version to keep', async () => {
|
||||
const link = makeLink();
|
||||
provider.list.mockResolvedValue(ok(listing([remoteDoc({ remoteId: 'r-new', name: 'fresh.pdf', remoteVersion: 'v1' })])));
|
||||
provider.fetch.mockResolvedValueOnce(fail('timeout'));
|
||||
await service.syncLink(link);
|
||||
expect(itemRows()[0]).toMatchObject({ state: 'error', file_id: null, remote_id: 'r-new', remote_version: null });
|
||||
|
||||
settleBackoff();
|
||||
await service.syncLink(config.getLink(link.id));
|
||||
|
||||
expect(provider.fetch).toHaveBeenCalledTimes(2);
|
||||
expect(itemRows()).toHaveLength(1);
|
||||
expect(itemRows()[0]).toMatchObject({ state: 'synced', remote_version: 'v1', content_sha256: FILE_SHA });
|
||||
expect(fileRows()).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
* a month reached the limit after six months of otherwise healthy syncing and
|
||||
* was shelved permanently. Driven here through real runs, since the write is
|
||||
* private and a test hatch would only prove the hatch works.
|
||||
|
||||
@@ -169,6 +169,7 @@ const docsync: TranslationStrings = {
|
||||
'docsync.issues.error': 'لم يتم النقل.',
|
||||
|
||||
'docsync.error.unknown_provider': 'هذا المزوّد غير متاح على هذا الخادم.',
|
||||
'docsync.error.provider_disabled': 'متوقفة مؤقتاً: أوقف مسؤول الخادم هذا المزوّد. ستُستأنف المزامنة عند إعادة تشغيله.',
|
||||
};
|
||||
|
||||
export default docsync;
|
||||
|
||||
@@ -176,6 +176,7 @@ const docsync: TranslationStrings = {
|
||||
'docsync.issues.error': 'A transferência não foi concluída.',
|
||||
|
||||
'docsync.error.unknown_provider': 'Este provedor não está disponível nesta instância.',
|
||||
'docsync.error.provider_disabled': 'Pausado: um administrador desativou este provedor. A sincronização continua assim que ele for reativado.',
|
||||
};
|
||||
|
||||
export default docsync;
|
||||
|
||||
@@ -178,6 +178,7 @@ const docsync: TranslationStrings = {
|
||||
'docsync.issues.error': 'La transferència no s’ha completat.',
|
||||
|
||||
'docsync.error.unknown_provider': 'Aquest proveïdor no està disponible en aquesta instància.',
|
||||
'docsync.error.provider_disabled': 'En pausa: un administrador ha desactivat aquest proveïdor. La sincronització es reprèn quan es torni a activar.',
|
||||
};
|
||||
|
||||
export default docsync;
|
||||
|
||||
@@ -173,6 +173,7 @@ const docsync: TranslationStrings = {
|
||||
'docsync.issues.error': 'Přenos neproběhl.',
|
||||
|
||||
'docsync.error.unknown_provider': 'Tento poskytovatel není na této instanci dostupný.',
|
||||
'docsync.error.provider_disabled': 'Pozastaveno: správce tohoto poskytovatele vypnul. Synchronizace bude pokračovat, jakmile ho znovu zapne.',
|
||||
};
|
||||
|
||||
export default docsync;
|
||||
|
||||
@@ -178,6 +178,7 @@ const docsync: TranslationStrings = {
|
||||
'docsync.issues.error': 'Die Übertragung ist nicht durchgegangen.',
|
||||
|
||||
'docsync.error.unknown_provider': 'Dieser Anbieter steht auf dieser Instanz nicht zur Verfügung.',
|
||||
'docsync.error.provider_disabled': 'Pausiert: Ein Administrator hat diesen Anbieter abgeschaltet. Der Abgleich läuft weiter, sobald er wieder eingeschaltet ist.',
|
||||
};
|
||||
|
||||
export default docsync;
|
||||
|
||||
@@ -174,6 +174,7 @@ const docsync: TranslationStrings = {
|
||||
'docsync.issues.error': 'The transfer did not go through.',
|
||||
|
||||
'docsync.error.unknown_provider': 'This provider is not available on this instance.',
|
||||
'docsync.error.provider_disabled': 'Paused: an administrator has switched this provider off. Syncing resumes once it is back on.',
|
||||
};
|
||||
|
||||
export default docsync;
|
||||
|
||||
@@ -178,6 +178,7 @@ const docsync: TranslationStrings = {
|
||||
'docsync.issues.error': 'La transferencia no se completó.',
|
||||
|
||||
'docsync.error.unknown_provider': 'Este proveedor no está disponible en esta instancia.',
|
||||
'docsync.error.provider_disabled': 'En pausa: un administrador ha desactivado este proveedor. La sincronización se reanuda en cuanto vuelva a activarse.',
|
||||
};
|
||||
|
||||
export default docsync;
|
||||
|
||||
@@ -180,6 +180,7 @@ const docsync: TranslationStrings = {
|
||||
'docsync.issues.error': 'Le transfert n’a pas abouti.',
|
||||
|
||||
'docsync.error.unknown_provider': 'Ce fournisseur n’est pas disponible sur cette instance.',
|
||||
'docsync.error.provider_disabled': 'En pause : un administrateur a désactivé ce fournisseur. La synchronisation reprendra dès qu’il sera réactivé.',
|
||||
};
|
||||
|
||||
export default docsync;
|
||||
|
||||
@@ -181,6 +181,7 @@ const docsync: TranslationStrings = {
|
||||
'docsync.issues.error': 'Η μεταφορά δεν ολοκληρώθηκε.',
|
||||
|
||||
'docsync.error.unknown_provider': 'Αυτός ο πάροχος δεν είναι διαθέσιμος σε αυτή την εγκατάσταση.',
|
||||
'docsync.error.provider_disabled': 'Σε παύση: ένας διαχειριστής απενεργοποίησε αυτόν τον πάροχο. Ο συγχρονισμός θα συνεχιστεί μόλις ενεργοποιηθεί ξανά.',
|
||||
};
|
||||
|
||||
export default docsync;
|
||||
|
||||
@@ -179,6 +179,7 @@ const docsync: TranslationStrings = {
|
||||
'docsync.issues.error': 'Az átvitel nem sikerült.',
|
||||
|
||||
'docsync.error.unknown_provider': 'Ez a szolgáltató nem érhető el ezen a példányon.',
|
||||
'docsync.error.provider_disabled': 'Szüneteltetve: egy rendszergazda kikapcsolta ezt a szolgáltatót. A szinkronizálás folytatódik, amint újra bekapcsolják.',
|
||||
};
|
||||
|
||||
export default docsync;
|
||||
|
||||
@@ -178,6 +178,7 @@ const docsync: TranslationStrings = {
|
||||
'docsync.issues.error': 'Transfer tidak berhasil.',
|
||||
|
||||
'docsync.error.unknown_provider': 'Penyedia ini tidak tersedia di instans ini.',
|
||||
'docsync.error.provider_disabled': 'Dijeda: administrator menonaktifkan penyedia ini. Sinkronisasi berlanjut setelah penyedia diaktifkan kembali.',
|
||||
};
|
||||
|
||||
export default docsync;
|
||||
|
||||
@@ -178,6 +178,7 @@ const docsync: TranslationStrings = {
|
||||
'docsync.issues.error': 'Il trasferimento non è andato a buon fine.',
|
||||
|
||||
'docsync.error.unknown_provider': 'Questo provider non è disponibile su questa istanza.',
|
||||
'docsync.error.provider_disabled': 'In pausa: un amministratore ha disattivato questo provider. La sincronizzazione riprende appena viene riattivato.',
|
||||
};
|
||||
|
||||
export default docsync;
|
||||
|
||||
@@ -172,6 +172,7 @@ const docsync: TranslationStrings = {
|
||||
'docsync.issues.error': '転送できませんでした。',
|
||||
|
||||
'docsync.error.unknown_provider': 'この連携先はこのインスタンスでは利用できません。',
|
||||
'docsync.error.provider_disabled': '一時停止中:管理者がこの連携先を無効にしました。再び有効になると同期が再開されます。',
|
||||
};
|
||||
|
||||
export default docsync;
|
||||
|
||||
@@ -171,6 +171,7 @@ const docsync: TranslationStrings = {
|
||||
'docsync.issues.error': '전송이 완료되지 않았습니다.',
|
||||
|
||||
'docsync.error.unknown_provider': '이 저장소는 이 인스턴스에서 사용할 수 없습니다.',
|
||||
'docsync.error.provider_disabled': '일시 중지됨: 관리자가 이 저장소를 껐습니다. 다시 켜지면 동기화가 재개됩니다.',
|
||||
};
|
||||
|
||||
export default docsync;
|
||||
|
||||
@@ -175,6 +175,7 @@ const docsync: TranslationStrings = {
|
||||
'docsync.issues.error': 'De overdracht is niet gelukt.',
|
||||
|
||||
'docsync.error.unknown_provider': 'Deze aanbieder is niet beschikbaar op deze instantie.',
|
||||
'docsync.error.provider_disabled': 'Gepauzeerd: een beheerder heeft deze aanbieder uitgeschakeld. De synchronisatie gaat verder zodra hij weer aanstaat.',
|
||||
};
|
||||
|
||||
export default docsync;
|
||||
|
||||
@@ -177,6 +177,7 @@ const docsync: TranslationStrings = {
|
||||
'docsync.issues.error': 'Transfer się nie powiódł.',
|
||||
|
||||
'docsync.error.unknown_provider': 'Ten dostawca nie jest dostępny na tej instancji.',
|
||||
'docsync.error.provider_disabled': 'Wstrzymane: administrator wyłączył tego dostawcę. Synchronizacja zostanie wznowiona, gdy znów go włączy.',
|
||||
};
|
||||
|
||||
export default docsync;
|
||||
|
||||
@@ -178,6 +178,7 @@ const docsync: TranslationStrings = {
|
||||
'docsync.issues.error': 'Передача не прошла.',
|
||||
|
||||
'docsync.error.unknown_provider': 'Этот провайдер недоступен в этом экземпляре.',
|
||||
'docsync.error.provider_disabled': 'Приостановлено: администратор отключил этого провайдера. Синхронизация возобновится, как только его снова включат.',
|
||||
};
|
||||
|
||||
export default docsync;
|
||||
|
||||
@@ -172,6 +172,7 @@ const docsync: TranslationStrings = {
|
||||
'docsync.issues.error': 'Överföringen gick inte igenom.',
|
||||
|
||||
'docsync.error.unknown_provider': 'Den här leverantören är inte tillgänglig på den här instansen.',
|
||||
'docsync.error.provider_disabled': 'Pausad: en administratör har stängt av den här leverantören. Synkroniseringen fortsätter när den slås på igen.',
|
||||
};
|
||||
|
||||
export default docsync;
|
||||
|
||||
@@ -176,6 +176,7 @@ const docsync: TranslationStrings = {
|
||||
'docsync.issues.error': 'Aktarım tamamlanamadı.',
|
||||
|
||||
'docsync.error.unknown_provider': 'Bu sağlayıcı bu kurulumda kullanılamıyor.',
|
||||
'docsync.error.provider_disabled': 'Duraklatıldı: bir yönetici bu sağlayıcıyı kapattı. Yeniden açıldığında eşitleme kaldığı yerden devam eder.',
|
||||
};
|
||||
|
||||
export default docsync;
|
||||
|
||||
@@ -176,6 +176,7 @@ const docsync: TranslationStrings = {
|
||||
'docsync.issues.error': 'Передавання не відбулося.',
|
||||
|
||||
'docsync.error.unknown_provider': 'Цей постачальник недоступний на цьому сервері.',
|
||||
'docsync.error.provider_disabled': 'Призупинено: адміністратор вимкнув цього постачальника. Синхронізація відновиться, щойно його знову ввімкнуть.',
|
||||
};
|
||||
|
||||
export default docsync;
|
||||
|
||||
@@ -175,6 +175,7 @@ const docsync: TranslationStrings = {
|
||||
'docsync.issues.error': 'Việc truyền tệp không thành công.',
|
||||
|
||||
'docsync.error.unknown_provider': 'Nhà cung cấp này không khả dụng trên máy chủ này.',
|
||||
'docsync.error.provider_disabled': 'Đã tạm dừng: quản trị viên đã tắt nhà cung cấp này. Việc đồng bộ sẽ tiếp tục khi nó được bật lại.',
|
||||
};
|
||||
|
||||
export default docsync;
|
||||
|
||||
@@ -166,6 +166,7 @@ const docsync: TranslationStrings = {
|
||||
'docsync.issues.error': '傳輸沒有完成。',
|
||||
|
||||
'docsync.error.unknown_provider': '這個提供者在此執行個體上無法使用。',
|
||||
'docsync.error.provider_disabled': '已暫停:管理員已停用此提供者。重新啟用後會繼續同步。',
|
||||
};
|
||||
|
||||
export default docsync;
|
||||
|
||||
@@ -165,6 +165,7 @@ const docsync: TranslationStrings = {
|
||||
'docsync.issues.error': '传输未能完成。',
|
||||
|
||||
'docsync.error.unknown_provider': '此实例不提供该文档服务。',
|
||||
'docsync.error.provider_disabled': '已暂停:管理员已关闭此文档服务。重新启用后将继续同步。',
|
||||
};
|
||||
|
||||
export default docsync;
|
||||
|
||||
Reference in New Issue
Block a user