@@ -80,11 +80,39 @@ struct SchemaField: Identifiable, Codable, Hashable, Sendable {
8080 if let typeStr = try? container.decode(String.self, forKey: .dataType) {
8181 self.dataType = typeStr
8282 } else if let typeObj = try? container.decode(JSONValue.self, forKey: .dataType) {
83− self.dataType = typeObj.displayString
83+ self.dataType = Self.describeArrowType(typeObj)
8484 } else {
8585 self.dataType = "Unknown"
8686 }
8787 }
88+
89+ /// Renders a serde-serialized Arrow `DataType`. Parameterized types are
90+ /// externally tagged objects — `p_timestamp`, present in every Parseable
91+ /// stream, arrives as `{"Timestamp": ["Millisecond", null]}` and a list as
92+ /// `{"List": {<field>}}` — which `displayString` collapsed to the placeholder
93+ /// "{1 fields}". Produce Arrow's own notation instead, e.g.
94+ /// `Timestamp(Millisecond, None)` / `List(Utf8)`.
95+ static func describeArrowType(_ value: JSONValue) -> String {
96+ switch value {
97+ case .null:
98+ return "None"
99+ case .string(let s):
100+ return s
101+ case .array(let items):
102+ return items.map { describeArrowType($0) }.joined(separator: ", ")
103+ case .object(let dict):
104+ // A nested `Field` (list/map element, struct member): show its type.
105+ if let fieldType = dict["data_type"] {
106+ return describeArrowType(fieldType)
107+ }
108+ if dict.count == 1, let entry = dict.first {
109+ return "\(entry.key)(\(describeArrowType(entry.value)))"
110+ }
111+ return value.compactJSON
112+ default:
113+ return value.displayString
114+ }
115+ }
88116}
89117
90118/// Formats a byte count that may arrive from the server as a fractional or
@@ -106,14 +134,59 @@ private func formatByteSize(_ value: Int) -> String? {
106134/// Normalizes a byte count that arrived as a JSON *string*. A bare numeric string
107135/// (e.g. "1048576") is a raw byte count and is formatted like the numeric branches
108136/// so identical sizes render consistently ("1 MB") regardless of whether the server
109−/// quoted the value; an already-formatted string (e.g. "1.2 GB") is passed through
110−/// unchanged.
137+/// quoted the value. Parseable 1.x — the versions that quote sizes — sends them as
138+/// "<n> Bytes" (e.g. "1048576 Bytes"), so that unit suffix is accepted too. Any
139+/// other already-formatted string (e.g. "1.2 GB") is passed through unchanged.
111140private func formatByteSize(_ value: String) -> String {
112− let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
141+ var trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
142+ if trimmed.lowercased().hasSuffix(" bytes") {
143+ trimmed = String(trimmed.dropLast(" bytes".count))
144+ }
113145 if let n = Int(trimmed) { return formatByteSize(n) ?? value }
114146 return value
115147}
116148
149+/// A string-backed coding key for fields whose name differs across Parseable
150+/// versions (kebab-case, snake_case, and camelCase spellings of the same field).
151+private struct FlexibleCodingKey: CodingKey {
152+ let stringValue: String
153+ var intValue: Int? { nil }
154+ init(stringValue: String) { self.stringValue = stringValue }
155+ init?(intValue: Int) { nil }
156+}
157+
158+private extension KeyedDecodingContainer where Key == FlexibleCodingKey {
159+ /// The first of `keys` that decodes as a string.
160+ func firstString(_ keys: String...) -> String? {
161+ for key in keys {
162+ if let value = try? decode(String.self, forKey: FlexibleCodingKey(stringValue: key)) {
163+ return value
164+ }
165+ }
166+ return nil
167+ }
168+
169+ /// The first of `keys` that decodes as a boolean — either a JSON bool or the
170+ /// strings "true"/"false" (Parseable serializes `static_schema_flag` as the
171+ /// string "true").
172+ func firstBool(_ keys: String...) -> Bool? {
173+ for key in keys {
174+ let codingKey = FlexibleCodingKey(stringValue: key)
175+ if let value = try? decode(Bool.self, forKey: codingKey) {
176+ return value
177+ }
178+ if let string = try? decode(String.self, forKey: codingKey) {
179+ switch string.lowercased() {
180+ case "true": return true
181+ case "false": return false
182+ default: continue
183+ }
184+ }
185+ }
186+ return nil
187+ }
188+}
189+
117190struct StreamStats: Codable, Sendable {
118191 let ingestion: IngestionStats?
119192 let storage: StorageStats?
@@ -165,7 +238,11 @@ struct StreamStats: Codable, Sendable {
165238
166239 init(from decoder: Decoder) throws {
167240 let container = try decoder.container(keyedBy: CodingKeys.self)
168− self.type = try? container.decode(String.self, forKey: .type)
241+ // Parseable names this field `format` (e.g. "parquet") in every
242+ // version; `type` never appears, so "Storage Type" always read N/A.
243+ // Keep `type` as a fallback.
244+ self.type = (try? decoder.container(keyedBy: FlexibleCodingKey.self))?
245+ .firstString("format", "type")
169246 if let s = try? container.decode(String.self, forKey: .size) {
170247 self.size = formatByteSize(s)
171248 } else if let n = try? container.decode(Int.self, forKey: .size) {
@@ -208,11 +285,20 @@ struct StreamInfo: Codable, Sendable {
208285 }
209286
210287 init(from decoder: Decoder) throws {
211− let container = try decoder.container(keyedBy: CodingKeys.self)
212− self.createdAt = try? container.decode(String.self, forKey: .createdAt)
213− self.firstEventAt = try? container.decode(String.self, forKey: .firstEventAt)
214− self.cacheEnabled = try? container.decode(Bool.self, forKey: .cacheEnabled)
215− self.timePartition = try? container.decode(String.self, forKey: .timePartition)
216− self.staticSchemaFlag = try? container.decode(Bool.self, forKey: .staticSchemaFlag)
288+ // The /info key spellings vary by Parseable version: 1.x–2.x send
289+ // `created-at`/`first-event-at` with the other fields in snake_case
290+ // (`cache_enabled`, `time_partition`, `static_schema_flag`), while newer
291+ // servers send camelCase throughout (`createdAt`, `timePartition`, …).
292+ // The kebab-case `cache-enabled`/`time-partition`/`static-schema-flag`
293+ // decoded previously were never sent by any version, so those rows always
294+ // showed "N/A". The schema flag also arrives as the string "true".
295+ let container = try decoder.container(keyedBy: FlexibleCodingKey.self)
296+ self.createdAt = container.firstString("created-at", "createdAt", "created_at")
297+ self.firstEventAt = container.firstString("first-event-at", "firstEventAt", "first_event_at")
298+ self.cacheEnabled = container.firstBool("cache_enabled", "cacheEnabled", "cache-enabled")
299+ self.timePartition = container.firstString("time_partition", "timePartition", "time-partition")
300+ self.staticSchemaFlag = container.firstBool(
301+ "static_schema_flag", "staticSchemaFlag", "static-schema-flag"
302+ )
217303 }
218304}