diff --git a/assets/js/ash_rpc.ts b/assets/js/ash_rpc.ts index c54061a..4b1efd6 100644 --- a/assets/js/ash_rpc.ts +++ b/assets/js/ash_rpc.ts @@ -2,6 +2,7 @@ // Do not edit this file manually +import type { AshRpcError, ConditionalPaginatedResultMixed, InferResult, SortString, UUID, UnifiedFieldSelection, ValidationResult, tweetsFilterInput, tweetsResourceSchema, tweetsSortField } from "./ash_types"; export type * from "./ash_types"; // Helper Functions @@ -199,3 +200,314 @@ export async function executeValidationRpcRequest( + +export type CreateTweetInput = { + content: string; +}; + +export type CreateTweetFields = UnifiedFieldSelection[]; + +export type InferCreateTweetResult< + Fields extends CreateTweetFields | undefined, +> = InferResult; + +export type CreateTweetResult = | { success: true; data: InferCreateTweetResult; } +| { success: false; errors: AshRpcError[]; } + +; + +/** + * Create a new Tweet + * + * @ashActionType :create + */ +export async function createTweet( + config: { + tenant?: string; + input: CreateTweetInput; + fields?: Fields; + headers?: Record; + fetchOptions?: RequestInit; + customFetch?: (input: RequestInfo | URL, init?: RequestInit) => Promise; +} +): Promise> { + const payload = { + action: "create_tweet", + ...(config.tenant !== undefined && { tenant: config.tenant }), + input: config.input, + ...(config.fields !== undefined && { fields: config.fields }) + }; + + return executeActionRpcRequest>( + payload, + config + ); +} + + +/** + * Validate: Create a new Tweet + * + * @ashActionType :create + * @validation true + */ +export async function validateCreateTweet( + config: { + tenant?: string; + input: CreateTweetInput; + headers?: Record; + fetchOptions?: RequestInit; + customFetch?: (input: RequestInfo | URL, init?: RequestInit) => Promise; +} +): Promise { + const payload = { + action: "create_tweet", + ...(config.tenant !== undefined && { tenant: config.tenant }), + input: config.input + }; + + return executeValidationRpcRequest( + payload, + config + ); +} + + +export type DestroyTweetResult = | { success: true; data: {}; } +| { success: false; errors: AshRpcError[]; } + +; + +/** + * Delete a Tweet + * + * @ashActionType :destroy + */ +export async function destroyTweet( + config: { + tenant?: string; + identity: UUID; + headers?: Record; + fetchOptions?: RequestInit; + customFetch?: (input: RequestInfo | URL, init?: RequestInit) => Promise; +} +): Promise { + const payload = { + action: "destroy_tweet", + ...(config.tenant !== undefined && { tenant: config.tenant }), + identity: config.identity + }; + + return executeActionRpcRequest( + payload, + config + ); +} + + +/** + * Validate: Delete a Tweet + * + * @ashActionType :destroy + * @validation true + */ +export async function validateDestroyTweet( + config: { + tenant?: string; + identity: UUID | string; + headers?: Record; + fetchOptions?: RequestInit; + customFetch?: (input: RequestInfo | URL, init?: RequestInit) => Promise; +} +): Promise { + const payload = { + action: "destroy_tweet", + ...(config.tenant !== undefined && { tenant: config.tenant }), + identity: config.identity + }; + + return executeValidationRpcRequest( + payload, + config + ); +} + + +export type ReadTweetFields = UnifiedFieldSelection[]; + + +export type InferReadTweetResult< + Fields extends ReadTweetFields | undefined, + Page extends ReadTweetConfig["page"] = undefined +> = ConditionalPaginatedResultMixed>, { + results: Array>; + hasMore: boolean; + limit: number; + offset: number; + count?: number | null; + type: "offset"; +}, { + results: Array>; + hasMore: boolean; + limit: number; + after: string | null; + before: string | null; + previousPage: string; + nextPage: string; + count?: number | null; + type: "keyset"; +}>; + +export type ReadTweetConfig = { + tenant?: string; + fields: ReadTweetFields; + filter?: tweetsFilterInput; + sort?: SortString | SortString[]; + page?: ( + { + limit?: number; + offset?: number; + count?: boolean; + } | { + limit?: number; + after?: string; + before?: string; + } + ); + headers?: Record; + fetchOptions?: RequestInit; + customFetch?: (input: RequestInfo | URL, init?: RequestInit) => Promise; +}; + +export type ReadTweetResult = | { success: true; data: InferReadTweetResult; } +| { success: false; errors: AshRpcError[]; } + +; + +/** + * Read Tweet records + * + * @ashActionType :read + */ +export async function readTweet( + config: Config & { fields: Fields } +): Promise> { + const payload = { + action: "read_tweet", + ...(config.tenant !== undefined && { tenant: config.tenant }), + ...(config.fields !== undefined && { fields: config.fields }), + ...(config.filter && { filter: config.filter }), + ...(config.sort && { sort: Array.isArray(config.sort) ? config.sort.join(",") : config.sort }), + ...(config.page && { page: config.page }) + }; + + return executeActionRpcRequest>( + payload, + config + ); +} + + +/** + * Validate: Read Tweet records + * + * @ashActionType :read + * @validation true + */ +export async function validateReadTweet( + config: { + tenant?: string; + headers?: Record; + fetchOptions?: RequestInit; + customFetch?: (input: RequestInfo | URL, init?: RequestInit) => Promise; +} +): Promise { + const payload = { + action: "read_tweet", + ...(config.tenant !== undefined && { tenant: config.tenant }) + }; + + return executeValidationRpcRequest( + payload, + config + ); +} + + +export type UpdateTweetInput = { + content?: string; + userId?: UUID; + state?: "posted" | "drafted"; +}; + +export type UpdateTweetFields = UnifiedFieldSelection[]; + +export type InferUpdateTweetResult< + Fields extends UpdateTweetFields | undefined, +> = InferResult; + +export type UpdateTweetResult = | { success: true; data: InferUpdateTweetResult; } +| { success: false; errors: AshRpcError[]; } + +; + +/** + * Update an existing Tweet + * + * @ashActionType :update + */ +export async function updateTweet( + config: { + tenant?: string; + identity: UUID; + input: UpdateTweetInput; + fields?: Fields; + headers?: Record; + fetchOptions?: RequestInit; + customFetch?: (input: RequestInfo | URL, init?: RequestInit) => Promise; +} +): Promise> { + const payload = { + action: "update_tweet", + ...(config.tenant !== undefined && { tenant: config.tenant }), + identity: config.identity, + input: config.input, + ...(config.fields !== undefined && { fields: config.fields }) + }; + + return executeActionRpcRequest>( + payload, + config + ); +} + + +/** + * Validate: Update an existing Tweet + * + * @ashActionType :update + * @validation true + */ +export async function validateUpdateTweet( + config: { + tenant?: string; + identity: UUID | string; + input: UpdateTweetInput; + headers?: Record; + fetchOptions?: RequestInit; + customFetch?: (input: RequestInfo | URL, init?: RequestInit) => Promise; +} +): Promise { + const payload = { + action: "update_tweet", + ...(config.tenant !== undefined && { tenant: config.tenant }), + identity: config.identity, + input: config.input + }; + + return executeValidationRpcRequest( + payload, + config + ); +} + diff --git a/assets/js/ash_types.ts b/assets/js/ash_types.ts index 31243c3..b1d1d4b 100644 --- a/assets/js/ash_types.ts +++ b/assets/js/ash_types.ts @@ -3,14 +3,70 @@ +export type UUID = string; + +// tweets Schema +export type tweetsResourceSchema = { + __type: "Resource"; + __primitiveFields: "id" | "content" | "userId" | "state"; + id: UUID; + content: string; + userId: UUID; + state: "posted" | "drafted"; +}; +export type tweetsAttributesOnlySchema = { + __type: "Resource"; + __primitiveFields: "id" | "content" | "userId" | "state"; + id: UUID; + content: string; + userId: UUID; + state: "posted" | "drafted"; +}; + + +export type tweetsFilterInput = { + and?: Array; + or?: Array; + not?: Array; + + id?: { + eq?: UUID; + notEq?: UUID; + in?: Array; + }; + + content?: { + eq?: string; + notEq?: string; + in?: Array; + }; + + userId?: { + eq?: UUID; + notEq?: UUID; + in?: Array; + }; + + state?: { + eq?: "posted" | "drafted"; + notEq?: "posted" | "drafted"; + in?: Array<"posted" | "drafted">; + }; +}; +export const tweetsFilterFields = ["id", "content", "userId", "state", "user"] as const; +export type tweetsFilterField = (typeof tweetsFilterFields)[number]; + + +export const tweetsSortFields = ["id", "content", "userId", "state"] as const; +export type tweetsSortField = (typeof tweetsSortFields)[number]; // Utility Types diff --git a/config/config.exs b/config/config.exs index b382988..5a8a118 100644 --- a/config/config.exs +++ b/config/config.exs @@ -90,7 +90,7 @@ config :spark, config :mixer, ecto_repos: [Mixer.Repo], generators: [timestamp_type: :utc_datetime], - ash_domains: [Mixer.Accounts], + ash_domains: [Mixer.Accounts, Mixer.Posts], ash_authentication: [return_error_on_invalid_magic_link_token?: true] # Configure the endpoint diff --git a/lib/mixer/posts.ex b/lib/mixer/posts.ex new file mode 100644 index 0000000..e99466b --- /dev/null +++ b/lib/mixer/posts.ex @@ -0,0 +1,22 @@ +defmodule Mixer.Posts do + use Ash.Domain, + otp_app: :mixer, + extensions: [AshTypescript.Rpc, AshAdmin.Domain] + + admin do + show? true + end + + resources do + resource Mixer.Posts.Tweet + end + + typescript_rpc do + resource Mixer.Posts.Tweet do + rpc_action :create_tweet, :create + rpc_action :read_tweet, :read + rpc_action :update_tweet, :update + rpc_action :destroy_tweet, :destroy + end + end +end diff --git a/lib/mixer/posts/tweet.ex b/lib/mixer/posts/tweet.ex new file mode 100644 index 0000000..e5a41b8 --- /dev/null +++ b/lib/mixer/posts/tweet.ex @@ -0,0 +1,60 @@ +defmodule Mixer.Posts.Tweet do + use Ash.Resource, + otp_app: :mixer, + domain: Mixer.Posts, + data_layer: AshPostgres.DataLayer, + authorizers: [Ash.Policy.Authorizer], + extensions: [AshStateMachine, AshTypescript.Resource] + + postgres do + table "tweets" + repo Mixer.Repo + end + + typescript do + type_name "tweets" + end + + state_machine do + initial_states [:drafted] + default_initial_state :drafted + + transitions do + transition :create, from: :*, to: :posted + end + end + + actions do + defaults [:read, :destroy, update: :*] + + create :create do + upsert? true + accept [:content] + change relate_actor(:user) + change transition_state(:posted) + end + end + + attributes do + uuid_primary_key :id + + attribute :content, :string do + allow_nil? false + public? true + end + + attribute :user_id, :uuid do + allow_nil? false + public? true + end + end + + relationships do + belongs_to :user, Mixer.Accounts.User do + attribute_type :uuid + attribute_writable? true + allow_nil? false + public? true + end + end +end diff --git a/priv/repo/migrations/20260330053523_setup_posts_and_tweets.exs b/priv/repo/migrations/20260330053523_setup_posts_and_tweets.exs new file mode 100644 index 0000000..7032144 --- /dev/null +++ b/priv/repo/migrations/20260330053523_setup_posts_and_tweets.exs @@ -0,0 +1,32 @@ +defmodule Mixer.Repo.Migrations.SetupPostsAndTweets do + @moduledoc """ + Updates resources based on their most recent snapshots. + + This file was autogenerated with `mix ash_postgres.generate_migrations` + """ + + use Ecto.Migration + + def up do + create table(:tweets, primary_key: false) do + add :id, :uuid, null: false, default: fragment("gen_random_uuid()"), primary_key: true + add :content, :text, null: false + + add :user_id, + references(:users, + column: :id, + name: "tweets_user_id_fkey", + type: :uuid, + prefix: "public" + ), null: false + + add :state, :text, null: false, default: "drafted" + end + end + + def down do + drop constraint(:tweets, "tweets_user_id_fkey") + + drop table(:tweets) + end +end diff --git a/priv/resource_snapshots/repo/tweets/20260330053524.json b/priv/resource_snapshots/repo/tweets/20260330053524.json new file mode 100644 index 0000000..3fdcf18 --- /dev/null +++ b/priv/resource_snapshots/repo/tweets/20260330053524.json @@ -0,0 +1,87 @@ +{ + "attributes": [ + { + "allow_nil?": false, + "default": "fragment(\"gen_random_uuid()\")", + "generated?": false, + "precision": null, + "primary_key?": true, + "references": null, + "scale": null, + "size": null, + "source": "id", + "type": "uuid" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "content", + "type": "text" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": { + "deferrable": false, + "destination_attribute": "id", + "destination_attribute_default": null, + "destination_attribute_generated": null, + "index?": false, + "match_type": null, + "match_with": null, + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "name": "tweets_user_id_fkey", + "on_delete": null, + "on_update": null, + "primary_key?": true, + "schema": "public", + "table": "users" + }, + "scale": null, + "size": null, + "source": "user_id", + "type": "uuid" + }, + { + "allow_nil?": false, + "default": "\"drafted\"", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "state", + "type": "text" + } + ], + "base_filter": null, + "check_constraints": [], + "create_table_options": null, + "custom_indexes": [], + "custom_statements": [], + "has_create_action": true, + "hash": "675AD3F7639EEA15B853B45EE76B73E63AEF0A9B7936A048244EBE50ADB1B7F5", + "identities": [], + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "repo": "Elixir.Mixer.Repo", + "schema": null, + "table": "tweets" +} \ No newline at end of file