diff --git a/bun.lock b/bun.lock index 6d3632a..4960da4 100644 --- a/bun.lock +++ b/bun.lock @@ -53,6 +53,7 @@ "version": "0.1.0", "dependencies": { "@fundable-indexer/common": "workspace:*", + "typeorm": "^0.3.20", }, }, "indexer/streams": { @@ -1028,8 +1029,6 @@ "body-parser/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], - "cliui/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], - "color/color-convert": ["color-convert@1.9.3", "", { "dependencies": { "color-name": "1.1.3" } }, "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg=="], "express/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], @@ -1048,26 +1047,12 @@ "send/encodeurl": ["encodeurl@1.0.2", "", {}, "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w=="], - "string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], - - "string-width-cjs/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], - - "string-width-cjs/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], - - "strip-ansi-cjs/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], - "test-exclude/glob": ["glob@13.0.6", "", { "dependencies": { "minimatch": "^10.2.2", "minipass": "^7.1.3", "path-scurry": "^2.0.2" } }, "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw=="], "test-exclude/minimatch": ["minimatch@10.2.4", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg=="], "vite/esbuild": ["esbuild@0.27.7", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.7", "@esbuild/android-arm": "0.27.7", "@esbuild/android-arm64": "0.27.7", "@esbuild/android-x64": "0.27.7", "@esbuild/darwin-arm64": "0.27.7", "@esbuild/darwin-x64": "0.27.7", "@esbuild/freebsd-arm64": "0.27.7", "@esbuild/freebsd-x64": "0.27.7", "@esbuild/linux-arm": "0.27.7", "@esbuild/linux-arm64": "0.27.7", "@esbuild/linux-ia32": "0.27.7", "@esbuild/linux-loong64": "0.27.7", "@esbuild/linux-mips64el": "0.27.7", "@esbuild/linux-ppc64": "0.27.7", "@esbuild/linux-riscv64": "0.27.7", "@esbuild/linux-s390x": "0.27.7", "@esbuild/linux-x64": "0.27.7", "@esbuild/netbsd-arm64": "0.27.7", "@esbuild/netbsd-x64": "0.27.7", "@esbuild/openbsd-arm64": "0.27.7", "@esbuild/openbsd-x64": "0.27.7", "@esbuild/openharmony-arm64": "0.27.7", "@esbuild/sunos-x64": "0.27.7", "@esbuild/win32-arm64": "0.27.7", "@esbuild/win32-ia32": "0.27.7", "@esbuild/win32-x64": "0.27.7" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w=="], - "wrap-ansi/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], - - "wrap-ansi-cjs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], - - "wrap-ansi-cjs/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], - "@isaacs/cliui/string-width/emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="], "@isaacs/cliui/strip-ansi/ansi-regex": ["ansi-regex@6.1.0", "", {}, "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA=="], @@ -1078,8 +1063,6 @@ "body-parser/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], - "cliui/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], - "color/color-convert/color-name": ["color-name@1.1.3", "", {}, "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw=="], "express/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], @@ -1090,10 +1073,6 @@ "send/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], - "string-width-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], - - "string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], - "test-exclude/glob/path-scurry": ["path-scurry@2.0.2", "", { "dependencies": { "lru-cache": "^11.0.0", "minipass": "^7.1.2" } }, "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg=="], "test-exclude/minimatch/brace-expansion": ["brace-expansion@5.0.4", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg=="], @@ -1148,12 +1127,6 @@ "vite/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.7", "", { "os": "win32", "cpu": "x64" }, "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg=="], - "wrap-ansi-cjs/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], - - "wrap-ansi-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], - - "wrap-ansi/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], - "test-exclude/glob/path-scurry/lru-cache": ["lru-cache@11.2.7", "", {}, "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA=="], "test-exclude/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], diff --git a/indexer/README.md b/indexer/README.md index d6a5415..092a4b1 100644 --- a/indexer/README.md +++ b/indexer/README.md @@ -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`. diff --git a/indexer/common/src/db/migrations/0002_create_distributions_schema.sql b/indexer/common/src/db/migrations/0002_create_distributions_schema.sql new file mode 100644 index 0000000..e8920bc --- /dev/null +++ b/indexer/common/src/db/migrations/0002_create_distributions_schema.sql @@ -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; +$$; diff --git a/indexer/distributions/package.json b/indexer/distributions/package.json index 40a5dcc..1abd73c 100644 --- a/indexer/distributions/package.json +++ b/indexer/distributions/package.json @@ -15,6 +15,7 @@ "type-check": "tsc -p tsconfig.json --noEmit" }, "dependencies": { - "@fundable-indexer/common": "workspace:*" + "@fundable-indexer/common": "workspace:*", + "typeorm": "^0.3.20" } } diff --git a/indexer/distributions/src/db/entity/ClaimAction.ts b/indexer/distributions/src/db/entity/ClaimAction.ts new file mode 100644 index 0000000..5478c7e --- /dev/null +++ b/indexer/distributions/src/db/entity/ClaimAction.ts @@ -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" }) + 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; +} diff --git a/indexer/distributions/src/db/entity/DistributionBatch.ts b/indexer/distributions/src/db/entity/DistributionBatch.ts new file mode 100644 index 0000000..9bb8fc8 --- /dev/null +++ b/indexer/distributions/src/db/entity/DistributionBatch.ts @@ -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; +} diff --git a/indexer/distributions/src/db/schema.test.ts b/indexer/distributions/src/db/schema.test.ts new file mode 100644 index 0000000..909778f --- /dev/null +++ b/indexer/distributions/src/db/schema.test.ts @@ -0,0 +1,117 @@ +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { getMetadataArgsStorage } from "typeorm"; +import { describe, expect, test } from "vitest"; + +import { ClaimAction } from "./entity/ClaimAction.js"; +import { DistributionBatch } from "./entity/DistributionBatch.js"; +import { DISTRIBUTION_BATCH_STATUSES, isDistributionBatchStatus } from "./status.js"; + +const migrationsDir = join( + dirname(fileURLToPath(import.meta.url)), + "../../../common/src/db/migrations", +); +const migrationSql = readFileSync( + join(migrationsDir, "0002_create_distributions_schema.sql"), + "utf8", +); + +describe("distribution batch status", () => { + test("includes pause/resume lifecycle values", () => { + expect(DISTRIBUTION_BATCH_STATUSES).toContain("active"); + expect(DISTRIBUTION_BATCH_STATUSES).toContain("paused"); + expect(DISTRIBUTION_BATCH_STATUSES).toContain("completed"); + expect(DISTRIBUTION_BATCH_STATUSES).toContain("cancelled"); + }); + + test("validates known status values", () => { + expect(isDistributionBatchStatus("paused")).toBe(true); + expect(isDistributionBatchStatus("unknown")).toBe(false); + }); +}); + +describe("DistributionBatch entity", () => { + test("maps to distribution_batch table", () => { + const table = getMetadataArgsStorage().tables.find( + (entry) => entry.target === DistributionBatch, + ); + expect(table?.name).toBe("distribution_batch"); + }); + + test("stores token amounts as numeric columns", () => { + const columns = getMetadataArgsStorage().columns.filter( + (entry) => entry.target === DistributionBatch, + ); + const totalAmount = columns.find((entry) => entry.propertyName === "totalAmount"); + const claimedAmount = columns.find((entry) => entry.propertyName === "claimedAmount"); + + expect(totalAmount?.options.type).toBe("numeric"); + expect(totalAmount?.options.precision).toBe(78); + expect(totalAmount?.options.scale).toBe(0); + expect(claimedAmount?.options.type).toBe("numeric"); + }); + + test("tracks pause and resume timestamps", () => { + const columns = getMetadataArgsStorage().columns.filter( + (entry) => entry.target === DistributionBatch, + ); + const pausedAt = columns.find((entry) => entry.propertyName === "pausedAt"); + const resumedAt = columns.find((entry) => entry.propertyName === "resumedAt"); + const status = columns.find((entry) => entry.propertyName === "status"); + + expect(pausedAt?.options.nullable).toBe(true); + expect(resumedAt?.options.nullable).toBe(true); + expect(status?.options.default).toBe("active"); + }); +}); + +describe("ClaimAction entity", () => { + test("maps to claim_action table", () => { + const table = getMetadataArgsStorage().tables.find((entry) => entry.target === ClaimAction); + expect(table?.name).toBe("claim_action"); + }); + + test("stores claim amount as numeric column", () => { + const columns = getMetadataArgsStorage().columns.filter( + (entry) => entry.target === ClaimAction, + ); + const amount = columns.find((entry) => entry.propertyName === "amount"); + + expect(amount?.options.type).toBe("numeric"); + expect(amount?.options.precision).toBe(78); + expect(amount?.options.scale).toBe(0); + }); + + test("relates to DistributionBatch via batchId", () => { + const relations = getMetadataArgsStorage().relations.filter( + (entry) => entry.target === ClaimAction, + ); + const batchRelation = relations.find((entry) => entry.propertyName === "batch"); + + expect(batchRelation?.relationType).toBeDefined(); + expect(batchRelation?.propertyName).toBe("batch"); + expect(batchRelation?.inverseSideProperty).toBeDefined(); + }); +}); + +describe("0002_create_distributions_schema migration", () => { + test("creates distribution_batch and claim_action tables", () => { + expect(migrationSql).toContain("CREATE TABLE distribution_batch"); + expect(migrationSql).toContain("CREATE TABLE claim_action"); + }); + + test("uses integer-safe amount columns", () => { + expect(migrationSql).toContain("NUMERIC(78, 0)"); + expect(migrationSql).not.toMatch(/double precision|real|float/i); + }); + + test("adds pause/resume status constraint and indexes", () => { + expect(migrationSql).toContain("'paused'"); + expect(migrationSql).toContain("paused_at"); + expect(migrationSql).toContain("resumed_at"); + expect(migrationSql).toContain("distribution_batch_status_idx"); + expect(migrationSql).toContain("claim_action_batch_id_idx"); + }); +}); diff --git a/indexer/distributions/src/db/status.ts b/indexer/distributions/src/db/status.ts new file mode 100644 index 0000000..1deabf8 --- /dev/null +++ b/indexer/distributions/src/db/status.ts @@ -0,0 +1,11 @@ +/** + * Lifecycle status for a distribution batch. + * `paused` and `active` support pause/resume indexing behavior. + */ +export const DISTRIBUTION_BATCH_STATUSES = ["active", "paused", "completed", "cancelled"] as const; + +export type DistributionBatchStatus = (typeof DISTRIBUTION_BATCH_STATUSES)[number]; + +export function isDistributionBatchStatus(value: string): value is DistributionBatchStatus { + return (DISTRIBUTION_BATCH_STATUSES as readonly string[]).includes(value); +} diff --git a/indexer/distributions/src/index.ts b/indexer/distributions/src/index.ts index 51d59b6..248bfc8 100644 --- a/indexer/distributions/src/index.ts +++ b/indexer/distributions/src/index.ts @@ -1,7 +1,20 @@ import { commonPackage } from "@fundable-indexer/common"; +import { ClaimAction } from "./db/entity/ClaimAction.js"; +import { DistributionBatch } from "./db/entity/DistributionBatch.js"; + +export { ClaimAction } from "./db/entity/ClaimAction.js"; +export { DistributionBatch } from "./db/entity/DistributionBatch.js"; +export { + DISTRIBUTION_BATCH_STATUSES, + type DistributionBatchStatus, + isDistributionBatchStatus, +} from "./db/status.js"; + export const distributionsPackage = { name: "@fundable-indexer/distributions", role: "distribution-indexer", common: commonPackage.name, } as const; + +export const distributionsEntities = [DistributionBatch, ClaimAction] as const; diff --git a/indexer/tsconfig.base.json b/indexer/tsconfig.base.json index 66dd4c8..9b7e628 100644 --- a/indexer/tsconfig.base.json +++ b/indexer/tsconfig.base.json @@ -20,6 +20,8 @@ "skipLibCheck": true, "strict": true, "target": "ES2023", - "types": ["vitest/globals"] + "types": ["vitest/globals"], + "experimentalDecorators": true, + "emitDecoratorMetadata": true } }