diff --git a/server/src/db/dialect/sql-functions.ts b/server/src/db/dialect/sql-functions.ts
index 64f19d54b..2545afe33 100644
--- a/server/src/db/dialect/sql-functions.ts
+++ b/server/src/db/dialect/sql-functions.ts
@@ -1,6 +1,6 @@
import { raw, type Platform, type RawQueryFragment } from '@mikro-orm/core';
import { SqlitePlatform } from '@mikro-orm/sql';
-import type { Expression, ExpressionBuilder, ExpressionWrapper, ReferenceExpression, SqlBool, StringReference } from 'kysely';
+import { sql, type Expression, type ExpressionBuilder, type ExpressionWrapper, type RawBuilder, type ReferenceExpression, type SqlBool, type StringReference } from 'kysely';
/**
* The only place a repository may spell a database function.
@@ -230,6 +230,30 @@ export function coalesceParam(platform: Platform, ref: string, value: string | n
return unsupported(platform);
}
+/**
+ * `COALESCE(?,
)` — the MIRROR-IMAGE value-side shape of
+ * {@link coalesceParam} above: the bound value goes FIRST, the column is
+ * the fallback. NOT interchangeable with `coalesceParam` — that one keeps
+ * the EXISTING column unless it is NULL (`places.service.ts`'s own legacy
+ * shape); this one prefers the NEW value unless IT is null, keeping the
+ * existing column only then. `doc-sync.service.ts`'s DS23/DS24
+ * (`upsertItem`'s `file_id=COALESCE(?,file_id)`,
+ * `remote_id=COALESCE(?,remote_id)`, … eight columns, both the plain
+ * `nativeUpdate` and the Kysely `ON CONFLICT` forms) is the shape this
+ * exists for — confirmed genuinely the opposite direction from
+ * `coalesceParam`'s own doc'd precedent by reading the legacy SQL text
+ * literally, not assumed from the function name alone (a same-named
+ * "coalesce" helper reused in the wrong direction silently drops every
+ * incoming update — verified directly against a live row: a `recordAttempt`
+ * call passing a genuinely new `remote_version`/`content_sha256` left both
+ * columns exactly as they were before, since `coalesceParam`'s `COALESCE(col,
+ * ?)` always preferred the non-null existing value).
+ */
+export function coalesceOverride(platform: Platform, value: string | number | null, ref: string): RawQueryFragment & symbol {
+ if (platform instanceof SqlitePlatform) return raw(`COALESCE(?, ${column(ref)})`, [value]);
+ return unsupported(platform);
+}
+
/**
* `ABS( - ?)`, for use as a filter KEY (the `'string'`-hint coercion
* `lower()` above relies on) so it composes with `$lte`/`$gte`:
@@ -871,3 +895,60 @@ export function nowPlusSecondsKysely(
return unsupported(platform);
}
+// ---------------------------------------------------------------------------
+// Plan 3h Task 5 (doc-sync) — `FOUND_AGAIN` (`doc-sync.service.ts:53-54`), the
+// module-level SQL-text CASE constant reused by four legacy UPDATEs (DS2
+// `relocate`, DS3 `rename_remote`, DS5 `rename_local`, DS12 `touch`). Unlike
+// every other CASE in this file's DS2-DS12 neighbourhood — whose WHEN
+// condition binds a value already known in JS at the call site (a boolean
+// flag, a state string), so the whole CASE collapses to a plain JS ternary
+// deciding which field to include in a `nativeUpdate` call — this one
+// compares the ROW'S OWN CURRENT `state`/`file_id` columns, which are not
+// knowable until the UPDATE executes. That is genuinely SQL-level, and the
+// four call sites share one method (`DocumentSyncItemsRepository`'s private
+// `foundAgain` builder) rather than four independent re-derivations, per the
+// task's own instruction to keep `FOUND_AGAIN` "a reusable typed CASE
+// expression ... consumed by all four".
+// ---------------------------------------------------------------------------
+
+/**
+ * `CASE WHEN = 'remote_missing' AND IS NOT NULL THEN
+ * 'synced' ELSE END` — doc-sync's `FOUND_AGAIN` clause, for the
+ * `state` column of an UPDATE `document_sync_items` SET list. Only a copy
+ * that is BOTH on record as missing AND still paired to a file recovers this
+ * way; every other state (`error`, `conflict`, a row with no file at all)
+ * passes through unchanged. `remote_missing_at` is cleared unconditionally
+ * by the SAME UPDATE's own `remote_missing_at = NULL` (a plain value, not
+ * part of this fragment — the caller sets it alongside).
+ */
+export function foundAgainState(platform: Platform, stateRef: string, fileIdRef: string): RawQueryFragment {
+ if (platform instanceof SqlitePlatform) {
+ return raw(
+ `CASE WHEN ${column(stateRef)} = 'remote_missing' AND ${column(fileIdRef)} IS NOT NULL THEN 'synced' ELSE ${column(stateRef)} END`,
+ );
+ }
+ return unsupported(platform);
+}
+
+/**
+ * The Kysely-expression twin of {@link currentTimestamp}: the bare
+ * `CURRENT_TIMESTAMP` keyword, for a value position inside a hand-typed
+ * Kysely statement (DS24's `ON CONFLICT ... DO UPDATE SET last_seen_at =
+ * CURRENT_TIMESTAMP` — R3's partial-index upsert). Verified directly against
+ * `better-sqlite3` that SQLite accepts the bare keyword (`SELECT
+ * CURRENT_TIMESTAMP`) but rejects it called as a function
+ * (`SELECT CURRENT_TIMESTAMP()` → `near "(": syntax error`), so
+ * `eb.fn('current_timestamp', [])` — every other helper in this file's
+ * usual Kysely shape — is not an option here; the whole point of a keyword
+ * literal is that it takes no parentheses. This is this file's one
+ * Kysely-side use of the `sql` template tag — confined here, the sanctioned
+ * escape hatch this file already is for every raw-SQL spelling in the
+ * codebase (the MikroORM helpers above use `raw()` for the identical
+ * reason), never inline in a repository (the TRAP list's "Kysely `sql`
+ * banned under repositories" is about repository FILES, not this one).
+ */
+export function currentTimestampKysely(platform: Platform): RawBuilder {
+ if (platform instanceof SqlitePlatform) return sql`CURRENT_TIMESTAMP`;
+ return unsupported(platform);
+}
+
diff --git a/server/src/db/repositories/DocumentConnections.repository.ts b/server/src/db/repositories/DocumentConnections.repository.ts
index 593ee6111..1df1b8333 100644
--- a/server/src/db/repositories/DocumentConnections.repository.ts
+++ b/server/src/db/repositories/DocumentConnections.repository.ts
@@ -1,4 +1,195 @@
+import { currentTimestamp, coalesceOverride } from '../dialect/sql-functions';
import type { DocumentConnections } from '../entities/DocumentConnections.entity';
+import { type AssertRowKeys } from './_shared/rows';
import { TrekRepository } from './_shared/trek-repository';
-export class DocumentConnectionsRepository extends TrekRepository {}
+/**
+ * A bare `document_connections` row. `secrets` is opaque encrypted TEXT here
+ * and everywhere in this repository (R7): encryption/decryption stays in
+ * `DocSyncConfigService`/`doc-sync-secrets.ts`, never in this file — a
+ * repository method takes and returns the ciphertext exactly as stored,
+ * calling no crypto function of its own.
+ */
+export interface DocumentConnectionRow {
+ id: number;
+ trip_id: number;
+ provider_id: string;
+ owner_user_id: number;
+ base_url: string;
+ secrets: string | null;
+ settings: string;
+ allow_insecure_tls: number;
+ capabilities: string | null;
+ last_probe_at: string | null;
+ last_probe_state: string;
+ last_probe_error: string | null;
+ created_at: string | null;
+ updated_at: string | null;
+}
+
+const _connectionRowKeys: AssertRowKeys = true;
+
+interface OwnerLeftKyselyDB {
+ document_connections: { id: number; trip_id: number; owner_user_id: number };
+ trip_members: { trip_id: number; user_id: number };
+ trips: { id: number; user_id: number };
+}
+
+/**
+ * `document_connections` — one provider credential per trip (R7's encrypted
+ * `secrets` column; see its class docstring for the encrypt-in-service rule).
+ */
+export class DocumentConnectionsRepository extends TrekRepository {
+ /** DSC4 (`getConnection`) — `SELECT * FROM document_connections WHERE id = ?`. */
+ async findById(id: number): Promise {
+ const row = await this.findOne({ id });
+ return row ? toRow(row) : undefined;
+ }
+
+ /** DSC5 (`listConnections`) — `SELECT * FROM document_connections WHERE trip_id = ? ORDER BY id`. */
+ async listForTrip(tripId: number): Promise {
+ const rows = await this.find({ trip_id: tripId }, { orderBy: { id: 'asc' } });
+ return rows.map(toRow);
+ }
+
+ /** DSC7 (`upsertConnection`'s pre-check) — `SELECT * FROM document_connections WHERE trip_id = ? AND provider_id = ?`. */
+ async findForTripAndProvider(tripId: number, providerId: string): Promise {
+ const row = await this.findOne({ trip_id: tripId, provider_id: providerId });
+ return row ? toRow(row) : undefined;
+ }
+
+ /**
+ * DSC6 (`saveEarnedSecret`, inside `uow.transactional`) — `UPDATE
+ * document_connections SET secrets = ? WHERE id = ?`. `secrets` arrives
+ * already encrypted — this method never calls `encryptSecrets` itself.
+ */
+ async updateSecrets(id: number, secrets: string | null): Promise {
+ await this.nativeUpdate({ id }, { secrets });
+ }
+
+ /**
+ * DSC8 (`upsertConnection`, existing row, inside `uow.transactional`) —
+ * `UPDATE document_connections SET base_url = ?, secrets = ?, settings =
+ * ?, allow_insecure_tls = ?, owner_user_id = ?, updated_at =
+ * CURRENT_TIMESTAMP WHERE id = ?`.
+ */
+ async updateConnection(
+ id: number,
+ data: { base_url: string; secrets: string | null; settings: string; allow_insecure_tls: number; owner_user_id: number },
+ ): Promise {
+ const platform = this.getEntityManager().getPlatform();
+ await this.nativeUpdate(
+ { id },
+ {
+ base_url: data.base_url,
+ secrets: data.secrets,
+ settings: data.settings,
+ allow_insecure_tls: data.allow_insecure_tls,
+ ownerUser: data.owner_user_id,
+ updated_at: currentTimestamp(platform),
+ },
+ );
+ }
+
+ /**
+ * DSC9 (`upsertConnection`, new row, inside `uow.transactional`) —
+ * `INSERT INTO document_connections (trip_id, provider_id, owner_user_id,
+ * base_url, secrets, settings, allow_insecure_tls) VALUES (?×7)`.
+ */
+ async insertConnection(data: {
+ trip_id: number;
+ provider_id: string;
+ owner_user_id: number;
+ base_url: string;
+ secrets: string | null;
+ settings: string;
+ allow_insecure_tls: number;
+ }): Promise {
+ return await this.insert({
+ trip: data.trip_id,
+ provider: data.provider_id,
+ ownerUser: data.owner_user_id,
+ base_url: data.base_url,
+ secrets: data.secrets,
+ settings: data.settings,
+ allow_insecure_tls: data.allow_insecure_tls,
+ });
+ }
+
+ /**
+ * DSC10 (`recordProbe`) — `UPDATE document_connections SET last_probe_at =
+ * CURRENT_TIMESTAMP, last_probe_state = ?, last_probe_error = ?,
+ * capabilities = COALESCE(?, capabilities), updated_at = CURRENT_TIMESTAMP
+ * WHERE id = ?`.
+ */
+ async recordProbe(id: number, state: string, error: string | null, capabilitiesJson: string | null): Promise {
+ const platform = this.getEntityManager().getPlatform();
+ await this.nativeUpdate(
+ { id },
+ {
+ last_probe_at: currentTimestamp(platform),
+ last_probe_state: state,
+ last_probe_error: error,
+ capabilities: coalesceOverride(platform, capabilitiesJson, 'capabilities'),
+ updated_at: currentTimestamp(platform),
+ },
+ );
+ }
+
+ /** DSC11 (`deleteConnection`) — `DELETE FROM document_connections WHERE id = ?`. Its bindings cascade (ON DELETE CASCADE). */
+ async deleteById(id: number): Promise {
+ await this.nativeDelete({ id });
+ }
+
+ /**
+ * DSC20/DSC21's shared predicate — "which connections' credential owner is
+ * no longer on the connection's trip (neither via `trip_members` nor as
+ * the trip's own owner)". The legacy code re-implemented this `NOT IN
+ * (... UNION ...)` subquery inline at BOTH call sites
+ * (`ownerLeft`'s single-connection check, `markOrphanedLinks`'s bulk
+ * sweep); this is the ONE typed version both now consume —
+ * `DocSyncConfigService.ownerLeft(id)` checks membership of this list,
+ * `.markOrphanedLinks()` hands the whole list to
+ * `TripDocumentLinksRepository.markOrphanedByConnectionIds`. `UNION` (not
+ * `UNION ALL`) — the legacy text dedupes, matched exactly: Kysely's
+ * `.union()`, not `.unionAll()`.
+ */
+ async listOrphanedIds(): Promise {
+ const rows = await this.kysely()
+ .selectFrom('document_connections as c')
+ .select('c.id')
+ .where((eb) =>
+ eb(
+ 'c.owner_user_id',
+ 'not in',
+ (qb) =>
+ qb
+ .selectFrom('trip_members as tm')
+ .select('tm.user_id')
+ .whereRef('tm.trip_id', '=', 'c.trip_id')
+ .union(qb.selectFrom('trips as t').select('t.user_id').whereRef('t.id', '=', 'c.trip_id')),
+ ),
+ )
+ .execute();
+ return rows.map((r) => r.id);
+ }
+}
+
+function toRow(entity: DocumentConnections): DocumentConnectionRow {
+ return {
+ id: entity.id,
+ trip_id: entity.trip_id,
+ provider_id: entity.provider_id,
+ owner_user_id: entity.owner_user_id,
+ base_url: entity.base_url,
+ secrets: entity.secrets ?? null,
+ settings: entity.settings,
+ allow_insecure_tls: entity.allow_insecure_tls,
+ capabilities: entity.capabilities ?? null,
+ last_probe_at: entity.last_probe_at ?? null,
+ last_probe_state: entity.last_probe_state,
+ last_probe_error: entity.last_probe_error ?? null,
+ created_at: entity.created_at ?? null,
+ updated_at: entity.updated_at ?? null,
+ };
+}
diff --git a/server/src/db/repositories/DocumentProviderFields.repository.ts b/server/src/db/repositories/DocumentProviderFields.repository.ts
index 18903ab52..307275dde 100644
--- a/server/src/db/repositories/DocumentProviderFields.repository.ts
+++ b/server/src/db/repositories/DocumentProviderFields.repository.ts
@@ -1,4 +1,57 @@
import type { DocumentProviderFields } from '../entities/DocumentProviderFields.entity';
+import { type AssertRowKeys } from './_shared/rows';
import { TrekRepository } from './_shared/trek-repository';
-export class DocumentProviderFieldsRepository extends TrekRepository {}
+/** DSC2's projection — every column the connection form needs, minus `id`/`provider_id`. */
+export interface DocumentProviderFieldRow {
+ field_key: string;
+ /** i18n key suffix, never display text: the client resolves `docsync.`. */
+ label: string;
+ input_type: string;
+ placeholder: string | null;
+ hint: string | null;
+ secret: number;
+ required: number;
+ sort_order: number;
+}
+
+/** A bare `document_provider_fields` row (every scalar column), for parity with the entity's own scalar keys. */
+export interface DocumentProviderFieldFullRow extends DocumentProviderFieldRow {
+ id: number;
+ provider_id: string;
+}
+
+const _fieldRowKeys: AssertRowKeys = true;
+
+/**
+ * `document_provider_fields` — the per-provider form-field catalog (which
+ * credentials a connection needs, which are secret, which are required).
+ * Read-only, same reasoning as `DocumentProvidersRepository`: no statement
+ * anywhere in doc-sync writes to this table.
+ */
+export class DocumentProviderFieldsRepository extends TrekRepository {
+ /**
+ * DSC2 (`DocSyncConfigService.providerFields`) — `SELECT field_key, label,
+ * input_type, placeholder, hint, secret, required, sort_order FROM
+ * document_provider_fields WHERE provider_id = ? ORDER BY sort_order`.
+ */
+ async listForProvider(providerId: string): Promise {
+ const rows = await this.find(
+ { provider_id: providerId },
+ {
+ fields: ['field_key', 'label', 'input_type', 'placeholder', 'hint', 'secret', 'required', 'sort_order'],
+ orderBy: { sort_order: 'asc' },
+ },
+ );
+ return rows.map((r) => ({
+ field_key: r.field_key,
+ label: r.label,
+ input_type: r.input_type,
+ placeholder: r.placeholder ?? null,
+ hint: r.hint ?? null,
+ secret: r.secret ?? 0,
+ required: r.required ?? 0,
+ sort_order: r.sort_order ?? 0,
+ }));
+ }
+}
diff --git a/server/src/db/repositories/DocumentProviders.repository.ts b/server/src/db/repositories/DocumentProviders.repository.ts
index 931d7c788..3be73b077 100644
--- a/server/src/db/repositories/DocumentProviders.repository.ts
+++ b/server/src/db/repositories/DocumentProviders.repository.ts
@@ -1,4 +1,72 @@
import type { DocumentProviders } from '../entities/DocumentProviders.entity';
+import { type AssertRowKeys } from './_shared/rows';
import { TrekRepository } from './_shared/trek-repository';
-export class DocumentProvidersRepository extends TrekRepository {}
+/** DSCTRL2's wider catalog row — id/name/description/icon, no `enabled`/`sort_order` (those only gate/order the read, they are never returned to the client). */
+export interface DocumentProviderCatalogRow {
+ id: string;
+ name: string;
+ description: string | null;
+ icon: string | null;
+}
+
+/** A bare `document_providers` row (every scalar column). */
+export interface DocumentProviderRow {
+ id: string | null;
+ name: string;
+ description: string | null;
+ icon: string | null;
+ enabled: number | null;
+ sort_order: number | null;
+}
+
+const _providerRowKeys: AssertRowKeys = true;
+
+/**
+ * `document_providers` — the read-only catalog of document-store adapters
+ * this build ships (Paperless, Papra, Nextcloud, OpenCloud, Synology Drive).
+ * Nothing in doc-sync's OWN statements writes to this table — only
+ * `admin.service.ts`'s future Plan 3i work toggles `enabled` — so every
+ * method here is a getter, shaped for 3i's eventual reuse per the plan's
+ * "For the user" cross-reference note. Three distinct read shapes (DSC1's
+ * bare id list, DSC3's single name lookup, DSCTRL2's wider catalog row) stay
+ * three methods rather than one generic "list providers" — R2's own ruling:
+ * different column sets, different callers, never silently unified.
+ */
+export class DocumentProvidersRepository extends TrekRepository {
+ /**
+ * DSC1 (`DocSyncConfigService.enabledProviderIds`) — `SELECT id FROM
+ * document_providers WHERE enabled = 1 ORDER BY sort_order`. Only the
+ * providers an instance admin switched on may be configured.
+ */
+ async listEnabledIds(): Promise {
+ const rows = await this.find({ enabled: 1 }, { fields: ['id'], orderBy: { sort_order: 'asc' } });
+ return rows.map((r) => r.id as string);
+ }
+
+ /**
+ * DSC3 (`DocSyncConfigService.providerName`, private) — `SELECT name FROM
+ * document_providers WHERE id = ?`. Whatever the enabled flag says — a
+ * binding outlives the admin switching its provider off, and the client
+ * still needs its name.
+ */
+ async findName(id: string): Promise {
+ const row = await this.findOne({ id }, { fields: ['name'] });
+ return row?.name;
+ }
+
+ /**
+ * DSCTRL2 (`DocSyncController.providers`) — `SELECT id, name, description,
+ * icon FROM document_providers WHERE enabled = 1 ORDER BY sort_order`. A
+ * wider column list than DSC1 (adds `description`/`icon` for the form the
+ * client renders) — kept as its own method per R2, not folded into
+ * `listEnabledIds`.
+ */
+ async listEnabledCatalog(): Promise {
+ const rows = await this.find(
+ { enabled: 1 },
+ { fields: ['id', 'name', 'description', 'icon'], orderBy: { sort_order: 'asc' } },
+ );
+ return rows.map((r) => ({ id: r.id as string, name: r.name, description: r.description ?? null, icon: r.icon ?? null }));
+ }
+}
diff --git a/server/src/db/repositories/DocumentSyncItems.repository.ts b/server/src/db/repositories/DocumentSyncItems.repository.ts
index b2af97bed..1f09ec504 100644
--- a/server/src/db/repositories/DocumentSyncItems.repository.ts
+++ b/server/src/db/repositories/DocumentSyncItems.repository.ts
@@ -1,4 +1,670 @@
+import type { EntityData } from '@mikro-orm/core';
+import { coalesceOverride, currentTimestamp, currentTimestampKysely, foundAgainState, nowPlusSeconds, nowPlusSecondsKysely } from '../dialect/sql-functions';
import type { DocumentSyncItems } from '../entities/DocumentSyncItems.entity';
+import { type AssertRowKeys } from './_shared/rows';
import { TrekRepository } from './_shared/trek-repository';
-export class DocumentSyncItemsRepository extends TrekRepository {}
+/** A bare `document_sync_items` row (every scalar column). */
+export interface DocumentSyncItemRow {
+ id: number;
+ link_id: number;
+ trip_id: number;
+ file_id: number | null;
+ trek_doc_uid: string;
+ remote_id: string | null;
+ remote_name: string | null;
+ remote_version: string | null;
+ remote_size: number | null;
+ remote_modified_at: string | null;
+ content_sha256: string | null;
+ pushed_sha256: string | null;
+ state: string;
+ error_code: string | null;
+ attempts: number;
+ next_attempt_at: string | null;
+ remote_missing_at: string | null;
+ first_seen_at: string;
+ last_seen_at: string;
+ synced_at: string | null;
+ remote_trashed_at: string | null;
+}
+
+const _itemRowKeys: AssertRowKeys = true;
+
+/** DS18's narrow projection (`loadItems`) — 14 named columns, not `*`. */
+export interface SyncItemStateRow {
+ id: number;
+ file_id: number | null;
+ trek_doc_uid: string;
+ remote_id: string | null;
+ remote_version: string | null;
+ remote_name: string | null;
+ remote_size: number | null;
+ remote_modified_at: string | null;
+ content_sha256: string | null;
+ pushed_sha256: string | null;
+ state: string;
+ attempts: number;
+ next_attempt_at: string | null;
+ remote_missing_at: string | null;
+ remote_trashed_at: string | null;
+}
+
+/** DS34's issues projection. */
+export interface DocumentSyncIssueRow {
+ id: number;
+ state: string;
+ error_code: string | null;
+ remote_name: string | null;
+ remote_missing_at: string | null;
+ file_name: string | null;
+}
+
+/** DSCTRL4/DSCTRL5's items-listing projection (`i.*` plus `file_name`). */
+export interface DocumentSyncItemWithFileRow extends DocumentSyncItemRow {
+ file_name: string | null;
+}
+
+/** DS37's per-link holdings projection. */
+export interface DocumentSyncHoldingsRow {
+ link_id: number;
+ paired: number;
+ atProvider: number;
+ missing: number;
+}
+
+interface DocumentSyncItemsKyselyDB {
+ document_sync_items: DocumentSyncItemRow;
+ trip_files: { id: number; original_name: string };
+}
+
+/**
+ * DS24's insert-only shape — `id`/`first_seen_at`/`last_seen_at` are
+ * autoincrement/`DEFAULT CURRENT_TIMESTAMP` and `remote_missing_at`/
+ * `remote_trashed_at` are always NULL on a brand-new row (matching the
+ * legacy statement's own 16-column list, which omits all five) — a
+ * SEPARATE interface from {@link DocumentSyncItemsKyselyDB} rather than
+ * making those columns optional there, the same one-purpose-shaped-interface
+ * reasoning `FileLinksRepository`'s own `FileLinksWriteKyselyDB` docstring
+ * gives.
+ */
+interface DocumentSyncItemsWriteKyselyDB {
+ document_sync_items: {
+ link_id: number;
+ trip_id: number;
+ file_id: number | null;
+ trek_doc_uid: string;
+ remote_id: string | null;
+ remote_name: string | null;
+ remote_version: string | null;
+ remote_size: number | null;
+ remote_modified_at: string | null;
+ content_sha256: string | null;
+ pushed_sha256: string | null;
+ state: string;
+ error_code: string | null;
+ attempts: number;
+ next_attempt_at: string | null;
+ synced_at: string | null;
+ /**
+ * Not part of DS24's legacy `VALUES` column list (the row is brand new,
+ * so SQLite's own `DEFAULT CURRENT_TIMESTAMP` would fire identically) —
+ * set explicitly here anyway, to the SAME `CURRENT_TIMESTAMP` value,
+ * because Kysely's typed insert-expression callback form requires every
+ * declared column of an `ON CONFLICT ... DO UPDATE` target table to be
+ * present in `.values()` (a plain `?:` optional marker on this
+ * interface is not honoured by that callback form, verified directly —
+ * `tsc` still demanded it). Behaviourally identical either way: both
+ * paths evaluate to the DB clock at insert time.
+ */
+ last_seen_at: string;
+ };
+}
+
+const ISSUE_STATES = ['conflict', 'rejected_type', 'too_large', 'remote_missing', 'error'] as const;
+
+/**
+ * `document_sync_items` — one pairing row per document a binding has ever
+ * seen, either side. DS2-DS12 are eight distinct state-machine UPDATE
+ * shapes on this table (`applyAction`'s eight cases) — kept as eight
+ * methods, never collapsed into one generic "patch" method (the inventory's
+ * own explicit callout).
+ */
+export class DocumentSyncItemsRepository extends TrekRepository {
+ // ── applyAction's eight state-machine UPDATEs (DS2-DS12) ──────────────────
+
+ /** DS2 (`applyAction` 'relocate') — `UPDATE ... SET remote_id=?, remote_version=?, remote_size=COALESCE(?,remote_size), remote_modified_at=COALESCE(?,remote_modified_at), ${FOUND_AGAIN}, last_seen_at=CURRENT_TIMESTAMP WHERE id=?`. */
+ async applyRelocate(id: number, data: { remote_id: string; remote_version: string; remote_size: number | null; remote_modified_at: string | null }): Promise {
+ const platform = this.getEntityManager().getPlatform();
+ await this.nativeUpdate(
+ { id },
+ {
+ remote_id: data.remote_id,
+ remote_version: data.remote_version,
+ remote_size: coalesceOverride(platform, data.remote_size, 'remote_size'),
+ remote_modified_at: coalesceOverride(platform, data.remote_modified_at, 'remote_modified_at'),
+ remote_missing_at: null,
+ state: foundAgainState(platform, 'state', 'file_id'),
+ last_seen_at: currentTimestamp(platform),
+ },
+ );
+ }
+
+ /** DS3 (`applyAction` 'rename_remote') — `UPDATE ... SET remote_name=?, remote_version=?, ${FOUND_AGAIN}, last_seen_at=CURRENT_TIMESTAMP WHERE id=?`. */
+ async applyRenameRemote(id: number, data: { remote_name: string; remote_version: string }): Promise {
+ const platform = this.getEntityManager().getPlatform();
+ await this.nativeUpdate(
+ { id },
+ {
+ remote_name: data.remote_name,
+ remote_version: data.remote_version,
+ remote_missing_at: null,
+ state: foundAgainState(platform, 'state', 'file_id'),
+ last_seen_at: currentTimestamp(platform),
+ },
+ );
+ }
+
+ /** DS5 (`applyAction` 'rename_local', the `document_sync_items` half — DS4's `trip_files` write is TripFilesRepository's) — `UPDATE ... SET remote_name=?, ${FOUND_AGAIN}, last_seen_at=CURRENT_TIMESTAMP WHERE id=?`. */
+ async applyRenameLocal(id: number, data: { remote_name: string }): Promise {
+ const platform = this.getEntityManager().getPlatform();
+ await this.nativeUpdate(
+ { id },
+ {
+ remote_name: data.remote_name,
+ remote_missing_at: null,
+ state: foundAgainState(platform, 'state', 'file_id'),
+ last_seen_at: currentTimestamp(platform),
+ },
+ );
+ }
+
+ /** DS6 (`applyAction` 'conflict') — `UPDATE ... SET state='conflict', last_seen_at=CURRENT_TIMESTAMP WHERE id=?`. */
+ async markConflict(id: number): Promise {
+ const platform = this.getEntityManager().getPlatform();
+ await this.nativeUpdate({ id }, { state: 'conflict', last_seen_at: currentTimestamp(platform) });
+ }
+
+ /** DS7 (`applyAction` 'mark_remote_missing') — `UPDATE ... SET state='remote_missing', remote_missing_at=CURRENT_TIMESTAMP, last_seen_at=CURRENT_TIMESTAMP WHERE id=?`. */
+ async markRemoteMissing(id: number): Promise {
+ const platform = this.getEntityManager().getPlatform();
+ await this.nativeUpdate({ id }, { state: 'remote_missing', remote_missing_at: currentTimestamp(platform), last_seen_at: currentTimestamp(platform) });
+ }
+
+ /**
+ * DS8 (`applyAction` 'local_deleted') — `UPDATE ... SET
+ * state='local_deleted', remote_missing_at=CASE WHEN ?=1 THEN
+ * remote_missing_at ELSE NULL END, remote_trashed_at=CASE WHEN ?=1 THEN
+ * CURRENT_TIMESTAMP ELSE NULL END, last_seen_at=CURRENT_TIMESTAMP WHERE
+ * id=?`. Both CASEs' WHEN conditions bind a flag already known in JS at
+ * the call site (`remoteGone`/`binned`) — resolved here as a plain
+ * ternary, not a SQL-level CASE: `remoteGone` true KEEPS the column
+ * unchanged (omitted from the SET list) rather than "sets it to itself",
+ * a behaviourally identical, simpler shape.
+ */
+ async markLocalDeleted(id: number, data: { remoteGone: boolean; binned: boolean }): Promise {
+ const platform = this.getEntityManager().getPlatform();
+ const set: EntityData = {
+ state: 'local_deleted',
+ remote_trashed_at: data.binned ? currentTimestamp(platform) : null,
+ last_seen_at: currentTimestamp(platform),
+ };
+ if (!data.remoteGone) set.remote_missing_at = null;
+ await this.nativeUpdate({ id }, set);
+ }
+
+ /**
+ * DS9 (`applyAction` 'local_restored') — `UPDATE ... SET state=?,
+ * remote_missing_at=CASE WHEN ?=1 THEN CURRENT_TIMESTAMP ELSE NULL END,
+ * remote_trashed_at=NULL, last_seen_at=CURRENT_TIMESTAMP WHERE id=?`.
+ * Same JS-ternary resolution as {@link markLocalDeleted}.
+ */
+ async markLocalRestored(id: number, data: { state: string; missing: boolean }): Promise {
+ const platform = this.getEntityManager().getPlatform();
+ await this.nativeUpdate(
+ { id },
+ {
+ state: data.state,
+ remote_missing_at: data.missing ? currentTimestamp(platform) : null,
+ remote_trashed_at: null,
+ last_seen_at: currentTimestamp(platform),
+ },
+ );
+ }
+
+ /** DS10 (`applyAction` 'detach') — `UPDATE ... SET state='pending', remote_id=NULL, remote_version=NULL, remote_trashed_at=NULL, error_code=NULL, last_seen_at=CURRENT_TIMESTAMP WHERE id=?`. */
+ async markDetached(id: number): Promise {
+ const platform = this.getEntityManager().getPlatform();
+ await this.nativeUpdate(
+ { id },
+ { state: 'pending', remote_id: null, remote_version: null, remote_trashed_at: null, error_code: null, last_seen_at: currentTimestamp(platform) },
+ );
+ }
+
+ /** DS11 (`applyAction` 'remote_restored') — `UPDATE ... SET remote_trashed_at=NULL, last_seen_at=CURRENT_TIMESTAMP WHERE id=?`. */
+ async markRemoteRestored(id: number): Promise {
+ const platform = this.getEntityManager().getPlatform();
+ await this.nativeUpdate({ id }, { remote_trashed_at: null, last_seen_at: currentTimestamp(platform) });
+ }
+
+ /** DS12 (`applyAction` 'touch') — the most-used state-machine action: `UPDATE ... SET last_seen_at=CURRENT_TIMESTAMP, remote_version=COALESCE(?,remote_version), remote_name=COALESCE(?,remote_name), remote_id=COALESCE(?,remote_id), remote_size=COALESCE(?,remote_size), remote_modified_at=COALESCE(?,remote_modified_at), ${FOUND_AGAIN} WHERE id=?`. */
+ async applyTouch(
+ id: number,
+ data: { remote_version: string | null; remote_name: string | null; remote_id: string | null; remote_size: number | null; remote_modified_at: string | null },
+ ): Promise {
+ const platform = this.getEntityManager().getPlatform();
+ await this.nativeUpdate(
+ { id },
+ {
+ last_seen_at: currentTimestamp(platform),
+ remote_version: coalesceOverride(platform, data.remote_version, 'remote_version'),
+ remote_name: coalesceOverride(platform, data.remote_name, 'remote_name'),
+ remote_id: coalesceOverride(platform, data.remote_id, 'remote_id'),
+ remote_size: coalesceOverride(platform, data.remote_size, 'remote_size'),
+ remote_modified_at: coalesceOverride(platform, data.remote_modified_at, 'remote_modified_at'),
+ remote_missing_at: null,
+ state: foundAgainState(platform, 'state', 'file_id'),
+ },
+ );
+ }
+
+ // ── Reads ───────────────────────────────────────────────────────────────
+
+ /** DS13 (`pull`'s pre-image read) — `SELECT file_id, remote_name, content_sha256 FROM document_sync_items WHERE id=?`. */
+ async findPairing(id: number): Promise<{ file_id: number | null; remote_name: string | null; content_sha256: string | null } | undefined> {
+ const row = await this.kysely()
+ .selectFrom('document_sync_items')
+ .select(['file_id', 'remote_name', 'content_sha256'])
+ .where('id', '=', id)
+ .executeTakeFirst();
+ return row ? { file_id: row.file_id ?? null, remote_name: row.remote_name ?? null, content_sha256: row.content_sha256 ?? null } : undefined;
+ }
+
+ /**
+ * DS18 (`loadItems`) — the 14-column projection `SELECT id,file_id,trek_doc_uid,remote_id,remote_version,remote_name,remote_size,remote_modified_at,content_sha256,pushed_sha256,state,attempts,next_attempt_at,remote_missing_at,remote_trashed_at FROM document_sync_items WHERE link_id=?`.
+ * Kysely, not the QueryBuilder's `fields:` narrowing — this entity has
+ * THREE `persist(false)` mirror columns (`link_id`, `file_id`, `trip_id`),
+ * and a narrowed MikroORM `find`/`fields:` read silently drops one of
+ * them (the program-wide "select shadows through the relation" trap,
+ * `RoadtripDayBoundariesRepository`'s own docstring names it first) —
+ * measured directly here: `file_id` came back `undefined` on a row that
+ * had one, which fed the reconciler a false "new file", double-inserted
+ * the pairing and threw the table's own `(link_id, file_id)` partial
+ * unique index. Kysely's typed column list reads real DB columns
+ * directly and has no such gap.
+ */
+ async findByLink(linkId: number): Promise {
+ const rows = await this.kysely()
+ .selectFrom('document_sync_items')
+ .select([
+ 'id', 'file_id', 'trek_doc_uid', 'remote_id', 'remote_version', 'remote_name', 'remote_size', 'remote_modified_at',
+ 'content_sha256', 'pushed_sha256', 'state', 'attempts', 'next_attempt_at', 'remote_missing_at', 'remote_trashed_at',
+ ])
+ .where('link_id', '=', linkId)
+ .execute();
+ return rows.map((r) => ({
+ id: r.id,
+ file_id: r.file_id ?? null,
+ trek_doc_uid: r.trek_doc_uid,
+ remote_id: r.remote_id ?? null,
+ remote_version: r.remote_version ?? null,
+ remote_name: r.remote_name ?? null,
+ remote_size: r.remote_size ?? null,
+ remote_modified_at: r.remote_modified_at ?? null,
+ content_sha256: r.content_sha256 ?? null,
+ pushed_sha256: r.pushed_sha256 ?? null,
+ state: r.state,
+ attempts: r.attempts,
+ next_attempt_at: r.next_attempt_at ?? null,
+ remote_missing_at: r.remote_missing_at ?? null,
+ remote_trashed_at: r.remote_trashed_at ?? null,
+ }));
+ }
+
+ /** DS20 (`pairingOwner`) — `SELECT id FROM document_sync_items WHERE link_id=? AND remote_id=?`. */
+ async findPairingOwner(linkId: number, remoteId: string): Promise {
+ const row = await this.kysely()
+ .selectFrom('document_sync_items')
+ .select('id')
+ .where('link_id', '=', linkId)
+ .where('remote_id', '=', remoteId)
+ .executeTakeFirst();
+ return row?.id;
+ }
+
+ /** DS21 (`existingUid`) — `SELECT trek_doc_uid FROM document_sync_items WHERE id=? AND link_id=?`. */
+ async findTrekDocUid(id: number, linkId: number): Promise {
+ const row = await this.kysely()
+ .selectFrom('document_sync_items')
+ .select('trek_doc_uid')
+ .where('id', '=', id)
+ .where('link_id', '=', linkId)
+ .executeTakeFirst();
+ return row?.trek_doc_uid;
+ }
+
+ /** DS22 (`upsertItem`'s read-before-write attempt counter) — `SELECT attempts FROM document_sync_items WHERE id=?`. */
+ async getAttempts(id: number): Promise {
+ const row = await this.kysely().selectFrom('document_sync_items').select('attempts').where('id', '=', id).executeTakeFirst();
+ return row?.attempts;
+ }
+
+ // ── DS23/DS24 — R3's core ──────────────────────────────────────────────
+
+ /**
+ * DS23 (`upsertItem`, itemId≠null) — the plan's largest/most complex
+ * statement (§11): `UPDATE document_sync_items SET state=?, error_code=?,
+ * file_id=COALESCE(?,file_id), remote_id=COALESCE(?,remote_id),
+ * remote_version=COALESCE(?,remote_version),
+ * remote_name=COALESCE(?,remote_name), remote_size=COALESCE(?,remote_size),
+ * remote_modified_at=COALESCE(?,remote_modified_at),
+ * content_sha256=COALESCE(?,content_sha256),
+ * pushed_sha256=COALESCE(?,pushed_sha256), attempts=?, next_attempt_at=CASE
+ * WHEN ?=1 AND ? THEN datetime('now','+'||?||' seconds') ELSE NULL END,
+ * remote_missing_at=NULL, synced_at=CASE WHEN ?='synced' THEN
+ * CURRENT_TIMESTAMP ELSE synced_at END, last_seen_at=CURRENT_TIMESTAMP
+ * WHERE id=?`.
+ *
+ * A PLAIN (non-upsert) typed update, no `onConflict` needed (R3) — ten
+ * `COALESCE` columns via `coalesceOverride`, plus two CASEs that both
+ * collapse to a JS decision the caller (`DocSyncService.upsertItem`) has
+ * already made before calling this: the `?=1 AND ?` condition is
+ * `failed && attempts < maxAttempts`, both known at the call site, so the
+ * caller passes the plain second count (`next_attempt_after_seconds`) or
+ * `null`, and this method turns it into the `datetime('now', ...)`
+ * fragment itself, through Task 0's `nowPlusSeconds` helper — every
+ * dialect-function call stays inside the repository layer, matching
+ * {@link insertOrUpsertOnConflict}'s identical shape. `synced_at`'s "ELSE
+ * synced_at" (leave the column untouched) is simply OMITTING the field
+ * from this UPDATE's SET list when `state !== 'synced'` — a partial
+ * `nativeUpdate` never touches a field it is not given.
+ */
+ async recordAttempt(
+ id: number,
+ patch: {
+ state: string;
+ error_code: string | null;
+ file_id: number | null;
+ remote_id: string | null;
+ remote_version: string | null;
+ remote_name: string | null;
+ remote_size: number | null;
+ remote_modified_at: string | null;
+ content_sha256: string | null;
+ pushed_sha256: string | null;
+ attempts: number;
+ /** Seconds to add to the DB clock, when `failed && attempts < maxAttempts` (the caller's own decision), else `null`. */
+ next_attempt_after_seconds: number | null;
+ },
+ ): Promise {
+ const platform = this.getEntityManager().getPlatform();
+ const set: EntityData = {
+ state: patch.state,
+ error_code: patch.error_code,
+ file_id: coalesceOverride(platform, patch.file_id, 'file_id'),
+ remote_id: coalesceOverride(platform, patch.remote_id, 'remote_id'),
+ remote_version: coalesceOverride(platform, patch.remote_version, 'remote_version'),
+ remote_name: coalesceOverride(platform, patch.remote_name, 'remote_name'),
+ remote_size: coalesceOverride(platform, patch.remote_size, 'remote_size'),
+ remote_modified_at: coalesceOverride(platform, patch.remote_modified_at, 'remote_modified_at'),
+ content_sha256: coalesceOverride(platform, patch.content_sha256, 'content_sha256'),
+ pushed_sha256: coalesceOverride(platform, patch.pushed_sha256, 'pushed_sha256'),
+ attempts: patch.attempts,
+ next_attempt_at: patch.next_attempt_after_seconds !== null ? nowPlusSeconds(platform, patch.next_attempt_after_seconds) : null,
+ remote_missing_at: null,
+ last_seen_at: currentTimestamp(platform),
+ };
+ if (patch.state === 'synced') set.synced_at = currentTimestamp(platform);
+ await this.nativeUpdate({ id }, set);
+ }
+
+ /**
+ * DS24 (`upsertItem`, itemId=null) — R3's core: the PARTIAL UNIQUE INDEX
+ * upsert. `INSERT INTO document_sync_items (16 cols) VALUES (...) ON
+ * CONFLICT(link_id, remote_id) WHERE remote_id IS NOT NULL DO UPDATE SET
+ * state=excluded.state, error_code=excluded.error_code,
+ * file_id=COALESCE(excluded.file_id, document_sync_items.file_id),
+ * remote_version=COALESCE(excluded.remote_version,
+ * document_sync_items.remote_version),
+ * content_sha256=COALESCE(excluded.content_sha256,
+ * document_sync_items.content_sha256),
+ * pushed_sha256=COALESCE(excluded.pushed_sha256,
+ * document_sync_items.pushed_sha256), last_seen_at=CURRENT_TIMESTAMP`.
+ *
+ * MikroORM's `em.upsert` targets a table's DECLARED unique/PK constraints
+ * and has no way to express a PARTIAL index's WHERE predicate as the
+ * conflict target — SQLite itself refuses an `ON CONFLICT` that doesn't
+ * restate a partial index's predicate exactly. Hand-typed Kysely
+ * (`insertInto(...).onConflict(oc => oc.columns([...]).where(...)
+ * .doUpdateSet({...}))`), never `em.upsert`. `next_attempt_at`/`synced_at`
+ * are pre-resolved by the caller (see {@link recordAttempt}'s docstring —
+ * the same JS-ternary resolution, since both CASEs' conditions are known
+ * before this method is called).
+ */
+ async insertOrUpsertOnConflict(data: {
+ link_id: number;
+ trip_id: number;
+ file_id: number | null;
+ trek_doc_uid: string;
+ remote_id: string | null;
+ remote_name: string | null;
+ remote_version: string | null;
+ remote_size: number | null;
+ remote_modified_at: string | null;
+ content_sha256: string | null;
+ pushed_sha256: string | null;
+ state: string;
+ error_code: string | null;
+ attempts: number;
+ /** Seconds to add to the DB clock for `next_attempt_at` (mirrors `?=1` — a new row is always at its first failure, DS24's own comment), or `null` to leave it NULL. */
+ next_attempt_after_seconds: number | null;
+ /** Whether this insert's `state` is `'synced'` (sets `synced_at = CURRENT_TIMESTAMP`) — resolved by the caller, matching {@link recordAttempt}'s same JS-ternary shape. */
+ synced_now: boolean;
+ }): Promise {
+ const platform = this.getEntityManager().getPlatform();
+ await this.kysely()
+ .insertInto('document_sync_items')
+ .values((eb) => ({
+ link_id: data.link_id,
+ trip_id: data.trip_id,
+ file_id: data.file_id,
+ trek_doc_uid: data.trek_doc_uid,
+ remote_id: data.remote_id,
+ remote_name: data.remote_name,
+ remote_version: data.remote_version,
+ remote_size: data.remote_size,
+ remote_modified_at: data.remote_modified_at,
+ content_sha256: data.content_sha256,
+ pushed_sha256: data.pushed_sha256,
+ state: data.state,
+ error_code: data.error_code,
+ attempts: data.attempts,
+ next_attempt_at:
+ data.next_attempt_after_seconds !== null ? nowPlusSecondsKysely(platform, eb, data.next_attempt_after_seconds) : null,
+ synced_at: data.synced_now ? currentTimestampKysely(platform) : null,
+ last_seen_at: currentTimestampKysely(platform),
+ }))
+ .onConflict((oc) =>
+ oc
+ .columns(['link_id', 'remote_id'])
+ .where('remote_id', 'is not', null)
+ .doUpdateSet({
+ state: (eb) => eb.ref('excluded.state'),
+ error_code: (eb) => eb.ref('excluded.error_code'),
+ file_id: (eb) => eb.fn.coalesce(eb.ref('excluded.file_id'), eb.ref('document_sync_items.file_id')),
+ remote_version: (eb) => eb.fn.coalesce(eb.ref('excluded.remote_version'), eb.ref('document_sync_items.remote_version')),
+ content_sha256: (eb) => eb.fn.coalesce(eb.ref('excluded.content_sha256'), eb.ref('document_sync_items.content_sha256')),
+ pushed_sha256: (eb) => eb.fn.coalesce(eb.ref('excluded.pushed_sha256'), eb.ref('document_sync_items.pushed_sha256')),
+ last_seen_at: () => currentTimestampKysely(platform),
+ }),
+ )
+ .execute();
+ }
+
+ // ── Rename / conflict resolution (DS27-DS33 minus the trip_files halves) ──
+
+ /** DS27 (`renameRemoteTo`) — `UPDATE ... SET remote_name=?, remote_version=?, last_seen_at=CURRENT_TIMESTAMP WHERE id=?`. No `FOUND_AGAIN` — a distinct, simpler shape from DS3 (both rename the same two columns, but only DS3's caller is inside the reconciler's state machine). */
+ async setRemoteName(id: number, data: { remote_name: string; remote_version: string }): Promise {
+ const platform = this.getEntityManager().getPlatform();
+ await this.nativeUpdate({ id }, { remote_name: data.remote_name, remote_version: data.remote_version, last_seen_at: currentTimestamp(platform) });
+ }
+
+ /** DS28 (`resolveConflict`'s pre-image read) — `SELECT * FROM document_sync_items WHERE id=?`. */
+ async findRaw(id: number): Promise {
+ const row = await this.findOne({ id });
+ return row ? toRow(row) : undefined;
+ }
+
+ /** DS30 (`resolveConflict`, keep='trek') — `UPDATE ... SET state='pending', remote_version=NULL, error_code=NULL WHERE id=?`. */
+ async resolveKeepTrek(id: number): Promise {
+ await this.nativeUpdate({ id }, { state: 'pending', remote_version: null, error_code: null });
+ }
+
+ /** DS31 (`resolveConflict`, keep='provider') — `UPDATE ... SET state='pending', content_sha256=NULL, error_code=NULL WHERE id=?`. */
+ async resolveKeepProvider(id: number): Promise {
+ await this.nativeUpdate({ id }, { state: 'pending', content_sha256: null, error_code: null });
+ }
+
+ /** DS33 (`resolveConflict`, keep='both') — `UPDATE ... SET state='local_deleted', remote_id=NULL, error_code=NULL WHERE id=?`. */
+ async resolveKeepBoth(id: number): Promise {
+ await this.nativeUpdate({ id }, { state: 'local_deleted', remote_id: null, error_code: null });
+ }
+
+ // ── retryShelvedItems / delete / issues / status ───────────────────────
+
+ /** DS17 (`retryShelvedItems`) — `UPDATE document_sync_items SET attempts=0, next_attempt_at=NULL WHERE link_id=? AND state='error'`. Manual "sync now" only, never the scheduler. */
+ async resetShelvedForLink(linkId: number): Promise {
+ await this.nativeUpdate({ link_id: linkId, state: 'error' }, { attempts: 0, next_attempt_at: null });
+ }
+
+ /** DSC18 (`deleteLink`, inside `uow.transactional`) — `DELETE FROM document_sync_items WHERE link_id=?`. */
+ async deleteByLink(linkId: number): Promise {
+ await this.nativeDelete({ link_id: linkId });
+ }
+
+ /** DS34 (`issues`) — `SELECT i.id,i.state,i.error_code,i.remote_name,i.remote_missing_at,f.original_name AS file_name FROM document_sync_items i LEFT JOIN trip_files f ON f.id=i.file_id WHERE i.trip_id=? AND i.state IN (...) ORDER BY i.id DESC LIMIT 200`. */
+ async listIssues(tripId: number): Promise {
+ return await this.kysely()
+ .selectFrom('document_sync_items as i')
+ .leftJoin('trip_files as f', 'f.id', 'i.file_id')
+ .select(['i.id', 'i.state', 'i.error_code', 'i.remote_name', 'i.remote_missing_at', 'f.original_name as file_name'])
+ .where('i.trip_id', '=', tripId)
+ .where('i.state', 'in', [...ISSUE_STATES])
+ .orderBy('i.id', 'desc')
+ .limit(200)
+ .execute();
+ }
+
+ /** DS35 (`status`) — `SELECT state, COUNT(*) AS n FROM document_sync_items WHERE trip_id=? GROUP BY state`. */
+ async countByState(tripId: number): Promise> {
+ return await this.kysely()
+ .selectFrom('document_sync_items')
+ .select(['state', (eb) => eb.fn.countAll().as('n')])
+ .where('trip_id', '=', tripId)
+ .groupBy('state')
+ .execute();
+ }
+
+ /**
+ * DS37 (`status`'s per-link holdings) — `SELECT link_id, SUM(CASE WHEN
+ * file_id IS NOT NULL AND remote_id IS NOT NULL AND state='synced' THEN 1
+ * ELSE 0 END) AS paired, SUM(CASE WHEN remote_id IS NOT NULL AND
+ * state!='remote_missing' AND remote_trashed_at IS NULL AND
+ * (state!='local_deleted' OR remote_missing_at IS NULL) THEN 1 ELSE 0 END)
+ * AS atProvider, SUM(CASE WHEN state='remote_missing' THEN 1 ELSE 0 END)
+ * AS missing FROM document_sync_items WHERE trip_id=? GROUP BY link_id`.
+ */
+ async perLinkHoldings(tripId: number): Promise {
+ const db = this.kysely();
+ return await db
+ .selectFrom('document_sync_items')
+ .select((eb) => [
+ 'link_id',
+ eb.fn
+ .sum(
+ eb
+ .case()
+ .when(eb.and([eb('file_id', 'is not', null), eb('remote_id', 'is not', null), eb('state', '=', 'synced')]))
+ .then(1)
+ .else(0)
+ .end(),
+ )
+ .as('paired'),
+ eb.fn
+ .sum(
+ eb
+ .case()
+ .when(
+ eb.and([
+ eb('remote_id', 'is not', null),
+ eb('state', '!=', 'remote_missing'),
+ eb('remote_trashed_at', 'is', null),
+ eb.or([eb('state', '!=', 'local_deleted'), eb('remote_missing_at', 'is', null)]),
+ ]),
+ )
+ .then(1)
+ .else(0)
+ .end(),
+ )
+ .as('atProvider'),
+ eb.fn.sum(eb.case().when(eb('state', '=', 'remote_missing')).then(1).else(0).end()).as('missing'),
+ ])
+ .where('trip_id', '=', tripId)
+ .groupBy('link_id')
+ .execute();
+ }
+
+ // ── R2 — controller-SQL relocation (DSCTRL4/DSCTRL5) ───────────────────
+
+ /**
+ * DSCTRL4 (`DocSyncController.items`, state-filtered) — `SELECT i.*,
+ * f.original_name AS file_name FROM document_sync_items i LEFT JOIN
+ * trip_files f ON f.id=i.file_id WHERE i.trip_id=? AND i.state=? ORDER BY
+ * i.id DESC LIMIT 500`. A distinct `LIMIT 500` / fixed-vs-single-state
+ * shape from {@link listIssues}'s `LIMIT 200` fixed-state-list — kept its
+ * own method (R2's own instruction: three near-duplicate listing queries
+ * stay three methods, the WHERE/LIMIT shapes are not identical).
+ */
+ async listForTripByState(tripId: number, state: string): Promise {
+ return await this.itemsWithFileQuery().where('i.trip_id', '=', tripId).where('i.state', '=', state).orderBy('i.id', 'desc').limit(500).execute();
+ }
+
+ /** DSCTRL5 (`DocSyncController.items`, unfiltered) — same as {@link listForTripByState} minus the state predicate. */
+ async listForTrip(tripId: number): Promise {
+ return await this.itemsWithFileQuery().where('i.trip_id', '=', tripId).orderBy('i.id', 'desc').limit(500).execute();
+ }
+
+ private itemsWithFileQuery() {
+ return this.kysely()
+ .selectFrom('document_sync_items as i')
+ .leftJoin('trip_files as f', 'f.id', 'i.file_id')
+ .selectAll('i')
+ .select(['f.original_name as file_name']);
+ }
+}
+
+function toRow(entity: DocumentSyncItems): DocumentSyncItemRow {
+ return {
+ id: entity.id,
+ link_id: entity.link_id,
+ trip_id: entity.trip_id,
+ file_id: entity.file_id ?? null,
+ trek_doc_uid: entity.trek_doc_uid,
+ remote_id: entity.remote_id ?? null,
+ remote_name: entity.remote_name ?? null,
+ remote_version: entity.remote_version ?? null,
+ remote_size: entity.remote_size ?? null,
+ remote_modified_at: entity.remote_modified_at ?? null,
+ content_sha256: entity.content_sha256 ?? null,
+ pushed_sha256: entity.pushed_sha256 ?? null,
+ state: entity.state,
+ error_code: entity.error_code ?? null,
+ attempts: entity.attempts,
+ next_attempt_at: entity.next_attempt_at ?? null,
+ remote_missing_at: entity.remote_missing_at ?? null,
+ first_seen_at: entity.first_seen_at,
+ last_seen_at: entity.last_seen_at,
+ synced_at: entity.synced_at ?? null,
+ remote_trashed_at: entity.remote_trashed_at ?? null,
+ };
+}
diff --git a/server/src/db/repositories/FileLinks.repository.ts b/server/src/db/repositories/FileLinks.repository.ts
index a07cd1554..4409ffd50 100644
--- a/server/src/db/repositories/FileLinks.repository.ts
+++ b/server/src/db/repositories/FileLinks.repository.ts
@@ -139,4 +139,37 @@ export class FileLinksRepository extends TrekRepository {
.where('fl.file_id', '=', file_id)
.execute();
}
+
+ // ---------------------------------------------------------------------------
+ // Plan 3h Task 5 (doc-sync) — cross-domain additive method, flagged per the
+ // task brief (DS16 is cross-domain).
+ // ---------------------------------------------------------------------------
+
+ /**
+ * DS16 (`DocSyncService.pull`, inside `uow.transactional`, the "gone"
+ * branch) — `INSERT OR IGNORE INTO file_links (file_id, reservation_id,
+ * assignment_id, place_id, budget_item_id) SELECT ?, reservation_id,
+ * assignment_id, place_id, budget_item_id FROM file_links WHERE file_id =
+ * ?` — a genuine `INSERT ... SELECT`, copying every link the SUPERSEDED
+ * file held onto the new file that replaces it (a provider edit that
+ * bumped the version), so the booking/place/budget attachment follows the
+ * new revision. `INSERT OR IGNORE` because the new file may already hold
+ * one of the same links (an earlier partial run) — `ON CONFLICT DO
+ * NOTHING` targetless, same reasoning as {@link insertIgnore}'s own
+ * docstring (four independent partial-unique indexes, only one of which
+ * any given copied row happens to touch).
+ */
+ async copySelectIgnore(newFileId: number, fromFileId: number): Promise {
+ await this.kysely()
+ .insertInto('file_links')
+ .columns(['file_id', 'reservation_id', 'assignment_id', 'place_id', 'budget_item_id'])
+ .expression((eb) =>
+ eb
+ .selectFrom('file_links')
+ .select([eb.val(newFileId).as('file_id'), 'reservation_id', 'assignment_id', 'place_id', 'budget_item_id'])
+ .where('file_id', '=', fromFileId),
+ )
+ .onConflict((oc) => oc.doNothing())
+ .execute();
+ }
}
diff --git a/server/src/db/repositories/TripDocumentLinks.repository.ts b/server/src/db/repositories/TripDocumentLinks.repository.ts
index 5b98d9448..418f8ccbc 100644
--- a/server/src/db/repositories/TripDocumentLinks.repository.ts
+++ b/server/src/db/repositories/TripDocumentLinks.repository.ts
@@ -1,4 +1,257 @@
+import { coalesceParam, currentTimestamp, nowPlusSeconds } from '../dialect/sql-functions';
import type { TripDocumentLinks } from '../entities/TripDocumentLinks.entity';
+import { type AssertRowKeys } from './_shared/rows';
import { TrekRepository } from './_shared/trek-repository';
-export class TripDocumentLinksRepository extends TrekRepository {}
+/** A bare `trip_document_links` row (every scalar column). */
+export interface TripDocumentLinkRow {
+ id: number;
+ trip_id: number;
+ connection_id: number;
+ provider_id: string;
+ remote_scope_key: string;
+ remote_root_id: string | null;
+ remote_root_path: string | null;
+ remote_label: string;
+ direction: string;
+ delete_policy: string;
+ conflict_policy: string;
+ sync_enabled: number;
+ webhook_token: string | null;
+ webhook_secret: string | null;
+ webhook_subscription_id: string | null;
+ remote_cursor: string | null;
+ last_sync_at: string | null;
+ last_sync_state: string;
+ last_sync_error: string | null;
+ failure_count: number;
+ next_attempt_at: string | null;
+ created_by: number | null;
+ created_at: string | null;
+ updated_at: string | null;
+}
+
+const _linkRowKeys: AssertRowKeys = true;
+
+/** DSC17's dynamic SET list (`updateLink`) — only the fields present get written; `updated_at` is always stamped by {@link TripDocumentLinksRepository.updateFields} itself. */
+export interface TripDocumentLinkPatch {
+ direction?: string;
+ delete_policy?: string;
+ conflict_policy?: string;
+ sync_enabled?: number;
+ remote_label?: string;
+ /** The un-orphan reset (`sets.push("last_sync_state = 'never'")`) — set by the service, never inferred here. */
+ last_sync_state?: string;
+}
+
+/**
+ * `trip_document_links` — one trip's binding of a scope (folder/tag/space) at
+ * a `document_connections` row, both directions.
+ */
+export class TripDocumentLinksRepository extends TrekRepository {
+ /**
+ * DS1 (`DocSyncService.dueLinks`) — `SELECT * FROM trip_document_links
+ * WHERE sync_enabled=1 AND last_sync_state!='orphaned' 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
+ * LIMIT ?`. The provider-enabled subquery is resolved by the CALLER
+ * (`DocSyncService.dueLinks`, via `DocumentProvidersRepository
+ * .listEnabledIds`) rather than nested here as a raw SQL string (program
+ * rule 23 bans an inline SQL string in a repository; a real Kysely
+ * subquery was the other option, but this table's own `provider_id` set is
+ * bounded and small, and two plain reads keep this whole method on the
+ * QueryBuilder — rule 4's preferred query API — rather than dropping to
+ * Kysely for a subquery this cheap to resolve up front). The theoretical
+ * non-atomicity (a provider toggled between the two reads) affects at most
+ * one link's inclusion on one tick of a self-hosted, single-operator
+ * instance — flagged in the task report, not a parity break.
+ *
+ * **DS1's own doc comment describes a REAL production starvation bug this
+ * 3-key ORDER BY fixed** (a stalled binding sorting first on every tick and
+ * starving every link created after it) — preserved byte-for-byte, own
+ * named test (`doc-sync-svc: dueLinks orders by next_attempt_at then
+ * last_sync_at then id, not insertion order`).
+ */
+ async dueLinks(circuitOpenAfter: number, enabledProviderIds: readonly string[], limit: number): Promise {
+ if (enabledProviderIds.length === 0) return [];
+ const platform = this.getEntityManager().getPlatform();
+ const rows = await this.qb('l')
+ .select(['l.*'])
+ .where({
+ sync_enabled: 1,
+ last_sync_state: { $ne: 'orphaned' },
+ failure_count: { $lt: circuitOpenAfter },
+ $or: [{ next_attempt_at: null }, { next_attempt_at: { $lte: currentTimestamp(platform) } }],
+ provider_id: { $in: [...enabledProviderIds] },
+ })
+ .orderBy([
+ { [coalesceParam(platform, 'next_attempt_at', '1970-01-01')]: 'asc' },
+ { [coalesceParam(platform, 'last_sync_at', '1970-01-01')]: 'asc' },
+ { id: 'asc' },
+ ])
+ .limit(limit)
+ .execute('all', false);
+ return rows;
+ }
+
+ /** DSC12 (`listLinks`) — `SELECT * FROM trip_document_links WHERE trip_id = ? ORDER BY id`. */
+ async listForTrip(tripId: number): Promise {
+ return await this.qb('l').select(['l.*']).where({ trip_id: tripId }).orderBy({ id: 'asc' }).execute('all', false);
+ }
+
+ /** DSC13 (`getLink`) — `SELECT * FROM trip_document_links WHERE id = ?`. */
+ async findById(id: number): Promise {
+ return await this.qb('l').select(['l.*']).where({ id }).execute('get', false);
+ }
+
+ /** DSC14 (`getLinkByToken`) — `SELECT * FROM trip_document_links WHERE webhook_token = ?`. **PUBLIC/ANONYMOUS ENTRYPOINT** (the webhook controller's sole authentication check). */
+ async findByToken(token: string): Promise {
+ return await this.qb('l').select(['l.*']).where({ webhook_token: token }).execute('get', false);
+ }
+
+ /** DSC15 (`createLink`'s pre-check) — `SELECT * FROM trip_document_links WHERE trip_id = ? AND connection_id = ? AND remote_scope_key = ?`. */
+ async findByTripConnectionScope(tripId: number, connectionId: number, scopeKey: string): Promise {
+ return await this.qb('l')
+ .select(['l.*'])
+ .where({ trip_id: tripId, connection_id: connectionId, remote_scope_key: scopeKey })
+ .execute('get', false);
+ }
+
+ /**
+ * DSC16 (`createLink`) — `INSERT INTO trip_document_links (trip_id,
+ * connection_id, provider_id, remote_scope_key, remote_root_id,
+ * remote_root_path, remote_label, direction, delete_policy,
+ * conflict_policy, sync_enabled, webhook_token, webhook_secret,
+ * created_by, next_attempt_at) VALUES (?×14, CURRENT_TIMESTAMP)`.
+ * `webhook_secret` arrives already encrypted (R7) — this method never
+ * calls `encryptSecrets` itself. `next_attempt_at` is seeded to "now" so a
+ * fresh binding sorts first under DS1's due-links ordering.
+ */
+ async insertLink(data: {
+ trip_id: number;
+ connection_id: number;
+ provider_id: string;
+ remote_scope_key: string;
+ remote_root_id: string | null;
+ remote_root_path: string | null;
+ remote_label: string;
+ direction: string;
+ delete_policy: string;
+ conflict_policy: string;
+ sync_enabled: number;
+ webhook_token: string;
+ webhook_secret: string;
+ created_by: number;
+ }): Promise {
+ const platform = this.getEntityManager().getPlatform();
+ return await this.insert({
+ trip: data.trip_id,
+ connection: data.connection_id,
+ provider_id: data.provider_id,
+ remote_scope_key: data.remote_scope_key,
+ remote_root_id: data.remote_root_id,
+ remote_root_path: data.remote_root_path,
+ remote_label: data.remote_label,
+ direction: data.direction,
+ delete_policy: data.delete_policy,
+ conflict_policy: data.conflict_policy,
+ sync_enabled: data.sync_enabled,
+ webhook_token: data.webhook_token,
+ webhook_secret: data.webhook_secret,
+ createdByRef: data.created_by,
+ next_attempt_at: currentTimestamp(platform),
+ });
+ }
+
+ /**
+ * DSC17 (`updateLink`) — the dynamic SET list, as a typed partial (see
+ * {@link TripDocumentLinkPatch}'s own docstring). `updated_at` is always
+ * stamped; the caller (`DocSyncConfigService.updateLink`) only calls this
+ * once it has confirmed at least one field of `patch` is present, matching
+ * the legacy `sets.length === 0` early return.
+ */
+ async updateFields(id: number, patch: TripDocumentLinkPatch): Promise {
+ const platform = this.getEntityManager().getPlatform();
+ await this.nativeUpdate({ id }, { ...patch, updated_at: currentTimestamp(platform) });
+ }
+
+ /** DSC19 (`deleteLink`, inside `uow.transactional`) — `DELETE FROM trip_document_links WHERE id = ?`. */
+ async deleteById(id: number): Promise {
+ await this.nativeDelete({ id });
+ }
+
+ /**
+ * DSC21 (`markOrphanedLinks`) — `UPDATE trip_document_links SET
+ * last_sync_state='orphaned', sync_enabled=0, updated_at=CURRENT_TIMESTAMP
+ * WHERE sync_enabled=1 AND last_sync_state!='orphaned' AND connection_id
+ * IN ()`. The predicate itself is
+ * `DocumentConnectionsRepository.listOrphanedIds()` (DSC20/DSC21's shared
+ * repository predicate) — this method takes the already-resolved id list,
+ * matching {@link dueLinks}'s same "resolve the subquery's rows first,
+ * filter here" shape. An empty list short-circuits to zero rows changed,
+ * the same outcome `IN (SELECT ... WHERE )` would produce.
+ */
+ async markOrphanedByConnectionIds(connectionIds: readonly number[]): Promise {
+ if (connectionIds.length === 0) return 0;
+ const platform = this.getEntityManager().getPlatform();
+ return await this.nativeUpdate(
+ { sync_enabled: 1, last_sync_state: { $ne: 'orphaned' }, connection_id: { $in: [...connectionIds] } },
+ { last_sync_state: 'orphaned', sync_enabled: 0, updated_at: currentTimestamp(platform) },
+ );
+ }
+
+ /**
+ * DS25 (`DocSyncService.recordLinkSuccess`) — `UPDATE trip_document_links
+ * SET remote_cursor=?, last_sync_at=CURRENT_TIMESTAMP, last_sync_state=?,
+ * last_sync_error=?, failure_count=0, next_attempt_at=NULL,
+ * updated_at=CURRENT_TIMESTAMP WHERE id=?`.
+ */
+ async recordSuccess(id: number, data: { remote_cursor: string | null; state: string; error_code: string | null }): Promise {
+ const platform = this.getEntityManager().getPlatform();
+ await this.nativeUpdate(
+ { id },
+ {
+ remote_cursor: data.remote_cursor,
+ last_sync_at: currentTimestamp(platform),
+ last_sync_state: data.state,
+ last_sync_error: data.error_code,
+ failure_count: 0,
+ next_attempt_at: null,
+ updated_at: currentTimestamp(platform),
+ },
+ );
+ }
+
+ /**
+ * DS26 (`DocSyncService.recordLinkFailure`) — `UPDATE trip_document_links
+ * SET last_sync_at=CURRENT_TIMESTAMP, last_sync_state=?, last_sync_error=?,
+ * failure_count=?, next_attempt_at=datetime('now','+'||?||' seconds'),
+ * updated_at=CURRENT_TIMESTAMP WHERE id=?`. `nextAttemptSeconds` (the
+ * caller's own `backoffSeconds(...)` runtime value) is turned into the
+ * `datetime('now', ...)` fragment HERE, through Task 0's `nowPlusSeconds`
+ * helper — the service passes the plain second count, never a
+ * pre-rendered fragment, keeping every dialect-function call inside the
+ * repository layer.
+ */
+ async recordFailure(id: number, data: { state: string; error_code: string; failure_count: number; next_attempt_seconds: number }): Promise {
+ const platform = this.getEntityManager().getPlatform();
+ await this.nativeUpdate(
+ { id },
+ {
+ last_sync_at: currentTimestamp(platform),
+ last_sync_state: data.state,
+ last_sync_error: data.error_code,
+ failure_count: data.failure_count,
+ next_attempt_at: nowPlusSeconds(platform, data.next_attempt_seconds),
+ updated_at: currentTimestamp(platform),
+ },
+ );
+ }
+
+ /** DSCTRL3 (R2 — moved off the controller) — `UPDATE trip_document_links SET webhook_subscription_id = ? WHERE id = ?`, the webhook-registration follow-up. */
+ async setWebhookSubscriptionId(id: number, subscriptionId: string): Promise {
+ await this.nativeUpdate({ id }, { webhook_subscription_id: subscriptionId });
+ }
+}
diff --git a/server/src/db/repositories/TripFiles.repository.ts b/server/src/db/repositories/TripFiles.repository.ts
index 8678b3b64..11c8850b1 100644
--- a/server/src/db/repositories/TripFiles.repository.ts
+++ b/server/src/db/repositories/TripFiles.repository.ts
@@ -197,6 +197,69 @@ export class TripFilesRepository extends TrekRepository {
await this.nativeDelete({ id });
}
+ // ---------------------------------------------------------------------------
+ // Plan 3h Task 5 (doc-sync) — cross-domain additive methods, flagged per the
+ // task brief (DS4/DS15/DS16/DS19/DS29/DS32/DS36 are cross-domain). DS15
+ // reuses the existing {@link setStarred} above; these four are new.
+ // ---------------------------------------------------------------------------
+
+ /**
+ * DS4/DS32 (`DocSyncService.applyAction` 'rename_local', `resolveConflict`
+ * keep='provider' rename) — `UPDATE trip_files SET original_name = ?
+ * WHERE id = ?`, the identical statement shape at both legacy call sites
+ * — one method, not two.
+ */
+ async renameOriginalName(id: number, original_name: string): Promise {
+ await this.nativeUpdate({ id }, { original_name });
+ }
+
+ /**
+ * DS29 (`DocSyncService.resolveConflict`'s local-name read) — `SELECT
+ * original_name FROM trip_files WHERE id = ?`. Kysely, per this class's
+ * own docstring (a bare QB/`fields:` read risks silently dropping one of
+ * this entity's six `persist(false)` mirror columns — irrelevant to this
+ * one-column projection, but every other read in this file stays on
+ * Kysely for the same reason, so this one does too).
+ */
+ async findOriginalName(id: number): Promise {
+ const row = await this.kysely().selectFrom('trip_files').select('original_name').where('id', '=', id).executeTakeFirst();
+ return row?.original_name;
+ }
+
+ /**
+ * DS19 (`DocSyncService.loadLocalDocuments`) — `SELECT f.id,
+ * f.original_name, f.file_size, f.mime_type, f.deleted_at FROM trip_files
+ * f WHERE f.trip_id = ? AND f.message_id IS NULL AND f.note_id IS NULL`.
+ * Excludes chat and note attachments — a binding mirrors the trip's
+ * PAPERWORK, not its conversation.
+ */
+ async listSyncableForTrip(trip_id: number): Promise> {
+ return await this.kysely()
+ .selectFrom('trip_files')
+ .select(['id', 'original_name', 'file_size', 'mime_type', 'deleted_at'])
+ .where('trip_id', '=', trip_id)
+ .where('message_id', 'is', null)
+ .where('note_id', 'is', null)
+ .execute();
+ }
+
+ /**
+ * DS36 (`DocSyncService.status`'s holdings count) — `SELECT COUNT(*) AS n
+ * FROM trip_files WHERE trip_id = ? AND deleted_at IS NULL AND message_id
+ * IS NULL AND note_id IS NULL`.
+ */
+ async countActiveDocuments(trip_id: number): Promise {
+ const row = await this.kysely()
+ .selectFrom('trip_files')
+ .select((eb) => eb.fn.countAll().as('n'))
+ .where('trip_id', '=', trip_id)
+ .where('deleted_at', 'is', null)
+ .where('message_id', 'is', null)
+ .where('note_id', 'is', null)
+ .executeTakeFirstOrThrow();
+ return row.n;
+ }
+
/**
* FL23 (`FilesService.emptyTrash`'s bulk delete) — `DELETE FROM trip_files
* WHERE id IN (dynamic)`, only for the ids whose storage delete already
diff --git a/server/src/nest/doc-sync/doc-sync-config.service.ts b/server/src/nest/doc-sync/doc-sync-config.service.ts
index 0e29111f9..f63aa5ed7 100644
--- a/server/src/nest/doc-sync/doc-sync-config.service.ts
+++ b/server/src/nest/doc-sync/doc-sync-config.service.ts
@@ -1,7 +1,20 @@
-import { Injectable } from '@nestjs/common';
+import { HttpException, Injectable } from '@nestjs/common';
+import { InjectRepository } from '@mikro-orm/nestjs';
import crypto from 'crypto';
import { DOCSYNC_SECRET_MASK, type DocsyncConnectionInput, type DocsyncLinkInput } from '@trek/shared';
-import { DatabaseService } from '../database/database.service';
+import type { User } from '../../types';
+import { Trips } from '../../db/entities/Trips.entity';
+import type { TripsRepository } from '../../db/repositories/Trips.repository';
+import { DocumentProviders } from '../../db/entities/DocumentProviders.entity';
+import type { DocumentProviderCatalogRow, DocumentProvidersRepository } from '../../db/repositories/DocumentProviders.repository';
+import { DocumentProviderFields } from '../../db/entities/DocumentProviderFields.entity';
+import type { DocumentProviderFieldRow, DocumentProviderFieldsRepository } from '../../db/repositories/DocumentProviderFields.repository';
+import { DocumentConnections } from '../../db/entities/DocumentConnections.entity';
+import type { DocumentConnectionRow, DocumentConnectionsRepository } from '../../db/repositories/DocumentConnections.repository';
+import { TripDocumentLinks } from '../../db/entities/TripDocumentLinks.entity';
+import type { TripDocumentLinkPatch, TripDocumentLinkRow, TripDocumentLinksRepository } from '../../db/repositories/TripDocumentLinks.repository';
+import { DocumentSyncItems } from '../../db/entities/DocumentSyncItems.entity';
+import type { DocumentSyncItemsRepository } from '../../db/repositories/DocumentSyncItems.repository';
import { UnitOfWork } from '../database/unit-of-work';
import { checkSsrf } from '../../utils/ssrfGuard';
import { DocumentProviderRegistry } from './document-provider.registry';
@@ -28,88 +41,41 @@ import { decryptSecrets, encryptSecrets, maskSecrets, mergeSecrets } from './doc
* beside `trip_id` precisely so that the credential holder is nameable: when
* they leave the trip the binding goes to `orphaned` rather than quietly
* carrying on with an ex-member's token.
+ *
+ * R2 (Plan 3h Task 5): also owns the two controller-level statements that
+ * gate a trip's bindings (`assertCanManage`, DSCTRL1) and the provider
+ * catalog composition (`providersCatalog`, DSCTRL2) — both used to live
+ * directly in `DocSyncController`, the one architecture deviation this
+ * program's whole gather ever found (raw SQL inside a `@Controller`).
*/
-export interface ConnectionRow {
- id: number;
- trip_id: number;
- provider_id: string;
- owner_user_id: number;
- base_url: string;
- secrets: string | null;
- settings: string;
- allow_insecure_tls: number;
- capabilities: string | null;
- last_probe_at: string | null;
- last_probe_state: string;
- last_probe_error: string | null;
- created_at: string;
-}
-
-export interface LinkRow {
- id: number;
- trip_id: number;
- connection_id: number;
- provider_id: string;
- remote_scope_key: string;
- remote_root_id: string | null;
- remote_root_path: string | null;
- remote_label: string;
- direction: string;
- delete_policy: string;
- conflict_policy: string;
- sync_enabled: number;
- webhook_token: string | null;
- webhook_secret: string | null;
- webhook_subscription_id: string | null;
- remote_cursor: string | null;
- last_sync_at: string | null;
- last_sync_state: string;
- last_sync_error: string | null;
- failure_count: number;
- next_attempt_at: string | null;
-}
-
-interface ProviderFieldRow {
- field_key: string;
- /** i18n key suffix, never display text: the client resolves `docsync.`. */
- label: string;
- input_type: string;
- placeholder: string | null;
- hint: string | null;
- secret: number;
- required: number;
- sort_order: number;
-}
+export type ConnectionRow = DocumentConnectionRow;
+export type LinkRow = TripDocumentLinkRow;
+type ProviderFieldRow = DocumentProviderFieldRow;
@Injectable()
export class DocSyncConfigService {
constructor(
- private readonly db: DatabaseService,
+ @InjectRepository(Trips) private readonly trips: TripsRepository,
+ @InjectRepository(DocumentProviders) private readonly providers: DocumentProvidersRepository,
+ @InjectRepository(DocumentProviderFields) private readonly providerFieldsRepo: DocumentProviderFieldsRepository,
+ @InjectRepository(DocumentConnections) private readonly connections: DocumentConnectionsRepository,
+ @InjectRepository(TripDocumentLinks) private readonly links: TripDocumentLinksRepository,
+ @InjectRepository(DocumentSyncItems) private readonly items: DocumentSyncItemsRepository,
private readonly registry: DocumentProviderRegistry,
private readonly uow: UnitOfWork,
) {}
// ── Provider metadata ──────────────────────────────────────────────────────
- /** Only providers the instance admin switched on may be configured. */
+ /** DSC1 — Only providers the instance admin switched on may be configured. */
async enabledProviderIds(): Promise {
- return this.db.connection
- .prepare('SELECT id FROM document_providers WHERE enabled = 1 ORDER BY sort_order')
- .all()
- .map((r) => (r as { id: string }).id);
+ return this.providers.listEnabledIds();
}
+ /** DSC2 — every column the form needs, not just the three the secret bookkeeping uses. */
async providerFields(providerId: string): Promise {
- // Every column the form needs, not just the three the secret bookkeeping
- // uses: the client renders the field from this row, and a missing `label`
- // turns into a literal "docsync.undefined" on screen.
- return this.db.connection
- .prepare(
- `SELECT field_key, label, input_type, placeholder, hint, secret, required, sort_order
- FROM document_provider_fields WHERE provider_id = ? ORDER BY sort_order`,
- )
- .all(providerId) as ProviderFieldRow[];
+ return this.providerFieldsRepo.listForProvider(providerId);
}
private async secretKeys(providerId: string): Promise {
@@ -117,27 +83,63 @@ export class DocSyncConfigService {
}
/**
- * The display name whatever the enabled flag says. A binding outlives the
- * admin switching its provider off, and the client only gets names for the
- * providers that are still on, so without this it would show the raw id.
+ * DSC3 — the display name whatever the enabled flag says. A binding
+ * outlives the admin switching its provider off, and the client only gets
+ * names for the providers that are still on, so without this it would
+ * show the raw id.
*/
private async providerName(providerId: string): Promise {
- const row = this.db.connection
- .prepare('SELECT name FROM document_providers WHERE id = ?')
- .get(providerId) as { name: string } | undefined;
- return row?.name ?? providerId;
+ return (await this.providers.findName(providerId)) ?? providerId;
+ }
+
+ // ── R2 — the controller's own two statements ────────────────────────────
+
+ /**
+ * DSCTRL1 (`DocSyncController.assertCanManage`) — the trip owner or an
+ * instance admin, the same rule as `canManageDocSync` on the client.
+ * Checked here rather than through `@RequirePermission`: the permission
+ * table has no "manage integrations" action and inventing one would grant
+ * it to every trip_member by default, which is the opposite of what a
+ * credential this broad needs. The 403 still says "owner": an admin never
+ * sees it, and to everybody who does, the owner is the person to ask.
+ */
+ async assertCanManage(tripId: number, user: User): Promise {
+ if (user.role === 'admin') return;
+ const ownerId = await this.trips.getOwnerId(tripId);
+ if (ownerId === null) throw new HttpException('Trip not found', 404);
+ if (Number(ownerId) !== Number(user.id)) {
+ throw new HttpException('Only the trip owner can change document sync', 403);
+ }
+ }
+
+ /**
+ * DSCTRL2 (`DocSyncController.providers`) — which providers this instance
+ * has switched on, with their form fields, composed exactly as the
+ * controller used to: `available` (whether a registered adapter exists)
+ * and `fields` (booleanised `secret`/`required`) around the raw catalog
+ * read. A wider column list than {@link enabledProviderIds} (DSC1) — kept
+ * its own method, R2's own instruction not to unify near-duplicate reads
+ * with different shapes.
+ */
+ async providersCatalog(): Promise & { secret: boolean; required: boolean }> }>> {
+ const rows = await this.providers.listEnabledCatalog();
+ return await Promise.all(
+ rows.map(async (p) => ({
+ ...p,
+ available: !!this.registry.get(p.id),
+ fields: (await this.providerFields(p.id)).map((f) => ({ ...f, secret: f.secret === 1, required: f.required === 1 })),
+ })),
+ );
}
// ── Connections ────────────────────────────────────────────────────────────
async getConnection(id: number): Promise {
- return this.db.connection.prepare('SELECT * FROM document_connections WHERE id = ?').get(id) as ConnectionRow | undefined;
+ return this.connections.findById(id);
}
async listConnections(tripId: number): Promise {
- return this.db.connection
- .prepare('SELECT * FROM document_connections WHERE trip_id = ? ORDER BY id')
- .all(tripId) as ConnectionRow[];
+ return this.connections.listForTrip(tripId);
}
/**
@@ -160,7 +162,7 @@ export class DocSyncConfigService {
}
return {
connectionId: row.id,
- createdAt: row.created_at,
+ createdAt: row.created_at ?? '',
ownerId: row.owner_user_id,
baseUrl: row.base_url,
secrets: decryptSecrets(row.secrets),
@@ -192,9 +194,7 @@ export class DocSyncConfigService {
const secrets = decryptSecrets(row.secrets);
if (value === null) delete secrets[key];
else secrets[key] = value;
- this.db.connection
- .prepare('UPDATE document_connections SET secrets = ? WHERE id = ?')
- .run(encryptSecrets(secrets), connectionId);
+ await this.connections.updateSecrets(connectionId, encryptSecrets(secrets));
});
}
@@ -272,9 +272,7 @@ export class DocSyncConfigService {
const secretKeys = fields.filter((f) => f.secret === 1).map((f) => f.field_key);
const plainKeys = fields.filter((f) => f.secret !== 1).map((f) => f.field_key);
- const existing = this.db.connection
- .prepare('SELECT * FROM document_connections WHERE trip_id = ? AND provider_id = ?')
- .get(tripId, input.providerId) as ConnectionRow | undefined;
+ const existing = await this.connections.findForTripAndProvider(tripId, input.providerId);
const storedSecrets = existing ? decryptSecrets(existing.secrets) : {};
// What the form actually filled in, by the same rule the merge below uses.
@@ -320,37 +318,32 @@ export class DocSyncConfigService {
const row = await this.uow.transactional(async () => {
if (existing) {
- this.db.connection
- .prepare(
- `UPDATE document_connections
- SET base_url = ?, secrets = ?, settings = ?, allow_insecure_tls = ?,
- owner_user_id = ?, updated_at = CURRENT_TIMESTAMP
- WHERE id = ?`,
- )
- .run(urlCheck.data.url, encrypted, settingsJson, insecure, ownerId, existing.id);
+ await this.connections.updateConnection(existing.id, {
+ base_url: urlCheck.data.url,
+ secrets: encrypted,
+ settings: settingsJson,
+ allow_insecure_tls: insecure,
+ owner_user_id: ownerId,
+ });
return (await this.getConnection(existing.id)) as ConnectionRow;
}
- const info = this.db.connection
- .prepare(
- `INSERT INTO document_connections (trip_id, provider_id, owner_user_id, base_url, secrets, settings, allow_insecure_tls)
- VALUES (?, ?, ?, ?, ?, ?, ?)`,
- )
- .run(tripId, input.providerId, userId, urlCheck.data.url, encrypted, settingsJson, insecure);
- return (await this.getConnection(Number(info.lastInsertRowid))) as ConnectionRow;
+ const id = await this.connections.insertConnection({
+ trip_id: tripId,
+ provider_id: input.providerId,
+ owner_user_id: userId,
+ base_url: urlCheck.data.url,
+ secrets: encrypted,
+ settings: settingsJson,
+ allow_insecure_tls: insecure,
+ });
+ return (await this.getConnection(id)) as ConnectionRow;
});
return { success: true, data: row };
}
async recordProbe(connectionId: number, state: 'ok' | 'failed', error: string | null, capabilities: unknown): Promise {
- this.db.connection
- .prepare(
- `UPDATE document_connections
- SET last_probe_at = CURRENT_TIMESTAMP, last_probe_state = ?, last_probe_error = ?,
- capabilities = COALESCE(?, capabilities), updated_at = CURRENT_TIMESTAMP
- WHERE id = ?`,
- )
- .run(state, error, capabilities ? JSON.stringify(capabilities) : null, connectionId);
+ await this.connections.recordProbe(connectionId, state, error, capabilities ? JSON.stringify(capabilities) : null);
}
/**
@@ -359,25 +352,21 @@ export class DocSyncConfigService {
* of this action that is safe to offer without a confirmation dialog.
*/
async deleteConnection(id: number): Promise {
- this.db.connection.prepare('DELETE FROM document_connections WHERE id = ?').run(id);
+ await this.connections.deleteById(id);
}
// ── Trip bindings ──────────────────────────────────────────────────────────
async listLinks(tripId: number): Promise {
- return this.db.connection
- .prepare('SELECT * FROM trip_document_links WHERE trip_id = ? ORDER BY id')
- .all(tripId) as LinkRow[];
+ return this.links.listForTrip(tripId);
}
async getLink(id: number): Promise {
- return this.db.connection.prepare('SELECT * FROM trip_document_links WHERE id = ?').get(id) as LinkRow | undefined;
+ return this.links.findById(id);
}
async getLinkByToken(token: string): Promise {
- return this.db.connection
- .prepare('SELECT * FROM trip_document_links WHERE webhook_token = ?')
- .get(token) as LinkRow | undefined;
+ return this.links.findByToken(token);
}
toScopeRef(link: LinkRow): DocumentScopeRef {
@@ -404,9 +393,7 @@ export class DocSyncConfigService {
if (!conn || conn.trip_id !== tripId) {
return { success: false, error: { code: 'not_found', detail: 'connection not found for this trip' } };
}
- const existing = this.db.connection
- .prepare('SELECT * FROM trip_document_links WHERE trip_id = ? AND connection_id = ? AND remote_scope_key = ?')
- .get(tripId, input.connectionId, input.scopeKey) as LinkRow | undefined;
+ const existing = await this.links.findByTripConnectionScope(tripId, input.connectionId, input.scopeKey);
if (existing) return { success: true, data: existing };
// A per-link token, so revoking one binding cannot be used to poke another,
@@ -414,52 +401,44 @@ export class DocSyncConfigService {
const token = crypto.randomBytes(24).toString('base64url');
const secret = crypto.randomBytes(24).toString('base64url');
- const info = this.db.connection
- .prepare(
- `INSERT INTO trip_document_links
- (trip_id, connection_id, provider_id, remote_scope_key, remote_root_id, remote_root_path,
- remote_label, direction, delete_policy, conflict_policy, sync_enabled,
- webhook_token, webhook_secret, created_by, next_attempt_at)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)`,
- )
- .run(
- tripId,
- input.connectionId,
- conn.provider_id,
- input.scopeKey,
- input.remoteRootId ?? null,
- input.remoteRootPath ?? null,
- input.remoteLabel ?? '',
- input.direction,
- input.deletePolicy,
- input.conflictPolicy,
- input.syncEnabled ? 1 : 0,
- token,
- String(encryptSecrets({ webhook: secret })),
- userId,
- );
- return { success: true, data: (await this.getLink(Number(info.lastInsertRowid))) as LinkRow };
+ const id = await this.links.insertLink({
+ trip_id: tripId,
+ connection_id: input.connectionId,
+ provider_id: conn.provider_id,
+ remote_scope_key: input.scopeKey,
+ remote_root_id: input.remoteRootId ?? null,
+ remote_root_path: input.remoteRootPath ?? null,
+ remote_label: input.remoteLabel ?? '',
+ direction: input.direction,
+ delete_policy: input.deletePolicy,
+ conflict_policy: input.conflictPolicy,
+ sync_enabled: input.syncEnabled ? 1 : 0,
+ webhook_token: token,
+ webhook_secret: String(encryptSecrets({ webhook: secret })),
+ created_by: userId,
+ });
+ return { success: true, data: (await this.getLink(id)) as LinkRow };
}
async updateLink(id: number, patch: Partial): Promise {
- const sets: string[] = [];
- const values: unknown[] = [];
// Sync automatically is the way back for an orphaned binding, but only once
// the person whose credential it runs under is on the trip again. Until
// then the switch stays off rather than handing that credential back.
const current = patch.syncEnabled === true ? await this.getLink(id) : undefined;
+ let resetNever = false;
if (current?.last_sync_state === 'orphaned') {
if (await this.ownerLeft(current.connection_id)) patch = { ...patch, syncEnabled: undefined };
- else sets.push("last_sync_state = 'never'");
+ else resetNever = true;
}
- if (patch.direction !== undefined) { sets.push('direction = ?'); values.push(patch.direction); }
- if (patch.deletePolicy !== undefined) { sets.push('delete_policy = ?'); values.push(patch.deletePolicy); }
- if (patch.conflictPolicy !== undefined) { sets.push('conflict_policy = ?'); values.push(patch.conflictPolicy); }
- if (patch.syncEnabled !== undefined) { sets.push('sync_enabled = ?'); values.push(patch.syncEnabled ? 1 : 0); }
- if (patch.remoteLabel !== undefined) { sets.push('remote_label = ?'); values.push(patch.remoteLabel); }
- if (sets.length === 0) return await this.getLink(id);
- sets.push('updated_at = CURRENT_TIMESTAMP');
- this.db.connection.prepare(`UPDATE trip_document_links SET ${sets.join(', ')} WHERE id = ?`).run(...values, id);
+ const fields: TripDocumentLinkPatch = {};
+ if (patch.direction !== undefined) fields.direction = patch.direction;
+ if (patch.deletePolicy !== undefined) fields.delete_policy = patch.deletePolicy;
+ if (patch.conflictPolicy !== undefined) fields.conflict_policy = patch.conflictPolicy;
+ if (patch.syncEnabled !== undefined) fields.sync_enabled = patch.syncEnabled ? 1 : 0;
+ if (patch.remoteLabel !== undefined) fields.remote_label = patch.remoteLabel;
+ if (resetNever) fields.last_sync_state = 'never';
+ if (Object.keys(fields).length === 0) return await this.getLink(id);
+ await this.links.updateFields(id, fields);
return await this.getLink(id);
}
@@ -470,11 +449,21 @@ export class DocSyncConfigService {
*/
async deleteLink(id: number): Promise {
await this.uow.transactional(async () => {
- this.db.connection.prepare('DELETE FROM document_sync_items WHERE link_id = ?').run(id);
- this.db.connection.prepare('DELETE FROM trip_document_links WHERE id = ?').run(id);
+ await this.items.deleteByLink(id);
+ await this.links.deleteById(id);
});
}
+ /**
+ * DSCTRL3 (R2 — moved off the controller) — the webhook-registration
+ * follow-up: `UPDATE trip_document_links SET webhook_subscription_id = ?
+ * WHERE id = ?`, run after `createLink`'s provider `registerWebhook` call
+ * succeeds.
+ */
+ async setWebhookSubscriptionId(linkId: number, subscriptionId: string): Promise {
+ await this.links.setWebhookSubscriptionId(linkId, subscriptionId);
+ }
+
webhookSecret(link: LinkRow): string {
return decryptSecrets(link.webhook_secret).webhook ?? '';
}
@@ -531,44 +520,31 @@ export class DocSyncConfigService {
return link.last_sync_state === 'orphaned' || (await this.ownerLeft(link.connection_id));
}
+ /**
+ * DSC20 — whether a connection's credential owner is no longer on its
+ * trip. Consumes {@link DocumentConnectionsRepository.listOrphanedIds},
+ * the ONE shared predicate DSC20 (this single-link check) and DSC21
+ * (`markOrphanedLinks`'s bulk sweep, below) both draw from — the legacy
+ * code re-implemented the identical `NOT IN (... UNION ...)` subquery at
+ * both call sites; this is the single converted version.
+ */
private async ownerLeft(connectionId: number): Promise {
- return !!this.db.connection
- .prepare(
- `SELECT 1 FROM document_connections c
- WHERE c.id = ?
- AND c.owner_user_id NOT IN (
- SELECT tm.user_id FROM trip_members tm WHERE tm.trip_id = c.trip_id
- UNION SELECT t.user_id FROM trips t WHERE t.id = c.trip_id
- )`,
- )
- .get(connectionId);
+ return (await this.connections.listOrphanedIds()).includes(connectionId);
}
/**
- * Bindings whose credential owner is no longer on the trip.
+ * DSC21 — bindings whose credential owner is no longer on the trip.
*
* Checked on every run rather than hooked to member removal, because a
* membership can also disappear through a trip transfer or a direct DB edit,
* and a binding that keeps using an ex-member's token is the kind of thing
- * nobody notices until it is a complaint.
+ * nobody notices until it is a complaint. Shares {@link ownerLeft}'s
+ * predicate (via {@link DocumentConnectionsRepository.listOrphanedIds}) —
+ * the bulk-sweep variant of the same underlying condition.
*/
async markOrphanedLinks(): Promise {
- const info = this.db.connection
- .prepare(
- `UPDATE trip_document_links
- SET last_sync_state = 'orphaned', sync_enabled = 0, updated_at = CURRENT_TIMESTAMP
- WHERE sync_enabled = 1
- AND last_sync_state != 'orphaned'
- AND connection_id IN (
- SELECT c.id FROM document_connections c
- WHERE c.owner_user_id NOT IN (
- SELECT tm.user_id FROM trip_members tm WHERE tm.trip_id = c.trip_id
- UNION SELECT t.user_id FROM trips t WHERE t.id = c.trip_id
- )
- )`,
- )
- .run();
- return info.changes;
+ const ids = await this.connections.listOrphanedIds();
+ return await this.links.markOrphanedByConnectionIds(ids);
}
}
diff --git a/server/src/nest/doc-sync/doc-sync-webhook.controller.ts b/server/src/nest/doc-sync/doc-sync-webhook.controller.ts
index 83884894b..7e48c7419 100644
--- a/server/src/nest/doc-sync/doc-sync-webhook.controller.ts
+++ b/server/src/nest/doc-sync/doc-sync-webhook.controller.ts
@@ -1,9 +1,10 @@
import { Controller, HttpCode, OnModuleDestroy, Param, Post, Req } from '@nestjs/common';
+import { MikroORM } from '@mikro-orm/core';
import type { Request } from 'express';
import crypto from 'crypto';
import { Public } from '../auth/public.decorator';
-import { DatabaseService } from '../database/database.service';
-import { SETTING_SYNC_ENABLED, WEBHOOK_NUDGE_DEBOUNCE_SECONDS } from './doc-sync.constants';
+import { withRequestContext } from '../database/request-context';
+import { WEBHOOK_NUDGE_DEBOUNCE_SECONDS } from './doc-sync.constants';
import { DocSyncConfigService, type LinkRow } from './doc-sync-config.service';
import { DocSyncService } from './doc-sync.service';
import { logError } from '../audit/audit-log.logger';
@@ -42,7 +43,7 @@ export class DocSyncWebhookController implements OnModuleDestroy {
constructor(
private readonly config: DocSyncConfigService,
private readonly sync: DocSyncService,
- private readonly db: DatabaseService,
+ private readonly orm: MikroORM,
) {}
/**
@@ -54,15 +55,13 @@ export class DocSyncWebhookController implements OnModuleDestroy {
* addon off would have watched it carry on. Checked when the timer fires as
* well as on arrival, so a switch thrown during the debounce window still
* takes effect.
+ *
+ * DSWH1 (R2 — moved off this controller): the kill-switch read now lives
+ * on `DocSyncService.isSyncEnabled`, the same rule the job obeys.
*/
private async syncIsOn(link: LinkRow): Promise {
if ((await this.sync.isSwitchedOff(link))) return false;
- const killSwitch = this.db.get<{ value: string }>(
- 'SELECT value FROM app_settings WHERE key = ?', SETTING_SYNC_ENABLED,
- )?.value;
- // Unrecognised values mean ON: the setting is absent by default, and only an
- // explicit 'false' stops the sync. Same rule as the job.
- return killSwitch !== 'false';
+ return this.sync.isSyncEnabled();
}
onModuleDestroy(): void {
@@ -106,6 +105,18 @@ export class DocSyncWebhookController implements OnModuleDestroy {
* because the provider is usually still writing the rest. The link is looked
* up again when the timer fires, so a binding switched off or deleted in the
* meantime does not get one last run out of a stale row.
+ *
+ * R9 (Plan 3h Task 5): the timer body forks its OWN fresh request context
+ * via `withRequestContext` — the same shape
+ * `StorageHealthNotifierService`'s listener (Plan 3f Task 4, R3) uses. This
+ * `nudge()` handler already returned its `{received:true}` response before
+ * this timer fires (`WEBHOOK_NUDGE_DEBOUNCE_SECONDS` later), so whatever
+ * request-scoped `EntityManager` fork the original HTTP request forked is
+ * long gone by the time this body runs — insurance, per 3f's own
+ * measurement that `AsyncLocalStorage` survives a detached chain intact in
+ * this codebase today, not a fix for an observed failure (the pre-conversion
+ * code had no `EntityManager` to lose in the first place: raw
+ * `better-sqlite3` calls have no request-scoping concept at all).
*/
private schedule(linkId: number, reload: () => ReturnType, isRetry = false): void {
if (this.pending.has(linkId)) return;
@@ -113,7 +124,7 @@ export class DocSyncWebhookController implements OnModuleDestroy {
// helper and its rejection is observed here rather than left unhandled
// (recipe R1.5).
const timer = setTimeout(() => {
- void (async () => {
+ void withRequestContext(this.orm, async () => {
this.pending.delete(linkId);
const fresh = await reload();
if (!fresh || fresh.sync_enabled !== 1) return;
@@ -124,7 +135,7 @@ export class DocSyncWebhookController implements OnModuleDestroy {
// again once rather than waiting out a whole poll interval: once, and
// only for `busy`, so this cannot become a loop.
if (res?.state === 'busy' && !isRetry) this.schedule(linkId, reload, true);
- })().catch((err: unknown) => {
+ }).catch((err: unknown) => {
logError(`Document sync webhook nudge failed for link ${linkId}: ${err instanceof Error ? err.message : String(err)}`);
});
}, WEBHOOK_NUDGE_DEBOUNCE_SECONDS * 1000);
diff --git a/server/src/nest/doc-sync/doc-sync.controller.ts b/server/src/nest/doc-sync/doc-sync.controller.ts
index 4b8ab1463..69b479538 100644
--- a/server/src/nest/doc-sync/doc-sync.controller.ts
+++ b/server/src/nest/doc-sync/doc-sync.controller.ts
@@ -22,7 +22,6 @@ import { RequireAddon } from '../addons/require-addon.decorator';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { CurrentUser } from '../auth/current-user.decorator';
import { TripAccessGuard } from '../permissions/trip-access.guard';
-import { DatabaseService } from '../database/database.service';
import { RealtimeService } from '../realtime/realtime.service';
import { docFailed } from './document-provider';
import { DocSyncConfigService, type LinkRow } from './doc-sync-config.service';
@@ -70,7 +69,6 @@ export class DocSyncController {
private readonly config: DocSyncConfigService,
private readonly sync: DocSyncService,
private readonly registry: DocumentProviderRegistry,
- private readonly db: DatabaseService,
private readonly realtime: RealtimeService,
) {}
@@ -97,33 +95,27 @@ export class DocSyncController {
*
* The 403 still says "owner": an admin never sees it, and to everybody who
* does, the owner is the person to ask.
+ *
+ * R2 (Plan 3h Task 5): the trip-owner lookup itself (DSCTRL1, raw SQL
+ * directly in this controller — the architecture deviation the whole
+ * program's gather flagged) now lives on `DocSyncConfigService
+ * .assertCanManage`; this stays a thin delegation so every one of this
+ * controller's existing call sites (string `tripId`) is unchanged.
*/
private async assertCanManage(tripId: string, user: User): Promise {
- if (user.role === 'admin') return;
- const trip = this.db.get<{ user_id: number }>('SELECT user_id FROM trips WHERE id = ?', tripId);
- if (!trip) throw new HttpException('Trip not found', 404);
- if (Number(trip.user_id) !== Number(user.id)) {
- throw new HttpException('Only the trip owner can change document sync', 403);
- }
+ await this.config.assertCanManage(Number(tripId), user);
}
// ── Providers and status ───────────────────────────────────────────────────
- /** Which providers this instance has switched on, with their form fields. */
+ /**
+ * Which providers this instance has switched on, with their form fields.
+ * R2: the raw catalog read (DSCTRL2) and its `available`/`fields`
+ * composition now live on `DocSyncConfigService.providersCatalog`.
+ */
@Get('providers')
async providers() {
- const rows = this.db.connection
- .prepare('SELECT id, name, description, icon FROM document_providers WHERE enabled = 1 ORDER BY sort_order')
- .all() as Array<{ id: string; name: string; description: string | null; icon: string }>;
- return await Promise.all(
- rows.map(async (p) => ({
- ...p,
- // A provider row with no registered adapter would render a form that
- // cannot work, so it is reported rather than hidden.
- available: !!this.registry.get(p.id),
- fields: (await this.config.providerFields(p.id)).map((f) => ({ ...f, secret: f.secret === 1, required: f.required === 1 })),
- })),
- );
+ return this.config.providersCatalog();
}
@Get('status')
@@ -317,9 +309,7 @@ export class DocSyncController {
`link ${res.data.id}: ${conn.provider_id} refused the webhook subscription (${hook.error.code}${hook.error.detail ? `: ${hook.error.detail}` : ''}), polling carries it`,
);
} else {
- this.db.connection
- .prepare('UPDATE trip_document_links SET webhook_subscription_id = ? WHERE id = ?')
- .run(hook.data.subscriptionId, res.data.id);
+ await this.config.setWebhookSubscriptionId(res.data.id, hook.data.subscriptionId);
}
}
}
@@ -397,24 +387,15 @@ export class DocSyncController {
// ── Documents and conflicts ────────────────────────────────────────────────
+ /**
+ * R2: DSCTRL4/DSCTRL5's two near-duplicate listing statements now live on
+ * `DocSyncService.itemsForTrip`, kept as two distinct repository methods
+ * (state-filtered vs unfiltered) — never unified with `issues()`'s own
+ * `LIMIT 200` fixed-state-list read.
+ */
@Get('items')
async items(@Param('tripId') tripId: string, @Query('state') state?: string) {
- const rows = state
- ? this.db.connection
- .prepare(
- `SELECT i.*, f.original_name AS file_name FROM document_sync_items i
- LEFT JOIN trip_files f ON f.id = i.file_id
- WHERE i.trip_id = ? AND i.state = ? ORDER BY i.id DESC LIMIT 500`,
- )
- .all(Number(tripId), state)
- : this.db.connection
- .prepare(
- `SELECT i.*, f.original_name AS file_name FROM document_sync_items i
- LEFT JOIN trip_files f ON f.id = i.file_id
- WHERE i.trip_id = ? ORDER BY i.id DESC LIMIT 500`,
- )
- .all(Number(tripId));
- return rows;
+ return this.sync.itemsForTrip(Number(tripId), state);
}
@Post('items/:itemId/resolve')
diff --git a/server/src/nest/doc-sync/doc-sync.job.ts b/server/src/nest/doc-sync/doc-sync.job.ts
index 4dd24e3d5..3a81a9783 100644
--- a/server/src/nest/doc-sync/doc-sync.job.ts
+++ b/server/src/nest/doc-sync/doc-sync.job.ts
@@ -1,8 +1,10 @@
import { Injectable, type OnApplicationBootstrap } from '@nestjs/common';
+import { InjectRepository } from '@mikro-orm/nestjs';
import { logError, logInfo } from '../audit/audit-log.logger';
import { ADDON_IDS } from '../../addons';
import { AddonsService } from '../addons/addons.service';
-import { DatabaseService } from '../database/database.service';
+import { AppSettings } from '../../db/entities/AppSettings.entity';
+import type { AppSettingsRepository } from '../../db/repositories/AppSettings.repository';
import { CronRegistrarService } from '../scheduling/cron-registrar.service';
import { DocSyncConfigService } from './doc-sync-config.service';
import { DocSyncService } from './doc-sync.service';
@@ -40,7 +42,7 @@ import {
@Injectable()
export class DocSyncJob implements OnApplicationBootstrap {
constructor(
- private readonly db: DatabaseService,
+ @InjectRepository(AppSettings) private readonly appSettings: AppSettingsRepository,
private readonly sync: DocSyncService,
private readonly config: DocSyncConfigService,
private readonly addons: AddonsService,
@@ -52,9 +54,8 @@ export class DocSyncJob implements OnApplicationBootstrap {
async onApplicationBootstrap(): Promise {
if (!this.registrar.isEnabled()) return;
- // Through runOnBoot (task-6-review-parity.md C1: raw SQL today, but the
- // wrap belongs at the entrypoint so it stays safe if this dependency
- // graph goes repository-backed later).
+ // Through runOnBoot (task-6-review-parity.md C1: repository-backed as of
+ // Plan 3h Task 5 — the wrap stays at the entrypoint regardless).
await this.registrar.runOnBoot('docsync-boot', async () => {
logInfo(`Document sync: polling every ${await this.intervalSeconds()}s`);
});
@@ -79,7 +80,7 @@ export class DocSyncJob implements OnApplicationBootstrap {
}
private async intervalSeconds(): Promise {
- const raw = this.db.get<{ value: string }>('SELECT value FROM app_settings WHERE key = ?', SETTING_POLL_INTERVAL)?.value;
+ const raw = (await this.appSettings.getValue(SETTING_POLL_INTERVAL)) ?? undefined;
const parsed = Number.parseInt(raw || '', 10);
if (!Number.isFinite(parsed)) return DEFAULT_POLL_INTERVAL_SECONDS;
return Math.min(MAX_POLL_INTERVAL_SECONDS, Math.max(MIN_POLL_INTERVAL_SECONDS, parsed));
@@ -88,7 +89,7 @@ export class DocSyncJob implements OnApplicationBootstrap {
async tick(): Promise {
try {
if (!(await this.addons.isAddonEnabled(ADDON_IDS.DOCUMENTS))) return;
- const killSwitch = this.db.get<{ value: string }>('SELECT value FROM app_settings WHERE key = ?', SETTING_SYNC_ENABLED)?.value;
+ const killSwitch = (await this.appSettings.getValue(SETTING_SYNC_ENABLED)) ?? undefined;
// Unrecognised values mean ON here because the setting is absent by
// default; only an explicit 'false' stops the sync.
if (killSwitch === 'false') return;
diff --git a/server/src/nest/doc-sync/doc-sync.module.ts b/server/src/nest/doc-sync/doc-sync.module.ts
index dfeefcb00..74ec15a86 100644
--- a/server/src/nest/doc-sync/doc-sync.module.ts
+++ b/server/src/nest/doc-sync/doc-sync.module.ts
@@ -1,4 +1,5 @@
import { Module } from '@nestjs/common';
+import { MikroOrmModule } from '@mikro-orm/nestjs';
import { AddonsModule } from '../addons/addons.module';
import { AuthModule } from '../auth/auth.module';
import { McpSharedModule } from '../mcp-shared/mcp-shared.module';
@@ -9,6 +10,15 @@ import { PermissionsModule } from '../permissions/permissions.module';
import { RealtimeModule } from '../realtime/realtime.module';
import { SchedulingModule } from '../scheduling/scheduling.module';
import { StorageModule } from '../storage/storage.module';
+import { DocumentConnections } from '../../db/entities/DocumentConnections.entity';
+import { DocumentProviders } from '../../db/entities/DocumentProviders.entity';
+import { DocumentProviderFields } from '../../db/entities/DocumentProviderFields.entity';
+import { TripDocumentLinks } from '../../db/entities/TripDocumentLinks.entity';
+import { DocumentSyncItems } from '../../db/entities/DocumentSyncItems.entity';
+import { TripFiles } from '../../db/entities/TripFiles.entity';
+import { FileLinks } from '../../db/entities/FileLinks.entity';
+import { AppSettings } from '../../db/entities/AppSettings.entity';
+import { Trips } from '../../db/entities/Trips.entity';
import { DocSyncConfigService } from './doc-sync-config.service';
import { DocSyncController } from './doc-sync.controller';
import { DocSyncWebhookController } from './doc-sync-webhook.controller';
@@ -39,6 +49,16 @@ import { WebdavClient } from './providers/webdav.client';
* separate ids because the admin toggles, the credential fields and the scope
* concept differ (a folder with an `oc:fileid` versus a space with a
* `driveId`), but the protocol underneath is the same and so is the code.
+ *
+ * `MikroOrmModule.forFeature` registers every entity this domain's
+ * `@InjectRepository` constructors need (Plan 3h Task 5) — the five
+ * doc-sync-owned tables (`DocumentConnections`/`DocumentProviders`/
+ * `DocumentProviderFields`/`TripDocumentLinks`/`DocumentSyncItems`) plus the
+ * three it reads/writes additive methods on (`TripFiles`/`FileLinks`, 3e —
+ * DS4/DS15/DS16/DS19/DS29/DS32/DS36's cross-domain statements;
+ * `AppSettings`, 3a — the job's self-throttle and the webhook's kill
+ * switch) and `Trips` (3c — `DocSyncConfigService.assertCanManage`'s
+ * trip-owner check, R2).
*/
@Module({
imports: [
@@ -52,6 +72,17 @@ import { WebdavClient } from './providers/webdav.client';
AddonsModule,
AuthModule,
McpSharedModule,
+ MikroOrmModule.forFeature([
+ DocumentConnections,
+ DocumentProviders,
+ DocumentProviderFields,
+ TripDocumentLinks,
+ DocumentSyncItems,
+ TripFiles,
+ FileLinks,
+ AppSettings,
+ Trips,
+ ]),
],
controllers: [DocSyncController, DocSyncWebhookController],
providers: [
diff --git a/server/src/nest/doc-sync/doc-sync.service.ts b/server/src/nest/doc-sync/doc-sync.service.ts
index 57a76fe10..3aa1af8d5 100644
--- a/server/src/nest/doc-sync/doc-sync.service.ts
+++ b/server/src/nest/doc-sync/doc-sync.service.ts
@@ -1,4 +1,5 @@
import { Injectable, Logger } from '@nestjs/common';
+import { InjectRepository } from '@mikro-orm/nestjs';
import crypto from 'crypto';
import fs from 'fs';
import path from 'path';
@@ -6,7 +7,6 @@ import { pipeline } from 'node:stream/promises';
import { Transform } from 'node:stream';
import { Readable } from 'node:stream';
import type { DocsyncErrorCode } from '@trek/shared';
-import { DatabaseService } from '../database/database.service';
import { AddonsService } from '../addons/addons.service';
import { ADDON_IDS } from '../../addons';
import { StorageService } from '../storage/storage.service';
@@ -15,6 +15,16 @@ import { FilesService } from '../files/files.service';
import { AllowedFileTypesService } from '../files/allowed-file-types.service';
import { RealtimeService } from '../realtime/realtime.service';
import { MAX_FILE_SIZE, isVideoExtension } from '../files/files.constants';
+import { TripDocumentLinks } from '../../db/entities/TripDocumentLinks.entity';
+import type { TripDocumentLinksRepository } from '../../db/repositories/TripDocumentLinks.repository';
+import { DocumentSyncItems } from '../../db/entities/DocumentSyncItems.entity';
+import type { DocumentSyncIssueRow, DocumentSyncItemsRepository } from '../../db/repositories/DocumentSyncItems.repository';
+import { TripFiles } from '../../db/entities/TripFiles.entity';
+import type { TripFilesRepository } from '../../db/repositories/TripFiles.repository';
+import { FileLinks } from '../../db/entities/FileLinks.entity';
+import type { FileLinksRepository } from '../../db/repositories/FileLinks.repository';
+import { AppSettings } from '../../db/entities/AppSettings.entity';
+import type { AppSettingsRepository } from '../../db/repositories/AppSettings.repository';
import { DocumentProviderRegistry } from './document-provider.registry';
import { DocSyncConfigService, type ConnectionRow, type LinkRow } from './doc-sync-config.service';
import type { DocumentProvider, RemoteDocument } from './document-provider';
@@ -25,6 +35,7 @@ import {
LINK_BACKOFF_SECONDS,
LINK_CIRCUIT_OPEN_AFTER,
MAX_TRANSFERS_PER_RUN,
+ SETTING_SYNC_ENABLED,
} from './doc-sync.constants';
import {
backoffSeconds,
@@ -39,20 +50,6 @@ import {
type SyncItemState,
} from './doc-sync.helpers';
-/**
- * The SET clause for a pairing whose copy is listed again: `touch`, `relocate`
- * and both renames.
- *
- * Only `touch` lifted the missing flag, so a copy found again under a new name
- * (a rename, or a move where ids are paths) stayed on the issues list for one
- * more run. `error` is left alone on purpose: that row either never got its
- * bytes or holds older ones than the provider, and calling it synced would hide
- * it for good. So is a row without a file, which is a deletion the planner
- * turns into `local_deleted`.
- */
-const FOUND_AGAIN = `remote_missing_at = NULL,
- state = CASE WHEN state = 'remote_missing' AND file_id IS NOT NULL THEN 'synced' ELSE state END`;
-
/**
* The reconciler: it executes what `planReconcile` decided, and does nothing
* else of consequence.
@@ -73,6 +70,13 @@ const FOUND_AGAIN = `remote_missing_at = NULL,
* 2. TREK's own writes must not come back as foreign changes. That is what
* `pushed_sha256` is for, and why the comparison is over content rather
* than over timestamps.
+ *
+ * Plan 3h Task 5: every `this.db.connection.prepare(...)` call converted onto
+ * `TripDocumentLinksRepository`/`DocumentSyncItemsRepository` (own tables) and
+ * additive methods on `TripFilesRepository`/`FileLinksRepository` (3e,
+ * cross-domain — DS4/DS15/DS16/DS19/DS29/DS32/DS36). `FOUND_AGAIN`'s CASE
+ * expression is now `foundAgainState` (`src/db/dialect/sql-functions.ts`),
+ * built once and consumed by the repository methods behind DS2/DS3/DS5/DS12.
*/
@Injectable()
export class DocSyncService {
@@ -81,7 +85,11 @@ export class DocSyncService {
private readonly running = new Set();
constructor(
- private readonly db: DatabaseService,
+ @InjectRepository(TripDocumentLinks) private readonly linksRepo: TripDocumentLinksRepository,
+ @InjectRepository(DocumentSyncItems) private readonly items: DocumentSyncItemsRepository,
+ @InjectRepository(TripFiles) private readonly tripFiles: TripFilesRepository,
+ @InjectRepository(FileLinks) private readonly fileLinks: FileLinksRepository,
+ @InjectRepository(AppSettings) private readonly appSettings: AppSettingsRepository,
private readonly config: DocSyncConfigService,
private readonly registry: DocumentProviderRegistry,
private readonly storage: StorageService,
@@ -108,22 +116,16 @@ export class DocSyncService {
* 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.
+ *
+ * DS1's 3-key ORDER BY (`next_attempt_at`, then `last_sync_at`, then `id`)
+ * is production-proven — preserved byte-exact in
+ * `TripDocumentLinksRepository.dueLinks`. The provider-enabled subquery is
+ * resolved here first (`config.enabledProviderIds`), then handed to the
+ * repository as a plain id list — see that method's own docstring for why.
*/
async dueLinks(limit = 20): Promise {
- return this.db.connection
- .prepare(
- `SELECT * FROM trip_document_links
- WHERE sync_enabled = 1
- AND last_sync_state != 'orphaned'
- 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
- LIMIT ?`,
- )
- .all(LINK_CIRCUIT_OPEN_AFTER, limit) as LinkRow[];
+ const enabledProviderIds = await this.config.enabledProviderIds();
+ return this.linksRepo.dueLinks(LINK_CIRCUIT_OPEN_AFTER, enabledProviderIds, limit);
}
/**
@@ -140,6 +142,18 @@ export class DocSyncService {
return !(await this.config.enabledProviderIds()).includes(link.provider_id);
}
+ /**
+ * DSWH1 (R2 — moved off `DocSyncWebhookController`) — the instance-wide
+ * kill switch (`SELECT value FROM app_settings WHERE key = ?`,
+ * `SETTING_SYNC_ENABLED`), the same switch the job's own tick obeys.
+ * Unrecognised values mean ON: the setting is absent by default, and only
+ * an explicit `'false'` stops the sync.
+ */
+ async isSyncEnabled(): Promise {
+ const value = await this.appSettings.getValue(SETTING_SYNC_ENABLED);
+ return value !== 'false';
+ }
+
/**
* Run one link.
*
@@ -308,54 +322,35 @@ export class DocSyncService {
// keeps the agreed name; the rename, if there was one, is the planner's
// next action for this row. That may be a rename rather than a `touch`,
// so a copy on record as missing is lifted here already.
- this.db.connection
- .prepare(
- `UPDATE document_sync_items
- SET remote_id = ?, remote_version = ?,
- remote_size = COALESCE(?, remote_size), remote_modified_at = COALESCE(?, remote_modified_at),
- ${FOUND_AGAIN}, last_seen_at = CURRENT_TIMESTAMP
- WHERE id = ?`,
- )
- .run(action.remote.remoteId, action.remote.remoteVersion, action.remote.size, action.remote.remoteModifiedAt, action.itemId);
+ await this.items.applyRelocate(action.itemId, {
+ remote_id: action.remote.remoteId,
+ remote_version: action.remote.remoteVersion,
+ remote_size: action.remote.size,
+ remote_modified_at: action.remote.remoteModifiedAt,
+ });
return 'ok';
}
case 'rename_remote': {
const res = await ctx.provider.rename(ctx.ref, ctx.scope, action.remoteId, action.name);
if (docFailed(res)) return res.error.code;
- this.db.connection
- .prepare(
- `UPDATE document_sync_items
- SET remote_name = ?, remote_version = ?, ${FOUND_AGAIN}, last_seen_at = CURRENT_TIMESTAMP
- WHERE id = ?`,
- )
- .run(action.name, res.data.remoteVersion, action.itemId);
+ await this.items.applyRenameRemote(action.itemId, { remote_name: action.name, remote_version: res.data.remoteVersion });
return 'ok';
}
case 'rename_local': {
- this.db.connection.prepare('UPDATE trip_files SET original_name = ? WHERE id = ?').run(action.name, action.fileId);
+ await this.tripFiles.renameOriginalName(action.fileId, action.name);
this.realtime.broadcast(ctx.link.trip_id, 'file:updated', {
file: await this.files.getFileById(action.fileId, ctx.link.trip_id),
});
- this.db.connection
- .prepare(`UPDATE document_sync_items SET remote_name = ?, ${FOUND_AGAIN}, last_seen_at = CURRENT_TIMESTAMP WHERE id = ?`)
- .run(action.name, action.itemId);
+ await this.items.applyRenameLocal(action.itemId, { remote_name: action.name });
return 'ok';
}
case 'conflict': {
- this.db.connection
- .prepare("UPDATE document_sync_items SET state = 'conflict', last_seen_at = CURRENT_TIMESTAMP WHERE id = ?")
- .run(action.itemId);
+ await this.items.markConflict(action.itemId);
return 'conflict';
}
case 'mark_remote_missing': {
// Recorded, not executed. A human decides whether the local copy goes.
- this.db.connection
- .prepare(
- `UPDATE document_sync_items
- SET state = 'remote_missing', remote_missing_at = CURRENT_TIMESTAMP, last_seen_at = CURRENT_TIMESTAMP
- WHERE id = ?`,
- )
- .run(action.itemId);
+ await this.items.markRemoteMissing(action.itemId);
return 'ok';
}
case 'local_deleted': {
@@ -373,30 +368,14 @@ export class DocSyncService {
// restore in TREK later has to know whether the gap upstream is TREK's.
// A copy on record as gone stays on record: the holdings count reads
// the mark, and the copy is no more at the store than it was before.
- this.db.connection
- .prepare(
- `UPDATE document_sync_items
- SET state = 'local_deleted',
- remote_missing_at = CASE WHEN ? = 1 THEN remote_missing_at ELSE NULL END,
- remote_trashed_at = CASE WHEN ? = 1 THEN CURRENT_TIMESTAMP ELSE NULL END,
- last_seen_at = CURRENT_TIMESTAMP
- WHERE id = ?`,
- )
- .run(remoteGone ? 1 : 0, binned ? 1 : 0, action.itemId);
+ await this.items.markLocalDeleted(action.itemId, { remoteGone, binned });
return 'ok';
}
case 'local_restored': {
// A copy found gone is flagged the way `mark_remote_missing` flags one,
// and recovers the same way: a later `touch` lifts it once it is back.
const missing = action.missing === true;
- this.db.connection
- .prepare(
- `UPDATE document_sync_items
- SET state = ?, remote_missing_at = CASE WHEN ? = 1 THEN CURRENT_TIMESTAMP ELSE NULL END,
- remote_trashed_at = NULL, last_seen_at = CURRENT_TIMESTAMP
- WHERE id = ?`,
- )
- .run(missing ? 'remote_missing' : 'synced', missing ? 1 : 0, action.itemId);
+ await this.items.markLocalRestored(action.itemId, { state: missing ? 'remote_missing' : 'synced', missing });
return 'ok';
}
case 'detach': {
@@ -404,20 +383,11 @@ export class DocSyncService {
// provider copy is new. The push the planner queued after this fills
// the pairing in again, and if that push fails the row is an ordinary
// unpaired file that the next run retries.
- this.db.connection
- .prepare(
- `UPDATE document_sync_items
- SET state = 'pending', remote_id = NULL, remote_version = NULL, remote_trashed_at = NULL,
- error_code = NULL, last_seen_at = CURRENT_TIMESTAMP
- WHERE id = ?`,
- )
- .run(action.itemId);
+ await this.items.markDetached(action.itemId);
return 'ok';
}
case 'remote_restored': {
- this.db.connection
- .prepare('UPDATE document_sync_items SET remote_trashed_at = NULL, last_seen_at = CURRENT_TIMESTAMP WHERE id = ?')
- .run(action.itemId);
+ await this.items.markRemoteRestored(action.itemId);
return 'ok';
}
case 'touch': {
@@ -428,22 +398,13 @@ export class DocSyncService {
// Size and modification time are kept current as well: where the id is
// the path, they are how the planner recognises this copy once it has
// been renamed or moved.
- this.db.connection
- .prepare(
- `UPDATE document_sync_items
- SET last_seen_at = CURRENT_TIMESTAMP,
- remote_version = COALESCE(?, remote_version),
- remote_name = COALESCE(?, remote_name),
- remote_id = COALESCE(?, remote_id),
- remote_size = COALESCE(?, remote_size),
- remote_modified_at = COALESCE(?, remote_modified_at),
- ${FOUND_AGAIN}
- WHERE id = ?`,
- )
- .run(
- action.remote?.remoteVersion ?? null, action.remote?.name ?? null, action.remote?.remoteId ?? null,
- action.remote?.size ?? null, action.remote?.remoteModifiedAt ?? null, action.itemId,
- );
+ await this.items.applyTouch(action.itemId, {
+ remote_version: action.remote?.remoteVersion ?? null,
+ remote_name: action.remote?.name ?? null,
+ remote_id: action.remote?.remoteId ?? null,
+ remote_size: action.remote?.size ?? null,
+ remote_modified_at: action.remote?.remoteModifiedAt ?? null,
+ });
return 'ok';
}
default:
@@ -514,11 +475,7 @@ export class DocSyncService {
// What this row pointed at before, if anything: a pull_update replaces a
// document TREK already holds, and `createFile` only ever inserts.
- const pairing = itemId === null
- ? undefined
- : (this.db.connection
- .prepare('SELECT file_id, remote_name, content_sha256 FROM document_sync_items WHERE id = ?')
- .get(itemId) as { file_id: number | null; remote_name: string | null; content_sha256: string | null } | undefined);
+ const pairing = itemId === null ? undefined : await this.items.findPairing(itemId);
const supersededId = pairing?.file_id ?? null;
const superseded = supersededId === null ? undefined : await this.files.getFileById(supersededId, ctx.link.trip_id);
@@ -578,13 +535,8 @@ export class DocSyncService {
const gone = supersededId !== null && Number(supersededId) !== Number(file.id);
const starred = gone && !!superseded?.starred;
if (gone) {
- if (starred) this.db.connection.prepare('UPDATE trip_files SET starred = 1 WHERE id = ?').run(file.id);
- this.db.connection
- .prepare(
- `INSERT OR IGNORE INTO file_links (file_id, reservation_id, assignment_id, place_id, budget_item_id)
- SELECT ?, reservation_id, assignment_id, place_id, budget_item_id FROM file_links WHERE file_id = ?`,
- )
- .run(file.id, supersededId);
+ if (starred) await this.tripFiles.setStarred(Number(file.id), 1);
+ await this.fileLinks.copySelectIgnore(Number(file.id), supersededId as number);
await this.files.softDeleteFile(supersededId as number);
}
await this.upsertItem(ctx.link, {
@@ -719,41 +671,29 @@ export class DocSyncService {
* this: automatic retries are exactly what the limit exists to stop.
*/
async retryShelvedItems(linkId: number): Promise {
- this.db.connection
- .prepare(
- `UPDATE document_sync_items
- SET attempts = 0, next_attempt_at = NULL
- WHERE link_id = ? AND state = 'error'`,
- )
- .run(linkId);
+ await this.items.resetShelvedForLink(linkId);
}
// ── State loading and writing ──────────────────────────────────────────────
private async loadItems(linkId: number): Promise {
- const rows = this.db.connection
- .prepare(
- `SELECT id, file_id, trek_doc_uid, remote_id, remote_version, remote_name, remote_size, remote_modified_at,
- content_sha256, pushed_sha256, state, attempts, next_attempt_at, remote_missing_at, remote_trashed_at
- FROM document_sync_items WHERE link_id = ?`,
- )
- .all(linkId) as Array>;
+ const rows = await this.items.findByLink(linkId);
return rows.map((r) => ({
- id: Number(r.id),
- fileId: r.file_id === null ? null : Number(r.file_id),
- trekDocUid: String(r.trek_doc_uid),
- remoteId: r.remote_id === null ? null : String(r.remote_id),
- remoteVersion: r.remote_version === null ? null : String(r.remote_version),
- remoteName: r.remote_name === null || r.remote_name === undefined ? null : String(r.remote_name),
- remoteSize: r.remote_size === null ? null : Number(r.remote_size),
- remoteModifiedAt: r.remote_modified_at === null ? null : String(r.remote_modified_at),
- contentSha256: r.content_sha256 === null ? null : String(r.content_sha256),
- pushedSha256: r.pushed_sha256 === null ? null : String(r.pushed_sha256),
- state: String(r.state),
- attempts: Number(r.attempts ?? 0),
- nextAttemptAt: r.next_attempt_at === null || r.next_attempt_at === undefined ? null : String(r.next_attempt_at),
- remoteMissingAt: r.remote_missing_at === null ? null : String(r.remote_missing_at),
- remoteTrashedAt: r.remote_trashed_at === null ? null : String(r.remote_trashed_at),
+ id: r.id,
+ fileId: r.file_id,
+ trekDocUid: r.trek_doc_uid,
+ remoteId: r.remote_id,
+ remoteVersion: r.remote_version,
+ remoteName: r.remote_name,
+ remoteSize: r.remote_size,
+ remoteModifiedAt: r.remote_modified_at,
+ contentSha256: r.content_sha256,
+ pushedSha256: r.pushed_sha256,
+ state: r.state,
+ attempts: r.attempts,
+ nextAttemptAt: r.next_attempt_at,
+ remoteMissingAt: r.remote_missing_at,
+ remoteTrashedAt: r.remote_trashed_at,
}));
}
@@ -772,18 +712,12 @@ export class DocSyncService {
* would need a per-document assignment that nothing in the UI offers yet.
*/
private async loadLocalDocuments(link: LinkRow): Promise {
- const rows = this.db.connection
- .prepare(
- `SELECT f.id, f.original_name, f.file_size, f.mime_type, f.deleted_at
- FROM trip_files f
- WHERE f.trip_id = ? AND f.message_id IS NULL AND f.note_id IS NULL`,
- )
- .all(link.trip_id) as Array>;
+ const rows = await this.tripFiles.listSyncableForTrip(link.trip_id);
return rows.map((r) => ({
- fileId: Number(r.id),
- name: String(r.original_name),
- size: Number(r.file_size ?? 0),
- mimeType: r.mime_type === null ? null : String(r.mime_type),
+ fileId: r.id,
+ name: r.original_name,
+ size: r.file_size ?? 0,
+ mimeType: r.mime_type,
// Deliberately null, and not the agreed hash out of document_sync_items:
// reading that column here would compare it against itself, and
// `localChanged` in the planner could then never be true. TREK has no way
@@ -792,25 +726,20 @@ export class DocSyncService {
// than admitting it. The day the file manager grows a replace, this needs
// a real hash column on trip_files, filled at upload time.
sha256: null,
- deletedAt: r.deleted_at === null ? null : String(r.deleted_at),
+ deletedAt: r.deleted_at,
}));
}
/** The item already paired with this remote document, if it is a different one. */
private async pairingOwner(linkId: number, remoteId: string, itemId: number | null): Promise {
- const row = this.db.connection
- .prepare('SELECT id FROM document_sync_items WHERE link_id = ? AND remote_id = ?')
- .get(linkId, remoteId) as { id?: number } | undefined;
- if (row?.id === undefined) return null;
- return itemId !== null && Number(row.id) === Number(itemId) ? null : Number(row.id);
+ const found = await this.items.findPairingOwner(linkId, remoteId);
+ if (found === undefined) return null;
+ return itemId !== null && Number(found) === Number(itemId) ? null : Number(found);
}
private async existingUid(linkId: number, itemId: number | null): Promise {
if (itemId === null) return null;
- const row = this.db.connection
- .prepare('SELECT trek_doc_uid FROM document_sync_items WHERE id = ? AND link_id = ?')
- .get(itemId, linkId) as { trek_doc_uid?: string } | undefined;
- return row?.trek_doc_uid ?? null;
+ return (await this.items.findTrekDocUid(itemId, linkId)) ?? null;
}
private async hashStoredFile(storageKey: string): Promise {
@@ -875,43 +804,29 @@ export class DocSyncService {
* pass now clears it, which is what makes the limit mean "six failures in
* a row" rather than "six failures ever".
*/
- const before = (this.db.connection
- .prepare('SELECT attempts FROM document_sync_items WHERE id = ?')
- .get(patch.itemId) as { attempts: number } | undefined)?.attempts ?? 0;
+ const before = (await this.items.getAttempts(patch.itemId)) ?? 0;
const attempts = failed ? before + 1 : 0;
+ // A row that has used up its attempts stops retrying and waits for a
+ // person; planReconcile skips it and a manual run clears the counter
+ // (see retryShelvedItems). The repository turns this second count into
+ // the DB-clock `datetime('now', ...)` fragment itself (recordAttempt's
+ // own docstring).
+ const nextAttemptAfterSeconds = failed && attempts < ITEM_MAX_ATTEMPTS ? backoffSeconds(ITEM_BACKOFF_SECONDS, attempts) : null;
- this.db.connection
- .prepare(
- `UPDATE document_sync_items
- SET state = ?, error_code = ?, file_id = COALESCE(?, file_id),
- remote_id = COALESCE(?, remote_id), remote_version = COALESCE(?, remote_version),
- remote_name = COALESCE(?, remote_name), remote_size = COALESCE(?, remote_size),
- remote_modified_at = COALESCE(?, remote_modified_at),
- content_sha256 = COALESCE(?, content_sha256),
- pushed_sha256 = COALESCE(?, pushed_sha256),
- attempts = ?,
- -- A row that has used up its attempts stops retrying and waits
- -- for a person; planReconcile skips it and a manual run clears
- -- the counter (see retryShelvedItems).
- next_attempt_at = CASE
- WHEN ? = 1 AND ? < ? THEN datetime('now', '+' || ? || ' seconds')
- ELSE NULL
- END,
- remote_missing_at = NULL,
- synced_at = CASE WHEN ? = 'synced' THEN CURRENT_TIMESTAMP ELSE synced_at END,
- last_seen_at = CURRENT_TIMESTAMP
- WHERE id = ?`,
- )
- .run(
- patch.state, patch.errorCode ?? null, patch.fileId ?? null,
- remoteId, remoteVersion,
- remoteName, remoteSize, remoteModifiedAt,
- patch.contentSha256 ?? null, patch.pushedSha256 ?? null,
- attempts,
- failed ? 1 : 0, attempts, ITEM_MAX_ATTEMPTS,
- backoffSeconds(ITEM_BACKOFF_SECONDS, attempts), patch.state,
- patch.itemId,
- );
+ await this.items.recordAttempt(patch.itemId, {
+ state: patch.state,
+ error_code: patch.errorCode ?? null,
+ file_id: patch.fileId ?? null,
+ remote_id: remoteId,
+ remote_version: remoteVersion,
+ remote_name: remoteName,
+ remote_size: remoteSize,
+ remote_modified_at: remoteModifiedAt,
+ content_sha256: patch.contentSha256 ?? null,
+ pushed_sha256: patch.pushedSha256 ?? null,
+ attempts,
+ next_attempt_after_seconds: nextAttemptAfterSeconds,
+ });
return;
}
@@ -920,76 +835,44 @@ export class DocSyncService {
// without one), and SQLite only matches an ON CONFLICT target to a partial
// index when the predicate is restated. Without it every insert fails with
// "ON CONFLICT clause does not match any PRIMARY KEY or UNIQUE constraint".
- this.db.connection
- .prepare(
- `INSERT INTO document_sync_items
- (link_id, trip_id, file_id, trek_doc_uid, remote_id, remote_name, remote_version, remote_size,
- remote_modified_at, content_sha256, pushed_sha256, state, error_code, attempts, next_attempt_at, synced_at)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
- CASE WHEN ? = 1 THEN datetime('now', '+' || ? || ' seconds') ELSE NULL END,
- CASE WHEN ? = 'synced' THEN CURRENT_TIMESTAMP ELSE NULL END)
- ON CONFLICT(link_id, remote_id) WHERE remote_id IS NOT NULL DO UPDATE SET
- state = excluded.state, error_code = excluded.error_code,
- file_id = COALESCE(excluded.file_id, document_sync_items.file_id),
- remote_version = COALESCE(excluded.remote_version, document_sync_items.remote_version),
- content_sha256 = COALESCE(excluded.content_sha256, document_sync_items.content_sha256),
- pushed_sha256 = COALESCE(excluded.pushed_sha256, document_sync_items.pushed_sha256),
- last_seen_at = CURRENT_TIMESTAMP`,
- )
- .run(
- link.id, link.trip_id, patch.fileId ?? null, patch.trekDocUid ?? newTrekDocUid(),
- remoteId, remoteName, remoteVersion, remoteSize,
- remoteModifiedAt, patch.contentSha256 ?? null, patch.pushedSha256 ?? null,
- patch.state, patch.errorCode ?? null, failed ? 1 : 0,
- failed ? 1 : 0, backoffSeconds(ITEM_BACKOFF_SECONDS, 1), // a new row is always at its first failure
- patch.state,
- );
+ await this.items.insertOrUpsertOnConflict({
+ link_id: link.id,
+ trip_id: link.trip_id,
+ file_id: patch.fileId ?? null,
+ trek_doc_uid: patch.trekDocUid ?? newTrekDocUid(),
+ remote_id: remoteId,
+ remote_name: remoteName,
+ remote_version: remoteVersion,
+ remote_size: remoteSize,
+ remote_modified_at: remoteModifiedAt,
+ content_sha256: patch.contentSha256 ?? null,
+ pushed_sha256: patch.pushedSha256 ?? null,
+ state: patch.state,
+ error_code: patch.errorCode ?? null,
+ attempts: failed ? 1 : 0,
+ // A new row is always at its first failure.
+ next_attempt_after_seconds: failed ? backoffSeconds(ITEM_BACKOFF_SECONDS, 1) : null,
+ synced_now: patch.state === 'synced',
+ });
}
private async recordLinkSuccess(link: LinkRow, cursor: string | null, state: string, errorCode: string | null): Promise {
- this.db.connection
- .prepare(
- `UPDATE trip_document_links
- SET remote_cursor = ?, last_sync_at = CURRENT_TIMESTAMP, last_sync_state = ?,
- last_sync_error = ?, failure_count = 0, next_attempt_at = NULL, updated_at = CURRENT_TIMESTAMP
- WHERE id = ?`,
- )
- .run(cursor, state, errorCode, link.id);
+ await this.linksRepo.recordSuccess(link.id, { remote_cursor: cursor, state, error_code: errorCode });
}
private async recordLinkFailure(link: LinkRow, code: string, state = 'failed'): Promise {
const failures = link.failure_count + 1;
const wait = backoffSeconds(LINK_BACKOFF_SECONDS, failures);
- this.db.connection
- .prepare(
- `UPDATE trip_document_links
- SET last_sync_at = CURRENT_TIMESTAMP, last_sync_state = ?, last_sync_error = ?,
- failure_count = ?, next_attempt_at = datetime('now', '+' || ? || ' seconds'),
- updated_at = CURRENT_TIMESTAMP
- WHERE id = ?`,
- )
- .run(state, code, failures, wait, link.id);
+ await this.linksRepo.recordFailure(link.id, {
+ state,
+ error_code: code,
+ failure_count: failures,
+ next_attempt_seconds: wait,
+ });
}
// ── Conflict resolution ────────────────────────────────────────────────────
- /**
- * Resolve a conflict the way a human chose.
- *
- * `both` keeps the provider copy as a second TREK document rather than
- * overwriting either side, which is the only outcome that cannot lose work
- * and is therefore what the UI offers first.
- */
- /**
- * Decide a conflict.
- *
- * `tripId` is not decoration: the route authorises the caller against the trip
- * in its URL, and without checking the row against the same trip an owner of
- * any trip could resolve a conflict in somebody else's: the id is a plain
- * integer and nothing else tied the two together. Same rule as everywhere in
- * this codebase: every referenced id must exist AND belong to the same trip.
- */
-
/**
* Rename the provider's copy, outside a run.
*
@@ -1006,18 +889,29 @@ export class DocSyncService {
this.logger.warn(`link ${link.id}: renaming ${remoteId} to "${name}" failed: ${res.error.code}`);
return;
}
- this.db.connection
- .prepare('UPDATE document_sync_items SET remote_name = ?, remote_version = ?, last_seen_at = CURRENT_TIMESTAMP WHERE id = ?')
- .run(name, res.data.remoteVersion, itemId);
+ await this.items.setRemoteName(itemId, { remote_name: name, remote_version: res.data.remoteVersion });
}
+ /**
+ * Decide a conflict.
+ *
+ * `tripId` is not decoration: the route authorises the caller against the trip
+ * in its URL, and without checking the row against the same trip an owner of
+ * any trip could resolve a conflict in somebody else's: the id is a plain
+ * integer and nothing else tied the two together. Same rule as everywhere in
+ * this codebase: every referenced id must exist AND belong to the same trip.
+ * This check stays a JS-level comparison AFTER the read — never folded into
+ * the SQL WHERE clause — per the inventory's explicit callout.
+ *
+ * `both` keeps the provider copy as a second TREK document rather than
+ * overwriting either side, which is the only outcome that cannot lose work
+ * and is therefore what the UI offers first.
+ */
async resolveConflict(itemId: number, keep: 'trek' | 'provider' | 'both', tripId?: number): Promise {
- const item = this.db.connection
- .prepare('SELECT * FROM document_sync_items WHERE id = ?')
- .get(itemId) as Record | undefined;
+ const item = await this.items.findRaw(itemId);
if (!item || item.state !== 'conflict') return false;
if (tripId !== undefined && Number(item.trip_id) !== Number(tripId)) return false;
- const link = await this.config.getLink(Number(item.link_id));
+ const link = await this.config.getLink(item.link_id);
if (!link) return false;
/**
@@ -1035,48 +929,36 @@ export class DocSyncService {
* that renamed, and gets renamed back), keeping the provider's takes the
* local rename back (TREK then reads as unchanged, and follows).
*/
- const localName = item.file_id === null
- ? null
- : (this.db.connection
- .prepare('SELECT original_name FROM trip_files WHERE id = ?')
- .get(item.file_id) as { original_name: string } | undefined)?.original_name ?? null;
+ const localName = item.file_id === null ? null : ((await this.tripFiles.findOriginalName(item.file_id)) ?? null);
if (keep === 'trek') {
// Forget the provider's version marker, so the next run sees no upstream
// change and pushes TREK's copy. Clearing content_sha256 instead would do
// the opposite: it reads as "TREK never agreed to these bytes", and the
// provider's copy comes down over the one the user just chose to keep.
- this.db.connection
- .prepare("UPDATE document_sync_items SET state = 'pending', remote_version = NULL, error_code = NULL WHERE id = ?")
- .run(itemId);
+ await this.items.resolveKeepTrek(itemId);
// Carry the name over as well, here rather than by moving the arbiter:
// TREK winning means the provider's copy takes TREK's name, and the only
// way to say that through `remote_name` would be to write the provider's
// CURRENT name into it, which this method does not know without asking.
if (localName && item.remote_id && localName !== item.remote_name) {
- await this.renameRemoteTo(link, String(item.remote_id), localName, itemId);
+ await this.renameRemoteTo(link, item.remote_id, localName, itemId);
}
} else if (keep === 'provider') {
- this.db.connection
- .prepare("UPDATE document_sync_items SET state = 'pending', content_sha256 = NULL, error_code = NULL WHERE id = ?")
- .run(itemId);
+ await this.items.resolveKeepProvider(itemId);
// Take the local rename back to the agreed name. Only the provider's
// rename is then left standing, and the next run follows it the ordinary
// way, through the planner, with no special case in it. Cleaned the way
// a download cleans it: the planner compares cleaned names, so a raw one
// with a slash in it would read as TREK renaming the file all over again.
- const agreedName = item.remote_name ? sanitizeIncomingName(String(item.remote_name)) : null;
+ const agreedName = item.remote_name ? sanitizeIncomingName(item.remote_name) : null;
if (item.file_id !== null && agreedName && localName !== agreedName) {
- this.db.connection
- .prepare('UPDATE trip_files SET original_name = ? WHERE id = ?')
- .run(agreedName, item.file_id);
+ await this.tripFiles.renameOriginalName(item.file_id, agreedName);
}
} else {
// Detach the pairing and let the next run pull the provider copy as a new
// document. Both versions survive, under two rows.
- this.db.connection
- .prepare("UPDATE document_sync_items SET state = 'local_deleted', remote_id = NULL, error_code = NULL WHERE id = ?")
- .run(itemId);
+ await this.items.resolveKeepBoth(itemId);
}
await this.syncLink(link, { full: true });
return true;
@@ -1087,26 +969,25 @@ export class DocSyncService {
* that vanished upstream. Deliberately not "everything not synced": a row
* waiting for its turn is not a problem anyone should be shown.
*/
- async issues(tripId: number): Promise>> {
- return this.db.connection
- .prepare(
- `SELECT i.id, i.state, i.error_code, i.remote_name, i.remote_missing_at, f.original_name AS file_name
- FROM document_sync_items i
- LEFT JOIN trip_files f ON f.id = i.file_id
- WHERE i.trip_id = ?
- AND i.state IN ('conflict', 'rejected_type', 'too_large', 'remote_missing', 'error')
- ORDER BY i.id DESC
- LIMIT 200`,
- )
- .all(tripId) as Array>;
+ async issues(tripId: number): Promise {
+ return this.items.listIssues(tripId);
+ }
+
+ /**
+ * DSCTRL4/DSCTRL5 (R2 — moved off the controller, `DocSyncController.items`)
+ * — the trip's sync items, state-filtered or not. Two distinct `LIMIT 500`
+ * queries (a 3rd near-duplicate listing shape alongside {@link issues}'s
+ * own `LIMIT 200` fixed-state-list) stay two repository methods, per R2's
+ * own instruction not to unify near-duplicate reads with different shapes.
+ */
+ async itemsForTrip(tripId: number, state?: string) {
+ return state === undefined ? this.items.listForTrip(tripId) : this.items.listForTripByState(tripId, state);
}
/** Per-trip view for the UI: what is synced, what needs attention. */
async status(tripId: number): Promise> {
const links = await this.config.listLinks(tripId);
- const counts = this.db.connection
- .prepare('SELECT state, COUNT(*) AS n FROM document_sync_items WHERE trip_id = ? GROUP BY state')
- .all(tripId) as Array<{ state: string; n: number }>;
+ const counts = await this.items.countByState(tripId);
/**
* What each side is actually holding, per binding.
@@ -1116,28 +997,8 @@ export class DocSyncService {
* are the standing numbers instead: how many documents this trip has, how
* many of them the store has, and how many are only on one side.
*/
- const totalHere = this.db.connection
- .prepare(
- `SELECT COUNT(*) AS n FROM trip_files
- WHERE trip_id = ? AND deleted_at IS NULL AND message_id IS NULL AND note_id IS NULL`,
- )
- .get(tripId) as { n: number };
-
- const perLink = this.db.connection
- .prepare(
- `SELECT link_id,
- SUM(CASE WHEN file_id IS NOT NULL AND remote_id IS NOT NULL AND state = 'synced' THEN 1 ELSE 0 END) AS paired,
- -- A deletion closed over a copy already gone keeps its missing mark,
- -- and that copy is at the store no more than a binned one is.
- SUM(CASE WHEN remote_id IS NOT NULL AND state != 'remote_missing' AND remote_trashed_at IS NULL
- AND (state != 'local_deleted' OR remote_missing_at IS NULL)
- THEN 1 ELSE 0 END) AS atProvider,
- SUM(CASE WHEN state = 'remote_missing' THEN 1 ELSE 0 END) AS missing
- FROM document_sync_items
- WHERE trip_id = ?
- GROUP BY link_id`,
- )
- .all(tripId) as Array<{ link_id: number; paired: number; atProvider: number; missing: number }>;
+ const totalHere = await this.tripFiles.countActiveDocuments(tripId);
+ const perLink = await this.items.perLinkHoldings(tripId);
const byLink = new Map(perLink.map((r) => [r.link_id, r]));
// `map` cannot await the provider-off read, so the projection runs as an
@@ -1151,7 +1012,7 @@ export class DocSyncService {
// without the binding's own state being touched.
providerOff: await this.isSwitchedOff(l),
holdings: {
- inTrek: totalHere.n,
+ inTrek: totalHere,
atProvider: Number(row?.atProvider ?? 0),
paired: Number(row?.paired ?? 0),
missing: Number(row?.missing ?? 0),
diff --git a/server/tests/helpers/doc-sync-repos.ts b/server/tests/helpers/doc-sync-repos.ts
new file mode 100644
index 000000000..4594ba06c
--- /dev/null
+++ b/server/tests/helpers/doc-sync-repos.ts
@@ -0,0 +1,105 @@
+/**
+ * Plan 3h Task 5 (doc-sync) — the repositories a hand-constructed
+ * `DocSyncConfigService`/`DocSyncService`/`DocSyncController`/
+ * `DocSyncWebhookController`/`DocSyncJob` needs, bound to a suite's own
+ * better-sqlite3 handle.
+ *
+ * A separate file from `tests/helpers/test-uow.ts` on purpose: that file is
+ * a shared-window append target for THREE concurrent Plan 3h tasks
+ * (collections, dawarich, doc-sync — the plan's own "shared-file windows"
+ * note), and this task's brief names this file specifically so its own
+ * repository helpers land without racing another task's append to the same
+ * file. Same memoisation-per-handle pattern as every helper in
+ * `test-uow.ts` (one `sharedTestOrm(db)` per test file's better-sqlite3
+ * handle, imported from there rather than opening a second `MikroORM.init`
+ * over the same connection — `test-uow.ts`'s own `sharedTestOrm` docstring
+ * explains why a second ORM instance is a real, if subtle, bug: two
+ * identity maps and two Kysely clients, neither closed).
+ */
+import type Database from 'better-sqlite3';
+import { sharedTestOrm } from './test-uow';
+import { DocumentConnections } from '../../src/db/entities/DocumentConnections.entity';
+import type { DocumentConnectionsRepository } from '../../src/db/repositories/DocumentConnections.repository';
+import { DocumentProviders } from '../../src/db/entities/DocumentProviders.entity';
+import type { DocumentProvidersRepository } from '../../src/db/repositories/DocumentProviders.repository';
+import { DocumentProviderFields } from '../../src/db/entities/DocumentProviderFields.entity';
+import type { DocumentProviderFieldsRepository } from '../../src/db/repositories/DocumentProviderFields.repository';
+import { TripDocumentLinks } from '../../src/db/entities/TripDocumentLinks.entity';
+import type { TripDocumentLinksRepository } from '../../src/db/repositories/TripDocumentLinks.repository';
+import { DocumentSyncItems } from '../../src/db/entities/DocumentSyncItems.entity';
+import type { DocumentSyncItemsRepository } from '../../src/db/repositories/DocumentSyncItems.repository';
+import { TripFiles } from '../../src/db/entities/TripFiles.entity';
+import type { TripFilesRepository } from '../../src/db/repositories/TripFiles.repository';
+import { FileLinks } from '../../src/db/entities/FileLinks.entity';
+import type { FileLinksRepository } from '../../src/db/repositories/FileLinks.repository';
+
+const documentConnectionsPerHandle = new WeakMap>();
+const documentProvidersPerHandle = new WeakMap>();
+const documentProviderFieldsPerHandle = new WeakMap>();
+const tripDocumentLinksPerHandle = new WeakMap>();
+const documentSyncItemsPerHandle = new WeakMap>();
+const tripFilesPerHandle = new WeakMap>();
+const fileLinksPerHandle = new WeakMap>();
+
+/** The `DocumentConnectionsRepository` a hand-constructed `DocSyncConfigService` needs. */
+export function createTestDocumentConnectionsRepo(db: Database.Database): Promise {
+ const existing = documentConnectionsPerHandle.get(db);
+ if (existing !== undefined) return existing;
+ const pending = sharedTestOrm(db).then((t) => t.repo(DocumentConnections));
+ documentConnectionsPerHandle.set(db, pending);
+ return pending;
+}
+
+/** The `DocumentProvidersRepository` a hand-constructed `DocSyncConfigService` needs. */
+export function createTestDocumentProvidersRepo(db: Database.Database): Promise {
+ const existing = documentProvidersPerHandle.get(db);
+ if (existing !== undefined) return existing;
+ const pending = sharedTestOrm(db).then((t) => t.repo(DocumentProviders));
+ documentProvidersPerHandle.set(db, pending);
+ return pending;
+}
+
+/** The `DocumentProviderFieldsRepository` a hand-constructed `DocSyncConfigService` needs. */
+export function createTestDocumentProviderFieldsRepo(db: Database.Database): Promise {
+ const existing = documentProviderFieldsPerHandle.get(db);
+ if (existing !== undefined) return existing;
+ const pending = sharedTestOrm(db).then((t) => t.repo(DocumentProviderFields));
+ documentProviderFieldsPerHandle.set(db, pending);
+ return pending;
+}
+
+/** The `TripDocumentLinksRepository` a hand-constructed `DocSyncConfigService`/`DocSyncService` needs. */
+export function createTestTripDocumentLinksRepo(db: Database.Database): Promise {
+ const existing = tripDocumentLinksPerHandle.get(db);
+ if (existing !== undefined) return existing;
+ const pending = sharedTestOrm(db).then((t) => t.repo(TripDocumentLinks));
+ tripDocumentLinksPerHandle.set(db, pending);
+ return pending;
+}
+
+/** The `DocumentSyncItemsRepository` a hand-constructed `DocSyncService` needs. */
+export function createTestDocumentSyncItemsRepo(db: Database.Database): Promise {
+ const existing = documentSyncItemsPerHandle.get(db);
+ if (existing !== undefined) return existing;
+ const pending = sharedTestOrm(db).then((t) => t.repo(DocumentSyncItems));
+ documentSyncItemsPerHandle.set(db, pending);
+ return pending;
+}
+
+/** The `TripFilesRepository` a hand-constructed `DocSyncService` needs (cross-domain, 3e — DS4/DS15/DS19/DS29/DS32/DS36). */
+export function createTestTripFilesRepo(db: Database.Database): Promise {
+ const existing = tripFilesPerHandle.get(db);
+ if (existing !== undefined) return existing;
+ const pending = sharedTestOrm(db).then((t) => t.repo(TripFiles));
+ tripFilesPerHandle.set(db, pending);
+ return pending;
+}
+
+/** The `FileLinksRepository` a hand-constructed `DocSyncService` needs (cross-domain, 3e — DS16). */
+export function createTestFileLinksRepo(db: Database.Database): Promise {
+ const existing = fileLinksPerHandle.get(db);
+ if (existing !== undefined) return existing;
+ const pending = sharedTestOrm(db).then((t) => t.repo(FileLinks));
+ fileLinksPerHandle.set(db, pending);
+ return pending;
+}
diff --git a/server/tests/integration/doc-sync-webhook-request-context.test.ts b/server/tests/integration/doc-sync-webhook-request-context.test.ts
new file mode 100644
index 000000000..1aa48654a
--- /dev/null
+++ b/server/tests/integration/doc-sync-webhook-request-context.test.ts
@@ -0,0 +1,123 @@
+/**
+ * Doc-sync webhook request-context ratchet (Plan 3h Task 5, R9).
+ *
+ * `DocSyncWebhookController#schedule()`'s `setTimeout` callback runs fully
+ * detached from the HTTP request that triggered it: `nudge()` already
+ * answered `{received:true}` before the debounce window
+ * (`WEBHOOK_NUDGE_DEBOUNCE_SECONDS`) elapses, so whatever request-scoped
+ * `EntityManager` fork the triggering request held is long gone by the time
+ * the timer body runs. Per 3f's own R3 precedent (measured: `AsyncLocalStorage`
+ * survives a detached chain intact in this codebase today), the timer body
+ * now forks its OWN fresh request context via `withRequestContext` — insurance,
+ * not a fix for an observed failure.
+ *
+ * This test drives the REAL `DocSyncWebhookController.nudge()` (not a
+ * hand-built double) through a real HTTP request against a compiled
+ * `buildApp()` boot — `nudge()` itself needs the per-request `EntityManager`
+ * fork Nest's own middleware provides, so only the schedule()`'s DETACHED
+ * TIMER body (which runs from a genuinely bare context, after the response
+ * has already gone out) is what this test's assertion is about — and asserts
+ * that body never logs `cannotUseGlobalContext` — the same
+ * regression class `boot-sweeps-request-context.test.ts` guards for the seven
+ * cron boot sweeps and `storage-health-notifier-request-context.test.ts`
+ * guards for Plan 3f's own R3 listener.
+ */
+import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
+import type { INestApplication } from '@nestjs/common';
+import request from 'supertest';
+import type { Server } from 'http';
+
+vi.mock('../../src/db/database', async () => {
+ const { createSnapshotTestDb, buildDbMock } = await import('../helpers/db-mock');
+ return buildDbMock(createSnapshotTestDb());
+});
+vi.mock('../../src/config', () => ({
+ JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
+ ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
+ updateJwtSecret: () => {},
+ SESSION_DURATION: '24h',
+ SESSION_DURATION_MS: 86400000,
+ SESSION_DURATION_SECONDS: 86400,
+ DEFAULT_LANGUAGE: 'en',
+}));
+vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn(), getOnlineUserIds: vi.fn(() => []) }));
+
+import { db as testDb } from '../../src/db/database';
+import { buildApp } from '../../src/bootstrap';
+import { createUser, createTrip } from '../helpers/factories';
+import { WEBHOOK_NUDGE_DEBOUNCE_SECONDS } from '../../src/nest/doc-sync/doc-sync.constants';
+
+describe('DocSyncWebhookController#schedule() runs its detached timer body inside its own request context', () => {
+ let app: INestApplication;
+ let server: Server;
+ let token: string;
+
+ beforeAll(async () => {
+ app = await buildApp();
+ server = app.getHttpServer();
+
+ // The addon gate and the provider both have to be genuinely on: nudge()
+ // returns before ever scheduling anything otherwise (isSwitchedOff's own
+ // early-out), and this ratchet needs schedule() to actually fire.
+ testDb.exec("UPDATE addons SET enabled = 1 WHERE id = 'documents'");
+ testDb.exec("UPDATE document_providers SET enabled = 1 WHERE id = 'paperless'");
+
+ const owner = createUser(testDb, { username: 'docsync-webhook-ctx', email: 'docsync-webhook-ctx@test.local' }).user;
+ const trip = createTrip(testDb, owner.id, { title: 'Japan' });
+ const connInfo = testDb
+ .prepare(
+ `INSERT INTO document_connections (trip_id, provider_id, owner_user_id, base_url, secrets, settings)
+ VALUES (?, 'paperless', ?, 'https://paperless.example.com', NULL, '{}')`,
+ )
+ .run(trip.id, owner.id);
+ token = 'webhook-ctx-ratchet-token';
+ 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, webhook_token, created_by)
+ VALUES (?, ?, 'paperless', 'tag:1', 'Japan', 'both', 'unlink', 'manual', 1, ?, ?)`,
+ )
+ .run(trip.id, connInfo.lastInsertRowid, token, owner.id);
+ });
+
+ afterAll(async () => {
+ await app.close();
+ testDb.close();
+ });
+
+ it(
+ 'WEBHOOK-CTX-001: nudging from a bare (non-request) context never logs cannotUseGlobalContext once the debounce timer fires',
+ async () => {
+ const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
+ try {
+ // A REAL HTTP request through the compiled app — not a hand-built
+ // double, and not a direct method call either: `nudge()` itself
+ // needs the per-request `EntityManager` fork Nest's own middleware
+ // provides (its own `getLinkByToken` read would otherwise throw
+ // `cannotUseGlobalContext` before ever reaching `schedule()` — this
+ // is what a direct `app.get(...).nudge(...)` call, tried first,
+ // actually surfaced: confirmation this test needs the real request
+ // path, not a shortcut around it). Only the DETACHED TIMER body
+ // that fires after this response is the "bare context" under test.
+ const res = await request(server).post(`/api/docsync/webhook/${token}`).send({});
+ expect(res.status).toBe(200);
+ expect(res.body).toEqual({ received: true });
+
+ // Let the real debounce timer fire, then drain the microtasks its
+ // async body queues (real timers here — the detached body is a
+ // genuine `setTimeout`, not something fake timers can fast-forward
+ // through a real HTTP-free integration boot safely).
+ await new Promise((resolve) => setTimeout(resolve, (WEBHOOK_NUDGE_DEBOUNCE_SECONDS + 2) * 1000));
+
+ const suspicious = errSpy.mock.calls
+ .map((args) => args.map(String).join(' '))
+ .filter((line) => /cannotUseGlobalContext|global EntityManager/i.test(line));
+ expect(suspicious).toEqual([]);
+ } finally {
+ errSpy.mockRestore();
+ }
+ },
+ 20_000,
+ );
+});
diff --git a/server/tests/unit/db/dialect/sql-functions.test.ts b/server/tests/unit/db/dialect/sql-functions.test.ts
index e367c9e1c..d9ca6fff3 100644
--- a/server/tests/unit/db/dialect/sql-functions.test.ts
+++ b/server/tests/unit/db/dialect/sql-functions.test.ts
@@ -12,6 +12,7 @@ import {
castInteger,
castIntegerKysely,
coalesce,
+ coalesceOverride,
coalesceParam,
collateNoCase,
columnIncrementedBy,
@@ -21,9 +22,11 @@ import {
countAll,
countAllRef,
currentTimestamp,
+ currentTimestampKysely,
dateAdd,
dateOf,
dayDistance,
+ foundAgainState,
lower,
lowerParam,
lowerTrim,
@@ -337,6 +340,42 @@ describe('sql-functions (sqlite)', () => {
expect((testDb.prepare('SELECT display_name FROM users WHERE id = ?').get(user.id) as { display_name: string }).display_name).toBe('Fallback Name');
});
+ // Plan 3h Task 5 (doc-sync, DS23/DS24) — `coalesceOverride`, the
+ // mirror-image value-side shape `COALESCE(?, col)`: the caller's NEW
+ // value wins unless it is null, unlike `coalesceParam`'s `COALESCE(col,
+ // ?)` above (existing column wins unless IT is null). Confirmed
+ // genuinely the opposite direction by a live round-trip, not assumed from
+ // the two functions' similar names.
+ it('SQLF-090: coalesceOverride(value, ref) writes COALESCE(?, col) — a non-null new value overwrites the existing column', async () => {
+ const { user } = createUser(testDb);
+ testDb.prepare('UPDATE users SET display_name = ? WHERE id = ?').run('Old Name', user.id);
+ const platform = t.em.getPlatform();
+ await t.em.createQueryBuilder(Users, 'u')
+ .update({ display_name: coalesceOverride(platform, 'New Name', 'display_name') })
+ .where({ id: user.id })
+ .execute('run');
+ t.clear();
+ expect((testDb.prepare('SELECT display_name FROM users WHERE id = ?').get(user.id) as { display_name: string }).display_name).toBe('New Name');
+ });
+
+ it('SQLF-091: coalesceOverride(null, ref) leaves the existing column untouched — a null new value never clobbers what is stored', async () => {
+ const { user } = createUser(testDb);
+ testDb.prepare('UPDATE users SET display_name = ? WHERE id = ?').run('Keep Me', user.id);
+ const platform = t.em.getPlatform();
+ await t.em.createQueryBuilder(Users, 'u')
+ .update({ display_name: coalesceOverride(platform, null, 'display_name') })
+ .where({ id: user.id })
+ .execute('run');
+ t.clear();
+ expect((testDb.prepare('SELECT display_name FROM users WHERE id = ?').get(user.id) as { display_name: string }).display_name).toBe('Keep Me');
+ });
+
+ it('SQLF-092: an unknown platform fails closed for coalesceOverride', () => {
+ class FakePlatform extends Platform {}
+ const foreign = new FakePlatform();
+ expect(() => coalesceOverride(foreign, 'x', 'u.a')).toThrow(/no implementation for platform FakePlatform/);
+ });
+
it('SQLF-021: absDifference is usable as a filter key, matching ABS(col - ?) <= tolerance', async () => {
const { user: near } = createUser(testDb);
testDb.prepare('UPDATE users SET login_count = 12 WHERE id = ?').run(near.id);
@@ -1124,6 +1163,79 @@ describe('sql-functions (sqlite)', () => {
const eb = expressionBuilder();
expect(() => nowPlusSecondsKysely(foreign, eb, 1)).toThrow(/no implementation for platform FakePlatform/);
});
+
+ // Plan 3h Task 5 (doc-sync) — `foundAgainState`, the typed rebuild of
+ // `doc-sync.service.ts`'s module-level `FOUND_AGAIN` SQL-text constant
+ // (DS2/DS3/DS5/DS12's shared SET clause). A scratch temp table stands in
+ // for `document_sync_items` here — the CASE only ever reads the two
+ // columns it names, so a two-column probe table exercises its logic
+ // exactly, without a trip/connection/link FK chain this file has no other
+ // reason to build.
+ describe('foundAgainState (doc-sync FOUND_AGAIN)', () => {
+ beforeEach(() => {
+ testDb.exec('DROP TABLE IF EXISTS found_again_probe');
+ testDb.exec('CREATE TEMP TABLE found_again_probe (id INTEGER PRIMARY KEY, state TEXT NOT NULL, file_id INTEGER)');
+ });
+
+ function apply(state: string, fileId: number | null): string {
+ const platform = t.em.getPlatform();
+ testDb.prepare('DELETE FROM found_again_probe WHERE id = 1').run();
+ testDb.prepare('INSERT INTO found_again_probe (id, state, file_id) VALUES (1, ?, ?)').run(state, fileId);
+ testDb.prepare(`UPDATE found_again_probe SET state = ${foundAgainState(platform, 'state', 'file_id').sql} WHERE id = 1`).run();
+ return (testDb.prepare('SELECT state FROM found_again_probe WHERE id = 1').get() as { state: string }).state;
+ }
+
+ it("SQLF-083: recovers a row on record as missing once its file is paired again, matching the legacy FOUND_AGAIN text (state='remote_missing' AND file_id IS NOT NULL -> 'synced')", () => {
+ expect(apply('remote_missing', 42)).toBe('synced');
+ });
+
+ it('SQLF-084: leaves a row on record as missing alone while it has no paired file (a rename/move under a path-as-id provider, not yet re-matched)', () => {
+ expect(apply('remote_missing', null)).toBe('remote_missing');
+ });
+
+ it('SQLF-085: leaves every other state untouched — error is never silently cleared by FOUND_AGAIN', () => {
+ for (const state of ['error', 'conflict', 'synced', 'pending', 'local_deleted']) {
+ expect(apply(state, 7)).toBe(state);
+ }
+ });
+
+ it('SQLF-086: an unknown platform fails closed for foundAgainState', () => {
+ class FakePlatform extends Platform {}
+ const foreign = new FakePlatform();
+ expect(() => foundAgainState(foreign, 'state', 'file_id')).toThrow(/no implementation for platform FakePlatform/);
+ });
+ });
+
+ // Plan 3h Task 5 (doc-sync, R3) — `currentTimestampKysely`, DS24's
+ // `last_seen_at = CURRENT_TIMESTAMP` inside the partial-index `ON
+ // CONFLICT ... DO UPDATE`.
+ describe('currentTimestampKysely', () => {
+ it('SQLF-087: compiles to the bare CURRENT_TIMESTAMP keyword with no parameters, matching currentTimestamp\'s own raw text and a real row\'s clock value', async () => {
+ const platform = t.em.getPlatform();
+ const compiled = t.em
+ .getKysely()
+ .selectFrom('users')
+ .select(() => [currentTimestampKysely(platform).as('d')])
+ .compile();
+ expect(compiled.sql).toBe('select CURRENT_TIMESTAMP as "d" from "users"');
+ expect(compiled.parameters).toEqual([]);
+
+ createUser(testDb);
+ const got = testDb.prepare(compiled.sql).get(...compiled.parameters) as { d: string };
+ const raw = testDb.prepare(`SELECT ${currentTimestamp(platform).sql} as d`).get() as { d: string };
+ expect(Math.abs(new Date(`${got.d.replace(' ', 'T')}Z`).getTime() - new Date(`${raw.d.replace(' ', 'T')}Z`).getTime())).toBeLessThan(10_000);
+ });
+
+ it('SQLF-088: current_timestamp() called as a function is rejected by SQLite — proving the bare-keyword shape is load-bearing, not a style choice', () => {
+ expect(() => testDb.prepare('SELECT current_timestamp() as d').get()).toThrow(/syntax error/);
+ });
+
+ it('SQLF-089: an unknown platform fails closed for currentTimestampKysely', () => {
+ class FakePlatform extends Platform {}
+ const foreign = new FakePlatform();
+ expect(() => currentTimestampKysely(foreign)).toThrow(/no implementation for platform FakePlatform/);
+ });
+ });
});
/**
diff --git a/server/tests/unit/db/repositories/DocumentSyncItems.repository.test.ts b/server/tests/unit/db/repositories/DocumentSyncItems.repository.test.ts
new file mode 100644
index 000000000..af82d0cce
--- /dev/null
+++ b/server/tests/unit/db/repositories/DocumentSyncItems.repository.test.ts
@@ -0,0 +1,245 @@
+/**
+ * `DocumentSyncItemsRepository` (Plan 3h Task 5, R3's own two named
+ * requirements): DS24's partial-unique-index upsert
+ * (`insertOrUpsertOnConflict`, `ON CONFLICT(link_id, remote_id) WHERE
+ * remote_id IS NOT NULL DO UPDATE`) and DS23's plain conditional update
+ * (`recordAttempt`, ten `COALESCE` columns + the backoff second-count).
+ */
+import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
+import { createSnapshotTestDb } from '../../../helpers/db-mock';
+import { resetTestDb } from '../../../helpers/test-db';
+import { createTestOrm, type TestOrm } from '../../../helpers/test-orm';
+import { createTrip, createUser } from '../../../helpers/factories';
+import { DocumentSyncItems } from '../../../../src/db/entities/DocumentSyncItems.entity';
+import type { DocumentSyncItemsRepository } from '../../../../src/db/repositories/DocumentSyncItems.repository';
+import { currentTimestampKysely } from '../../../../src/db/dialect/sql-functions';
+
+const testDb = createSnapshotTestDb();
+let t: TestOrm;
+let repo: DocumentSyncItemsRepository;
+
+beforeAll(async () => {
+ t = await createTestOrm(testDb);
+ repo = t.repo(DocumentSyncItems);
+});
+beforeEach(() => { resetTestDb(testDb); t.clear(); });
+afterAll(async () => { await t.close(); testDb.close(); });
+
+function fixture() {
+ const { user } = createUser(testDb);
+ const trip = createTrip(testDb, user.id);
+ const connInfo = testDb
+ .prepare(
+ `INSERT INTO document_connections (trip_id, provider_id, owner_user_id, base_url, secrets, settings)
+ VALUES (?, 'paperless', ?, 'https://paperless.example.com', NULL, '{}')`,
+ )
+ .run(trip.id, user.id);
+ const linkInfo = 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 (?, ?, 'paperless', 'tag:1', 'Japan', 'both', 'unlink', 'manual', 1, ?)`,
+ )
+ .run(trip.id, connInfo.lastInsertRowid, user.id);
+ return { user, trip, linkId: Number(linkInfo.lastInsertRowid) };
+}
+
+function insertData(linkId: number, tripId: number, over: Partial[0]> = {}) {
+ return {
+ link_id: linkId,
+ trip_id: tripId,
+ file_id: null,
+ trek_doc_uid: `uid-${Math.random()}`,
+ remote_id: null,
+ remote_name: null,
+ remote_version: null,
+ remote_size: null,
+ remote_modified_at: null,
+ content_sha256: null,
+ pushed_sha256: null,
+ state: 'synced',
+ error_code: null,
+ attempts: 0,
+ next_attempt_after_seconds: null,
+ synced_now: true,
+ ...over,
+ };
+}
+
+describe('DocumentSyncItemsRepository', () => {
+ describe('insertOrUpsertOnConflict (DS24, R3 — the partial-unique-index upsert)', () => {
+ it('DS24REPO-001: pinned rendered SQL for the ON CONFLICT DO UPDATE target and SET clause', () => {
+ const platform = t.em.getPlatform();
+ const compiled = t.em
+ .getKysely<{ document_sync_items: Record }>()
+ .insertInto('document_sync_items')
+ .values({
+ link_id: 1, trip_id: 1, file_id: null, trek_doc_uid: 'x', remote_id: 'r1', remote_name: null,
+ remote_version: null, remote_size: null, remote_modified_at: null, content_sha256: null,
+ pushed_sha256: null, state: 'synced', error_code: null, attempts: 0, next_attempt_at: null,
+ synced_at: null, last_seen_at: 'CURRENT_TIMESTAMP',
+ })
+ .onConflict((oc) =>
+ oc
+ .columns(['link_id', 'remote_id'])
+ .where('remote_id', 'is not', null)
+ .doUpdateSet({
+ state: (eb) => eb.ref('excluded.state'),
+ error_code: (eb) => eb.ref('excluded.error_code'),
+ file_id: (eb) => eb.fn.coalesce(eb.ref('excluded.file_id'), eb.ref('document_sync_items.file_id')),
+ remote_version: (eb) => eb.fn.coalesce(eb.ref('excluded.remote_version'), eb.ref('document_sync_items.remote_version')),
+ content_sha256: (eb) => eb.fn.coalesce(eb.ref('excluded.content_sha256'), eb.ref('document_sync_items.content_sha256')),
+ pushed_sha256: (eb) => eb.fn.coalesce(eb.ref('excluded.pushed_sha256'), eb.ref('document_sync_items.pushed_sha256')),
+ last_seen_at: () => currentTimestampKysely(platform),
+ }),
+ )
+ .compile();
+ // Pinned for the task report — the exact ON CONFLICT clause Kysely
+ // renders for this table, proving the partial-index predicate is
+ // restated verbatim (SQLite's own requirement, DS24's own doc
+ // comment) and the DO UPDATE SET matches the legacy statement's own
+ // five re-written columns plus the two verbatim-from-excluded ones.
+ expect(compiled.sql).toContain('on conflict ("link_id", "remote_id") where "remote_id" is not null do update set');
+ expect(compiled.sql).toContain('"state" = "excluded"."state"');
+ expect(compiled.sql).toContain('"error_code" = "excluded"."error_code"');
+ expect(compiled.sql).toContain('"file_id" = coalesce("excluded"."file_id", "document_sync_items"."file_id")');
+ expect(compiled.sql).toContain('"remote_version" = coalesce("excluded"."remote_version", "document_sync_items"."remote_version")');
+ expect(compiled.sql).toContain('"content_sha256" = coalesce("excluded"."content_sha256", "document_sync_items"."content_sha256")');
+ expect(compiled.sql).toContain('"pushed_sha256" = coalesce("excluded"."pushed_sha256", "document_sync_items"."pushed_sha256")');
+ expect(compiled.sql).toContain('"last_seen_at" = CURRENT_TIMESTAMP');
+ // Pinned in full, for the task report:
+ expect(compiled.sql).toBe(
+ 'insert into "document_sync_items" ("link_id", "trip_id", "file_id", "trek_doc_uid", "remote_id", "remote_name", "remote_version", "remote_size", "remote_modified_at", "content_sha256", "pushed_sha256", "state", "error_code", "attempts", "next_attempt_at", "synced_at", "last_seen_at") values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) on conflict ("link_id", "remote_id") where "remote_id" is not null do update set "state" = "excluded"."state", "error_code" = "excluded"."error_code", "file_id" = coalesce("excluded"."file_id", "document_sync_items"."file_id"), "remote_version" = coalesce("excluded"."remote_version", "document_sync_items"."remote_version"), "content_sha256" = coalesce("excluded"."content_sha256", "document_sync_items"."content_sha256"), "pushed_sha256" = coalesce("excluded"."pushed_sha256", "document_sync_items"."pushed_sha256"), "last_seen_at" = CURRENT_TIMESTAMP',
+ );
+ });
+
+ /**
+ * R3's own required mutation test: insert two rows differing only in
+ * `remote_id` under the same `link_id` (both succeed — the partial
+ * index only constrains non-NULL `remote_id`s against each other), then
+ * a THIRD insert colliding on `(link_id, remote_id)` with one of them —
+ * proving the partial index still enforces "at most one non-NULL
+ * remote_id per link" post-conversion by upserting (not duplicating).
+ */
+ it('DS24REPO-002 (R3 mutation proof): a colliding (link_id, remote_id) pair upserts in place rather than duplicating', async () => {
+ const { linkId, trip } = fixture();
+ await repo.insertOrUpsertOnConflict(insertData(linkId, trip.id, { remote_id: 'r1', trek_doc_uid: 'uid-a', state: 'synced', remote_version: 'v1' }));
+ await repo.insertOrUpsertOnConflict(insertData(linkId, trip.id, { remote_id: 'r2', trek_doc_uid: 'uid-b', state: 'synced', remote_version: 'v1' }));
+ const afterTwo = testDb.prepare('SELECT id, remote_id, remote_version FROM document_sync_items WHERE link_id = ? ORDER BY id').all(linkId) as Array<{ id: number; remote_id: string; remote_version: string }>;
+ expect(afterTwo).toHaveLength(2);
+ const r1Id = afterTwo.find((r) => r.remote_id === 'r1')!.id;
+
+ // The colliding insert: same link_id + remote_id='r1' as the first row.
+ await repo.insertOrUpsertOnConflict(insertData(linkId, trip.id, { remote_id: 'r1', trek_doc_uid: 'uid-c', state: 'conflict', remote_version: 'v2' }));
+
+ const afterThree = testDb.prepare('SELECT id, remote_id, remote_version, state FROM document_sync_items WHERE link_id = ? ORDER BY id').all(linkId) as Array<{ id: number; remote_id: string; remote_version: string; state: string }>;
+ // Still exactly two rows — the third insert upserted onto the first, not a third row.
+ expect(afterThree).toHaveLength(2);
+ const upserted = afterThree.find((r) => r.id === r1Id)!;
+ expect(upserted.remote_id).toBe('r1');
+ expect(upserted.state).toBe('conflict');
+ expect(upserted.remote_version).toBe('v2');
+ });
+
+ it('DS24REPO-003: two rows with NULL remote_id under the same link never collide (the partial index only constrains non-NULL)', async () => {
+ const { linkId, trip } = fixture();
+ await repo.insertOrUpsertOnConflict(insertData(linkId, trip.id, { remote_id: null, trek_doc_uid: 'uid-x' }));
+ await repo.insertOrUpsertOnConflict(insertData(linkId, trip.id, { remote_id: null, trek_doc_uid: 'uid-y' }));
+ const rows = testDb.prepare('SELECT id FROM document_sync_items WHERE link_id = ?').all(linkId);
+ expect(rows).toHaveLength(2);
+ });
+ });
+
+ describe('recordAttempt (DS23, R3 — the plain conditional update, itemId≠null)', () => {
+ async function seedRow(linkId: number, tripId: number) {
+ const id = await repo.insertOrUpsertOnConflict(
+ insertData(linkId, tripId, {
+ remote_id: 'r1', file_id: null, remote_name: 'a.pdf', remote_version: 'v1', remote_size: 512,
+ remote_modified_at: '2026-09-01T08:00:00Z', content_sha256: 'old-hash', pushed_sha256: 'old-pushed',
+ }),
+ ).then(() => (testDb.prepare('SELECT id FROM document_sync_items WHERE link_id = ? AND remote_id = ?').get(linkId, 'r1') as { id: number }).id);
+ return id;
+ }
+
+ /**
+ * R3's full-key parity test: all ten `COALESCE` columns win with a
+ * non-null new value (branch 1) — matching the legacy `COALESCE(?,
+ * col)` — and each falls back to the EXISTING column when the new
+ * value is null (branch 2), the same statement's other half. Both
+ * branches proved on the SAME row, sequentially, so branch 2 is
+ * genuinely proving "kept", not merely "never changed".
+ */
+ it('DS23REPO-001 (R3 parity): a non-null new value overwrites every COALESCE column; a null one keeps the existing value', async () => {
+ const { linkId, trip } = fixture();
+ const itemId = await seedRow(linkId, trip.id);
+ const newFileId = Number(
+ testDb.prepare('INSERT INTO trip_files (trip_id, filename, original_name) VALUES (?, ?, ?)').run(trip.id, 'stored.pdf', 'b.pdf').lastInsertRowid,
+ );
+
+ // Branch 1: every COALESCE column gets a genuinely new, non-null value.
+ await repo.recordAttempt(itemId, {
+ state: 'synced', error_code: null, file_id: newFileId, remote_id: 'r1-new', remote_version: 'v2',
+ remote_name: 'b.pdf', remote_size: 2048, remote_modified_at: '2026-09-18T10:00:00Z',
+ content_sha256: 'new-hash', pushed_sha256: 'new-pushed', attempts: 0, next_attempt_after_seconds: null,
+ });
+ let row = testDb.prepare('SELECT * FROM document_sync_items WHERE id = ?').get(itemId) as Record;
+ expect(row).toMatchObject({
+ file_id: newFileId, remote_id: 'r1-new', remote_version: 'v2', remote_name: 'b.pdf', remote_size: 2048,
+ remote_modified_at: '2026-09-18T10:00:00Z', content_sha256: 'new-hash', pushed_sha256: 'new-pushed',
+ });
+
+ // Branch 2: every COALESCE column arrives null — the existing (branch-1) value survives untouched.
+ await repo.recordAttempt(itemId, {
+ state: 'synced', error_code: null, file_id: null, remote_id: null, remote_version: null,
+ remote_name: null, remote_size: null, remote_modified_at: null, content_sha256: null,
+ pushed_sha256: null, attempts: 0, next_attempt_after_seconds: null,
+ });
+ row = testDb.prepare('SELECT * FROM document_sync_items WHERE id = ?').get(itemId) as Record;
+ expect(row).toMatchObject({
+ file_id: newFileId, remote_id: 'r1-new', remote_version: 'v2', remote_name: 'b.pdf', remote_size: 2048,
+ remote_modified_at: '2026-09-18T10:00:00Z', content_sha256: 'new-hash', pushed_sha256: 'new-pushed',
+ });
+ });
+
+ it('DS23REPO-002: next_attempt_after_seconds shifts the DB clock forward by the given second count; null leaves it NULL', async () => {
+ const { linkId, trip } = fixture();
+ const itemId = await seedRow(linkId, trip.id);
+
+ await repo.recordAttempt(itemId, {
+ state: 'error', error_code: 'timeout', file_id: null, remote_id: null, remote_version: null,
+ remote_name: null, remote_size: null, remote_modified_at: null, content_sha256: null,
+ pushed_sha256: null, attempts: 1, next_attempt_after_seconds: 300,
+ });
+ const row = testDb.prepare("SELECT next_attempt_at, datetime('now', '+300 seconds') AS expected FROM document_sync_items WHERE id = ?").get(itemId) as { next_attempt_at: string; expected: string };
+ expect(row.next_attempt_at).not.toBeNull();
+ expect(Math.abs(new Date(`${row.next_attempt_at.replace(' ', 'T')}Z`).getTime() - new Date(`${row.expected.replace(' ', 'T')}Z`).getTime())).toBeLessThan(5000);
+
+ await repo.recordAttempt(itemId, {
+ state: 'synced', error_code: null, file_id: null, remote_id: null, remote_version: null,
+ remote_name: null, remote_size: null, remote_modified_at: null, content_sha256: null,
+ pushed_sha256: null, attempts: 0, next_attempt_after_seconds: null,
+ });
+ const cleared = testDb.prepare('SELECT next_attempt_at FROM document_sync_items WHERE id = ?').get(itemId) as { next_attempt_at: string | null };
+ expect(cleared.next_attempt_at).toBeNull();
+ });
+
+ it('DS23REPO-003: synced_at is stamped only when state is synced, and left alone otherwise', async () => {
+ const { linkId, trip } = fixture();
+ const itemId = await seedRow(linkId, trip.id);
+ testDb.prepare("UPDATE document_sync_items SET synced_at = '2020-01-01 00:00:00' WHERE id = ?").run(itemId);
+
+ await repo.recordAttempt(itemId, {
+ state: 'error', error_code: 'timeout', file_id: null, remote_id: null, remote_version: null,
+ remote_name: null, remote_size: null, remote_modified_at: null, content_sha256: null,
+ pushed_sha256: null, attempts: 1, next_attempt_after_seconds: null,
+ });
+ expect((testDb.prepare('SELECT synced_at FROM document_sync_items WHERE id = ?').get(itemId) as { synced_at: string }).synced_at).toBe('2020-01-01 00:00:00');
+
+ await repo.recordAttempt(itemId, {
+ state: 'synced', error_code: null, file_id: null, remote_id: null, remote_version: null,
+ remote_name: null, remote_size: null, remote_modified_at: null, content_sha256: null,
+ pushed_sha256: null, attempts: 0, next_attempt_after_seconds: null,
+ });
+ expect((testDb.prepare('SELECT synced_at FROM document_sync_items WHERE id = ?').get(itemId) as { synced_at: string }).synced_at).not.toBe('2020-01-01 00:00:00');
+ });
+ });
+});
diff --git a/server/tests/unit/nest/doc-sync/doc-sync-config.service.test.ts b/server/tests/unit/nest/doc-sync/doc-sync-config.service.test.ts
index 6a7bcc770..6ad32f487 100644
--- a/server/tests/unit/nest/doc-sync/doc-sync-config.service.test.ts
+++ b/server/tests/unit/nest/doc-sync/doc-sync-config.service.test.ts
@@ -56,7 +56,6 @@ import { createTables } from '../../../../src/db/schema';
import { runMigrations } from '../../../../src/db/migrations';
import { resetTestDb } from '../../../helpers/test-db';
import { createTrip, createUser } from '../../../helpers/factories';
-import { DatabaseService } from '../../../../src/nest/database/database.service';
import {
DocSyncConfigService,
type ConnectionRow,
@@ -67,7 +66,14 @@ import type { DocumentProviderRegistry } from '../../../../src/nest/doc-sync/doc
// off, so the boolean discriminant stops discriminating. The domain's own
// predicate is what every call site uses instead.
import { docFailed } from '../../../../src/nest/doc-sync/document-provider';
-import { createTestUnitOfWork } from '../../../helpers/test-uow';
+import { createTestUnitOfWork, createTestTripsRepo } from '../../../helpers/test-uow';
+import {
+ createTestDocumentConnectionsRepo,
+ createTestDocumentProviderFieldsRepo,
+ createTestDocumentProvidersRepo,
+ createTestDocumentSyncItemsRepo,
+ createTestTripDocumentLinksRepo,
+} from '../../../helpers/doc-sync-repos';
let svc: DocSyncConfigService;
@@ -120,7 +126,16 @@ async function storedSecret(connectionId: number): Promise {
beforeAll(async () => {
createTables(testDb);
runMigrations(testDb);
- svc = new DocSyncConfigService(new DatabaseService(testDb), {} as DocumentProviderRegistry, await createTestUnitOfWork(testDb));
+ svc = new DocSyncConfigService(
+ await createTestTripsRepo(testDb),
+ await createTestDocumentProvidersRepo(testDb),
+ await createTestDocumentProviderFieldsRepo(testDb),
+ await createTestDocumentConnectionsRepo(testDb),
+ await createTestTripDocumentLinksRepo(testDb),
+ await createTestDocumentSyncItemsRepo(testDb),
+ {} as DocumentProviderRegistry,
+ await createTestUnitOfWork(testDb),
+ );
});
beforeEach(() => {
@@ -727,6 +742,44 @@ describe('markOrphanedLinks', () => {
expect(await svc.getLink(kept.id)).toMatchObject({ last_sync_state: 'never', sync_enabled: 1 });
});
+ /**
+ * DSC20/DSC21's shared predicate (`DocumentConnectionsRepository
+ * .listOrphanedIds`) — the single-link check (`isOrphaned`, which
+ * delegates to the private `ownerLeft`) and the bulk sweep
+ * (`markOrphanedLinks`) must agree on every case, since both now draw
+ * from the SAME repository method rather than two independent
+ * re-implementations of the `NOT IN (... UNION ...)` subquery. Three
+ * cases in one test: an owner still on the trip via `trip_members`, an
+ * owner on it only via `trips.user_id` (no membership row), and an owner
+ * genuinely gone.
+ */
+ it('the single-link check and the bulk sweep agree on every case: owner via trip_members, owner via trips.user_id, owner genuinely gone', async () => {
+ // A connection is keyed per (trip_id, provider_id) — `upsertConnection`
+ // treats a second `connect()` call for a provider this trip already
+ // has as an EDIT of that same row, not a new connection. Three
+ // independent connections therefore need three distinct providers.
+ const viaMembership = await bind(MEMBER, 'paperless');
+ const viaTripOwner = await bind(OWNER, 'nextcloud');
+ // A stranger who was never on the trip at all — indistinguishable from
+ // "genuinely gone" to the shared predicate, which only asks "is the
+ // owner currently IN the membership set", never why they are not.
+ const strangerId = createUser(testDb, { username: 'stranger-doc-sync', email: 'stranger-doc-sync@test.local' }).user.id;
+ const goneConn = await connect(
+ { providerId: 'synologydrive', credentials: { username: 'anna', password: 'nas-pw' }, baseUrl: 'https://nas.example.com:5001' },
+ strangerId,
+ );
+ const goneLink = await link(goneConn.id, { scopeKey: 'scope:gone' });
+
+ expect(await svc.isOrphaned(await svc.getLink(viaMembership.id) as LinkRow)).toBe(false);
+ expect(await svc.isOrphaned(await svc.getLink(viaTripOwner.id) as LinkRow)).toBe(false);
+ expect(await svc.isOrphaned(await svc.getLink(goneLink.id) as LinkRow)).toBe(true);
+
+ expect(await svc.markOrphanedLinks()).toBe(1);
+ expect((await svc.getLink(viaMembership.id))?.last_sync_state).toBe('never');
+ expect((await svc.getLink(viaTripOwner.id))?.last_sync_state).toBe('never');
+ expect((await svc.getLink(goneLink.id))?.last_sync_state).toBe('orphaned');
+ });
+
it('counts nothing on a second sweep, so the job stays quiet after the first one', async () => {
await bind(MEMBER, 'paperless');
testDb.prepare('DELETE FROM trip_members WHERE trip_id = ? AND user_id = ?').run(TRIP, MEMBER);
diff --git a/server/tests/unit/nest/doc-sync/doc-sync-webhook.controller.test.ts b/server/tests/unit/nest/doc-sync/doc-sync-webhook.controller.test.ts
index 1cad9fb29..6ced4fcfa 100644
--- a/server/tests/unit/nest/doc-sync/doc-sync-webhook.controller.test.ts
+++ b/server/tests/unit/nest/doc-sync/doc-sync-webhook.controller.test.ts
@@ -1,4 +1,4 @@
-import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { describe, it, expect, vi, beforeAll, afterAll, beforeEach, afterEach } from 'vitest';
import crypto from 'crypto';
import type { Request } from 'express';
@@ -16,7 +16,8 @@ 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 { DatabaseService } from '../../../../src/nest/database/database.service';
+import { createSnapshotTestDb } from '../../../helpers/db-mock';
+import { createTestOrm } from '../../../helpers/test-orm';
/**
* The webhook endpoint, with both services stubbed.
@@ -64,6 +65,9 @@ const link = (over: Partial = {}): LinkRow => ({
last_sync_error: null,
failure_count: 0,
next_attempt_at: null,
+ created_by: 1,
+ created_at: '2026-09-01 08:00:00',
+ updated_at: '2026-09-01 08:00:00',
...over,
});
@@ -75,26 +79,35 @@ const config = {
webhookSecret: vi.fn(() => SECRET),
};
-const settings = new Map();
-const db = {
- get: vi.fn((_sql: string, key?: unknown) => {
- const value = settings.get(String(key));
- return value === undefined ? undefined : { value };
- }),
-};
-
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),
+ // DSWH1 (R2 — moved off this controller onto DocSyncService.isSyncEnabled):
+ // the instance-wide kill switch, the same rule the job's own tick obeys.
+ isSyncEnabled: vi.fn(() => true),
};
-const controller = new DocSyncWebhookController(
- config as unknown as DocSyncConfigService,
- sync as unknown as DocSyncService,
- db as unknown as DatabaseService,
-);
+// R9's withRequestContext wrap needs a real MikroORM EntityManager to fork
+// (RequestContext.create calls em.fork(...)) — a bare `{ em: {} }` double
+// cannot satisfy that, so this file draws the same shared test ORM
+// `StorageHealthNotifierService`'s own unit test uses for the identical
+// reason (its class docstring: "these hand-built doubles pass the shared
+// test ORM instead"). Nothing in this file's own assertions touches SQLite
+// (`config`/`sync` are still full mocks), so no `createTables`/migrations
+// beyond what `createSnapshotTestDb`'s cached snapshot already carries.
+const testDb = createSnapshotTestDb();
+let controller: DocSyncWebhookController;
+
+beforeAll(async () => {
+ const t = await createTestOrm(testDb);
+ controller = new DocSyncWebhookController(config as unknown as DocSyncConfigService, sync as unknown as DocSyncService, t.orm);
+});
+
+afterAll(() => {
+ testDb.close();
+});
/** Only what the handler reads: headers, the parsed body, and the raw bytes. */
function makeReq(headers: Record = {}, opts: { body?: unknown; rawBody?: Buffer | undefined } = {}): Request {
@@ -123,8 +136,8 @@ function papraReq(payload: string, signature: string, sent = payload): Request {
beforeEach(() => {
vi.clearAllMocks();
vi.useFakeTimers();
- settings.clear();
sync.isSwitchedOff.mockReturnValue(false);
+ sync.isSyncEnabled.mockReturnValue(true);
config.getLinkByToken.mockImplementation((token: string) => (token === 'tok-live' ? link() : undefined));
config.getLink.mockImplementation((id: number) => (id === 4 ? link() : undefined));
config.webhookSecret.mockReturnValue(SECRET);
@@ -404,7 +417,7 @@ describe('the admin switches', () => {
});
it('does nothing while the kill switch is set', async () => {
- settings.set('docsync_sync_enabled', 'false');
+ sync.isSyncEnabled.mockReturnValue(false);
await controller.nudge('tok-live', makeReq({ 'x-trek-docsync-secret': SECRET }));
await settle();
expect(sync.syncLink).not.toHaveBeenCalled();
diff --git a/server/tests/unit/nest/doc-sync/doc-sync.controller.test.ts b/server/tests/unit/nest/doc-sync/doc-sync.controller.test.ts
index dead09ab5..5a7435389 100644
--- a/server/tests/unit/nest/doc-sync/doc-sync.controller.test.ts
+++ b/server/tests/unit/nest/doc-sync/doc-sync.controller.test.ts
@@ -60,7 +60,6 @@ vi.mock('../../../../src/utils/ssrfGuard', async (importOriginal) => ({
import { createTables } from '../../../../src/db/schema';
import { runMigrations } from '../../../../src/db/migrations';
import { createTrip, createUser } from '../../../helpers/factories';
-import { DatabaseService } from '../../../../src/nest/database/database.service';
import { DocSyncController } from '../../../../src/nest/doc-sync/doc-sync.controller';
import { DocSyncConfigService } from '../../../../src/nest/doc-sync/doc-sync-config.service';
import { DocumentProviderRegistry } from '../../../../src/nest/doc-sync/document-provider.registry';
@@ -78,7 +77,14 @@ import type {
DocsyncLinkDto,
} from '../../../../src/nest/doc-sync/doc-sync.dto';
import type { User } from '../../../../src/types';
-import { createTestUnitOfWork } from '../../../helpers/test-uow';
+import { createTestUnitOfWork, createTestTripsRepo } from '../../../helpers/test-uow';
+import {
+ createTestDocumentConnectionsRepo,
+ createTestDocumentProviderFieldsRepo,
+ createTestDocumentProvidersRepo,
+ createTestDocumentSyncItemsRepo,
+ createTestTripDocumentLinksRepo,
+} from '../../../helpers/doc-sync-repos';
const CAPS = {
push: 'webhook-self-registered' as const,
@@ -121,10 +127,30 @@ const sync = {
isSwitchedOff: vi.fn((_link: LinkRow) => false),
status: vi.fn(() => ({ links: [], items: {} })),
resolveConflict: vi.fn(async () => true),
+ // R2 (DSCTRL4/DSCTRL5, moved off the controller onto DocSyncService) — a
+ // real read against this file's own testDb, not a stub: "the document
+ // list" describe block below is testing the join/trip-scoping SHAPE this
+ // method now owns, and a canned return would only restate the assertion.
+ itemsForTrip: vi.fn((tripId: number, state?: string) =>
+ state
+ ? testDb
+ .prepare(
+ `SELECT i.*, f.original_name AS file_name FROM document_sync_items i
+ LEFT JOIN trip_files f ON f.id = i.file_id
+ WHERE i.trip_id = ? AND i.state = ? ORDER BY i.id DESC LIMIT 500`,
+ )
+ .all(tripId, state)
+ : testDb
+ .prepare(
+ `SELECT i.*, f.original_name AS file_name FROM document_sync_items i
+ LEFT JOIN trip_files f ON f.id = i.file_id
+ WHERE i.trip_id = ? ORDER BY i.id DESC LIMIT 500`,
+ )
+ .all(tripId),
+ ),
};
const registry = new DocumentProviderRegistry([paperless, nextcloud] as unknown as DocumentProvider[]);
-const dbs = new DatabaseService(testDb);
const realtime = { broadcast: vi.fn() };
// Built in beforeAll: DocSyncConfigService now takes a UnitOfWork, and
// createTestUnitOfWork is async — module-scope construction cannot await it.
@@ -184,8 +210,17 @@ let otherTripId: number;
beforeAll(async () => {
createTables(testDb);
runMigrations(testDb);
- config = new DocSyncConfigService(dbs, registry, await createTestUnitOfWork(testDb));
- controller = new DocSyncController(config, sync as unknown as DocSyncService, registry, dbs, realtime as unknown as RealtimeService);
+ config = new DocSyncConfigService(
+ await createTestTripsRepo(testDb),
+ await createTestDocumentProvidersRepo(testDb),
+ await createTestDocumentProviderFieldsRepo(testDb),
+ await createTestDocumentConnectionsRepo(testDb),
+ await createTestTripDocumentLinksRepo(testDb),
+ await createTestDocumentSyncItemsRepo(testDb),
+ registry,
+ await createTestUnitOfWork(testDb),
+ );
+ controller = new DocSyncController(config, sync as unknown as DocSyncService, registry, realtime as unknown as RealtimeService);
const o = createUser(testDb, { username: 'owner', email: 'owner@test.local' }).user;
const m = createUser(testDb, { username: 'member', email: 'member@test.local' }).user;
const a = createUser(testDb, { username: 'admin', email: 'admin@test.local', role: 'admin' }).user;
diff --git a/server/tests/unit/nest/doc-sync/doc-sync.job.test.ts b/server/tests/unit/nest/doc-sync/doc-sync.job.test.ts
index 824581939..c14d33206 100644
--- a/server/tests/unit/nest/doc-sync/doc-sync.job.test.ts
+++ b/server/tests/unit/nest/doc-sync/doc-sync.job.test.ts
@@ -33,7 +33,7 @@ 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 { DatabaseService } from '../../../../src/nest/database/database.service';
+import type { AppSettingsRepository } from '../../../../src/db/repositories/AppSettings.repository';
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';
@@ -42,7 +42,16 @@ import type { RealtimeService } from '../../../../src/nest/realtime/realtime.ser
import { createTables } from '../../../../src/db/schema';
import { runMigrations } from '../../../../src/db/migrations';
import { createTrip, createUser } from '../../../helpers/factories';
-import { createTestUnitOfWork, createTestAppSettingsRepo } from '../../../helpers/test-uow';
+import { createTestUnitOfWork, createTestAppSettingsRepo, createTestTripsRepo } from '../../../helpers/test-uow';
+import {
+ createTestDocumentConnectionsRepo,
+ createTestDocumentProviderFieldsRepo,
+ createTestDocumentProvidersRepo,
+ createTestDocumentSyncItemsRepo,
+ createTestFileLinksRepo,
+ createTestTripDocumentLinksRepo,
+ createTestTripFilesRepo,
+} from '../../../helpers/doc-sync-repos';
const link = (id: number): LinkRow => ({ id, provider_id: 'paperless' } as LinkRow);
@@ -65,13 +74,11 @@ function makeJob(over: Partial = {}) {
if (setup.interval !== undefined) settings.set(SETTING_POLL_INTERVAL, setup.interval);
if (setup.killSwitch !== undefined) settings.set(SETTING_SYNC_ENABLED, setup.killSwitch);
- // Shaped like the real `get(sql, ...params)` so the stub cannot drift from
- // the signature the job calls, and so a case can tell the two keys apart.
- const db = {
- get: vi.fn((_sql: string, key?: unknown) => {
- const value = settings.get(String(key));
- return value === undefined ? undefined : { value };
- }),
+ // Shaped like the real `AppSettingsRepository.getValue(key): Promise` so the stub cannot drift from the signature the job calls, and
+ // so a case can tell the two keys apart.
+ const appSettings = {
+ getValue: vi.fn(async (key: string) => settings.get(key) ?? null),
};
let onTick: (() => void | Promise) | undefined;
@@ -99,13 +106,13 @@ function makeJob(over: Partial = {}) {
const addons = { isAddonEnabled: vi.fn(() => setup.addonOn) };
const job = new DocSyncJob(
- db as unknown as DatabaseService,
+ appSettings as unknown as AppSettingsRepository,
sync as unknown as DocSyncService,
config as unknown as DocSyncConfigService,
addons as unknown as AddonsService,
registrar as unknown as CronRegistrarService,
);
- return { job, db, registrar, sync, config, addons, takeTick: () => onTick };
+ return { job, appSettings, registrar, sync, config, addons, takeTick: () => onTick };
}
beforeEach(() => vi.clearAllMocks());
@@ -114,10 +121,10 @@ describe('DocSyncJob bootstrap', () => {
it('schedules nothing, reads nothing and logs nothing while the registrar is off', async () => {
// The test gate. A job that registered past it would have every suite boot
// start polling whatever document store sits in the fixture database.
- const { job, registrar, db } = makeJob({ registrarEnabled: false });
+ const { job, registrar, appSettings } = makeJob({ registrarEnabled: false });
await job.onApplicationBootstrap();
expect(registrar.register).not.toHaveBeenCalled();
- expect(db.get).not.toHaveBeenCalled();
+ expect(appSettings.getValue).not.toHaveBeenCalled();
expect(log.logInfo).not.toHaveBeenCalled();
});
@@ -134,9 +141,9 @@ describe('DocSyncJob bootstrap', () => {
});
it('reads the interval from its own app_settings key', async () => {
- const { job, db } = makeJob({ interval: '600' });
+ const { job, appSettings } = makeJob({ interval: '600' });
await job.onApplicationBootstrap();
- expect(db.get).toHaveBeenCalledWith('SELECT value FROM app_settings WHERE key = ?', SETTING_POLL_INTERVAL);
+ expect(appSettings.getValue).toHaveBeenCalledWith(SETTING_POLL_INTERVAL);
});
it('hands the registrar the tick itself, so a fired cron reaches the sync', async () => {
@@ -165,12 +172,12 @@ describe('DocSyncJob bootstrap', () => {
describe('DocSyncJob tick', () => {
it('does nothing at all while the documents addon is off', async () => {
- const { job, sync, config, db } = makeJob({ addonOn: false, links: [link(1)] });
+ const { job, sync, config, appSettings } = makeJob({ addonOn: false, links: [link(1)] });
await job.tick();
expect(sync.dueLinks).not.toHaveBeenCalled();
expect(sync.syncLink).not.toHaveBeenCalled();
expect(config.markOrphanedLinks).not.toHaveBeenCalled();
- expect(db.get).not.toHaveBeenCalled();
+ expect(appSettings.getValue).not.toHaveBeenCalled();
});
it('asks the addon gate again on every tick, so switching it on needs no restart', async () => {
@@ -186,9 +193,9 @@ describe('DocSyncJob tick', () => {
});
it('stops the run on the kill switch, without touching the bindings', async () => {
- const { job, sync, config, db } = makeJob({ killSwitch: 'false', links: [link(1)] });
+ const { job, sync, config, appSettings } = makeJob({ killSwitch: 'false', links: [link(1)] });
await job.tick();
- expect(db.get).toHaveBeenCalledWith('SELECT value FROM app_settings WHERE key = ?', SETTING_SYNC_ENABLED);
+ expect(appSettings.getValue).toHaveBeenCalledWith(SETTING_SYNC_ENABLED);
expect(config.markOrphanedLinks).not.toHaveBeenCalled();
expect(sync.dueLinks).not.toHaveBeenCalled();
});
@@ -206,10 +213,10 @@ describe('DocSyncJob tick', () => {
});
it('re-reads the kill switch per tick instead of remembering the first answer', async () => {
- const { job, db } = makeJob({ links: [link(1)] });
+ const { job, appSettings } = makeJob({ links: [link(1)] });
await job.tick();
await job.tick();
- const killSwitchReads = db.get.mock.calls.filter((c) => c[1] === SETTING_SYNC_ENABLED).length;
+ const killSwitchReads = appSettings.getValue.mock.calls.filter((c) => c[0] === SETTING_SYNC_ENABLED).length;
expect(killSwitchReads).toBe(2);
});
@@ -367,18 +374,15 @@ describe('DocSyncJob due-ness', () => {
it('takes a changed setting without a restart, which is the whole point', async () => {
const settings = new Map([[SETTING_POLL_INTERVAL, '3600']]);
- const db = {
- get: vi.fn((_sql: string, key?: unknown) => {
- const value = settings.get(String(key));
- return value === undefined ? undefined : { value };
- }),
+ const appSettings = {
+ getValue: vi.fn(async (key: string) => settings.get(key) ?? null),
};
const sync = {
dueLinks: vi.fn(() => [link(1)]),
syncLink: vi.fn(async () => RUN),
};
const job = new DocSyncJob(
- db as unknown as DatabaseService,
+ appSettings as unknown as AppSettingsRepository,
sync as unknown as DocSyncService,
{ markOrphanedLinks: vi.fn(() => 0) } as unknown as DocSyncConfigService,
{ isAddonEnabled: vi.fn(() => true) } as unknown as AddonsService,
@@ -414,7 +418,6 @@ describe('DocSyncJob due-ness', () => {
*/
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;
@@ -437,25 +440,42 @@ describe('DocSyncJob and a provider switched off in the admin panel', () => {
// cannot await it.
let config: DocSyncConfigService;
let service: DocSyncService;
+ let appSettingsRepo: AppSettingsRepository;
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 tick = () => new DocSyncJob(appSettingsRepo, 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(async () => {
- config = new DocSyncConfigService(dbs, registry, await createTestUnitOfWork(testDb));
+ createTables(testDb);
+ runMigrations(testDb);
+ appSettingsRepo = await createTestAppSettingsRepo(testDb);
+ config = new DocSyncConfigService(
+ await createTestTripsRepo(testDb),
+ await createTestDocumentProvidersRepo(testDb),
+ await createTestDocumentProviderFieldsRepo(testDb),
+ await createTestDocumentConnectionsRepo(testDb),
+ await createTestTripDocumentLinksRepo(testDb),
+ await createTestDocumentSyncItemsRepo(testDb),
+ registry,
+ await createTestUnitOfWork(testDb),
+ );
service = new DocSyncService(
- dbs, config, registry,
- {} as StorageService, {} as FilesService, new AllowedFileTypesService(await createTestAppSettingsRepo(testDb)),
+ await createTestTripDocumentLinksRepo(testDb),
+ await createTestDocumentSyncItemsRepo(testDb),
+ await createTestTripFilesRepo(testDb),
+ await createTestFileLinksRepo(testDb),
+ appSettingsRepo,
+ config,
+ registry,
+ {} as StorageService, {} as FilesService, new AllowedFileTypesService(appSettingsRepo),
{ broadcast: vi.fn() } as unknown as RealtimeService,
addons,
await createTestUnitOfWork(testDb),
);
- 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) => {
diff --git a/server/tests/unit/nest/doc-sync/doc-sync.service.test.ts b/server/tests/unit/nest/doc-sync/doc-sync.service.test.ts
index b28e0783c..f17bb82b4 100644
--- a/server/tests/unit/nest/doc-sync/doc-sync.service.test.ts
+++ b/server/tests/unit/nest/doc-sync/doc-sync.service.test.ts
@@ -46,7 +46,6 @@ import type { DocsyncErrorCode } from '@trek/shared';
import { createTables } from '../../../../src/db/schema';
import { runMigrations } from '../../../../src/db/migrations';
import { createTrip, createUser } from '../../../helpers/factories';
-import { DatabaseService } from '../../../../src/nest/database/database.service';
import { AllowedFileTypesService } from '../../../../src/nest/files/allowed-file-types.service';
import { MAX_FILE_SIZE } from '../../../../src/nest/files/files.constants';
import { DocSyncConfigService, type LinkRow } from '../../../../src/nest/doc-sync/doc-sync-config.service';
@@ -70,7 +69,16 @@ import type { AddonsService } from '../../../../src/nest/addons/addons.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 { createTestUnitOfWork, createTestAppSettingsRepo } from '../../../helpers/test-uow';
+import { createTestUnitOfWork, createTestAppSettingsRepo, createTestTripsRepo } from '../../../helpers/test-uow';
+import {
+ createTestDocumentConnectionsRepo,
+ createTestDocumentProviderFieldsRepo,
+ createTestDocumentProvidersRepo,
+ createTestDocumentSyncItemsRepo,
+ createTestFileLinksRepo,
+ createTestTripDocumentLinksRepo,
+ createTestTripFilesRepo,
+} from '../../../helpers/doc-sync-repos';
// ── Fixtures ─────────────────────────────────────────────────────────────────
@@ -319,11 +327,23 @@ describe('DocSyncService', () => {
.run(tripId, ownerId);
connectionId = Number(info.lastInsertRowid);
- const dbs = new DatabaseService(testDb);
const registry = new DocumentProviderRegistry([provider as unknown as DocumentProvider]);
- config = new DocSyncConfigService(dbs, registry, await createTestUnitOfWork(testDb));
+ config = new DocSyncConfigService(
+ await createTestTripsRepo(testDb),
+ await createTestDocumentProvidersRepo(testDb),
+ await createTestDocumentProviderFieldsRepo(testDb),
+ await createTestDocumentConnectionsRepo(testDb),
+ await createTestTripDocumentLinksRepo(testDb),
+ await createTestDocumentSyncItemsRepo(testDb),
+ registry,
+ await createTestUnitOfWork(testDb),
+ );
service = new DocSyncService(
- dbs,
+ await createTestTripDocumentLinksRepo(testDb),
+ await createTestDocumentSyncItemsRepo(testDb),
+ await createTestTripFilesRepo(testDb),
+ await createTestFileLinksRepo(testDb),
+ await createTestAppSettingsRepo(testDb),
config,
registry,
storage,
@@ -396,6 +416,31 @@ describe('DocSyncService', () => {
expect((await service.dueLinks(5)).length).toBe(5);
});
+ /**
+ * R3's own named test, per the plan's exact instruction: the whole
+ * 3-key ORDER BY (`next_attempt_at`, then `last_sync_at`, then `id`) in
+ * one assertion, not folded into any of the shape-specific cases above.
+ * Insertion order is DELIBERATELY scrambled relative to the expected
+ * result, so a regression to "no ORDER BY" (SQLite's own insertion-order
+ * fallback) or to "ORDER BY id only" would both fail this test, not just
+ * the narrower cases above.
+ */
+ it('doc-sync-svc: dueLinks orders by next_attempt_at then last_sync_at then id, not insertion order', async () => {
+ // Two ties on next_attempt_at (both NULL, sorting as the epoch) broken
+ // by last_sync_at; within THAT tie, two rows broken by id — created in
+ // an order that would fail every one of these three keys if it were
+ // read back as inserted.
+ const tieBreakSecond = await makeLink({ lastSyncAt: null }); // next=NULL, last=NULL — ties tieBreakFirst on both keys
+ const tieBreakFirst = await makeLink({ lastSyncAt: null }); // same ties; id is the ONLY thing that can separate these two
+ const midStale = await makeLink({ lastSyncAt: sqlTime('-2 hours') }); // next=NULL, last=2h ago — after the NULL/NULL pair, before the recent one
+ const midRecent = await makeLink({ lastSyncAt: sqlTime('-1 minute') }); // next=NULL, last=1m ago — last of the NULL-next-attempt group
+ const backedOff = await makeLink({ nextAttemptAt: sqlTime('-1 hour'), lastSyncAt: sqlTime('-1 minute') }); // next_attempt_at is NON-null — sorts after every NULL row regardless of last_sync_at
+
+ const ids = (await service.dueLinks()).map((l) => l.id);
+ const expected = [tieBreakSecond.id, tieBreakFirst.id].sort((a, b) => a - b);
+ expect(ids).toEqual([...expected, midStale.id, midRecent.id, backedOff.id]);
+ });
+
it('lets a binding back in one failure before the circuit opens', async () => {
const nearly = await makeLink({ failureCount: LINK_CIRCUIT_OPEN_AFTER - 1 });
expect((await service.dueLinks()).map((l) => l.id)).toEqual([nearly.id]);