From fa6753c79ac6701c24ffaf020254c9f5554dc949 Mon Sep 17 00:00:00 2001 From: muokwejosh-cloud Date: Thu, 23 Jul 2026 21:33:11 +0100 Subject: [PATCH 1/2] Add contiguous Stellar indexer checkpoints --- lib/stellar/indexer.ts | 307 +++++++++++++++++++++++++++++-- models/StellarDeadLetterEvent.ts | 54 ++++++ models/StellarIndexerCursor.ts | 26 ++- models/StellarRawEvent.ts | 61 ++++++ 4 files changed, 432 insertions(+), 16 deletions(-) create mode 100644 models/StellarDeadLetterEvent.ts create mode 100644 models/StellarRawEvent.ts diff --git a/lib/stellar/indexer.ts b/lib/stellar/indexer.ts index d5532377..103dbea3 100644 --- a/lib/stellar/indexer.ts +++ b/lib/stellar/indexer.ts @@ -23,6 +23,7 @@ import type { StellarEventType, ChainMoveRecordType } from "@/models/StellarIndexedEvent" import { getStellarConfig } from "@/lib/stellar/config" +import crypto from "crypto" // --------------------------------------------------------------------------- // Public types @@ -60,6 +61,9 @@ export interface StellarIndexerSyncResult { duplicates: number errors: number lastCursor: string | null + rawPersisted?: number + deadLetters?: number + leaseAcquired?: boolean } /** Options for creating an indexer instance. */ @@ -79,6 +83,10 @@ export interface StellarIndexerOptions { * If false, forces live mode. If omitted the config's `mock` flag is used. */ mock?: boolean + workerId?: string + leaseMs?: number + expectedNetwork?: string + expectedContractId?: string } // --------------------------------------------------------------------------- @@ -145,13 +153,103 @@ async function loadCursor(streamId: string): Promise { return doc?.cursor ?? null } -async function saveCursor(streamId: string, cursor: string): Promise { +async function saveCursor(streamId: string, cursor: string, leaseToken?: string): Promise { const { default: StellarIndexerCursor } = await import("@/models/StellarIndexerCursor") - await StellarIndexerCursor.findByIdAndUpdate( - streamId, - { $set: { streamId, cursor } }, + const filter: Record = { _id: streamId } + if (leaseToken) filter.leaseToken = leaseToken + const result = await StellarIndexerCursor.findOneAndUpdate( + filter, + { $set: { streamId, cursor, rawCursor: cursor, projectionCursor: cursor, lastHeartbeatAt: new Date() } }, { upsert: true, new: true }, - ) + ).lean() + if (!result) throw new Error("Stellar indexer lease lost while saving cursor") +} + +async function acquireLease(streamId: string, workerId: string, leaseMs: number, network: string): Promise { + const { default: StellarIndexerCursor } = await import("@/models/StellarIndexerCursor") + const now = new Date() + const leaseToken = crypto.randomUUID() + const leaseExpiresAt = new Date(now.getTime() + leaseMs) + const doc = await StellarIndexerCursor.findOneAndUpdate( + { + _id: streamId, + $or: [ + { leaseExpiresAt: { $exists: false } }, + { leaseExpiresAt: { $lte: now } }, + { leaseOwner: workerId }, + ], + }, + { + $set: { + streamId, + network, + leaseOwner: workerId, + leaseToken, + leaseExpiresAt, + lastHeartbeatAt: now, + }, + $setOnInsert: { cursor: "" }, + }, + { upsert: true, new: true }, + ).lean() + return doc?.leaseToken === leaseToken ? leaseToken : null +} + +function parseSequence(op: RawStellarOperation): number { + const token = String(op.paging_token || "") + const numeric = Number(token) + if (Number.isFinite(numeric) && numeric > 0) return numeric + const ledger = Number(op.ledger_attr || 0) + const eventIndex = Number((op as any).event_index ?? (op as any).operation_index ?? 0) + if (ledger > 0) return ledger * 1_000_000 + eventIndex + const digits = token.match(/\d+/g)?.join("") + return digits ? Number(digits) : Math.abs(hashString(token || op.id)) +} + +function hashString(value: string): number { + return crypto.createHash("sha256").update(value).digest().readUInt32BE(0) +} + +function rawEventKey(op: RawStellarOperation, streamId: string, network: string) { + const eventIndex = Number((op as any).event_index ?? (op as any).operation_index ?? 0) + return { + network, + streamId, + ledger: Number(op.ledger_attr || 0), + transactionHash: op.transaction_hash || op.id, + eventIndex, + } +} + +async function persistRawEnvelope( + op: RawStellarOperation, + streamId: string, + network: string, + expectedContractId?: string, +): Promise<{ inserted: boolean; duplicate: boolean; sequence: number; error?: string }> { + const { default: StellarRawEvent } = await import("@/models/StellarRawEvent") + const contractId = typeof op.contract_id === "string" ? op.contract_id : undefined + if (expectedContractId && contractId && contractId !== expectedContractId) { + return { inserted: false, duplicate: false, sequence: parseSequence(op), error: "wrong contract" } + } + const doc = { + ...rawEventKey(op, streamId, network), + sequence: parseSequence(op), + pagingToken: op.paging_token, + operationId: op.id, + contractId, + status: "received", + raw: op as Record, + } + try { + await StellarRawEvent.create(doc) + return { inserted: true, duplicate: false, sequence: doc.sequence } + } catch (err: unknown) { + if (typeof err === "object" && err !== null && "code" in err && (err as { code?: unknown }).code === 11000) { + return { inserted: false, duplicate: true, sequence: doc.sequence } + } + return { inserted: false, duplicate: false, sequence: doc.sequence, error: err instanceof Error ? err.message : String(err) } + } } // --------------------------------------------------------------------------- @@ -203,6 +301,55 @@ async function persistEvent(op: RawStellarOperation): Promise { } } +async function markProjected(op: RawStellarOperation, streamId: string, network: string): Promise { + const { default: StellarRawEvent } = await import("@/models/StellarRawEvent") + await StellarRawEvent.findOneAndUpdate(rawEventKey(op, streamId, network), { + $set: { status: "projected", projectedAt: new Date(), lastError: undefined }, + $inc: { attempts: 1 }, + }) +} + +async function quarantineDeadLetter( + op: RawStellarOperation, + streamId: string, + network: string, + reason: string, +): Promise { + const { default: StellarRawEvent } = await import("@/models/StellarRawEvent") + const { default: StellarDeadLetterEvent } = await import("@/models/StellarDeadLetterEvent") + const key = rawEventKey(op, streamId, network) + await StellarRawEvent.findOneAndUpdate(key, { + $set: { status: "dead_letter", lastError: reason }, + $inc: { attempts: 1 }, + }) + await StellarDeadLetterEvent.findOneAndUpdate( + key, + { + $set: { + ...key, + pagingToken: op.paging_token, + operationId: op.id, + contractId: typeof op.contract_id === "string" ? op.contract_id : undefined, + reason, + raw: op as Record, + }, + $inc: { attempts: 1 }, + }, + { upsert: true, new: true }, + ) +} + +async function findHighestContiguousCursor(streamId: string, network: string, fetchedOps: RawStellarOperation[]) { + const { default: StellarRawEvent } = await import("@/models/StellarRawEvent") + let cursor: string | null = null + for (const op of fetchedOps) { + const raw = await StellarRawEvent.findOne(rawEventKey(op, streamId, network)).lean() + if (!raw || raw.status !== "projected") break + cursor = op.paging_token + } + return cursor +} + // --------------------------------------------------------------------------- // Mock data source // --------------------------------------------------------------------------- @@ -334,12 +481,34 @@ export interface StellarIndexer { * MongoDB `_id` and counted in `duplicates` rather than raising an error. */ sync(): Promise + replayDeadLetters(limit?: number): Promise + health(): Promise /** Returns the current stream ID. */ streamId: string /** Whether this indexer instance is running in mock mode. */ isMock: boolean } +export interface StellarIndexerHealthReport { + streamId: string + network: string + sourceCursor: string | null + rawCheckpoint: string | null + projectionCheckpoint: string | null + leaseOwner?: string + leaseExpiresAt?: string + lag: { + rawUnprojected: number + deadLetters: number + } + oldestFailure?: { + sequence: number + operationId: string + reason: string + createdAt: string + } +} + /** * Creates a Stellar event indexer configured via `getStellarConfig()` and * the optional `options` overrides. @@ -356,15 +525,27 @@ export function createStellarIndexer(options: StellarIndexerOptions = {}): Stell const streamId = options.streamId ?? "payments" const limit = options.limit ?? 50 const isMock = options.mock !== undefined ? options.mock : config.mock + const workerId = options.workerId ?? `${streamId}-${process.pid}` + const leaseMs = options.leaseMs ?? 30_000 + const network = (options.expectedNetwork ?? config.network).toLowerCase() + const expectedContractId = options.expectedContractId ?? config.contractId async function sync(): Promise { let processed = 0 let duplicates = 0 let errors = 0 + let rawPersisted = 0 + let deadLetters = 0 let lastCursor: string | null = null console.info(`[StellarIndexer] sync start — stream=${streamId} mock=${isMock}`) + const leaseToken = await acquireLease(streamId, workerId, leaseMs, network) + if (!leaseToken) { + console.info(`[StellarIndexer] lease busy - stream=${streamId}`) + return { processed, duplicates, errors, lastCursor, rawPersisted, deadLetters, leaseAcquired: false } + } + // 1. Load last cursor const cursor = await loadCursor(streamId) console.info(`[StellarIndexer] resuming from cursor=${cursor ?? "beginning"}`) @@ -382,33 +563,53 @@ export function createStellarIndexer(options: StellarIndexerOptions = {}): Stell } catch (err) { const message = err instanceof Error ? err.message : String(err) console.error(`[StellarIndexer] upstream fetch failed: ${message}`) - return { processed: 0, duplicates: 0, errors: 1, lastCursor: cursor } + return { processed: 0, duplicates: 0, errors: 1, lastCursor: cursor, rawPersisted, deadLetters, leaseAcquired: true } } - // 3. Persist each operation + // 3. Persist raw envelopes first, then project idempotently. for (const op of operations) { + if ((op as any).network && String((op as any).network).toLowerCase() !== network) { + errors++ + deadLetters++ + await quarantineDeadLetter(op, streamId, network, "wrong network") + console.error(`[StellarIndexer] wrong network id=${op.id}`) + continue + } + + const rawResult = await persistRawEnvelope(op, streamId, network, expectedContractId) + if (rawResult.inserted) rawPersisted++ + if (rawResult.error) { + errors++ + deadLetters++ + await quarantineDeadLetter(op, streamId, network, rawResult.error) + console.error(`[StellarIndexer] raw envelope rejected id=${op.id}: ${rawResult.error}`) + continue + } + const result = await persistEvent(op) if (result.inserted) { processed++ - lastCursor = op.paging_token + await markProjected(op, streamId, network) console.info( `[StellarIndexer] indexed id=${op.id} type=${op.type} chainMoveRecord=${mapEventToChainMoveRecord(op)}`, ) } else if (result.duplicate) { duplicates++ - // Advance cursor past the duplicate so we don't replay it forever. - lastCursor = op.paging_token + await markProjected(op, streamId, network) console.info(`[StellarIndexer] duplicate id=${op.id} — skipping`) } else { errors++ + deadLetters++ + await quarantineDeadLetter(op, streamId, network, result.error || "projection failed") console.error(`[StellarIndexer] error persisting id=${op.id}: ${result.error}`) } } - // 4. Persist the latest cursor if anything was processed or skipped + // 4. Persist only the latest contiguous projected cursor. + lastCursor = await findHighestContiguousCursor(streamId, network, operations) if (lastCursor) { - await saveCursor(streamId, lastCursor) + await saveCursor(streamId, lastCursor, leaseToken) console.info(`[StellarIndexer] cursor saved: ${lastCursor}`) } @@ -416,8 +617,86 @@ export function createStellarIndexer(options: StellarIndexerOptions = {}): Stell `[StellarIndexer] sync complete — processed=${processed} duplicates=${duplicates} errors=${errors}`, ) - return { processed, duplicates, errors, lastCursor } + return { processed, duplicates, errors, lastCursor, rawPersisted, deadLetters, leaseAcquired: true } + } + + async function replayDeadLetters(replayLimit = 25): Promise { + const { default: StellarDeadLetterEvent } = await import("@/models/StellarDeadLetterEvent") + const leaseToken = await acquireLease(streamId, workerId, leaseMs, network) + if (!leaseToken) { + return { processed: 0, duplicates: 0, errors: 0, lastCursor: null, rawPersisted: 0, deadLetters: 0, leaseAcquired: false } + } + + let processed = 0 + let duplicates = 0 + let errors = 0 + const failures = await StellarDeadLetterEvent.find({ network, streamId, resolvedAt: { $exists: false } }) + .sort({ sequence: 1 }) + .limit(Math.max(1, Math.min(replayLimit, 100))) + .lean() + + for (const failure of failures) { + const op = failure.raw as RawStellarOperation + const result = await persistEvent(op) + await StellarDeadLetterEvent.findByIdAndUpdate(failure._id, { + $inc: { replayCount: 1 }, + $set: { lastReplayAt: new Date() }, + }) + if (result.inserted || result.duplicate) { + if (result.inserted) processed++ + if (result.duplicate) duplicates++ + await markProjected(op, streamId, network) + await StellarDeadLetterEvent.findByIdAndUpdate(failure._id, { $set: { resolvedAt: new Date() } }) + } else { + errors++ + await quarantineDeadLetter(op, streamId, network, result.error || "replay projection failed") + } + } + + const rawEvents = await (await import("@/models/StellarRawEvent")).default + .find({ network, streamId, status: "projected" }) + .sort({ sequence: 1 }) + .lean() + const contiguousOps = rawEvents.map((event: any) => event.raw as RawStellarOperation) + const lastCursor = await findHighestContiguousCursor(streamId, network, contiguousOps) + if (lastCursor) await saveCursor(streamId, lastCursor, leaseToken) + + return { processed, duplicates, errors, lastCursor, rawPersisted: 0, deadLetters: errors, leaseAcquired: true } + } + + async function health(): Promise { + const [{ default: StellarIndexerCursor }, { default: StellarRawEvent }, { default: StellarDeadLetterEvent }] = + await Promise.all([ + import("@/models/StellarIndexerCursor"), + import("@/models/StellarRawEvent"), + import("@/models/StellarDeadLetterEvent"), + ]) + const [cursor, rawUnprojected, deadLetters, oldestFailure] = await Promise.all([ + StellarIndexerCursor.findById(streamId).lean(), + StellarRawEvent.countDocuments({ network, streamId, status: "received" }), + StellarDeadLetterEvent.countDocuments({ network, streamId, resolvedAt: { $exists: false } }), + StellarDeadLetterEvent.findOne({ network, streamId, resolvedAt: { $exists: false } }).sort({ sequence: 1 }).lean(), + ]) + + return { + streamId, + network, + sourceCursor: cursor?.cursor || null, + rawCheckpoint: cursor?.rawCursor || cursor?.cursor || null, + projectionCheckpoint: cursor?.projectionCursor || cursor?.cursor || null, + leaseOwner: cursor?.leaseOwner, + leaseExpiresAt: cursor?.leaseExpiresAt?.toISOString?.(), + lag: { rawUnprojected, deadLetters }, + oldestFailure: oldestFailure + ? { + sequence: oldestFailure.sequence, + operationId: oldestFailure.operationId, + reason: oldestFailure.reason, + createdAt: oldestFailure.createdAt.toISOString(), + } + : undefined, + } } - return { sync, streamId, isMock } + return { sync, replayDeadLetters, health, streamId, isMock } } diff --git a/models/StellarDeadLetterEvent.ts b/models/StellarDeadLetterEvent.ts new file mode 100644 index 00000000..64141673 --- /dev/null +++ b/models/StellarDeadLetterEvent.ts @@ -0,0 +1,54 @@ +import mongoose, { Schema } from "mongoose" + +export interface IStellarDeadLetterEvent { + network: string + streamId: string + sequence: number + pagingToken: string + operationId: string + ledger: number + transactionHash: string + eventIndex: number + contractId?: string + reason: string + attempts: number + replayCount: number + lastReplayAt?: Date + resolvedAt?: Date + raw: Record + createdAt: Date + updatedAt: Date +} + +const StellarDeadLetterEventSchema = new Schema( + { + network: { type: String, required: true, trim: true, lowercase: true, index: true }, + streamId: { type: String, required: true, trim: true, index: true }, + sequence: { type: Number, required: true, index: true }, + pagingToken: { type: String, required: true, trim: true }, + operationId: { type: String, required: true, trim: true, index: true }, + ledger: { type: Number, required: true, index: true }, + transactionHash: { type: String, required: true, trim: true }, + eventIndex: { type: Number, required: true }, + contractId: { type: String, trim: true, index: true }, + reason: { type: String, required: true }, + attempts: { type: Number, required: true, default: 1 }, + replayCount: { type: Number, required: true, default: 0 }, + lastReplayAt: { type: Date }, + resolvedAt: { type: Date, index: true }, + raw: { type: Schema.Types.Mixed, required: true }, + }, + { timestamps: true }, +) + +StellarDeadLetterEventSchema.index( + { network: 1, streamId: 1, ledger: 1, transactionHash: 1, eventIndex: 1 }, + { unique: true }, +) +StellarDeadLetterEventSchema.index({ network: 1, streamId: 1, resolvedAt: 1, sequence: 1 }) + +export default (mongoose.models.StellarDeadLetterEvent || + mongoose.model("StellarDeadLetterEvent", StellarDeadLetterEventSchema)) as mongoose.Model<{ + _id: any + [key: string]: any +}> diff --git a/models/StellarIndexerCursor.ts b/models/StellarIndexerCursor.ts index 67fd10d3..86cd7441 100644 --- a/models/StellarIndexerCursor.ts +++ b/models/StellarIndexerCursor.ts @@ -11,12 +11,25 @@ export interface IStellarIndexerCursor { streamId: string /** Stellar Horizon paging token / cursor value for the last processed record. */ cursor: string + rawCursor?: string + projectionCursor?: string + network?: string + leaseOwner?: string + leaseToken?: string + leaseExpiresAt?: Date + lastHeartbeatAt?: Date + lastErrorAt?: Date + lastError?: string updatedAt: Date createdAt: Date } const StellarIndexerCursorSchema = new Schema( { + _id: { + type: String, + required: true, + }, streamId: { type: String, required: true, @@ -26,12 +39,21 @@ const StellarIndexerCursorSchema = new Schema( }, cursor: { type: String, - required: true, + default: "", trim: true, }, + rawCursor: { type: String, trim: true }, + projectionCursor: { type: String, trim: true }, + network: { type: String, trim: true, lowercase: true, index: true }, + leaseOwner: { type: String, trim: true }, + leaseToken: { type: String, trim: true }, + leaseExpiresAt: { type: Date, index: true }, + lastHeartbeatAt: { type: Date }, + lastErrorAt: { type: Date }, + lastError: { type: String }, }, { timestamps: true }, ) export default (mongoose.models.StellarIndexerCursor || - mongoose.model("StellarIndexerCursor", StellarIndexerCursorSchema)) as mongoose.Model<{ _id: any; [key: string]: any }>; \ No newline at end of file + mongoose.model("StellarIndexerCursor", StellarIndexerCursorSchema)) as mongoose.Model<{ _id: any; [key: string]: any }>; diff --git a/models/StellarRawEvent.ts b/models/StellarRawEvent.ts new file mode 100644 index 00000000..1138044e --- /dev/null +++ b/models/StellarRawEvent.ts @@ -0,0 +1,61 @@ +import mongoose, { Schema } from "mongoose" + +export type StellarRawEventStatus = "received" | "projected" | "dead_letter" + +export interface IStellarRawEvent { + network: string + streamId: string + sequence: number + pagingToken: string + ledger: number + transactionHash: string + eventIndex: number + operationId: string + contractId?: string + status: StellarRawEventStatus + attempts: number + lastError?: string + projectedAt?: Date + raw: Record + createdAt: Date + updatedAt: Date +} + +const StellarRawEventSchema = new Schema( + { + network: { type: String, required: true, trim: true, lowercase: true, index: true }, + streamId: { type: String, required: true, trim: true, index: true }, + sequence: { type: Number, required: true, index: true }, + pagingToken: { type: String, required: true, trim: true, index: true }, + ledger: { type: Number, required: true, index: true }, + transactionHash: { type: String, required: true, trim: true }, + eventIndex: { type: Number, required: true, min: 0 }, + operationId: { type: String, required: true, trim: true, index: true }, + contractId: { type: String, trim: true, index: true }, + status: { + type: String, + enum: ["received", "projected", "dead_letter"], + required: true, + default: "received", + index: true, + }, + attempts: { type: Number, required: true, default: 0, min: 0 }, + lastError: { type: String }, + projectedAt: { type: Date }, + raw: { type: Schema.Types.Mixed, required: true }, + }, + { timestamps: true }, +) + +StellarRawEventSchema.index( + { network: 1, streamId: 1, ledger: 1, transactionHash: 1, eventIndex: 1 }, + { unique: true }, +) +StellarRawEventSchema.index({ network: 1, streamId: 1, sequence: 1 }, { unique: true }) +StellarRawEventSchema.index({ network: 1, streamId: 1, status: 1, sequence: 1 }) + +export default (mongoose.models.StellarRawEvent || + mongoose.model("StellarRawEvent", StellarRawEventSchema)) as mongoose.Model<{ + _id: any + [key: string]: any +}> From fc8c53640e5e87d4bc5142d3386e11b6dd07a1f9 Mon Sep 17 00:00:00 2001 From: muokwejosh-cloud Date: Thu, 23 Jul 2026 22:09:16 +0100 Subject: [PATCH 2/2] Fix indexer cursor typing and CI OpenAPI check --- models/StellarIndexerCursor.ts | 9 +++++---- package.json | 2 ++ scripts/check-openapi-drift.ts | 7 ++++++- 3 files changed, 13 insertions(+), 5 deletions(-) diff --git a/models/StellarIndexerCursor.ts b/models/StellarIndexerCursor.ts index 86cd7441..8315ed75 100644 --- a/models/StellarIndexerCursor.ts +++ b/models/StellarIndexerCursor.ts @@ -1,4 +1,4 @@ -import mongoose, { Document, Schema } from "mongoose" +import mongoose, { Schema } from "mongoose" /** * Persists the last successfully processed Stellar cursor (paging token) @@ -7,6 +7,7 @@ import mongoose, { Document, Schema } from "mongoose" * full history on every run. */ export interface IStellarIndexerCursor { + _id: string /** Stable identifier for the indexer stream, e.g. "payments", "operations". */ streamId: string /** Stellar Horizon paging token / cursor value for the last processed record. */ @@ -20,8 +21,8 @@ export interface IStellarIndexerCursor { lastHeartbeatAt?: Date lastErrorAt?: Date lastError?: string - updatedAt: Date - createdAt: Date + updatedAt?: Date + createdAt?: Date } const StellarIndexerCursorSchema = new Schema( @@ -56,4 +57,4 @@ const StellarIndexerCursorSchema = new Schema( ) export default (mongoose.models.StellarIndexerCursor || - mongoose.model("StellarIndexerCursor", StellarIndexerCursorSchema)) as mongoose.Model<{ _id: any; [key: string]: any }>; + mongoose.model("StellarIndexerCursor", StellarIndexerCursorSchema)) as mongoose.Model diff --git a/package.json b/package.json index 38eb1a93..3c40ad71 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,8 @@ "lint": "eslint .", "test": "vitest run", "typecheck": "tsc --noEmit", + "openapi:generate": "tsx scripts/generate-openapi.ts", + "openapi:check": "tsx scripts/check-openapi-drift.ts && tsx scripts/check-openapi-compat.ts", "start": "next start", "fx:legacy-check": "tsx scripts/check-legacy-fx-transactions.ts", "demo:pool-asset": "tsx scripts/demo-pool-asset.ts" diff --git a/scripts/check-openapi-drift.ts b/scripts/check-openapi-drift.ts index 779f1fe5..d8331487 100644 --- a/scripts/check-openapi-drift.ts +++ b/scripts/check-openapi-drift.ts @@ -1,9 +1,14 @@ import { readFileSync } from "fs" import { execFileSync } from "child_process" +import { createRequire } from "module" +import { dirname, resolve } from "path" const target = "docs/openapi/chainmove.openapi.json" +const require = createRequire(import.meta.url) +const tsxPackageRoot = dirname(require.resolve("tsx/package.json")) +const tsxCli = resolve(tsxPackageRoot, "dist/cli.mjs") const before = readFileSync(target, "utf8") -execFileSync("npx", ["tsx", "scripts/generate-openapi.ts"], { stdio: "inherit" }) +execFileSync(process.execPath, [tsxCli, "scripts/generate-openapi.ts"], { stdio: "inherit" }) const after = readFileSync(target, "utf8") if (before !== after) {