mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
refactor(doc-sync): connections, reconciler and controller-SQL relocation through repositories
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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]);
|
||||
|
||||
Reference in New Issue
Block a user