Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 71 additions & 4 deletions src/diff.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
/**
* Invoice diff utility — compare two invoice states.
*
* Pure function with no RPC calls or side effects.
* Returns structured diff showing only changed fields.
* Invoice diff utility — compare two invoice states and perform three-way
* merge for invoice state reconciliation.
*
* Pure functions with no RPC calls or side effects.
* Returns structured diff showing only changed fields, or a merged invoice.
*/

import type { Invoice } from "./types.js";
import { MergeConflictError } from "./errors.js";

/**
* A single field change in an invoice diff.
Expand Down Expand Up @@ -189,3 +191,68 @@ export function diffInvoices(a: Invoice, b: Invoice): InvoiceDiff {
export function hasDiff(a: Invoice, b: Invoice): boolean {
return diffInvoices(a, b).length > 0;
}

/**
* Perform a three-way merge of invoice states.
*
* Compares `local` and `remote` each against the common `base` ancestor and
* produces a single merged invoice according to these rules:
*
* - **Neither branch modified the field** → keep the base value unchanged.
* - **Only one branch modified the field** → use that branch's value (fast-forward).
* - **Both branches modified the field to different values** → throw {@link MergeConflictError}.
* - **Both branches modified the field to the *same* value** → use that value (no conflict).
*
* @param base - The common ancestor invoice (fork point).
* @param local - The locally-modified copy of the invoice.
* @param remote - The remotely-modified copy of the invoice.
* @returns A new merged `Invoice` object.
* @throws {MergeConflictError} When both branches diverge on the same field.
*
* @example
* ```typescript
* const base = await client.getInvoice("123"); // original snapshot
* const local = { ...base, memo: "updated locally" };
* const remote = await client.getInvoice("123"); // re-fetched after remote edit
*
* const merged = mergeInvoices(base, local, remote);
* ```
*/
export function mergeInvoices(base: Invoice, local: Invoice, remote: Invoice): Invoice {
const merged: Invoice = { ...base };

for (const field of INVOICE_FIELDS) {
const baseVal = base[field];
const localVal = local[field];
const remoteVal = remote[field];

const localChanged = !valuesEqual(baseVal, localVal);
const remoteChanged = !valuesEqual(baseVal, remoteVal);

if (!localChanged && !remoteChanged) {
// Neither branch touched this field — keep base value.
continue;
}

if (localChanged && !remoteChanged) {
// Only local changed — take local value.
(merged as Record<string, unknown>)[field] = localVal;
continue;
}

if (!localChanged && remoteChanged) {
// Only remote changed — take remote value.
(merged as Record<string, unknown>)[field] = remoteVal;
continue;
}

// Both changed — conflict unless they converged to the same value.
if (valuesEqual(localVal, remoteVal)) {
(merged as Record<string, unknown>)[field] = localVal;
} else {
throw new MergeConflictError(field, baseVal, localVal, remoteVal);
}
}

return merged;
}
48 changes: 48 additions & 0 deletions src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2163,3 +2163,51 @@ export class SdkError extends Error {
export function isSdkError(err: unknown): err is SdkError {
return err instanceof SdkError;
}

// ---------------------------------------------------------------------------
// Three-way merge errors (issue #703)
// ---------------------------------------------------------------------------

/**
* Thrown by {@link mergeInvoices} when both local and remote branches have
* modified the same field relative to the common base, producing a conflict
* that cannot be resolved automatically.
*/
export class MergeConflictError extends StellarSplitError {
/** The invoice field that caused the conflict. */
readonly field: string;
/** The value of the field on the base (common ancestor) invoice. */
readonly baseValue: unknown;
/** The value of the field on the local branch. */
readonly localValue: unknown;
/** The value of the field on the remote branch. */
readonly remoteValue: unknown;

constructor(
field: string,
baseValue: unknown,
localValue: unknown,
remoteValue: unknown,
) {
super(
`Merge conflict on field "${field}": both branches diverged from base`,
"MERGE_CONFLICT",
{
field,
baseValue: typeof baseValue === "bigint" ? baseValue.toString() : baseValue,
localValue: typeof localValue === "bigint" ? localValue.toString() : localValue,
remoteValue: typeof remoteValue === "bigint" ? remoteValue.toString() : remoteValue,
},
);
this.name = "MergeConflictError";
this.field = field;
this.baseValue = baseValue;
this.localValue = localValue;
this.remoteValue = remoteValue;
Object.setPrototypeOf(this, new.target.prototype);
}
}

export function isMergeConflictError(err: unknown): err is MergeConflictError {
return err instanceof MergeConflictError;
}
131 changes: 131 additions & 0 deletions src/standby.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,3 +51,134 @@ export class WarmStandby {
}
}
}

// ---------------------------------------------------------------------------
// StandbyController — issue #702
// ---------------------------------------------------------------------------

/** Options accepted by {@link StandbyController}. */
export interface StandbyControllerOptions {
/**
* Duration in milliseconds during which standby activation is suppressed
* after {@link StandbyController.start} is called.
*
* Useful to prevent normal initialisation traffic from falsely triggering
* standby mode during startup.
*
* @default 0
*/
warmUpMs?: number;

/**
* Milliseconds of inactivity before standby mode is activated.
*
* @default 30_000
*/
inactivityMs?: number;
}

/**
* Controls standby-mode activation based on inactivity, with an optional
* warm-up window that suppresses standby during startup.
*
* @example
* ```typescript
* const ctrl = new StandbyController({ warmUpMs: 5_000, inactivityMs: 30_000 });
* ctrl.onStandby(() => console.log("entered standby"));
* ctrl.start();
*
* // Record activity whenever the SDK makes an RPC call:
* ctrl.recordActivity();
* ```
*/
export class StandbyController {
private readonly warmUpMs: number;
private readonly inactivityMs: number;
private standbyListeners: Array<() => void> = [];
private inactivityHandle: ReturnType<typeof setTimeout> | null = null;
private warmUpHandle: ReturnType<typeof setTimeout> | null = null;
private isWarmedUp = false;
private isStandby = false;
private started = false;

constructor(options: StandbyControllerOptions = {}) {
this.warmUpMs = options.warmUpMs ?? 0;
this.inactivityMs = options.inactivityMs ?? 30_000;
}

/**
* Register a callback invoked when standby mode activates.
*/
onStandby(listener: () => void): void {
this.standbyListeners.push(listener);
}

/**
* Start the controller. The warm-up timer begins immediately; inactivity
* detection only arms once the warm-up period has elapsed.
*/
start(): void {
if (this.started) return;
this.started = true;
this.isStandby = false;

if (this.warmUpMs > 0) {
// Suppress inactivity detection during the warm-up window.
this.warmUpHandle = setTimeout(() => {
this.warmUpHandle = null;
this.isWarmedUp = true;
this.scheduleStandby();
}, this.warmUpMs);
} else {
this.isWarmedUp = true;
this.scheduleStandby();
}
}

/**
* Record that activity occurred. Resets the inactivity timer (but only
* after the warm-up period has ended).
*/
recordActivity(): void {
if (!this.isWarmedUp) return;
this.cancelStandby();
this.scheduleStandby();
}

/** Whether the controller is currently in standby mode. */
get standby(): boolean {
return this.isStandby;
}

/** Stop the controller and cancel all pending timers. */
stop(): void {
this.cancelStandby();
if (this.warmUpHandle !== null) {
clearTimeout(this.warmUpHandle);
this.warmUpHandle = null;
}
this.started = false;
this.isWarmedUp = false;
this.isStandby = false;
}

// ---- private helpers ----

private scheduleStandby(): void {
this.inactivityHandle = setTimeout(() => {
this.inactivityHandle = null;
this.isStandby = true;
for (const listener of this.standbyListeners) {
listener();
}
}, this.inactivityMs);
}

private cancelStandby(): void {
if (this.inactivityHandle !== null) {
clearTimeout(this.inactivityHandle);
this.inactivityHandle = null;
}
this.isStandby = false;
}
}
Loading