From 202b3d88c8483254d6f9b14497d457d7a19fcb84 Mon Sep 17 00:00:00 2001 From: jamals86 Date: Thu, 7 May 2026 22:34:11 +0300 Subject: [PATCH 1/5] Add React live-queries SDK; update client API Introduce React live-query support and related tooling (030-react-live-queries): add @kalamdb/react package, React examples (examples/react-ai-chat), new TypeScript/React SDK tests and specs, and docs showing React usage. Rename client subscription APIs across CLI and tests from subscribe/subscribe_with_config to live_events/live_events_with_config and update call sites accordingly. Update project docs and READMEs (license clarified to Apache-2.0), add a KalamDB speckit constitution, and relax backend server allowed_origins to '*' for dev setups. Miscellaneous updates: docs/sdk React examples, TypeScript SDK additions, and numerous test adjustments to align with the new live-query APIs. --- .github/agents/copilot-instructions.md | 6 +- .specify/memory/constitution.md | 75 +- Cargo.lock | 148 +- Cargo.toml | 16 +- README.md | 4 + backend/README.md | 2 +- backend/server.toml | 9 +- benchv2/README.md | 3 +- cli/README.md | 2 +- cli/src/session.rs | 4 +- .../cluster_test_subscription_nodes.rs | 6 +- cli/tests/cluster/cluster_test_ws_follower.rs | 2 +- cli/tests/common/mod.rs | 2 +- cli/tests/connection/concurrent_ws_tests.rs | 4 +- .../security/smoke_test_security_access.rs | 2 +- .../smoke_test_shared_table_subscription.rs | 2 +- .../smoke_test_subscription_advanced.rs | 2 +- .../smoke_test_subscription_close.rs | 4 +- .../smoke_test_subscription_listing.rs | 6 +- ...smoke_test_subscription_multi_reconnect.rs | 4 +- ...moke_test_subscription_reconnect_resume.rs | 8 +- .../usecases/smoke_test_batch_control.rs | 2 +- cli/tests/subscription/slow_subscriber.rs | 24 +- docs/sdk/sdk.md | 63 +- examples/chat-with-ai/README.md | 2 +- examples/chat-with-ai/scripts/ensure-sdk.sh | 14 +- examples/chat-with-ai/src/App.tsx | 54 +- examples/chat-with-ai/tests/chat.spec.mjs | 17 +- examples/react-ai-chat/.gitignore | 5 + examples/react-ai-chat/README.md | 48 + examples/react-ai-chat/chat-app.sql | 72 + examples/react-ai-chat/index.html | 12 + examples/react-ai-chat/package.json | 42 + examples/react-ai-chat/scripts/ensure-sdk.sh | 16 + .../react-ai-chat/scripts/generate-schema.sh | 5 + examples/react-ai-chat/setup.sh | 60 + examples/react-ai-chat/src/agent/index.ts | 246 ++ examples/react-ai-chat/src/agent/logic.ts | 67 + examples/react-ai-chat/src/app/App.tsx | 129 + examples/react-ai-chat/src/app/client.ts | 31 + .../src/app/components/Aside.tsx | 60 + .../src/app/components/ChatComposer.tsx | 83 + .../src/app/components/Conversation.tsx | 359 +++ .../src/app/components/Messages.tsx | 187 ++ examples/react-ai-chat/src/app/demo-client.ts | 371 +++ examples/react-ai-chat/src/app/main.tsx | 10 + .../react-ai-chat/src/app/schema.generated.ts | 59 + examples/react-ai-chat/src/app/styles.css | 506 ++++ examples/react-ai-chat/tests/agent.test.ts | 31 + examples/react-ai-chat/tests/chat.spec.mjs | 26 + examples/react-ai-chat/tsconfig.json | 21 + examples/react-ai-chat/vite.config.ts | 34 + examples/simple-typescript/README.md | 2 +- examples/simple-typescript/src/App.tsx | 2 +- link/README.md | 4 +- link/kalam-client/tests/integration_tests.rs | 4 +- .../tests/proxied/ack_before_first_batch.rs | 3 +- .../proxied/blackhole_during_subscribe.rs | 6 +- .../tests/proxied/double_outage.rs | 2 +- .../tests/proxied/event_counter_integrity.rs | 2 +- .../tests/proxied/gradual_degradation.rs | 2 +- .../proxied/heavy_write_burst_recovery.rs | 2 +- .../large_snapshot_repeated_outages.rs | 2 +- .../tests/proxied/latency_during_snapshot.rs | 2 +- .../tests/proxied/live_updates_resume.rs | 2 +- .../loading_resume_with_live_writes.rs | 3 +- .../tests/proxied/mixed_stage_recovery.rs | 4 +- .../tests/proxied/multi_sub_bounce.rs | 6 +- link/kalam-client/tests/proxied/rapid_flap.rs | 2 +- .../tests/proxied/server_down_initial_load.rs | 2 +- .../tests/proxied/socket_drop_resume.rs | 2 +- .../tests/proxied/staggered_outages.rs | 2 +- .../proxied/subscribe_during_reconnect.rs | 4 +- .../tests/proxied/transport_impairments.rs | 12 +- .../proxied/unsubscribe_during_outage.rs | 4 +- .../tests/proxied/update_delete_resume.rs | 2 +- .../tests/test_shared_connection.rs | 94 +- .../tests/test_subscription_cleanup.rs | 10 +- .../tests/test_user_table_subscriptions.rs | 7 +- .../tests/test_websocket_integration.rs | 10 +- link/kalam-client/tests/wasm_integration.rs | 25 +- link/kalam-link-dart/src/api.rs | 70 +- link/kalam-link-dart/src/frb_generated.rs | 527 ++-- link/kalam-link-dart/src/models.rs | 3 + link/link-common/src/client/runtime.rs | 34 +- .../connection/models/connection_options.rs | 6 +- .../src/subscription/live_rows_event.rs | 3 +- .../subscription/live_rows_materializer.rs | 30 +- link/link-common/src/subscription/manager.rs | 2 +- link/link-common/src/wasm/client.rs | 28 +- link/link-common/src/wasm/state.rs | 3 + link/sdks/dart/README.md | 31 +- link/sdks/dart/example/main.dart | 2 +- link/sdks/dart/lib/src/generated/api.dart | 104 +- .../dart/lib/src/generated/frb_generated.dart | 495 ++-- .../lib/src/generated/frb_generated.io.dart | 128 +- .../lib/src/generated/frb_generated.web.dart | 104 +- link/sdks/dart/lib/src/generated/models.dart | 1 + .../lib/src/generated/models.freezed.dart | 39 +- link/sdks/dart/lib/src/kalam_client.dart | 145 +- link/sdks/dart/lib/src/models.dart | 18 + link/sdks/dart/pubspec.yaml | 4 +- .../test/e2e/keepalive/keepalive_test.dart | 2 +- .../keepalive/subscription_cleanup_test.dart | 14 +- .../e2e/reconnect/app_lifecycle_test.dart | 2 +- .../test/e2e/reconnect/reconnect_test.dart | 40 +- .../dart/test/e2e/reconnect/resume_test.dart | 20 +- .../subscription_options_test.dart | 22 +- .../e2e/subscription/subscription_test.dart | 108 +- link/sdks/dart/test/live_server_test.dart | 4 +- .../dart/test/main_thread_blocking_test.dart | 19 +- link/sdks/dart/web/pkg/README.md | 6 +- link/sdks/dart/web/pkg/kalam_link_dart.d.ts | 105 +- link/sdks/dart/web/pkg/kalam_link_dart.js | 172 +- .../sdks/dart/web/pkg/kalam_link_dart_bg.wasm | Bin 486553 -> 491427 bytes .../dart/web/pkg/kalam_link_dart_bg.wasm.d.ts | 15 +- link/sdks/python/Cargo.lock | 257 +- link/sdks/python/README.md | 38 +- link/sdks/python/kalamdb/__init__.py | 6 +- link/sdks/python/kalamdb/_native.pyi | 77 +- link/sdks/python/src/lib.rs | 522 +++- link/sdks/python/tests/test_repr.py | 6 +- link/sdks/python/tests/test_subscriptions.py | 28 +- link/sdks/typescript/README.md | 23 +- link/sdks/typescript/client/QUICKSTART.md | 12 +- link/sdks/typescript/client/README.md | 65 +- link/sdks/typescript/client/example/app.js | 22 +- link/sdks/typescript/client/package.json | 6 +- link/sdks/typescript/client/src/client.ts | 276 +- link/sdks/typescript/client/src/file_ref.ts | 84 +- .../src/helpers/subscription_helpers.ts | 102 +- link/sdks/typescript/client/src/index.ts | 52 +- .../typescript/client/src/live/controller.ts | 174 ++ .../typescript/client/src/live/descriptor.ts | 155 ++ .../typescript/client/src/live/projection.ts | 96 + link/sdks/typescript/client/src/types.ts | 105 +- .../typescript/client/tests/basic.test.mjs | 26 +- .../client/tests/browser-resume-e2e.html | 2 +- .../typescript/client/tests/browser-test.html | 3 +- .../tests/e2e/reconnect/reconnect.test.mjs | 60 +- .../tests/e2e/reconnect/resume.test.mjs | 28 +- .../e2e/subscription/subscription.test.mjs | 32 +- .../client/tests/integration.test.js | 4 +- .../client/tests/live-controller.test.mjs | 52 + .../client/tests/live-query-typed.test.mjs | 26 + .../client/tests/live-sql-descriptor.test.mjs | 21 + .../client/tests/normalize.test.mjs | 8 +- .../client/tests/readme-examples.test.mjs | 13 +- .../tests/sdk-runtime-coverage.test.mjs | 68 +- .../single-socket-subscriptions.test.mjs | 64 +- .../tests/test_subscription_initial_data.mjs | 12 +- .../typescript/client/tests/types.test.ts | 26 +- .../client/tests/websocket.test.mjs | 4 +- link/sdks/typescript/consumer/README.md | 6 +- link/sdks/typescript/consumer/package.json | 2 +- link/sdks/typescript/orm/README.md | 42 +- link/sdks/typescript/orm/package-lock.json | 8 +- link/sdks/typescript/orm/package.json | 5 +- link/sdks/typescript/orm/src/index.ts | 2 +- link/sdks/typescript/orm/src/live.ts | 101 +- .../orm/tests/live-descriptor.test.mjs | 26 + .../orm/tests/live-typed-query.test.mjs | 19 + link/sdks/typescript/orm/tests/live.test.mjs | 22 +- link/sdks/typescript/react/README.md | 144 ++ .../react/example/assistant-workspace.tsx | 48 + .../react/example/messages-pane.tsx | 29 + .../react/example/sql-messages-pane.tsx | 25 + link/sdks/typescript/react/package-lock.json | 2271 +++++++++++++++++ link/sdks/typescript/react/package.json | 82 + .../react/src/components/LiveQueries.tsx | 8 + .../react/src/components/LiveQuery.tsx | 9 + link/sdks/typescript/react/src/context.tsx | 19 + .../react/src/hooks/useLiveQueries.ts | 234 ++ .../react/src/hooks/useLiveQuery.ts | 188 ++ .../react/src/hooks/useLiveSelection.ts | 9 + .../react/src/hooks/useMutationState.ts | 166 ++ link/sdks/typescript/react/src/index.ts | 29 + link/sdks/typescript/react/src/types.ts | 134 + .../react/tests/assistant-workflow.test.tsx | 87 + .../react/tests/live-queries.test.tsx | 94 + .../react/tests/live-query.test.tsx | 72 + .../typescript/react/tests/sql-mode.test.tsx | 30 + .../typescript/react/tests/test-utils.tsx | 68 + link/sdks/typescript/react/tsconfig.json | 21 + pg/README.md | 6 +- .../checklists/requirements.md | 36 + .../contracts/react-sdk.md | 174 ++ specs/030-react-live-queries/data-model.md | 189 ++ specs/030-react-live-queries/plan.md | 144 ++ specs/030-react-live-queries/quickstart.md | 250 ++ specs/030-react-live-queries/research.md | 114 + specs/030-react-live-queries/spec.md | 168 ++ specs/030-react-live-queries/tasks.md | 312 +++ ui/eslint.config.js | 2 +- ui/package.json | 8 +- .../assistant/AssistantWorkflowDemo.test.tsx | 51 + .../assistant/AssistantWorkflowDemo.tsx | 115 + .../live-data/ReactLiveQueryDemo.test.tsx | 52 + .../live-data/ReactLiveQueryDemo.tsx | 123 + .../preview/StudioResultsGrid.tsx | 4 +- ui/src/lib/kalam-client.ts | 127 +- ui/src/pages/LiveQueries.tsx | 6 + ui/tsconfig.json | 1 + ui/vite.config.ts | 9 +- 204 files changed, 11910 insertions(+), 2033 deletions(-) create mode 100644 examples/react-ai-chat/.gitignore create mode 100644 examples/react-ai-chat/README.md create mode 100644 examples/react-ai-chat/chat-app.sql create mode 100644 examples/react-ai-chat/index.html create mode 100644 examples/react-ai-chat/package.json create mode 100644 examples/react-ai-chat/scripts/ensure-sdk.sh create mode 100644 examples/react-ai-chat/scripts/generate-schema.sh create mode 100755 examples/react-ai-chat/setup.sh create mode 100644 examples/react-ai-chat/src/agent/index.ts create mode 100644 examples/react-ai-chat/src/agent/logic.ts create mode 100644 examples/react-ai-chat/src/app/App.tsx create mode 100644 examples/react-ai-chat/src/app/client.ts create mode 100644 examples/react-ai-chat/src/app/components/Aside.tsx create mode 100644 examples/react-ai-chat/src/app/components/ChatComposer.tsx create mode 100644 examples/react-ai-chat/src/app/components/Conversation.tsx create mode 100644 examples/react-ai-chat/src/app/components/Messages.tsx create mode 100644 examples/react-ai-chat/src/app/demo-client.ts create mode 100644 examples/react-ai-chat/src/app/main.tsx create mode 100644 examples/react-ai-chat/src/app/schema.generated.ts create mode 100644 examples/react-ai-chat/src/app/styles.css create mode 100644 examples/react-ai-chat/tests/agent.test.ts create mode 100644 examples/react-ai-chat/tests/chat.spec.mjs create mode 100644 examples/react-ai-chat/tsconfig.json create mode 100644 examples/react-ai-chat/vite.config.ts create mode 100644 link/sdks/typescript/client/src/live/controller.ts create mode 100644 link/sdks/typescript/client/src/live/descriptor.ts create mode 100644 link/sdks/typescript/client/src/live/projection.ts create mode 100644 link/sdks/typescript/client/tests/live-controller.test.mjs create mode 100644 link/sdks/typescript/client/tests/live-query-typed.test.mjs create mode 100644 link/sdks/typescript/client/tests/live-sql-descriptor.test.mjs create mode 100644 link/sdks/typescript/orm/tests/live-descriptor.test.mjs create mode 100644 link/sdks/typescript/orm/tests/live-typed-query.test.mjs create mode 100644 link/sdks/typescript/react/README.md create mode 100644 link/sdks/typescript/react/example/assistant-workspace.tsx create mode 100644 link/sdks/typescript/react/example/messages-pane.tsx create mode 100644 link/sdks/typescript/react/example/sql-messages-pane.tsx create mode 100644 link/sdks/typescript/react/package-lock.json create mode 100644 link/sdks/typescript/react/package.json create mode 100644 link/sdks/typescript/react/src/components/LiveQueries.tsx create mode 100644 link/sdks/typescript/react/src/components/LiveQuery.tsx create mode 100644 link/sdks/typescript/react/src/context.tsx create mode 100644 link/sdks/typescript/react/src/hooks/useLiveQueries.ts create mode 100644 link/sdks/typescript/react/src/hooks/useLiveQuery.ts create mode 100644 link/sdks/typescript/react/src/hooks/useLiveSelection.ts create mode 100644 link/sdks/typescript/react/src/hooks/useMutationState.ts create mode 100644 link/sdks/typescript/react/src/index.ts create mode 100644 link/sdks/typescript/react/src/types.ts create mode 100644 link/sdks/typescript/react/tests/assistant-workflow.test.tsx create mode 100644 link/sdks/typescript/react/tests/live-queries.test.tsx create mode 100644 link/sdks/typescript/react/tests/live-query.test.tsx create mode 100644 link/sdks/typescript/react/tests/sql-mode.test.tsx create mode 100644 link/sdks/typescript/react/tests/test-utils.tsx create mode 100644 link/sdks/typescript/react/tsconfig.json create mode 100644 specs/030-react-live-queries/checklists/requirements.md create mode 100644 specs/030-react-live-queries/contracts/react-sdk.md create mode 100644 specs/030-react-live-queries/data-model.md create mode 100644 specs/030-react-live-queries/plan.md create mode 100644 specs/030-react-live-queries/quickstart.md create mode 100644 specs/030-react-live-queries/research.md create mode 100644 specs/030-react-live-queries/spec.md create mode 100644 specs/030-react-live-queries/tasks.md create mode 100644 ui/src/components/assistant/AssistantWorkflowDemo.test.tsx create mode 100644 ui/src/components/assistant/AssistantWorkflowDemo.tsx create mode 100644 ui/src/components/live-data/ReactLiveQueryDemo.test.tsx create mode 100644 ui/src/components/live-data/ReactLiveQueryDemo.tsx diff --git a/.github/agents/copilot-instructions.md b/.github/agents/copilot-instructions.md index 3843b2d41..0453f363c 100644 --- a/.github/agents/copilot-instructions.md +++ b/.github/agents/copilot-instructions.md @@ -1,6 +1,6 @@ # KalamDB Development Guidelines -Auto-generated from all feature plans. Last updated: 2026-04-20 +Auto-generated from all feature plans. Last updated: 2026-05-06 ## Active Technologies - Rust 1.90+ (edition 2021) + DataFusion 40.0, Apache Arrow 52.0, RocksDB 0.24, Actix-Web 4.4, DashMap 5, serde 1.0, tokio 1.48 (027-pg-transactions) @@ -9,6 +9,8 @@ Auto-generated from all feature plans. Last updated: 2026-04-20 - RocksDB-backed `system.users` via `IndexedEntityStore`; broader platform storage remains RocksDB + Parquet through existing abstractions (028-auth-integration) - Rust 1.92+ (edition 2021) across backend crates and CLI + DataFusion 53.1.0 (`datafusion`, `datafusion-datasource`, `datafusion-common`, `datafusion-expr`), Arrow 58.1.0, Parquet 58.1.0, object_store 0.13.2, tokio 1.51, RocksDB 0.24, Actix-Web 4.13, moka plan cache (029-datafusion-modernization) - RocksDB hot path plus manifest-directed Parquet cold storage via `kalamdb-filestore`, `StorageCached`, and `ManifestAccessPlanner` (029-datafusion-modernization) +- TypeScript 6.0.x, React 19.2, Node.js 18+ for package build/tes + React 19, React DOM 19, `@kalamdb/client`, `@kalamdb/orm`, `drizzle-orm`, Vitest, React Testing Library (030-react-live-queries) +- Existing KalamDB HTTP/WebSocket APIs via `@kalamdb/client`; no new persistent storage (030-react-live-queries) - Rust 1.92+ (edition 2021) for backend and PostgreSQL extension crates + DataFusion 40.0, Apache Arrow 52.0, Apache Parquet 52.0, RocksDB 0.24, Actix-Web 4.4, tonic/prost for pg RPC transport, DashMap for concurrent registries (027-pg-transactions) @@ -28,9 +30,9 @@ cargo test [ONLY COMMANDS FOR ACTIVE TECHNOLOGIES][ONLY COMMANDS FOR ACTIVE TECH Rust 1.92+ (edition 2021) for backend and PostgreSQL extension crates: Follow standard conventions ## Recent Changes +- 030-react-live-queries: Added TypeScript 6.0.x, React 19.2, Node.js 18+ for package build/tes + React 19, React DOM 19, `@kalamdb/client`, `@kalamdb/orm`, `drizzle-orm`, Vitest, React Testing Library - 029-datafusion-modernization: Added Rust 1.92+ (edition 2021) across backend crates and CLI + DataFusion 53.1.0 (`datafusion`, `datafusion-datasource`, `datafusion-common`, `datafusion-expr`), Arrow 58.1.0, Parquet 58.1.0, object_store 0.13.2, tokio 1.51, RocksDB 0.24, Actix-Web 4.13, moka plan cache - 028-auth-integration: Added Rust 1.92+ (edition 2021) for backend, CLI, link-common, and Dart bridge; TypeScript/JavaScript ES2020+ and Dart only for downstream contract consumers and docs + Actix-Web 4.4, jsonwebtoken 9.2, kalamdb-auth OIDC/JWKS validator, kalamdb-commons typed models, kalamdb-store IndexedEntityStore, tokio, serde, link-common, flutter_rust_bridge bridge models -- 027-pg-transactions: Added Rust 1.90+ (edition 2021) + DataFusion 40.0, Apache Arrow 52.0, RocksDB 0.24, Actix-Web 4.4, DashMap 5, serde 1.0, tokio 1.48 diff --git a/.specify/memory/constitution.md b/.specify/memory/constitution.md index a4670ff46..4983c7993 100644 --- a/.specify/memory/constitution.md +++ b/.specify/memory/constitution.md @@ -1,50 +1,55 @@ -# [PROJECT_NAME] Constitution - +# KalamDB Speckit Constitution ## Core Principles -### [PRINCIPLE_1_NAME] - -[PRINCIPLE_1_DESCRIPTION] - +### I. Performance-First Execution -### [PRINCIPLE_2_NAME] - -[PRINCIPLE_2_DESCRIPTION] - +- Features, plans, and tasks MUST prefer lower runtime cost, lower allocation pressure, smaller dependency surface, and faster build feedback when tradeoffs are otherwise comparable. +- Changes in hot paths MUST avoid extra SQL rewrite passes, duplicate orchestration layers, or framework-specific work in shared core packages unless a measured benefit justifies the added complexity. +- Performance-oriented benchmarks and perf e2e runs MUST record per-test runtime in seconds. -### [PRINCIPLE_3_NAME] - -[PRINCIPLE_3_DESCRIPTION] - +### II. Boundary Ownership Before Convenience -### [PRINCIPLE_4_NAME] - -[PRINCIPLE_4_DESCRIPTION] - +- Work MUST respect package and crate ownership boundaries: shared live-query behavior belongs in framework-agnostic client layers, React-only concerns belong in the React SDK package, filesystem logic belongs in `kalamdb-filestore`, and key-value engine logic belongs in `kalamdb-store`. +- Orchestration layers MUST delegate to the owning crate or package instead of embedding lower-level storage or framework-specific details directly. +- Public contracts SHOULD use typed models and reusable shared abstractions instead of duplicating parallel representations. -### [PRINCIPLE_5_NAME] - -[PRINCIPLE_5_DESCRIPTION] - +### III. Minimal Dependency Expansion -## [SECTION_2_NAME] - +- New dependencies MUST use the smallest viable feature set and SHOULD be added only in the package that directly needs them. +- Shared packages MUST remain free of UI-framework dependencies unless the shared package itself is the framework binding. +- Plans and tasks SHOULD favor reuse of existing KalamDB packages and tooling before introducing new libraries or parallel implementations. -[SECTION_2_CONTENT] - +### IV. Validation, Testing, and Documentation Ship Together -## [SECTION_3_NAME] - +- Every feature plan MUST define focused executable validation for the affected surface before implementation begins. +- SDK changes under `link/sdks/**` MUST include test coverage and MUST update both repo-side docs and the corresponding KalamSite SDK docs. +- Tasks for each user story MUST preserve an independently testable slice so implementation can be validated incrementally. -[SECTION_3_CONTENT] - +### V. Composable, Low-Boilerplate APIs + +- Shared behavior intended for more than one UI framework MUST be defined in a framework-agnostic layer before framework wrappers are added. +- React-facing APIs SHOULD prefer hook-first composition with thin wrapper components instead of forcing nested render-prop or mirror-state patterns for advanced screens. +- Derived screen state SHOULD remain a pure projection over authoritative live state rather than becoming a second client-side source of truth. + +## Architecture and Delivery Constraints + +- Architecture-affecting work MUST update the relevant design artifacts so specs, plans, tasks, and public contracts stay aligned with the intended implementation boundaries. +- Generated directories and generated SDK outputs MUST not be edited manually. +- External documentation paths that are out of workspace scope may be referenced in plans and tasks, but implementation work MUST call out when those updates cannot be validated locally. + +## Workflow and Quality Gates + +- Every plan MUST include a constitution check that maps the feature to these principles before implementation starts. +- When the constitution changes, active feature plans and tasks that rely on it MUST be reviewed and updated in the same change where practical. +- Complexity exceptions MUST be made explicit in the relevant plan instead of being implied by implementation details. +- Validation gates SHOULD use the narrowest executable check that can falsify the intended behavior before broader workspace checks are run. ## Governance - -[GOVERNANCE_RULES] - +- This constitution supersedes conflicting guidance in feature specs, plans, and task lists. +- `AGENTS.md` and `.github/copilot-instructions.md` provide operational guidance for day-to-day work, but they do not weaken the principles in this constitution. +- Amendments require updating this file, documenting the reason in the associated change, and realigning affected active planning artifacts when needed. +- Compliance with this constitution MUST be checked before `/speckit.implement` begins. -**Version**: [CONSTITUTION_VERSION] | **Ratified**: [RATIFICATION_DATE] | **Last Amended**: [LAST_AMENDED_DATE] - +**Version**: 1.0.0 | **Ratified**: 2026-05-07 | **Last Amended**: 2026-05-07 diff --git a/Cargo.lock b/Cargo.lock index d174b2947..8f83e30d2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -911,9 +911,9 @@ checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" [[package]] name = "bcrypt" -version = "0.19.0" +version = "0.19.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "523ab528ce3a7ada6597f8ccf5bd8d85ebe26d5edf311cad4d1d3cfb2d357ac6" +checksum = "24ae5479c93d3720e4c1dbd6b945b97457c50cb672781104768190371df1a905" dependencies = [ "base64", "blowfish", @@ -998,9 +998,9 @@ dependencies = [ [[package]] name = "blowfish" -version = "0.9.1" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e412e2cd0f2b2d93e02543ceae7917b3c70331573df19ee046bcbc35e45e87d7" +checksum = "62ce3946557b35e71d1bbe07ec385073ce9eda05043f95de134eb578fcf1a298" dependencies = [ "byteorder", "cipher", @@ -1273,11 +1273,11 @@ dependencies = [ [[package]] name = "cipher" -version = "0.4.4" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +checksum = "e34d8227fe1ba289043aeb13792056ff80fd6de1a9f49137a5f499de8e8c78ea" dependencies = [ - "crypto-common 0.1.7", + "crypto-common 0.2.1", "inout", ] @@ -3671,11 +3671,11 @@ dependencies = [ [[package]] name = "inout" -version = "0.1.4" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7" dependencies = [ - "generic-array", + "hybrid-array", ] [[package]] @@ -3805,9 +3805,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.97" +version = "0.3.98" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1840c94c045fbcf8ba2812c95db44499f7c64910a912551aaaa541decebcacf" +checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08" dependencies = [ "cfg-if", "futures-util", @@ -3832,7 +3832,7 @@ dependencies = [ [[package]] name = "kalam-cli" -version = "0.4.3-rc.4" +version = "0.5.0-beta.1" dependencies = [ "anyhow", "assert_cmd", @@ -3874,7 +3874,7 @@ dependencies = [ [[package]] name = "kalam-client" -version = "0.4.3-rc.4" +version = "0.5.0-beta.1" dependencies = [ "kalamdb-configs", "kalamdb-server", @@ -3889,7 +3889,7 @@ dependencies = [ [[package]] name = "kalam-consumer-wasm" -version = "0.4.3-rc.4" +version = "0.5.0-beta.1" dependencies = [ "base64", "js-sys", @@ -3903,7 +3903,7 @@ dependencies = [ [[package]] name = "kalam-link-dart" -version = "0.4.3-rc.4" +version = "0.5.0-beta.1" dependencies = [ "anyhow", "flutter_rust_bridge", @@ -3915,7 +3915,7 @@ dependencies = [ [[package]] name = "kalam-pg-api" -version = "0.4.3-rc.4" +version = "0.5.0-beta.1" dependencies = [ "arrow", "async-trait", @@ -3927,7 +3927,7 @@ dependencies = [ [[package]] name = "kalam-pg-client" -version = "0.4.3-rc.4" +version = "0.5.0-beta.1" dependencies = [ "arrow", "arrow-ipc", @@ -3947,7 +3947,7 @@ dependencies = [ [[package]] name = "kalam-pg-common" -version = "0.4.3-rc.4" +version = "0.5.0-beta.1" dependencies = [ "datafusion-common", "serde", @@ -3956,7 +3956,7 @@ dependencies = [ [[package]] name = "kalam-pg-extension" -version = "0.4.3-rc.4" +version = "0.5.0-beta.1" dependencies = [ "arrow", "async-trait", @@ -3987,7 +3987,7 @@ dependencies = [ [[package]] name = "kalam-pg-fdw" -version = "0.4.3-rc.4" +version = "0.5.0-beta.1" dependencies = [ "datafusion-common", "kalam-pg-api", @@ -3998,7 +3998,7 @@ dependencies = [ [[package]] name = "kalam-pg-types" -version = "0.4.3-rc.4" +version = "0.5.0-beta.1" dependencies = [ "kalam-pg-common", "kalamdb-commons", @@ -4006,7 +4006,7 @@ dependencies = [ [[package]] name = "kalamdb-api" -version = "0.4.3-rc.4" +version = "0.5.0-beta.1" dependencies = [ "actix-files", "actix-multipart", @@ -4049,7 +4049,7 @@ dependencies = [ [[package]] name = "kalamdb-auth" -version = "0.4.3-rc.4" +version = "0.5.0-beta.1" dependencies = [ "actix-web", "anyhow", @@ -4079,7 +4079,7 @@ dependencies = [ [[package]] name = "kalamdb-commons" -version = "0.4.3-rc.4" +version = "0.5.0-beta.1" dependencies = [ "arrow", "arrow-schema", @@ -4107,7 +4107,7 @@ dependencies = [ [[package]] name = "kalamdb-configs" -version = "0.4.3-rc.4" +version = "0.5.0-beta.1" dependencies = [ "anyhow", "ipnet", @@ -4118,7 +4118,7 @@ dependencies = [ [[package]] name = "kalamdb-core" -version = "0.4.3-rc.4" +version = "0.5.0-beta.1" dependencies = [ "anyhow", "arrow", @@ -4174,7 +4174,7 @@ dependencies = [ [[package]] name = "kalamdb-datafusion-sources" -version = "0.4.3-rc.4" +version = "0.5.0-beta.1" dependencies = [ "arrow", "arrow-schema", @@ -4192,7 +4192,7 @@ dependencies = [ [[package]] name = "kalamdb-dba" -version = "0.4.3-rc.4" +version = "0.5.0-beta.1" dependencies = [ "chrono", "datafusion", @@ -4210,7 +4210,7 @@ dependencies = [ [[package]] name = "kalamdb-dialect" -version = "0.4.3-rc.4" +version = "0.5.0-beta.1" dependencies = [ "anyhow", "arrow", @@ -4228,7 +4228,7 @@ dependencies = [ [[package]] name = "kalamdb-filestore" -version = "0.4.3-rc.4" +version = "0.5.0-beta.1" dependencies = [ "arrow", "bytes", @@ -4259,7 +4259,7 @@ dependencies = [ [[package]] name = "kalamdb-handlers" -version = "0.4.3-rc.4" +version = "0.5.0-beta.1" dependencies = [ "kalamdb-commons", "kalamdb-core", @@ -4274,7 +4274,7 @@ dependencies = [ [[package]] name = "kalamdb-handlers-admin" -version = "0.4.3-rc.4" +version = "0.5.0-beta.1" dependencies = [ "arrow", "chrono", @@ -4294,7 +4294,7 @@ dependencies = [ [[package]] name = "kalamdb-handlers-ddl" -version = "0.4.3-rc.4" +version = "0.5.0-beta.1" dependencies = [ "arrow", "chrono", @@ -4318,7 +4318,7 @@ dependencies = [ [[package]] name = "kalamdb-handlers-stream" -version = "0.4.3-rc.4" +version = "0.5.0-beta.1" dependencies = [ "chrono", "datafusion", @@ -4335,7 +4335,7 @@ dependencies = [ [[package]] name = "kalamdb-handlers-support" -version = "0.4.3-rc.4" +version = "0.5.0-beta.1" dependencies = [ "chrono", "datafusion", @@ -4352,7 +4352,7 @@ dependencies = [ [[package]] name = "kalamdb-handlers-user" -version = "0.4.3-rc.4" +version = "0.5.0-beta.1" dependencies = [ "chrono", "kalamdb-auth", @@ -4368,7 +4368,7 @@ dependencies = [ [[package]] name = "kalamdb-jobs" -version = "0.4.3-rc.4" +version = "0.5.0-beta.1" dependencies = [ "async-trait", "chrono", @@ -4396,7 +4396,7 @@ dependencies = [ [[package]] name = "kalamdb-live" -version = "0.4.3-rc.4" +version = "0.5.0-beta.1" dependencies = [ "arrow", "async-trait", @@ -4424,7 +4424,7 @@ dependencies = [ [[package]] name = "kalamdb-macros" -version = "0.4.3-rc.4" +version = "0.5.0-beta.1" dependencies = [ "proc-macro2", "quote", @@ -4433,7 +4433,7 @@ dependencies = [ [[package]] name = "kalamdb-observability" -version = "0.4.3-rc.4" +version = "0.5.0-beta.1" dependencies = [ "cc", "chrono", @@ -4446,7 +4446,7 @@ dependencies = [ [[package]] name = "kalamdb-pg" -version = "0.4.3-rc.4" +version = "0.5.0-beta.1" dependencies = [ "arrow", "arrow-ipc", @@ -4473,7 +4473,7 @@ dependencies = [ [[package]] name = "kalamdb-plan-cache" -version = "0.4.3-rc.4" +version = "0.5.0-beta.1" dependencies = [ "datafusion", "kalamdb-commons", @@ -4482,7 +4482,7 @@ dependencies = [ [[package]] name = "kalamdb-publisher" -version = "0.4.3-rc.4" +version = "0.5.0-beta.1" dependencies = [ "chrono", "dashmap 6.1.0", @@ -4499,7 +4499,7 @@ dependencies = [ [[package]] name = "kalamdb-raft" -version = "0.4.3-rc.4" +version = "0.5.0-beta.1" dependencies = [ "async-trait", "chrono", @@ -4535,7 +4535,7 @@ dependencies = [ [[package]] name = "kalamdb-server" -version = "0.4.3-rc.4" +version = "0.5.0-beta.1" dependencies = [ "actix-cors", "actix-web", @@ -4595,7 +4595,7 @@ dependencies = [ [[package]] name = "kalamdb-server-auth" -version = "0.4.3-rc.4" +version = "0.5.0-beta.1" dependencies = [ "log", "rcgen", @@ -4605,7 +4605,7 @@ dependencies = [ [[package]] name = "kalamdb-session" -version = "0.4.3-rc.4" +version = "0.5.0-beta.1" dependencies = [ "kalamdb-commons", "tokio", @@ -4613,7 +4613,7 @@ dependencies = [ [[package]] name = "kalamdb-session-datafusion" -version = "0.4.3-rc.4" +version = "0.5.0-beta.1" dependencies = [ "arrow", "async-trait", @@ -4626,7 +4626,7 @@ dependencies = [ [[package]] name = "kalamdb-sharding" -version = "0.4.3-rc.4" +version = "0.5.0-beta.1" dependencies = [ "kalamdb-commons", "kalamdb-configs", @@ -4635,7 +4635,7 @@ dependencies = [ [[package]] name = "kalamdb-store" -version = "0.4.3-rc.4" +version = "0.5.0-beta.1" dependencies = [ "anyhow", "async-trait", @@ -4657,7 +4657,7 @@ dependencies = [ [[package]] name = "kalamdb-streams" -version = "0.4.3-rc.4" +version = "0.5.0-beta.1" dependencies = [ "chrono", "dashmap 6.1.0", @@ -4671,7 +4671,7 @@ dependencies = [ [[package]] name = "kalamdb-system" -version = "0.4.3-rc.4" +version = "0.5.0-beta.1" dependencies = [ "arrow", "async-trait", @@ -4696,7 +4696,7 @@ dependencies = [ [[package]] name = "kalamdb-tables" -version = "0.4.3-rc.4" +version = "0.5.0-beta.1" dependencies = [ "arrow", "async-trait", @@ -4729,7 +4729,7 @@ dependencies = [ [[package]] name = "kalamdb-transactions" -version = "0.4.3-rc.4" +version = "0.5.0-beta.1" dependencies = [ "datafusion", "futures-util", @@ -4741,7 +4741,7 @@ dependencies = [ [[package]] name = "kalamdb-vector" -version = "0.4.3-rc.4" +version = "0.5.0-beta.1" dependencies = [ "async-trait", "bytes", @@ -4763,7 +4763,7 @@ dependencies = [ [[package]] name = "kalamdb-views" -version = "0.4.3-rc.4" +version = "0.5.0-beta.1" dependencies = [ "arrow", "async-trait", @@ -4949,7 +4949,7 @@ dependencies = [ [[package]] name = "link-common" -version = "0.4.3-rc.4" +version = "0.5.0-beta.1" dependencies = [ "aws-lc-rs", "base64", @@ -7868,9 +7868,9 @@ checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" [[package]] name = "tonic" -version = "0.14.5" +version = "0.14.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fec7c61a0695dc1887c1b53952990f3ad2e3a31453e1f49f10e75424943a93ec" +checksum = "ac2a5518c70fa84342385732db33fb3f44bc4cc748936eb5833d2df34d6445ef" dependencies = [ "async-trait", "axum", @@ -7899,9 +7899,9 @@ dependencies = [ [[package]] name = "tonic-prost" -version = "0.14.5" +version = "0.14.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a55376a0bbaa4975a3f10d009ad763d8f4108f067c7c2e74f3001fb49778d309" +checksum = "50849f68853be452acf590cde0b146665b8d507b3b8af17261df47e02c209ea0" dependencies = [ "bytes", "prost", @@ -8356,9 +8356,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.120" +version = "0.2.121" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df52b6d9b87e0c74c9edfa1eb2d9bf85e5d63515474513aa50fa181b3c4f5db1" +checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790" dependencies = [ "cfg-if", "once_cell", @@ -8369,9 +8369,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.70" +version = "0.4.71" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af934872acec734c2d80e6617bbb5ff4f12b052dd8e6332b0817bce889516084" +checksum = "96492d0d3ffba25305a7dc88720d250b1401d7edca02cc3bcd50633b424673b8" dependencies = [ "js-sys", "wasm-bindgen", @@ -8379,9 +8379,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.120" +version = "0.2.121" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78b1041f495fb322e64aca85f5756b2172e35cd459376e67f2a6c9dffcedb103" +checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -8389,9 +8389,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.120" +version = "0.2.121" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9dcd0ff20416988a18ac686d4d4d0f6aae9ebf08a389ff5d29012b05af2a1b41" +checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2" dependencies = [ "bumpalo", "proc-macro2", @@ -8402,9 +8402,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.120" +version = "0.2.121" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49757b3c82ebf16c57d69365a142940b384176c24df52a087fb748e2085359ea" +checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441" dependencies = [ "unicode-ident", ] @@ -8471,9 +8471,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.97" +version = "0.3.98" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2eadbac71025cd7b0834f20d1fe8472e8495821b4e9801eb0a60bd1f19827602" +checksum = "4b572dff8bcf38bad0fa19729c89bb5748b2b9b1d8be70cf90df697e3a8f32aa" dependencies = [ "js-sys", "wasm-bindgen", diff --git a/Cargo.toml b/Cargo.toml index 0f0671834..c4a34bbff 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -51,7 +51,7 @@ members = [ exclude = ["benchv2"] [workspace.package] -version = "0.4.3-rc.4" +version = "0.5.0-beta.1" edition = "2021" rust-version = "1.92" authors = ["KalamDB Team"] @@ -161,14 +161,14 @@ rmp = "0.8" rmp-serde = "1.3" # gRPC for Raft network layer -tonic = { version = "0.14.5" } -tonic-prost = "0.14.5" +tonic = { version = "0.14.6" } +tonic-prost = "0.14.6" tonic-build = "0.14.5" prost = "0.14.3" prost-types = "0.14.3" # Additional dependencies -bcrypt = "0.19.0" +bcrypt = "0.19.1" rand = "0.10.1" rcgen = "0.14.7" x509-parser = "0.18.1" @@ -218,12 +218,12 @@ storekey = "0.11" moka = { version = "0.12.15", features = ["future", "sync"] } ntest = "0.9.5" ipnet = "2.11.0" -wasm-bindgen = { version = "0.2.120" } -wasm-bindgen-futures = { version = "0.4.70" } -js-sys = { version = "0.3.97" } +wasm-bindgen = { version = "0.2.121" } +wasm-bindgen-futures = { version = "0.4.71" } +js-sys = { version = "0.3.98" } libc = "0.2.186" libmimalloc-sys = { version = "0.1.47", features = ["extended"] } -web-sys = { version = "0.3.97" } +web-sys = { version = "0.3.98" } tsify = { version = "0.5.6", default-features = false, features = ["js"] } serde-wasm-bindgen = "0.6.5" flate2 = "1.1.9" diff --git a/README.md b/README.md index b60fe40eb..878657783 100644 --- a/README.md +++ b/README.md @@ -233,3 +233,7 @@ CREATE TABLE chat.messages ( - Website: KalamDB is under active development and evolving quickly. + +## License + +Licensed under the Apache License, Version 2.0 (`Apache-2.0`). See [LICENSE.txt](LICENSE.txt) and [NOTICE](NOTICE). diff --git a/backend/README.md b/backend/README.md index b111a9454..104ad35f4 100644 --- a/backend/README.md +++ b/backend/README.md @@ -297,4 +297,4 @@ For details, see [KNOWN_ISSUES.md](./KNOWN_ISSUES.md). ## License -MIT OR Apache-2.0 +Licensed under the Apache License, Version 2.0 (`Apache-2.0`). See [../LICENSE.txt](../LICENSE.txt) and [../NOTICE](../NOTICE). diff --git a/backend/server.toml b/backend/server.toml index c9f7a4336..4fb320376 100644 --- a/backend/server.toml +++ b/backend/server.toml @@ -318,14 +318,7 @@ strict_ws_origin_check = false # Bind-to-all-interfaces still needs an explicit browser allowlist. # Add your public hostname(s) here for reverse-proxy or production deployments. allowed_origins = [ - "http://localhost:4173", - "http://127.0.0.1:4173", - "http://localhost:5173", - "http://127.0.0.1:5173", - "http://localhost:5174", - "http://127.0.0.1:5174", - "http://localhost:8080", - "http://127.0.0.1:8080", + "*", ] # Allowed HTTP methods (default: common REST methods) diff --git a/benchv2/README.md b/benchv2/README.md index ebd09dfdf..b5786cded 100644 --- a/benchv2/README.md +++ b/benchv2/README.md @@ -108,4 +108,5 @@ Contributions are welcome! Please follow the standard Git workflow: 3. Submit a pull request with a description of your changes. ## License -This project is licensed under the MIT License. See the LICENSE file for details. \ No newline at end of file + +Licensed under the Apache License, Version 2.0 (`Apache-2.0`). See [../LICENSE.txt](../LICENSE.txt) and [../NOTICE](../NOTICE). \ No newline at end of file diff --git a/cli/README.md b/cli/README.md index 3d6d4c562..1b8479e64 100644 --- a/cli/README.md +++ b/cli/README.md @@ -537,7 +537,7 @@ cargo build --release ## License -Same license as KalamDB main project. +Licensed under the Apache License, Version 2.0 (`Apache-2.0`). See [../LICENSE.txt](../LICENSE.txt) and [../NOTICE](../NOTICE). ## Contributing diff --git a/cli/src/session.rs b/cli/src/session.rs index f1e796e5f..9dff80084 100644 --- a/cli/src/session.rs +++ b/cli/src/session.rs @@ -1823,7 +1823,7 @@ impl CLISession { eprintln!("Press Ctrl+C (or 'q') to unsubscribe and return to CLI\n"); } - let mut subscription = self.client.subscribe_with_config(config).await?; + let mut subscription = self.client.live_events_with_config(config).await?; if self.animations { eprintln!("Subscription established (ID: {})", subscription.subscription_id()); @@ -1985,7 +1985,7 @@ impl CLISession { eprintln!(); } - let mut subscription = self.client.subscribe_with_config(config).await?; + let mut subscription = self.client.live_events_with_config(config).await?; if self.animations { eprintln!("Subscription established (ID: {})", subscription.subscription_id()); diff --git a/cli/tests/cluster/cluster_test_subscription_nodes.rs b/cli/tests/cluster/cluster_test_subscription_nodes.rs index 8fa16b5b0..ef9e08021 100644 --- a/cli/tests/cluster/cluster_test_subscription_nodes.rs +++ b/cli/tests/cluster/cluster_test_subscription_nodes.rs @@ -94,7 +94,7 @@ async fn subscribe_with_retry( ) -> SubscriptionManager { let mut last_error: Option = None; for attempt in 0..max_attempts { - let mut subscription = client.subscribe(query).await.expect("Failed to subscribe"); + let mut subscription = client.live_events(query).await.expect("Failed to subscribe"); if let Ok(Some(Ok(event))) = tokio::time::timeout(Duration::from_secs(5), subscription.next()).await @@ -379,7 +379,7 @@ fn cluster_test_subscription_multi_node_identical() { cluster_runtime().block_on(async { // Create subscription on leader only (Spec 021: leader-only reads) let leader_client = create_ws_client(&leader_url); - let mut subscription = leader_client.subscribe(&query).await.expect("Failed to subscribe"); + let mut subscription = leader_client.live_events(&query).await.expect("Failed to subscribe"); let received_count = Arc::new(std::sync::atomic::AtomicUsize::new(0)); // Insert multiple rows on leader @@ -609,7 +609,7 @@ fn cluster_test_subscription_user_table_any_node() { .expect("Failed to build client"); let query = format!("SELECT * FROM {}", full); - match client.subscribe(&query).await { + match client.live_events(&query).await { Ok(_sub) => { println!(" ✓ Node {} accepts user table subscription", idx); }, diff --git a/cli/tests/cluster/cluster_test_ws_follower.rs b/cli/tests/cluster/cluster_test_ws_follower.rs index 010e6d9ec..64682fb87 100644 --- a/cli/tests/cluster/cluster_test_ws_follower.rs +++ b/cli/tests/cluster/cluster_test_ws_follower.rs @@ -76,7 +76,7 @@ async fn subscribe_with_retry( ) -> SubscriptionManager { let mut last_error: Option = None; for attempt in 0..max_attempts { - let mut subscription = client.subscribe(query).await.expect("Failed to subscribe"); + let mut subscription = client.live_events(query).await.expect("Failed to subscribe"); if let Ok(Some(Ok(event))) = tokio::time::timeout(Duration::from_secs(5), subscription.next()).await diff --git a/cli/tests/common/mod.rs b/cli/tests/common/mod.rs index 3b73e15c8..f4276a036 100644 --- a/cli/tests/common/mod.rs +++ b/cli/tests/common/mod.rs @@ -4827,7 +4827,7 @@ impl SubscriptionListener { }; // Start subscription - let mut subscription = match client.subscribe(&query).await { + let mut subscription = match client.live_events(&query).await { Ok(s) => s, Err(e) => { let _ = event_tx.send(format!("ERROR: Failed to subscribe: {}", e)); diff --git a/cli/tests/connection/concurrent_ws_tests.rs b/cli/tests/connection/concurrent_ws_tests.rs index fcee42c8f..981ce75f5 100644 --- a/cli/tests/connection/concurrent_ws_tests.rs +++ b/cli/tests/connection/concurrent_ws_tests.rs @@ -99,7 +99,7 @@ fn test_concurrent_websocket_subscriptions() { }; let mut sub = match client - .subscribe_with_config(SubscriptionConfig::without_initial_data( + .live_events_with_config(SubscriptionConfig::without_initial_data( format!("conc_sub_{}", i), &query, )) @@ -278,7 +278,7 @@ fn test_rapid_connect_disconnect() { let subscribe_result = tokio::time::timeout( Duration::from_secs(8), - client.subscribe_with_config(SubscriptionConfig::without_initial_data( + client.live_events_with_config(SubscriptionConfig::without_initial_data( format!("rapid_{}", i), &query, )), diff --git a/cli/tests/smoke/security/smoke_test_security_access.rs b/cli/tests/smoke/security/smoke_test_security_access.rs index a07966789..15ecb3a5d 100644 --- a/cli/tests/smoke/security/smoke_test_security_access.rs +++ b/cli/tests/smoke/security/smoke_test_security_access.rs @@ -52,7 +52,7 @@ fn subscribe_as_user(username: &str, password: &str, query: &str) -> Result<(), .map_err(|e| format!("Failed to build runtime: {}", e))?; let subscribe_result = rt.block_on(async move { - let mut subscription = client.subscribe(query).await?; + let mut subscription = client.live_events(query).await?; // Wait for the first event: ACK means success, Error means permission denied. // The server sends permission errors as WebSocket error events, not as connection diff --git a/cli/tests/smoke/subscription/smoke_test_shared_table_subscription.rs b/cli/tests/smoke/subscription/smoke_test_shared_table_subscription.rs index 6e34f7883..ebedcbb00 100644 --- a/cli/tests/smoke/subscription/smoke_test_shared_table_subscription.rs +++ b/cli/tests/smoke/subscription/smoke_test_shared_table_subscription.rs @@ -37,7 +37,7 @@ fn try_subscribe_as_user(username: &str, password: &str, query: &str) -> Result< .map_err(|e| format!("Failed to build runtime: {}", e))?; let result = rt.block_on(async move { - let mut subscription = client.subscribe(query).await?; + let mut subscription = client.live_events(query).await?; // The server sends permission errors as WebSocket error events, not as connection // failures. We must read at least one event to detect the server's response. diff --git a/cli/tests/smoke/subscription/smoke_test_subscription_advanced.rs b/cli/tests/smoke/subscription/smoke_test_subscription_advanced.rs index c13d3d0d6..79a99d80a 100644 --- a/cli/tests/smoke/subscription/smoke_test_subscription_advanced.rs +++ b/cli/tests/smoke/subscription/smoke_test_subscription_advanced.rs @@ -109,7 +109,7 @@ impl SubscriptionListenerAdvanced { if let Some(opts) = options.as_ref() { config.options = Some(opts.clone()); } - match client.subscribe_with_config(config).await { + match client.live_events_with_config(config).await { Ok(s) => { subscription = Some(s); break; diff --git a/cli/tests/smoke/subscription/smoke_test_subscription_close.rs b/cli/tests/smoke/subscription/smoke_test_subscription_close.rs index 2494ef843..7648aa83b 100644 --- a/cli/tests/smoke/subscription/smoke_test_subscription_close.rs +++ b/cli/tests/smoke/subscription/smoke_test_subscription_close.rs @@ -101,7 +101,7 @@ fn smoke_subscription_explicit_close_removes_live_query() { let query_sql = format!("SELECT * FROM {} -- {}", full_clone, marker_clone); let cfg = SubscriptionConfig::new(format!("sub_{}", marker_clone), query_sql); - let mut sub = match client.subscribe_with_config(cfg).await { + let mut sub = match client.live_events_with_config(cfg).await { Ok(s) => s, Err(e) => { eprintln!("subscribe failed: {e}"); @@ -209,7 +209,7 @@ fn smoke_subscription_drop_removes_live_query() { let query_sql = format!("SELECT * FROM {} -- {}", full_clone, marker_clone); let cfg = SubscriptionConfig::new(format!("sub_{}", marker_clone), query_sql); - let mut sub = match client.subscribe_with_config(cfg).await { + let mut sub = match client.live_events_with_config(cfg).await { Ok(s) => s, Err(e) => { eprintln!("subscribe failed: {e}"); diff --git a/cli/tests/smoke/subscription/smoke_test_subscription_listing.rs b/cli/tests/smoke/subscription/smoke_test_subscription_listing.rs index 69f6e714e..dd7f788f0 100644 --- a/cli/tests/smoke/subscription/smoke_test_subscription_listing.rs +++ b/cli/tests/smoke/subscription/smoke_test_subscription_listing.rs @@ -78,7 +78,7 @@ fn smoke_subscription_listing_and_close_removes() { // 2) Subscribe let query_sql = format!("SELECT * FROM {}", full_clone); let cfg = SubscriptionConfig::new("sub_list_1".to_string(), query_sql.clone()); - let mut sub1 = client.subscribe_with_config(cfg).await.expect("subscribe 1"); + let mut sub1 = client.live_events_with_config(cfg).await.expect("subscribe 1"); // Wait for ack let ack_deadline = tokio::time::Instant::now() + Duration::from_secs(5); @@ -108,7 +108,7 @@ fn smoke_subscription_listing_and_close_removes() { // 4) Subscribe to a second query let cfg2 = SubscriptionConfig::new("sub_list_2".to_string(), query_sql.clone()); - let mut sub2 = client.subscribe_with_config(cfg2).await.expect("subscribe 2"); + let mut sub2 = client.live_events_with_config(cfg2).await.expect("subscribe 2"); // Wait for ack let ack_deadline2 = tokio::time::Instant::now() + Duration::from_secs(5); loop { @@ -208,7 +208,7 @@ fn smoke_subscription_listing_tracks_seq_id() { "sub_seq_1".to_string(), format!("SELECT * FROM {}", full_clone), ); - let mut sub = client.subscribe_with_config(cfg).await.expect("subscribe"); + let mut sub = client.live_events_with_config(cfg).await.expect("subscribe"); // Wait for ack let ack_deadline = tokio::time::Instant::now() + Duration::from_secs(5); diff --git a/cli/tests/smoke/subscription/smoke_test_subscription_multi_reconnect.rs b/cli/tests/smoke/subscription/smoke_test_subscription_multi_reconnect.rs index c7194ba5a..07a09cf78 100644 --- a/cli/tests/smoke/subscription/smoke_test_subscription_multi_reconnect.rs +++ b/cli/tests/smoke/subscription/smoke_test_subscription_multi_reconnect.rs @@ -132,11 +132,11 @@ fn smoke_subscription_multi_reconnect_parallel() { // Subscribe to BOTH queries (mirrors App.tsx with messages + agent_events). let mut sub_a = client - .subscribe_with_config(SubscriptionConfig::new(&sub_id_a, &query_a)) + .live_events_with_config(SubscriptionConfig::new(&sub_id_a, &query_a)) .await .expect("subscribe A"); let mut sub_b = client - .subscribe_with_config(SubscriptionConfig::new(&sub_id_b, &query_b)) + .live_events_with_config(SubscriptionConfig::new(&sub_id_b, &query_b)) .await .expect("subscribe B"); diff --git a/cli/tests/smoke/subscription/smoke_test_subscription_reconnect_resume.rs b/cli/tests/smoke/subscription/smoke_test_subscription_reconnect_resume.rs index abee3ec72..130a21f2e 100644 --- a/cli/tests/smoke/subscription/smoke_test_subscription_reconnect_resume.rs +++ b/cli/tests/smoke/subscription/smoke_test_subscription_reconnect_resume.rs @@ -146,7 +146,7 @@ fn smoke_subscription_reconnect_basic_resume() { let sub_id = format!("recon_basic_{}", ns); let mut sub = client - .subscribe_with_config(SubscriptionConfig::new(&sub_id, &query)) + .live_events_with_config(SubscriptionConfig::new(&sub_id, &query)) .await .expect("subscribe"); @@ -196,7 +196,7 @@ fn smoke_subscription_reconnect_basic_resume() { let sub_id2 = format!("recon_basic2_{}", ns); let mut sub2 = client - .subscribe_with_config(SubscriptionConfig::new(&sub_id2, &query)) + .live_events_with_config(SubscriptionConfig::new(&sub_id2, &query)) .await .expect("re-subscribe after reconnect"); @@ -281,7 +281,7 @@ fn smoke_subscription_resume_from_seq_id() { let sub_id = format!("recon_seq_{}", ns); let mut sub = client - .subscribe_with_config(SubscriptionConfig::new(&sub_id, &query)) + .live_events_with_config(SubscriptionConfig::new(&sub_id, &query)) .await .expect("subscribe"); @@ -356,7 +356,7 @@ fn smoke_subscription_resume_from_seq_id() { cfg2.options = Some(options); let mut sub2 = - client.subscribe_with_config(cfg2).await.expect("re-subscribe with from_seq_id"); + client.live_events_with_config(cfg2).await.expect("re-subscribe with from_seq_id"); // Gap rows must arrive (as catch-up initial data or change events). let resume_events = collect_until(&mut sub2, event_timeout(15), |evs| { diff --git a/cli/tests/smoke/usecases/smoke_test_batch_control.rs b/cli/tests/smoke/usecases/smoke_test_batch_control.rs index ea9a1dc3e..a89c1f24b 100644 --- a/cli/tests/smoke/usecases/smoke_test_batch_control.rs +++ b/cli/tests/smoke/usecases/smoke_test_batch_control.rs @@ -226,7 +226,7 @@ impl BatchSubscriptionListener { config.options = Some(opts); } - let mut subscription = match client.subscribe_with_config(config).await { + let mut subscription = match client.live_events_with_config(config).await { Ok(s) => s, Err(e) => { let _ = event_tx.send(format!("ERROR: Failed to subscribe: {}", e)); diff --git a/cli/tests/subscription/slow_subscriber.rs b/cli/tests/subscription/slow_subscriber.rs index 61887ea39..16ca03533 100644 --- a/cli/tests/subscription/slow_subscriber.rs +++ b/cli/tests/subscription/slow_subscriber.rs @@ -162,7 +162,7 @@ fn subscription_slow_consumer_initial_data() { let (events, hit_error) = rt.block_on(async { // Use a generous initial_data_timeout so slow processing doesn't kill us let client = slow_client(180, 180).expect("client"); - let mut sub = client.subscribe(&query).await.expect("subscribe"); + let mut sub = client.live_events(&query).await.expect("subscribe"); drain_with_delay( &mut sub, (total_rows + 5) as usize, @@ -242,7 +242,7 @@ fn subscription_3g_like_high_latency() { // Single runtime handles both phases: initial data and live change events let (initial_ok, change_found) = rt.block_on(async { let client = slow_client(60, 60).expect("client"); - let mut sub = client.subscribe(&query).await.expect("subscribe"); + let mut sub = client.live_events(&query).await.expect("subscribe"); // Wait 300ms (simulated RTT) before starting to read tokio::time::sleep(Duration::from_millis(300)).await; @@ -318,7 +318,7 @@ fn subscription_slow_consumer_concurrent_writes() { let (insert_count, error_count) = rt.block_on(async { let client = slow_client(180, 180).expect("client"); - let mut sub = client.subscribe(&query).await.expect("subscribe"); + let mut sub = client.live_events(&query).await.expect("subscribe"); // Drain initial empty Ack let (_, _) = @@ -405,7 +405,7 @@ fn subscription_reconnect_after_drop() { let (first_count, second_count) = rt.block_on(async { // ── First connection: read a couple events then drop abruptly ── let client1 = slow_client(60, 60).expect("client1"); - let mut sub1 = client1.subscribe(&query).await.expect("subscribe1"); + let mut sub1 = client1.live_events(&query).await.expect("subscribe1"); let (evs1, _) = drain_with_delay(&mut sub1, 3, Duration::from_millis(0), Duration::from_secs(10)).await; let c1 = evs1.len(); @@ -418,7 +418,7 @@ fn subscription_reconnect_after_drop() { // ── Second connection: full subscription, verify snapshot ── let client2 = slow_client(60, 60).expect("client2"); - let mut sub2 = client2.subscribe(&query).await.expect("subscribe2"); + let mut sub2 = client2.live_events(&query).await.expect("subscribe2"); let (evs2, hit_err2) = drain_with_delay(&mut sub2, 15, Duration::from_millis(0), Duration::from_secs(30)) .await; @@ -501,7 +501,7 @@ fn subscription_timeout_graceful_then_reconnect() { .expect("tight client"); // This might succeed quickly or fail — either is acceptable - match client_tight.subscribe(&query).await { + match client_tight.live_events(&query).await { Ok(mut sub) => { let (evs, _) = drain_with_delay( &mut sub, @@ -526,7 +526,7 @@ fn subscription_timeout_graceful_then_reconnect() { // ── Phase 2: normal timeouts – must succeed ── let client_normal = slow_client(30, 30).expect("normal client"); - let mut sub_normal = client_normal.subscribe(&query).await.expect("normal subscribe"); + let mut sub_normal = client_normal.live_events(&query).await.expect("normal subscribe"); let (evs_normal, hit_err) = drain_with_delay( &mut sub_normal, 40, @@ -630,7 +630,7 @@ fn subscription_multiple_concurrent_slow_subscribers() { }, }; - let mut sub = match client.subscribe(&q).await { + let mut sub = match client.live_events(&q).await { Ok(s) => s, Err(e) => { eprintln!("[SUB {}] subscribe error: {}", idx, e); @@ -757,7 +757,7 @@ fn subscription_large_initial_data_slow_batch_consumer() { ); let mut config = SubscriptionConfig::new(&sub_id, &query); config.options = Some(SubscriptionOptions::default().with_batch_size(8)); - let mut sub = client.subscribe_with_config(config).await.expect("subscribe"); + let mut sub = client.live_events_with_config(config).await.expect("subscribe"); // 50ms per event – slow 3G batch consumer drain_with_delay( @@ -833,7 +833,7 @@ fn subscription_repeated_reconnect_loop() { println!("[TEST] reconnect loop round {}/{}", round, RECONNECT_ROUNDS); let client = slow_client(30, 30).expect("client"); - let mut sub = client.subscribe(&query).await.expect("subscribe"); + let mut sub = client.live_events(&query).await.expect("subscribe"); // Read just a few events then drop abruptly (simulates connection reset) let (evs, hit_error) = @@ -900,7 +900,7 @@ fn subscription_stable_after_idle_pause() { let found = rt.block_on(async { let client = slow_client(30, 30).expect("client"); - let mut sub = client.subscribe(&query).await.expect("subscribe"); + let mut sub = client.live_events(&query).await.expect("subscribe"); // Drain initial snapshot let (_, _) = @@ -968,7 +968,7 @@ fn subscription_burst_then_slow_catchup() { let (received_burst, hit_error) = rt.block_on(async { let client = slow_client(180, 180).expect("client"); - let mut sub = client.subscribe(&query).await.expect("subscribe"); + let mut sub = client.live_events(&query).await.expect("subscribe"); // Drain empty initial Ack let (_, _) = diff --git a/docs/sdk/sdk.md b/docs/sdk/sdk.md index 8bcf3a6f2..2d156375b 100644 --- a/docs/sdk/sdk.md +++ b/docs/sdk/sdk.md @@ -1,8 +1,8 @@ # KalamDB TypeScript/JavaScript SDK -The official TypeScript/JavaScript SDK for KalamDB, built on top of a Rust → WASM core. +The official TypeScript/JavaScript SDK for KalamDB, built on top of a Rust -> WASM core. -Worker and topic-consumer APIs now live in the separate `@kalamdb/consumer` package. This page focuses on the app-facing `@kalamdb/client` surface. +Worker and topic-consumer APIs now live in the separate `@kalamdb/consumer` package. React live-query UI APIs live in `@kalamdb/react`, which wraps the app-facing `@kalamdb/client` surface. - **Tiny bundle size** with minimal dependencies - **Cross-platform**: Works in Node.js and browsers @@ -19,6 +19,13 @@ yarn add @kalamdb/client pnpm add @kalamdb/client ``` +For React live-query components and hooks: + +```bash +npm install @kalamdb/client @kalamdb/react react react-dom +npm install @kalamdb/orm drizzle-orm +``` + ## Building From Source (This Repo) This repo contains two related pieces: @@ -97,6 +104,58 @@ await unsubscribe(); await client.disconnect(); ``` +## React Live Queries + +`@kalamdb/react` provides `KalamProvider`, `LiveQuery`, `LiveQueries`, `useLiveQuery`, `useLiveQueries`, and `useLiveSelection`. It supports raw SQL mode and typed Drizzle mode through `@kalamdb/orm`. + +```tsx +import { KalamProvider, LiveQueries, useLiveSelection } from '@kalamdb/react'; +import { asc, eq } from 'drizzle-orm'; +import { createClient, Auth } from '@kalamdb/client'; +import { approvals, messages, toolCalls, typing } from './schema.generated'; + +const client = createClient({ + url: 'http://localhost:8080', + authProvider: async () => Auth.basic('admin', 'AdminPass123!'), +}); + +export function AssistantScreen({ conversationId }: { conversationId: string }) { + return ( + + eq(table.conversationId, conversationId), + orderBy: (table) => asc(table.createdAt), + deps: [conversationId], + }, + typing: { table: typing, where: (table) => eq(table.conversationId, conversationId), deps: [conversationId] }, + toolCalls: { table: toolCalls, where: (table) => eq(table.conversationId, conversationId), deps: [conversationId] }, + approvals: { table: approvals, where: (table) => eq(table.conversationId, conversationId), deps: [conversationId] }, + }} + > + {(live) => } + + + ); +} + +function AssistantBody({ live }) { + const assistant = useLiveSelection(live, (context) => ({ + messages: context.messages.rows, + typingUsers: context.typing.rows.map((row) => row.userName), + activeTools: context.toolCalls.rows.filter((row) => row.status !== 'completed'), + pendingApprovals: context.approvals.rows.filter((row) => row.status === 'pending'), + approve: (approvalId: string) => context.update(approvals, approvalId).set({ status: 'approved' }), + })); + + return ; +} +``` + +The repo includes [../../examples/react-ai-chat](../../examples/react-ai-chat), a runnable React validation app with conversation sidebar, history loading, multi-file messages, typing, tool activity, streamed replies, edit/cancel actions, and human approvals. + ## API Reference ### Creating a Client diff --git a/examples/chat-with-ai/README.md b/examples/chat-with-ai/README.md index 3a623ccb0..2f7e4c2b2 100644 --- a/examples/chat-with-ai/README.md +++ b/examples/chat-with-ai/README.md @@ -154,4 +154,4 @@ If port 5173 is busy, Vite prints the next available local URL. Use the URL from ## License -Part of the KalamDB project. See the repository root for license details. +Licensed under the Apache License, Version 2.0 (`Apache-2.0`). See [../../LICENSE.txt](../../LICENSE.txt) and [../../NOTICE](../../NOTICE). diff --git a/examples/chat-with-ai/scripts/ensure-sdk.sh b/examples/chat-with-ai/scripts/ensure-sdk.sh index 53a081c06..eaf48ba8e 100755 --- a/examples/chat-with-ai/scripts/ensure-sdk.sh +++ b/examples/chat-with-ai/scripts/ensure-sdk.sh @@ -8,22 +8,32 @@ TYPESCRIPT_SDK_DIR="$(cd "$PROJECT_DIR/../../link/sdks/typescript" && pwd)" CLIENT_DIR="$TYPESCRIPT_SDK_DIR/client" CONSUMER_DIR="$TYPESCRIPT_SDK_DIR/consumer" ORM_DIR="$TYPESCRIPT_SDK_DIR/orm" +CLIENT_CORE_DIR="$(cd "$PROJECT_DIR/../../link/kalam-client" && pwd)" CLIENT_ENTRY="$CLIENT_DIR/dist/src/index.js" CLIENT_WASM="$CLIENT_DIR/dist/wasm/kalam_client_bg.wasm" CONSUMER_ENTRY="$CONSUMER_DIR/dist/src/index.js" CONSUMER_WASM="$CONSUMER_DIR/dist/wasm/kalam_consumer_bg.wasm" ORM_ENTRY="$ORM_DIR/dist/index.js" +client_needs_build=false orm_needs_build=false +if [ ! -f "$CLIENT_ENTRY" ] || [ ! -f "$CLIENT_WASM" ]; then + client_needs_build=true +elif find "$CLIENT_DIR/src" "$CLIENT_CORE_DIR/src" -type f \( -name '*.ts' -o -name '*.rs' \) -newer "$CLIENT_ENTRY" | grep -q .; then + client_needs_build=true +elif find "$CLIENT_DIR/src" "$CLIENT_CORE_DIR/src" -type f \( -name '*.ts' -o -name '*.rs' \) -newer "$CLIENT_WASM" | grep -q .; then + client_needs_build=true +fi + if [ ! -f "$ORM_ENTRY" ]; then orm_needs_build=true elif find "$ORM_DIR/src" -type f -newer "$ORM_ENTRY" | grep -q .; then orm_needs_build=true fi -if [ ! -f "$CLIENT_ENTRY" ] || [ ! -f "$CLIENT_WASM" ]; then - echo "@kalamdb/client is not compiled. Building now..." +if [ "$client_needs_build" = true ]; then + echo "@kalamdb/client needs a rebuild. Building now..." echo "" cd "$CLIENT_DIR" bash build.sh diff --git a/examples/chat-with-ai/src/App.tsx b/examples/chat-with-ai/src/App.tsx index 3268bcc2f..b565cf18a 100644 --- a/examples/chat-with-ai/src/App.tsx +++ b/examples/chat-with-ai/src/App.tsx @@ -4,14 +4,14 @@ import { Auth, ChangeType, MessageType, + type RowData, createClient, type SubscriptionErrorEvent, } from '@kalamdb/client'; import { eq } from 'drizzle-orm'; -import { kalamDriver, liveTable, subscribeTable, type TableSubscriptionEvent } from '@kalamdb/orm'; +import { kalamDriver, liveTable } from '@kalamdb/orm'; import { drizzle } from 'drizzle-orm/pg-proxy'; import { - chat_demo_agent_events as agentEvents, chat_demo_agent_eventsConfig as agentEventsConfig, chat_demo_messages as chatMessages, type ChatDemoAgentEvents as AgentEventRow, @@ -37,6 +37,11 @@ const timeFormatter = new Intl.DateTimeFormat(undefined, { second: '2-digit', }); +function resolveBrowserWasmUrl(): string { + const base = '/wasm/kalam_client_bg.wasm'; + return import.meta.env.DEV ? `${base}?t=${Date.now()}` : base; +} + function createAuthedClient() { return createClient({ url: import.meta.env.VITE_KALAMDB_URL ?? 'http://127.0.0.1:8080', @@ -45,6 +50,7 @@ function createAuthedClient() { import.meta.env.VITE_KALAMDB_PASSWORD ?? 'kalamdb123', ), disableCompression: true, + wasmUrl: resolveBrowserWasmUrl(), }); } @@ -145,6 +151,28 @@ function deriveFallbackDraft(messages: ChatMessageRow[]): LiveDraft | null { return null; } +function sqlLiteral(value: string): string { + return `'${value.replace(/'/g, "''")}'`; +} + +function mapAgentEventRow(row: RowData): AgentEventRow { + return { + _seq: row._seq?.asSeqId()?.toString() ?? row._seq?.asString() ?? null, + id: row.id?.asString() ?? '', + response_id: row.response_id?.asString() ?? '', + room: row.room?.asString() ?? '', + sender_username: row.sender_username?.asString() ?? '', + stage: row.stage?.asString() ?? '', + preview: row.preview?.asString() ?? '', + message: row.message?.asString() ?? '', + created_at: row.created_at?.asDate() ?? new Date(0), + }; +} + +function mapAgentEventRows(rows: RowData[] | undefined): AgentEventRow[] { + return (rows ?? []).map(mapAgentEventRow); +} + export function App() { const [messages, setMessages] = useState([]); const [events, setEvents] = useState([]); @@ -167,7 +195,7 @@ export function App() { }); }; - const handleEventSubscription = (event: TableSubscriptionEvent): void => { + const handleEventSubscription = (event: { type: string; rows?: RowData[]; old_values?: RowData[]; code?: string; message?: string; change_type?: string }): void => { if (!active) { return; } @@ -183,7 +211,7 @@ export function App() { } if (event.type === MessageType.InitialDataBatch) { - publishEvents(upsertEvents(bufferedEvents, event.rows ?? [])); + publishEvents(upsertEvents(bufferedEvents, mapAgentEventRows(event.rows))); return; } @@ -192,16 +220,16 @@ export function App() { } if (event.change_type === ChangeType.Delete) { - publishEvents(removeEvents(bufferedEvents, event.old_values ?? [])); + publishEvents(removeEvents(bufferedEvents, mapAgentEventRows(event.old_values))); return; } let nextEvents = bufferedEvents; if (event.change_type === ChangeType.Update) { - nextEvents = removeEvents(nextEvents, event.old_values ?? []); + nextEvents = removeEvents(nextEvents, mapAgentEventRows(event.old_values)); } - publishEvents(upsertEvents(nextEvents, event.rows ?? [])); + publishEvents(upsertEvents(nextEvents, mapAgentEventRows(event.rows))); }; const start = async (): Promise => { @@ -217,11 +245,11 @@ export function App() { }, { where: eq(chatMessages.room, ROOM), - // `last_rows` asks the server for a rewind window at subscribe time. + // `lastRows` asks the server for a rewind window at subscribe time. // `limit` keeps the materialized client-side live state bounded // after that rewind and across later live changes. limit: MAX_CHAT_MESSAGES, - subscriptionOptions: { last_rows: MAX_CHAT_MESSAGES }, + lastRows: MAX_CHAT_MESSAGES, onError: (event: SubscriptionErrorEvent) => { if (!active) { return; @@ -235,13 +263,11 @@ export function App() { // The draft rail keeps raw protocol frames so rapid typing bursts can // be reconciled locally instead of waiting for a full live-row view. - const eventsUnsubscribe = await subscribeTable( - client, - agentEvents, + const eventsUnsubscribe = await client.liveEvents( + `SELECT * FROM chat_demo.agent_events WHERE room = ${sqlLiteral(ROOM)}`, handleEventSubscription, { - where: eq(agentEvents.room, ROOM), - last_rows: MAX_AGENT_EVENTS, + lastRows: MAX_AGENT_EVENTS, }, ); unsubscribers.push(eventsUnsubscribe); diff --git a/examples/chat-with-ai/tests/chat.spec.mjs b/examples/chat-with-ai/tests/chat.spec.mjs index df47de40d..db882eeb5 100644 --- a/examples/chat-with-ai/tests/chat.spec.mjs +++ b/examples/chat-with-ai/tests/chat.spec.mjs @@ -15,6 +15,14 @@ const adminPassword = 'kalamdb123'; let agentProcess; const testGroup = `chat-demo-test-${Date.now()}`; +function appendNodeOption(existing, flag) { + if (!existing) { + return flag; + } + + return existing.split(/\s+/).includes(flag) ? existing : `${existing} ${flag}`; +} + async function login(user, password) { const response = await fetch(`${serverUrl}/v1/api/auth/login`, { method: 'POST', @@ -334,6 +342,7 @@ test.beforeAll(async () => { ...process.env, KALAMDB_GROUP: testGroup, KALAMDB_START: 'latest', + NODE_OPTIONS: appendNodeOption(process.env.NODE_OPTIONS, '--preserve-symlinks'), }, }); @@ -584,10 +593,10 @@ test('subscription can disconnect and resume from the saved checkpoint without r let resumedUnsubscribe; try { - initialUnsubscribe = await client.subscribeWithSql( + initialUnsubscribe = await client.liveEvents( sql, (event) => preEvents.push(event), - { last_rows: 0 }, + { lastRows: 0 }, ); await waitFor( @@ -614,10 +623,10 @@ test('subscription can disconnect and resume from the saved checkpoint without r await insertAssistantMessages(resumeRoom, [gapContent]); - resumedUnsubscribe = await client.subscribeWithSql( + resumedUnsubscribe = await client.liveEvents( sql, (event) => resumedEvents.push(event), - { from: checkpoint, last_rows: 0 }, + { from: checkpoint, lastRows: 0 }, ); expect(client.isConnected()).toBe(true); diff --git a/examples/react-ai-chat/.gitignore b/examples/react-ai-chat/.gitignore new file mode 100644 index 000000000..b3d832416 --- /dev/null +++ b/examples/react-ai-chat/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +dist/ +.env +.env.local +*.log \ No newline at end of file diff --git a/examples/react-ai-chat/README.md b/examples/react-ai-chat/README.md new file mode 100644 index 000000000..a6f1e1f6d --- /dev/null +++ b/examples/react-ai-chat/README.md @@ -0,0 +1,48 @@ +# React AI Chat Example + +This example is a chat-application validation surface for `@kalamdb/react`. It keeps the browser code in [src/app](src/app) and the topic worker in [src/agent](src/agent), then demonstrates conversations, websocket-confirmed sends, streamed typing tokens, final assistant inserts, file attachments, and approval actions with small, readable components. + +The example setup is intentionally simple: one SQL file and one setup script. The setup script uses the installed `kalam` CLI, clears the two example topics if they already exist, imports [chat-app.sql](chat-app.sql), then writes `.env.local` for the browser and agent. + +## Quick Start + +```bash +npm install +npm run setup +npm run agent +npm run dev +``` + +Open the Vite URL, usually `http://127.0.0.1:5176`. + +`npm run setup` clears the example topics if they already exist, runs `kalam --file chat-app.sql`, creates the tables and topics, seeds the default conversation, and writes `.env.local` with `VITE_KALAMDB_DEMO_MODE=false`. + +If you need non-default credentials, set `KALAMDB_URL`, `KALAMDB_USER`, and `KALAMDB_PASSWORD` before running the script. + +Send a message like: + +```text +customer refund needs approval +``` + +The agent will create approval rows, stream typing tokens, and continue after approval actions. + +If you want browser-only demo mode instead of the server-backed flow, skip `npm run setup` and set `VITE_KALAMDB_DEMO_MODE=true` in `.env.local`. + +## Files Worth Reading + +- [src/app/App.tsx](src/app/App.tsx): `KalamProvider` and `LiveQueries` orchestration. +- [src/app/components/Aside.tsx](src/app/components/Aside.tsx): conversation creation and selection. +- [src/app/components/Conversation.tsx](src/app/components/Conversation.tsx): sticky header, scrollable messages, sticky composer. +- [src/app/components/ChatComposer.tsx](src/app/components/ChatComposer.tsx): attach-file and enter-to-send composer. +- [src/agent/index.ts](src/agent/index.ts): deterministic topic-agent worker. +- [chat-app.sql](chat-app.sql): full namespace reset, table/topic creation, and seed data. + +## Validation + +```bash +npm run build +npm test +``` + +The package scripts build the local TypeScript SDKs first if their `dist` folders are missing. \ No newline at end of file diff --git a/examples/react-ai-chat/chat-app.sql b/examples/react-ai-chat/chat-app.sql new file mode 100644 index 000000000..c17be402c --- /dev/null +++ b/examples/react-ai-chat/chat-app.sql @@ -0,0 +1,72 @@ +CREATE NAMESPACE IF NOT EXISTS react_ai_chat; + +DROP TABLE IF EXISTS react_ai_chat.approval_actions; +DROP TABLE IF EXISTS react_ai_chat.typing_tokens; +DROP TABLE IF EXISTS react_ai_chat.approvals; +DROP TABLE IF EXISTS react_ai_chat.messages; +DROP TABLE IF EXISTS react_ai_chat.conversations; + +CREATE TABLE IF NOT EXISTS react_ai_chat.conversations ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL, + summary TEXT NOT NULL DEFAULT '', + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW() +) WITH (TYPE = 'USER'); + +CREATE TABLE IF NOT EXISTS react_ai_chat.messages ( + id BIGINT PRIMARY KEY DEFAULT SNOWFLAKE_ID(), + client_id TEXT, + conversation_id TEXT NOT NULL, + reply_to_message_id TEXT, + role TEXT NOT NULL, + body TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'sent', + attachment FILE, + approval_id TEXT, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW() +) WITH (TYPE = 'USER'); + +CREATE TABLE IF NOT EXISTS react_ai_chat.approvals ( + id TEXT PRIMARY KEY, + conversation_id TEXT NOT NULL, + message_id TEXT NOT NULL, + title TEXT NOT NULL, + body TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW() +) WITH (TYPE = 'USER'); + +CREATE TABLE IF NOT EXISTS react_ai_chat.typing_tokens ( + id BIGINT PRIMARY KEY DEFAULT SNOWFLAKE_ID(), + conversation_id TEXT NOT NULL, + message_id TEXT NOT NULL, + status TEXT NOT NULL, + token TEXT NOT NULL DEFAULT '', + created_at TIMESTAMP NOT NULL DEFAULT NOW() +) WITH (TYPE = 'STREAM', TTL_SECONDS = 120); + +CREATE TABLE IF NOT EXISTS react_ai_chat.approval_actions ( + id BIGINT PRIMARY KEY DEFAULT SNOWFLAKE_ID(), + approval_id TEXT NOT NULL, + conversation_id TEXT NOT NULL, + action TEXT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT NOW() +) WITH (TYPE = 'USER'); + +CREATE TOPIC react_ai_chat.agent_messages; +ALTER TOPIC react_ai_chat.agent_messages ADD SOURCE react_ai_chat.messages ON INSERT; + +CREATE TOPIC react_ai_chat.agent_actions; +ALTER TOPIC react_ai_chat.agent_actions ADD SOURCE react_ai_chat.approval_actions ON INSERT; + +INSERT INTO react_ai_chat.conversations (id, title, summary) +VALUES ('project-alpha', 'Project Alpha', 'Data analysis with approval-gated database migration'); + +INSERT INTO react_ai_chat.approvals (id, conversation_id, message_id, title, body, status) +VALUES ('approval-project-alpha', 'project-alpha', '1001', 'Action Required', 'Run database migration for Project Alpha?', 'pending'); + +INSERT INTO react_ai_chat.messages (id, client_id, conversation_id, role, body, status, approval_id) +VALUES (1001, NULL, 'project-alpha', 'assistant', 'I analyzed the historical datasets and generated the quarterly summary chart. The European market outliers are isolated and ready for review.', 'complete', 'approval-project-alpha'); \ No newline at end of file diff --git a/examples/react-ai-chat/index.html b/examples/react-ai-chat/index.html new file mode 100644 index 000000000..dd989b306 --- /dev/null +++ b/examples/react-ai-chat/index.html @@ -0,0 +1,12 @@ + + + + + + KalamDB React AI Chat + + +
+ + + \ No newline at end of file diff --git a/examples/react-ai-chat/package.json b/examples/react-ai-chat/package.json new file mode 100644 index 000000000..6b7350bf1 --- /dev/null +++ b/examples/react-ai-chat/package.json @@ -0,0 +1,42 @@ +{ + "name": "kalamdb-react-ai-chat", + "version": "1.0.0", + "private": true, + "type": "module", + "description": "KalamDB React live-query AI chat example with conversations, files, typing, tools, and approvals", + "scripts": { + "presetup": "bash scripts/ensure-sdk.sh", + "setup": "bash setup.sh", + "pregenerate:schema": "bash scripts/ensure-sdk.sh", + "generate:schema": "bash scripts/generate-schema.sh", + "predev": "bash scripts/ensure-sdk.sh", + "dev": "vite --host 127.0.0.1", + "pretest": "bash scripts/ensure-sdk.sh", + "prebuild": "bash scripts/ensure-sdk.sh", + "build": "tsc && vite build", + "preview": "vite preview --host 127.0.0.1", + "preagent": "bash scripts/ensure-sdk.sh", + "agent": "tsx src/agent/index.ts", + "test": "tsc && tsx --test tests/agent.test.ts && node --test tests/chat.spec.mjs" + }, + "dependencies": { + "@kalamdb/client": "file:../../link/sdks/typescript/client", + "@kalamdb/consumer": "file:../../link/sdks/typescript/consumer", + "@kalamdb/orm": "file:../../link/sdks/typescript/orm", + "@kalamdb/react": "file:../../link/sdks/typescript/react", + "dotenv": "^17.4.2", + "drizzle-orm": "^0.45.2", + "lucide-react": "^1.14.0", + "react": "^19.2.5", + "react-dom": "^19.2.5" + }, + "devDependencies": { + "@types/node": "^25.6.0", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.1", + "tsx": "^4.21.0", + "typescript": "^6.0.3", + "vite": "^8.0.10" + } +} \ No newline at end of file diff --git a/examples/react-ai-chat/scripts/ensure-sdk.sh b/examples/react-ai-chat/scripts/ensure-sdk.sh new file mode 100644 index 000000000..0d15482cc --- /dev/null +++ b/examples/react-ai-chat/scripts/ensure-sdk.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)" + +ensure_package() { + local dir="$1" + local build_cmd="$2" + if [ ! -d "$ROOT_DIR/$dir/dist" ]; then + (cd "$ROOT_DIR/$dir" && npm install --no-package-lock && npm run "$build_cmd") + fi +} + +ensure_package "link/sdks/typescript/client" "build:ts" +ensure_package "link/sdks/typescript/orm" "build" +ensure_package "link/sdks/typescript/react" "build" \ No newline at end of file diff --git a/examples/react-ai-chat/scripts/generate-schema.sh b/examples/react-ai-chat/scripts/generate-schema.sh new file mode 100644 index 000000000..bec35bf84 --- /dev/null +++ b/examples/react-ai-chat/scripts/generate-schema.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +set -euo pipefail + +echo "examples/react-ai-chat uses the checked-in src/app/schema.generated.ts for quick local runs." +echo "For a live server, run chat-app.sql, then regenerate with kalamdb-orm if you want schema drift checks." \ No newline at end of file diff --git a/examples/react-ai-chat/setup.sh b/examples/react-ai-chat/setup.sh new file mode 100755 index 000000000..3ebaca9c6 --- /dev/null +++ b/examples/react-ai-chat/setup.sh @@ -0,0 +1,60 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SERVER_URL="${KALAMDB_URL:-http://127.0.0.1:8080}" +USER="${KALAMDB_USER:-admin}" +PASSWORD="${KALAMDB_PASSWORD:-kalamdb123}" +SQL_FILE="$SCRIPT_DIR/chat-app.sql" +ENV_FILE="$SCRIPT_DIR/.env.local" + +fail() { + echo "[setup][error] $*" >&2 + exit 1 +} + +require_cmd() { + command -v "$1" >/dev/null 2>&1 || fail "Missing required command: $1" +} + +run_kalam() { + kalam \ + --url "$SERVER_URL" \ + --user "$USER" \ + --password "$PASSWORD" \ + --no-spinner \ + "$@" +} + +drop_topic_if_present() { + local topic="$1" + if ! run_kalam --command "DROP TOPIC $topic" >/dev/null 2>&1; then + : + fi +} + +echo "Building local SDK packages..." +bash scripts/ensure-sdk.sh + +require_cmd kalam + +echo "Clearing prior example topics if they exist..." +drop_topic_if_present "react_ai_chat.agent_actions" +drop_topic_if_present "react_ai_chat.agent_messages" + +echo "Importing $(basename "$SQL_FILE") with kalam CLI..." +run_kalam --file "$SQL_FILE" + +cat > "$ENV_FILE" <; + +function field(row: TopicRow, key: string): string { + const value = row[key]; + return typeof value === 'string' ? value : value == null ? '' : String(value); +} + +function validUser(user: string): string { + if (!/^[A-Za-z0-9._-]+$/.test(user)) { + throw new Error(`Unsupported KalamDB user: ${user}`); + } + return user; +} + +async function sleep(ms: number): Promise { + await new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function runSqlAsUser(client: KalamDBClient, sql: string, params?: unknown[]): Promise { + await client.executeAsUser(sql, validUser(KALAMDB_USER), params); +} + +async function insertTypingToken( + client: KalamDBClient, + conversationId: string, + messageId: string, + status: string, + token: string, +): Promise { + await runSqlAsUser( + client, + 'INSERT INTO react_ai_chat.typing_tokens (conversation_id, message_id, status, token) VALUES ($1, $2, $3, $4)', + [conversationId, messageId, status, token], + ); +} + +async function insertAssistantMessage( + client: KalamDBClient, + values: { + clientId?: string; + conversationId: string; + replyToMessageId: string; + body: string; + status: string; + approvalId?: string; + }, +): Promise { + await runSqlAsUser( + client, + 'INSERT INTO react_ai_chat.messages (client_id, conversation_id, reply_to_message_id, role, body, status, approval_id) VALUES ($1, $2, $3, $4, $5, $6, $7)', + [ + values.clientId ?? null, + values.conversationId, + values.replyToMessageId, + 'assistant', + values.body, + values.status, + values.approvalId ?? null, + ], + ); +} + +async function insertApproval( + client: KalamDBClient, + values: { + id: string; + conversationId: string; + messageId: string; + title: string; + body: string; + }, +): Promise { + await runSqlAsUser( + client, + 'INSERT INTO react_ai_chat.approvals (id, conversation_id, message_id, title, body, status) VALUES ($1, $2, $3, $4, $5, $6)', + [values.id, values.conversationId, values.messageId, values.title, values.body, 'pending'], + ); +} + +async function updateApproval(client: KalamDBClient, approvalId: string, status: string): Promise { + await runSqlAsUser( + client, + 'UPDATE react_ai_chat.approvals SET status = $1, updated_at = NOW() WHERE id = $2', + [status, approvalId], + ); +} + +async function readApproval(client: KalamDBClient, approvalId: string): Promise { + const rows = await client.queryAll( + `EXECUTE AS USER '${validUser(KALAMDB_USER)}' (SELECT * FROM react_ai_chat.approvals WHERE id = $1)`, + [approvalId], + ); + const row = rows[0]; + if (!row) { + return null; + } + + return Object.fromEntries(Object.entries(row).map(([key, value]) => [key, cellString(value)])); +} + +async function streamThenInsertReply( + client: KalamDBClient, + conversationId: string, + replyToMessageId: string, + body: string, +): Promise { + const draftMessageId = `draft-${replyToMessageId}-${Date.now()}`; + await insertTypingToken(client, conversationId, draftMessageId, 'thinking', 'Thinking through the next step. '); + await sleep(STREAM_DELAY_MS); + + for (const token of splitIntoTokenChunks(body)) { + await insertTypingToken(client, conversationId, draftMessageId, 'typing', token); + await sleep(STREAM_DELAY_MS); + } + + await insertTypingToken(client, conversationId, draftMessageId, 'saving', 'Saving final answer.'); + await insertAssistantMessage(client, { + clientId: draftMessageId, + conversationId, + replyToMessageId, + body, + status: 'complete', + }); +} + +async function handleUserMessage(client: KalamDBClient, row: TopicRow): Promise { + if (field(row, 'role') !== 'user' || field(row, 'status') !== 'sent') { + return; + } + + const messageId = field(row, 'id'); + const conversationId = field(row, 'conversation_id'); + const body = field(row, 'body'); + const plan = createToolPlan(body); + + if (plan.requiresApproval) { + const approvalId = `approval-${messageId}`; + await insertApproval(client, { + id: approvalId, + conversationId, + messageId, + title: plan.approvalTitle, + body: plan.approvalBody, + }); + await insertAssistantMessage(client, { + conversationId, + replyToMessageId: messageId, + body: buildApprovalMessage(body), + status: 'awaiting_approval', + approvalId, + }); + return; + } + + await streamThenInsertReply(client, conversationId, messageId, buildAssistantReply(body)); +} + +async function handleApprovalAction(client: KalamDBClient, row: TopicRow): Promise { + const action = field(row, 'action'); + const approvalId = field(row, 'approval_id'); + const conversationId = field(row, 'conversation_id'); + const approval = await readApproval(client, approvalId); + const sourceMessageId = field(approval ?? {}, 'message_id') || approvalId; + + if (action === 'declined') { + await updateApproval(client, approvalId, 'declined'); + await insertAssistantMessage(client, { + conversationId, + replyToMessageId: sourceMessageId, + body: 'Approval was declined, so I stopped the action and left the workspace unchanged.', + status: 'complete', + }); + return; + } + + await updateApproval(client, approvalId, 'approved'); + await streamThenInsertReply(client, conversationId, sourceMessageId, buildAssistantReply('approval granted')); +} + +export async function startReactAiChatAgent(stopSignal?: AbortSignal): Promise { + const client = createConsumerClient({ + url: KALAMDB_URL, + authProvider: async () => Auth.basic(KALAMDB_USER, KALAMDB_PASSWORD), + }); + const sqlClient = client as unknown as KalamDBClient; + + await Promise.all([ + runAgent({ + client, + name: 'react-ai-chat-message-agent', + topic: MESSAGE_TOPIC, + groupId: process.env.KALAMDB_GROUP ?? 'react-ai-chat-message-agent', + start: 'earliest', + batchSize: 10, + timeoutSeconds: 30, + stopSignal, + onRow: async (_ctx, row) => handleUserMessage(sqlClient, row), + }), + runAgent({ + client, + name: 'react-ai-chat-action-agent', + topic: ACTION_TOPIC, + groupId: process.env.KALAMDB_ACTION_GROUP ?? 'react-ai-chat-action-agent', + start: 'earliest', + batchSize: 10, + timeoutSeconds: 30, + stopSignal, + onRow: async (_ctx, row) => handleApprovalAction(sqlClient, row), + }), + ]); +} + +function cellString(value: unknown): string { + if (value && typeof value === 'object' && 'asString' in value && typeof value.asString === 'function') { + return value.asString() ?? ''; + } + return value == null ? '' : String(value); +} + +if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) { + const controller = new AbortController(); + process.on('SIGINT', () => controller.abort()); + process.on('SIGTERM', () => controller.abort()); + + startReactAiChatAgent(controller.signal).catch((error) => { + console.error('react-ai-chat-agent failed', error); + process.exit(1); + }); +} \ No newline at end of file diff --git a/examples/react-ai-chat/src/agent/logic.ts b/examples/react-ai-chat/src/agent/logic.ts new file mode 100644 index 000000000..495c6ce5f --- /dev/null +++ b/examples/react-ai-chat/src/agent/logic.ts @@ -0,0 +1,67 @@ +export type ToolPlan = { + toolName: string; + requiresApproval: boolean; + approvalTitle: string; + approvalBody: string; +}; + +export function createToolPlan(message: string): ToolPlan { + const lower = message.toLowerCase(); + const requiresApproval = lower.includes('migrate') + || lower.includes('database') + || lower.includes('customer') + || lower.includes('refund') + || lower.includes('approval'); + + if (requiresApproval) { + return { + toolName: 'human_approval', + requiresApproval: true, + approvalTitle: 'Action Required', + approvalBody: lower.includes('project alpha') + ? 'Run database migration for Project Alpha?' + : 'Approve the assistant before it performs this customer-facing or data-changing step.', + }; + } + + return { + toolName: lower.includes('deploy') || lower.includes('release') + ? 'release_lookup' + : lower.includes('analysis') || lower.includes('outlier') ? 'analysis_sandbox' : 'conversation_search', + requiresApproval: false, + approvalTitle: '', + approvalBody: '', + }; +} + +export function buildApprovalMessage(message: string): string { + const subject = message.trim() || 'this request'; + return `I can continue with "${subject}", but this step needs a human decision to approve or decline before the agent proceeds.`; +} + +export function buildAssistantReply(message: string): string { + const subject = message.trim() || 'the latest request'; + const lower = subject.toLowerCase(); + + if (lower.includes('approved') || lower.includes('approval granted')) { + return 'Approval received. I continued the database migration plan, verified the dependent checks, and recorded the next safe action for Project Alpha.'; + } + + if (lower.includes('outlier') || lower.includes('analysis') || lower.includes('spreadsheet')) { + return 'I checked the spreadsheet, isolated the European market outliers, and prepared a compact summary for the Project Alpha workspace.'; + } + + if (lower.includes('deploy') || lower.includes('release')) { + return `I reviewed "${subject}" with the release context and found the next deployment check to run.`; + } + + return `I reviewed "${subject}" against the conversation context and prepared the next concise step.`; +} + +export function splitIntoTokenChunks(text: string, chunkSize = 24): string[] { + const chunks: string[] = []; + for (let index = 0; index < text.length; index += chunkSize) { + chunks.push(text.slice(index, index + chunkSize)); + } + return chunks; +} \ No newline at end of file diff --git a/examples/react-ai-chat/src/app/App.tsx b/examples/react-ai-chat/src/app/App.tsx new file mode 100644 index 000000000..fcbbe165f --- /dev/null +++ b/examples/react-ai-chat/src/app/App.tsx @@ -0,0 +1,129 @@ +import { useEffect, useMemo, useState } from 'react'; +import { KalamProvider, LiveQueries, type MultiLiveQueryContext } from '@kalamdb/react'; +import { asc, desc, eq } from 'drizzle-orm'; +import { getExampleClient, isExampleDemoMode } from './client'; +import { Aside } from './components/Aside'; +import { Conversation } from './components/Conversation'; +import { approvalActions, conversations, messages, typingTokens } from './schema.generated'; +import type { ConversationRow } from './schema.generated'; + +const SELECTED_CONVERSATION_KEY = 'kalamdb-react-ai-chat-selected-v3'; +const DEFAULT_CONVERSATION_ID = 'project-alpha'; + +type ChatQueries = { + conversations: { table: typeof conversations }; + messages: { table: typeof messages }; + typingTokens: { table: typeof typingTokens }; +}; + +export type ChatLiveContext = MultiLiveQueryContext; + +export function App() { + const client = useMemo(() => getExampleClient(), []); + const [selectedConversationId, setSelectedConversationId] = useState(loadSelectedConversationId); + + useEffect(() => { + window.localStorage.setItem(SELECTED_CONVERSATION_KEY, selectedConversationId); + }, [selectedConversationId]); + + const queries = useMemo(() => ({ + conversations: { + table: conversations, + orderBy: (table: typeof conversations) => desc(table.updatedAt), + limit: 50, + }, + messages: { + table: messages, + where: (table: typeof messages) => eq(table.conversationId, selectedConversationId), + orderBy: (table: typeof messages) => asc(table.createdAt), + deps: [selectedConversationId], + }, + typingTokens: { + table: typingTokens, + where: (table: typeof typingTokens) => eq(table.conversationId, selectedConversationId), + orderBy: (table: typeof typingTokens) => asc(table.createdAt), + deps: [selectedConversationId], + }, + }), [selectedConversationId]); + + return ( + + + {(live) => ( + + )} + + + ); +} + +function ChatWorkspace({ + live, + selectedConversationId, + onSelectConversation, +}: { + live: ChatLiveContext; + selectedConversationId: string; + onSelectConversation: (conversationId: string) => void; +}) { + const currentConversation = useMemo( + () => resolveConversation(live.conversations.rows, selectedConversationId), + [live.conversations.rows, selectedConversationId], + ); + + useEffect(() => { + if (!currentConversation && live.conversations.rows[0]) { + onSelectConversation(live.conversations.rows[0].id); + } + }, [currentConversation, live.conversations.rows, onSelectConversation]); + + const createConversation = async () => { + const id = createId('conversation'); + await live.insert(conversations).values({ + id, + title: 'New chat', + summary: 'Fresh conversation', + createdAt: new Date(), + updatedAt: new Date(), + }); + onSelectConversation(id); + }; + + return ( +
+
+ ); +} + +function resolveConversation(rows: ConversationRow[], selectedConversationId: string): ConversationRow | null { + return rows.find((conversation) => conversation.id === selectedConversationId) ?? rows[0] ?? null; +} + +function createId(prefix: string): string { + return `${prefix}-${crypto.randomUUID?.() ?? `${Date.now()}-${Math.random().toString(16).slice(2)}`}`; +} + +function loadSelectedConversationId(): string { + return window.localStorage.getItem(SELECTED_CONVERSATION_KEY) ?? DEFAULT_CONVERSATION_ID; +} \ No newline at end of file diff --git a/examples/react-ai-chat/src/app/client.ts b/examples/react-ai-chat/src/app/client.ts new file mode 100644 index 000000000..6fd1469bf --- /dev/null +++ b/examples/react-ai-chat/src/app/client.ts @@ -0,0 +1,31 @@ +import { Auth, createClient, type KalamDBClient } from '@kalamdb/client'; +import { createDemoClient } from './demo-client'; + +const DEMO_MODE = import.meta.env.VITE_KALAMDB_DEMO_MODE !== 'false'; + +let singleton: KalamDBClient | null = null; + +export function isExampleDemoMode(): boolean { + return DEMO_MODE; +} + +export function getExampleClient(): KalamDBClient { + if (singleton) { + return singleton; + } + + if (DEMO_MODE) { + singleton = createDemoClient(); + return singleton; + } + + singleton = createClient({ + url: import.meta.env.VITE_KALAMDB_URL ?? 'http://127.0.0.1:8080', + authProvider: async () => Auth.basic( + import.meta.env.VITE_KALAMDB_USER ?? 'admin', + import.meta.env.VITE_KALAMDB_PASSWORD ?? 'kalamdb123', + ), + disableCompression: true, + }); + return singleton; +} \ No newline at end of file diff --git a/examples/react-ai-chat/src/app/components/Aside.tsx b/examples/react-ai-chat/src/app/components/Aside.tsx new file mode 100644 index 000000000..2be4a5d58 --- /dev/null +++ b/examples/react-ai-chat/src/app/components/Aside.tsx @@ -0,0 +1,60 @@ +import { HelpCircle, MessageSquarePlus, Settings } from 'lucide-react'; +import type { ConversationRow } from '../schema.generated'; + +const dateFormatter = new Intl.DateTimeFormat(undefined, { month: 'short', day: 'numeric' }); + +export function Aside({ + conversations, + selectedConversationId, + onCreate, + onSelect, +}: { + conversations: ConversationRow[]; + selectedConversationId: string; + onCreate: () => void; + onSelect: (conversationId: string) => void; +}) { + return ( + + ); +} \ No newline at end of file diff --git a/examples/react-ai-chat/src/app/components/ChatComposer.tsx b/examples/react-ai-chat/src/app/components/ChatComposer.tsx new file mode 100644 index 000000000..394f844ae --- /dev/null +++ b/examples/react-ai-chat/src/app/components/ChatComposer.tsx @@ -0,0 +1,83 @@ +import { Paperclip, SendHorizontal, X } from 'lucide-react'; +import { useRef, useState } from 'react'; + +export function ChatComposer({ + disabled, + onSend, +}: { + disabled: boolean; + onSend: (body: string, attachment: File | null) => Promise; +}) { + const [body, setBody] = useState(''); + const [attachment, setAttachment] = useState(null); + const inputRef = useRef(null); + const canSend = body.trim().length > 0 && !disabled; + + const submit = async () => { + const trimmed = body.trim(); + if (!trimmed || disabled) { + return; + } + + await onSend(trimmed, attachment); + setBody(''); + setAttachment(null); + if (inputRef.current) { + inputRef.current.value = ''; + } + }; + + return ( +
{ + event.preventDefault(); + void submit(); + }} + > +