refactor(doc-sync): connections, reconciler and controller-SQL relocation through repositories

This commit is contained in:
jubnl
2026-09-24 00:40:23 +02:00
parent 182744e416
commit 8bcfd92142
23 changed files with 2658 additions and 638 deletions
+82 -1
View File
@@ -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(?, <col>)` — 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(<col> - ?)`, 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<DB, TB extends keyof DB>(
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 <stateRef> = 'remote_missing' AND <fileIdRef> IS NOT NULL THEN
* 'synced' ELSE <stateRef> 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<string> {
if (platform instanceof SqlitePlatform) return sql<string>`CURRENT_TIMESTAMP`;
return unsupported(platform);
}
@@ -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<DocumentConnections> {}
/**
* 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<DocumentConnectionRow, DocumentConnections> = 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<DocumentConnections> {
/** DSC4 (`getConnection`) — `SELECT * FROM document_connections WHERE id = ?`. */
async findById(id: number): Promise<DocumentConnectionRow | undefined> {
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<DocumentConnectionRow[]> {
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<DocumentConnectionRow | undefined> {
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<void> {
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<void> {
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<number> {
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<void> {
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<void> {
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<number[]> {
const rows = await this.kysely<OwnerLeftKyselyDB>()
.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,
};
}
@@ -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<DocumentProviderFields> {}
/** 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>`. */
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<DocumentProviderFieldFullRow, DocumentProviderFields> = 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<DocumentProviderFields> {
/**
* 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<DocumentProviderFieldRow[]> {
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,
}));
}
}
@@ -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<DocumentProviders> {}
/** 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<DocumentProviderRow, DocumentProviders> = 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<DocumentProviders> {
/**
* 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<string[]> {
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<string | undefined> {
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<DocumentProviderCatalogRow[]> {
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 }));
}
}
@@ -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<DocumentSyncItems> {}
/** 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<DocumentSyncItemRow, DocumentSyncItems> = 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<DocumentSyncItems> {
// ── 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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
const platform = this.getEntityManager().getPlatform();
const set: EntityData<DocumentSyncItems> = {
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<void> {
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<void> {
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<void> {
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<void> {
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<DocumentSyncItemsKyselyDB>()
.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<SyncItemStateRow[]> {
const rows = await this.kysely<DocumentSyncItemsKyselyDB>()
.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<number | undefined> {
const row = await this.kysely<DocumentSyncItemsKyselyDB>()
.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<string | undefined> {
const row = await this.kysely<DocumentSyncItemsKyselyDB>()
.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<number | undefined> {
const row = await this.kysely<DocumentSyncItemsKyselyDB>().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<void> {
const platform = this.getEntityManager().getPlatform();
const set: EntityData<DocumentSyncItems> = {
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<void> {
const platform = this.getEntityManager().getPlatform();
await this.kysely<DocumentSyncItemsWriteKyselyDB>()
.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<void> {
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<DocumentSyncItemRow | undefined> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<DocumentSyncIssueRow[]> {
return await this.kysely<DocumentSyncItemsKyselyDB>()
.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<Array<{ state: string; n: number }>> {
return await this.kysely<DocumentSyncItemsKyselyDB>()
.selectFrom('document_sync_items')
.select(['state', (eb) => eb.fn.countAll<number>().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<DocumentSyncHoldingsRow[]> {
const db = this.kysely<DocumentSyncItemsKyselyDB>();
return await db
.selectFrom('document_sync_items')
.select((eb) => [
'link_id',
eb.fn
.sum<number>(
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<number>(
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<number>(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<DocumentSyncItemWithFileRow[]> {
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<DocumentSyncItemWithFileRow[]> {
return await this.itemsWithFileQuery().where('i.trip_id', '=', tripId).orderBy('i.id', 'desc').limit(500).execute();
}
private itemsWithFileQuery() {
return this.kysely<DocumentSyncItemsKyselyDB>()
.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,
};
}
@@ -139,4 +139,37 @@ export class FileLinksRepository extends TrekRepository<FileLinks> {
.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<void> {
await this.kysely<FileLinksWriteKyselyDB>()
.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();
}
}
@@ -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<TripDocumentLinks> {}
/** 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<TripDocumentLinkRow, TripDocumentLinks> = 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<TripDocumentLinks> {
/**
* 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<TripDocumentLinkRow[]> {
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<TripDocumentLinkRow[]>('all', false);
return rows;
}
/** DSC12 (`listLinks`) — `SELECT * FROM trip_document_links WHERE trip_id = ? ORDER BY id`. */
async listForTrip(tripId: number): Promise<TripDocumentLinkRow[]> {
return await this.qb('l').select(['l.*']).where({ trip_id: tripId }).orderBy({ id: 'asc' }).execute<TripDocumentLinkRow[]>('all', false);
}
/** DSC13 (`getLink`) — `SELECT * FROM trip_document_links WHERE id = ?`. */
async findById(id: number): Promise<TripDocumentLinkRow | undefined> {
return await this.qb('l').select(['l.*']).where({ id }).execute<TripDocumentLinkRow | undefined>('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<TripDocumentLinkRow | undefined> {
return await this.qb('l').select(['l.*']).where({ webhook_token: token }).execute<TripDocumentLinkRow | undefined>('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<TripDocumentLinkRow | undefined> {
return await this.qb('l')
.select(['l.*'])
.where({ trip_id: tripId, connection_id: connectionId, remote_scope_key: scopeKey })
.execute<TripDocumentLinkRow | undefined>('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<number> {
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<void> {
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<void> {
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 shared owner-left predicate>)`. 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 <nothing matches>)` would produce.
*/
async markOrphanedByConnectionIds(connectionIds: readonly number[]): Promise<number> {
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<void> {
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<void> {
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<void> {
await this.nativeUpdate({ id }, { webhook_subscription_id: subscriptionId });
}
}
@@ -197,6 +197,69 @@ export class TripFilesRepository extends TrekRepository<TripFiles> {
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<void> {
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<string | undefined> {
const row = await this.kysely<TripFilesKyselyDB>().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<Array<{ id: number; original_name: string; file_size: number | null; mime_type: string | null; deleted_at: string | null }>> {
return await this.kysely<TripFilesKyselyDB>()
.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<number> {
const row = await this.kysely<TripFilesKyselyDB>()
.selectFrom('trip_files')
.select((eb) => eb.fn.countAll<number>().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
@@ -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>`. */
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<string[]> {
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<ProviderFieldRow[]> {
// 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<string[]> {
@@ -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<string> {
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<void> {
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<Array<DocumentProviderCatalogRow & { available: boolean; fields: Array<Omit<ProviderFieldRow, 'secret' | 'required'> & { 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<ConnectionRow | undefined> {
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<ConnectionRow[]> {
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<void> {
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<void> {
this.db.connection.prepare('DELETE FROM document_connections WHERE id = ?').run(id);
await this.connections.deleteById(id);
}
// ── Trip bindings ──────────────────────────────────────────────────────────
async listLinks(tripId: number): Promise<LinkRow[]> {
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<LinkRow | undefined> {
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<LinkRow | undefined> {
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<DocsyncLinkInput>): Promise<LinkRow | undefined> {
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<void> {
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<void> {
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<boolean> {
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<number> {
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);
}
}
@@ -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<boolean> {
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<DocSyncConfigService['getLink']>, 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);
+21 -40
View File
@@ -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<void> {
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')
+8 -7
View File
@@ -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<void> {
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<number> {
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<void> {
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;
@@ -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: [
+185 -324
View File
@@ -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<number>();
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<LinkRow[]> {
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<boolean> {
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<void> {
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<SyncItemState[]> {
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<Record<string, unknown>>;
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<LocalDocument[]> {
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<Record<string, unknown>>;
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<number | null> {
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<string | null> {
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<string> {
@@ -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<void> {
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<void> {
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<boolean> {
const item = this.db.connection
.prepare('SELECT * FROM document_sync_items WHERE id = ?')
.get(itemId) as Record<string, unknown> | 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<Array<Record<string, unknown>>> {
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<Record<string, unknown>>;
async issues(tripId: number): Promise<DocumentSyncIssueRow[]> {
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<Record<string, unknown>> {
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),
+105
View File
@@ -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<Database.Database, Promise<DocumentConnectionsRepository>>();
const documentProvidersPerHandle = new WeakMap<Database.Database, Promise<DocumentProvidersRepository>>();
const documentProviderFieldsPerHandle = new WeakMap<Database.Database, Promise<DocumentProviderFieldsRepository>>();
const tripDocumentLinksPerHandle = new WeakMap<Database.Database, Promise<TripDocumentLinksRepository>>();
const documentSyncItemsPerHandle = new WeakMap<Database.Database, Promise<DocumentSyncItemsRepository>>();
const tripFilesPerHandle = new WeakMap<Database.Database, Promise<TripFilesRepository>>();
const fileLinksPerHandle = new WeakMap<Database.Database, Promise<FileLinksRepository>>();
/** The `DocumentConnectionsRepository` a hand-constructed `DocSyncConfigService` needs. */
export function createTestDocumentConnectionsRepo(db: Database.Database): Promise<DocumentConnectionsRepository> {
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<DocumentProvidersRepository> {
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<DocumentProviderFieldsRepository> {
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<TripDocumentLinksRepository> {
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<DocumentSyncItemsRepository> {
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<TripFilesRepository> {
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<FileLinksRepository> {
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;
}
@@ -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,
);
});
@@ -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<UsersKyselyDB, 'users'>();
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<UsersKyselyDB>()
.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/);
});
});
});
/**
@@ -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<Parameters<DocumentSyncItemsRepository['insertOrUpsertOnConflict']>[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<string, unknown> }>()
.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<string, unknown>;
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<string, unknown>;
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');
});
});
});
@@ -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<string | undefined> {
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);
@@ -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> = {}): 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<string, string>();
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<string, string> = {}, 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();
@@ -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;
@@ -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<Setup> = {}) {
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<T>(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<string
// | null>` 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<void>) | undefined;
@@ -99,13 +106,13 @@ function makeJob(over: Partial<Setup> = {}) {
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<string, string>([[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) => {
@@ -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]);