@develit-services/bank 5.7.1 → 5.9.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.
Files changed (36) hide show
  1. package/dist/base.cjs +296 -32
  2. package/dist/base.d.cts +38 -11
  3. package/dist/base.d.mts +38 -11
  4. package/dist/base.d.ts +38 -11
  5. package/dist/base.mjs +296 -32
  6. package/dist/database/schema.cjs +1 -1
  7. package/dist/database/schema.d.cts +1 -1
  8. package/dist/database/schema.d.mts +1 -1
  9. package/dist/database/schema.d.ts +1 -1
  10. package/dist/database/schema.mjs +1 -1
  11. package/dist/export/workflows.cjs +213 -42
  12. package/dist/export/workflows.d.cts +1 -0
  13. package/dist/export/workflows.d.mts +1 -0
  14. package/dist/export/workflows.d.ts +1 -0
  15. package/dist/export/workflows.mjs +214 -43
  16. package/dist/service.d.cts +3 -0
  17. package/dist/service.d.mts +3 -0
  18. package/dist/service.d.ts +3 -0
  19. package/dist/shared/{bank.BzDNLxB_.mjs → bank.BT7HayCV.mjs} +4 -0
  20. package/dist/shared/{bank.BUzoc8p6.d.cts → bank.Bhh_O_5-.d.mts} +1 -1
  21. package/dist/shared/{bank.DZ3Ow4bP.d.mts → bank.BhyIbhvK.d.cts} +1 -1
  22. package/dist/shared/{bank.CBpXmaTL.d.ts → bank.Bnc_DoG4.d.ts} +1 -1
  23. package/dist/shared/{bank.CQjuudum.cjs → bank.BsWBG0gb.cjs} +1 -1
  24. package/dist/shared/{bank.9Yw4KHyl.cjs → bank.Ca3jzmIb.cjs} +4 -0
  25. package/dist/shared/{bank.DLU1sOBm.mjs → bank.DAjqsBQN.mjs} +30 -3
  26. package/dist/shared/{bank.B0DNtuUM.cjs → bank.DBTtRCcf.cjs} +33 -2
  27. package/dist/shared/{bank.BVXtqHnq.mjs → bank.kz-PKVi5.mjs} +1 -1
  28. package/dist/shared/{bank.BQwwtIR4.d.ts → bank.mkq4IlpC.d.cts} +68 -2
  29. package/dist/shared/{bank.BQwwtIR4.d.cts → bank.mkq4IlpC.d.mts} +68 -2
  30. package/dist/shared/{bank.BQwwtIR4.d.mts → bank.mkq4IlpC.d.ts} +68 -2
  31. package/dist/types.cjs +2 -2
  32. package/dist/types.d.cts +4 -4
  33. package/dist/types.d.mts +4 -4
  34. package/dist/types.d.ts +4 -4
  35. package/dist/types.mjs +2 -2
  36. package/package.json +1 -1
@@ -1,16 +1,16 @@
1
1
  'use strict';
2
2
 
3
3
  const backendSdk = require('@develit-io/backend-sdk');
4
- const paymentDirection = require('../shared/bank.CQjuudum.cjs');
4
+ const paymentDirection = require('../shared/bank.BsWBG0gb.cjs');
5
5
  const batchLifecycle = require('../shared/bank.NF8bZBy0.cjs');
6
6
  const drizzleOrm = require('drizzle-orm');
7
- const bank = require('../shared/bank.B0DNtuUM.cjs');
7
+ const bank = require('../shared/bank.DBTtRCcf.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');
11
11
  require('node:crypto');
12
12
  require('date-fns');
13
- require('../shared/bank.9Yw4KHyl.cjs');
13
+ require('../shared/bank.Ca3jzmIb.cjs');
14
14
  require('drizzle-orm/sqlite-core');
15
15
  require('@develit-io/general-codes');
16
16
  require('jose');
@@ -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) {
@@ -373,10 +444,49 @@ class BankSyncAccountPayments extends cloudflare_workers.WorkflowEntrypoint {
373
444
  }
374
445
  return account2;
375
446
  });
447
+ if (!account.syncEnabled) {
448
+ logger.info("sync.disabled", {
449
+ accountId,
450
+ instanceId: event.instanceId,
451
+ iteration
452
+ });
453
+ return;
454
+ }
376
455
  if (!account.lastSyncAt) {
377
456
  throw new Error(`lastSyncedAt is not set for account: ${accountId}`);
378
457
  }
379
- const payments = await step.do(
458
+ if (bank.isSupersededBy(
459
+ event.instanceId,
460
+ account.lastSyncMetadata?.instanceId,
461
+ accountId
462
+ )) {
463
+ logger.info("sync.superseded", {
464
+ accountId,
465
+ instanceId: event.instanceId,
466
+ supersededBy: account.lastSyncMetadata?.instanceId,
467
+ iteration
468
+ });
469
+ return;
470
+ }
471
+ const lastSyncAtMs = account.lastSyncAt.getTime();
472
+ const windowMs = await step.do("resolve sync window", async () => {
473
+ const window = resolveSyncWindow(
474
+ new Date(lastSyncAtMs),
475
+ Date.now(),
476
+ MAX_CATCH_UP_WINDOW_MS
477
+ );
478
+ return {
479
+ dateFromMs: window.dateFrom.getTime(),
480
+ dateToMs: window.dateTo.getTime(),
481
+ isCatchUp: window.isCatchUp
482
+ };
483
+ });
484
+ const syncWindow = {
485
+ dateFrom: new Date(windowMs.dateFromMs),
486
+ dateTo: new Date(windowMs.dateToMs),
487
+ isCatchUp: windowMs.isCatchUp
488
+ };
489
+ const fetched = await step.do(
380
490
  "fetch bank payments",
381
491
  {
382
492
  retries: { limit: 5, delay: "2 minutes", backoff: "exponential" },
@@ -405,42 +515,88 @@ class BankSyncAccountPayments extends cloudflare_workers.WorkflowEntrypoint {
405
515
  }
406
516
  ]
407
517
  });
408
- const result = await connector.getAllAccountPayments({
409
- account,
410
- filter: { dateFrom: account.lastSyncAt }
411
- });
518
+ const fetchWindow = async (window) => {
519
+ const result = await connector.getAllAccountPayments({
520
+ account,
521
+ filter: {
522
+ dateFrom: window.dateFrom,
523
+ dateTo: window.dateTo
524
+ }
525
+ });
526
+ const identifiable = result.filter(
527
+ (p) => p.parsed.bankRefId?.trim()
528
+ );
529
+ const skipped = result.length - identifiable.length;
530
+ if (skipped > 0) {
531
+ logger.warn("payments.fetch.skipped_blank_bank_ref", {
532
+ accountId,
533
+ connectorKey: account.connectorKey,
534
+ skipped
535
+ });
536
+ }
537
+ return Promise.all(
538
+ identifiable.map(async (p) => ({
539
+ parsed: {
540
+ ...p.parsed,
541
+ id: await deriveV4PaymentId(p.parsed)
542
+ }
543
+ }))
544
+ );
545
+ };
546
+ let bounded;
547
+ try {
548
+ bounded = await fetchBoundedByWindow({
549
+ window: syncWindow,
550
+ fetchWindow,
551
+ guardResult: guardStepResultSize,
552
+ minWindowMs: MIN_CATCH_UP_WINDOW_MS,
553
+ onNarrowed: (narrowed) => logger.warn("payments.fetch.window-narrowed", {
554
+ accountId,
555
+ connectorKey: account.connectorKey,
556
+ dateFrom: narrowed.dateFrom.toISOString(),
557
+ dateTo: narrowed.dateTo.toISOString()
558
+ })
559
+ });
560
+ } catch (err) {
561
+ if (err instanceof StepResultTooLargeError) {
562
+ logger.error("payments.fetch.result-too-large", {
563
+ accountId,
564
+ connectorKey: account.connectorKey,
565
+ dateFrom: syncWindow.dateFrom.toISOString(),
566
+ dateTo: syncWindow.dateTo.toISOString()
567
+ });
568
+ }
569
+ throw err;
570
+ }
412
571
  logger.info("payments.fetch.completed", {
413
572
  accountId,
414
573
  connectorKey: account.connectorKey,
415
- paymentsCount: result.length
574
+ paymentsCount: bounded.result.length
416
575
  });
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
- );
576
+ return {
577
+ payments: bounded.result,
578
+ windowMs: {
579
+ dateFromMs: bounded.window.dateFrom.getTime(),
580
+ dateToMs: bounded.window.dateTo.getTime(),
581
+ isCatchUp: bounded.window.isCatchUp
582
+ }
583
+ };
433
584
  } 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
585
  logger.error("payments.fetch.failed", {
436
586
  accountId,
437
587
  connectorKey: account.connectorKey,
438
- error: message
588
+ error: err instanceof Error ? err.message : String(err)
439
589
  });
440
- throw new Error(message);
590
+ rethrowFetchError(err);
441
591
  }
442
592
  }
443
593
  );
594
+ const payments = fetched.payments;
595
+ const effectiveWindow = {
596
+ dateFrom: new Date(fetched.windowMs.dateFromMs),
597
+ dateTo: new Date(fetched.windowMs.dateToMs),
598
+ isCatchUp: fetched.windowMs.isCatchUp
599
+ };
444
600
  const paymentsToProcess = payments.filter(
445
601
  (p) => paymentDirection.isPaymentCompleted(p.parsed)
446
602
  );
@@ -515,6 +671,7 @@ class BankSyncAccountPayments extends cloudflare_workers.WorkflowEntrypoint {
515
671
  }))
516
672
  );
517
673
  const lastSyncMetadata = {
674
+ instanceId: event.instanceId,
518
675
  payments: payments.length,
519
676
  paymentsToProcess: paymentsToProcess.length,
520
677
  paymentsInserted: paymentsToInsert.length,
@@ -531,15 +688,27 @@ class BankSyncAccountPayments extends cloudflare_workers.WorkflowEntrypoint {
531
688
  };
532
689
  const updateLastSyncCommand = updateAccountLastSyncCommand(db, {
533
690
  accountId: account.id,
534
- lastSyncAt: now,
535
- lastSyncMetadata
691
+ // End of the window just fetched, not `now`: while catching up the
692
+ // window stops short of now, and advancing past it would skip every
693
+ // payment in the gap.
694
+ lastSyncAt: effectiveWindow.dateTo,
695
+ lastSyncMetadata,
696
+ seenInstanceId: account.lastSyncMetadata?.instanceId ?? null
536
697
  }).command;
698
+ let syncStateRows;
537
699
  if (createCommands.length) {
538
- await db.batch(
700
+ const [updateResult] = await db.batch(
539
701
  backendSdk.asNonEmpty([updateLastSyncCommand, ...createCommands])
540
702
  );
703
+ syncStateRows = updateResult;
541
704
  } else {
542
- await updateLastSyncCommand;
705
+ syncStateRows = await updateLastSyncCommand;
706
+ }
707
+ if (syncStateRows.length === 0) {
708
+ logger.info("sync.write-superseded", {
709
+ accountId,
710
+ instanceId: event.instanceId
711
+ });
543
712
  }
544
713
  if (eventsToEmit.length) {
545
714
  await pushToQueue(
@@ -549,14 +718,16 @@ class BankSyncAccountPayments extends cloudflare_workers.WorkflowEntrypoint {
549
718
  }
550
719
  return {
551
720
  ...lastSyncMetadata,
552
- newLastSyncAt: now
721
+ newLastSyncAt: effectiveWindow.dateTo
553
722
  };
554
723
  }
555
724
  );
556
- await step.sleep(
557
- "Sleep for next sync",
558
- `${account.syncIntervalS} seconds`
559
- );
725
+ if (!effectiveWindow.isCatchUp) {
726
+ await step.sleep(
727
+ "Sleep for next sync",
728
+ `${account.syncIntervalS} seconds`
729
+ );
730
+ }
560
731
  }
561
732
  }
562
733
  }
@@ -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>;