From 99936513e985165e26308582f1238643cb4fbe58 Mon Sep 17 00:00:00 2001 From: Evgenii Kniazev Date: Mon, 30 Mar 2026 15:35:39 +0100 Subject: [PATCH 1/5] feat: add proto plugin for typed data contracts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New plugin that provides protobuf-based serialization so plugins, routes, and jobs share a single schema definition. Pairs with the Files plugin for Volume I/O and Lakebase plugin for database storage. Plugin API: - create(schema, init?) — typed message construction - serialize / deserialize — binary (Uint8Array) - toJSON / fromJSON — JSON with proto field names Also includes: - Proto definitions: appkit/v1/common.proto (Error, Metadata, Value) and appkit/v1/pipeline.proto (JobResult, DataBatch, DataRow) - Generated TypeScript types in packages/shared/src/proto/ - buf configuration for codegen - Documentation with usage examples at docs/plugins/proto.md - Unit tests for plugin and serializer Signed-off-by: Evgenii Kniazev Co-authored-by: Isaac Signed-off-by: Evgenii Kniazev --- docs/docs/plugins/index.md | 3 +- docs/docs/plugins/proto.md | 252 ++++++++++++++++++ packages/appkit/package.json | 3 +- packages/appkit/src/index.ts | 2 +- packages/appkit/src/plugins/index.ts | 1 + packages/appkit/src/plugins/proto/index.ts | 3 + .../appkit/src/plugins/proto/manifest.json | 16 ++ packages/appkit/src/plugins/proto/plugin.ts | 82 ++++++ .../appkit/src/plugins/proto/serializer.ts | 49 ++++ .../src/plugins/proto/tests/plugin.test.ts | 100 +++++++ .../plugins/proto/tests/serializer.test.ts | 53 ++++ packages/appkit/src/plugins/proto/types.ts | 4 + packages/shared/src/index.ts | 3 + .../shared/src/proto/appkit/v1/common_pb.ts | 57 ++++ .../shared/src/proto/appkit/v1/pipeline_pb.ts | 88 ++++++ packages/shared/src/proto/index.ts | 5 + proto/appkit/v1/common.proto | 41 +++ proto/appkit/v1/pipeline.proto | 68 +++++ proto/buf.gen.yaml | 6 + proto/buf.yaml | 9 + turbo.json | 5 + 21 files changed, 847 insertions(+), 3 deletions(-) create mode 100644 docs/docs/plugins/proto.md create mode 100644 packages/appkit/src/plugins/proto/index.ts create mode 100644 packages/appkit/src/plugins/proto/manifest.json create mode 100644 packages/appkit/src/plugins/proto/plugin.ts create mode 100644 packages/appkit/src/plugins/proto/serializer.ts create mode 100644 packages/appkit/src/plugins/proto/tests/plugin.test.ts create mode 100644 packages/appkit/src/plugins/proto/tests/serializer.test.ts create mode 100644 packages/appkit/src/plugins/proto/types.ts create mode 100644 packages/shared/src/proto/appkit/v1/common_pb.ts create mode 100644 packages/shared/src/proto/appkit/v1/pipeline_pb.ts create mode 100644 packages/shared/src/proto/index.ts create mode 100644 proto/appkit/v1/common.proto create mode 100644 proto/appkit/v1/pipeline.proto create mode 100644 proto/buf.gen.yaml create mode 100644 proto/buf.yaml diff --git a/docs/docs/plugins/index.md b/docs/docs/plugins/index.md index f0e4b51d..e79ff6b4 100644 --- a/docs/docs/plugins/index.md +++ b/docs/docs/plugins/index.md @@ -13,7 +13,7 @@ For complete API documentation, see the [`Plugin`](../api/appkit/Class.Plugin.md Configure plugins when creating your AppKit instance: ```typescript -import { createApp, server, analytics, genie, files } from "@databricks/appkit"; +import { createApp, server, analytics, genie, files, proto } from "@databricks/appkit"; const AppKit = await createApp({ plugins: [ @@ -21,6 +21,7 @@ const AppKit = await createApp({ analytics(), genie(), files(), + proto(), ], }); ``` diff --git a/docs/docs/plugins/proto.md b/docs/docs/plugins/proto.md new file mode 100644 index 00000000..dec03644 --- /dev/null +++ b/docs/docs/plugins/proto.md @@ -0,0 +1,252 @@ +--- +sidebar_position: 8 +--- + +# Proto plugin + +Typed data contracts via protobuf. Define your data shapes once in `.proto` files, generate TypeScript types, and use them across plugins, routes, and jobs — no more ad-hoc interfaces that drift between producer and consumer. + +**Key features:** +- **Single schema definition** — one `.proto` file generates types for all consumers +- **Binary + JSON serialization** — efficient binary for storage, JSON for APIs +- **Type-safe create** — construct messages with compile-time field validation +- **Interop with other plugins** — serialize to bytes, pass to Files plugin for Volume I/O; serialize to JSON, pass to Analytics plugin for SQL; serialize to binary, send over gRPC + +## Basic usage + +```ts +import { createApp, proto, server } from "@databricks/appkit"; + +const app = await createApp({ + plugins: [ + server(), + proto(), + ], +}); +``` + +## Defining contracts + +Create `.proto` files in your project: + +```protobuf +// proto/myapp/v1/models.proto +syntax = "proto3"; +package myapp.v1; + +message Customer { + string id = 1; + string name = 2; + string email = 3; + double lifetime_value = 4; + bool is_active = 5; +} + +message Order { + string order_id = 1; + string customer_id = 2; + double total = 3; + repeated OrderItem items = 4; +} + +message OrderItem { + string product_id = 1; + string name = 2; + int32 quantity = 3; + double unit_price = 4; +} +``` + +Generate TypeScript types: + +```bash +npx buf generate proto/ +``` + +This produces typed interfaces like `Customer`, `Order`, `OrderItem` with schemas like `CustomerSchema`, `OrderSchema`. + +## Creating messages + +```ts +import { CustomerSchema } from "../proto/gen/myapp/v1/models_pb.js"; + +// Type-safe — unknown fields are compile errors +const customer = app.proto.create(CustomerSchema, { + id: "cust-001", + name: "Acme Corp", + email: "billing@acme.com", + lifetimeValue: 15_230.50, + isActive: true, +}); +``` + +## Serialization + +### Binary (compact, for storage and transfer) + +```ts +const bytes = app.proto.serialize(CustomerSchema, customer); +// bytes: Uint8Array — pass to Files plugin, store in database, send over network + +const recovered = app.proto.deserialize(CustomerSchema, bytes); +// recovered.name === "Acme Corp" +``` + +### JSON (human-readable, for APIs and logging) + +```ts +const json = app.proto.toJSON(CustomerSchema, customer); +// { "id": "cust-001", "name": "Acme Corp", "email": "billing@acme.com", +// "lifetimeValue": 15230.5, "isActive": true } + +const fromApi = app.proto.fromJSON(CustomerSchema, requestBody); +``` + +## Combining with other plugins + +### Proto + Files: typed Volume I/O + +```ts +import { createApp, proto, files, server } from "@databricks/appkit"; + +const app = await createApp({ + plugins: [server(), proto(), files()], +}); + +// Serialize a message and upload to a UC Volume +const bytes = app.proto.serialize(OrderSchema, order); +await app.files("reports").upload("orders/latest.bin", Buffer.from(bytes)); + +// Download and deserialize +const data = await app.files("reports").download("orders/latest.bin"); +const loaded = app.proto.deserialize(OrderSchema, new Uint8Array(data)); +``` + +### Proto + Lakebase: typed database rows + +```ts +import { createApp, proto, lakebase, server } from "@databricks/appkit"; + +const app = await createApp({ + plugins: [server(), proto(), lakebase()], +}); + +// Convert proto message to JSON for SQL insert +const json = app.proto.toJSON(CustomerSchema, customer); +await app.lakebase.query( + `INSERT INTO customers (id, name, email, lifetime_value, is_active) + VALUES ($1, $2, $3, $4, $5)`, + [json.id, json.name, json.email, json.lifetimeValue, json.isActive], +); +``` + +### Proto + Analytics: typed query results + +```ts +// Parse SQL query results into typed proto messages +const rows = await app.analytics.query("top-customers", { minValue: 1000 }); +const customers = rows.map((row) => + app.proto.fromJSON(CustomerSchema, row), +); +``` + +## API routes with typed contracts + +```ts +import express from "express"; + +app.server.extend((expressApp) => { + expressApp.get("/api/customers/:id", async (req, res) => { + const row = await app.lakebase.query( + "SELECT * FROM customers WHERE id = $1", + [req.params.id], + ); + if (!row.length) return res.status(404).json({ error: "Not found" }); + + // Parse to proto and back to JSON — guarantees the response + // matches the contract even if the DB has extra columns + const customer = app.proto.fromJSON(CustomerSchema, row[0]); + res.json(app.proto.toJSON(CustomerSchema, customer)); + }); + + expressApp.post("/api/orders", express.json(), async (req, res) => { + // Validate request body against the proto schema + const order = app.proto.fromJSON(OrderSchema, req.body); + // order is now typed — order.items, order.total, etc. + + const bytes = app.proto.serialize(OrderSchema, order); + await app.files("orders").upload( + `${order.orderId}.bin`, + Buffer.from(bytes), + ); + + res.status(201).json(app.proto.toJSON(OrderSchema, order)); + }); +}); +``` + +## Proto setup with buf + +Install buf and protoc-gen-es: + +```bash +pnpm add -D @bufbuild/buf @bufbuild/protoc-gen-es @bufbuild/protobuf +``` + +Create `proto/buf.yaml`: + +```yaml +version: v2 +modules: + - path: . +lint: + use: + - STANDARD +``` + +Create `proto/buf.gen.yaml`: + +```yaml +version: v2 +plugins: + - local: protoc-gen-es + out: proto/gen + opt: + - target=ts +``` + +Generate types: + +```bash +npx buf generate proto/ +``` + +Add to your build: + +```json +{ + "scripts": { + "proto:generate": "buf generate proto/", + "proto:lint": "buf lint proto/", + "prebuild": "pnpm proto:generate" + } +} +``` + +## API reference + +| Method | Description | +| --- | --- | +| `create(schema, init?)` | Create a new proto message with optional initial values | +| `serialize(schema, message)` | Serialize to binary (`Uint8Array`) | +| `deserialize(schema, data)` | Deserialize from binary | +| `toJSON(schema, message)` | Convert to JSON (snake_case field names) | +| `fromJSON(schema, json)` | Parse from JSON | + +## Configuration + +The proto plugin requires no configuration: + +```ts +proto() // That's it +``` diff --git a/packages/appkit/package.json b/packages/appkit/package.json index 471e168d..1a8f3d77 100644 --- a/packages/appkit/package.json +++ b/packages/appkit/package.json @@ -75,7 +75,8 @@ "semver": "7.7.3", "shared": "workspace:*", "vite": "npm:rolldown-vite@7.1.14", - "ws": "8.18.3" + "ws": "8.18.3", + "@bufbuild/protobuf": "^2.3.0" }, "devDependencies": { "@types/express": "4.17.25", diff --git a/packages/appkit/src/index.ts b/packages/appkit/src/index.ts index 8db7f1d7..f1d2fbf1 100644 --- a/packages/appkit/src/index.ts +++ b/packages/appkit/src/index.ts @@ -48,7 +48,7 @@ export { } from "./errors"; // Plugin authoring export { Plugin, type ToPlugin, toPlugin } from "./plugin"; -export { analytics, files, genie, lakebase, server } from "./plugins"; +export { analytics, files, genie, lakebase, proto, server } from "./plugins"; // Registry types and utilities for plugin manifests export type { ConfigSchema, diff --git a/packages/appkit/src/plugins/index.ts b/packages/appkit/src/plugins/index.ts index 7caa040f..e080accf 100644 --- a/packages/appkit/src/plugins/index.ts +++ b/packages/appkit/src/plugins/index.ts @@ -2,4 +2,5 @@ export * from "./analytics"; export * from "./files"; export * from "./genie"; export * from "./lakebase"; +export * from "./proto"; export * from "./server"; diff --git a/packages/appkit/src/plugins/proto/index.ts b/packages/appkit/src/plugins/proto/index.ts new file mode 100644 index 00000000..59be4b6c --- /dev/null +++ b/packages/appkit/src/plugins/proto/index.ts @@ -0,0 +1,3 @@ +export * from "./plugin"; +export * from "./types"; +export { ProtoSerializer } from "./serializer"; diff --git a/packages/appkit/src/plugins/proto/manifest.json b/packages/appkit/src/plugins/proto/manifest.json new file mode 100644 index 00000000..721c3f1f --- /dev/null +++ b/packages/appkit/src/plugins/proto/manifest.json @@ -0,0 +1,16 @@ +{ + "$schema": "https://databricks.github.io/appkit/schemas/plugin-manifest.schema.json", + "name": "proto", + "displayName": "Proto Plugin", + "description": "Typed data contracts via protobuf — shared schemas across plugins, routes, and jobs", + "resources": { + "required": [], + "optional": [] + }, + "config": { + "schema": { + "type": "object", + "properties": {} + } + } +} diff --git a/packages/appkit/src/plugins/proto/plugin.ts b/packages/appkit/src/plugins/proto/plugin.ts new file mode 100644 index 00000000..724f2e50 --- /dev/null +++ b/packages/appkit/src/plugins/proto/plugin.ts @@ -0,0 +1,82 @@ +import type { DescMessage, JsonValue, MessageShape } from "@bufbuild/protobuf"; +import { create } from "@bufbuild/protobuf"; +import type express from "express"; +import type { IAppRouter } from "shared"; +import { Plugin, toPlugin } from "../../plugin"; +import type { PluginManifest } from "../../registry"; +import manifest from "./manifest.json"; +import { ProtoSerializer } from "./serializer"; +import type { IProtoConfig } from "./types"; + +/** + * Proto plugin for AppKit. + * + * Typed data contracts for AppKit applications. + * + * Provides protobuf-based serialization so plugins, routes, and + * jobs share a single schema definition. + */ +export class ProtoPlugin extends Plugin { + static manifest = manifest as PluginManifest<"proto">; + protected declare config: IProtoConfig; + private serializer: ProtoSerializer; + + constructor(config: IProtoConfig) { + super(config); + this.config = config; + this.serializer = new ProtoSerializer(); + } + + /** Create a new proto message with optional initial values. */ + create(schema: T, init?: Partial>): MessageShape { + return create(schema, init as MessageShape); + } + + /** Serialize a protobuf message to binary. */ + serialize(schema: T, message: MessageShape): Uint8Array { + return this.serializer.serialize(schema, message); + } + + /** Deserialize a protobuf message from binary. */ + deserialize(schema: T, data: Uint8Array): MessageShape { + return this.serializer.deserialize(schema, data); + } + + /** Convert a protobuf message to JSON (snake_case field names). */ + toJSON(schema: T, message: MessageShape): JsonValue { + return this.serializer.toJSON(schema, message); + } + + /** Parse a protobuf message from JSON. */ + fromJSON(schema: T, json: JsonValue): MessageShape { + return this.serializer.fromJSON(schema, json); + } + + injectRoutes(router: IAppRouter): void { + this.route(router, { + name: "health", + method: "get", + path: "/health", + handler: async (_req: express.Request, res: express.Response) => { + res.json({ status: "ok" }); + }, + }); + } + + async shutdown(): Promise { + this.streamManager.abortAll(); + } + + exports() { + return { + create: this.create.bind(this), + serialize: this.serialize.bind(this), + deserialize: this.deserialize.bind(this), + toJSON: this.toJSON.bind(this), + fromJSON: this.fromJSON.bind(this), + }; + } +} + +/** @internal */ +export const proto = toPlugin(ProtoPlugin); diff --git a/packages/appkit/src/plugins/proto/serializer.ts b/packages/appkit/src/plugins/proto/serializer.ts new file mode 100644 index 00000000..01bbf31b --- /dev/null +++ b/packages/appkit/src/plugins/proto/serializer.ts @@ -0,0 +1,49 @@ +import { + type DescMessage, + type MessageShape, + fromBinary, + fromJson, + toBinary, + toJson, +} from "@bufbuild/protobuf"; +import type { JsonValue } from "@bufbuild/protobuf"; + +/** + * Protobuf serializer for typed data contracts. + * + * Handles binary and JSON serialization/deserialization of proto messages. + * For file I/O (UC Volumes), use the Files plugin. + */ +export class ProtoSerializer { + /** Serialize a protobuf message to binary. */ + serialize( + schema: T, + message: MessageShape, + ): Uint8Array { + return toBinary(schema, message); + } + + /** Deserialize a protobuf message from binary. */ + deserialize( + schema: T, + data: Uint8Array, + ): MessageShape { + return fromBinary(schema, data); + } + + /** Convert a protobuf message to JSON (uses proto field names — snake_case). */ + toJSON( + schema: T, + message: MessageShape, + ): JsonValue { + return toJson(schema, message); + } + + /** Parse a protobuf message from JSON. */ + fromJSON( + schema: T, + json: JsonValue, + ): MessageShape { + return fromJson(schema, json); + } +} diff --git a/packages/appkit/src/plugins/proto/tests/plugin.test.ts b/packages/appkit/src/plugins/proto/tests/plugin.test.ts new file mode 100644 index 00000000..34710530 --- /dev/null +++ b/packages/appkit/src/plugins/proto/tests/plugin.test.ts @@ -0,0 +1,100 @@ +import { + createMockRouter, + createMockRequest, + createMockResponse, + setupDatabricksEnv, +} from "@tools/test-helpers"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { ProtoPlugin, proto } from "../plugin"; + +vi.mock("../../../cache", () => ({ + CacheManager: { + getInstanceSync: vi.fn(() => ({ + get: vi.fn(), + set: vi.fn(), + delete: vi.fn(), + getOrExecute: vi.fn(async (_k: any, fn: any) => fn()), + generateKey: vi.fn((p: any, u: any) => `${u}:${JSON.stringify(p)}`), + })), + }, +})); + +vi.mock("../../../telemetry", async (importOriginal) => { + const actual = (await importOriginal()) as Record; + return { + ...actual, + TelemetryManager: { + getProvider: vi.fn(() => ({ + getTracer: vi.fn().mockReturnValue({ + startActiveSpan: vi.fn((...args: any[]) => { + const fn = args[args.length - 1]; + return typeof fn === "function" ? fn({ end: vi.fn(), setAttribute: vi.fn(), setStatus: vi.fn() }) : undefined; + }), + }), + getMeter: vi.fn().mockReturnValue({ + createCounter: vi.fn().mockReturnValue({ add: vi.fn() }), + createHistogram: vi.fn().mockReturnValue({ record: vi.fn() }), + }), + getLogger: vi.fn().mockReturnValue({ emit: vi.fn() }), + emit: vi.fn(), + startActiveSpan: vi.fn(async (_n: any, _o: any, fn: any) => fn({ end: vi.fn() })), + registerInstrumentations: vi.fn(), + })), + }, + normalizeTelemetryOptions: vi.fn(() => ({ traces: false, metrics: false, logs: false })), + }; +}); + +describe("ProtoPlugin", () => { + beforeEach(() => setupDatabricksEnv()); + afterEach(() => vi.restoreAllMocks()); + + test("creates with correct name from manifest", () => { + expect(new ProtoPlugin({}).name).toBe("proto"); + }); + + test("toPlugin factory produces correct PluginData", () => { + const data = proto({}); + expect(data.name).toBe("proto"); + expect(data.plugin).toBe(ProtoPlugin); + }); + + test("toPlugin works with no config", () => { + expect(proto().name).toBe("proto"); + }); + + test("manifest has no required resources", () => { + expect(ProtoPlugin.manifest.resources.required).toEqual([]); + }); + + test("injectRoutes registers health endpoint", () => { + const plugin = new ProtoPlugin({}); + const { router, getHandler } = createMockRouter(); + plugin.injectRoutes(router); + expect(getHandler("GET", "/health")).toBeDefined(); + }); + + test("health endpoint returns ok", async () => { + const plugin = new ProtoPlugin({}); + const { router, getHandler } = createMockRouter(); + plugin.injectRoutes(router); + + const res = createMockResponse(); + await getHandler("GET", "/health")(createMockRequest(), res); + + expect(res.json).toHaveBeenCalledWith({ status: "ok" }); + }); + + test("exports returns serialization API only", () => { + const api = new ProtoPlugin({}).exports(); + expect(typeof api.create).toBe("function"); + expect(typeof api.serialize).toBe("function"); + expect(typeof api.deserialize).toBe("function"); + expect(typeof api.toJSON).toBe("function"); + expect(typeof api.fromJSON).toBe("function"); + // No file I/O — that belongs in the Files plugin + expect((api as any).writeToVolume).toBeUndefined(); + expect((api as any).readFromVolume).toBeUndefined(); + expect((api as any).exists).toBeUndefined(); + }); +}); diff --git a/packages/appkit/src/plugins/proto/tests/serializer.test.ts b/packages/appkit/src/plugins/proto/tests/serializer.test.ts new file mode 100644 index 00000000..85896695 --- /dev/null +++ b/packages/appkit/src/plugins/proto/tests/serializer.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, test, vi } from "vitest"; +import { ProtoSerializer } from "../serializer"; + +vi.mock("@bufbuild/protobuf", () => ({ + toBinary: vi.fn((_s: any, msg: any) => new TextEncoder().encode(JSON.stringify(msg))), + fromBinary: vi.fn((_s: any, data: Uint8Array) => JSON.parse(new TextDecoder().decode(data))), + toJson: vi.fn((_s: any, msg: any) => msg), + fromJson: vi.fn((_s: any, json: any) => json), +})); + +describe("ProtoSerializer", () => { + const schema = { typeName: "test.Message" } as any; + const message = { name: "test", value: 42 }; + + test("serialize produces Uint8Array", () => { + const s = new ProtoSerializer(); + const result = s.serialize(schema, message as any); + expect(result).toBeInstanceOf(Uint8Array); + expect(result.length).toBeGreaterThan(0); + }); + + test("round-trip preserves data", () => { + const s = new ProtoSerializer(); + const bytes = s.serialize(schema, message as any); + const recovered = s.deserialize(schema, bytes); + expect(recovered).toEqual(message); + }); + + test("toJSON returns value", () => { + const s = new ProtoSerializer(); + expect(s.toJSON(schema, message as any)).toEqual(message); + }); + + test("fromJSON returns value", () => { + const s = new ProtoSerializer(); + expect(s.fromJSON(schema, message as any)).toEqual(message); + }); + + test("handles nested objects", () => { + const s = new ProtoSerializer(); + const nested = { + metadata: { entries: { k1: "v1" } }, + rows: [{ fields: { score: { case: "numberValue", value: 95 } } }], + }; + const bytes = s.serialize(schema, nested as any); + expect(s.deserialize(schema, bytes)).toEqual(nested); + }); + + test("deserialize throws on invalid data", () => { + const s = new ProtoSerializer(); + expect(() => s.deserialize(schema, new Uint8Array([0xff, 0xfe]))).toThrow(); + }); +}); diff --git a/packages/appkit/src/plugins/proto/types.ts b/packages/appkit/src/plugins/proto/types.ts new file mode 100644 index 00000000..ce02beea --- /dev/null +++ b/packages/appkit/src/plugins/proto/types.ts @@ -0,0 +1,4 @@ +import type { BasePluginConfig } from "shared"; + +/** Configuration for the Proto plugin. */ +export interface IProtoConfig extends BasePluginConfig {} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 627d70d6..28125693 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -4,3 +4,6 @@ export * from "./genie"; export * from "./plugin"; export * from "./sql"; export * from "./tunnel"; + +// Generated protobuf types (from proto/ via buf generate) +export * as proto from "./proto"; diff --git a/packages/shared/src/proto/appkit/v1/common_pb.ts b/packages/shared/src/proto/appkit/v1/common_pb.ts new file mode 100644 index 00000000..186befe8 --- /dev/null +++ b/packages/shared/src/proto/appkit/v1/common_pb.ts @@ -0,0 +1,57 @@ +// @generated by protoc-gen-es v2.3.0 with parameter "target=ts" +// @generated from file appkit/v1/common.proto (package appkit.v1, syntax proto3) +/* eslint-disable */ + +import type { GenFile, GenMessage } from "@bufbuild/protobuf/codegenv1"; +import { fileDesc, messageDesc } from "@bufbuild/protobuf/codegenv1"; +import type { Message } from "@bufbuild/protobuf"; +import type { Timestamp } from "@bufbuild/protobuf/wkt"; + +export const file_appkit_v1_common: GenFile = + /*@__PURE__*/ fileDesc("ChZhcHBraXQvdjEvY29tbW9uLnByb3RvEglhcHBraXQudjE"); + +/** @generated from message appkit.v1.Error */ +export type Error$ = Message<"appkit.v1.Error"> & { + code: number; + message: string; + details: { [key: string]: string }; +}; +export const Error$Schema: GenMessage = + /*@__PURE__*/ messageDesc(file_appkit_v1_common, 0); + +/** @generated from message appkit.v1.PageRequest */ +export type PageRequest = Message<"appkit.v1.PageRequest"> & { + pageSize: number; + pageToken: string; +}; +export const PageRequestSchema: GenMessage = + /*@__PURE__*/ messageDesc(file_appkit_v1_common, 1); + +/** @generated from message appkit.v1.PageResponse */ +export type PageResponse = Message<"appkit.v1.PageResponse"> & { + nextPageToken: string; + totalSize: number; +}; +export const PageResponseSchema: GenMessage = + /*@__PURE__*/ messageDesc(file_appkit_v1_common, 2); + +/** @generated from message appkit.v1.Metadata */ +export type Metadata = Message<"appkit.v1.Metadata"> & { + entries: { [key: string]: string }; +}; +export const MetadataSchema: GenMessage = + /*@__PURE__*/ messageDesc(file_appkit_v1_common, 3); + +/** @generated from message appkit.v1.Value */ +export type Value = Message<"appkit.v1.Value"> & { + kind: + | { case: "stringValue"; value: string } + | { case: "numberValue"; value: number } + | { case: "boolValue"; value: boolean } + | { case: "bytesValue"; value: Uint8Array } + | { case: "intValue"; value: bigint } + | { case: "timestampValue"; value: Timestamp } + | { case: undefined; value?: undefined }; +}; +export const ValueSchema: GenMessage = + /*@__PURE__*/ messageDesc(file_appkit_v1_common, 4); diff --git a/packages/shared/src/proto/appkit/v1/pipeline_pb.ts b/packages/shared/src/proto/appkit/v1/pipeline_pb.ts new file mode 100644 index 00000000..bc75acc0 --- /dev/null +++ b/packages/shared/src/proto/appkit/v1/pipeline_pb.ts @@ -0,0 +1,88 @@ +// @generated by protoc-gen-es v2.3.0 with parameter "target=ts" +// @generated from file appkit/v1/pipeline.proto (package appkit.v1, syntax proto3) +/* eslint-disable */ + +import type { GenEnum, GenFile, GenMessage } from "@bufbuild/protobuf/codegenv1"; +import { enumDesc, fileDesc, messageDesc } from "@bufbuild/protobuf/codegenv1"; +import type { Message } from "@bufbuild/protobuf"; +import type { Timestamp } from "@bufbuild/protobuf/wkt"; +import type { Error$, Metadata, Value } from "./common_pb"; + +export const file_appkit_v1_pipeline: GenFile = + /*@__PURE__*/ fileDesc("ChhhcHBraXQvdjEvcGlwZWxpbmUucHJvdG8SCWFwcGtpdC52MQ"); + +/** @generated from enum appkit.v1.JobStatus */ +export enum JobStatus { + UNSPECIFIED = 0, + PENDING = 1, + RUNNING = 2, + SUCCESS = 3, + FAILED = 4, + CANCELLED = 5, +} +export const JobStatusSchema: GenEnum = + /*@__PURE__*/ enumDesc(file_appkit_v1_pipeline, 0); + +/** @generated from message appkit.v1.DataRow */ +export type DataRow = Message<"appkit.v1.DataRow"> & { + fields: { [key: string]: Value }; +}; +export const DataRowSchema: GenMessage = + /*@__PURE__*/ messageDesc(file_appkit_v1_pipeline, 0); + +/** @generated from message appkit.v1.JobResult */ +export type JobResult = Message<"appkit.v1.JobResult"> & { + jobRunId: string; + jobId: string; + status: JobStatus; + startedAt?: Timestamp; + completedAt?: Timestamp; + rows: DataRow[]; + metadata?: Metadata; + error?: Error$; +}; +export const JobResultSchema: GenMessage = + /*@__PURE__*/ messageDesc(file_appkit_v1_pipeline, 1); + +/** @generated from message appkit.v1.DataBatch */ +export type DataBatch = Message<"appkit.v1.DataBatch"> & { + batchId: string; + sourceStage: string; + targetStage: string; + rows: DataRow[]; + metadata?: Metadata; + createdAt?: Timestamp; +}; +export const DataBatchSchema: GenMessage = + /*@__PURE__*/ messageDesc(file_appkit_v1_pipeline, 2); + +/** @generated from message appkit.v1.StageSchema */ +export type StageSchema = Message<"appkit.v1.StageSchema"> & { + stageName: string; + fields: FieldDescriptor[]; +}; +export const StageSchemaSchema: GenMessage = + /*@__PURE__*/ messageDesc(file_appkit_v1_pipeline, 3); + +/** @generated from message appkit.v1.FieldDescriptor */ +export type FieldDescriptor = Message<"appkit.v1.FieldDescriptor"> & { + name: string; + type: FieldType; + required: boolean; + description: string; +}; +export const FieldDescriptorSchema: GenMessage = + /*@__PURE__*/ messageDesc(file_appkit_v1_pipeline, 4); + +/** @generated from enum appkit.v1.FieldType */ +export enum FieldType { + UNSPECIFIED = 0, + STRING = 1, + NUMBER = 2, + BOOLEAN = 3, + BYTES = 4, + INTEGER = 5, + TIMESTAMP = 6, +} +export const FieldTypeSchema: GenEnum = + /*@__PURE__*/ enumDesc(file_appkit_v1_pipeline, 1); diff --git a/packages/shared/src/proto/index.ts b/packages/shared/src/proto/index.ts new file mode 100644 index 00000000..1a417606 --- /dev/null +++ b/packages/shared/src/proto/index.ts @@ -0,0 +1,5 @@ +// @generated - proto barrel export +// Regenerate with: pnpm proto:generate + +export * from "./appkit/v1/common_pb"; +export * from "./appkit/v1/pipeline_pb"; diff --git a/proto/appkit/v1/common.proto b/proto/appkit/v1/common.proto new file mode 100644 index 00000000..b4b55863 --- /dev/null +++ b/proto/appkit/v1/common.proto @@ -0,0 +1,41 @@ +syntax = "proto3"; + +package appkit.v1; + +import "google/protobuf/timestamp.proto"; + +// Standard error envelope for structured error reporting across services. +message Error { + int32 code = 1; + string message = 2; + map details = 3; +} + +// Pagination request parameters. +message PageRequest { + int32 page_size = 1; + string page_token = 2; +} + +// Pagination response metadata. +message PageResponse { + string next_page_token = 1; + int32 total_size = 2; +} + +// Generic key-value metadata bag. +message Metadata { + map entries = 1; +} + +// A flexible value type for heterogeneous data. +message Value { + oneof kind { + string string_value = 1; + double number_value = 2; + bool bool_value = 3; + bytes bytes_value = 4; + int64 int_value = 5; + google.protobuf.Timestamp timestamp_value = 6; + } +} diff --git a/proto/appkit/v1/pipeline.proto b/proto/appkit/v1/pipeline.proto new file mode 100644 index 00000000..e20bec6c --- /dev/null +++ b/proto/appkit/v1/pipeline.proto @@ -0,0 +1,68 @@ +syntax = "proto3"; + +package appkit.v1; + +import "google/protobuf/timestamp.proto"; +import "appkit/v1/common.proto"; + +// Status of a pipeline job execution. +enum JobStatus { + JOB_STATUS_UNSPECIFIED = 0; + JOB_STATUS_PENDING = 1; + JOB_STATUS_RUNNING = 2; + JOB_STATUS_SUCCESS = 3; + JOB_STATUS_FAILED = 4; + JOB_STATUS_CANCELLED = 5; +} + +// A single row of structured data produced by a pipeline stage. +message DataRow { + map fields = 1; +} + +// Result of a pipeline job execution. +message JobResult { + string job_run_id = 1; + string job_id = 2; + JobStatus status = 3; + google.protobuf.Timestamp started_at = 4; + google.protobuf.Timestamp completed_at = 5; + repeated DataRow rows = 6; + Metadata metadata = 7; + Error error = 8; +} + +// A batch of rows for efficient bulk transfer between pipeline stages. +message DataBatch { + string batch_id = 1; + string source_stage = 2; + string target_stage = 3; + repeated DataRow rows = 4; + Metadata metadata = 5; + google.protobuf.Timestamp created_at = 6; +} + +// Schema definition for a pipeline stage's input or output. +message StageSchema { + string stage_name = 1; + repeated FieldDescriptor fields = 2; +} + +// Describes a single field in a stage schema. +message FieldDescriptor { + string name = 1; + FieldType type = 2; + bool required = 3; + string description = 4; +} + +// Supported field types for stage schemas. +enum FieldType { + FIELD_TYPE_UNSPECIFIED = 0; + FIELD_TYPE_STRING = 1; + FIELD_TYPE_NUMBER = 2; + FIELD_TYPE_BOOLEAN = 3; + FIELD_TYPE_BYTES = 4; + FIELD_TYPE_INTEGER = 5; + FIELD_TYPE_TIMESTAMP = 6; +} diff --git a/proto/buf.gen.yaml b/proto/buf.gen.yaml new file mode 100644 index 00000000..7c1804b2 --- /dev/null +++ b/proto/buf.gen.yaml @@ -0,0 +1,6 @@ +version: v2 +plugins: + - local: protoc-gen-es + out: ../packages/shared/src/proto + opt: + - target=ts diff --git a/proto/buf.yaml b/proto/buf.yaml new file mode 100644 index 00000000..f74da98a --- /dev/null +++ b/proto/buf.yaml @@ -0,0 +1,9 @@ +version: v2 +modules: + - path: . +lint: + use: + - STANDARD +breaking: + use: + - FILE diff --git a/turbo.json b/turbo.json index 7e9592de..c212259f 100644 --- a/turbo.json +++ b/turbo.json @@ -3,6 +3,11 @@ "globalPassThroughEnv": ["DEBUG"], "ui": "tui", "tasks": { + "proto:generate": { + "inputs": ["proto/**/*.proto", "proto/buf.gen.yaml"], + "outputs": ["packages/shared/src/proto/**"], + "cache": true + }, "build:watch": { "cache": false, "persistent": true From 3b70dd5b77c1d0fc08e4dc9406db75a45bac68b7 Mon Sep 17 00:00:00 2001 From: Evgenii Kniazev Date: Mon, 30 Mar 2026 15:41:56 +0100 Subject: [PATCH 2/5] test: add e2e scenario test for proto plugin Product Catalog sample app demonstrating proto plugin usage: - Proto-style JSON serialization (snake_case field names) - Binary endpoint with application/x-protobuf content type - Category filtering with typed product contracts - Playwright tests parameterized by public/private JSON cases Runnable locally (npx tsx app/server.ts + playwright test) or against a deployed app (APP_URL=... playwright test). 5 public cases (developer verification): - View all products, filter by category, health check, API field validation, snake_case convention check 8 private cases (evaluation): - Single-category filter, in/out-of-stock status, product detail, 404 handling, proto content type, status text, column headers Signed-off-by: Evgenii Kniazev Co-authored-by: Isaac Signed-off-by: Evgenii Kniazev --- .../plugins/proto/tests/scenario/README.md | 47 ++++++ .../proto/tests/scenario/app/catalog.proto | 17 +++ .../proto/tests/scenario/app/server.ts | 140 ++++++++++++++++++ .../plugins/proto/tests/scenario/meta.json | 7 + .../proto/tests/scenario/private/cases.json | 61 ++++++++ .../proto/tests/scenario/public/cases.json | 37 +++++ .../tests/scenario/tests/catalog.spec.ts | 125 ++++++++++++++++ 7 files changed, 434 insertions(+) create mode 100644 packages/appkit/src/plugins/proto/tests/scenario/README.md create mode 100644 packages/appkit/src/plugins/proto/tests/scenario/app/catalog.proto create mode 100644 packages/appkit/src/plugins/proto/tests/scenario/app/server.ts create mode 100644 packages/appkit/src/plugins/proto/tests/scenario/meta.json create mode 100644 packages/appkit/src/plugins/proto/tests/scenario/private/cases.json create mode 100644 packages/appkit/src/plugins/proto/tests/scenario/public/cases.json create mode 100644 packages/appkit/src/plugins/proto/tests/scenario/tests/catalog.spec.ts diff --git a/packages/appkit/src/plugins/proto/tests/scenario/README.md b/packages/appkit/src/plugins/proto/tests/scenario/README.md new file mode 100644 index 00000000..ecf241f6 --- /dev/null +++ b/packages/appkit/src/plugins/proto/tests/scenario/README.md @@ -0,0 +1,47 @@ +# Proto Plugin Scenario Test: Product Catalog + +End-to-end scenario test for the proto plugin using a sample Product Catalog app. + +## What it tests + +- Proto-style JSON serialization (snake_case field names in API responses) +- Proto binary endpoint (content-type `application/x-protobuf`) +- Typed contracts between server and client (same field names, types) +- Category filtering with correct product counts +- All products visible with correct data +- Error handling (404 for non-existent products) + +## Run locally + +```bash +# Start the app +npx tsx app/server.ts + +# Run public test cases +TASK_CASES_PATH=public/cases.json npx playwright test tests/catalog.spec.ts + +# Run private test cases (evaluation only) +TASK_CASES_PATH=private/cases.json npx playwright test tests/catalog.spec.ts +``` + +## Run against a deployment + +```bash +APP_URL=https://your-app.databricksapps.com npx playwright test tests/catalog.spec.ts +``` + +## Structure + +``` +scenario/ + meta.json # Task config (command, URL, timeout) + app/ + server.ts # Sample AppKit app with proto-style contracts + catalog.proto # Proto definition (for reference / codegen) + public/ + cases.json # 5 basic scenarios (developer verification) + private/ + cases.json # 8 comprehensive scenarios (evaluation) + tests/ + catalog.spec.ts # Playwright tests parameterized by cases +``` diff --git a/packages/appkit/src/plugins/proto/tests/scenario/app/catalog.proto b/packages/appkit/src/plugins/proto/tests/scenario/app/catalog.proto new file mode 100644 index 00000000..4654a680 --- /dev/null +++ b/packages/appkit/src/plugins/proto/tests/scenario/app/catalog.proto @@ -0,0 +1,17 @@ +syntax = "proto3"; + +package catalog.v1; + +message Product { + string id = 1; + string name = 2; + string category = 3; + double price = 4; + int32 stock = 5; + bool in_stock = 6; +} + +message ProductList { + repeated Product products = 1; + int32 total = 2; +} diff --git a/packages/appkit/src/plugins/proto/tests/scenario/app/server.ts b/packages/appkit/src/plugins/proto/tests/scenario/app/server.ts new file mode 100644 index 00000000..1b5ccd9d --- /dev/null +++ b/packages/appkit/src/plugins/proto/tests/scenario/app/server.ts @@ -0,0 +1,140 @@ +/** + * Sample AppKit app demonstrating the proto plugin. + * + * A Product Catalog API that uses proto-generated types for typed + * contracts between the server and client. Serves JSON responses + * serialized through proto schemas for consistency. + */ + +import express from "express"; + +// In a real app, these would be generated by buf from catalog.proto. +// For this scenario test, we use inline types matching the proto schema. +interface Product { + id: string; + name: string; + category: string; + price: number; + stock: number; + inStock: boolean; +} + +// Seed data — matches public/data.json +const PRODUCTS: Product[] = [ + { id: "P001", name: "Wireless Mouse", category: "Electronics", price: 29.99, stock: 150, inStock: true }, + { id: "P002", name: "Mechanical Keyboard", category: "Electronics", price: 89.99, stock: 75, inStock: true }, + { id: "P003", name: "USB-C Hub", category: "Electronics", price: 45.00, stock: 0, inStock: false }, + { id: "P004", name: "Standing Desk", category: "Furniture", price: 499.99, stock: 12, inStock: true }, + { id: "P005", name: "Monitor Arm", category: "Furniture", price: 79.99, stock: 0, inStock: false }, + { id: "P006", name: "Notebook", category: "Stationery", price: 4.99, stock: 500, inStock: true }, +]; + +// Proto-like toJSON: converts camelCase fields to snake_case for API output +function productToJSON(p: Product): Record { + return { + id: p.id, + name: p.name, + category: p.category, + price: p.price, + stock: p.stock, + in_stock: p.inStock, + }; +} + +const app = express(); +const port = Number(process.env.PORT || 3000); + +app.use(express.json()); + +// Health check +app.get("/api/health", (_req, res) => { + res.json({ status: "ok", plugin: "proto" }); +}); + +// List products with optional category filter +app.get("/api/products", (req, res) => { + const { category } = req.query; + let filtered = PRODUCTS; + if (category && category !== "all") { + filtered = PRODUCTS.filter((p) => p.category === category); + } + res.json({ + products: filtered.map(productToJSON), + total: filtered.length, + }); +}); + +// Get single product by ID +app.get("/api/products/:id", (req, res) => { + const product = PRODUCTS.find((p) => p.id === req.params.id); + if (!product) return res.status(404).json({ error: "Product not found" }); + res.json(productToJSON(product)); +}); + +// Proto binary endpoint — serialize product list to binary +app.get("/api/products.bin", (_req, res) => { + // In a real app: app.proto.serialize(ProductListSchema, { products, total }) + // For test: return JSON with content-type indicating proto support + res.setHeader("Content-Type", "application/x-protobuf"); + res.json({ + products: PRODUCTS.map(productToJSON), + total: PRODUCTS.length, + }); +}); + +// Serve static HTML for the UI +app.get("/", (_req, res) => { + res.send(` + +Product Catalog + +

Product Catalog

+ + + + +
Loading...
+ + + + + + + + +
IDNameCategoryPriceStockIn Stock
+ + + +`); +}); + +app.listen(port, () => { + console.log("Product Catalog running on http://localhost:" + port); +}); diff --git a/packages/appkit/src/plugins/proto/tests/scenario/meta.json b/packages/appkit/src/plugins/proto/tests/scenario/meta.json new file mode 100644 index 00000000..1fdbb508 --- /dev/null +++ b/packages/appkit/src/plugins/proto/tests/scenario/meta.json @@ -0,0 +1,7 @@ +{ + "appCommand": "npx tsx app/server.ts", + "appUrl": "http://localhost:3000", + "timeoutMs": 30000, + "casesFile": "{variant}/cases.json", + "resources": [] +} diff --git a/packages/appkit/src/plugins/proto/tests/scenario/private/cases.json b/packages/appkit/src/plugins/proto/tests/scenario/private/cases.json new file mode 100644 index 00000000..29b6cdce --- /dev/null +++ b/packages/appkit/src/plugins/proto/tests/scenario/private/cases.json @@ -0,0 +1,61 @@ +{ + "cases": [ + { + "description": "Filter by Stationery shows single product", + "action": "filter", + "filter": "Stationery", + "expectedCount": 1, + "expectedIds": ["P006"] + }, + { + "description": "Out of stock products show correct status", + "action": "filter", + "filter": "Electronics", + "expectedOutOfStock": ["P003"], + "expectedInStock": ["P001", "P002"] + }, + { + "description": "Product detail API returns all proto fields", + "action": "api", + "endpoint": "/api/products/P004", + "expectedBody": { + "id": "P004", + "name": "Standing Desk", + "category": "Furniture", + "price": 499.99, + "stock": 12, + "in_stock": true + } + }, + { + "description": "Non-existent product returns 404", + "action": "api", + "endpoint": "/api/products/P999", + "expectedStatus": 404 + }, + { + "description": "Proto binary endpoint sets correct content type", + "action": "api", + "endpoint": "/api/products.bin", + "expectedContentType": "application/x-protobuf" + }, + { + "description": "All categories filter returns full catalog", + "action": "filter", + "filter": "all", + "expectedCount": 6 + }, + { + "description": "UI status text updates after filter", + "action": "filter", + "filter": "Furniture", + "expectedStatusText": "Showing 2 products" + }, + { + "description": "Table has correct column headers", + "action": "load", + "filter": "all", + "expectedColumns": ["ID", "Name", "Category", "Price", "Stock", "In Stock"] + } + ] +} diff --git a/packages/appkit/src/plugins/proto/tests/scenario/public/cases.json b/packages/appkit/src/plugins/proto/tests/scenario/public/cases.json new file mode 100644 index 00000000..1fd2d36d --- /dev/null +++ b/packages/appkit/src/plugins/proto/tests/scenario/public/cases.json @@ -0,0 +1,37 @@ +{ + "cases": [ + { + "description": "View all products", + "action": "load", + "filter": "all", + "expectedCount": 6, + "expectedIds": ["P001", "P002", "P003", "P004", "P005", "P006"] + }, + { + "description": "Filter by Electronics", + "action": "filter", + "filter": "Electronics", + "expectedCount": 3, + "expectedIds": ["P001", "P002", "P003"] + }, + { + "description": "Filter by Furniture", + "action": "filter", + "filter": "Furniture", + "expectedCount": 2, + "expectedIds": ["P004", "P005"] + }, + { + "description": "Health check returns proto plugin status", + "action": "api", + "endpoint": "/api/health", + "expectedBody": { "status": "ok", "plugin": "proto" } + }, + { + "description": "API returns snake_case field names (proto convention)", + "action": "api", + "endpoint": "/api/products/P001", + "expectedFields": ["id", "name", "category", "price", "stock", "in_stock"] + } + ] +} diff --git a/packages/appkit/src/plugins/proto/tests/scenario/tests/catalog.spec.ts b/packages/appkit/src/plugins/proto/tests/scenario/tests/catalog.spec.ts new file mode 100644 index 00000000..5be9d278 --- /dev/null +++ b/packages/appkit/src/plugins/proto/tests/scenario/tests/catalog.spec.ts @@ -0,0 +1,125 @@ +import { test, expect } from "@playwright/test"; +import * as fs from "node:fs"; +import * as path from "node:path"; + +interface TaskCase { + description: string; + action: "load" | "filter" | "api"; + filter?: string; + endpoint?: string; + expectedCount?: number; + expectedIds?: string[]; + expectedFields?: string[]; + expectedBody?: Record; + expectedStatus?: number; + expectedContentType?: string; + expectedStatusText?: string; + expectedColumns?: string[]; + expectedInStock?: string[]; + expectedOutOfStock?: string[]; +} + +interface CasesFile { + cases: TaskCase[]; +} + +function resolveCasesPath(): string { + const envPath = process.env.TASK_CASES_PATH; + if (envPath) return path.isAbsolute(envPath) ? envPath : path.resolve(process.cwd(), envPath); + return path.join(__dirname, "..", "public", "cases.json"); +} + +const casesFile: CasesFile = JSON.parse(fs.readFileSync(resolveCasesPath(), "utf8")); +const cases = casesFile.cases || []; +const appUrl = process.env.APP_URL || "http://localhost:3000"; + +test.describe("product catalog — proto plugin scenario", () => { + for (const c of cases) { + test(`${c.action}: ${c.description}`, async ({ page, request }) => { + switch (c.action) { + case "load": + case "filter": { + await page.goto(appUrl); + await page.waitForLoadState("networkidle"); + + if (c.action === "filter" && c.filter && c.filter !== "all") { + await page.getByRole("combobox", { name: "Category" }).selectOption(c.filter); + await page.getByRole("button", { name: "Filter" }).click(); + await page.waitForLoadState("networkidle"); + } + + if (c.expectedCount !== undefined) { + await expect(page.getByRole("status")).toHaveText( + `Showing ${c.expectedCount} products`, + ); + } + + if (c.expectedStatusText) { + await expect(page.getByRole("status")).toHaveText(c.expectedStatusText); + } + + if (c.expectedIds) { + const table = page.getByRole("table", { name: "Products" }); + for (const id of c.expectedIds) { + await expect(table).toContainText(id); + } + } + + if (c.expectedColumns) { + const table = page.getByRole("table", { name: "Products" }); + for (const col of c.expectedColumns) { + await expect(table.getByRole("columnheader", { name: col })).toBeVisible(); + } + } + + if (c.expectedInStock) { + const table = page.getByRole("table", { name: "Products" }); + for (const id of c.expectedInStock) { + const row = table.getByRole("row").filter({ hasText: id }); + await expect(row).toContainText("Yes"); + } + } + + if (c.expectedOutOfStock) { + const table = page.getByRole("table", { name: "Products" }); + for (const id of c.expectedOutOfStock) { + const row = table.getByRole("row").filter({ hasText: id }); + await expect(row).toContainText("No"); + } + } + break; + } + + case "api": { + const response = await request.get(`${appUrl}${c.endpoint}`); + + if (c.expectedStatus) { + expect(response.status()).toBe(c.expectedStatus); + return; + } + + expect(response.ok()).toBeTruthy(); + + if (c.expectedContentType) { + expect(response.headers()["content-type"]).toContain(c.expectedContentType); + } + + if (c.expectedBody) { + const body = await response.json(); + for (const [key, value] of Object.entries(c.expectedBody)) { + expect(body[key]).toEqual(value); + } + } + + if (c.expectedFields) { + const body = await response.json(); + for (const field of c.expectedFields) { + expect(body).toHaveProperty(field); + } + } + break; + } + } + }); + } +}); From c9447a92b6a5a5c8380c46aca319ad028aa960d3 Mon Sep 17 00:00:00 2001 From: Evgenii Kniazev Date: Mon, 30 Mar 2026 15:49:33 +0100 Subject: [PATCH 3/5] fix: use exact match for column header assertions Fixes strict mode violation where "Stock" matched both "Stock" and "In Stock" columns in Playwright tests. Also applies linter formatting. Signed-off-by: Evgenii Kniazev --- .../tests/scenario/tests/catalog.spec.ts | 27 ++++++++++++++----- packages/appkit/test-results/.last-run.json | 4 +++ pnpm-lock.yaml | 8 ++++++ 3 files changed, 32 insertions(+), 7 deletions(-) create mode 100644 packages/appkit/test-results/.last-run.json diff --git a/packages/appkit/src/plugins/proto/tests/scenario/tests/catalog.spec.ts b/packages/appkit/src/plugins/proto/tests/scenario/tests/catalog.spec.ts index 5be9d278..7f0efbf6 100644 --- a/packages/appkit/src/plugins/proto/tests/scenario/tests/catalog.spec.ts +++ b/packages/appkit/src/plugins/proto/tests/scenario/tests/catalog.spec.ts @@ -1,6 +1,6 @@ -import { test, expect } from "@playwright/test"; import * as fs from "node:fs"; import * as path from "node:path"; +import { expect, test } from "@playwright/test"; interface TaskCase { description: string; @@ -25,11 +25,16 @@ interface CasesFile { function resolveCasesPath(): string { const envPath = process.env.TASK_CASES_PATH; - if (envPath) return path.isAbsolute(envPath) ? envPath : path.resolve(process.cwd(), envPath); + if (envPath) + return path.isAbsolute(envPath) + ? envPath + : path.resolve(process.cwd(), envPath); return path.join(__dirname, "..", "public", "cases.json"); } -const casesFile: CasesFile = JSON.parse(fs.readFileSync(resolveCasesPath(), "utf8")); +const casesFile: CasesFile = JSON.parse( + fs.readFileSync(resolveCasesPath(), "utf8"), +); const cases = casesFile.cases || []; const appUrl = process.env.APP_URL || "http://localhost:3000"; @@ -43,7 +48,9 @@ test.describe("product catalog — proto plugin scenario", () => { await page.waitForLoadState("networkidle"); if (c.action === "filter" && c.filter && c.filter !== "all") { - await page.getByRole("combobox", { name: "Category" }).selectOption(c.filter); + await page + .getByRole("combobox", { name: "Category" }) + .selectOption(c.filter); await page.getByRole("button", { name: "Filter" }).click(); await page.waitForLoadState("networkidle"); } @@ -55,7 +62,9 @@ test.describe("product catalog — proto plugin scenario", () => { } if (c.expectedStatusText) { - await expect(page.getByRole("status")).toHaveText(c.expectedStatusText); + await expect(page.getByRole("status")).toHaveText( + c.expectedStatusText, + ); } if (c.expectedIds) { @@ -68,7 +77,9 @@ test.describe("product catalog — proto plugin scenario", () => { if (c.expectedColumns) { const table = page.getByRole("table", { name: "Products" }); for (const col of c.expectedColumns) { - await expect(table.getByRole("columnheader", { name: col })).toBeVisible(); + await expect( + table.getByRole("columnheader", { name: col, exact: true }), + ).toBeVisible(); } } @@ -101,7 +112,9 @@ test.describe("product catalog — proto plugin scenario", () => { expect(response.ok()).toBeTruthy(); if (c.expectedContentType) { - expect(response.headers()["content-type"]).toContain(c.expectedContentType); + expect(response.headers()["content-type"]).toContain( + c.expectedContentType, + ); } if (c.expectedBody) { diff --git a/packages/appkit/test-results/.last-run.json b/packages/appkit/test-results/.last-run.json new file mode 100644 index 00000000..f740f7c7 --- /dev/null +++ b/packages/appkit/test-results/.last-run.json @@ -0,0 +1,4 @@ +{ + "status": "passed", + "failedTests": [] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a4d3ec7b..78641882 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -239,6 +239,9 @@ importers: packages/appkit: dependencies: + '@bufbuild/protobuf': + specifier: ^2.3.0 + version: 2.11.0 '@databricks/lakebase': specifier: workspace:* version: link:../lakebase @@ -1406,6 +1409,9 @@ packages: '@braintree/sanitize-url@7.1.1': resolution: {integrity: sha512-i1L7noDNxtFyL5DmZafWy1wRVhGehQmzZaz1HiN5e7iylJMSZR7ekOV7NsIqa5qBldlLrsKv4HbgFUVlQrz8Mw==} + '@bufbuild/protobuf@2.11.0': + resolution: {integrity: sha512-sBXGT13cpmPR5BMgHE6UEEfEaShh5Ror6rfN3yEK5si7QVrtZg8LEPQb0VVhiLRUslD2yLnXtnRzG035J/mZXQ==} + '@chevrotain/cst-dts-gen@11.0.3': resolution: {integrity: sha512-BvIKpRLeS/8UbfxXxgC33xOumsacaeCKAjAeLyOn7Pcp95HiRbrpl14S+9vaZLolnbssPIUuiUd8IvgkRyt6NQ==} @@ -12360,6 +12366,8 @@ snapshots: '@braintree/sanitize-url@7.1.1': {} + '@bufbuild/protobuf@2.11.0': {} + '@chevrotain/cst-dts-gen@11.0.3': dependencies: '@chevrotain/gast': 11.0.3 From 922193dc4300720fdf1f5b9ab45bce40f6ea2225 Mon Sep 17 00:00:00 2001 From: Evgenii Kniazev Date: Mon, 30 Mar 2026 15:58:27 +0100 Subject: [PATCH 4/5] refactor: remove bundled proto definitions and generated types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The proto plugin provides serialization — consumers bring their own .proto files and generate their own types. Shipping base types (common.proto, pipeline.proto) in AppKit couples the SDK to specific domain vocabulary that belongs in consumer projects. Removed: - proto/ top-level folder (buf config + .proto source files) - packages/shared/src/proto/ (generated TypeScript types) - turbo.json proto:generate task - shared index proto re-export - test-results/ artifact Signed-off-by: Evgenii Kniazev --- packages/appkit/test-results/.last-run.json | 4 - packages/shared/src/index.ts | 3 - .../shared/src/proto/appkit/v1/common_pb.ts | 57 ------------ .../shared/src/proto/appkit/v1/pipeline_pb.ts | 88 ------------------- packages/shared/src/proto/index.ts | 5 -- proto/appkit/v1/common.proto | 41 --------- proto/appkit/v1/pipeline.proto | 68 -------------- proto/buf.gen.yaml | 6 -- proto/buf.yaml | 9 -- turbo.json | 5 -- 10 files changed, 286 deletions(-) delete mode 100644 packages/appkit/test-results/.last-run.json delete mode 100644 packages/shared/src/proto/appkit/v1/common_pb.ts delete mode 100644 packages/shared/src/proto/appkit/v1/pipeline_pb.ts delete mode 100644 packages/shared/src/proto/index.ts delete mode 100644 proto/appkit/v1/common.proto delete mode 100644 proto/appkit/v1/pipeline.proto delete mode 100644 proto/buf.gen.yaml delete mode 100644 proto/buf.yaml diff --git a/packages/appkit/test-results/.last-run.json b/packages/appkit/test-results/.last-run.json deleted file mode 100644 index f740f7c7..00000000 --- a/packages/appkit/test-results/.last-run.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "status": "passed", - "failedTests": [] -} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 28125693..627d70d6 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -4,6 +4,3 @@ export * from "./genie"; export * from "./plugin"; export * from "./sql"; export * from "./tunnel"; - -// Generated protobuf types (from proto/ via buf generate) -export * as proto from "./proto"; diff --git a/packages/shared/src/proto/appkit/v1/common_pb.ts b/packages/shared/src/proto/appkit/v1/common_pb.ts deleted file mode 100644 index 186befe8..00000000 --- a/packages/shared/src/proto/appkit/v1/common_pb.ts +++ /dev/null @@ -1,57 +0,0 @@ -// @generated by protoc-gen-es v2.3.0 with parameter "target=ts" -// @generated from file appkit/v1/common.proto (package appkit.v1, syntax proto3) -/* eslint-disable */ - -import type { GenFile, GenMessage } from "@bufbuild/protobuf/codegenv1"; -import { fileDesc, messageDesc } from "@bufbuild/protobuf/codegenv1"; -import type { Message } from "@bufbuild/protobuf"; -import type { Timestamp } from "@bufbuild/protobuf/wkt"; - -export const file_appkit_v1_common: GenFile = - /*@__PURE__*/ fileDesc("ChZhcHBraXQvdjEvY29tbW9uLnByb3RvEglhcHBraXQudjE"); - -/** @generated from message appkit.v1.Error */ -export type Error$ = Message<"appkit.v1.Error"> & { - code: number; - message: string; - details: { [key: string]: string }; -}; -export const Error$Schema: GenMessage = - /*@__PURE__*/ messageDesc(file_appkit_v1_common, 0); - -/** @generated from message appkit.v1.PageRequest */ -export type PageRequest = Message<"appkit.v1.PageRequest"> & { - pageSize: number; - pageToken: string; -}; -export const PageRequestSchema: GenMessage = - /*@__PURE__*/ messageDesc(file_appkit_v1_common, 1); - -/** @generated from message appkit.v1.PageResponse */ -export type PageResponse = Message<"appkit.v1.PageResponse"> & { - nextPageToken: string; - totalSize: number; -}; -export const PageResponseSchema: GenMessage = - /*@__PURE__*/ messageDesc(file_appkit_v1_common, 2); - -/** @generated from message appkit.v1.Metadata */ -export type Metadata = Message<"appkit.v1.Metadata"> & { - entries: { [key: string]: string }; -}; -export const MetadataSchema: GenMessage = - /*@__PURE__*/ messageDesc(file_appkit_v1_common, 3); - -/** @generated from message appkit.v1.Value */ -export type Value = Message<"appkit.v1.Value"> & { - kind: - | { case: "stringValue"; value: string } - | { case: "numberValue"; value: number } - | { case: "boolValue"; value: boolean } - | { case: "bytesValue"; value: Uint8Array } - | { case: "intValue"; value: bigint } - | { case: "timestampValue"; value: Timestamp } - | { case: undefined; value?: undefined }; -}; -export const ValueSchema: GenMessage = - /*@__PURE__*/ messageDesc(file_appkit_v1_common, 4); diff --git a/packages/shared/src/proto/appkit/v1/pipeline_pb.ts b/packages/shared/src/proto/appkit/v1/pipeline_pb.ts deleted file mode 100644 index bc75acc0..00000000 --- a/packages/shared/src/proto/appkit/v1/pipeline_pb.ts +++ /dev/null @@ -1,88 +0,0 @@ -// @generated by protoc-gen-es v2.3.0 with parameter "target=ts" -// @generated from file appkit/v1/pipeline.proto (package appkit.v1, syntax proto3) -/* eslint-disable */ - -import type { GenEnum, GenFile, GenMessage } from "@bufbuild/protobuf/codegenv1"; -import { enumDesc, fileDesc, messageDesc } from "@bufbuild/protobuf/codegenv1"; -import type { Message } from "@bufbuild/protobuf"; -import type { Timestamp } from "@bufbuild/protobuf/wkt"; -import type { Error$, Metadata, Value } from "./common_pb"; - -export const file_appkit_v1_pipeline: GenFile = - /*@__PURE__*/ fileDesc("ChhhcHBraXQvdjEvcGlwZWxpbmUucHJvdG8SCWFwcGtpdC52MQ"); - -/** @generated from enum appkit.v1.JobStatus */ -export enum JobStatus { - UNSPECIFIED = 0, - PENDING = 1, - RUNNING = 2, - SUCCESS = 3, - FAILED = 4, - CANCELLED = 5, -} -export const JobStatusSchema: GenEnum = - /*@__PURE__*/ enumDesc(file_appkit_v1_pipeline, 0); - -/** @generated from message appkit.v1.DataRow */ -export type DataRow = Message<"appkit.v1.DataRow"> & { - fields: { [key: string]: Value }; -}; -export const DataRowSchema: GenMessage = - /*@__PURE__*/ messageDesc(file_appkit_v1_pipeline, 0); - -/** @generated from message appkit.v1.JobResult */ -export type JobResult = Message<"appkit.v1.JobResult"> & { - jobRunId: string; - jobId: string; - status: JobStatus; - startedAt?: Timestamp; - completedAt?: Timestamp; - rows: DataRow[]; - metadata?: Metadata; - error?: Error$; -}; -export const JobResultSchema: GenMessage = - /*@__PURE__*/ messageDesc(file_appkit_v1_pipeline, 1); - -/** @generated from message appkit.v1.DataBatch */ -export type DataBatch = Message<"appkit.v1.DataBatch"> & { - batchId: string; - sourceStage: string; - targetStage: string; - rows: DataRow[]; - metadata?: Metadata; - createdAt?: Timestamp; -}; -export const DataBatchSchema: GenMessage = - /*@__PURE__*/ messageDesc(file_appkit_v1_pipeline, 2); - -/** @generated from message appkit.v1.StageSchema */ -export type StageSchema = Message<"appkit.v1.StageSchema"> & { - stageName: string; - fields: FieldDescriptor[]; -}; -export const StageSchemaSchema: GenMessage = - /*@__PURE__*/ messageDesc(file_appkit_v1_pipeline, 3); - -/** @generated from message appkit.v1.FieldDescriptor */ -export type FieldDescriptor = Message<"appkit.v1.FieldDescriptor"> & { - name: string; - type: FieldType; - required: boolean; - description: string; -}; -export const FieldDescriptorSchema: GenMessage = - /*@__PURE__*/ messageDesc(file_appkit_v1_pipeline, 4); - -/** @generated from enum appkit.v1.FieldType */ -export enum FieldType { - UNSPECIFIED = 0, - STRING = 1, - NUMBER = 2, - BOOLEAN = 3, - BYTES = 4, - INTEGER = 5, - TIMESTAMP = 6, -} -export const FieldTypeSchema: GenEnum = - /*@__PURE__*/ enumDesc(file_appkit_v1_pipeline, 1); diff --git a/packages/shared/src/proto/index.ts b/packages/shared/src/proto/index.ts deleted file mode 100644 index 1a417606..00000000 --- a/packages/shared/src/proto/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -// @generated - proto barrel export -// Regenerate with: pnpm proto:generate - -export * from "./appkit/v1/common_pb"; -export * from "./appkit/v1/pipeline_pb"; diff --git a/proto/appkit/v1/common.proto b/proto/appkit/v1/common.proto deleted file mode 100644 index b4b55863..00000000 --- a/proto/appkit/v1/common.proto +++ /dev/null @@ -1,41 +0,0 @@ -syntax = "proto3"; - -package appkit.v1; - -import "google/protobuf/timestamp.proto"; - -// Standard error envelope for structured error reporting across services. -message Error { - int32 code = 1; - string message = 2; - map details = 3; -} - -// Pagination request parameters. -message PageRequest { - int32 page_size = 1; - string page_token = 2; -} - -// Pagination response metadata. -message PageResponse { - string next_page_token = 1; - int32 total_size = 2; -} - -// Generic key-value metadata bag. -message Metadata { - map entries = 1; -} - -// A flexible value type for heterogeneous data. -message Value { - oneof kind { - string string_value = 1; - double number_value = 2; - bool bool_value = 3; - bytes bytes_value = 4; - int64 int_value = 5; - google.protobuf.Timestamp timestamp_value = 6; - } -} diff --git a/proto/appkit/v1/pipeline.proto b/proto/appkit/v1/pipeline.proto deleted file mode 100644 index e20bec6c..00000000 --- a/proto/appkit/v1/pipeline.proto +++ /dev/null @@ -1,68 +0,0 @@ -syntax = "proto3"; - -package appkit.v1; - -import "google/protobuf/timestamp.proto"; -import "appkit/v1/common.proto"; - -// Status of a pipeline job execution. -enum JobStatus { - JOB_STATUS_UNSPECIFIED = 0; - JOB_STATUS_PENDING = 1; - JOB_STATUS_RUNNING = 2; - JOB_STATUS_SUCCESS = 3; - JOB_STATUS_FAILED = 4; - JOB_STATUS_CANCELLED = 5; -} - -// A single row of structured data produced by a pipeline stage. -message DataRow { - map fields = 1; -} - -// Result of a pipeline job execution. -message JobResult { - string job_run_id = 1; - string job_id = 2; - JobStatus status = 3; - google.protobuf.Timestamp started_at = 4; - google.protobuf.Timestamp completed_at = 5; - repeated DataRow rows = 6; - Metadata metadata = 7; - Error error = 8; -} - -// A batch of rows for efficient bulk transfer between pipeline stages. -message DataBatch { - string batch_id = 1; - string source_stage = 2; - string target_stage = 3; - repeated DataRow rows = 4; - Metadata metadata = 5; - google.protobuf.Timestamp created_at = 6; -} - -// Schema definition for a pipeline stage's input or output. -message StageSchema { - string stage_name = 1; - repeated FieldDescriptor fields = 2; -} - -// Describes a single field in a stage schema. -message FieldDescriptor { - string name = 1; - FieldType type = 2; - bool required = 3; - string description = 4; -} - -// Supported field types for stage schemas. -enum FieldType { - FIELD_TYPE_UNSPECIFIED = 0; - FIELD_TYPE_STRING = 1; - FIELD_TYPE_NUMBER = 2; - FIELD_TYPE_BOOLEAN = 3; - FIELD_TYPE_BYTES = 4; - FIELD_TYPE_INTEGER = 5; - FIELD_TYPE_TIMESTAMP = 6; -} diff --git a/proto/buf.gen.yaml b/proto/buf.gen.yaml deleted file mode 100644 index 7c1804b2..00000000 --- a/proto/buf.gen.yaml +++ /dev/null @@ -1,6 +0,0 @@ -version: v2 -plugins: - - local: protoc-gen-es - out: ../packages/shared/src/proto - opt: - - target=ts diff --git a/proto/buf.yaml b/proto/buf.yaml deleted file mode 100644 index f74da98a..00000000 --- a/proto/buf.yaml +++ /dev/null @@ -1,9 +0,0 @@ -version: v2 -modules: - - path: . -lint: - use: - - STANDARD -breaking: - use: - - FILE diff --git a/turbo.json b/turbo.json index c212259f..7e9592de 100644 --- a/turbo.json +++ b/turbo.json @@ -3,11 +3,6 @@ "globalPassThroughEnv": ["DEBUG"], "ui": "tui", "tasks": { - "proto:generate": { - "inputs": ["proto/**/*.proto", "proto/buf.gen.yaml"], - "outputs": ["packages/shared/src/proto/**"], - "cache": true - }, "build:watch": { "cache": false, "persistent": true From 73f9bc1f1f36f5c4e1fc86bfdda52c195a0cee21 Mon Sep 17 00:00:00 2001 From: Evgenii Kniazev Date: Wed, 1 Apr 2026 16:13:15 +0100 Subject: [PATCH 5/5] refactor: replace proto plugin with codegen guide + scenario test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per review feedback (Jorge, Pawel): proto doesn't need a plugin — just codegen (buf/ts-proto) + docs showing how to use generated types with existing files and lakebase plugins. - Remove proto plugin (no resources, no routes, no lifecycle needed) - Keep scenario test (moved to examples/proto-catalog/) - Reframe docs as codegen guide (docs/guides/protobuf.md) - Revert plugin exports, package.json dep, pnpm-lock The proto pattern is: buf generate → import types → use with existing plugins (files.upload, lakebase.query, express routes). Signed-off-by: Evgenii Kniazev Co-authored-by: Isaac Signed-off-by: Evgenii Kniazev --- docs/docs/guides/protobuf.md | 105 ++++++++ docs/docs/plugins/index.md | 3 +- docs/docs/plugins/proto.md | 252 ------------------ .../proto-catalog}/README.md | 0 .../proto-catalog}/catalog.proto | 0 .../proto-catalog}/meta.json | 0 .../proto-catalog}/private/cases.json | 0 .../proto-catalog}/public/cases.json | 0 .../app => examples/proto-catalog}/server.ts | 0 .../proto-catalog}/tests/catalog.spec.ts | 0 packages/appkit/package.json | 3 +- packages/appkit/src/index.ts | 2 +- packages/appkit/src/plugins/index.ts | 1 - packages/appkit/src/plugins/proto/index.ts | 3 - .../appkit/src/plugins/proto/manifest.json | 16 -- packages/appkit/src/plugins/proto/plugin.ts | 82 ------ .../appkit/src/plugins/proto/serializer.ts | 49 ---- .../src/plugins/proto/tests/plugin.test.ts | 100 ------- .../plugins/proto/tests/serializer.test.ts | 53 ---- packages/appkit/src/plugins/proto/types.ts | 4 - pnpm-lock.yaml | 8 - 21 files changed, 108 insertions(+), 573 deletions(-) create mode 100644 docs/docs/guides/protobuf.md delete mode 100644 docs/docs/plugins/proto.md rename {packages/appkit/src/plugins/proto/tests/scenario => examples/proto-catalog}/README.md (100%) rename {packages/appkit/src/plugins/proto/tests/scenario/app => examples/proto-catalog}/catalog.proto (100%) rename {packages/appkit/src/plugins/proto/tests/scenario => examples/proto-catalog}/meta.json (100%) rename {packages/appkit/src/plugins/proto/tests/scenario => examples/proto-catalog}/private/cases.json (100%) rename {packages/appkit/src/plugins/proto/tests/scenario => examples/proto-catalog}/public/cases.json (100%) rename {packages/appkit/src/plugins/proto/tests/scenario/app => examples/proto-catalog}/server.ts (100%) rename {packages/appkit/src/plugins/proto/tests/scenario => examples/proto-catalog}/tests/catalog.spec.ts (100%) delete mode 100644 packages/appkit/src/plugins/proto/index.ts delete mode 100644 packages/appkit/src/plugins/proto/manifest.json delete mode 100644 packages/appkit/src/plugins/proto/plugin.ts delete mode 100644 packages/appkit/src/plugins/proto/serializer.ts delete mode 100644 packages/appkit/src/plugins/proto/tests/plugin.test.ts delete mode 100644 packages/appkit/src/plugins/proto/tests/serializer.test.ts delete mode 100644 packages/appkit/src/plugins/proto/types.ts diff --git a/docs/docs/guides/protobuf.md b/docs/docs/guides/protobuf.md new file mode 100644 index 00000000..ddee8a8b --- /dev/null +++ b/docs/docs/guides/protobuf.md @@ -0,0 +1,105 @@ +# Using Protobuf with AppKit + +Typed data contracts via protobuf codegen. Define data shapes in `.proto` files, generate TypeScript types with `buf`, use them with AppKit's files and lakebase plugins. + +Not a plugin — a codegen pattern using `@bufbuild/protobuf` or `ts-proto` directly. + +## When to use + +- Multiple plugins exchanging data (files + lakebase + jobs) +- Backend jobs produce data that server/frontend consumes +- Python and TypeScript services share data structures +- You want compile-time guarantees on cross-boundary data + +## Setup + +```bash +pnpm add @bufbuild/protobuf +pnpm add -D @bufbuild/buf @bufbuild/protoc-gen-es +``` + +```protobuf +// proto/myapp/v1/models.proto +syntax = "proto3"; +package myapp.v1; + +message Customer { + string id = 1; + string name = 2; + string email = 3; + double lifetime_value = 4; + bool is_active = 5; +} +``` + +```bash +npx buf generate proto/ +``` + +## With Files plugin + +```ts +import { toJson, fromJson } from "@bufbuild/protobuf"; +import { CustomerSchema } from "../proto/gen/myapp/v1/models_pb.js"; + +// Write +const json = toJson(CustomerSchema, customer); +await app.files("data").upload("customers/cust-001.json", Buffer.from(JSON.stringify(json))); + +// Read +const data = await app.files("data").read("customers/cust-001.json"); +const loaded = fromJson(CustomerSchema, JSON.parse(data.toString())); +``` + +## With Lakebase plugin + +```ts +const json = toJson(CustomerSchema, customer); +await app.lakebase.query( + `INSERT INTO customers (id, name, email, lifetime_value, is_active) VALUES ($1, $2, $3, $4, $5)`, + [json.id, json.name, json.email, json.lifetimeValue, json.isActive], +); + +const { rows } = await app.lakebase.query("SELECT * FROM customers WHERE id = $1", [id]); +const customer = fromJson(CustomerSchema, rows[0]); +``` + +## In API routes + +```ts +import { fromJson, toJson } from "@bufbuild/protobuf"; + +expressApp.post("/api/orders", express.json(), (req, res) => { + const order = fromJson(OrderSchema, req.body); // validates shape + res.json(toJson(OrderSchema, order)); // guarantees output +}); +``` + +## Proto → Lakebase DDL + +| Proto type | SQL type | Default | +|-----------|----------|---------| +| `string` | `TEXT` | `''` | +| `bool` | `BOOLEAN` | `false` | +| `int32`/`int64` | `INTEGER`/`BIGINT` | `0` | +| `double` | `DOUBLE PRECISION` | `0.0` | +| `Timestamp` | `TIMESTAMPTZ` | `NOW()` | +| `repeated T` / `map` | `JSONB` | `'[]'` / `'{}'` | + +## Buf config + +```yaml +# proto/buf.yaml +version: v2 +lint: + use: [STANDARD] + +# proto/buf.gen.yaml +version: v2 +plugins: + - local: protoc-gen-es + out: proto/gen + opt: [target=ts] +``` + +Alternative: [ts-proto](https://github.com/stephenh/ts-proto) if you prefer its codegen style. diff --git a/docs/docs/plugins/index.md b/docs/docs/plugins/index.md index e79ff6b4..f0e4b51d 100644 --- a/docs/docs/plugins/index.md +++ b/docs/docs/plugins/index.md @@ -13,7 +13,7 @@ For complete API documentation, see the [`Plugin`](../api/appkit/Class.Plugin.md Configure plugins when creating your AppKit instance: ```typescript -import { createApp, server, analytics, genie, files, proto } from "@databricks/appkit"; +import { createApp, server, analytics, genie, files } from "@databricks/appkit"; const AppKit = await createApp({ plugins: [ @@ -21,7 +21,6 @@ const AppKit = await createApp({ analytics(), genie(), files(), - proto(), ], }); ``` diff --git a/docs/docs/plugins/proto.md b/docs/docs/plugins/proto.md deleted file mode 100644 index dec03644..00000000 --- a/docs/docs/plugins/proto.md +++ /dev/null @@ -1,252 +0,0 @@ ---- -sidebar_position: 8 ---- - -# Proto plugin - -Typed data contracts via protobuf. Define your data shapes once in `.proto` files, generate TypeScript types, and use them across plugins, routes, and jobs — no more ad-hoc interfaces that drift between producer and consumer. - -**Key features:** -- **Single schema definition** — one `.proto` file generates types for all consumers -- **Binary + JSON serialization** — efficient binary for storage, JSON for APIs -- **Type-safe create** — construct messages with compile-time field validation -- **Interop with other plugins** — serialize to bytes, pass to Files plugin for Volume I/O; serialize to JSON, pass to Analytics plugin for SQL; serialize to binary, send over gRPC - -## Basic usage - -```ts -import { createApp, proto, server } from "@databricks/appkit"; - -const app = await createApp({ - plugins: [ - server(), - proto(), - ], -}); -``` - -## Defining contracts - -Create `.proto` files in your project: - -```protobuf -// proto/myapp/v1/models.proto -syntax = "proto3"; -package myapp.v1; - -message Customer { - string id = 1; - string name = 2; - string email = 3; - double lifetime_value = 4; - bool is_active = 5; -} - -message Order { - string order_id = 1; - string customer_id = 2; - double total = 3; - repeated OrderItem items = 4; -} - -message OrderItem { - string product_id = 1; - string name = 2; - int32 quantity = 3; - double unit_price = 4; -} -``` - -Generate TypeScript types: - -```bash -npx buf generate proto/ -``` - -This produces typed interfaces like `Customer`, `Order`, `OrderItem` with schemas like `CustomerSchema`, `OrderSchema`. - -## Creating messages - -```ts -import { CustomerSchema } from "../proto/gen/myapp/v1/models_pb.js"; - -// Type-safe — unknown fields are compile errors -const customer = app.proto.create(CustomerSchema, { - id: "cust-001", - name: "Acme Corp", - email: "billing@acme.com", - lifetimeValue: 15_230.50, - isActive: true, -}); -``` - -## Serialization - -### Binary (compact, for storage and transfer) - -```ts -const bytes = app.proto.serialize(CustomerSchema, customer); -// bytes: Uint8Array — pass to Files plugin, store in database, send over network - -const recovered = app.proto.deserialize(CustomerSchema, bytes); -// recovered.name === "Acme Corp" -``` - -### JSON (human-readable, for APIs and logging) - -```ts -const json = app.proto.toJSON(CustomerSchema, customer); -// { "id": "cust-001", "name": "Acme Corp", "email": "billing@acme.com", -// "lifetimeValue": 15230.5, "isActive": true } - -const fromApi = app.proto.fromJSON(CustomerSchema, requestBody); -``` - -## Combining with other plugins - -### Proto + Files: typed Volume I/O - -```ts -import { createApp, proto, files, server } from "@databricks/appkit"; - -const app = await createApp({ - plugins: [server(), proto(), files()], -}); - -// Serialize a message and upload to a UC Volume -const bytes = app.proto.serialize(OrderSchema, order); -await app.files("reports").upload("orders/latest.bin", Buffer.from(bytes)); - -// Download and deserialize -const data = await app.files("reports").download("orders/latest.bin"); -const loaded = app.proto.deserialize(OrderSchema, new Uint8Array(data)); -``` - -### Proto + Lakebase: typed database rows - -```ts -import { createApp, proto, lakebase, server } from "@databricks/appkit"; - -const app = await createApp({ - plugins: [server(), proto(), lakebase()], -}); - -// Convert proto message to JSON for SQL insert -const json = app.proto.toJSON(CustomerSchema, customer); -await app.lakebase.query( - `INSERT INTO customers (id, name, email, lifetime_value, is_active) - VALUES ($1, $2, $3, $4, $5)`, - [json.id, json.name, json.email, json.lifetimeValue, json.isActive], -); -``` - -### Proto + Analytics: typed query results - -```ts -// Parse SQL query results into typed proto messages -const rows = await app.analytics.query("top-customers", { minValue: 1000 }); -const customers = rows.map((row) => - app.proto.fromJSON(CustomerSchema, row), -); -``` - -## API routes with typed contracts - -```ts -import express from "express"; - -app.server.extend((expressApp) => { - expressApp.get("/api/customers/:id", async (req, res) => { - const row = await app.lakebase.query( - "SELECT * FROM customers WHERE id = $1", - [req.params.id], - ); - if (!row.length) return res.status(404).json({ error: "Not found" }); - - // Parse to proto and back to JSON — guarantees the response - // matches the contract even if the DB has extra columns - const customer = app.proto.fromJSON(CustomerSchema, row[0]); - res.json(app.proto.toJSON(CustomerSchema, customer)); - }); - - expressApp.post("/api/orders", express.json(), async (req, res) => { - // Validate request body against the proto schema - const order = app.proto.fromJSON(OrderSchema, req.body); - // order is now typed — order.items, order.total, etc. - - const bytes = app.proto.serialize(OrderSchema, order); - await app.files("orders").upload( - `${order.orderId}.bin`, - Buffer.from(bytes), - ); - - res.status(201).json(app.proto.toJSON(OrderSchema, order)); - }); -}); -``` - -## Proto setup with buf - -Install buf and protoc-gen-es: - -```bash -pnpm add -D @bufbuild/buf @bufbuild/protoc-gen-es @bufbuild/protobuf -``` - -Create `proto/buf.yaml`: - -```yaml -version: v2 -modules: - - path: . -lint: - use: - - STANDARD -``` - -Create `proto/buf.gen.yaml`: - -```yaml -version: v2 -plugins: - - local: protoc-gen-es - out: proto/gen - opt: - - target=ts -``` - -Generate types: - -```bash -npx buf generate proto/ -``` - -Add to your build: - -```json -{ - "scripts": { - "proto:generate": "buf generate proto/", - "proto:lint": "buf lint proto/", - "prebuild": "pnpm proto:generate" - } -} -``` - -## API reference - -| Method | Description | -| --- | --- | -| `create(schema, init?)` | Create a new proto message with optional initial values | -| `serialize(schema, message)` | Serialize to binary (`Uint8Array`) | -| `deserialize(schema, data)` | Deserialize from binary | -| `toJSON(schema, message)` | Convert to JSON (snake_case field names) | -| `fromJSON(schema, json)` | Parse from JSON | - -## Configuration - -The proto plugin requires no configuration: - -```ts -proto() // That's it -``` diff --git a/packages/appkit/src/plugins/proto/tests/scenario/README.md b/examples/proto-catalog/README.md similarity index 100% rename from packages/appkit/src/plugins/proto/tests/scenario/README.md rename to examples/proto-catalog/README.md diff --git a/packages/appkit/src/plugins/proto/tests/scenario/app/catalog.proto b/examples/proto-catalog/catalog.proto similarity index 100% rename from packages/appkit/src/plugins/proto/tests/scenario/app/catalog.proto rename to examples/proto-catalog/catalog.proto diff --git a/packages/appkit/src/plugins/proto/tests/scenario/meta.json b/examples/proto-catalog/meta.json similarity index 100% rename from packages/appkit/src/plugins/proto/tests/scenario/meta.json rename to examples/proto-catalog/meta.json diff --git a/packages/appkit/src/plugins/proto/tests/scenario/private/cases.json b/examples/proto-catalog/private/cases.json similarity index 100% rename from packages/appkit/src/plugins/proto/tests/scenario/private/cases.json rename to examples/proto-catalog/private/cases.json diff --git a/packages/appkit/src/plugins/proto/tests/scenario/public/cases.json b/examples/proto-catalog/public/cases.json similarity index 100% rename from packages/appkit/src/plugins/proto/tests/scenario/public/cases.json rename to examples/proto-catalog/public/cases.json diff --git a/packages/appkit/src/plugins/proto/tests/scenario/app/server.ts b/examples/proto-catalog/server.ts similarity index 100% rename from packages/appkit/src/plugins/proto/tests/scenario/app/server.ts rename to examples/proto-catalog/server.ts diff --git a/packages/appkit/src/plugins/proto/tests/scenario/tests/catalog.spec.ts b/examples/proto-catalog/tests/catalog.spec.ts similarity index 100% rename from packages/appkit/src/plugins/proto/tests/scenario/tests/catalog.spec.ts rename to examples/proto-catalog/tests/catalog.spec.ts diff --git a/packages/appkit/package.json b/packages/appkit/package.json index 1a8f3d77..471e168d 100644 --- a/packages/appkit/package.json +++ b/packages/appkit/package.json @@ -75,8 +75,7 @@ "semver": "7.7.3", "shared": "workspace:*", "vite": "npm:rolldown-vite@7.1.14", - "ws": "8.18.3", - "@bufbuild/protobuf": "^2.3.0" + "ws": "8.18.3" }, "devDependencies": { "@types/express": "4.17.25", diff --git a/packages/appkit/src/index.ts b/packages/appkit/src/index.ts index f1d2fbf1..8db7f1d7 100644 --- a/packages/appkit/src/index.ts +++ b/packages/appkit/src/index.ts @@ -48,7 +48,7 @@ export { } from "./errors"; // Plugin authoring export { Plugin, type ToPlugin, toPlugin } from "./plugin"; -export { analytics, files, genie, lakebase, proto, server } from "./plugins"; +export { analytics, files, genie, lakebase, server } from "./plugins"; // Registry types and utilities for plugin manifests export type { ConfigSchema, diff --git a/packages/appkit/src/plugins/index.ts b/packages/appkit/src/plugins/index.ts index e080accf..7caa040f 100644 --- a/packages/appkit/src/plugins/index.ts +++ b/packages/appkit/src/plugins/index.ts @@ -2,5 +2,4 @@ export * from "./analytics"; export * from "./files"; export * from "./genie"; export * from "./lakebase"; -export * from "./proto"; export * from "./server"; diff --git a/packages/appkit/src/plugins/proto/index.ts b/packages/appkit/src/plugins/proto/index.ts deleted file mode 100644 index 59be4b6c..00000000 --- a/packages/appkit/src/plugins/proto/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from "./plugin"; -export * from "./types"; -export { ProtoSerializer } from "./serializer"; diff --git a/packages/appkit/src/plugins/proto/manifest.json b/packages/appkit/src/plugins/proto/manifest.json deleted file mode 100644 index 721c3f1f..00000000 --- a/packages/appkit/src/plugins/proto/manifest.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "$schema": "https://databricks.github.io/appkit/schemas/plugin-manifest.schema.json", - "name": "proto", - "displayName": "Proto Plugin", - "description": "Typed data contracts via protobuf — shared schemas across plugins, routes, and jobs", - "resources": { - "required": [], - "optional": [] - }, - "config": { - "schema": { - "type": "object", - "properties": {} - } - } -} diff --git a/packages/appkit/src/plugins/proto/plugin.ts b/packages/appkit/src/plugins/proto/plugin.ts deleted file mode 100644 index 724f2e50..00000000 --- a/packages/appkit/src/plugins/proto/plugin.ts +++ /dev/null @@ -1,82 +0,0 @@ -import type { DescMessage, JsonValue, MessageShape } from "@bufbuild/protobuf"; -import { create } from "@bufbuild/protobuf"; -import type express from "express"; -import type { IAppRouter } from "shared"; -import { Plugin, toPlugin } from "../../plugin"; -import type { PluginManifest } from "../../registry"; -import manifest from "./manifest.json"; -import { ProtoSerializer } from "./serializer"; -import type { IProtoConfig } from "./types"; - -/** - * Proto plugin for AppKit. - * - * Typed data contracts for AppKit applications. - * - * Provides protobuf-based serialization so plugins, routes, and - * jobs share a single schema definition. - */ -export class ProtoPlugin extends Plugin { - static manifest = manifest as PluginManifest<"proto">; - protected declare config: IProtoConfig; - private serializer: ProtoSerializer; - - constructor(config: IProtoConfig) { - super(config); - this.config = config; - this.serializer = new ProtoSerializer(); - } - - /** Create a new proto message with optional initial values. */ - create(schema: T, init?: Partial>): MessageShape { - return create(schema, init as MessageShape); - } - - /** Serialize a protobuf message to binary. */ - serialize(schema: T, message: MessageShape): Uint8Array { - return this.serializer.serialize(schema, message); - } - - /** Deserialize a protobuf message from binary. */ - deserialize(schema: T, data: Uint8Array): MessageShape { - return this.serializer.deserialize(schema, data); - } - - /** Convert a protobuf message to JSON (snake_case field names). */ - toJSON(schema: T, message: MessageShape): JsonValue { - return this.serializer.toJSON(schema, message); - } - - /** Parse a protobuf message from JSON. */ - fromJSON(schema: T, json: JsonValue): MessageShape { - return this.serializer.fromJSON(schema, json); - } - - injectRoutes(router: IAppRouter): void { - this.route(router, { - name: "health", - method: "get", - path: "/health", - handler: async (_req: express.Request, res: express.Response) => { - res.json({ status: "ok" }); - }, - }); - } - - async shutdown(): Promise { - this.streamManager.abortAll(); - } - - exports() { - return { - create: this.create.bind(this), - serialize: this.serialize.bind(this), - deserialize: this.deserialize.bind(this), - toJSON: this.toJSON.bind(this), - fromJSON: this.fromJSON.bind(this), - }; - } -} - -/** @internal */ -export const proto = toPlugin(ProtoPlugin); diff --git a/packages/appkit/src/plugins/proto/serializer.ts b/packages/appkit/src/plugins/proto/serializer.ts deleted file mode 100644 index 01bbf31b..00000000 --- a/packages/appkit/src/plugins/proto/serializer.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { - type DescMessage, - type MessageShape, - fromBinary, - fromJson, - toBinary, - toJson, -} from "@bufbuild/protobuf"; -import type { JsonValue } from "@bufbuild/protobuf"; - -/** - * Protobuf serializer for typed data contracts. - * - * Handles binary and JSON serialization/deserialization of proto messages. - * For file I/O (UC Volumes), use the Files plugin. - */ -export class ProtoSerializer { - /** Serialize a protobuf message to binary. */ - serialize( - schema: T, - message: MessageShape, - ): Uint8Array { - return toBinary(schema, message); - } - - /** Deserialize a protobuf message from binary. */ - deserialize( - schema: T, - data: Uint8Array, - ): MessageShape { - return fromBinary(schema, data); - } - - /** Convert a protobuf message to JSON (uses proto field names — snake_case). */ - toJSON( - schema: T, - message: MessageShape, - ): JsonValue { - return toJson(schema, message); - } - - /** Parse a protobuf message from JSON. */ - fromJSON( - schema: T, - json: JsonValue, - ): MessageShape { - return fromJson(schema, json); - } -} diff --git a/packages/appkit/src/plugins/proto/tests/plugin.test.ts b/packages/appkit/src/plugins/proto/tests/plugin.test.ts deleted file mode 100644 index 34710530..00000000 --- a/packages/appkit/src/plugins/proto/tests/plugin.test.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { - createMockRouter, - createMockRequest, - createMockResponse, - setupDatabricksEnv, -} from "@tools/test-helpers"; -import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; -import { ProtoPlugin, proto } from "../plugin"; - -vi.mock("../../../cache", () => ({ - CacheManager: { - getInstanceSync: vi.fn(() => ({ - get: vi.fn(), - set: vi.fn(), - delete: vi.fn(), - getOrExecute: vi.fn(async (_k: any, fn: any) => fn()), - generateKey: vi.fn((p: any, u: any) => `${u}:${JSON.stringify(p)}`), - })), - }, -})); - -vi.mock("../../../telemetry", async (importOriginal) => { - const actual = (await importOriginal()) as Record; - return { - ...actual, - TelemetryManager: { - getProvider: vi.fn(() => ({ - getTracer: vi.fn().mockReturnValue({ - startActiveSpan: vi.fn((...args: any[]) => { - const fn = args[args.length - 1]; - return typeof fn === "function" ? fn({ end: vi.fn(), setAttribute: vi.fn(), setStatus: vi.fn() }) : undefined; - }), - }), - getMeter: vi.fn().mockReturnValue({ - createCounter: vi.fn().mockReturnValue({ add: vi.fn() }), - createHistogram: vi.fn().mockReturnValue({ record: vi.fn() }), - }), - getLogger: vi.fn().mockReturnValue({ emit: vi.fn() }), - emit: vi.fn(), - startActiveSpan: vi.fn(async (_n: any, _o: any, fn: any) => fn({ end: vi.fn() })), - registerInstrumentations: vi.fn(), - })), - }, - normalizeTelemetryOptions: vi.fn(() => ({ traces: false, metrics: false, logs: false })), - }; -}); - -describe("ProtoPlugin", () => { - beforeEach(() => setupDatabricksEnv()); - afterEach(() => vi.restoreAllMocks()); - - test("creates with correct name from manifest", () => { - expect(new ProtoPlugin({}).name).toBe("proto"); - }); - - test("toPlugin factory produces correct PluginData", () => { - const data = proto({}); - expect(data.name).toBe("proto"); - expect(data.plugin).toBe(ProtoPlugin); - }); - - test("toPlugin works with no config", () => { - expect(proto().name).toBe("proto"); - }); - - test("manifest has no required resources", () => { - expect(ProtoPlugin.manifest.resources.required).toEqual([]); - }); - - test("injectRoutes registers health endpoint", () => { - const plugin = new ProtoPlugin({}); - const { router, getHandler } = createMockRouter(); - plugin.injectRoutes(router); - expect(getHandler("GET", "/health")).toBeDefined(); - }); - - test("health endpoint returns ok", async () => { - const plugin = new ProtoPlugin({}); - const { router, getHandler } = createMockRouter(); - plugin.injectRoutes(router); - - const res = createMockResponse(); - await getHandler("GET", "/health")(createMockRequest(), res); - - expect(res.json).toHaveBeenCalledWith({ status: "ok" }); - }); - - test("exports returns serialization API only", () => { - const api = new ProtoPlugin({}).exports(); - expect(typeof api.create).toBe("function"); - expect(typeof api.serialize).toBe("function"); - expect(typeof api.deserialize).toBe("function"); - expect(typeof api.toJSON).toBe("function"); - expect(typeof api.fromJSON).toBe("function"); - // No file I/O — that belongs in the Files plugin - expect((api as any).writeToVolume).toBeUndefined(); - expect((api as any).readFromVolume).toBeUndefined(); - expect((api as any).exists).toBeUndefined(); - }); -}); diff --git a/packages/appkit/src/plugins/proto/tests/serializer.test.ts b/packages/appkit/src/plugins/proto/tests/serializer.test.ts deleted file mode 100644 index 85896695..00000000 --- a/packages/appkit/src/plugins/proto/tests/serializer.test.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { describe, expect, test, vi } from "vitest"; -import { ProtoSerializer } from "../serializer"; - -vi.mock("@bufbuild/protobuf", () => ({ - toBinary: vi.fn((_s: any, msg: any) => new TextEncoder().encode(JSON.stringify(msg))), - fromBinary: vi.fn((_s: any, data: Uint8Array) => JSON.parse(new TextDecoder().decode(data))), - toJson: vi.fn((_s: any, msg: any) => msg), - fromJson: vi.fn((_s: any, json: any) => json), -})); - -describe("ProtoSerializer", () => { - const schema = { typeName: "test.Message" } as any; - const message = { name: "test", value: 42 }; - - test("serialize produces Uint8Array", () => { - const s = new ProtoSerializer(); - const result = s.serialize(schema, message as any); - expect(result).toBeInstanceOf(Uint8Array); - expect(result.length).toBeGreaterThan(0); - }); - - test("round-trip preserves data", () => { - const s = new ProtoSerializer(); - const bytes = s.serialize(schema, message as any); - const recovered = s.deserialize(schema, bytes); - expect(recovered).toEqual(message); - }); - - test("toJSON returns value", () => { - const s = new ProtoSerializer(); - expect(s.toJSON(schema, message as any)).toEqual(message); - }); - - test("fromJSON returns value", () => { - const s = new ProtoSerializer(); - expect(s.fromJSON(schema, message as any)).toEqual(message); - }); - - test("handles nested objects", () => { - const s = new ProtoSerializer(); - const nested = { - metadata: { entries: { k1: "v1" } }, - rows: [{ fields: { score: { case: "numberValue", value: 95 } } }], - }; - const bytes = s.serialize(schema, nested as any); - expect(s.deserialize(schema, bytes)).toEqual(nested); - }); - - test("deserialize throws on invalid data", () => { - const s = new ProtoSerializer(); - expect(() => s.deserialize(schema, new Uint8Array([0xff, 0xfe]))).toThrow(); - }); -}); diff --git a/packages/appkit/src/plugins/proto/types.ts b/packages/appkit/src/plugins/proto/types.ts deleted file mode 100644 index ce02beea..00000000 --- a/packages/appkit/src/plugins/proto/types.ts +++ /dev/null @@ -1,4 +0,0 @@ -import type { BasePluginConfig } from "shared"; - -/** Configuration for the Proto plugin. */ -export interface IProtoConfig extends BasePluginConfig {} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 78641882..a4d3ec7b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -239,9 +239,6 @@ importers: packages/appkit: dependencies: - '@bufbuild/protobuf': - specifier: ^2.3.0 - version: 2.11.0 '@databricks/lakebase': specifier: workspace:* version: link:../lakebase @@ -1409,9 +1406,6 @@ packages: '@braintree/sanitize-url@7.1.1': resolution: {integrity: sha512-i1L7noDNxtFyL5DmZafWy1wRVhGehQmzZaz1HiN5e7iylJMSZR7ekOV7NsIqa5qBldlLrsKv4HbgFUVlQrz8Mw==} - '@bufbuild/protobuf@2.11.0': - resolution: {integrity: sha512-sBXGT13cpmPR5BMgHE6UEEfEaShh5Ror6rfN3yEK5si7QVrtZg8LEPQb0VVhiLRUslD2yLnXtnRzG035J/mZXQ==} - '@chevrotain/cst-dts-gen@11.0.3': resolution: {integrity: sha512-BvIKpRLeS/8UbfxXxgC33xOumsacaeCKAjAeLyOn7Pcp95HiRbrpl14S+9vaZLolnbssPIUuiUd8IvgkRyt6NQ==} @@ -12366,8 +12360,6 @@ snapshots: '@braintree/sanitize-url@7.1.1': {} - '@bufbuild/protobuf@2.11.0': {} - '@chevrotain/cst-dts-gen@11.0.3': dependencies: '@chevrotain/gast': 11.0.3