@@ -80,11 +80,39 @@ struct SchemaField: Identifiable, Codable, Hashable, Sendable {
80 if let typeStr = try? container.decode(String.self, forKey: .dataType) {
80 if let typeStr = try? container.decode(String.self, forKey: .dataType) {
81 self.dataType = typeStr
81 self.dataType = typeStr
82 } else if let typeObj = try? container.decode(JSONValue.self, forKey: .dataType) {
82 } else if let typeObj = try? container.decode(JSONValue.self, forKey: .dataType) {
83− self.dataType = typeObj.displayString
83+ self.dataType = Self.describeArrowType(typeObj)
85 self.dataType = "Unknown"
85 self.dataType = "Unknown"
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 {
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)
108+ if dict.count == 1, let entry = dict.first {
109+ return "\(entry.key)(\(describeArrowType(entry.value)))"
111+ return value.compactJSON
113+ return value.displayString
90/// Formats a byte count that may arrive from the server as a fractional or
118/// Formats a byte count that may arrive from the server as a fractional or
@@ -106,14 +134,59 @@ private func formatByteSize(_ value: Int) -> String? {
106/// Normalizes a byte count that arrived as a JSON *string*. A bare numeric string
134/// Normalizes a byte count that arrived as a JSON *string*. A bare numeric string
107/// (e.g. "1048576") is a raw byte count and is formatted like the numeric branches
135/// (e.g. "1048576") is a raw byte count and is formatted like the numeric branches
108/// so identical sizes render consistently ("1 MB") regardless of whether the server
136/// so identical sizes render consistently ("1 MB") regardless of whether the server
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
109−/// quoted the value; an already-formatted string (e.g. "1.2 GB") is passed through
139+/// other already-formatted string (e.g. "1.2 GB") is passed through unchanged.
111private func formatByteSize(_ value: String) -> String {
140private 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))
113 if let n = Int(trimmed) { return formatByteSize(n) ?? value }
145 if let n = Int(trimmed) { return formatByteSize(n) ?? value }
114 return value
146 return value
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 }
158+private extension KeyedDecodingContainer where Key == FlexibleCodingKey {
159+ /// The first of `keys` that decodes as a string.
160+ func firstString(_ keys: String...) -> String? {
162+ if let value = try? decode(String.self, forKey: FlexibleCodingKey(stringValue: key)) {
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
172+ func firstBool(_ keys: String...) -> Bool? {
174+ let codingKey = FlexibleCodingKey(stringValue: key)
175+ if let value = try? decode(Bool.self, forKey: codingKey) {
178+ if let string = try? decode(String.self, forKey: codingKey) {
179+ switch string.lowercased() {
180+ case "true": return true
181+ case "false": return false
117struct StreamStats: Codable, Sendable {
190struct StreamStats: Codable, Sendable {
118 let ingestion: IngestionStats?
191 let ingestion: IngestionStats?
119 let storage: StorageStats?
192 let storage: StorageStats?
@@ -165,7 +238,11 @@ struct StreamStats: Codable, Sendable {
166 init(from decoder: Decoder) throws {
239 init(from decoder: Decoder) throws {
167 let container = try decoder.container(keyedBy: CodingKeys.self)
240 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")
169 if let s = try? container.decode(String.self, forKey: .size) {
246 if let s = try? container.decode(String.self, forKey: .size) {
170 self.size = formatByteSize(s)
247 self.size = formatByteSize(s)
171 } else if let n = try? container.decode(Int.self, forKey: .size) {
248 } else if let n = try? container.decode(Int.self, forKey: .size) {
@@ -208,11 +285,20 @@ struct StreamInfo: Codable, Sendable {
210 init(from decoder: Decoder) throws {
287 init(from decoder: Decoder) throws {
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".
211− let container = try decoder.container(keyedBy: CodingKeys.self)
295+ let container = try decoder.container(keyedBy: FlexibleCodingKey.self)
212− self.createdAt = try? container.decode(String.self, forKey: .createdAt)
296+ self.createdAt = container.firstString("created-at", "createdAt", "created_at")
213− self.firstEventAt = try? container.decode(String.self, forKey: .firstEventAt)
297+ self.firstEventAt = container.firstString("first-event-at", "firstEventAt", "first_event_at")
214− self.cacheEnabled = try? container.decode(Bool.self, forKey: .cacheEnabled)
298+ self.cacheEnabled = container.firstBool("cache_enabled", "cacheEnabled", "cache-enabled")
215− self.timePartition = try? container.decode(String.self, forKey: .timePartition)
299+ self.timePartition = container.firstString("time_partition", "timePartition", "time-partition")
216− self.staticSchemaFlag = try? container.decode(Bool.self, forKey: .staticSchemaFlag)
300+ self.staticSchemaFlag = container.firstBool(
301+ "static_schema_flag", "staticSchemaFlag", "static-schema-flag"