diff --git a/.vscode/settings.json b/.vscode/settings.json index 68d2bd29..b18efa88 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -5,5 +5,6 @@ "editor.tabSize": 2, "editor.codeActionsOnSave": { "source.organizeImports": "explicit" - } + }, + "jest.runMode": "on-demand" } diff --git a/src/components/gui/schema-sidebar-list.tsx b/src/components/gui/schema-sidebar-list.tsx index 1aa156b9..ad6fbef1 100644 --- a/src/components/gui/schema-sidebar-list.tsx +++ b/src/components/gui/schema-sidebar-list.tsx @@ -5,10 +5,11 @@ import { scc } from "@/core/command"; import { DatabaseSchemaItem } from "@/drivers/base-driver"; import { triggerEditorExtensionTab } from "@/extensions/trigger-editor"; import { ExportFormat, exportTableData } from "@/lib/export-helper"; -import { Table } from "@phosphor-icons/react"; +import { Icon, Table } from "@phosphor-icons/react"; import { LucideCog, LucideDatabase, LucideView } from "lucide-react"; import { useCallback, useEffect, useMemo, useState } from "react"; import { ListView, ListViewItem } from "../listview"; +import { CloudflareIcon } from "../resource-card/icon"; import SchemaCreateDialog from "./schema-editor/schema-create"; interface SchemaListProps { @@ -39,12 +40,17 @@ function prepareListViewItem( let icon = Table; let iconClassName = ""; + console.log("ss", s); + if (s.type === "trigger") { icon = LucideCog; iconClassName = "text-purple-500"; } else if (s.type === "view") { icon = LucideView; iconClassName = "text-green-600 dark:text-green-300"; + } else if (s.type === "table" && s.name === "_cf_KV") { + icon = CloudflareIcon as Icon; + iconClassName = "text-orange-500"; } return { diff --git a/src/components/gui/table-result/render-cell.tsx b/src/components/gui/table-result/render-cell.tsx index b41b04bd..f0c04c9b 100644 --- a/src/components/gui/table-result/render-cell.tsx +++ b/src/components/gui/table-result/render-cell.tsx @@ -1,7 +1,9 @@ import BlobCell from "@/components/gui/table-cell/blob-cell"; import { DatabaseValue } from "@/drivers/base-driver"; import parseSafeJson from "@/lib/json-safe"; +import { deserializeV8 } from "@/lib/v8-derialization"; import { ColumnType } from "@outerbase/sdk-transform"; +import { useMemo } from "react"; import BigNumberCell from "../table-cell/big-number-cell"; import GenericCell from "../table-cell/generic-cell"; import NumberCell from "../table-cell/number-cell"; @@ -42,17 +44,93 @@ function determineCellType(value: unknown) { return undefined; } -export default function tableResultCellRenderer({ - y, - x, - state, - header, - isFocus, -}: OptimizeTableCellRenderProps) { +function CloudflareKvValue({ + props, +}: { + props: OptimizeTableCellRenderProps; +}) { + const { y, x, state, header, isFocus } = props; + + const value = useMemo(() => { + const rawBuffer = state.getValue(y, x); + let buffer = new ArrayBuffer(); + + if (rawBuffer instanceof ArrayBuffer) { + buffer = rawBuffer; + } else if (rawBuffer instanceof Uint8Array) { + buffer = rawBuffer.buffer as ArrayBuffer; + } else if (rawBuffer instanceof Array) { + buffer = new Uint8Array(rawBuffer).buffer; + } + + return deserializeV8(buffer); + }, [y, x, state]); + + let displayValue: string | null = ""; + + if (value.value !== undefined) { + if (typeof value.value === "string") { + displayValue = value.value; + } else if (value.value === null) { + displayValue = null; + } else if (typeof value.value === "object") { + // Protect from circular references + try { + displayValue = JSON.stringify(value.value, null); + } catch (e) { + if (e instanceof Error) { + value.error = e.message; + } else { + value.error = String(e); + } + } + } else { + displayValue = String(value.value); + } + } + + if (value.error) { + return ( +
+ Error: {value.error} +
+ ); + } + + return ( + { + state.changeValue(y, x, newValue); + }} + /> + ); +} + +export default function tableResultCellRenderer( + props: OptimizeTableCellRenderProps +) { + const { y, x, state, header, isFocus } = props; + const editMode = isFocus && state.isInEditMode(); const value = state.getValue(y, x); + const valueType = determineCellType(value); + // Check if it is Cloudflare KV type + if ( + header.metadata?.from?.table === "_cf_KV" && + header.metadata?.from?.column === "value" + ) { + return ; + } + switch (valueType ?? header.metadata.type) { case ColumnType.INTEGER: return ( diff --git a/src/components/listview/index.tsx b/src/components/listview/index.tsx index 687e279c..2247114c 100644 --- a/src/components/listview/index.tsx +++ b/src/components/listview/index.tsx @@ -6,11 +6,8 @@ import { } from "@/components/ui/context-menu"; import { OpenContextMenuList } from "@/core/channel-builtin"; import { cn } from "@/lib/utils"; -import { - LucideChevronDown, - LucideChevronRight, - LucideIcon, -} from "lucide-react"; +import { Icon } from "@phosphor-icons/react"; +import { LucideChevronDown, LucideChevronRight } from "lucide-react"; import React, { Dispatch, Fragment, @@ -24,7 +21,7 @@ import HighlightText from "../ui/highlight-text"; export interface ListViewItem { key: string; name: string; - icon: LucideIcon; + icon: Icon; iconColor?: string; iconBadgeColor?: string; data: T; diff --git a/src/lib/build-table-result.ts b/src/lib/build-table-result.ts index 9bfdf1ba..49eba2b9 100644 --- a/src/lib/build-table-result.ts +++ b/src/lib/build-table-result.ts @@ -207,6 +207,16 @@ export function pipeVirtualColumnAsReadOnly( } } +export function pipeCloudflareSpecialTable( + headers: OptimizeTableHeaderProps[] +) { + for (const header of headers) { + if (header.metadata.from?.table === "_cf_KV") { + header.setting.readonly = true; + } + } +} + export function pipeCalculateInitialSize( headers: OptimizeTableHeaderProps[], { result }: BuildTableResultProps @@ -293,6 +303,7 @@ export function buildTableResultHeader( pipeAttachColumnViaSchemas(headers, props); pipeEditableTable(headers, props); pipeVirtualColumnAsReadOnly(headers); + pipeCloudflareSpecialTable(headers); pipeCalculateInitialSize(headers, props); pipeColumnIcon(headers); diff --git a/src/lib/v8-derialization/deserialize.test.ts b/src/lib/v8-derialization/deserialize.test.ts new file mode 100644 index 00000000..c9f25d24 --- /dev/null +++ b/src/lib/v8-derialization/deserialize.test.ts @@ -0,0 +1,79 @@ +import { deserializeV8 } from "."; + +function p(hex: string) { + const buffer = new Uint8Array(hex.length / 2); + for (let i = 0; i < hex.length; i += 2) { + buffer[i / 2] = parseInt(hex.substring(i, i + 2), 16); + } + + return deserializeV8(buffer.buffer); +} + +describe("V8 Deserialization", () => { + it("positive small number", () => { + expect(p("FF0F4906").value).toBe(3); + }); + + it("negative number", () => { + expect(p("FF0F4905").value).toBe(-3); + }); + + it("positive large number", () => { + expect(p("FF0F4994B0BEDF01").value).toBe(234343434); + }); + + it("string", () => { + expect(p("FF0F220B68656C6C6F20776F726C64").value).toBe("hello world"); + }); + + it("unicode string", () => { + expect(p("FF0F6308604F7D59164E4C75").value).toBe("你好世界"); + }); + + it("true", () => { + expect(p("FF0F54").value).toBe(true); + }); + + it("false", () => { + expect(p("FF0F46").value).toBe(false); + }); + + it("null", () => { + expect(p("FF0F30").value).toBe(null); + }); + + it("double", () => { + expect(p("FF0F4E1F85EB51B81E0940").value).toBeCloseTo(3.14); + }); + + it("big number", () => { + expect(p("FF0F5A10D20A1FEB8CA954AB").value).toBe(12345678901234567890n); + }); + + it("array", () => { + expect(p("FF0F4103220568656C6C6F2205776F726C64490A240003").value).toEqual([ + "hello", + "world", + 5, + ]); + }); + + it("object", () => { + expect( + p("FF0F6F220568656C6C6F2205776F726C6422066E756D626572490A7B02").value + ).toEqual({ hello: "world", number: 5 }); + }); + + it("object with undefined", () => { + expect( + p( + "FF0F6F220568656C6C6F2205776F726C6422036172724103490249044906240003220275645F7B03" + ).value + ).toEqual({ hello: "world", arr: [1, 2, 3], ud: undefined }); + }); + + it("date", () => { + const date = new Date(1743508780000); + expect(p("FF0F4400003E8B135F7942").value).toEqual(date); + }); +}); diff --git a/src/lib/v8-derialization/index.ts b/src/lib/v8-derialization/index.ts new file mode 100644 index 00000000..74b29f74 --- /dev/null +++ b/src/lib/v8-derialization/index.ts @@ -0,0 +1,369 @@ +/** + * Provides human-readable values for __cf_KV by deserializing V8 serialized data. + * Implements a simplified version with limited supported types sufficient for our needs. + * + * Reference: https://github.com/v8/v8/blob/master/src/objects/value-serializer.cc + */ + +/** + * Class to track object references during deserialization + */ +class ObjectRegistry { + private objects: Map = new Map(); + private nextId: number = 0; + + /** + * Register an object and get its ID + */ + register(obj: unknown): number { + const id = this.nextId++; + this.objects.set(id, obj); + return id; + } + + /** + * Get an object by its ID + */ + get(id: number): unknown { + const obj = this.objects.get(id); + if (obj === undefined) { + throw new Error( + `Object reference not found: ${id} (available: ${[...this.objects.keys()].join(", ")})` + ); + } + return obj; + } +} + +interface DeserializationResponse { + error?: string; + value: unknown; +} + +export function deserializeV8(buffer: ArrayBuffer): DeserializationResponse { + const df = new DataView(buffer); + + // Check if the first byte is 0xFF + if (df.getUint8(0) !== 0xff) { + return { error: "Invalid data format", value: undefined }; + } + + // Check if the second byte is 0x0F + if (df.getUint8(1) !== 0x0f) { + return { + error: "We only support deserialize version 15", + value: undefined, + }; + } + + try { + // Create a registry to track objects for reference handling + const registry = new ObjectRegistry(); + const [value] = deserializeValue(df, 2, registry); + return { value }; + } catch (e) { + if (e instanceof Error) { + console.log(e.message); + return { error: e.message, value: undefined }; + } + return { error: "Deserialization failed", value: undefined }; + } +} + +function deserializeValue( + df: DataView, + offset: number, + registry: ObjectRegistry = new ObjectRegistry() +): [unknown, number] { + const type = df.getUint8(offset); + + switch (type) { + // ZigZag-encoded signed 32-bit integer (like sint32 in protobuf) + case "I".charCodeAt(0): { + const [value, bytesRead] = decodeZigZag(df, offset + 1); + return [value, offset + 1 + bytesRead]; + } + case "U".charCodeAt(0): { + // Varint-encoded unsigned 32-bit integer + const [value, bytesRead] = decodeVarint(df, offset + 1); + return [value, offset + 1 + bytesRead]; + } + case "c".charCodeAt(0): { + // Two-byte string (UTF-16) + return deserializeString(df, offset + 1, "utf-16le"); + } + case '"'.charCodeAt(0): { + // One-byte ASCII string + return deserializeString(df, offset + 1, "ascii"); + } + case "S".charCodeAt(0): { + // UTF-8 string + return deserializeString(df, offset + 1, "utf-8"); + } + // Boolean values + case "F".charCodeAt(0): { + // Boolean false + return [false, offset + 1]; + } + case "T".charCodeAt(0): { + // Boolean true + return [true, offset + 1]; + } + // Null value + case "0".charCodeAt(0): { + // Null + return [null, offset + 1]; + } + // Undefined value + case "_".charCodeAt(0): { + // Undefined + return [undefined, offset + 1]; + } + // Double precision floating point + case "N".charCodeAt(0): { + // Double (8 bytes, 64-bit IEEE 754) + const value = df.getFloat64(offset + 1, true); // true = little endian + return [value, offset + 1 + 8]; + } + // BigInt + case "Z".charCodeAt(0): { + return deserializeBigInt(df, offset + 1); + } + // Date + case "D".charCodeAt(0): { + // Date: milliseconds since epoch as double (8 bytes, 64-bit IEEE 754) + const millisSinceEpoch = df.getFloat64(offset + 1, true); // true = little endian + return [new Date(millisSinceEpoch), offset + 1 + 8]; + } + case "A".charCodeAt(0): { + return deserializeDenseJSArray(df, offset + 1, registry); + } + case "o".charCodeAt(0): { + // JavaScript Object + return deserializeJSObject(df, offset + 1, registry); + } + // Object reference + case "^".charCodeAt(0): { + // Reference to a previously deserialized object + const [refId, bytesRead] = decodeVarint(df, offset + 1); + const referencedObject = registry.get(refId); + return [referencedObject, offset + 1 + bytesRead]; + } + + default: + throw new Error( + `Unsupported type: ${String.fromCharCode(type)} (${type})` + ); + } +} + +/** + * Deserializes a JavaScript object + * @param df DataView containing the serialized data + * @param offset Current offset in the DataView (after the type byte) + * @param registry Registry to track object references + */ +function deserializeJSObject( + df: DataView, + offset: number, + registry: ObjectRegistry +): [Record, number] { + const obj: Record = {}; + + // Register the object before populating it (enables handling circular references) + registry.register(obj); + + let currentOffset = offset; + let propertyCount = 0; + + // Keep reading key-value pairs until we find the end marker + while (df.getUint8(currentOffset) !== "{".charCodeAt(0)) { + // Read property key + const [key, keyOffset] = deserializeValue(df, currentOffset, registry); + currentOffset = keyOffset; + + // Read property value + const [value, valueOffset] = deserializeValue(df, currentOffset, registry); + currentOffset = valueOffset; + + // Set property on object + if (typeof key === "string" || typeof key === "number") { + obj[key] = value; + } + + propertyCount++; + } + + // Skip the end marker + currentOffset++; + + // Read numProperties for validation + const [numProperties, propBytesRead] = decodeVarint(df, currentOffset); + currentOffset += propBytesRead; + + // Validate that we read the expected number of properties + if (propertyCount !== numProperties) { + throw new Error( + `Expected ${numProperties} properties, but read ${propertyCount}` + ); + } + + return [obj, currentOffset]; +} + +/** + * Deserializes a dense JavaScript array + * @param df DataView containing the serialized data + * @param offset Current offset in the DataView (after the type byte) + * @param registry Registry to track object references + */ +function deserializeDenseJSArray( + df: DataView, + offset: number, + registry: ObjectRegistry +): [unknown[], number] { + const array: unknown[] = []; + + // Register the array before populating it (enables handling circular references) + registry.register(array); + + const [length, bytesRead] = decodeVarint(df, offset); + let currentOffset = offset + bytesRead; + + // Read each array element + for (let i = 0; i < length; i++) { + const [value, nextOffset] = deserializeValue(df, currentOffset, registry); + array.push(value); + currentOffset = nextOffset; + } + + // Check for the end marker + if (df.getUint8(currentOffset) !== "$".charCodeAt(0)) { + throw new Error("Expected end of array marker"); + } + + // Skip the end marker + currentOffset++; + + // Read numProperties and length + const [numProperties, propBytesRead] = decodeVarint(df, currentOffset); + currentOffset += propBytesRead; + + const [arrayLength, lengthBytesRead] = decodeVarint(df, currentOffset); + currentOffset += lengthBytesRead; + + // Read properties if any (key-value pairs) + for (let i = 0; i < numProperties; i++) { + // Read property key + const [key, keyOffset] = deserializeValue(df, currentOffset, registry); + currentOffset = keyOffset; + + // Read property value + const [value, valueOffset] = deserializeValue(df, currentOffset, registry); + currentOffset = valueOffset; + + // Set property on array + if (typeof key === "string" || typeof key === "number") { + (array as any)[key] = value; + } + } + + // Set the array length to match the serialized length + array.length = arrayLength; + + return [array, currentOffset]; +} + +/** + * Deserializes a BigInt value + * @param df DataView containing the serialized data + * @param offset Current offset in the DataView (after the type byte) + */ +function deserializeBigInt(df: DataView, offset: number): [bigint, number] { + // Read the bitfield which contains both sign and length information + const [bitfield, bytesReadForBitfield] = decodeVarint(df, offset); + + // Extract sign from the bitfield (lowest bit is the sign: 0 = positive, 1 = negative) + const isNegative = (bitfield & 1) === 1; + + // Calculate the byte length (based on V8's implementation) + // The length is stored in the upper bits of the bitfield + const byteLength = Math.floor(bitfield / 2); // Simplified approximation + + // Read the bytes for the BigInt digits + const digitsStart = offset + bytesReadForBitfield; + let bigintValue = 0n; + + // Read each byte and build the BigInt + for (let i = 0; i < byteLength; i++) { + // BigInts are stored in little-endian format in V8 + const byte = df.getUint8(digitsStart + i); + bigintValue = bigintValue + (BigInt(byte) << BigInt(8 * i)); + } + + // Apply sign + if (isNegative) { + bigintValue = -bigintValue; + } + + return [bigintValue, digitsStart + byteLength]; +} + +/** + * Helper function to deserialize string values + * @param df DataView containing the serialized data + * @param offset Current offset in the DataView (after the type byte) + * @param encoding String encoding to use for decoding + */ +function deserializeString( + df: DataView, + offset: number, + encoding: string +): [string, number] { + const [length, lengthBytesRead] = decodeVarint(df, offset); + + // Calculate string data offset and create view + const stringStart = offset + lengthBytesRead; + const stringBytes = new Uint8Array( + df.buffer, + df.byteOffset + stringStart, + length + ); + + // Decode using the specified encoding + const decoder = new TextDecoder(encoding); + const value = decoder.decode(stringBytes); + + return [value, stringStart + length]; +} + +// Decode a varint (variable-length integer encoding) +function decodeVarint(df: DataView, offset: number): [number, number] { + let result = 0; + let shift = 0; + let bytesRead = 0; + let currentByte; + + do { + if (shift >= 32) { + throw new Error("Varint is too large"); + } + + currentByte = df.getUint8(offset + bytesRead); + bytesRead++; + + // Add the lower 7 bits to the result + result |= (currentByte & 0x7f) << shift; + shift += 7; + } while (currentByte & 0x80); // Continue if the high bit is set + + return [result >>> 0, bytesRead]; // Ensure unsigned 32-bit +} + +// Decode a ZigZag encoded value +function decodeZigZag(df: DataView, offset: number): [number, number] { + const [value, bytesRead] = decodeVarint(df, offset); + // ZigZag decoding: (value >> 1) ^ -(value & 1) + const decoded = (value >>> 1) ^ -(value & 1); + return [decoded, bytesRead]; +} diff --git a/tsconfig.json b/tsconfig.json index 97a8b2c6..dadafa24 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,27 +1,27 @@ -{ - "compilerOptions": { - "target": "es2015", - "lib": ["dom", "dom.iterable", "esnext"], - "allowJs": true, - "skipLibCheck": true, - "strict": true, - "noEmit": true, - "esModuleInterop": true, - "module": "esnext", - "moduleResolution": "bundler", - "resolveJsonModule": true, - "isolatedModules": true, - "jsx": "preserve", - "incremental": true, - "plugins": [ - { - "name": "next" - } - ], - "paths": { - "@/*": ["./src/*"] - } - }, - "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], - "exclude": ["node_modules"] -} +{ + "compilerOptions": { + "target": "es2020", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true, + "plugins": [ + { + "name": "next" + } + ], + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], + "exclude": ["node_modules"] +}