diff --git a/.changeset/wet-meals-work.md b/.changeset/wet-meals-work.md new file mode 100644 index 000000000..fa177aa86 --- /dev/null +++ b/.changeset/wet-meals-work.md @@ -0,0 +1,7 @@ +--- +'@codama/visitors-core': minor +'@codama/node-types': minor +'@codama/nodes': minor +--- + +Add a new InstructionStatusNode to instructions diff --git a/README.md b/README.md index b95106027..bdc0fcf69 100644 --- a/README.md +++ b/README.md @@ -114,15 +114,14 @@ Feel free to PR your own visitor here for others to discover. Note that they are ### Generates program clients -| Visitor | Description | Maintainer | -| ----------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | ------------------------------------- | -| `@codama/renderers-js` ([docs](https://github.com/codama-idl/renderers-js)) | Generates a JavaScript client compatible with [Solana Kit](https://www.solanakit.com/). | [Anza](https://www.anza.xyz/) | -| `@codama/renderers-js-umi` ([docs](https://github.com/codama-idl/renderers-js-umi)) | Generates a JavaScript client compatible with [the Umi framework](https://developers.metaplex.com/umi). | [Metaplex](https://www.metaplex.com/) | -| `@codama/renderers-rust` ([docs](https://github.com/codama-idl/renderers-rust)) | Generates a Rust client compatible with [the Solana SDK](https://github.com/anza-xyz/solana-sdk). | [Anza](https://www.anza.xyz/) | -| `@codama/renderers-vixen-parser` ([docs](https://github.com/codama-idl/renderers-vixen-parser)) | Generates [Yellowstone](https://github.com/rpcpool/yellowstone-grpc) account and instruction parsers. | [Triton One](https://triton.one/) | -| `@limechain/codama-dart` ([docs](https://github.com/limechain/codama-dart))| Generates a Dart client. | [LimeChain](https://github.com/limechain/)| -| `codama-py` ([docs](https://github.com/Solana-ZH/codama-py)) | Generates a Python client. | [Solar](https://github.com/Solana-ZH) | - +| Visitor | Description | Maintainer | +| ----------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | ------------------------------------------ | +| `@codama/renderers-js` ([docs](https://github.com/codama-idl/renderers-js)) | Generates a JavaScript client compatible with [Solana Kit](https://www.solanakit.com/). | [Anza](https://www.anza.xyz/) | +| `@codama/renderers-js-umi` ([docs](https://github.com/codama-idl/renderers-js-umi)) | Generates a JavaScript client compatible with [the Umi framework](https://developers.metaplex.com/umi). | [Metaplex](https://www.metaplex.com/) | +| `@codama/renderers-rust` ([docs](https://github.com/codama-idl/renderers-rust)) | Generates a Rust client compatible with [the Solana SDK](https://github.com/anza-xyz/solana-sdk). | [Anza](https://www.anza.xyz/) | +| `@codama/renderers-vixen-parser` ([docs](https://github.com/codama-idl/renderers-vixen-parser)) | Generates [Yellowstone](https://github.com/rpcpool/yellowstone-grpc) account and instruction parsers. | [Triton One](https://triton.one/) | +| `@limechain/codama-dart` ([docs](https://github.com/limechain/codama-dart)) | Generates a Dart client. | [LimeChain](https://github.com/limechain/) | +| `codama-py` ([docs](https://github.com/Solana-ZH/codama-py)) | Generates a Python client. | [Solar](https://github.com/Solana-ZH) | ### Provides utility diff --git a/packages/node-types/src/InstructionNode.ts b/packages/node-types/src/InstructionNode.ts index 85fc6e531..be6479dd1 100644 --- a/packages/node-types/src/InstructionNode.ts +++ b/packages/node-types/src/InstructionNode.ts @@ -3,6 +3,7 @@ import type { InstructionAccountNode } from './InstructionAccountNode'; import type { InstructionArgumentNode } from './InstructionArgumentNode'; import type { InstructionByteDeltaNode } from './InstructionByteDeltaNode'; import type { InstructionRemainingAccountsNode } from './InstructionRemainingAccountsNode'; +import type { InstructionStatusNode } from './InstructionStatusNode'; import type { CamelCaseString, Docs } from './shared'; type SubInstructionNode = InstructionNode; @@ -34,5 +35,6 @@ export interface InstructionNode< readonly remainingAccounts?: TRemainingAccounts; readonly byteDeltas?: TByteDeltas; readonly discriminators?: TDiscriminators; + readonly status?: InstructionStatusNode; readonly subInstructions?: TSubInstructions; } diff --git a/packages/node-types/src/InstructionStatusNode.ts b/packages/node-types/src/InstructionStatusNode.ts new file mode 100644 index 000000000..3a707c4da --- /dev/null +++ b/packages/node-types/src/InstructionStatusNode.ts @@ -0,0 +1,9 @@ +import type { InstructionLifecycle } from './shared'; + +export interface InstructionStatusNode { + readonly kind: 'instructionStatusNode'; + + // Data. + readonly lifecycle: InstructionLifecycle; + readonly message?: string; +} diff --git a/packages/node-types/src/Node.ts b/packages/node-types/src/Node.ts index 96ae3a95e..c082abf8b 100644 --- a/packages/node-types/src/Node.ts +++ b/packages/node-types/src/Node.ts @@ -9,6 +9,7 @@ import type { InstructionArgumentNode } from './InstructionArgumentNode'; import type { InstructionByteDeltaNode } from './InstructionByteDeltaNode'; import type { InstructionNode } from './InstructionNode'; import type { InstructionRemainingAccountsNode } from './InstructionRemainingAccountsNode'; +import type { InstructionStatusNode } from './InstructionStatusNode'; import type { RegisteredLinkNode } from './linkNodes/LinkNode'; import type { PdaNode } from './PdaNode'; import type { RegisteredPdaSeedNode } from './pdaSeedNodes/PdaSeedNode'; @@ -28,6 +29,7 @@ export type Node = | InstructionByteDeltaNode | InstructionNode | InstructionRemainingAccountsNode + | InstructionStatusNode | PdaNode | ProgramNode | RegisteredContextualValueNode diff --git a/packages/node-types/src/index.ts b/packages/node-types/src/index.ts index 8c7aaba25..8b4a8d2d6 100644 --- a/packages/node-types/src/index.ts +++ b/packages/node-types/src/index.ts @@ -6,6 +6,7 @@ export * from './InstructionArgumentNode'; export * from './InstructionByteDeltaNode'; export * from './InstructionNode'; export * from './InstructionRemainingAccountsNode'; +export * from './InstructionStatusNode'; export * from './Node'; export * from './PdaNode'; export * from './ProgramNode'; diff --git a/packages/node-types/src/shared/index.ts b/packages/node-types/src/shared/index.ts index 74c22233d..b2213bb87 100644 --- a/packages/node-types/src/shared/index.ts +++ b/packages/node-types/src/shared/index.ts @@ -1,4 +1,5 @@ export * from './bytesEncoding'; export * from './docs'; +export * from './instructionLifecycle'; export * from './stringCases'; export * from './version'; diff --git a/packages/node-types/src/shared/instructionLifecycle.ts b/packages/node-types/src/shared/instructionLifecycle.ts new file mode 100644 index 000000000..6edfd75df --- /dev/null +++ b/packages/node-types/src/shared/instructionLifecycle.ts @@ -0,0 +1 @@ +export type InstructionLifecycle = 'archived' | 'deprecated' | 'draft' | 'live'; diff --git a/packages/nodes/docs/InstructionNode.md b/packages/nodes/docs/InstructionNode.md index 5a82028f3..b6a092dec 100644 --- a/packages/nodes/docs/InstructionNode.md +++ b/packages/nodes/docs/InstructionNode.md @@ -25,6 +25,7 @@ This node represents an instruction in a program. | `remainingAccounts` | [`InstructionRemainingAccountsNode`](./InstructionRemainingAccountsNode.md)[] | (Optional) The list of dynamic remaining accounts requirements for the instruction. For instance, an instruction may have a variable number of signers at the end of the accounts list. | | `byteDeltas` | [`InstructionByteDeltaNode`](./InstructionByteDeltaNode.md)[] | (Optional) The list of byte variations that the instruction causes. They should all be added together unless the `subtract` attribute is used. | | `discriminators` | [`DiscriminatorNode`](./DiscriminatorNode.md)[] | (Optional) The nodes that distinguish this instruction from others in the program. If multiple discriminators are provided, they are combined using a logical AND operation. | +| `status` | [`InstructionStatusNode`](./InstructionStatusNode.md) | (Optional) The status of the instruction and an optional message about that status. | | `subInstructions` | [`InstructionNode`](./InstructionNode.md)[] | (Optional) A list of nested instructions should this instruction be split into multiple sub-instructions to define distinct scenarios. | ## Functions @@ -121,7 +122,7 @@ instructionNode({ }); ``` -### An instruction with nested versionned instructions +### An instruction with nested versioned instructions ```ts instructionNode({ @@ -167,3 +168,45 @@ instructionNode({ ], }); ``` + +### A deprecated instruction + +```ts +instructionNode({ + name: 'oldIncrement', + status: instructionStatusNode( + 'deprecated', + 'Use the `increment` instruction instead. This will be removed in v3.0.0.', + ), + accounts: [instructionAccountNode({ name: 'counter', isWritable: true, isSigner: false })], + arguments: [instructionArgumentNode({ name: 'amount', type: numberTypeNode('u8') })], +}); +``` + +### An archived instruction + +```ts +instructionNode({ + name: 'legacyTransfer', + status: instructionStatusNode( + 'archived', + 'This instruction was removed in v2.0.0. It is kept here for historical parsing.', + ), + accounts: [ + instructionAccountNode({ name: 'source', isWritable: true, isSigner: true }), + instructionAccountNode({ name: 'destination', isWritable: true, isSigner: false }), + ], + arguments: [instructionArgumentNode({ name: 'amount', type: numberTypeNode('u64') })], +}); +``` + +### A draft instruction + +```ts +instructionNode({ + name: 'experimentalFeature', + status: instructionStatusNode('draft', 'This instruction is under development and may change.'), + accounts: [instructionAccountNode({ name: 'config', isWritable: true, isSigner: true })], + arguments: [], +}); +``` diff --git a/packages/nodes/docs/InstructionStatusNode.md b/packages/nodes/docs/InstructionStatusNode.md new file mode 100644 index 000000000..1ccd7c802 --- /dev/null +++ b/packages/nodes/docs/InstructionStatusNode.md @@ -0,0 +1,81 @@ +# `InstructionStatusNode` + +This node represents the status of an instruction along with an optional message. + +## Attributes + +### Data + +| Attribute | Type | Description | +| ----------- | ------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `kind` | `"instructionStatusNode"` | The node discriminator. | +| `lifecycle` | `"live"` \| `"deprecated"` \| `"archived"` \| `"draft"` | The lifecycle status of the instruction. `"live"` means accessible (the default), `"deprecated"` means about to be archived, `"archived"` means no longer accessible but kept for historical parsing, `"draft"` means not fully implemented yet. | +| `message` | `string` | (Optional) Additional information about the current status for program consumers. | + +## Functions + +### `instructionStatusNode(lifecycle, message?)` + +Helper function that creates an `InstructionStatusNode` object. + +```ts +const statusNode = instructionStatusNode('deprecated', 'Use the newInstruction instead'); +``` + +## Examples + +### A live instruction (no status needed) + +For live instructions, you typically don't need to set a status at all: + +```ts +instructionNode({ + name: 'transfer', + accounts: [...], + arguments: [...], +}); +``` + +### A deprecated instruction + +```ts +instructionNode({ + name: 'oldTransfer', + status: instructionStatusNode('deprecated', 'Use the `transfer` instruction instead. This will be removed in v3.0.0.'), + accounts: [...], + arguments: [...], +}); +``` + +### An archived instruction + +```ts +instructionNode({ + name: 'legacyTransfer', + status: instructionStatusNode('archived', 'This instruction was removed in v2.0.0. It is kept here for historical parsing.'), + accounts: [...], + arguments: [...], +}); +``` + +### A draft instruction + +```ts +instructionNode({ + name: 'experimentalFeature', + status: instructionStatusNode('draft', 'This instruction is under development and may change.'), + accounts: [...], + arguments: [...], +}); +``` + +### Status without a message + +```ts +instructionNode({ + name: 'someInstruction', + status: instructionStatusNode('deprecated'), + accounts: [...], + arguments: [...], +}); +``` diff --git a/packages/nodes/src/InstructionNode.ts b/packages/nodes/src/InstructionNode.ts index eae0a1527..03d2cfd29 100644 --- a/packages/nodes/src/InstructionNode.ts +++ b/packages/nodes/src/InstructionNode.ts @@ -78,6 +78,7 @@ export function instructionNode< name: camelCase(input.name), docs: parseDocs(input.docs), optionalAccountStrategy: parseOptionalAccountStrategy(input.optionalAccountStrategy), + ...(input.status !== undefined && { status: input.status }), // Children. accounts: (input.accounts ?? []) as TAccounts, diff --git a/packages/nodes/src/InstructionStatusNode.ts b/packages/nodes/src/InstructionStatusNode.ts new file mode 100644 index 000000000..68681068f --- /dev/null +++ b/packages/nodes/src/InstructionStatusNode.ts @@ -0,0 +1,11 @@ +import type { InstructionLifecycle, InstructionStatusNode } from '@codama/node-types'; + +export function instructionStatusNode(lifecycle: InstructionLifecycle, message?: string): InstructionStatusNode { + return Object.freeze({ + kind: 'instructionStatusNode', + + // Data. + lifecycle, + ...(message !== undefined && { message }), + }); +} diff --git a/packages/nodes/src/Node.ts b/packages/nodes/src/Node.ts index 193787cc0..73b2a7148 100644 --- a/packages/nodes/src/Node.ts +++ b/packages/nodes/src/Node.ts @@ -27,6 +27,7 @@ export const REGISTERED_NODE_KINDS = [ 'instructionByteDeltaNode' as const, 'instructionNode' as const, 'instructionRemainingAccountsNode' as const, + 'instructionStatusNode' as const, 'errorNode' as const, 'definedTypeNode' as const, ]; diff --git a/packages/nodes/src/index.ts b/packages/nodes/src/index.ts index 70aecdaf9..39ffa8649 100644 --- a/packages/nodes/src/index.ts +++ b/packages/nodes/src/index.ts @@ -18,6 +18,7 @@ export * from './InstructionArgumentNode'; export * from './InstructionByteDeltaNode'; export * from './InstructionNode'; export * from './InstructionRemainingAccountsNode'; +export * from './InstructionStatusNode'; export * from './Node'; export * from './PdaNode'; export * from './ProgramNode'; diff --git a/packages/nodes/test/InstructionNode.test.ts b/packages/nodes/test/InstructionNode.test.ts index d5c97b502..a603069d1 100644 --- a/packages/nodes/test/InstructionNode.test.ts +++ b/packages/nodes/test/InstructionNode.test.ts @@ -1,6 +1,6 @@ import { expect, test } from 'vitest'; -import { instructionNode } from '../src'; +import { instructionNode, instructionStatusNode } from '../src'; test('it returns the right node kind', () => { const node = instructionNode({ name: 'foo' }); @@ -11,3 +11,55 @@ test('it returns a frozen object', () => { const node = instructionNode({ name: 'foo' }); expect(Object.isFrozen(node)).toBe(true); }); + +test('it defaults to no status', () => { + const node = instructionNode({ name: 'foo' }); + expect(node.status).toBeUndefined(); +}); + +test('it can have a live status', () => { + const statusMode = instructionStatusNode('live'); + const node = instructionNode({ name: 'foo', status: statusMode }); + expect(node.status).toBe(statusMode); + expect(node.status?.lifecycle).toBe('live'); +}); + +test('it can have a deprecated status with message', () => { + const statusMode = instructionStatusNode('deprecated', 'Use the newFoo instruction instead.'); + const node = instructionNode({ name: 'foo', status: statusMode }); + expect(node.status).toBe(statusMode); + expect(node.status?.lifecycle).toBe('deprecated'); + expect(node.status?.message).toBe('Use the newFoo instruction instead.'); +}); + +test('it can have an archived status with message', () => { + const statusMode = instructionStatusNode('archived', 'This instruction was removed in v2.0.0.'); + const node = instructionNode({ name: 'foo', status: statusMode }); + expect(node.status).toBe(statusMode); + expect(node.status?.lifecycle).toBe('archived'); + expect(node.status?.message).toBe('This instruction was removed in v2.0.0.'); +}); + +test('it can have a draft status with message', () => { + const statusMode = instructionStatusNode('draft', 'This instruction is under development.'); + const node = instructionNode({ name: 'foo', status: statusMode }); + expect(node.status).toBe(statusMode); + expect(node.status?.lifecycle).toBe('draft'); + expect(node.status?.message).toBe('This instruction is under development.'); +}); + +test('it can have a status without a message', () => { + const statusMode = instructionStatusNode('deprecated'); + const node = instructionNode({ name: 'foo', status: statusMode }); + expect(node.status).toBe(statusMode); + expect(node.status?.lifecycle).toBe('deprecated'); + expect(node.status?.message).toBeUndefined(); +}); + +test('it can have an empty message', () => { + const statusMode = instructionStatusNode('deprecated', ''); + const node = instructionNode({ name: 'foo', status: statusMode }); + expect(node.status).toBe(statusMode); + expect(node.status?.lifecycle).toBe('deprecated'); + expect(node.status?.message).toBe(''); +}); diff --git a/packages/nodes/test/InstructionStatusNode.test.ts b/packages/nodes/test/InstructionStatusNode.test.ts new file mode 100644 index 000000000..e4bf80757 --- /dev/null +++ b/packages/nodes/test/InstructionStatusNode.test.ts @@ -0,0 +1,25 @@ +import { expect, test } from 'vitest'; + +import { instructionStatusNode } from '../src'; + +test('it returns the right node kind', () => { + const node = instructionStatusNode('live'); + expect(node.kind).toBe('instructionStatusNode'); +}); + +test('it returns a frozen object', () => { + const node = instructionStatusNode('live'); + expect(Object.isFrozen(node)).toBe(true); +}); + +test('it can have a status with message', () => { + const node = instructionStatusNode('deprecated', 'Use newInstruction'); + expect(node.lifecycle).toBe('deprecated'); + expect(node.message).toBe('Use newInstruction'); +}); + +test('it can have a status without message', () => { + const node = instructionStatusNode('archived'); + expect(node.lifecycle).toBe('archived'); + expect(node.message).toBeUndefined(); +}); diff --git a/packages/visitors-core/src/getDebugStringVisitor.ts b/packages/visitors-core/src/getDebugStringVisitor.ts index f90c377a9..9b971f41a 100644 --- a/packages/visitors-core/src/getDebugStringVisitor.ts +++ b/packages/visitors-core/src/getDebugStringVisitor.ts @@ -61,6 +61,8 @@ function getNodeDetails(node: Node): string[] { ]; case 'instructionByteDeltaNode': return [...(node.subtract ? ['subtract'] : []), ...(node.withHeader ? ['withHeader'] : [])]; + case 'instructionStatusNode': + return [node.lifecycle, ...(node.message ? [node.message] : [])]; case 'errorNode': return [node.code.toString(), node.name]; case 'accountLinkNode': diff --git a/packages/visitors-core/src/identityVisitor.ts b/packages/visitors-core/src/identityVisitor.ts index 9bd26f763..a5928b2af 100644 --- a/packages/visitors-core/src/identityVisitor.ts +++ b/packages/visitors-core/src/identityVisitor.ts @@ -34,6 +34,7 @@ import { instructionLinkNode, instructionNode, instructionRemainingAccountsNode, + instructionStatusNode, mapEntryValueNode, mapTypeNode, mapValueNode, @@ -136,6 +137,8 @@ export function identityVisitor( if (keys.includes('instructionNode')) { visitor.visitInstruction = function visitInstruction(node) { + const status = node.status ? (visit(this)(node.status) ?? undefined) : undefined; + if (status) assertIsNode(status, 'instructionStatusNode'); return instructionNode({ ...node, accounts: node.accounts @@ -162,6 +165,7 @@ export function identityVisitor( .map(visit(this)) .filter(removeNullAndAssertIsNodeFilter('instructionRemainingAccountsNode')) : undefined, + status, subInstructions: node.subInstructions ? node.subInstructions.map(visit(this)).filter(removeNullAndAssertIsNodeFilter('instructionNode')) : undefined, @@ -206,6 +210,12 @@ export function identityVisitor( }; } + if (keys.includes('instructionStatusNode')) { + visitor.visitInstructionStatus = function visitInstructionStatus(node) { + return instructionStatusNode(node.lifecycle, node.message); + }; + } + if (keys.includes('definedTypeNode')) { visitor.visitDefinedType = function visitDefinedType(node) { const type = visit(this)(node.type); diff --git a/packages/visitors-core/src/mergeVisitor.ts b/packages/visitors-core/src/mergeVisitor.ts index 58bf7391c..ca8999ba0 100644 --- a/packages/visitors-core/src/mergeVisitor.ts +++ b/packages/visitors-core/src/mergeVisitor.ts @@ -52,6 +52,7 @@ export function mergeVisitor( if (keys.includes('instructionNode')) { visitor.visitInstruction = function visitInstruction(node) { return merge(node, [ + ...(node.status ? visit(this)(node.status) : []), ...node.accounts.flatMap(visit(this)), ...node.arguments.flatMap(visit(this)), ...(node.extraArguments ?? []).flatMap(visit(this)), @@ -90,6 +91,12 @@ export function mergeVisitor( }; } + if (keys.includes('instructionStatusNode')) { + visitor.visitInstructionStatus = function visitInstructionStatus(node) { + return merge(node, []); + }; + } + if (keys.includes('definedTypeNode')) { visitor.visitDefinedType = function visitDefinedType(node) { return merge(node, visit(this)(node.type)); diff --git a/packages/visitors-core/test/nodes/InstructionNode.test.ts b/packages/visitors-core/test/nodes/InstructionNode.test.ts index 60ee83359..b4d75a68a 100644 --- a/packages/visitors-core/test/nodes/InstructionNode.test.ts +++ b/packages/visitors-core/test/nodes/InstructionNode.test.ts @@ -5,6 +5,7 @@ import { instructionByteDeltaNode, instructionNode, instructionRemainingAccountsNode, + instructionStatusNode, numberTypeNode, numberValueNode, publicKeyTypeNode, @@ -123,3 +124,19 @@ test('sub instructions', () => { expectMergeVisitorCount(nodeWithSubInstructions, 3); expectIdentityVisitor(nodeWithSubInstructions); }); + +test('status mode', () => { + const nodeWithStatus = instructionNode({ + name: 'deprecatedInstruction', + status: instructionStatusNode('deprecated', 'Use newInstruction instead'), + }); + + expectMergeVisitorCount(nodeWithStatus, 2); + expectIdentityVisitor(nodeWithStatus); + expectDebugStringVisitor( + nodeWithStatus, + ` +instructionNode [deprecatedInstruction] +| instructionStatusNode [deprecated.Use newInstruction instead]`, + ); +});