From 7701f8d86619ba1cfa07fe6be76d07ad19499ad2 Mon Sep 17 00:00:00 2001 From: Oskar Hane Date: Wed, 14 Oct 2026 11:31:38 +0200 Subject: [PATCH 1/2] desktop-createdat-fix/createdat-int64: model Connection.createdAt as optional int64 unix-ms and render created_at as RFC3339 UTC --- .../neo4j-cli-Patch-20261014-093059.yaml | 4 ++ .../commands/desktop/connection/list_test.go | 11 +++- internal/commands/desktop/list_test.go | 11 +++- internal/desktopclient/client_test.go | 45 +++++++++++++++ internal/desktopclient/output.go | 31 +++++++++- internal/desktopclient/output_test.go | 57 +++++++++++++++++++ internal/desktopclient/types.go | 5 +- 7 files changed, 158 insertions(+), 6 deletions(-) create mode 100644 .changes/unreleased/neo4j-cli-Patch-20261014-093059.yaml create mode 100644 internal/desktopclient/output_test.go diff --git a/.changes/unreleased/neo4j-cli-Patch-20261014-093059.yaml b/.changes/unreleased/neo4j-cli-Patch-20261014-093059.yaml new file mode 100644 index 00000000..ba0fc6e4 --- /dev/null +++ b/.changes/unreleased/neo4j-cli-Patch-20261014-093059.yaml @@ -0,0 +1,4 @@ +project: neo4j-cli +kind: Patch +body: 'Fixed `desktop list` and `desktop connection list` failing against Neo4j Desktop 2.3, which reports a connection''s `createdAt` as a numeric timestamp instead of a string.' +time: 2026-10-14T09:30:59+00:00 diff --git a/internal/commands/desktop/connection/list_test.go b/internal/commands/desktop/connection/list_test.go index f99677b8..799b135e 100644 --- a/internal/commands/desktop/connection/list_test.go +++ b/internal/commands/desktop/connection/list_test.go @@ -85,7 +85,7 @@ func TestConnectionList_JSON_RendersConnectionArray(t *testing.T) { return } _, _ = w.Write([]byte(`[ - {"id":"c1","name":"aura-dev","connectionUri":"neo4j+s://abc.databases.neo4j.io"}, + {"id":"c1","name":"aura-dev","connectionUri":"neo4j+s://abc.databases.neo4j.io","createdAt":1747843200000}, {"id":"c2","name":"self-hosted","connectionUri":"neo4j://10.0.0.1:7687"} ]`)) }) @@ -102,6 +102,15 @@ func TestConnectionList_JSON_RendersConnectionArray(t *testing.T) { if got[0]["id"] != "c1" || got[1]["id"] != "c2" { t.Fatalf("expected ids [c1 c2], got %v", got) } + // Desktop 2.3's numeric createdAt renders as RFC3339 UTC; the key is + // omitted entirely when Desktop does not report one. + wantCreatedAt := time.UnixMilli(1747843200000).UTC().Format(time.RFC3339) + if got[0]["created_at"] != wantCreatedAt { + t.Fatalf("expected created_at=%q, got %v", wantCreatedAt, got[0]["created_at"]) + } + if _, ok := got[1]["created_at"]; ok { + t.Fatalf("expected created_at omitted when absent on the wire, got %v", got[1]) + } } func TestConnectionList_EmptyJSON_RendersEmptyArrayNotNull(t *testing.T) { diff --git a/internal/commands/desktop/list_test.go b/internal/commands/desktop/list_test.go index 56da6bfe..3614c5bf 100644 --- a/internal/commands/desktop/list_test.go +++ b/internal/commands/desktop/list_test.go @@ -148,7 +148,7 @@ func TestList_JSON_BothPopulated(t *testing.T) { {"id":"b","name":"Bob"} ]` connections := `[ - {"id":"c1","name":"Aura prod","connectionUri":"neo4j+s://abc.databases.neo4j.io","projects":["my-proj"]}, + {"id":"c1","name":"Aura prod","connectionUri":"neo4j+s://abc.databases.neo4j.io","projects":["my-proj"],"createdAt":1747843200000}, {"id":"c2","name":"Sandbox","connectionUri":"neo4j+s://xyz.databases.neo4j.io"} ]` h.withHandler(defaultListHandler(t, slim, perID, connections)) @@ -197,6 +197,15 @@ func TestList_JSON_BothPopulated(t *testing.T) { if !ok || len(projects) != 1 || projects[0] != "my-proj" { t.Fatalf("expected projects=[my-proj] on JSON wire payload, got %v", out.Connections[0]["projects"]) } + // Desktop 2.3's numeric createdAt decodes and renders as RFC3339 UTC; + // the key is omitted entirely when Desktop does not report one. + wantCreatedAt := time.UnixMilli(1747843200000).UTC().Format(time.RFC3339) + if out.Connections[0]["created_at"] != wantCreatedAt { + t.Fatalf("expected created_at=%q, got %v", wantCreatedAt, out.Connections[0]["created_at"]) + } + if _, ok := out.Connections[1]["created_at"]; ok { + t.Fatalf("expected created_at omitted when absent on the wire, got %v", out.Connections[1]) + } } func TestList_JSON_DbmssOnly(t *testing.T) { diff --git a/internal/desktopclient/client_test.go b/internal/desktopclient/client_test.go index afbe12e2..45d88df6 100644 --- a/internal/desktopclient/client_test.go +++ b/internal/desktopclient/client_test.go @@ -738,6 +738,51 @@ func TestClient_ListConnections_SendsAuthHeadersAndDecodes(t *testing.T) { } } +func TestClient_ListConnections_CreatedAtDecode(t *testing.T) { + // Desktop 2.3 changed the createdAt wire field from an RFC3339 string to + // a numeric unix-milliseconds epoch; older Desktop versions omit the + // field entirely. Both shapes must decode. + tests := []struct { + name string + payload string + want int64 + }{ + { + name: "numeric createdAt (Desktop 2.3)", + payload: `[{"id":"c1","name":"aura","connectionUri":"neo4j+s://abc.databases.neo4j.io","createdAt":1747843200000}]`, + want: 1747843200000, + }, + { + name: "createdAt absent (older Desktop)", + payload: `[{"id":"c1","name":"aura","connectionUri":"neo4j+s://abc.databases.neo4j.io"}]`, + want: 0, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + const salt, clientID = "salt", "cid" + pinClientSeams(t, clientID, time.Date(2026, 5, 18, 12, 0, 0, 0, time.UTC)) + + srv, probe := newAuthedServer(t, salt, clientID, func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(tc.payload)) + }) + _ = srv + + cl, _ := NewClient(probe, salt) + got, err := cl.ListConnections(context.Background()) + if err != nil { + t.Fatalf("ListConnections: %v", err) + } + if len(got) != 1 { + t.Fatalf("len = %d, want 1", len(got)) + } + if got[0].CreatedAt != tc.want { + t.Fatalf("CreatedAt = %d, want %d", got[0].CreatedAt, tc.want) + } + }) + } +} + func TestClient_ListConnections_EmptyResponse(t *testing.T) { const salt, clientID = "salt", "cid" pinClientSeams(t, clientID, time.Date(2026, 5, 18, 12, 0, 0, 0, time.UTC)) diff --git a/internal/desktopclient/output.go b/internal/desktopclient/output.go index 13663b69..6a425c36 100644 --- a/internal/desktopclient/output.go +++ b/internal/desktopclient/output.go @@ -3,6 +3,8 @@ package desktopclient +import "time" + // This file holds OUTPUT projections only. The wire structs in types.go keep // Desktop's camelCase tags because they both parse Desktop's HTTP responses and // serialize request bodies. These projections re-tag the CLI-rendered output as @@ -41,6 +43,8 @@ func DbmsInfoOutputs(items []DbmsInfo) []DbmsInfoOutput { } // ConnectionOutput is the snake_case CLI-rendered projection of Connection. +// CreatedAt is Desktop's unix-milliseconds timestamp rendered as RFC3339 UTC, +// omitted when Desktop does not report one. type ConnectionOutput struct { ID string `json:"id"` Name string `json:"name"` @@ -53,8 +57,31 @@ type ConnectionOutput struct { ManifestPath string `json:"manifest_path,omitempty"` } -// ToOutput projects a Connection onto its snake_case render shape. -func (c Connection) ToOutput() ConnectionOutput { return ConnectionOutput(c) } +// ToOutput projects a Connection onto its snake_case render shape. Unlike +// DbmsInfo this is NOT a straight struct conversion: the wire's int64 +// unix-milliseconds CreatedAt is rendered as an RFC3339 UTC string. +func (c Connection) ToOutput() ConnectionOutput { + return ConnectionOutput{ + ID: c.ID, + Name: c.Name, + Description: c.Description, + Tags: c.Tags, + Projects: c.Projects, + Metadata: c.Metadata, + ConnectionURI: c.ConnectionURI, + CreatedAt: formatCreatedAtMs(c.CreatedAt), + ManifestPath: c.ManifestPath, + } +} + +// formatCreatedAtMs renders a unix-milliseconds timestamp as RFC3339 UTC; +// a zero/absent timestamp renders as "" so `omitempty` drops the key. +func formatCreatedAtMs(unixMs int64) string { + if unixMs <= 0 { + return "" + } + return time.UnixMilli(unixMs).UTC().Format(time.RFC3339) +} // ConnectionOutputs projects a slice, returning a non-nil empty slice for nil // input so JSON renders `[]` rather than `null`. diff --git a/internal/desktopclient/output_test.go b/internal/desktopclient/output_test.go new file mode 100644 index 00000000..c708ffc6 --- /dev/null +++ b/internal/desktopclient/output_test.go @@ -0,0 +1,57 @@ +// Copyright (c) "Neo4j" +// Neo4j Sweden AB [http://neo4j.com] + +package desktopclient + +import ( + "encoding/json" + "testing" + "time" +) + +func TestConnectionOutputs_CreatedAtRendering(t *testing.T) { + // Desktop 2.3 reports createdAt as unix-milliseconds; the CLI renders it + // as RFC3339 UTC under `created_at`, omitted when Desktop does not report + // one (older Desktop versions / legacy entries). + const unixMs = 1747843200000 + tests := []struct { + name string + connection Connection + wantKey bool + }{ + { + name: "numeric createdAt renders RFC3339 UTC", + connection: Connection{ID: "c1", Name: "aura", ConnectionURI: "neo4j+s://abc.databases.neo4j.io", CreatedAt: unixMs}, + wantKey: true, + }, + { + name: "absent createdAt is omitted", + connection: Connection{ID: "c2", Name: "local", ConnectionURI: "neo4j://localhost:7687"}, + wantKey: false, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + raw, err := json.Marshal(ConnectionOutputs([]Connection{tc.connection})) + if err != nil { + t.Fatalf("marshal: %v", err) + } + var got []map[string]any + if err := json.Unmarshal(raw, &got); err != nil { + t.Fatalf("unmarshal: %v (raw: %s)", err, raw) + } + if len(got) != 1 { + t.Fatalf("len = %d, want 1", len(got)) + } + gotVal, ok := got[0]["created_at"] + if ok != tc.wantKey { + t.Fatalf("created_at presence = %v, want %v (raw: %s)", ok, tc.wantKey, raw) + } + if tc.wantKey { + if want := time.UnixMilli(unixMs).UTC().Format(time.RFC3339); gotVal != want { + t.Fatalf("created_at = %v, want %q", gotVal, want) + } + } + }) + } +} diff --git a/internal/desktopclient/types.go b/internal/desktopclient/types.go index 8d6fdcb1..cddf5678 100644 --- a/internal/desktopclient/types.go +++ b/internal/desktopclient/types.go @@ -62,7 +62,8 @@ type Credentials struct { // Connection is a saved remote DB connection profile (Aura URI / remote Neo4j // endpoint) the user has registered with Desktop. Optional fields are // `omitempty` so they disappear from `--format json` output when Desktop -// doesn't populate them. +// doesn't populate them. `CreatedAt` is an optional unix-milliseconds +// timestamp (Desktop 2.3+); older Desktop versions omit it. type Connection struct { ID string `json:"id"` Name string `json:"name"` @@ -71,7 +72,7 @@ type Connection struct { Projects []string `json:"projects,omitempty"` Metadata map[string]any `json:"metadata,omitempty"` ConnectionURI string `json:"connectionUri,omitempty"` - CreatedAt string `json:"createdAt,omitempty"` + CreatedAt int64 `json:"createdAt,omitempty"` ManifestPath string `json:"manifestPath,omitempty"` } From 3ded9b67e217d07c4458fd555950d5ad238a3832 Mon Sep 17 00:00:00 2001 From: Oskar Hane Date: Wed, 14 Oct 2026 12:09:16 +0200 Subject: [PATCH 2/2] desktop-createdat-fix/share-createdat-format: extract shared unix-ms to RFC3339 formatter as desktopclient.FormatUnixMsRFC3339 --- internal/commands/desktop/project/output.go | 13 +------------ internal/commands/desktop/project/spec.go | 6 ++++-- internal/desktopclient/output.go | 10 ++++++---- internal/desktopclient/output_test.go | 19 +++++++++++++++++++ 4 files changed, 30 insertions(+), 18 deletions(-) diff --git a/internal/commands/desktop/project/output.go b/internal/commands/desktop/project/output.go index f6f404c9..a1a2e9cb 100644 --- a/internal/commands/desktop/project/output.go +++ b/internal/commands/desktop/project/output.go @@ -4,8 +4,6 @@ package project import ( - "time" - "github.com/neo4j/cli/internal/desktopclient" ) @@ -21,14 +19,5 @@ type projectOutput struct { } func toProjectOutput(p desktopclient.Project) projectOutput { - return projectOutput{ID: p.ID, Name: p.Name, CreatedAt: formatCreatedAt(p.CreatedAt)} -} - -// formatCreatedAt renders a unix-milliseconds timestamp as RFC3339 UTC; -// a zero/absent timestamp renders as "". -func formatCreatedAt(unixMs int64) string { - if unixMs <= 0 { - return "" - } - return time.UnixMilli(unixMs).UTC().Format(time.RFC3339) + return projectOutput{ID: p.ID, Name: p.Name, CreatedAt: desktopclient.FormatUnixMsRFC3339(p.CreatedAt)} } diff --git a/internal/commands/desktop/project/spec.go b/internal/commands/desktop/project/spec.go index 0e044f68..0896da1a 100644 --- a/internal/commands/desktop/project/spec.go +++ b/internal/commands/desktop/project/spec.go @@ -87,6 +87,8 @@ neo4j-cli desktop project delete my-project --yes --force --format json --rw`, FindByName: desktopclient.FindProjectByName, FindByID: desktopclient.FindProjectByID, - RowJSON: func(p desktopclient.Project) any { return toProjectOutput(p) }, - RowValues: func(p desktopclient.Project) []string { return []string{p.ID, p.Name, formatCreatedAt(p.CreatedAt)} }, + RowJSON: func(p desktopclient.Project) any { return toProjectOutput(p) }, + RowValues: func(p desktopclient.Project) []string { + return []string{p.ID, p.Name, desktopclient.FormatUnixMsRFC3339(p.CreatedAt)} + }, } diff --git a/internal/desktopclient/output.go b/internal/desktopclient/output.go index 6a425c36..70af4d9b 100644 --- a/internal/desktopclient/output.go +++ b/internal/desktopclient/output.go @@ -69,14 +69,16 @@ func (c Connection) ToOutput() ConnectionOutput { Projects: c.Projects, Metadata: c.Metadata, ConnectionURI: c.ConnectionURI, - CreatedAt: formatCreatedAtMs(c.CreatedAt), + CreatedAt: FormatUnixMsRFC3339(c.CreatedAt), ManifestPath: c.ManifestPath, } } -// formatCreatedAtMs renders a unix-milliseconds timestamp as RFC3339 UTC; -// a zero/absent timestamp renders as "" so `omitempty` drops the key. -func formatCreatedAtMs(unixMs int64) string { +// FormatUnixMsRFC3339 renders a unix-milliseconds timestamp as RFC3339 UTC; +// a non-positive (zero/absent) timestamp renders as "" so `omitempty` drops +// the key. This is the single unix-ms → RFC3339 formatter shared by every CLI +// projection of a Desktop timestamp. +func FormatUnixMsRFC3339(unixMs int64) string { if unixMs <= 0 { return "" } diff --git a/internal/desktopclient/output_test.go b/internal/desktopclient/output_test.go index c708ffc6..346b54d0 100644 --- a/internal/desktopclient/output_test.go +++ b/internal/desktopclient/output_test.go @@ -9,6 +9,25 @@ import ( "time" ) +func TestFormatUnixMsRFC3339(t *testing.T) { + tests := []struct { + name string + unixMs int64 + want string + }{ + {name: "positive unix-ms renders RFC3339 UTC", unixMs: 1747843200000, want: "2025-05-21T16:00:00Z"}, + {name: "zero renders empty", unixMs: 0, want: ""}, + {name: "negative renders empty", unixMs: -1, want: ""}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if got := FormatUnixMsRFC3339(tc.unixMs); got != tc.want { + t.Fatalf("FormatUnixMsRFC3339(%d) = %q, want %q", tc.unixMs, got, tc.want) + } + }) + } +} + func TestConnectionOutputs_CreatedAtRendering(t *testing.T) { // Desktop 2.3 reports createdAt as unix-milliseconds; the CLI renders it // as RFC3339 UTC under `created_at`, omitted when Desktop does not report