diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index aa5eb4427..b15c088c7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -150,6 +150,25 @@ jobs: - name: Build the NixOS system for catcolab-next run: nix build .#nixosConfigurations.catcolab-next.config.system.build.toplevel + check_generated_bindings: + name: check generated bindings + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Install Nix + uses: cachix/install-nix-action@v25 + + - name: Configure Cachix + uses: cachix/cachix-action@v14 + with: + name: catcolab-jmoggr + authToken: '${{ secrets.CACHIX_AUTH_TOKEN }}' + + - name: Check generated bindings + run: nix build .#generated-bindings-check + backend_dev_setup: name: backend dev setup runs-on: ubuntu-latest diff --git a/flake.nix b/flake.nix index 68af9c723..47b8b7cb0 100644 --- a/flake.nix +++ b/flake.nix @@ -253,6 +253,11 @@ checkMode = true; }; + generated-bindings-check = pkgsLinux.callPackage ./infrastructure/generated-bindings-check.nix { + inherit craneLib cargoArtifacts; + pkgs = pkgsLinux; + }; + # VMs built with `nixos-rebuild build-vm` (like `nix build # .#nixosConfigurations.catcolab-vm.config.system.build.vm`) are not the same # as "traditional" VMs, which causes deploy-rs to fail when deploying to them. diff --git a/infrastructure/generated-bindings-check.nix b/infrastructure/generated-bindings-check.nix new file mode 100644 index 000000000..17942f521 --- /dev/null +++ b/infrastructure/generated-bindings-check.nix @@ -0,0 +1,60 @@ +{ + craneLib, + cargoArtifacts, + pkgs, +}: +craneLib.mkCargoDerivation { + inherit cargoArtifacts; + + pname = "generated-bindings-check"; + version = "0.1.0"; + + nativeBuildInputs = [ + pkgs.pkg-config + ]; + + buildInputs = [ + pkgs.openssl + ]; + + src = pkgs.lib.fileset.toSource { + root = ../.; + fileset = pkgs.lib.fileset.unions [ + ../Cargo.toml + ../Cargo.lock + (craneLib.fileset.commonCargoSources ../packages/backend) + (craneLib.fileset.commonCargoSources ../packages/migrator) + (craneLib.fileset.commonCargoSources ../packages/notebook-types) + ../packages/backend/.sqlx + ../packages/backend/pkg + ]; + }; + + SQLX_OFFLINE = "true"; + # Override crane's default of "false" to match the behavior developers get + # in their shell. TypeId ordering depends on incremental compilation state. + CARGO_BUILD_INCREMENTAL = "true"; + + buildPhaseCargoCommand = '' + cargo run -p backend -- generate-bindings + ''; + + checkPhaseCargoCommand = '' + if ! diff -ru packages/backend/pkg.orig packages/backend/pkg --exclude node_modules; then + echo "generate-bindings produced changes to packages/backend/pkg/." + echo "Please run 'cargo run -p backend -- generate-bindings' and commit the result." + exit 1 + fi + ''; + + installPhaseCommand = '' + mkdir -p $out + ''; + + doCheck = true; + + # Save a copy of the original before building so we can diff afterwards + preBuild = '' + cp -r packages/backend/pkg packages/backend/pkg.orig + ''; +} diff --git a/packages/backend/pkg/src/index.ts b/packages/backend/pkg/src/index.ts index e7769944e..8530ecd79 100644 --- a/packages/backend/pkg/src/index.ts +++ b/packages/backend/pkg/src/index.ts @@ -13,40 +13,41 @@ */ import type { Query, Mutation, Subscription } from "@qubit-rs/client"; -export type Permissions = { +export type JsonValue = number | string | boolean | Array | { [key in string]?: JsonValue } | null; +export type NewPermissions = { /** * Base permission level for any person, logged in or not. */ anyone: PermissionLevel | null, /** - * Permission level for the current user. - */ -user: PermissionLevel | null, -/** - * Permission levels for all other users. + * Permission levels for users. * - * Only owners of the document have access to this information. + * A mapping from user IDs to permission levels. */ -users: Array | null, }; +users: { [key in string]?: PermissionLevel }, }; export type UserPermissions = { user: UserSummary, level: PermissionLevel, }; -export type RefDoc = { "tag": "Readonly", binaryData: string, isDeleted: boolean, permissions: Permissions, } | { "tag": "Live", docId: string, isDeleted: boolean, permissions: Permissions, }; export type UserProfile = { username: string | null, displayName: string | null, }; -export type JsonValue = number | string | boolean | Array | { [key in string]?: JsonValue } | null; -export type RefQueryParams = { ownerUsernameQuery: string | null, refNameQuery: string | null, searcherMinLevel: PermissionLevel | null, includePublicDocuments: boolean | null, onlyDeleted: boolean | null, limit: number | null, offset: number | null, }; -export type RpcResult = { "tag": "Ok", content: T, } | { "tag": "Err", code: number, message: string, }; -export type NewPermissions = { +export type Permissions = { /** * Base permission level for any person, logged in or not. */ anyone: PermissionLevel | null, /** - * Permission levels for users. + * Permission level for the current user. + */ +user: PermissionLevel | null, +/** + * Permission levels for all other users. * - * A mapping from user IDs to permission levels. + * Only owners of the document have access to this information. */ -users: { [key in string]?: PermissionLevel }, }; -export type UserSummary = { id: string, username: string | null, displayName: string | null, }; +users: Array | null, }; export type RefStub = { name: string, typeName: string, refId: string, permissionLevel: PermissionLevel, owner: UserSummary | null, createdAt: string, }; +export type RefDoc = { "tag": "Readonly", binaryData: string, isDeleted: boolean, permissions: Permissions, } | { "tag": "Live", docId: string, isDeleted: boolean, permissions: Permissions, }; +export type UserSummary = { id: string, username: string | null, displayName: string | null, }; +export type RpcResult = { "tag": "Ok", content: T, } | { "tag": "Err", code: number, message: string, }; +export type PermissionLevel = "Read" | "Write" | "Maintain" | "Own"; +export type RefQueryParams = { ownerUsernameQuery: string | null, refNameQuery: string | null, searcherMinLevel: PermissionLevel | null, includePublicDocuments: boolean | null, onlyDeleted: boolean | null, limit: number | null, offset: number | null, }; export type Paginated = { /** * The total number of items matching the query criteria. @@ -61,5 +62,4 @@ offset: number, */ items: Array, }; export type UsernameStatus = "Available" | "Unavailable" | "Invalid"; -export type PermissionLevel = "Read" | "Write" | "Maintain" | "Own"; export type QubitServer = { create_snapshot: Mutation<[ref_id: string], RpcResult>, delete_ref: Mutation<[ref_id: string], RpcResult>, get_active_user_profile: Query<[], RpcResult>, get_doc: Query<[ref_id: string], RpcResult>, get_permissions: Query<[ref_id: string], RpcResult>, get_ref_children_stubs: Query<[ref_id: string], RpcResult>>, head_snapshot: Query<[ref_id: string], RpcResult>, new_ref: Mutation<[content: JsonValue], RpcResult>, restore_ref: Mutation<[ref_id: string], RpcResult>, search_ref_stubs: Query<[query_params: RefQueryParams], RpcResult>>, set_active_user_profile: Mutation<[user: UserProfile], RpcResult>, set_permissions: Mutation<[ref_id: string, new: NewPermissions], RpcResult>, sign_up_or_sign_in: Mutation<[], RpcResult>, user_by_username: Query<[username: string], RpcResult>, username_status: Query<[username: string], RpcResult>, validate_session: Query<[], RpcResult>, }; diff --git a/packages/backend/src/main.rs b/packages/backend/src/main.rs index 62ebc2b88..928b026b8 100644 --- a/packages/backend/src/main.rs +++ b/packages/backend/src/main.rs @@ -59,14 +59,31 @@ async fn main() { tracing_subscriber::fmt().with_env_filter(env_filter).init(); + let cli = Cli::parse(); + + if let Some(Command::GenerateBindings) = cli.command { + use qubit::TypeScript; + + let path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("pkg") + .join("src") + .join("index.ts"); + + rpc::router() + .as_codegen() + .write_type(&path, TypeScript::new()) + .expect("Failed to write TypeScript bindings"); + + info!("Successfully generated TypeScript bindings to: {}", path.display()); + return; + } + let db = PgPoolOptions::new() .max_connections(10) .connect(&dotenvy::var("DATABASE_URL").expect("`DATABASE_URL` should be set")) .await .expect("Failed to connect to database"); - let cli = Cli::parse(); - let mut migrator = Migrator::default(); migrator .add_migrations(migrator::migrations()) @@ -80,22 +97,7 @@ async fn main() { return; } - Command::GenerateBindings => { - use qubit::TypeScript; - - let path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .join("pkg") - .join("src") - .join("index.ts"); - - rpc::router() - .as_codegen() - .write_type(&path, TypeScript::new()) - .expect("Failed to write TypeScript bindings"); - - info!("Successfully generated TypeScript bindings to: {}", path.display()); - return; - } + Command::GenerateBindings => unreachable!(), Command::Serve => { info!("Applying database migrations...");