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
2 changes: 1 addition & 1 deletion AGENTS.md

Large diffs are not rendered by default.

110 changes: 66 additions & 44 deletions cmd/ledgerctl/ledgers/config_model.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (

"github.com/formancehq/ledger/v3/cmd/ledgerctl/accounttypes"
"github.com/formancehq/ledger/v3/internal/pkg/filterexpr"
"github.com/formancehq/ledger/v3/internal/pkg/semver"
"github.com/formancehq/ledger/v3/internal/proto/commonpb"
"github.com/formancehq/ledger/v3/internal/proto/servicepb"
)
Expand Down Expand Up @@ -269,7 +270,11 @@ func ComputeDiff(ledgerName string, current, desired *EditableConfig) ([]DiffAct
}
actions = append(actions, pqActions...)

actions = append(actions, diffNumscripts(ledgerName, current, desired)...)
nsActions, err := diffNumscripts(ledgerName, current, desired)
if err != nil {
return nil, err
}
actions = append(actions, nsActions...)

return actions, nil
}
Expand Down Expand Up @@ -744,63 +749,80 @@ func diffPreparedQueries(ledgerName string, current, desired *EditableConfig) ([
return actions, nil
}

func diffNumscripts(ledgerName string, current, desired *EditableConfig) []DiffAction {
func diffNumscripts(ledgerName string, current, desired *EditableConfig) ([]DiffAction, error) {
var actions []DiffAction

// Added or updated
// The library is immutable and append-only: a save is only ever a NEW
// version. Editing a version's content in place is impossible server-side,
// so an edit that keeps the same version is a mistake — catch it here
// rather than emitting a save the server rejects with
// NUMSCRIPT_VERSION_ALREADY_EXISTS.
for name, desiredNS := range desired.Numscripts {
currentNS, exists := current.Numscripts[name]
if !exists || currentNS.Content != desiredNS.Content || currentNS.Version != desiredNS.Version {
op := "add"
desc := fmt.Sprintf("Save numscript %q", name)
if desiredNS.Version != "" {
desc += fmt.Sprintf(" (v%s)", desiredNS.Version)

if exists && currentNS.Version == desiredNS.Version {
if currentNS.Content != desiredNS.Content {
return nil, fmt.Errorf("numscript %q: content changed but version %q was not bumped; publish the change under a new semver", name, desiredNS.Version)
}
if exists {
op = "update"
desc = fmt.Sprintf("Update numscript %q", name)
if desiredNS.Version != "" {
desc += fmt.Sprintf(" (v%s)", desiredNS.Version)
}

continue
}

// The server requires an explicit full canonical semver; validate here
// so an omitted/partial/non-canonical version fails the diff with a
// clear message rather than a valid-looking plan that only errors at
// apply time.
desiredVersion, err := semver.Parse(desiredNS.Version)
if err != nil {
return nil, fmt.Errorf("numscript %q: %w (a full canonical semver is required, e.g. 1.0.0)", name, err)
}

// The exported config carries only the greatest version of each script,
// and latest is always the greatest stored semver. A desired version at
// or below the current greatest can never become latest, so the plan
// would never converge (and re-fail with NUMSCRIPT_VERSION_ALREADY_EXISTS
// once stored). Require a strictly greater version to advance latest.
if exists {
currentVersion, err := semver.Parse(currentNS.Version)
if err != nil {
return nil, fmt.Errorf("numscript %q: stored version %q is not a canonical semver: %w", name, currentNS.Version, err)
}
actions = append(actions, DiffAction{
Section: "numscript",
Operation: op,
Description: desc,
Request: &servicepb.Request{
Type: &servicepb.Request_SaveNumscript{
SaveNumscript: &servicepb.SaveNumscriptRequest{
Ledger: ledgerName,
Name: name,
Content: desiredNS.Content,
Version: desiredNS.Version,
},

if desiredVersion.Compare(currentVersion) <= 0 {
return nil, fmt.Errorf("numscript %q: desired version %q is not greater than the current greatest %q; publish a higher version to advance latest", name, desiredNS.Version, currentNS.Version)
}
}

desc := fmt.Sprintf("Save numscript %q (v%s)", name, desiredNS.Version)
Comment thread
NumaryBot marked this conversation as resolved.

actions = append(actions, DiffAction{
Section: "numscript",
Operation: "add",
Description: desc,
Request: &servicepb.Request{
Type: &servicepb.Request_SaveNumscript{
SaveNumscript: &servicepb.SaveNumscriptRequest{
Comment thread
NumaryBot marked this conversation as resolved.
Ledger: ledgerName,
Name: name,
Content: desiredNS.Content,
Version: desiredNS.Version,
},
},
})
}
},
})
}

// Removed
// The library is append-only: a script present in the store but dropped
// from the desired config cannot be removed, so the desired state is
// unreachable. Fail loudly rather than silently reporting "already up to
// date" (the other config sections emit a remove action here).
for name := range current.Numscripts {
if _, exists := desired.Numscripts[name]; !exists {
actions = append(actions, DiffAction{
Section: "numscript",
Operation: "remove",
Description: fmt.Sprintf("Delete numscript %q", name),
Request: &servicepb.Request{
Type: &servicepb.Request_DeleteNumscript{
DeleteNumscript: &servicepb.DeleteNumscriptRequest{
Ledger: ledgerName,
Name: name,
},
},
},
})
if _, ok := desired.Numscripts[name]; !ok {
return nil, fmt.Errorf("numscript %q exists in the ledger but is absent from the desired config; the append-only library does not support removal", name)
}
}

return actions
return actions, nil
}

func parseEnforcementModeProto(s string) commonpb.ChartEnforcementMode {
Expand Down
4 changes: 2 additions & 2 deletions cmd/ledgerctl/numscripts/command.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,15 @@ func NewCommand() *cobra.Command {
Use: "numscripts",
Aliases: []string{"numscript", "ns"},
Short: "Manage numscript library",
Long: "Commands for managing the per-ledger numscript library (save, get, list, delete)",
Long: "Commands for managing the per-ledger numscript library (save, get, list, versions)",
}

cmd.PersistentFlags().String("ledger", "", "Ledger name (interactive selection if omitted)")

cmd.AddCommand(NewSaveCommand())
cmd.AddCommand(NewGetCommand())
cmd.AddCommand(NewListCommand())
cmd.AddCommand(NewDeleteCommand())
cmd.AddCommand(NewVersionsCommand())

return cmd
}
87 changes: 0 additions & 87 deletions cmd/ledgerctl/numscripts/delete.go

This file was deleted.

2 changes: 1 addition & 1 deletion cmd/ledgerctl/numscripts/list.go
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ func runList(cmd *cobra.Command, _ []string) error {
if len(scripts) == 0 {
pterm.Info.Printfln("No numscripts in library for ledger %s.", ledgerName)
pterm.Println(pterm.Gray("Hint: Save a numscript using:"))
pterm.FgCyan.Printfln(" ledgerctl numscripts save <name> --ledger %s --file <path>", ledgerName)
pterm.FgCyan.Printfln(" ledgerctl numscripts save <name> --ledger %s --file <path> --version 1.0.0", ledgerName)

return nil
}
Expand Down
13 changes: 7 additions & 6 deletions cmd/ledgerctl/numscripts/save.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,22 +17,23 @@ func NewSaveCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "save <name>",
Short: "Save a numscript to the library",
Long: `Save a numscript to a ledger's library.
Long: `Save a new immutable version of a numscript to a ledger's library.

If a script with the same name already exists, a new version is created.
The script content is read from a file (--file) or stdin.
The library is append-only: the version must be an explicit full semver and
must not already exist. The script content is read from a file (--file) or stdin.

Examples:
ledgerctl numscripts save transfer --ledger myledger --file transfer.num
cat transfer.num | ledgerctl numscripts save transfer --ledger myledger`,
ledgerctl numscripts save transfer --ledger myledger --file transfer.num --version 1.0.0
cat transfer.num | ledgerctl numscripts save transfer --ledger myledger --version 1.0.0`,
Args: cobra.ExactArgs(1),
ValidArgsFunction: cobra.NoFileCompletions,
RunE: runSave,
}

cmd.Flags().String("file", "", "Path to the numscript file (reads stdin if omitted)")
cmd.Flags().String("version", "", "Semver version (e.g. 1.0.0) or empty for latest")
cmd.Flags().String("version", "", "Explicit full semver (e.g. 1.0.0); required")
cmd.Flags().Duration("timeout", cmdutil.DefaultTimeout, "Request timeout")
_ = cmd.MarkFlagRequired("version")

return cmd
}
Expand Down
98 changes: 98 additions & 0 deletions cmd/ledgerctl/numscripts/versions.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
package numscripts

import (
"github.com/pterm/pterm"
"github.com/spf13/cobra"

"github.com/formancehq/ledger/v3/cmd/ledgerctl/cmdutil"
"github.com/formancehq/ledger/v3/internal/proto/servicepb"
)

// NewVersionsCommand creates the numscripts versions command.
func NewVersionsCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "versions <name>",
Short: "List every stored version of a numscript",
Long: `List every stored version of a numscript, with the status of each derived
from the current latest pointer: active, superseded, or tombstoned.

Examples:
ledgerctl numscripts versions transfer --ledger myledger`,
Args: cobra.ExactArgs(1),
ValidArgsFunction: cobra.NoFileCompletions,
RunE: runVersions,
}

cmdutil.AddConsistencyFlags(cmd)
cmdutil.AddOutputFlags(cmd)
cmd.Flags().Duration("timeout", cmdutil.DefaultTimeout, "Request timeout")

return cmd
}

func runVersions(cmd *cobra.Command, args []string) error {
name := args[0]

client, conn, err := cmdutil.GetClient(cmd)
if err != nil {
return err
}

defer func() { _ = conn.Close() }()

ledgerFlag, _ := cmd.Flags().GetString("ledger")
ledgerName, err := cmdutil.SelectLedger(cmd, client, ledgerFlag)
if err != nil {
return err
}

ctx, cancel := cmdutil.GetContext(cmd)
defer cancel()

resp, err := client.ListNumscriptVersions(ctx, &servicepb.ListNumscriptVersionsRequest{
Ledger: ledgerName,
Name: name,
Read: cmdutil.BuildReadOptions(cmdutil.GetConsistencyFlags(cmd)),
})
if err != nil {
return cmdutil.FormatGRPCError("failed to list numscript versions", err)
}

versions := resp.GetVersions()

if handled, err := cmdutil.EncodeStructured(cmd, resp); handled || err != nil {
return err
}

if len(versions) == 0 {
pterm.Info.Printfln("No versions stored for numscript %s on ledger %s.", name, ledgerName)

return nil
}

pterm.Printf("Latest: %s\n\n", pterm.Cyan(resp.GetLatestVersion()))

tableData := pterm.TableData{
{"VERSION", "CREATED AT"},
}

for _, v := range versions {
createdAt := ""
if v.GetCreatedAt() != nil {
createdAt = v.GetCreatedAt().AsTime().Format("2006-01-02T15:04:05Z07:00")
}

tableData = append(tableData, []string{
v.GetVersion(),
createdAt,
})
}

if err := pterm.DefaultTable.WithHasHeader().WithData(tableData).Render(); err != nil {
return err
}

pterm.Println()

return nil
}
Loading
Loading