@develit-services/bank 5.7.0 → 5.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,10 +1,10 @@
1
1
  'use strict';
2
2
 
3
3
  const backendSdk = require('@develit-io/backend-sdk');
4
- const paymentDirection = require('../shared/bank.B1Xa5U2u.cjs');
4
+ const paymentDirection = require('../shared/bank.CQjuudum.cjs');
5
5
  const batchLifecycle = require('../shared/bank.NF8bZBy0.cjs');
6
6
  const drizzleOrm = require('drizzle-orm');
7
- const bank = require('../shared/bank.BcAwr2OG.cjs');
7
+ const bank = require('../shared/bank.Cy7xa566.cjs');
8
8
  const cloudflare_workers = require('cloudflare:workers');
9
9
  const cloudflare_workflows = require('cloudflare:workflows');
10
10
  const d1 = require('drizzle-orm/d1');
@@ -20,12 +20,17 @@ require('drizzle-orm/zod');
20
20
  const updateAccountLastSyncCommand = (db, {
21
21
  lastSyncAt,
22
22
  accountId,
23
- lastSyncMetadata
23
+ lastSyncMetadata,
24
+ seenInstanceId
24
25
  }) => {
26
+ const markerUnchanged = seenInstanceId === void 0 ? void 0 : (
27
+ // IS instead of = so a null marker compares equal to null.
28
+ drizzleOrm.sql`json_extract(${paymentDirection.tables.account.lastSyncMetadata}, '$.instanceId') IS ${seenInstanceId}`
29
+ );
25
30
  const command = db.update(paymentDirection.tables.account).set({
26
31
  lastSyncAt,
27
32
  lastSyncMetadata
28
- }).where(drizzleOrm.eq(paymentDirection.tables.account.id, accountId)).returning();
33
+ }).where(drizzleOrm.and(drizzleOrm.eq(paymentDirection.tables.account.id, accountId), markerUnchanged)).returning();
29
34
  return {
30
35
  command
31
36
  };
@@ -340,6 +345,72 @@ function getStepCount(ctx) {
340
345
  return ctx.step?.count ?? 0;
341
346
  }
342
347
 
348
+ const MAX_CATCH_UP_WINDOW_MS = 7 * 24 * 60 * 60 * 1e3;
349
+ function resolveSyncWindow(lastSyncAt, nowMs, maxWindowMs) {
350
+ const openEnded = {
351
+ dateFrom: lastSyncAt,
352
+ dateTo: new Date(Math.max(nowMs, lastSyncAt.getTime())),
353
+ isCatchUp: false
354
+ };
355
+ if (!Number.isFinite(maxWindowMs) || maxWindowMs <= 0) return openEnded;
356
+ const clippedToMs = lastSyncAt.getTime() + maxWindowMs;
357
+ if (clippedToMs >= nowMs) return openEnded;
358
+ return {
359
+ dateFrom: lastSyncAt,
360
+ dateTo: new Date(clippedToMs),
361
+ isCatchUp: true
362
+ };
363
+ }
364
+
365
+ const STEP_RESULT_BYTE_CEILING = 800 * 1024;
366
+ class StepResultTooLargeError extends cloudflare_workflows.NonRetryableError {
367
+ }
368
+ function guardStepResultSize(value, ceilingBytes = STEP_RESULT_BYTE_CEILING) {
369
+ const bytes = new TextEncoder().encode(JSON.stringify(value)).byteLength;
370
+ if (bytes > ceilingBytes) {
371
+ const count = Array.isArray(value) ? value.length : 1;
372
+ throw new StepResultTooLargeError(
373
+ `Fetched ${count} payments (${bytes} bytes), over the ${ceilingBytes} byte step result ceiling`
374
+ );
375
+ }
376
+ }
377
+ function rethrowFetchError(err) {
378
+ if (err instanceof cloudflare_workflows.NonRetryableError) throw err;
379
+ const message = err instanceof Error ? err.message : typeof err === "object" && err !== null && "message" in err ? String(err.message) : JSON.stringify(err);
380
+ throw new Error(message);
381
+ }
382
+
383
+ const MIN_CATCH_UP_WINDOW_MS = 6 * 60 * 60 * 1e3;
384
+ async function fetchBoundedByWindow({
385
+ window,
386
+ fetchWindow,
387
+ guardResult,
388
+ minWindowMs,
389
+ onNarrowed
390
+ }) {
391
+ let current = window;
392
+ while (true) {
393
+ const result = await fetchWindow(current);
394
+ try {
395
+ guardResult(result);
396
+ return { result, window: current };
397
+ } catch (tooLarge) {
398
+ if (!(tooLarge instanceof StepResultTooLargeError)) throw tooLarge;
399
+ const halvedSpanMs = Math.floor(
400
+ (current.dateTo.getTime() - current.dateFrom.getTime()) / 2
401
+ );
402
+ if (halvedSpanMs < minWindowMs) throw tooLarge;
403
+ current = {
404
+ dateFrom: current.dateFrom,
405
+ dateTo: new Date(current.dateFrom.getTime() + halvedSpanMs),
406
+ // Stops short of the original target, so another pass is needed.
407
+ isCatchUp: true
408
+ };
409
+ onNarrowed?.(current);
410
+ }
411
+ }
412
+ }
413
+
343
414
  async function pushToQueue(queue, message) {
344
415
  if (!Array.isArray(message)) {
345
416
  await queue.send(message, { contentType: "v8" });
@@ -354,7 +425,8 @@ async function pushToQueue(queue, message) {
354
425
  }
355
426
  class BankSyncAccountPayments extends cloudflare_workers.WorkflowEntrypoint {
356
427
  async run(event, step) {
357
- const { accountId } = event.payload;
428
+ const { accountId, maxIterations } = event.payload;
429
+ const iterationBudget = bank.resolveIterationBudget(maxIterations);
358
430
  const db = d1.drizzle(this.env.BANK_D1, { schema: paymentDirection.tables, relations: paymentDirection.relations });
359
431
  const logger = createWorkflowLogger(event.instanceId);
360
432
  if (!accountId) {
@@ -364,8 +436,7 @@ class BankSyncAccountPayments extends cloudflare_workers.WorkflowEntrypoint {
364
436
  "capture workflow start time",
365
437
  async () => Date.now()
366
438
  );
367
- while (true) {
368
- const now = /* @__PURE__ */ new Date();
439
+ for (let iteration = 0; iteration < iterationBudget; iteration++) {
369
440
  const account = await step.do("load account", async () => {
370
441
  const account2 = await bank.getAccountByIdQuery(db, { accountId });
371
442
  if (!account2) {
@@ -376,7 +447,38 @@ class BankSyncAccountPayments extends cloudflare_workers.WorkflowEntrypoint {
376
447
  if (!account.lastSyncAt) {
377
448
  throw new Error(`lastSyncedAt is not set for account: ${accountId}`);
378
449
  }
379
- const payments = await step.do(
450
+ if (bank.isSupersededBy(
451
+ event.instanceId,
452
+ account.lastSyncMetadata?.instanceId,
453
+ accountId
454
+ )) {
455
+ logger.info("sync.superseded", {
456
+ accountId,
457
+ instanceId: event.instanceId,
458
+ supersededBy: account.lastSyncMetadata?.instanceId,
459
+ iteration
460
+ });
461
+ return;
462
+ }
463
+ const lastSyncAtMs = account.lastSyncAt.getTime();
464
+ const windowMs = await step.do("resolve sync window", async () => {
465
+ const window = resolveSyncWindow(
466
+ new Date(lastSyncAtMs),
467
+ Date.now(),
468
+ MAX_CATCH_UP_WINDOW_MS
469
+ );
470
+ return {
471
+ dateFromMs: window.dateFrom.getTime(),
472
+ dateToMs: window.dateTo.getTime(),
473
+ isCatchUp: window.isCatchUp
474
+ };
475
+ });
476
+ const syncWindow = {
477
+ dateFrom: new Date(windowMs.dateFromMs),
478
+ dateTo: new Date(windowMs.dateToMs),
479
+ isCatchUp: windowMs.isCatchUp
480
+ };
481
+ const fetched = await step.do(
380
482
  "fetch bank payments",
381
483
  {
382
484
  retries: { limit: 5, delay: "2 minutes", backoff: "exponential" },
@@ -405,42 +507,88 @@ class BankSyncAccountPayments extends cloudflare_workers.WorkflowEntrypoint {
405
507
  }
406
508
  ]
407
509
  });
408
- const result = await connector.getAllAccountPayments({
409
- account,
410
- filter: { dateFrom: account.lastSyncAt }
411
- });
510
+ const fetchWindow = async (window) => {
511
+ const result = await connector.getAllAccountPayments({
512
+ account,
513
+ filter: {
514
+ dateFrom: window.dateFrom,
515
+ dateTo: window.dateTo
516
+ }
517
+ });
518
+ const identifiable = result.filter(
519
+ (p) => p.parsed.bankRefId?.trim()
520
+ );
521
+ const skipped = result.length - identifiable.length;
522
+ if (skipped > 0) {
523
+ logger.warn("payments.fetch.skipped_blank_bank_ref", {
524
+ accountId,
525
+ connectorKey: account.connectorKey,
526
+ skipped
527
+ });
528
+ }
529
+ return Promise.all(
530
+ identifiable.map(async (p) => ({
531
+ parsed: {
532
+ ...p.parsed,
533
+ id: await deriveV4PaymentId(p.parsed)
534
+ }
535
+ }))
536
+ );
537
+ };
538
+ let bounded;
539
+ try {
540
+ bounded = await fetchBoundedByWindow({
541
+ window: syncWindow,
542
+ fetchWindow,
543
+ guardResult: guardStepResultSize,
544
+ minWindowMs: MIN_CATCH_UP_WINDOW_MS,
545
+ onNarrowed: (narrowed) => logger.warn("payments.fetch.window-narrowed", {
546
+ accountId,
547
+ connectorKey: account.connectorKey,
548
+ dateFrom: narrowed.dateFrom.toISOString(),
549
+ dateTo: narrowed.dateTo.toISOString()
550
+ })
551
+ });
552
+ } catch (err) {
553
+ if (err instanceof StepResultTooLargeError) {
554
+ logger.error("payments.fetch.result-too-large", {
555
+ accountId,
556
+ connectorKey: account.connectorKey,
557
+ dateFrom: syncWindow.dateFrom.toISOString(),
558
+ dateTo: syncWindow.dateTo.toISOString()
559
+ });
560
+ }
561
+ throw err;
562
+ }
412
563
  logger.info("payments.fetch.completed", {
413
564
  accountId,
414
565
  connectorKey: account.connectorKey,
415
- paymentsCount: result.length
566
+ paymentsCount: bounded.result.length
416
567
  });
417
- const identifiable = result.filter(
418
- (p) => p.parsed.bankRefId?.trim()
419
- );
420
- const skipped = result.length - identifiable.length;
421
- if (skipped > 0) {
422
- logger.warn("payments.fetch.skipped_blank_bank_ref", {
423
- accountId,
424
- connectorKey: account.connectorKey,
425
- skipped
426
- });
427
- }
428
- return Promise.all(
429
- identifiable.map(async (p) => ({
430
- parsed: { ...p.parsed, id: await deriveV4PaymentId(p.parsed) }
431
- }))
432
- );
568
+ return {
569
+ payments: bounded.result,
570
+ windowMs: {
571
+ dateFromMs: bounded.window.dateFrom.getTime(),
572
+ dateToMs: bounded.window.dateTo.getTime(),
573
+ isCatchUp: bounded.window.isCatchUp
574
+ }
575
+ };
433
576
  } catch (err) {
434
- const message = err instanceof Error ? err.message : typeof err === "object" && err !== null && "message" in err ? String(err.message) : JSON.stringify(err);
435
577
  logger.error("payments.fetch.failed", {
436
578
  accountId,
437
579
  connectorKey: account.connectorKey,
438
- error: message
580
+ error: err instanceof Error ? err.message : String(err)
439
581
  });
440
- throw new Error(message);
582
+ rethrowFetchError(err);
441
583
  }
442
584
  }
443
585
  );
586
+ const payments = fetched.payments;
587
+ const effectiveWindow = {
588
+ dateFrom: new Date(fetched.windowMs.dateFromMs),
589
+ dateTo: new Date(fetched.windowMs.dateToMs),
590
+ isCatchUp: fetched.windowMs.isCatchUp
591
+ };
444
592
  const paymentsToProcess = payments.filter(
445
593
  (p) => paymentDirection.isPaymentCompleted(p.parsed)
446
594
  );
@@ -515,6 +663,7 @@ class BankSyncAccountPayments extends cloudflare_workers.WorkflowEntrypoint {
515
663
  }))
516
664
  );
517
665
  const lastSyncMetadata = {
666
+ instanceId: event.instanceId,
518
667
  payments: payments.length,
519
668
  paymentsToProcess: paymentsToProcess.length,
520
669
  paymentsInserted: paymentsToInsert.length,
@@ -531,15 +680,27 @@ class BankSyncAccountPayments extends cloudflare_workers.WorkflowEntrypoint {
531
680
  };
532
681
  const updateLastSyncCommand = updateAccountLastSyncCommand(db, {
533
682
  accountId: account.id,
534
- lastSyncAt: now,
535
- lastSyncMetadata
683
+ // End of the window just fetched, not `now`: while catching up the
684
+ // window stops short of now, and advancing past it would skip every
685
+ // payment in the gap.
686
+ lastSyncAt: effectiveWindow.dateTo,
687
+ lastSyncMetadata,
688
+ seenInstanceId: account.lastSyncMetadata?.instanceId ?? null
536
689
  }).command;
690
+ let syncStateRows;
537
691
  if (createCommands.length) {
538
- await db.batch(
692
+ const [updateResult] = await db.batch(
539
693
  backendSdk.asNonEmpty([updateLastSyncCommand, ...createCommands])
540
694
  );
695
+ syncStateRows = updateResult;
541
696
  } else {
542
- await updateLastSyncCommand;
697
+ syncStateRows = await updateLastSyncCommand;
698
+ }
699
+ if (syncStateRows.length === 0) {
700
+ logger.info("sync.write-superseded", {
701
+ accountId,
702
+ instanceId: event.instanceId
703
+ });
543
704
  }
544
705
  if (eventsToEmit.length) {
545
706
  await pushToQueue(
@@ -549,14 +710,16 @@ class BankSyncAccountPayments extends cloudflare_workers.WorkflowEntrypoint {
549
710
  }
550
711
  return {
551
712
  ...lastSyncMetadata,
552
- newLastSyncAt: now
713
+ newLastSyncAt: effectiveWindow.dateTo
553
714
  };
554
715
  }
555
716
  );
556
- await step.sleep(
557
- "Sleep for next sync",
558
- `${account.syncIntervalS} seconds`
559
- );
717
+ if (!effectiveWindow.isCatchUp) {
718
+ await step.sleep(
719
+ "Sleep for next sync",
720
+ `${account.syncIntervalS} seconds`
721
+ );
722
+ }
560
723
  }
561
724
  }
562
725
  }
@@ -9,6 +9,7 @@ declare class BankProcessBatch extends WorkflowEntrypoint<BankEnv, Params$1> {
9
9
 
10
10
  type Params = {
11
11
  accountId: string;
12
+ maxIterations?: number;
12
13
  };
13
14
  declare class BankSyncAccountPayments extends WorkflowEntrypoint<BankEnv, Params> {
14
15
  run(event: WorkflowEvent<Params>, step: WorkflowStep): Promise<void>;
@@ -9,6 +9,7 @@ declare class BankProcessBatch extends WorkflowEntrypoint<BankEnv, Params$1> {
9
9
 
10
10
  type Params = {
11
11
  accountId: string;
12
+ maxIterations?: number;
12
13
  };
13
14
  declare class BankSyncAccountPayments extends WorkflowEntrypoint<BankEnv, Params> {
14
15
  run(event: WorkflowEvent<Params>, step: WorkflowStep): Promise<void>;
@@ -9,6 +9,7 @@ declare class BankProcessBatch extends WorkflowEntrypoint<BankEnv, Params$1> {
9
9
 
10
10
  type Params = {
11
11
  accountId: string;
12
+ maxIterations?: number;
12
13
  };
13
14
  declare class BankSyncAccountPayments extends WorkflowEntrypoint<BankEnv, Params> {
14
15
  run(event: WorkflowEvent<Params>, step: WorkflowStep): Promise<void>;