From f3ce8a09d972dc54aeca945cb35d325d0727dc36 Mon Sep 17 00:00:00 2001 From: Ben Dodson Date: Thu, 27 Aug 2026 16:45:31 -0700 Subject: [PATCH] feat(clientsql): add portable runtime and debugger Consolidate the hermetic ClientSQL generator, pinned SQLite toolchain, native/web runtime, integration coverage, debugger provider, and smoke fixtures into one dependency-complete change.\n\nCarries six native API version annotations plus one matching generator expectation; the commit must remain draft until Snap allocates one concrete version and completes the sensitive-file/import review. --- BUILD.bazel | 10 + MODULE.bazel | 18 + apps/ledger_sql_demo/BUILD.bazel | 12 + apps/ledger_sql_demo/README.md | 41 + apps/ledger_sql_demo/package.json | 13 + apps/ledger_sql_demo/scripts/android_smoke.py | 199 ++ .../src/valdi/ledger_sql_demo/BUILD.bazel | 27 + .../src/valdi/ledger_sql_demo/module.yaml | 10 + .../ledger_sql_demo/sql/LedgerDb/Ledger.sq | 100 + .../ledger_sql_demo/src/LedgerSqlDemoApp.tsx | 1796 +++++++++++++++ .../src/valdi/ledger_sql_demo/tsconfig.json | 8 + bin/BUILD.bazel | 11 +- bzl/BUILD.bazel | 6 + bzl/dependencies.bzl | 18 + bzl/valdi/BUILD.bazel | 77 + bzl/valdi/valdi_compiled.bzl | 9 +- bzl/valdi/valdi_paths.bzl | 9 +- bzl/valdi/valdi_projectsync.bzl | 15 +- bzl/valdi/valdi_run_compiler.bzl | 25 +- bzl/valdi/valdi_static_resource.bzl | 1 + bzl/valdi/valdi_toolchain.bzl | 2 +- compiler/clientsql/BUILD.bazel | 85 + compiler/clientsql/README.md | 98 + compiler/clientsql/package_clientsql.py | 76 + .../clientsql/run_generated_javascript.js | 9 + compiler/clientsql/sqlite_316_validator.cpp | 403 ++++ compiler/clientsql/src/clientsql/__init__.py | 1 + compiler/clientsql/src/clientsql/__main__.py | 4 + compiler/clientsql/src/clientsql/cli.py | 168 ++ compiler/clientsql/src/clientsql/model.py | 59 + compiler/clientsql/src/clientsql/sql.py | 1223 +++++++++++ .../clientsql/src/clientsql/typescript.py | 839 +++++++ compiler/clientsql/src/clientsql/validator.py | 178 ++ compiler/clientsql/src/clientsql_main.py | 4 + compiler/clientsql/src/clientsql_toolchain.py | 103 + compiler/clientsql/test_clientsql.py | 1880 ++++++++++++++++ compiler/compiler/BUILD.bazel | 10 + .../Sources/Config/ValdiProjectConfig.swift | 4 +- .../Processors/ClientSqlProcessor.swift | 40 +- .../ClientSqlProcessorTests.swift | 24 + fossa-deps.yml | 6 + .../src/valdi/client_sql/BUILD.bazel | 84 + .../src/valdi/client_sql/README.md | 62 + .../src/valdi/client_sql/module.yaml | 8 + .../native/ClientSQLNativeModuleFactory.cpp | 1948 +++++++++++++++++ .../native/ClientSQLNativeModuleFactory.hpp | 35 + .../ClientSQLNativeModuleFactory_tests.cpp | 1853 ++++++++++++++++ .../src/valdi/client_sql/src/ClientSQL.ts | 5 + .../valdi/client_sql/src/ClientSQLDebug.ts | 931 ++++++++ .../valdi/client_sql/src/ClientSQLNative.d.ts | 108 + .../client_sql/test/ClientSQLDebug.spec.ts | 219 ++ .../src/valdi/client_sql/tsconfig.json | 8 + .../valdi/client_sql/web/ClientSQLNative.ts | 113 + .../src/valdi/client_sql/web/tsconfig.json | 16 + third-party/sqlite/BUILD.bazel | 11 + third-party/sqlite/LICENSE.md | 85 + third-party/sqlite/README.md | 45 + third-party/sqlite/sqlite.BUILD | 24 + third-party/sqlite/sqlite_316.BUILD | 27 + valdi/BUILD.bazel | 39 +- .../integration/ClientSQLRuntime_tests.cpp | 126 ++ valdi/test/integration/RuntimeTestsUtils.cpp | 16 +- valdi/test/integration/RuntimeTestsUtils.hpp | 12 +- .../modules/client_sql_smoke/BUILD.bazel | 33 + .../modules/client_sql_smoke/module.yaml | 9 + .../client_sql_smoke/sql/TestDb/User.sq | 29 + .../client_sql_smoke/sql/migration/2.sqm | 1 + .../client_sql_smoke/src/ClientSQLSmoke.ts | 110 + .../modules/client_sql_smoke/tsconfig.json | 8 + 69 files changed, 13554 insertions(+), 32 deletions(-) create mode 100644 apps/ledger_sql_demo/BUILD.bazel create mode 100644 apps/ledger_sql_demo/README.md create mode 100644 apps/ledger_sql_demo/package.json create mode 100644 apps/ledger_sql_demo/scripts/android_smoke.py create mode 100644 apps/ledger_sql_demo/src/valdi/ledger_sql_demo/BUILD.bazel create mode 100644 apps/ledger_sql_demo/src/valdi/ledger_sql_demo/module.yaml create mode 100644 apps/ledger_sql_demo/src/valdi/ledger_sql_demo/sql/LedgerDb/Ledger.sq create mode 100644 apps/ledger_sql_demo/src/valdi/ledger_sql_demo/src/LedgerSqlDemoApp.tsx create mode 100644 apps/ledger_sql_demo/src/valdi/ledger_sql_demo/tsconfig.json create mode 100644 compiler/clientsql/BUILD.bazel create mode 100644 compiler/clientsql/README.md create mode 100644 compiler/clientsql/package_clientsql.py create mode 100644 compiler/clientsql/run_generated_javascript.js create mode 100644 compiler/clientsql/sqlite_316_validator.cpp create mode 100644 compiler/clientsql/src/clientsql/__init__.py create mode 100644 compiler/clientsql/src/clientsql/__main__.py create mode 100644 compiler/clientsql/src/clientsql/cli.py create mode 100644 compiler/clientsql/src/clientsql/model.py create mode 100644 compiler/clientsql/src/clientsql/sql.py create mode 100644 compiler/clientsql/src/clientsql/typescript.py create mode 100644 compiler/clientsql/src/clientsql/validator.py create mode 100644 compiler/clientsql/src/clientsql_main.py create mode 100644 compiler/clientsql/src/clientsql_toolchain.py create mode 100644 compiler/clientsql/test_clientsql.py create mode 100644 compiler/compiler/Compiler/Tests/CompilerTests/ClientSqlProcessorTests.swift create mode 100644 src/valdi_modules/src/valdi/client_sql/BUILD.bazel create mode 100644 src/valdi_modules/src/valdi/client_sql/README.md create mode 100644 src/valdi_modules/src/valdi/client_sql/module.yaml create mode 100644 src/valdi_modules/src/valdi/client_sql/native/ClientSQLNativeModuleFactory.cpp create mode 100644 src/valdi_modules/src/valdi/client_sql/native/ClientSQLNativeModuleFactory.hpp create mode 100644 src/valdi_modules/src/valdi/client_sql/native/ClientSQLNativeModuleFactory_tests.cpp create mode 100644 src/valdi_modules/src/valdi/client_sql/src/ClientSQL.ts create mode 100644 src/valdi_modules/src/valdi/client_sql/src/ClientSQLDebug.ts create mode 100644 src/valdi_modules/src/valdi/client_sql/src/ClientSQLNative.d.ts create mode 100644 src/valdi_modules/src/valdi/client_sql/test/ClientSQLDebug.spec.ts create mode 100644 src/valdi_modules/src/valdi/client_sql/tsconfig.json create mode 100644 src/valdi_modules/src/valdi/client_sql/web/ClientSQLNative.ts create mode 100644 src/valdi_modules/src/valdi/client_sql/web/tsconfig.json create mode 100644 third-party/sqlite/BUILD.bazel create mode 100644 third-party/sqlite/LICENSE.md create mode 100644 third-party/sqlite/README.md create mode 100644 third-party/sqlite/sqlite.BUILD create mode 100644 third-party/sqlite/sqlite_316.BUILD create mode 100644 valdi/test/integration/ClientSQLRuntime_tests.cpp create mode 100644 valdi/testdata/resources/modules/client_sql_smoke/BUILD.bazel create mode 100644 valdi/testdata/resources/modules/client_sql_smoke/module.yaml create mode 100644 valdi/testdata/resources/modules/client_sql_smoke/sql/TestDb/User.sq create mode 100644 valdi/testdata/resources/modules/client_sql_smoke/sql/migration/2.sqm create mode 100644 valdi/testdata/resources/modules/client_sql_smoke/src/ClientSQLSmoke.ts create mode 100644 valdi/testdata/resources/modules/client_sql_smoke/tsconfig.json diff --git a/BUILD.bazel b/BUILD.bazel index cbc8ae60e..1d6672154 100644 --- a/BUILD.bazel +++ b/BUILD.bazel @@ -12,3 +12,13 @@ npm_link_package( src = "@valdi//src/valdi_modules/src/valdi/valdi_core:valdi_core_dts", visibility = ["//visibility:public"], ) + +filegroup( + name = "clientsql_generator_test_data", + srcs = [ + "MODULE.bazel", + "fossa-deps.yml", + ], + testonly = True, + visibility = ["//compiler/clientsql:__pkg__"], +) diff --git a/MODULE.bazel b/MODULE.bazel index d338a79da..b49c01afe 100644 --- a/MODULE.bazel +++ b/MODULE.bazel @@ -183,6 +183,24 @@ http_archive( url = "https://github.com/fmtlib/fmt/releases/download/7.1.3/fmt-7.1.3.zip", ) +# MODULE.bazel and WORKSPACE repository setup cannot share repository-rule macros. Keep this +# declaration in sync with bzl/dependencies.bzl; ClientSQL's dependency-shape test guards it. +http_archive( + name = "valdi_clientsql_sqlite", + build_file = "@valdi//third-party/sqlite:sqlite.BUILD", + sha256 = "0e9483900e92cd5de8fd48d16bf9200145a61f7fd5be542a5ac81d8a9516eb9c", + strip_prefix = "sqlite-autoconf-3530400", + url = "https://www.sqlite.org/2026/sqlite-autoconf-3530400.tar.gz", +) + +http_archive( + name = "valdi_clientsql_sqlite_316", + build_file = "@valdi//third-party/sqlite:sqlite_316.BUILD", + sha256 = "3b5dfb65807e2b17e6463357df848e322badba01dc9a4a1de8fdbb72d448e3b0", + strip_prefix = "sqlite-amalgamation-3160000", + url = "https://www.sqlite.org/2017/sqlite-amalgamation-3160000.zip", +) + bazel_dep(name = "android_macros") local_path_override( module_name = "android_macros", diff --git a/apps/ledger_sql_demo/BUILD.bazel b/apps/ledger_sql_demo/BUILD.bazel new file mode 100644 index 000000000..e52ec54b7 --- /dev/null +++ b/apps/ledger_sql_demo/BUILD.bazel @@ -0,0 +1,12 @@ +load("//bzl/valdi:valdi_application.bzl", "valdi_application") + +valdi_application( + name = "ledger_sql_demo", + desktop_window_height = 820, + desktop_window_width = 900, + ios_bundle_id = "com.snap.valdi.ledgersqldemo", + root_component_path = "App@ledger_sql_demo/src/LedgerSqlDemoApp", + title = "Ledger SQL Demo", + version = "1.0.0", + deps = ["//apps/ledger_sql_demo/src/valdi/ledger_sql_demo"], +) diff --git a/apps/ledger_sql_demo/README.md b/apps/ledger_sql_demo/README.md new file mode 100644 index 000000000..4e8d9f23d --- /dev/null +++ b/apps/ledger_sql_demo/README.md @@ -0,0 +1,41 @@ +# Ledger SQL Demo + +Valdi app that exercises ClientSQL generated bindings with a small double-entry ledger. + +The app opens `LedgerDb`, seeds a handful of accounts, and subscribes to reactive aggregate queries for balances, recent ledger entries, and the transaction log. Transfers run inside `LedgerDb.transaction()`: each transfer inserts the debit entry, credit entry, and transaction log row as one committed unit. The generated binding calls the native ClientSQL transaction API, so writer work is serialized by the SQLite runtime and watched aggregate queries refresh after the native commit completes. + +The Valdi debugger Data section includes a ClientSQL browser that can inspect the live database while this demo is running. For a live target it shows table rows plus ClientSQL runtime state such as active transaction, deferred writer work, handle count, reader pool readiness, watcher count, and a bounded transaction history with commit/rollback durations. + +Expected local workflow: + +```bash +valdi install macos --application //apps/ledger_sql_demo:ledger_sql_demo_macos +valdi hotreload --target //apps/ledger_sql_demo:ledger_sql_demo_hotreload +``` + +Hot reload can update the Valdi TypeScript UI, but changes to the native ClientSQL runtime require rebuilding and relaunching the macOS app. + +iOS build validation: + +```bash +cd apps/ledger_sql_demo +npm run ios:build +``` + +Android build and install workflow: + +```bash +valdi install android \ + --application //apps/ledger_sql_demo:ledger_sql_demo_android +``` + +To run the same flow as an Android smoke check, with a UI assertion and screenshot capture: + +```bash +cd apps/ledger_sql_demo +npm run android:smoke:build +``` + +The smoke script waits for a connected emulator/device, installs the APK, launches the demo, taps `Run stress batch`, waits for the transaction-complete status text, and writes `/tmp/ledger-sql-android-smoke.png`. Use `ADB=/path/to/adb` or `--device ` when your local `adb` selection needs to be explicit. + +The demo should seed on first launch, and the `Run stress batch` button should append four transfer rows in one transaction, causing the reactive totals to refresh once after commit. diff --git a/apps/ledger_sql_demo/package.json b/apps/ledger_sql_demo/package.json new file mode 100644 index 000000000..f826dcb2e --- /dev/null +++ b/apps/ledger_sql_demo/package.json @@ -0,0 +1,13 @@ +{ + "name": "ledger_sql_demo", + "private": true, + "scripts": { + "macos": "valdi install macos --application //apps/ledger_sql_demo:ledger_sql_demo_macos", + "hotreload": "valdi hotreload --target //apps/ledger_sql_demo:ledger_sql_demo_hotreload", + "ios:build": "valdi build ios --application //apps/ledger_sql_demo:ledger_sql_demo_ios", + "android:build": "valdi build android --application //apps/ledger_sql_demo:ledger_sql_demo_android", + "android:install": "valdi install android --application //apps/ledger_sql_demo:ledger_sql_demo_android", + "android:smoke": "python3 scripts/android_smoke.py", + "android:smoke:build": "python3 scripts/android_smoke.py --build" + } +} diff --git a/apps/ledger_sql_demo/scripts/android_smoke.py b/apps/ledger_sql_demo/scripts/android_smoke.py new file mode 100644 index 000000000..cccb31aee --- /dev/null +++ b/apps/ledger_sql_demo/scripts/android_smoke.py @@ -0,0 +1,199 @@ +#!/usr/bin/env python3 +"""Build/install/launch smoke check for the Ledger ClientSQL Android demo.""" + +from __future__ import annotations + +import argparse +import os +import re +import shutil +import subprocess +import sys +import time +import xml.etree.ElementTree as ET +from pathlib import Path +from typing import Sequence + + +ROOT = Path(__file__).resolve().parents[3] +DEFAULT_APK = ROOT / "bazel-bin/apps/ledger_sql_demo/ledger_sql_demo_android.apk" +DEFAULT_SCREENSHOT = Path("/tmp/ledger-sql-android-smoke.png") +PACKAGE = "com.snap.valdi.ledger_sql_demo" +ACTIVITY = f"{PACKAGE}/.StartActivity" +ANDROID_BUILD_COMMAND = [ + "valdi", + "build", + "android", + "--application", + "//apps/ledger_sql_demo:ledger_sql_demo_android", +] +BOUNDS_RE = re.compile(r"\[(\d+),(\d+)\]\[(\d+),(\d+)\]") + + +def run(args: Sequence[str], *, cwd: Path = ROOT, capture: bool = False) -> subprocess.CompletedProcess: + print("$ " + " ".join(str(arg) for arg in args), flush=True) + return subprocess.run( + [str(arg) for arg in args], + cwd=cwd, + capture_output=capture, + check=True, + ) + + +def default_adb_path() -> str: + if os.environ.get("ADB"): + return os.environ["ADB"] + if shutil.which("adb"): + return "adb" + + candidates = [] + for variable in ("ANDROID_HOME", "ANDROID_SDK_ROOT"): + if os.environ.get(variable): + candidates.append(Path(os.environ[variable]) / "platform-tools/adb") + candidates.append(Path.home() / "Library/Android/sdk/platform-tools/adb") + + for candidate in candidates: + if candidate.exists(): + return str(candidate) + return "adb" + + +def adb(args: Sequence[str], *, device: str | None, capture: bool = False) -> subprocess.CompletedProcess: + adb_path = default_adb_path() + command = [adb_path] + if device: + command.extend(["-s", device]) + command.extend(args) + return run(command, capture=capture) + + +def wait_for_boot(device: str | None, timeout_seconds: float) -> None: + adb(["wait-for-device"], device=device) + deadline = time.monotonic() + timeout_seconds + while time.monotonic() < deadline: + result = adb(["shell", "getprop", "sys.boot_completed"], device=device, capture=True) + if result.stdout.decode(errors="replace").strip() == "1": + return + time.sleep(1) + raise TimeoutError("Android device did not finish booting before the smoke timeout") + + +def dump_ui(device: str | None) -> str: + adb(["shell", "uiautomator", "dump", "/sdcard/ledger_sql_demo_window.xml"], device=device) + result = adb(["exec-out", "cat", "/sdcard/ledger_sql_demo_window.xml"], device=device, capture=True) + return result.stdout.decode(errors="replace") + + +def node_text(node: ET.Element) -> str: + return node.attrib.get("text", "") + + +def parse_ui_xml(xml_text: str) -> ET.Element | None: + try: + return ET.fromstring(xml_text) + except ET.ParseError: + return None + + +def find_bounds(xml_text: str, text: str) -> tuple[int, int, int, int] | None: + root = parse_ui_xml(xml_text) + if root is None: + return None + for node in root.iter("node"): + candidate = node_text(node) + if candidate == text or text in candidate: + bounds = node.attrib.get("bounds", "") + match = BOUNDS_RE.fullmatch(bounds) + if match: + return ( + int(match.group(1)), + int(match.group(2)), + int(match.group(3)), + int(match.group(4)), + ) + return None + + +def contains_text(xml_text: str, text: str) -> bool: + root = parse_ui_xml(xml_text) + if root is None: + return False + return any(text in node_text(node) for node in root.iter("node")) + + +def tap_bounds(device: str | None, bounds: tuple[int, int, int, int]) -> None: + left, top, right, bottom = bounds + adb(["shell", "input", "tap", str((left + right) // 2), str((top + bottom) // 2)], device=device) + + +def tap_text(device: str | None, text: str) -> bool: + bounds = find_bounds(dump_ui(device), text) + if bounds is None: + return False + tap_bounds(device, bounds) + return True + + +def wait_for_text(device: str | None, text: str, timeout_seconds: float) -> None: + deadline = time.monotonic() + timeout_seconds + last_xml = "" + while time.monotonic() < deadline: + last_xml = dump_ui(device) + if contains_text(last_xml, text): + return + time.sleep(1) + raise AssertionError(f"Did not find Android UI text {text!r}. Last dump:\n{last_xml[:2000]}") + + +def screenshot(device: str | None, path: Path) -> None: + result = adb(["exec-out", "screencap", "-p"], device=device, capture=True) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(result.stdout) + print(f"Wrote screenshot: {path}", flush=True) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--build", action="store_true", help="Build the Android APK before installing it.") + parser.add_argument("--apk", type=Path, default=DEFAULT_APK, help="APK to install.") + parser.add_argument("--device", help="adb device serial. Defaults to adb's selected device.") + parser.add_argument("--timeout", type=float, default=60, help="Seconds to wait for boot and UI text.") + parser.add_argument("--reset-seed", action="store_true", help="Tap Reset seed data before the stress batch.") + parser.add_argument("--screenshot", type=Path, default=DEFAULT_SCREENSHOT, help="Screenshot output path.") + args = parser.parse_args() + + if args.build: + run(ANDROID_BUILD_COMMAND) + + if not args.apk.exists(): + raise FileNotFoundError(f"APK not found: {args.apk}. Pass --build or build it first.") + + wait_for_boot(args.device, args.timeout) + adb(["install", "-r", str(args.apk)], device=args.device) + adb(["shell", "am", "start", "-n", ACTIVITY], device=args.device) + + wait_for_text(args.device, "Ledger ClientSQL Demo", args.timeout) + wait_for_text(args.device, "Ledger actions ready.", args.timeout) + if args.reset_seed: + if not tap_text(args.device, "Reset seed data"): + raise AssertionError("Could not find Reset seed data button in Android UI dump") + wait_for_text(args.device, "Reset and reseeded ledger tables.", args.timeout) + + if not tap_text(args.device, "Run stress batch"): + raise AssertionError("Could not find Run stress batch button in Android UI dump") + + wait_for_text(args.device, "Committed four transfers in one transaction", args.timeout) + screenshot(args.device, args.screenshot) + print("Ledger ClientSQL Android smoke passed.", flush=True) + return 0 + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except subprocess.CalledProcessError as error: + if error.stdout: + sys.stdout.buffer.write(error.stdout) + if error.stderr: + sys.stderr.buffer.write(error.stderr) + raise diff --git a/apps/ledger_sql_demo/src/valdi/ledger_sql_demo/BUILD.bazel b/apps/ledger_sql_demo/src/valdi/ledger_sql_demo/BUILD.bazel new file mode 100644 index 000000000..cfe77a2ba --- /dev/null +++ b/apps/ledger_sql_demo/src/valdi/ledger_sql_demo/BUILD.bazel @@ -0,0 +1,27 @@ +load("//bzl/valdi:valdi_module.bzl", "valdi_module") + +valdi_module( + name = "ledger_sql_demo", + srcs = glob([ + "src/**/*.ts", + "src/**/*.tsx", + ]) + [ + "tsconfig.json", + ], + android_output_target = "release", + ios_module_name = "SCCLedgerSQLDemo", + ios_output_target = "release", + sql_db_names = ["LedgerDb"], + sql_srcs = glob([ + "sql/**/*.sq", + "sql/**/*.sqm", + "sql/sql_types.yaml", + "sql/sql_manifest.yaml", + ]), + visibility = ["//visibility:public"], + deps = [ + "//src/valdi_modules/src/valdi/client_sql", + "//src/valdi_modules/src/valdi/valdi_core", + "//src/valdi_modules/src/valdi/valdi_tsx", + ], +) diff --git a/apps/ledger_sql_demo/src/valdi/ledger_sql_demo/module.yaml b/apps/ledger_sql_demo/src/valdi/ledger_sql_demo/module.yaml new file mode 100644 index 000000000..2514cc204 --- /dev/null +++ b/apps/ledger_sql_demo/src/valdi/ledger_sql_demo/module.yaml @@ -0,0 +1,10 @@ +name: ledger_sql_demo +ios: + module_name: SCCLedgerSQLDemo + output: release +android: + output: release +dependencies: + - client_sql + - valdi_core + - valdi_tsx diff --git a/apps/ledger_sql_demo/src/valdi/ledger_sql_demo/sql/LedgerDb/Ledger.sq b/apps/ledger_sql_demo/src/valdi/ledger_sql_demo/sql/LedgerDb/Ledger.sq new file mode 100644 index 000000000..35250d037 --- /dev/null +++ b/apps/ledger_sql_demo/src/valdi/ledger_sql_demo/sql/LedgerDb/Ledger.sq @@ -0,0 +1,100 @@ +CREATE TABLE account ( + id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + code TEXT NOT NULL, + normal_side TEXT NOT NULL +); + +CREATE TABLE ledger_entry ( + id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + account_id INTEGER NOT NULL, + amount_cents INTEGER NOT NULL, + memo TEXT NOT NULL, + transfer_group TEXT NOT NULL, + created_at INTEGER NOT NULL +); + +CREATE TABLE transaction_log ( + id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + transfer_group TEXT NOT NULL, + from_account_id INTEGER NOT NULL, + to_account_id INTEGER NOT NULL, + amount_cents INTEGER NOT NULL, + memo TEXT NOT NULL, + created_at INTEGER NOT NULL +); + +selectAccounts: +SELECT * FROM account ORDER BY id; + +selectAccountByCode: +SELECT * FROM account WHERE code = :code; + +countAccounts: +SELECT count(*) AS count FROM account; + +countLedgerEntries: +SELECT count(*) AS count FROM ledger_entry; + +countTransactionLog: +SELECT count(*) AS count FROM transaction_log; + +selectBalances: +SELECT + account.id AS id, + account.name AS name, + account.code AS code, + COALESCE(SUM(ledger_entry.amount_cents), 0) AS balance_cents, + COUNT(ledger_entry.id) AS entry_count +FROM ledger_entry +JOIN account ON account.id = ledger_entry.account_id +GROUP BY account.id, account.name, account.code +ORDER BY account.id; + +selectRecentEntries: +SELECT + ledger_entry.id AS id, + ledger_entry.account_id AS account_id, + account.name AS account_name, + ledger_entry.amount_cents AS amount_cents, + ledger_entry.memo AS memo, + ledger_entry.transfer_group AS transfer_group, + ledger_entry.created_at AS created_at +FROM ledger_entry +JOIN account ON account.id = ledger_entry.account_id +ORDER BY ledger_entry.created_at DESC, ledger_entry.id DESC LIMIT :limit OFFSET :rowOffset; + +selectTransactionLog: +SELECT + transaction_log.id AS id, + transaction_log.transfer_group AS transfer_group, + from_account.name AS from_account_name, + to_account.name AS to_account_name, + transaction_log.amount_cents AS amount_cents, + transaction_log.memo AS memo, + transaction_log.created_at AS created_at +FROM transaction_log +JOIN account AS from_account ON from_account.id = transaction_log.from_account_id +JOIN account AS to_account ON to_account.id = transaction_log.to_account_id +ORDER BY transaction_log.created_at DESC, transaction_log.id DESC LIMIT :limit; + +insertAccount: +INSERT INTO account(name, code, normal_side) +VALUES (:name, :code, :normalSide); + +insertLedgerEntry: +INSERT INTO ledger_entry(account_id, amount_cents, memo, transfer_group, created_at) +VALUES (:accountId, :amountCents, :memo, :transferGroup, :createdAt); + +insertTransactionLog: +INSERT INTO transaction_log(transfer_group, from_account_id, to_account_id, amount_cents, memo, created_at) +VALUES (:transferGroup, :fromAccountId, :toAccountId, :amountCents, :memo, :createdAt); + +deleteLedgerEntries: +DELETE FROM ledger_entry; + +deleteTransactionLog: +DELETE FROM transaction_log; + +deleteAccounts: +DELETE FROM account; diff --git a/apps/ledger_sql_demo/src/valdi/ledger_sql_demo/src/LedgerSqlDemoApp.tsx b/apps/ledger_sql_demo/src/valdi/ledger_sql_demo/src/LedgerSqlDemoApp.tsx new file mode 100644 index 000000000..a46bd4996 --- /dev/null +++ b/apps/ledger_sql_demo/src/valdi/ledger_sql_demo/src/LedgerSqlDemoApp.tsx @@ -0,0 +1,1796 @@ +import { StatefulComponent, Component } from 'valdi_core/src/Component'; +import { Device } from 'valdi_core/src/Device'; +import { Style } from 'valdi_core/src/Style'; +import { systemBoldFont, systemFont } from 'valdi_core/src/SystemFont'; +import { Label, ScrollView, TextField, TextView, View } from 'valdi_tsx/src/NativeTemplateElements'; + +import { LedgerDb } from './sqlgen/LedgerDb'; +import { ClientSQLSubscription, LedgerQueries } from './sqlgen/LedgerQueries'; +import { + Account, + CountLedgerEntriesRow, + CountTransactionLogRow, + SelectBalancesRow, + SelectRecentEntriesRow, + SelectTransactionLogRow, +} from './sqlgen/LedgerTypes'; + +interface ViewModel {} + +const LEDGER_PAGE_SIZE = 12; +const LEDGER_SHOW_ALL_LIMIT = -1; + +enum LedgerMutation { + Idle, + Initializing, + Transfer, + StressBatch, + Reset, +} + +interface State { + accounts: Account[]; + balances: SelectBalancesRow[]; + recentEntries: SelectRecentEntriesRow[]; + transfers: SelectTransactionLogRow[]; + ledgerEntryCount: number; + recentEntriesOffset: number; + showAllLedgerEntries: boolean; + transferCount: number; + fromAccountId: number; + toAccountId: number; + amount: string; + memo: string; + status: string; + activeMutation: LedgerMutation; + transferSequence: number; +} + +interface AccountBalanceRowViewModel { + balance: SelectBalancesRow; + isFrom: boolean; + isTo: boolean; + onSelectFrom: (accountId: number) => void; + onSelectTo: (accountId: number) => void; +} + +class AccountBalanceRow extends Component { + onRender(): void { + const balance = this.viewModel.balance; + const balanceCents = balance.balance_cents ?? 0; + const compact = isCompactLayout(); + + + + + + + + ; + } + + private readonly selectFrom = (): void => { + this.viewModel.onSelectFrom(this.viewModel.balance.id); + }; + + private readonly selectTo = (): void => { + this.viewModel.onSelectTo(this.viewModel.balance.id); + }; +} + +interface TransferRowViewModel { + transfer: SelectTransactionLogRow; +} + +class TransferRow extends Component { + onRender(): void { + const transfer = this.viewModel.transfer; + + + + ; + } +} + +interface LedgerEntryRowViewModel { + entry: SelectRecentEntriesRow; +} + +class LedgerEntryRow extends Component { + onRender(): void { + const entry = this.viewModel.entry; + const compact = isCompactLayout(); + + ; + } +} + +interface LedgerPaginationControlsViewModel { + total: number; + offset: number; + pageSize: number; + shown: number; + showAll: boolean; + onPrevious: () => void; + onNext: () => void; + onShowAllToggle: () => void; +} + +class LedgerPaginationControls extends Component { + onRender(): void { + const total = this.viewModel.total; + const compact = isCompactLayout(); + const rangeStart = total === 0 ? 0 : this.viewModel.offset + 1; + const rangeEnd = this.viewModel.showAll ? total : Math.min(total, this.viewModel.offset + this.viewModel.shown); + const summary = this.viewModel.showAll + ? `Showing all ${total} ledger ${total === 1 ? 'entry' : 'entries'}` + : `Showing ${rangeStart}-${rangeEnd} of ${total}`; + + + ; + } + + private canPrevious(): boolean { + return !this.viewModel.showAll && this.viewModel.offset > 0; + } + + private canNext(): boolean { + return !this.viewModel.showAll && this.viewModel.offset + this.viewModel.shown < this.viewModel.total; + } + + private showAllButtonStyle(): Style { + if (this.viewModel.showAll) { + return styles.paginationButtonSelected; + } + return this.viewModel.total > this.viewModel.pageSize ? styles.paginationButton : styles.paginationButtonDisabled; + } + + private showAllTextStyle(): Style