Skip to content
Open
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
29 changes: 1 addition & 28 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 6 additions & 3 deletions indexer/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,9 @@ Indexer environment variables are documented in the repository root

## Status

This workspace is currently a scaffold. The poller, database repositories,
cursor persistence, event handlers, and GraphQL API are planned but not yet
implemented.
This workspace is currently a scaffold. The poller, cursor persistence,
event handlers, and GraphQL API are planned but not yet implemented.

The distributions domain database schema (`distribution_batch`, `claim_action`)
is defined under `distributions/src/db/` with a SQL migration in
`common/src/db/migrations/0002_create_distributions_schema.sql`.
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
-- Migration: 0002_create_distributions_schema
-- Creates distribution_batch and claim_action tables for the distributions indexer domain.

CREATE TABLE IF NOT EXISTS schema_migrations (
version TEXT NOT NULL PRIMARY KEY,
applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM schema_migrations WHERE version = '0002_create_distributions_schema'
) THEN
RAISE NOTICE 'Migration 0002_create_distributions_schema already applied, skipping.';
RETURN;
END IF;

CREATE TABLE distribution_batch (
id TEXT NOT NULL,
contract_id TEXT NOT NULL,
distributor TEXT NOT NULL,
token TEXT NOT NULL,
total_amount NUMERIC(78, 0) NOT NULL,
claimed_amount NUMERIC(78, 0) NOT NULL DEFAULT 0,
recipient_count INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'active',
paused_at BIGINT,
resumed_at BIGINT,
unique_ref TEXT NOT NULL,
ledger_number BIGINT NOT NULL,
tx_hash TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),

CONSTRAINT distribution_batch_pkey PRIMARY KEY (id),
CONSTRAINT distribution_batch_status_chk
CHECK (status IN ('active', 'paused', 'completed', 'cancelled')),
CONSTRAINT distribution_batch_total_amount_nonneg CHECK (total_amount >= 0),
CONSTRAINT distribution_batch_claimed_amount_nonneg CHECK (claimed_amount >= 0),
CONSTRAINT distribution_batch_recipient_count_nonneg CHECK (recipient_count >= 0)
);

CREATE INDEX distribution_batch_contract_id_idx
ON distribution_batch (contract_id);
CREATE INDEX distribution_batch_distributor_idx
ON distribution_batch (distributor);
CREATE INDEX distribution_batch_status_idx
ON distribution_batch (status);
CREATE INDEX distribution_batch_created_at_idx
ON distribution_batch (created_at);
CREATE INDEX distribution_batch_contract_status_idx
ON distribution_batch (contract_id, status);

CREATE TABLE claim_action (
id UUID NOT NULL DEFAULT gen_random_uuid(),
batch_id TEXT NOT NULL,
claimant TEXT NOT NULL,
amount NUMERIC(78, 0) NOT NULL,
tx_hash TEXT NOT NULL,
ledger_number BIGINT NOT NULL,
event_timestamp BIGINT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),

CONSTRAINT claim_action_pkey PRIMARY KEY (id),
CONSTRAINT claim_action_batch_fk
FOREIGN KEY (batch_id) REFERENCES distribution_batch (id) ON DELETE CASCADE,
CONSTRAINT claim_action_amount_nonneg CHECK (amount >= 0)
);

CREATE INDEX claim_action_batch_id_idx ON claim_action (batch_id);
CREATE INDEX claim_action_claimant_idx ON claim_action (claimant);
CREATE INDEX claim_action_tx_hash_idx ON claim_action (tx_hash);
CREATE INDEX claim_action_batch_claimant_idx ON claim_action (batch_id, claimant);

INSERT INTO schema_migrations (version) VALUES ('0002_create_distributions_schema');
END;
$$;
3 changes: 2 additions & 1 deletion indexer/distributions/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
"type-check": "tsc -p tsconfig.json --noEmit"
},
"dependencies": {
"@fundable-indexer/common": "workspace:*"
"@fundable-indexer/common": "workspace:*",
"typeorm": "^0.3.20"
Comment on lines +18 to +19

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Is reflect-metadata declared anywhere?
fd -t f 'package.json' indexer --exec sh -c 'echo "== {} =="; cat {}' | rg -n 'reflect-metadata' || echo "reflect-metadata NOT declared in any indexer package.json"

# Is it imported anywhere?
rg -nP '''(import|require)\([^)]*reflect-metadata|import\s+["'\'']reflect-metadata''' indexer || echo "reflect-metadata NOT imported anywhere"

Repository: Fundable-Protocol/Backend

Length of output: 260


Missing reflect-metadata dependency for decorator-based TypeORM entities.

TypeORM 0.3.x entities using decorators require reflect-metadata to be installed and imported once at startup. Verification confirms it is missing from all package.json files in the indexer/ workspace and is not imported anywhere.

πŸ› οΈ Proposed dependency addition
     "`@fundable-indexer/common`": "workspace:*",
+    "reflect-metadata": "^0.2.2",
     "typeorm": "^0.3.20"

Add import "reflect-metadata"; at the application entrypoint before loading any entity metadata to prevent runtime failures in metadata resolution.

πŸ“ Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
"@fundable-indexer/common": "workspace:*",
"typeorm": "^0.3.20"
"`@fundable-indexer/common`": "workspace:*",
"reflect-metadata": "^0.2.2",
"typeorm": "^0.3.20"
πŸ€– Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@indexer/distributions/package.json` around lines 18 - 19, Add the missing
reflect-metadata setup for TypeORM decorators: the `indexer/distributions`
package currently depends on `typeorm` but never installs or loads
`reflect-metadata`, so decorator-based entity metadata can fail at runtime.
Update the relevant package manifest(s) in the `indexer/` workspace to include
`reflect-metadata`, and import it once at the application entrypoint before any
TypeORM entities or DataSource initialization run. Use the package entry
bootstrap and the TypeORM entity-loading path as the places to verify the import
happens first.

}
}
55 changes: 55 additions & 0 deletions indexer/distributions/src/db/entity/ClaimAction.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import {
Column,
CreateDateColumn,
Entity,
Index,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
} from "typeorm";

import { DistributionBatch } from "./DistributionBatch.js";

@Entity("claim_action")
@Index("claim_action_batch_id_idx", ["batchId"])
@Index("claim_action_claimant_idx", ["claimant"])
@Index("claim_action_tx_hash_idx", ["txHash"])
@Index("claim_action_batch_claimant_idx", ["batchId", "claimant"])
export class ClaimAction {
@PrimaryGeneratedColumn("uuid")
id!: string;

@Column({ type: "varchar", comment: "Distribution batch this claim belongs to" })
batchId!: string;

@ManyToOne(
() => DistributionBatch,
(batch) => batch.claims,
{ onDelete: "CASCADE" },
)
@JoinColumn({ name: "batchId" })
Comment on lines +22 to +30

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

πŸ—„οΈ Data Integrity & Integration | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

fd --extension ts --glob '*migration*' --type f | grep -i claim

Repository: Fundable-Protocol/Backend

Length of output: 163


🏁 Script executed:

fd --extension ts --type f "src/db/migrations" | head -20

Repository: Fundable-Protocol/Backend

Length of output: 163


🏁 Script executed:

find . -type f -name "*migration*.ts" -path "*/src/*" | head -20

Repository: Fundable-Protocol/Backend

Length of output: 199


🏁 Script executed:

find . -type d -name "migrations" | head -5

Repository: Fundable-Protocol/Backend

Length of output: 215


🏁 Script executed:

grep -r "batch_id\|batchId" ./src/migrations --include="*.ts" | head -20

Repository: Fundable-Protocol/Backend

Length of output: 163


🏁 Script executed:

grep -r "batch_id\|batchId" ./indexer/common/src/db/migrations --include="*.ts" | head -20

Repository: Fundable-Protocol/Backend

Length of output: 163


🏁 Script executed:

ls -la ./src/migrations/

Repository: Fundable-Protocol/Backend

Length of output: 438


🏁 Script executed:

grep -r "batch_id\|batchId" ./src/migrations ./indexer/common/src/db/migrations --include="*.js" --include="*.ts" 2>/dev/null | head -20

Repository: Fundable-Protocol/Backend

Length of output: 163


🏁 Script executed:

cat ./indexer/distributions/src/db/entity/ClaimAction.ts

Repository: Fundable-Protocol/Backend

Length of output: 1542


🏁 Script executed:

find ./indexer -type f \( -name "*.js" -o -name "*.ts" \) | xargs grep -l "claim_action" | grep -i migration | head -10

Repository: Fundable-Protocol/Backend

Length of output: 163


🏁 Script executed:

find ./indexer -type f \( -name "*migration*" -o -name "*Migrate*" \) | head -10

Repository: Fundable-Protocol/Backend

Length of output: 163


🏁 Script executed:

grep -r "claim_action" --include="*.js" --include="*.ts" . | grep -i "createTable\|addColumn" | head -10

Repository: Fundable-Protocol/Backend

Length of output: 163


Align the @JoinColumn name with the database column to prevent foreign key lookup failures.

The current code explicitly sets @JoinColumn({ name: "batchId" }), which forces TypeORM to look for a column named batchId in the database. However, the database schema (created via the migration) uses the snake_case batch_id following the global naming strategy. This mismatch causes the ORM to fail in resolving the relationship.

Also update the scalar @Column definition to explicitly map to batch_id for clarity and consistency.

Suggested fix:

Detailed Change
  `@Index`("claim_action_batch_id_idx", ["batchId"])
  `@Index`("claim_action_claimant_idx", ["claimant"])
  `@Index`("claim_action_tx_hash_idx", ["txHash"])
  `@Index`("claim_action_batch_claimant_idx", ["batchId", "claimant"])
  export class ClaimAction {
    `@PrimaryGeneratedColumn`("uuid")
    id!: string;
  
-   `@Column`({ type: "varchar", comment: "Distribution batch this claim belongs to" })
+   `@Column`({ name: "batch_id", type: "varchar", comment: "Distribution batch this claim belongs to" })
    batchId!: string;
  
    `@ManyToOne`(
      () => DistributionBatch,
      (batch) => batch.claims,
      { onDelete: "CASCADE" },
    )
-   `@JoinColumn`({ name: "batchId" })
+   `@JoinColumn`({ name: "batch_id" })
    batch!: DistributionBatch;
πŸ“ Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
@Column({ type: "varchar", comment: "Distribution batch this claim belongs to" })
batchId!: string;
@ManyToOne(
() => DistributionBatch,
(batch) => batch.claims,
{ onDelete: "CASCADE" },
)
@JoinColumn({ name: "batchId" })
`@Column`({ name: "batch_id", type: "varchar", comment: "Distribution batch this claim belongs to" })
batchId!: string;
`@ManyToOne`(
() => DistributionBatch,
(batch) => batch.claims,
{ onDelete: "CASCADE" },
)
`@JoinColumn`({ name: "batch_id" })
batch!: DistributionBatch;
πŸ€– Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@indexer/distributions/src/db/entity/ClaimAction.ts` around lines 22 - 30, The
ClaimAction relation is mismatched with the database column naming, causing
TypeORM to look for the wrong foreign key column. Update the scalar column and
the relation in ClaimAction so the `batchId` property explicitly maps to the
snake_case database column used by the migration, and change the `@JoinColumn`
on the `DistributionBatch` relation to reference that same database column name.
Keep the mapping consistent with the naming strategy so the relationship can
resolve correctly.

batch!: DistributionBatch;

@Column({ type: "varchar", comment: "Address that claimed tokens" })
claimant!: string;

@Column({
type: "numeric",
precision: 78,
scale: 0,
comment: "Claimed amount in the token's smallest unit",
})
amount!: string;

@Column({ type: "varchar", comment: "Transaction hash where the claim occurred" })
txHash!: string;

@Column({ type: "bigint", comment: "Ledger where the claim was recorded" })
ledgerNumber!: string;

@Column({ type: "bigint", comment: "On-chain timestamp of the claim" })
eventTimestamp!: string;

@CreateDateColumn()
createdAt!: Date;
}
97 changes: 97 additions & 0 deletions indexer/distributions/src/db/entity/DistributionBatch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import {
Column,
CreateDateColumn,
Entity,
Index,
OneToMany,
PrimaryColumn,
UpdateDateColumn,
} from "typeorm";

import type { DistributionBatchStatus } from "../status.js";
import { ClaimAction } from "./ClaimAction.js";

@Entity("distribution_batch")
@Index("distribution_batch_contract_id_idx", ["contractId"])
@Index("distribution_batch_distributor_idx", ["distributor"])
@Index("distribution_batch_status_idx", ["status"])
@Index("distribution_batch_created_at_idx", ["createdAt"])
@Index("distribution_batch_contract_status_idx", ["contractId", "status"])
export class DistributionBatch {
@PrimaryColumn({
type: "varchar",
comment: "Deterministic batch ID from the Soroban contract",
})
id!: string;

@Column({ type: "varchar", comment: "Soroban contract that emitted the batch" })
contractId!: string;

@Column({ type: "varchar", comment: "Address that created the distribution batch" })
distributor!: string;

@Column({ type: "varchar", comment: "Token asset contract address" })
token!: string;

@Column({
type: "numeric",
precision: 78,
scale: 0,
comment: "Total allocated amount in the token's smallest unit",
})
totalAmount!: string;

@Column({
type: "numeric",
precision: 78,
scale: 0,
default: "0",
comment: "Total amount claimed so far in the token's smallest unit",
})
claimedAmount!: string;

@Column({ type: "integer", default: 0, comment: "Number of intended recipients" })
recipientCount!: number;

@Column({
type: "varchar",
default: "active",
comment: "Batch lifecycle status for pause/resume behavior",
})
status!: DistributionBatchStatus;

@Column({
type: "bigint",
nullable: true,
comment: "Unix timestamp when the batch was paused",
})
pausedAt!: string | null;

@Column({
type: "bigint",
nullable: true,
comment: "Unix timestamp when the batch was last resumed",
})
resumedAt!: string | null;

@Column({ type: "varchar", comment: "Contract-unique reference for the batch" })
uniqueRef!: string;

@Column({ type: "bigint", comment: "Ledger where the batch was created" })
ledgerNumber!: string;

@Column({ type: "varchar", comment: "Transaction hash that created the batch" })
txHash!: string;

@OneToMany(
() => ClaimAction,
(claim) => claim.batch,
)
claims!: ClaimAction[];

@CreateDateColumn()
createdAt!: Date;

@UpdateDateColumn()
updatedAt!: Date;
Comment on lines +27 to +96

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

πŸ—„οΈ Data Integrity & Integration | πŸ”΄ Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Inspect datasource / TypeORM config for an explicit snake_case naming strategy.
fd -i 'data-source|datasource|ormconfig' .
rg -n -C2 'namingStrategy|SnakeNamingStrategy|snake' .

Repository: Fundable-Protocol/Backend

Length of output: 203


🏁 Script executed:

cat -n src/config/persistence/data-source.ts

Repository: Fundable-Protocol/Backend

Length of output: 3452


Critical mismatch between TypeORM config and database schema: Missing SnakeNamingStrategy

The DistributionBatch entity defines properties in camelCase (e.g., contractId, totalAmount, created_at), while the migration 0002_create_distributions_schema.sql creates columns in snake_case (e.g., contract_id, total_amount, created_at).

The datasource configuration in src/config/persistence/data-source.ts (lines 39-64) does not define a namingStrategy. Without explicitly setting namingStrategy: new SnakeNamingStrategy(), TypeORM defaults to camelCase column names. This will cause runtime errors (column "contractid" does not exist) whenever the application attempts to query or persist DistributionBatch records.

Change required in src/config/persistence/data-source.ts:

import { SnakeNamingStrategy } from 'typeorm';
// ... inside AppDataSource options:
namingStrategy: new SnakeNamingStrategy(),
πŸ€– Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@indexer/distributions/src/db/entity/DistributionBatch.ts` around lines 27 -
96, The DistributionBatch entity uses camelCase property names, but the database
schema is snake_case, so TypeORM will generate mismatched column names unless
the datasource is configured correctly. Update the AppDataSource setup in
data-source.ts to use SnakeNamingStrategy so entities like DistributionBatch,
contractId, totalAmount, and createdAt map to the existing snake_case columns in
the migration.

}
Loading