From c79bcd0787ccb74569911a83b15fcc69189edc96 Mon Sep 17 00:00:00 2001 From: Garry Hill Date: Wed, 3 Sep 2025 17:10:33 +0100 Subject: [PATCH 01/18] [wip] --- lib/mix/tasks/phx_sync.tanstack_db.ex | 374 ++++++++++++++++++ .../phx_sync.tanstack_db/Caddyfile.eex | 10 + .../assets/css/app.css.eex | 12 + .../phx_sync.tanstack_db/assets/js/api.ts.eex | 64 +++ .../assets/js/app.tsx.eex | 28 ++ .../assets/js/db/auth.ts.eex | 30 ++ .../assets/js/db/collections.ts.eex | 90 +++++ .../assets/js/db/mutations.ts.eex | 78 ++++ .../assets/js/db/schema.ts.eex | 16 + .../assets/js/routes/__root.tsx.eex | 29 ++ .../assets/js/routes/about.tsx.eex | 9 + .../assets/js/routes/index.tsx.eex | 13 + .../assets/package.json.eex | 51 +++ .../assets/tailwind.config.js.eex | 3 + .../assets/tsconfig.app.json.eex | 27 ++ .../assets/tsconfig.json.eex | 8 + .../assets/tsconfig.node.json.eex | 25 ++ .../assets/vite.config.ts.eex | 46 +++ .../web/components/layouts/root.html.heex.eex | 17 + 19 files changed, 930 insertions(+) create mode 100644 lib/mix/tasks/phx_sync.tanstack_db.ex create mode 100644 priv/igniter/phx_sync.tanstack_db/Caddyfile.eex create mode 100644 priv/igniter/phx_sync.tanstack_db/assets/css/app.css.eex create mode 100644 priv/igniter/phx_sync.tanstack_db/assets/js/api.ts.eex create mode 100644 priv/igniter/phx_sync.tanstack_db/assets/js/app.tsx.eex create mode 100644 priv/igniter/phx_sync.tanstack_db/assets/js/db/auth.ts.eex create mode 100644 priv/igniter/phx_sync.tanstack_db/assets/js/db/collections.ts.eex create mode 100644 priv/igniter/phx_sync.tanstack_db/assets/js/db/mutations.ts.eex create mode 100644 priv/igniter/phx_sync.tanstack_db/assets/js/db/schema.ts.eex create mode 100644 priv/igniter/phx_sync.tanstack_db/assets/js/routes/__root.tsx.eex create mode 100644 priv/igniter/phx_sync.tanstack_db/assets/js/routes/about.tsx.eex create mode 100644 priv/igniter/phx_sync.tanstack_db/assets/js/routes/index.tsx.eex create mode 100644 priv/igniter/phx_sync.tanstack_db/assets/package.json.eex create mode 100644 priv/igniter/phx_sync.tanstack_db/assets/tailwind.config.js.eex create mode 100644 priv/igniter/phx_sync.tanstack_db/assets/tsconfig.app.json.eex create mode 100644 priv/igniter/phx_sync.tanstack_db/assets/tsconfig.json.eex create mode 100644 priv/igniter/phx_sync.tanstack_db/assets/tsconfig.node.json.eex create mode 100644 priv/igniter/phx_sync.tanstack_db/assets/vite.config.ts.eex create mode 100644 priv/igniter/phx_sync.tanstack_db/lib/web/components/layouts/root.html.heex.eex diff --git a/lib/mix/tasks/phx_sync.tanstack_db.ex b/lib/mix/tasks/phx_sync.tanstack_db.ex new file mode 100644 index 0000000..2b4ddb4 --- /dev/null +++ b/lib/mix/tasks/phx_sync.tanstack_db.ex @@ -0,0 +1,374 @@ +defmodule Mix.Tasks.PhxSync.TanstackDb.Docs do + @moduledoc false + + @spec short_doc() :: String.t() + def short_doc do + "A short description of your task" + end + + @spec example() :: String.t() + def example do + "mix phx_sync.tanstack_db" + end + + @spec long_doc() :: String.t() + def long_doc do + """ + #{short_doc()} + + Longer explanation of your task + + ## Example + + ```sh + #{example()} + ``` + + ## Options + + * `--example-option` or `-e` - Docs for your option + """ + end +end + +if Code.ensure_loaded?(Igniter) do + defmodule Mix.Tasks.PhxSync.TanstackDb do + import Igniter.Project.Application, only: [app_name: 1] + + @shortdoc "#{__MODULE__.Docs.short_doc()}" + + @moduledoc __MODULE__.Docs.long_doc() + + use Igniter.Mix.Task + + @impl Igniter.Mix.Task + def info(_argv, _composing_task) do + %Igniter.Mix.Task.Info{ + # Groups allow for overlapping arguments for tasks by the same author + # See the generators guide for more. + group: :phoenix_sync, + # *other* dependencies to add + # i.e `{:foo, "~> 2.0"}` + adds_deps: [], + # *other* dependencies to add and call their associated installers, if they exist + # i.e `{:foo, "~> 2.0"}` + installs: [], + # An example invocation + example: __MODULE__.Docs.example(), + # a list of positional arguments, i.e `[:file]` + positional: [], + # Other tasks your task composes using `Igniter.compose_task`, passing in the CLI argv + # This ensures your option schema includes options from nested tasks + composes: [], + # `OptionParser` schema + schema: [sync_pnpm: :boolean], + # Default values for the options in the `schema` + defaults: [ + sync_pnpm: false + ], + # CLI aliases + aliases: [], + # A list of options in the schema that are required + required: [] + } + end + + @impl Igniter.Mix.Task + def igniter(igniter) do + # controller endpoints + # - ingest + # - auth + # + igniter + |> configure_package_manager() + |> install_assets() + |> configure_watchers() + |> add_task_aliases() + |> write_layout() + |> define_routes() + |> add_caddy_file() + |> remove_esbuild() + |> add_ingest_flow() + |> run_assets_setup() + end + + defp add_ingest_flow(igniter) do + alias Igniter.Libs.Phoenix + + web_module = Phoenix.web_module(igniter) + {igniter, router} = Igniter.Libs.Phoenix.select_router(igniter) + + igniter + |> Phoenix.add_scope( + "/ingest", + """ + pipe_through :api + + # example router for accepting optimistic writes from the client + # See: https://tanstack.com/db/latest/docs/overview#making-optimistic-mutations + # post "/mutations", Controllers.IngestController, :ingest + """, + arg2: web_module, + router: router, + placement: :after + ) + # phoenix doesn't generally namespace controllers under Web.Controllers + # but igniter ignores my path here and puts the final file in the location + # defined by the module name conventions + |> Igniter.create_new_file( + "lib/#{Macro.underscore(web_module)}/controllers/ingest_controller.ex" |> dbg, + """ + defmodule #{inspect(Module.concat([web_module, Controllers, IngestController]))} do + use #{web_module}, :controller + + # See https://hexdocs.pm/phoenix_sync/readme.html#write-path-sync + + # alias Phoenix.Sync.Writer + + # def ingest(%{assigns: %{current_user: user}} = conn, %{"mutations" => mutations}) do + # {:ok, txid, _changes} = + # Writer.new() + # |> Writer.allow( + # Todos.Todo, + # accept: [:insert], + # check: &Ingest.check_event(&1, user) + # ) + # |> Writer.apply(mutations, Repo, format: Writer.Format.TanstackDB) + # + # json(conn, %{txid: txid}) + # end + end + """ + ) + end + + defp add_caddy_file(igniter) do + igniter + |> create_or_replace_file("Caddyfile") + end + + defp define_routes(igniter) do + {igniter, router} = Igniter.Libs.Phoenix.select_router(igniter) + + igniter + |> Igniter.Project.Module.find_and_update_module!( + router, + fn zipper -> + with {:ok, zipper} <- + Igniter.Code.Function.move_to_function_call( + zipper, + :get, + 3, + fn function_call -> + Igniter.Code.Function.argument_equals?(function_call, 0, "/") && + Igniter.Code.Function.argument_equals?(function_call, 1, PageController) && + Igniter.Code.Function.argument_equals?(function_call, 2, :home) + end + ), + {:ok, zipper} <- + Igniter.Code.Function.update_nth_argument(zipper, 0, fn zipper -> + {:ok, + Igniter.Code.Common.replace_code( + zipper, + Sourceror.parse_string!(~s|"/*page"|) + )} + end), + zipper <- + Igniter.Code.Common.add_comment( + zipper, + "Forward all routes onto the root layout since tanstack router does our routing", + [] + ) do + {:ok, zipper} + end + end + ) + end + + defp run_assets_setup(igniter) do + igniter + |> Igniter.add_task("assets.setup") + end + + defp write_layout(igniter) do + igniter + |> create_or_replace_file( + "lib/#{app_name(igniter)}_web/components/layouts/root.html.heex", + "lib/web/components/layouts/root.html.heex" + ) + end + + defp remove_esbuild(igniter) do + igniter + |> Igniter.add_task("deps.unlock", ["tailwind", "esbuild"]) + |> Igniter.add_task("deps.clean", ["tailwind", "esbuild"]) + |> Igniter.Project.Deps.remove_dep(:esbuild) + |> Igniter.Project.Deps.remove_dep(:tailwind) + |> Igniter.Project.Config.remove_application_configuration("config.exs", :esbuild) + |> Igniter.Project.Config.remove_application_configuration("config.exs", :tailwind) + end + + defp add_task_aliases(igniter) do + igniter + |> set_alias( + "assets.setup", + "cmd --cd assets #{package_manager(igniter)} install --ignore-workspace" + ) + |> set_alias( + "assets.build", + "cmd --cd assets #{js_runner(igniter)} vite build --config vite.config.js --mode development" + ) + |> set_alias( + "assets.deploy", + [ + "cmd --cd assets #{js_runner(igniter)} vite build --config vite.config.js --mode production", + "phx.digest" + ] + ) + end + + defp set_alias(igniter, task_name, command) do + igniter + |> Igniter.Project.TaskAliases.modify_existing_alias( + task_name, + fn zipper -> + Igniter.Code.Common.replace_code(zipper, quote(do: [unquote(command)])) + end + ) + end + + defp configure_watchers(igniter) do + config = + Sourceror.parse_string!(""" + [ + #{js_runner(igniter)}: [ + "vite", + "build", + "--config", + "vite.config.js", + "--mode", + "development", + "--watch", + cd: Path.expand("../assets", __DIR__) + ] + ] + """) + + case Igniter.Libs.Phoenix.select_endpoint(igniter) do + {igniter, nil} -> + igniter + + {igniter, module} -> + igniter + |> Igniter.Project.Config.configure( + "dev.exs", + app_name(igniter), + [module, :watchers], + {:code, config} + ) + end + end + + defp configure_package_manager(igniter) do + if System.find_executable("pnpm") || Keyword.get(igniter.args.options, :sync_pnpm, false) do + igniter + |> Igniter.add_notice("Using pnpm as package manager") + |> Igniter.assign(:package_manager, :pnpm) + else + if System.find_executable("npm") do + igniter + |> Igniter.add_notice("Using pnpm as package manager") + |> Igniter.assign(:package_manager, :npm) + else + igniter + |> Igniter.add_issue("Cannot find suitable package manager: please install pnpm or npm") + end + end + end + + defp install_assets(igniter) do + igniter + |> Igniter.create_or_update_file( + "assets/package.json", + render_template(igniter, "assets/package.json"), + fn src -> + # FIXME: merge dependencies and scripts + Rewrite.Source.update(src, :content, fn _content -> + render_template(igniter, "assets/package.json") + end) + end + ) + |> create_new_file("assets/vite.config.ts") + |> create_new_file("assets/tsconfig.node.json") + |> create_new_file("assets/tsconfig.app.json") + |> create_new_file("assets/tsconfig.json") + |> create_new_file("assets/js/db/auth.ts") + |> create_new_file("assets/js/db/collections.ts") + |> create_new_file("assets/js/db/mutations.ts") + |> create_new_file("assets/js/db/schema.ts") + |> create_new_file("assets/js/routes/__root.tsx") + |> create_new_file("assets/js/routes/index.tsx") + |> create_new_file("assets/js/routes/about.tsx") + |> create_new_file("assets/js/api.ts") + |> create_new_file("assets/js/app.tsx") + |> create_or_replace_file("assets/css/app.css") + end + + defp create_new_file(igniter, path) do + Igniter.create_new_file( + igniter, + path, + render_template(igniter, path) + ) + end + + defp create_or_replace_file(igniter, path, template_path \\ nil) do + contents = render_template(igniter, template_path || path) + + igniter + |> Igniter.create_or_update_file( + path, + contents, + &Rewrite.Source.update(&1, :content, fn _content -> contents end) + ) + end + + defp render_template(igniter, path) when is_binary(path) do + :phoenix_sync + |> :code.priv_dir() + |> Path.join("igniter/phx_sync.tanstack_db/#{path}.eex") + |> Path.expand(__DIR__) + |> EEx.eval_file(assigns: [app_name: app_name(igniter) |> to_string()]) + end + + defp js_runner(igniter) do + case(igniter.assigns.package_manager) do + :pnpm -> :pnpm + :npm -> :npx + end + end + + defp package_manager(igniter) do + igniter.assigns.package_manager + end + end +else + defmodule Mix.Tasks.PhxSync.TanstackDb do + @shortdoc "#{__MODULE__.Docs.short_doc()} | Install `igniter` to use" + + @moduledoc __MODULE__.Docs.long_doc() + + use Mix.Task + + @impl Mix.Task + def run(_argv) do + Mix.shell().error(""" + The task 'phx_sync.tanstack_db' requires igniter. Please install igniter and try again. + + For more information, see: https://hexdocs.pm/igniter/readme.html#installation + """) + + exit({:shutdown, 1}) + end + end +end diff --git a/priv/igniter/phx_sync.tanstack_db/Caddyfile.eex b/priv/igniter/phx_sync.tanstack_db/Caddyfile.eex new file mode 100644 index 0000000..0185ff1 --- /dev/null +++ b/priv/igniter/phx_sync.tanstack_db/Caddyfile.eex @@ -0,0 +1,10 @@ +localhost:4001 { + reverse_proxy localhost:4000 + encode { + gzip + } + header { + Vary "Authorization" + } +} + diff --git a/priv/igniter/phx_sync.tanstack_db/assets/css/app.css.eex b/priv/igniter/phx_sync.tanstack_db/assets/css/app.css.eex new file mode 100644 index 0000000..e13b58e --- /dev/null +++ b/priv/igniter/phx_sync.tanstack_db/assets/css/app.css.eex @@ -0,0 +1,12 @@ +@import "tailwindcss"; +@config "../tailwind.config.js"; + +@theme { + /* --color-brand: #fd4f00; */ +} + +@plugin "@tailwindcss/forms"; + +@custom-variant phx-click-loading (&:where(.phx-click-loading, .phx-click-loading *)); +@custom-variant phx-submit-loading (&:where(.phx-submit-loading, .phx-submit-loading *)); +@custom-variant phx-change-loading (&:where(.phx-change-loading, .phx-change-loading *)); diff --git a/priv/igniter/phx_sync.tanstack_db/assets/js/api.ts.eex b/priv/igniter/phx_sync.tanstack_db/assets/js/api.ts.eex new file mode 100644 index 0000000..9d8cfe1 --- /dev/null +++ b/priv/igniter/phx_sync.tanstack_db/assets/js/api.ts.eex @@ -0,0 +1,64 @@ +import axios, { AxiosError } from 'axios' +import type { PendingMutation } from '@tanstack/react-db' + +import { authCollection } from './db/collections' +import type { User } from './db/schema' + +type SignInResult = Pick + +type IngestPayload = { + mutations: Omit[] +} + +const authHeaders = () => { + const auth = authCollection.get('current') + + return auth !== undefined ? { Authorization: `Bearer ${auth.user_id}` } : {} +} + +export async function signIn( + username: string, + avatarUrl: string | undefined +): Promise { + const data = { + avatar_url: avatarUrl !== undefined ? avatarUrl : null, + username, + } + const headers = authHeaders() + + try { + const response = await axios.post('/auth/sign-in', data, { headers }) + const { id: user_id }: SignInResult = response.data + + return user_id + } catch (err: unknown) { + if (err instanceof AxiosError) { + return + } + + throw err + } +} + +export async function ingest( + payload: IngestPayload +): Promise { + const headers = authHeaders() + + try { + const response = await axios.post('/ingest/mutations', payload, { headers }) + + // Phoenix sync should return txid as a number but older versions used a string. + // So handle either, making sure we treat it internally as a number. + const txid = response.data.txid as string | number + const txidInt = typeof txid === 'string' ? parseInt(txid, 10) : txid + + return txidInt + } catch (err: unknown) { + if (err instanceof AxiosError) { + return + } + + throw err + } +} diff --git a/priv/igniter/phx_sync.tanstack_db/assets/js/app.tsx.eex b/priv/igniter/phx_sync.tanstack_db/assets/js/app.tsx.eex new file mode 100644 index 0000000..5cd7b08 --- /dev/null +++ b/priv/igniter/phx_sync.tanstack_db/assets/js/app.tsx.eex @@ -0,0 +1,28 @@ +import "../css/app.css" +import { StrictMode } from 'react' +import ReactDOM from 'react-dom/client' +import { RouterProvider, createRouter } from '@tanstack/react-router' + +// Import the generated route tree +import { routeTree } from './routeTree.gen' + +// Create a new router instance +const router = createRouter({ routeTree }) + +// Register the router instance for type safety +declare module '@tanstack/react-router' { + interface Register { + router: typeof router + } +} + +// Render the app +const rootElement = document.getElementById('root')! +if (!rootElement.innerHTML) { + const root = ReactDOM.createRoot(rootElement) + root.render( + + + , + ) +} diff --git a/priv/igniter/phx_sync.tanstack_db/assets/js/db/auth.ts.eex b/priv/igniter/phx_sync.tanstack_db/assets/js/db/auth.ts.eex new file mode 100644 index 0000000..66d4c46 --- /dev/null +++ b/priv/igniter/phx_sync.tanstack_db/assets/js/db/auth.ts.eex @@ -0,0 +1,30 @@ +import { authCollection } from './collections' +import type { Auth } from './schema' + +type CurrentAuth = Auth | undefined + +type AuthResult = { + currentUserId: string | null + isAuthenticated: boolean +} + +export async function signIn(user_id: string): Promise { + await authCollection.insert({ + key: 'current', + user_id: user_id, + }) +} + +export async function signOut(): Promise { + await authCollection.delete('current') +} + +export function useAuth(): AuthResult { + const auth: CurrentAuth = authCollection.get('current') + + const currentUserId = auth !== undefined ? auth.user_id : null + const isAuthenticated = currentUserId !== null + + return { currentUserId, isAuthenticated } +} + diff --git a/priv/igniter/phx_sync.tanstack_db/assets/js/db/collections.ts.eex b/priv/igniter/phx_sync.tanstack_db/assets/js/db/collections.ts.eex new file mode 100644 index 0000000..0198f58 --- /dev/null +++ b/priv/igniter/phx_sync.tanstack_db/assets/js/db/collections.ts.eex @@ -0,0 +1,90 @@ +import {createCollection, localStorageCollectionOptions,} from '@tanstack/react-db' +import { ingestMutations } from './mutations' + +import type { Value } from '@electric-sql/client' +import type { ElectricCollectionUtils } from '@tanstack/electric-db-collection' +import type { + InsertMutationFn, + UpdateMutationFn, + DeleteMutationFn, +} from '@tanstack/react-db' +import {authSchema, userSchema,} from './schema' + +import type { Auth, User } from './schema' + +type CollectionKey = string | number + +export const authCollection = createCollection( + localStorageCollectionOptions({ + storageKey: 'auth', + getKey: (item: Auth) => item.key, + onInsert: async () => true, + onUpdate: async () => true, + onDelete: async () => true, + schema: authSchema, + }) +) + +const headers = { + Authorization: async () => { + const auth = authCollection.get('current') + + return auth ? `Bearer ${auth.user_id}` : 'Unauthenticated' + }, +} + +async function onError(error: Error) { + const status = + 'status' in error && Number.isInteger(error.status) + ? error.status as number + : undefined + + if (status === 403 && authCollection.has('current')) { + await authCollection.delete('current') + + return { headers } + } + + if (status === 401) { + await new Promise((resolve) => authCollection.subscribeChanges(resolve)) + + return { headers } + } + + throw error +} + +const parser = { + timestamp: (dateStr: string) => { + // Timestamps sync in as naive datetime strings with no + // timezone info because they're all implicitly UTC. + const utcDateStr = dateStr.endsWith('Z') ? dateStr : `${dateStr}Z` + const date: Date = new Date(utcDateStr) + + // Cast to `Value`` because we haven't fixed the typing yet + // https://github.com/TanStack/db/pull/201 + return date as unknown as Value + }, +} + +const baseShapeOptions = { + headers, + onError, + parser, +} + +function operationHandlers() { + return { + onInsert: ingestMutations as InsertMutationFn, + onUpdate: ingestMutations as UpdateMutationFn, + onDelete: ingestMutations as DeleteMutationFn, + } +} + +function relativeUrl(path: string) { + return `${window.location.origin}${path}` +} + + +// @ts-ignore +window.authCollection = authCollection diff --git a/priv/igniter/phx_sync.tanstack_db/assets/js/db/mutations.ts.eex b/priv/igniter/phx_sync.tanstack_db/assets/js/db/mutations.ts.eex new file mode 100644 index 0000000..c7868de --- /dev/null +++ b/priv/igniter/phx_sync.tanstack_db/assets/js/db/mutations.ts.eex @@ -0,0 +1,78 @@ +import * as api from '../api' + +import type { ElectricCollectionUtils } from '@tanstack/electric-db-collection' +import type { + Collection, + MutationFn, + PendingMutation, + Transaction, + UtilsRecord, +} from '@tanstack/react-db' + +type MutationData = Omit + +const ONE_HOUR = 60 * 60 * 1_000 + +function isElectricUtils(utils: UtilsRecord): utils is ElectricCollectionUtils { + return 'awaitTxId' in utils && typeof (utils as any).awaitTxId === 'function' +} + +function patchRelationMetadata( + result: MutationData, + collection: Collection +): MutationData { + // Set the sync metadata from the collection id, because the default + // implementation looks for a `table` param which we don't use. + const parts = collection.id.split(':') + const relation = parts.length === 2 ? parts : ['public', parts[0]] + + result.syncMetadata = { relation } + return result +} + +function buildPayload(tx: Transaction) { + const mutations = tx.mutations.map((mutation: PendingMutation) => { + const { collection, ...result } = mutation + + return mutation.type === 'insert' + ? patchRelationMetadata(result, collection) + : result + }) + + return { mutations } +} + +async function hasSyncedBack( + tx: Transaction, + txid: number, + timeout: number = ONE_HOUR +) { + const collections = new Set( + tx.mutations.map((mutation) => mutation.collection).filter(Boolean) + ) + + const promises = [...collections].map((collection) => { + const utils = collection.utils + + if (isElectricUtils(utils)) { + return utils.awaitTxId(txid, timeout) + } + + throw new Error(`Unknown collection type`, { cause: { collection } }) + }) + + await Promise.all(promises) +} + +export const ingestMutations: MutationFn = async ({ transaction }) => { + const payload = buildPayload(transaction) + const txid = await api.ingest(payload) + + if (txid === undefined) { + return + } + + await hasSyncedBack(transaction, txid) + + return { txid } +} diff --git a/priv/igniter/phx_sync.tanstack_db/assets/js/db/schema.ts.eex b/priv/igniter/phx_sync.tanstack_db/assets/js/db/schema.ts.eex new file mode 100644 index 0000000..20800da --- /dev/null +++ b/priv/igniter/phx_sync.tanstack_db/assets/js/db/schema.ts.eex @@ -0,0 +1,16 @@ +import * as z from 'zod/v4' + + +export const authSchema = z.object({ + key: z.literal('current'), + user_id: z.uuid(), +}) + +export const userSchema = z.object({ + id: z.uuid(), + name: z.string() +}) + +export type Auth = z.infer +export type User = z.infer + diff --git a/priv/igniter/phx_sync.tanstack_db/assets/js/routes/__root.tsx.eex b/priv/igniter/phx_sync.tanstack_db/assets/js/routes/__root.tsx.eex new file mode 100644 index 0000000..db80599 --- /dev/null +++ b/priv/igniter/phx_sync.tanstack_db/assets/js/routes/__root.tsx.eex @@ -0,0 +1,29 @@ +import { createRootRoute, Link, Outlet } from '@tanstack/react-router' +import { TanStackRouterDevtools } from '@tanstack/react-router-devtools' +import { useAuth } from '../db/auth' +import { authCollection } from '../db/collections' + +const RootLayout = () => ( + <> +
+ + Home + {' '} + + About + +
+
+ + + +) + + +export const Route = createRootRoute({ + component: RootLayout, + loader: async () => { + await authCollection.preload() + }, +}) + diff --git a/priv/igniter/phx_sync.tanstack_db/assets/js/routes/about.tsx.eex b/priv/igniter/phx_sync.tanstack_db/assets/js/routes/about.tsx.eex new file mode 100644 index 0000000..cedc819 --- /dev/null +++ b/priv/igniter/phx_sync.tanstack_db/assets/js/routes/about.tsx.eex @@ -0,0 +1,9 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/about')({ + component: About, +}) + +function About() { + return
Hello from About!
+} diff --git a/priv/igniter/phx_sync.tanstack_db/assets/js/routes/index.tsx.eex b/priv/igniter/phx_sync.tanstack_db/assets/js/routes/index.tsx.eex new file mode 100644 index 0000000..2ed7b5d --- /dev/null +++ b/priv/igniter/phx_sync.tanstack_db/assets/js/routes/index.tsx.eex @@ -0,0 +1,13 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/')({ + component: Index, +}) + +function Index() { + return ( +
+

Welcome Home!

+
+ ) +} diff --git a/priv/igniter/phx_sync.tanstack_db/assets/package.json.eex b/priv/igniter/phx_sync.tanstack_db/assets/package.json.eex new file mode 100644 index 0000000..3abd5bf --- /dev/null +++ b/priv/igniter/phx_sync.tanstack_db/assets/package.json.eex @@ -0,0 +1,51 @@ +{ + "type": "module", + "scripts": { + "build": "tsc -b && vite build --mode development", + "build:only": "vite build --mode development", + "build:prod": "vite build --mode production", + "dev": "vite", + "format": "prettier --write \"**/*.{ts,tsx,js,jsx,json,css,md}\"", + "format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,json,css,md}\"", + "lint": "eslint .", + "preview": "vite preview", + "typecheck": "tsc -b" + }, + "dependencies": { + "phoenix": "file:../deps/phoenix", + "phoenix_html": "file:../deps/phoenix_html", + "phoenix_live_view": "file:../deps/phoenix_live_view", + "@electric-sql/client": "^1.0.9", + "@tanstack/db": "^0.1.3", + "@tanstack/electric-db-collection": "^0.1.3", + "@tanstack/react-db": "^0.1.3", + "@tanstack/react-router": "^1.131.7", + "@tanstack/react-router-devtools": "^1.131.7", + "axios": "^1.11.0", + "react": "19.1.1", + "react-dom": "19.1.1", + "react-json-view-lite": "^2.4.2", + "zod": "^4.0.17" + }, + "devDependencies": { + "tailwindcss": "^4.1.12", + "@tailwindcss/forms": "^0.5.10", + "@tailwindcss/vite": "^4.1.12", + "@tanstack/router-plugin": "^1.131.7", + "@types/node": "^24.2.1", + "@types/react": "^19.1.4", + "@types/react-dom": "^19.1.7", + "@typescript-eslint/eslint-plugin": "^7.2.0", + "@typescript-eslint/parser": "^7.2.0", + "@vitejs/plugin-react": "^4.4.1", + "eslint": "^8.57.0", + "eslint-config-prettier": "^10.1.5", + "eslint-plugin-prettier": "^5.4.0", + "eslint-plugin-react-hooks": "^4.6.0", + "eslint-plugin-react-refresh": "^0.4.6", + "prettier": "^3.2.4", + "typescript": "^5.2.2", + "vite": "^6.2.3" + } +} + diff --git a/priv/igniter/phx_sync.tanstack_db/assets/tailwind.config.js.eex b/priv/igniter/phx_sync.tanstack_db/assets/tailwind.config.js.eex new file mode 100644 index 0000000..b22cf46 --- /dev/null +++ b/priv/igniter/phx_sync.tanstack_db/assets/tailwind.config.js.eex @@ -0,0 +1,3 @@ +module.exports = { + content: ["./js/**/*.js", "../lib/**/*.*ex"], +}; diff --git a/priv/igniter/phx_sync.tanstack_db/assets/tsconfig.app.json.eex b/priv/igniter/phx_sync.tanstack_db/assets/tsconfig.app.json.eex new file mode 100644 index 0000000..d9268d4 --- /dev/null +++ b/priv/igniter/phx_sync.tanstack_db/assets/tsconfig.app.json.eex @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "ES2022", + "useDefineForClassFields": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["js"] +} + diff --git a/priv/igniter/phx_sync.tanstack_db/assets/tsconfig.json.eex b/priv/igniter/phx_sync.tanstack_db/assets/tsconfig.json.eex new file mode 100644 index 0000000..e891e30 --- /dev/null +++ b/priv/igniter/phx_sync.tanstack_db/assets/tsconfig.json.eex @@ -0,0 +1,8 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} + diff --git a/priv/igniter/phx_sync.tanstack_db/assets/tsconfig.node.json.eex b/priv/igniter/phx_sync.tanstack_db/assets/tsconfig.node.json.eex new file mode 100644 index 0000000..fec2ba1 --- /dev/null +++ b/priv/igniter/phx_sync.tanstack_db/assets/tsconfig.node.json.eex @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "ES2022", + "lib": ["ES2023"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["vite.config.ts"] +} + diff --git a/priv/igniter/phx_sync.tanstack_db/assets/vite.config.ts.eex b/priv/igniter/phx_sync.tanstack_db/assets/vite.config.ts.eex new file mode 100644 index 0000000..987f03e --- /dev/null +++ b/priv/igniter/phx_sync.tanstack_db/assets/vite.config.ts.eex @@ -0,0 +1,46 @@ +import { defineConfig, loadEnv } from 'vite' +import react from '@vitejs/plugin-react' +import tailwindcss from '@tailwindcss/vite' + +import { tanstackRouter } from '@tanstack/router-plugin/vite' + +export default defineConfig(({ command, mode }) => { + const env = loadEnv(mode, process.cwd(), '') + const isProd = mode === 'production' + + return { + publicDir: false, + build: { + outDir: '../priv/static', + target: ['es2022'], + minify: isProd, + sourcemap: !isProd, + rollupOptions: { + input: 'js/app.tsx', + output: { + assetFileNames: 'assets/[name][extname]', + chunkFileNames: 'assets/chunk/[name].js', + entryFileNames: 'assets/[name].js', + }, + }, + }, + define: { + __APP_ENV__: env.APP_ENV, + // Explicitly force production React + 'process.env.NODE_ENV': JSON.stringify(isProd ? 'production' : 'development'), + 'import.meta.env.PROD': isProd, + 'import.meta.env.DEV': !isProd, + }, + plugins: [ + tanstackRouter({ + target: 'react', + autoCodeSplitting: true, + routesDirectory: "./js/routes", + generatedRouteTree: "./js/routeTree.gen.ts", + }), + react(), + tailwindcss(), + ], + } +}) + diff --git a/priv/igniter/phx_sync.tanstack_db/lib/web/components/layouts/root.html.heex.eex b/priv/igniter/phx_sync.tanstack_db/lib/web/components/layouts/root.html.heex.eex new file mode 100644 index 0000000..2971279 --- /dev/null +++ b/priv/igniter/phx_sync.tanstack_db/lib/web/components/layouts/root.html.heex.eex @@ -0,0 +1,17 @@ + + + + + + + <.live_title default="<%= Macro.camelize(@app_name) %>" suffix=" ยท Phoenix Framework"> + {assigns[:page_title]} + + + + + +
+ + From d5ed14d8833c3115b6dbcb64cfe7278ed250a6cd Mon Sep 17 00:00:00 2001 From: Garry Hill Date: Thu, 4 Sep 2025 09:45:57 +0100 Subject: [PATCH 02/18] [wip] --- lib/mix/tasks/phx_sync.tanstack_db.ex | 4 ---- test/mix/tasks/phx_sync.tanstack_db_test.exs | 13 +++++++++++++ 2 files changed, 13 insertions(+), 4 deletions(-) create mode 100644 test/mix/tasks/phx_sync.tanstack_db_test.exs diff --git a/lib/mix/tasks/phx_sync.tanstack_db.ex b/lib/mix/tasks/phx_sync.tanstack_db.ex index 2b4ddb4..0407bf0 100644 --- a/lib/mix/tasks/phx_sync.tanstack_db.ex +++ b/lib/mix/tasks/phx_sync.tanstack_db.ex @@ -75,10 +75,6 @@ if Code.ensure_loaded?(Igniter) do @impl Igniter.Mix.Task def igniter(igniter) do - # controller endpoints - # - ingest - # - auth - # igniter |> configure_package_manager() |> install_assets() diff --git a/test/mix/tasks/phx_sync.tanstack_db_test.exs b/test/mix/tasks/phx_sync.tanstack_db_test.exs new file mode 100644 index 0000000..a01ab24 --- /dev/null +++ b/test/mix/tasks/phx_sync.tanstack_db_test.exs @@ -0,0 +1,13 @@ +defmodule Mix.Tasks.PhxSync.TanstackDbTest do + use ExUnit.Case, async: true + import Igniter.Test + + test "it warns when run" do + # generate a test project + test_project() + # run our task + |> Igniter.compose_task("phx_sync.tanstack_db", []) + # see tools in `Igniter.Test` for available assertions & helpers + |> assert_has_warning("mix phx_sync.tanstack_db is not yet implemented") + end +end From ec555ee2942d1c9cab49ca6d2dcb66b22a3c945c Mon Sep 17 00:00:00 2001 From: Garry Hill Date: Thu, 4 Sep 2025 11:18:05 +0100 Subject: [PATCH 03/18] update deps --- .../assets/package.json.eex | 28 +++++++++---------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/priv/igniter/phx_sync.tanstack_db/assets/package.json.eex b/priv/igniter/phx_sync.tanstack_db/assets/package.json.eex index 3abd5bf..ef8467e 100644 --- a/priv/igniter/phx_sync.tanstack_db/assets/package.json.eex +++ b/priv/igniter/phx_sync.tanstack_db/assets/package.json.eex @@ -16,16 +16,16 @@ "phoenix_html": "file:../deps/phoenix_html", "phoenix_live_view": "file:../deps/phoenix_live_view", "@electric-sql/client": "^1.0.9", - "@tanstack/db": "^0.1.3", - "@tanstack/electric-db-collection": "^0.1.3", - "@tanstack/react-db": "^0.1.3", - "@tanstack/react-router": "^1.131.7", - "@tanstack/react-router-devtools": "^1.131.7", + "@tanstack/db": "^0.1.11", + "@tanstack/electric-db-collection": "^0.1.12", + "@tanstack/react-db": "^0.1.11", + "@tanstack/react-router": "^1.131.35", + "@tanstack/react-router-devtools": "^1.131.35", "axios": "^1.11.0", "react": "19.1.1", "react-dom": "19.1.1", "react-json-view-lite": "^2.4.2", - "zod": "^4.0.17" + "zod": "^4.1.5" }, "devDependencies": { "tailwindcss": "^4.1.12", @@ -38,14 +38,14 @@ "@typescript-eslint/eslint-plugin": "^7.2.0", "@typescript-eslint/parser": "^7.2.0", "@vitejs/plugin-react": "^4.4.1", - "eslint": "^8.57.0", - "eslint-config-prettier": "^10.1.5", - "eslint-plugin-prettier": "^5.4.0", - "eslint-plugin-react-hooks": "^4.6.0", - "eslint-plugin-react-refresh": "^0.4.6", - "prettier": "^3.2.4", - "typescript": "^5.2.2", - "vite": "^6.2.3" + "eslint": "^9.34.0", + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-prettier": "^5.5.4", + "eslint-plugin-react-hooks": "^5.2.0", + "eslint-plugin-react-refresh": "^0.4.20", + "prettier": "^3.6.2", + "typescript": "^5.9.2", + "vite": "^7.1.4" } } From af33f6cbe14afbf22638c0f88e710ce996e9f5d9 Mon Sep 17 00:00:00 2001 From: Garry Hill Date: Thu, 4 Sep 2025 12:33:38 +0100 Subject: [PATCH 04/18] add tests and update after feedback --- lib/mix/tasks/phx_sync.tanstack_db.ex | 35 ++++-- .../phx_sync.tanstack_db/assets/js/api.ts.eex | 81 +++++++------ .../assets/package.json.eex | 6 +- test/mix/tasks/phx_sync.tanstack_db_test.exs | 112 ++++++++++++++++-- 4 files changed, 175 insertions(+), 59 deletions(-) diff --git a/lib/mix/tasks/phx_sync.tanstack_db.ex b/lib/mix/tasks/phx_sync.tanstack_db.ex index 0407bf0..5439bea 100644 --- a/lib/mix/tasks/phx_sync.tanstack_db.ex +++ b/lib/mix/tasks/phx_sync.tanstack_db.ex @@ -64,7 +64,7 @@ if Code.ensure_loaded?(Igniter) do schema: [sync_pnpm: :boolean], # Default values for the options in the `schema` defaults: [ - sync_pnpm: false + sync_pnpm: true ], # CLI aliases aliases: [], @@ -112,7 +112,7 @@ if Code.ensure_loaded?(Igniter) do # but igniter ignores my path here and puts the final file in the location # defined by the module name conventions |> Igniter.create_new_file( - "lib/#{Macro.underscore(web_module)}/controllers/ingest_controller.ex" |> dbg, + "lib/#{Macro.underscore(web_module)}/controllers/ingest_controller.ex", """ defmodule #{inspect(Module.concat([web_module, Controllers, IngestController]))} do use #{web_module}, :controller @@ -182,8 +182,11 @@ if Code.ensure_loaded?(Igniter) do end defp run_assets_setup(igniter) do - igniter - |> Igniter.add_task("assets.setup") + if igniter.assigns[:test_mode?] do + igniter + else + Igniter.add_task(igniter, "assets.setup") + end end defp write_layout(igniter) do @@ -266,14 +269,14 @@ if Code.ensure_loaded?(Igniter) do end defp configure_package_manager(igniter) do - if System.find_executable("pnpm") || Keyword.get(igniter.args.options, :sync_pnpm, false) do + if System.find_executable("pnpm") && Keyword.get(igniter.args.options, :sync_pnpm, true) do igniter |> Igniter.add_notice("Using pnpm as package manager") |> Igniter.assign(:package_manager, :pnpm) else if System.find_executable("npm") do igniter - |> Igniter.add_notice("Using pnpm as package manager") + |> Igniter.add_notice("Using npm as package manager") |> Igniter.assign(:package_manager, :npm) else igniter @@ -288,7 +291,6 @@ if Code.ensure_loaded?(Igniter) do "assets/package.json", render_template(igniter, "assets/package.json"), fn src -> - # FIXME: merge dependencies and scripts Rewrite.Source.update(src, :content, fn _content -> render_template(igniter, "assets/package.json") end) @@ -298,6 +300,7 @@ if Code.ensure_loaded?(Igniter) do |> create_new_file("assets/tsconfig.node.json") |> create_new_file("assets/tsconfig.app.json") |> create_new_file("assets/tsconfig.json") + |> create_or_replace_file("assets/tailwind.config.js") |> create_new_file("assets/js/db/auth.ts") |> create_new_file("assets/js/db/collections.ts") |> create_new_file("assets/js/db/mutations.ts") @@ -308,6 +311,7 @@ if Code.ensure_loaded?(Igniter) do |> create_new_file("assets/js/api.ts") |> create_new_file("assets/js/app.tsx") |> create_or_replace_file("assets/css/app.css") + |> Igniter.rm("assets/js/app.js") end defp create_new_file(igniter, path) do @@ -330,11 +334,22 @@ if Code.ensure_loaded?(Igniter) do end defp render_template(igniter, path) when is_binary(path) do + template_contents(path, app_name: app_name(igniter) |> to_string()) + end + + @doc false + def template_contents(path, assigns) do + template_dir() + |> Path.join("#{path}.eex") + |> Path.expand(__DIR__) + |> EEx.eval_file(assigns: assigns) + end + + @doc false + def template_dir do :phoenix_sync |> :code.priv_dir() - |> Path.join("igniter/phx_sync.tanstack_db/#{path}.eex") - |> Path.expand(__DIR__) - |> EEx.eval_file(assigns: [app_name: app_name(igniter) |> to_string()]) + |> Path.join("igniter/phx_sync.tanstack_db") end defp js_runner(igniter) do diff --git a/priv/igniter/phx_sync.tanstack_db/assets/js/api.ts.eex b/priv/igniter/phx_sync.tanstack_db/assets/js/api.ts.eex index 9d8cfe1..8548834 100644 --- a/priv/igniter/phx_sync.tanstack_db/assets/js/api.ts.eex +++ b/priv/igniter/phx_sync.tanstack_db/assets/js/api.ts.eex @@ -1,64 +1,67 @@ -import axios, { AxiosError } from 'axios' -import type { PendingMutation } from '@tanstack/react-db' +import type { PendingMutation } from "@tanstack/react-db"; -import { authCollection } from './db/collections' -import type { User } from './db/schema' +import { authCollection } from "./db/collections"; +import type { User } from "./db/schema"; -type SignInResult = Pick +type SignInResult = Pick; type IngestPayload = { - mutations: Omit[] -} + mutations: Omit[]; +}; -const authHeaders = () => { - const auth = authCollection.get('current') +const authHeaders = (): { authorization: string } | {} => { + const auth = authCollection.get("current"); - return auth !== undefined ? { Authorization: `Bearer ${auth.user_id}` } : {} -} + return auth !== undefined ? { authorization: `Bearer ${auth.user_id}` } : {}; +}; + +const reqHeaders = () => { + return { + "content-type": "application/json", + accept: "application/json", + ...authHeaders(), + }; +}; export async function signIn( username: string, - avatarUrl: string | undefined + avatarUrl: string | undefined, ): Promise { const data = { avatar_url: avatarUrl !== undefined ? avatarUrl : null, username, - } - const headers = authHeaders() + }; + const headers = reqHeaders(); - try { - const response = await axios.post('/auth/sign-in', data, { headers }) - const { id: user_id }: SignInResult = response.data + const response = await fetch("/auth/sign-in", { + method: "POST", + body: JSON.stringify(data), + headers, + }); - return user_id - } catch (err: unknown) { - if (err instanceof AxiosError) { - return - } - - throw err + if (response.ok) { + const { id: user_id }: SignInResult = await response.json(); + return user_id; } } export async function ingest( - payload: IngestPayload + payload: IngestPayload, ): Promise { - const headers = authHeaders() - - try { - const response = await axios.post('/ingest/mutations', payload, { headers }) + const headers = reqHeaders(); - // Phoenix sync should return txid as a number but older versions used a string. - // So handle either, making sure we treat it internally as a number. - const txid = response.data.txid as string | number - const txidInt = typeof txid === 'string' ? parseInt(txid, 10) : txid + const response = await fetch("/ingest/mutations", { + method: "POST", + body: JSON.stringify(payload), + headers, + }); - return txidInt - } catch (err: unknown) { - if (err instanceof AxiosError) { - return - } + if (response.ok) { + const data = await response.json(); + const txid = data.txid as string | number; + const txidInt = typeof txid === "string" ? parseInt(txid, 10) : txid; - throw err + return txidInt; } } + diff --git a/priv/igniter/phx_sync.tanstack_db/assets/package.json.eex b/priv/igniter/phx_sync.tanstack_db/assets/package.json.eex index ef8467e..c9aa61a 100644 --- a/priv/igniter/phx_sync.tanstack_db/assets/package.json.eex +++ b/priv/igniter/phx_sync.tanstack_db/assets/package.json.eex @@ -1,4 +1,6 @@ { + "name": "<%= @app_name %>", + "version": "0.0.0", "type": "module", "scripts": { "build": "tsc -b && vite build --mode development", @@ -16,12 +18,10 @@ "phoenix_html": "file:../deps/phoenix_html", "phoenix_live_view": "file:../deps/phoenix_live_view", "@electric-sql/client": "^1.0.9", - "@tanstack/db": "^0.1.11", "@tanstack/electric-db-collection": "^0.1.12", "@tanstack/react-db": "^0.1.11", "@tanstack/react-router": "^1.131.35", "@tanstack/react-router-devtools": "^1.131.35", - "axios": "^1.11.0", "react": "19.1.1", "react-dom": "19.1.1", "react-json-view-lite": "^2.4.2", @@ -38,7 +38,7 @@ "@typescript-eslint/eslint-plugin": "^7.2.0", "@typescript-eslint/parser": "^7.2.0", "@vitejs/plugin-react": "^4.4.1", - "eslint": "^9.34.0", + "eslint": "^8.56.0", "eslint-config-prettier": "^10.1.8", "eslint-plugin-prettier": "^5.5.4", "eslint-plugin-react-hooks": "^5.2.0", diff --git a/test/mix/tasks/phx_sync.tanstack_db_test.exs b/test/mix/tasks/phx_sync.tanstack_db_test.exs index a01ab24..327f531 100644 --- a/test/mix/tasks/phx_sync.tanstack_db_test.exs +++ b/test/mix/tasks/phx_sync.tanstack_db_test.exs @@ -1,13 +1,111 @@ defmodule Mix.Tasks.PhxSync.TanstackDbTest do use ExUnit.Case, async: true + import Igniter.Test - test "it warns when run" do - # generate a test project - test_project() - # run our task - |> Igniter.compose_task("phx_sync.tanstack_db", []) - # see tools in `Igniter.Test` for available assertions & helpers - |> assert_has_warning("mix phx_sync.tanstack_db is not yet implemented") + import Mix.Tasks.PhxSync.TanstackDb, only: [template_dir: 0, template_contents: 2] + + defp assert_renders_template(igniter, {template_path, render_path}) do + igniter + |> assert_content_equals(render_path, template_contents(template_path, app_name: "test")) + end + + test "package.json" do + json = template_contents("assets/package.json", app_name: "test") + assert json =~ ~r("type": "module") + assert json =~ ~r("name": "test") + assert json =~ ~r("version": "0.0.0") + end + + test "installs assets from templates" do + templates = + template_dir() + |> Path.join("**/*.*") + |> Path.wildcard() + |> Enum.map(&Path.relative_to(&1, template_dir())) + |> Enum.map(fn path -> + dir = + Path.dirname(path) + |> String.replace(~r/^\.$/, "") + + render_dir = + dir |> String.replace(~r|lib/web/components|, "lib/test_web/components") + + file = Path.basename(path) + + file = String.replace(file, ~r/\.eex$/, "") + + {Path.join(dir, file), Path.join(render_dir, file)} + end) + + igniter = + phx_test_project() + |> Igniter.compose_task("phx_sync.tanstack_db", []) + + for template <- templates do + assert_renders_template(igniter, template) + end + end + + test "patches tasks" do + igniter = + phx_test_project() + |> Igniter.compose_task("phx_sync.tanstack_db", ["--sync-pnpm"]) + + assert_has_patch(igniter, "mix.exs", """ + - | {:esbuild, "~> 0.8", runtime: Mix.env() == :dev}, + - | {:tailwind, "~> 0.2", runtime: Mix.env() == :dev}, + """) + + assert_has_patch(igniter, "mix.exs", """ + - | "assets.setup": ["tailwind.install --if-missing", "esbuild.install --if-missing"], + - | "assets.build": ["tailwind test", "esbuild test"], + + | "assets.setup": ["cmd --cd assets pnpm install --ignore-workspace"], + + | "assets.build": [ + + | "cmd --cd assets pnpm vite build --config vite.config.js --mode development" + + | ], + """) + end + + test "configures watchers in dev" do + igniter = + phx_test_project() + |> Igniter.compose_task("phx_sync.tanstack_db", ["--sync-pnpm"]) + + assert_has_patch(igniter, "config/dev.exs", """ + - | esbuild: {Esbuild, :install_and_run, [:test, ~w(--sourcemap=inline --watch)]}, + - | tailwind: {Tailwind, :install_and_run, [:test, ~w(--watch)]} + + | pnpm: [ + + | "vite", + + | "build", + + | "--config", + + | "vite.config.js", + + | "--mode", + + | "development", + + | "--watch", + + | cd: Path.expand("../assets", __DIR__) + + | ] + """) + end + + test "uses npm if told" do + igniter = + phx_test_project() + |> Igniter.compose_task("phx_sync.tanstack_db", ["--no-sync-pnpm"]) + + assert_has_patch(igniter, "config/dev.exs", """ + - | esbuild: {Esbuild, :install_and_run, [:test, ~w(--sourcemap=inline --watch)]}, + - | tailwind: {Tailwind, :install_and_run, [:test, ~w(--watch)]} + + | npx: [ + + | "vite", + + | "build", + + | "--config", + + | "vite.config.js", + + | "--mode", + + | "development", + + | "--watch", + + | cd: Path.expand("../assets", __DIR__) + + | ] + """) end end From dd9ee686e1beda2d62af4b3eee865ba6bfe4634f Mon Sep 17 00:00:00 2001 From: Garry Hill Date: Thu, 4 Sep 2025 13:35:37 +0100 Subject: [PATCH 05/18] better typing --- priv/igniter/phx_sync.tanstack_db/assets/js/api.ts.eex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/priv/igniter/phx_sync.tanstack_db/assets/js/api.ts.eex b/priv/igniter/phx_sync.tanstack_db/assets/js/api.ts.eex index 8548834..a9a7690 100644 --- a/priv/igniter/phx_sync.tanstack_db/assets/js/api.ts.eex +++ b/priv/igniter/phx_sync.tanstack_db/assets/js/api.ts.eex @@ -9,7 +9,7 @@ type IngestPayload = { mutations: Omit[]; }; -const authHeaders = (): { authorization: string } | {} => { +const authHeaders = (): { authorization?: string } => { const auth = authCollection.get("current"); return auth !== undefined ? { authorization: `Bearer ${auth.user_id}` } : {}; From 0f888acda2c60e1cd1d6f7e984a5cc0e5c5203b3 Mon Sep 17 00:00:00 2001 From: Garry Hill Date: Thu, 4 Sep 2025 13:41:54 +0100 Subject: [PATCH 06/18] test for removal of app.js --- test/mix/tasks/phx_sync.tanstack_db_test.exs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/test/mix/tasks/phx_sync.tanstack_db_test.exs b/test/mix/tasks/phx_sync.tanstack_db_test.exs index 327f531..0fe565a 100644 --- a/test/mix/tasks/phx_sync.tanstack_db_test.exs +++ b/test/mix/tasks/phx_sync.tanstack_db_test.exs @@ -108,4 +108,12 @@ defmodule Mix.Tasks.PhxSync.TanstackDbTest do + | ] """) end + + test "removes app.js" do + igniter = + phx_test_project() + |> Igniter.compose_task("phx_sync.tanstack_db", ["--sync-pnpm"]) + + assert_rms(igniter, ["assets/js/app.js"]) + end end From 3b7a1493bb93b20dc475fe5e89701a149a47fae4 Mon Sep 17 00:00:00 2001 From: Garry Hill Date: Thu, 4 Sep 2025 13:44:06 +0100 Subject: [PATCH 07/18] rename to phx.sync... --- ...sync.tanstack_db.ex => phx.sync.tanstack_db.ex} | 8 ++++---- .../Caddyfile.eex | 0 .../assets/css/app.css.eex | 0 .../assets/js/api.ts.eex | 0 .../assets/js/app.tsx.eex | 0 .../assets/js/db/auth.ts.eex | 0 .../assets/js/db/collections.ts.eex | 0 .../assets/js/db/mutations.ts.eex | 0 .../assets/js/db/schema.ts.eex | 0 .../assets/js/routes/__root.tsx.eex | 0 .../assets/js/routes/about.tsx.eex | 0 .../assets/js/routes/index.tsx.eex | 0 .../assets/package.json.eex | 0 .../assets/tailwind.config.js.eex | 0 .../assets/tsconfig.app.json.eex | 0 .../assets/tsconfig.json.eex | 0 .../assets/tsconfig.node.json.eex | 0 .../assets/vite.config.ts.eex | 0 .../lib/web/components/layouts/root.html.heex.eex | 0 ...k_db_test.exs => phx.sync.tanstack_db_test.exs} | 14 +++++++------- 20 files changed, 11 insertions(+), 11 deletions(-) rename lib/mix/tasks/{phx_sync.tanstack_db.ex => phx.sync.tanstack_db.ex} (98%) rename priv/igniter/{phx_sync.tanstack_db => phx.sync.tanstack_db}/Caddyfile.eex (100%) rename priv/igniter/{phx_sync.tanstack_db => phx.sync.tanstack_db}/assets/css/app.css.eex (100%) rename priv/igniter/{phx_sync.tanstack_db => phx.sync.tanstack_db}/assets/js/api.ts.eex (100%) rename priv/igniter/{phx_sync.tanstack_db => phx.sync.tanstack_db}/assets/js/app.tsx.eex (100%) rename priv/igniter/{phx_sync.tanstack_db => phx.sync.tanstack_db}/assets/js/db/auth.ts.eex (100%) rename priv/igniter/{phx_sync.tanstack_db => phx.sync.tanstack_db}/assets/js/db/collections.ts.eex (100%) rename priv/igniter/{phx_sync.tanstack_db => phx.sync.tanstack_db}/assets/js/db/mutations.ts.eex (100%) rename priv/igniter/{phx_sync.tanstack_db => phx.sync.tanstack_db}/assets/js/db/schema.ts.eex (100%) rename priv/igniter/{phx_sync.tanstack_db => phx.sync.tanstack_db}/assets/js/routes/__root.tsx.eex (100%) rename priv/igniter/{phx_sync.tanstack_db => phx.sync.tanstack_db}/assets/js/routes/about.tsx.eex (100%) rename priv/igniter/{phx_sync.tanstack_db => phx.sync.tanstack_db}/assets/js/routes/index.tsx.eex (100%) rename priv/igniter/{phx_sync.tanstack_db => phx.sync.tanstack_db}/assets/package.json.eex (100%) rename priv/igniter/{phx_sync.tanstack_db => phx.sync.tanstack_db}/assets/tailwind.config.js.eex (100%) rename priv/igniter/{phx_sync.tanstack_db => phx.sync.tanstack_db}/assets/tsconfig.app.json.eex (100%) rename priv/igniter/{phx_sync.tanstack_db => phx.sync.tanstack_db}/assets/tsconfig.json.eex (100%) rename priv/igniter/{phx_sync.tanstack_db => phx.sync.tanstack_db}/assets/tsconfig.node.json.eex (100%) rename priv/igniter/{phx_sync.tanstack_db => phx.sync.tanstack_db}/assets/vite.config.ts.eex (100%) rename priv/igniter/{phx_sync.tanstack_db => phx.sync.tanstack_db}/lib/web/components/layouts/root.html.heex.eex (100%) rename test/mix/tasks/{phx_sync.tanstack_db_test.exs => phx.sync.tanstack_db_test.exs} (87%) diff --git a/lib/mix/tasks/phx_sync.tanstack_db.ex b/lib/mix/tasks/phx.sync.tanstack_db.ex similarity index 98% rename from lib/mix/tasks/phx_sync.tanstack_db.ex rename to lib/mix/tasks/phx.sync.tanstack_db.ex index 5439bea..c150124 100644 --- a/lib/mix/tasks/phx_sync.tanstack_db.ex +++ b/lib/mix/tasks/phx.sync.tanstack_db.ex @@ -1,4 +1,4 @@ -defmodule Mix.Tasks.PhxSync.TanstackDb.Docs do +defmodule Mix.Tasks.Phx.Sync.TanstackDb.Docs do @moduledoc false @spec short_doc() :: String.t() @@ -32,7 +32,7 @@ defmodule Mix.Tasks.PhxSync.TanstackDb.Docs do end if Code.ensure_loaded?(Igniter) do - defmodule Mix.Tasks.PhxSync.TanstackDb do + defmodule Mix.Tasks.Phx.Sync.TanstackDb do import Igniter.Project.Application, only: [app_name: 1] @shortdoc "#{__MODULE__.Docs.short_doc()}" @@ -349,7 +349,7 @@ if Code.ensure_loaded?(Igniter) do def template_dir do :phoenix_sync |> :code.priv_dir() - |> Path.join("igniter/phx_sync.tanstack_db") + |> Path.join("igniter/phx.sync.tanstack_db") end defp js_runner(igniter) do @@ -364,7 +364,7 @@ if Code.ensure_loaded?(Igniter) do end end else - defmodule Mix.Tasks.PhxSync.TanstackDb do + defmodule Mix.Tasks.Phx.Sync.TanstackDb do @shortdoc "#{__MODULE__.Docs.short_doc()} | Install `igniter` to use" @moduledoc __MODULE__.Docs.long_doc() diff --git a/priv/igniter/phx_sync.tanstack_db/Caddyfile.eex b/priv/igniter/phx.sync.tanstack_db/Caddyfile.eex similarity index 100% rename from priv/igniter/phx_sync.tanstack_db/Caddyfile.eex rename to priv/igniter/phx.sync.tanstack_db/Caddyfile.eex diff --git a/priv/igniter/phx_sync.tanstack_db/assets/css/app.css.eex b/priv/igniter/phx.sync.tanstack_db/assets/css/app.css.eex similarity index 100% rename from priv/igniter/phx_sync.tanstack_db/assets/css/app.css.eex rename to priv/igniter/phx.sync.tanstack_db/assets/css/app.css.eex diff --git a/priv/igniter/phx_sync.tanstack_db/assets/js/api.ts.eex b/priv/igniter/phx.sync.tanstack_db/assets/js/api.ts.eex similarity index 100% rename from priv/igniter/phx_sync.tanstack_db/assets/js/api.ts.eex rename to priv/igniter/phx.sync.tanstack_db/assets/js/api.ts.eex diff --git a/priv/igniter/phx_sync.tanstack_db/assets/js/app.tsx.eex b/priv/igniter/phx.sync.tanstack_db/assets/js/app.tsx.eex similarity index 100% rename from priv/igniter/phx_sync.tanstack_db/assets/js/app.tsx.eex rename to priv/igniter/phx.sync.tanstack_db/assets/js/app.tsx.eex diff --git a/priv/igniter/phx_sync.tanstack_db/assets/js/db/auth.ts.eex b/priv/igniter/phx.sync.tanstack_db/assets/js/db/auth.ts.eex similarity index 100% rename from priv/igniter/phx_sync.tanstack_db/assets/js/db/auth.ts.eex rename to priv/igniter/phx.sync.tanstack_db/assets/js/db/auth.ts.eex diff --git a/priv/igniter/phx_sync.tanstack_db/assets/js/db/collections.ts.eex b/priv/igniter/phx.sync.tanstack_db/assets/js/db/collections.ts.eex similarity index 100% rename from priv/igniter/phx_sync.tanstack_db/assets/js/db/collections.ts.eex rename to priv/igniter/phx.sync.tanstack_db/assets/js/db/collections.ts.eex diff --git a/priv/igniter/phx_sync.tanstack_db/assets/js/db/mutations.ts.eex b/priv/igniter/phx.sync.tanstack_db/assets/js/db/mutations.ts.eex similarity index 100% rename from priv/igniter/phx_sync.tanstack_db/assets/js/db/mutations.ts.eex rename to priv/igniter/phx.sync.tanstack_db/assets/js/db/mutations.ts.eex diff --git a/priv/igniter/phx_sync.tanstack_db/assets/js/db/schema.ts.eex b/priv/igniter/phx.sync.tanstack_db/assets/js/db/schema.ts.eex similarity index 100% rename from priv/igniter/phx_sync.tanstack_db/assets/js/db/schema.ts.eex rename to priv/igniter/phx.sync.tanstack_db/assets/js/db/schema.ts.eex diff --git a/priv/igniter/phx_sync.tanstack_db/assets/js/routes/__root.tsx.eex b/priv/igniter/phx.sync.tanstack_db/assets/js/routes/__root.tsx.eex similarity index 100% rename from priv/igniter/phx_sync.tanstack_db/assets/js/routes/__root.tsx.eex rename to priv/igniter/phx.sync.tanstack_db/assets/js/routes/__root.tsx.eex diff --git a/priv/igniter/phx_sync.tanstack_db/assets/js/routes/about.tsx.eex b/priv/igniter/phx.sync.tanstack_db/assets/js/routes/about.tsx.eex similarity index 100% rename from priv/igniter/phx_sync.tanstack_db/assets/js/routes/about.tsx.eex rename to priv/igniter/phx.sync.tanstack_db/assets/js/routes/about.tsx.eex diff --git a/priv/igniter/phx_sync.tanstack_db/assets/js/routes/index.tsx.eex b/priv/igniter/phx.sync.tanstack_db/assets/js/routes/index.tsx.eex similarity index 100% rename from priv/igniter/phx_sync.tanstack_db/assets/js/routes/index.tsx.eex rename to priv/igniter/phx.sync.tanstack_db/assets/js/routes/index.tsx.eex diff --git a/priv/igniter/phx_sync.tanstack_db/assets/package.json.eex b/priv/igniter/phx.sync.tanstack_db/assets/package.json.eex similarity index 100% rename from priv/igniter/phx_sync.tanstack_db/assets/package.json.eex rename to priv/igniter/phx.sync.tanstack_db/assets/package.json.eex diff --git a/priv/igniter/phx_sync.tanstack_db/assets/tailwind.config.js.eex b/priv/igniter/phx.sync.tanstack_db/assets/tailwind.config.js.eex similarity index 100% rename from priv/igniter/phx_sync.tanstack_db/assets/tailwind.config.js.eex rename to priv/igniter/phx.sync.tanstack_db/assets/tailwind.config.js.eex diff --git a/priv/igniter/phx_sync.tanstack_db/assets/tsconfig.app.json.eex b/priv/igniter/phx.sync.tanstack_db/assets/tsconfig.app.json.eex similarity index 100% rename from priv/igniter/phx_sync.tanstack_db/assets/tsconfig.app.json.eex rename to priv/igniter/phx.sync.tanstack_db/assets/tsconfig.app.json.eex diff --git a/priv/igniter/phx_sync.tanstack_db/assets/tsconfig.json.eex b/priv/igniter/phx.sync.tanstack_db/assets/tsconfig.json.eex similarity index 100% rename from priv/igniter/phx_sync.tanstack_db/assets/tsconfig.json.eex rename to priv/igniter/phx.sync.tanstack_db/assets/tsconfig.json.eex diff --git a/priv/igniter/phx_sync.tanstack_db/assets/tsconfig.node.json.eex b/priv/igniter/phx.sync.tanstack_db/assets/tsconfig.node.json.eex similarity index 100% rename from priv/igniter/phx_sync.tanstack_db/assets/tsconfig.node.json.eex rename to priv/igniter/phx.sync.tanstack_db/assets/tsconfig.node.json.eex diff --git a/priv/igniter/phx_sync.tanstack_db/assets/vite.config.ts.eex b/priv/igniter/phx.sync.tanstack_db/assets/vite.config.ts.eex similarity index 100% rename from priv/igniter/phx_sync.tanstack_db/assets/vite.config.ts.eex rename to priv/igniter/phx.sync.tanstack_db/assets/vite.config.ts.eex diff --git a/priv/igniter/phx_sync.tanstack_db/lib/web/components/layouts/root.html.heex.eex b/priv/igniter/phx.sync.tanstack_db/lib/web/components/layouts/root.html.heex.eex similarity index 100% rename from priv/igniter/phx_sync.tanstack_db/lib/web/components/layouts/root.html.heex.eex rename to priv/igniter/phx.sync.tanstack_db/lib/web/components/layouts/root.html.heex.eex diff --git a/test/mix/tasks/phx_sync.tanstack_db_test.exs b/test/mix/tasks/phx.sync.tanstack_db_test.exs similarity index 87% rename from test/mix/tasks/phx_sync.tanstack_db_test.exs rename to test/mix/tasks/phx.sync.tanstack_db_test.exs index 0fe565a..09c5284 100644 --- a/test/mix/tasks/phx_sync.tanstack_db_test.exs +++ b/test/mix/tasks/phx.sync.tanstack_db_test.exs @@ -1,9 +1,9 @@ -defmodule Mix.Tasks.PhxSync.TanstackDbTest do +defmodule Mix.Tasks.Phx.Sync.TanstackDbTest do use ExUnit.Case, async: true import Igniter.Test - import Mix.Tasks.PhxSync.TanstackDb, only: [template_dir: 0, template_contents: 2] + import Mix.Tasks.Phx.Sync.TanstackDb, only: [template_dir: 0, template_contents: 2] defp assert_renders_template(igniter, {template_path, render_path}) do igniter @@ -40,7 +40,7 @@ defmodule Mix.Tasks.PhxSync.TanstackDbTest do igniter = phx_test_project() - |> Igniter.compose_task("phx_sync.tanstack_db", []) + |> Igniter.compose_task("phx.sync.tanstack_db", []) for template <- templates do assert_renders_template(igniter, template) @@ -50,7 +50,7 @@ defmodule Mix.Tasks.PhxSync.TanstackDbTest do test "patches tasks" do igniter = phx_test_project() - |> Igniter.compose_task("phx_sync.tanstack_db", ["--sync-pnpm"]) + |> Igniter.compose_task("phx.sync.tanstack_db", ["--sync-pnpm"]) assert_has_patch(igniter, "mix.exs", """ - | {:esbuild, "~> 0.8", runtime: Mix.env() == :dev}, @@ -70,7 +70,7 @@ defmodule Mix.Tasks.PhxSync.TanstackDbTest do test "configures watchers in dev" do igniter = phx_test_project() - |> Igniter.compose_task("phx_sync.tanstack_db", ["--sync-pnpm"]) + |> Igniter.compose_task("phx.sync.tanstack_db", ["--sync-pnpm"]) assert_has_patch(igniter, "config/dev.exs", """ - | esbuild: {Esbuild, :install_and_run, [:test, ~w(--sourcemap=inline --watch)]}, @@ -91,7 +91,7 @@ defmodule Mix.Tasks.PhxSync.TanstackDbTest do test "uses npm if told" do igniter = phx_test_project() - |> Igniter.compose_task("phx_sync.tanstack_db", ["--no-sync-pnpm"]) + |> Igniter.compose_task("phx.sync.tanstack_db", ["--no-sync-pnpm"]) assert_has_patch(igniter, "config/dev.exs", """ - | esbuild: {Esbuild, :install_and_run, [:test, ~w(--sourcemap=inline --watch)]}, @@ -112,7 +112,7 @@ defmodule Mix.Tasks.PhxSync.TanstackDbTest do test "removes app.js" do igniter = phx_test_project() - |> Igniter.compose_task("phx_sync.tanstack_db", ["--sync-pnpm"]) + |> Igniter.compose_task("phx.sync.tanstack_db", ["--sync-pnpm"]) assert_rms(igniter, ["assets/js/app.js"]) end From f40bc021e9214e05c74df485132aea630f82b545 Mon Sep 17 00:00:00 2001 From: Garry Hill Date: Thu, 4 Sep 2025 14:08:44 +0100 Subject: [PATCH 08/18] add docs --- lib/mix/tasks/phx.sync.tanstack_db.ex | 44 ++++++++++++++++----------- 1 file changed, 26 insertions(+), 18 deletions(-) diff --git a/lib/mix/tasks/phx.sync.tanstack_db.ex b/lib/mix/tasks/phx.sync.tanstack_db.ex index c150124..405a0da 100644 --- a/lib/mix/tasks/phx.sync.tanstack_db.ex +++ b/lib/mix/tasks/phx.sync.tanstack_db.ex @@ -3,12 +3,12 @@ defmodule Mix.Tasks.Phx.Sync.TanstackDb.Docs do @spec short_doc() :: String.t() def short_doc do - "A short description of your task" + "Convert a Phoenix application to a Tanstack DB based frontend" end @spec example() :: String.t() def example do - "mix phx_sync.tanstack_db" + "mix phx.sync.tanstack_db" end @spec long_doc() :: String.t() @@ -16,17 +16,39 @@ defmodule Mix.Tasks.Phx.Sync.TanstackDb.Docs do """ #{short_doc()} - Longer explanation of your task + This is a very invasive task that does the following: + + - Removes `esbuild` with `vite` and removes the Elixir integration with + tailwindcss + + - Adds a `package.json` with the required dependencies for `@tanstack/db`, + `@tanstack/router`, `react` and `tailwind` + + - Drops in some example routes, schemas, collections and mutation code + + - Replaces the default `root.html.heex` layout to one suitable for a + react-based SPA + + For this reason we recommend only running this on a fresh Phoenix project + (with `Phoenix.Sync` installed). ## Example ```sh + # install igniter.new + mix archive.install hex igniter_new + + # create a new phoenix application and install phoenix_sync in `embedded` mode + mix igniter.new my_app --install phoehix_sync --with phx.new --sync-mode embedded + + # setup my_app to use tanstack db #{example()} ``` ## Options - * `--example-option` or `-e` - Docs for your option + * `--sync-pnpm` - Use `pnpm` as package manager if available (default) + * `--no-sync-pnpm` - Use `npm` as package manager even if `pnpm` is installed """ end end @@ -44,31 +66,17 @@ if Code.ensure_loaded?(Igniter) do @impl Igniter.Mix.Task def info(_argv, _composing_task) do %Igniter.Mix.Task.Info{ - # Groups allow for overlapping arguments for tasks by the same author - # See the generators guide for more. group: :phoenix_sync, - # *other* dependencies to add - # i.e `{:foo, "~> 2.0"}` adds_deps: [], - # *other* dependencies to add and call their associated installers, if they exist - # i.e `{:foo, "~> 2.0"}` installs: [], - # An example invocation example: __MODULE__.Docs.example(), - # a list of positional arguments, i.e `[:file]` positional: [], - # Other tasks your task composes using `Igniter.compose_task`, passing in the CLI argv - # This ensures your option schema includes options from nested tasks composes: [], - # `OptionParser` schema schema: [sync_pnpm: :boolean], - # Default values for the options in the `schema` defaults: [ sync_pnpm: true ], - # CLI aliases aliases: [], - # A list of options in the schema that are required required: [] } end From 2096242572bb327c265642a71d4985ac1c1005a0 Mon Sep 17 00:00:00 2001 From: Garry Hill Date: Thu, 4 Sep 2025 14:20:04 +0100 Subject: [PATCH 09/18] install npm for tanstack task --- .github/workflows/elixir_tests.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/elixir_tests.yml b/.github/workflows/elixir_tests.yml index 80899b2..fed7dc2 100644 --- a/.github/workflows/elixir_tests.yml +++ b/.github/workflows/elixir_tests.yml @@ -35,6 +35,10 @@ jobs: steps: - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + with: + version: 10 + - uses: actions/setup-node@v4 - name: "Set PG settings" run: | From e52c24d3ed7fd0a10a657d051c1beda22b470b34 Mon Sep 17 00:00:00 2001 From: Garry Hill Date: Thu, 4 Sep 2025 14:28:10 +0100 Subject: [PATCH 10/18] dbg ci test failures --- lib/mix/tasks/phx.sync.tanstack_db.ex | 3 ++- test/mix/tasks/phx.sync.tanstack_db_test.exs | 2 ++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/lib/mix/tasks/phx.sync.tanstack_db.ex b/lib/mix/tasks/phx.sync.tanstack_db.ex index 405a0da..51952eb 100644 --- a/lib/mix/tasks/phx.sync.tanstack_db.ex +++ b/lib/mix/tasks/phx.sync.tanstack_db.ex @@ -277,7 +277,8 @@ if Code.ensure_loaded?(Igniter) do end defp configure_package_manager(igniter) do - if System.find_executable("pnpm") && Keyword.get(igniter.args.options, :sync_pnpm, true) do + if System.find_executable("pnpm") |> dbg && + Keyword.get(igniter.args.options, :sync_pnpm, true) do igniter |> Igniter.add_notice("Using pnpm as package manager") |> Igniter.assign(:package_manager, :pnpm) diff --git a/test/mix/tasks/phx.sync.tanstack_db_test.exs b/test/mix/tasks/phx.sync.tanstack_db_test.exs index 09c5284..cd40081 100644 --- a/test/mix/tasks/phx.sync.tanstack_db_test.exs +++ b/test/mix/tasks/phx.sync.tanstack_db_test.exs @@ -42,6 +42,8 @@ defmodule Mix.Tasks.Phx.Sync.TanstackDbTest do phx_test_project() |> Igniter.compose_task("phx.sync.tanstack_db", []) + assert [] == igniter.warnings + for template <- templates do assert_renders_template(igniter, template) end From 0daffdf9b21efd9b4a3ed7fd63fdb1cf3ee42b81 Mon Sep 17 00:00:00 2001 From: Garry Hill Date: Thu, 4 Sep 2025 14:51:33 +0100 Subject: [PATCH 11/18] fix for latest phx.new --- lib/mix/tasks/phx.sync.tanstack_db.ex | 5 ++--- .../phx.sync.tanstack_db/assets/tsconfig.app.json.eex | 3 +++ .../phx.sync.tanstack_db/assets/tsconfig.node.json.eex | 3 +++ test/mix/tasks/phx.sync.tanstack_db_test.exs | 6 +++--- 4 files changed, 11 insertions(+), 6 deletions(-) diff --git a/lib/mix/tasks/phx.sync.tanstack_db.ex b/lib/mix/tasks/phx.sync.tanstack_db.ex index 51952eb..b58cb99 100644 --- a/lib/mix/tasks/phx.sync.tanstack_db.ex +++ b/lib/mix/tasks/phx.sync.tanstack_db.ex @@ -277,8 +277,7 @@ if Code.ensure_loaded?(Igniter) do end defp configure_package_manager(igniter) do - if System.find_executable("pnpm") |> dbg && - Keyword.get(igniter.args.options, :sync_pnpm, true) do + if System.find_executable("pnpm") && Keyword.get(igniter.args.options, :sync_pnpm, true) do igniter |> Igniter.add_notice("Using pnpm as package manager") |> Igniter.assign(:package_manager, :pnpm) @@ -308,7 +307,7 @@ if Code.ensure_loaded?(Igniter) do |> create_new_file("assets/vite.config.ts") |> create_new_file("assets/tsconfig.node.json") |> create_new_file("assets/tsconfig.app.json") - |> create_new_file("assets/tsconfig.json") + |> create_or_replace_file("assets/tsconfig.json") |> create_or_replace_file("assets/tailwind.config.js") |> create_new_file("assets/js/db/auth.ts") |> create_new_file("assets/js/db/collections.ts") diff --git a/priv/igniter/phx.sync.tanstack_db/assets/tsconfig.app.json.eex b/priv/igniter/phx.sync.tanstack_db/assets/tsconfig.app.json.eex index d9268d4..62172cc 100644 --- a/priv/igniter/phx.sync.tanstack_db/assets/tsconfig.app.json.eex +++ b/priv/igniter/phx.sync.tanstack_db/assets/tsconfig.app.json.eex @@ -21,6 +21,9 @@ "noUnusedParameters": true, "noFallthroughCasesInSwitch": true, "noUncheckedSideEffectImports": true + "paths": { + "*": ["../deps/*"] + }, }, "include": ["js"] } diff --git a/priv/igniter/phx.sync.tanstack_db/assets/tsconfig.node.json.eex b/priv/igniter/phx.sync.tanstack_db/assets/tsconfig.node.json.eex index fec2ba1..7fbc186 100644 --- a/priv/igniter/phx.sync.tanstack_db/assets/tsconfig.node.json.eex +++ b/priv/igniter/phx.sync.tanstack_db/assets/tsconfig.node.json.eex @@ -19,6 +19,9 @@ "noUnusedParameters": true, "noFallthroughCasesInSwitch": true, "noUncheckedSideEffectImports": true + "paths": { + "*": ["../deps/*"] + }, }, "include": ["vite.config.ts"] } diff --git a/test/mix/tasks/phx.sync.tanstack_db_test.exs b/test/mix/tasks/phx.sync.tanstack_db_test.exs index cd40081..018ea44 100644 --- a/test/mix/tasks/phx.sync.tanstack_db_test.exs +++ b/test/mix/tasks/phx.sync.tanstack_db_test.exs @@ -55,13 +55,13 @@ defmodule Mix.Tasks.Phx.Sync.TanstackDbTest do |> Igniter.compose_task("phx.sync.tanstack_db", ["--sync-pnpm"]) assert_has_patch(igniter, "mix.exs", """ - - | {:esbuild, "~> 0.8", runtime: Mix.env() == :dev}, - - | {:tailwind, "~> 0.2", runtime: Mix.env() == :dev}, + - | {:esbuild, "~> 0.10", runtime: Mix.env() == :dev}, + - | {:tailwind, "~> 0.3", runtime: Mix.env() == :dev}, """) assert_has_patch(igniter, "mix.exs", """ - | "assets.setup": ["tailwind.install --if-missing", "esbuild.install --if-missing"], - - | "assets.build": ["tailwind test", "esbuild test"], + - | "assets.build": ["compile", "tailwind test", "esbuild test"], + | "assets.setup": ["cmd --cd assets pnpm install --ignore-workspace"], + | "assets.build": [ + | "cmd --cd assets pnpm vite build --config vite.config.js --mode development" From 62821e8b2c8eab07e98bf81381367ab6204f95fa Mon Sep 17 00:00:00 2001 From: Garry Hill Date: Thu, 4 Sep 2025 14:55:54 +0100 Subject: [PATCH 12/18] add compile step --- lib/mix/tasks/phx.sync.tanstack_db.ex | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/lib/mix/tasks/phx.sync.tanstack_db.ex b/lib/mix/tasks/phx.sync.tanstack_db.ex index b58cb99..9271fea 100644 --- a/lib/mix/tasks/phx.sync.tanstack_db.ex +++ b/lib/mix/tasks/phx.sync.tanstack_db.ex @@ -223,7 +223,10 @@ if Code.ensure_loaded?(Igniter) do ) |> set_alias( "assets.build", - "cmd --cd assets #{js_runner(igniter)} vite build --config vite.config.js --mode development" + [ + "compile", + "cmd --cd assets #{js_runner(igniter)} vite build --config vite.config.js --mode development" + ] ) |> set_alias( "assets.deploy", From 65e3596a84e99a210a3ac1e8c12763a44f8fa8a1 Mon Sep 17 00:00:00 2001 From: Garry Hill Date: Thu, 4 Sep 2025 14:57:17 +0100 Subject: [PATCH 13/18] fix tests --- test/mix/tasks/phx.sync.tanstack_db_test.exs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/test/mix/tasks/phx.sync.tanstack_db_test.exs b/test/mix/tasks/phx.sync.tanstack_db_test.exs index 018ea44..bbb9c21 100644 --- a/test/mix/tasks/phx.sync.tanstack_db_test.exs +++ b/test/mix/tasks/phx.sync.tanstack_db_test.exs @@ -64,8 +64,14 @@ defmodule Mix.Tasks.Phx.Sync.TanstackDbTest do - | "assets.build": ["compile", "tailwind test", "esbuild test"], + | "assets.setup": ["cmd --cd assets pnpm install --ignore-workspace"], + | "assets.build": [ + + | "compile", + | "cmd --cd assets pnpm vite build --config vite.config.js --mode development" + | ], + | "assets.deploy": [ + - | "tailwind test --minify", + - | "esbuild test --minify", + + | "cmd --cd assets pnpm vite build --config vite.config.js --mode production", + """) end From 31cf46060d05fc67a1b2630ed64c7525abb562a7 Mon Sep 17 00:00:00 2001 From: Garry Hill Date: Thu, 11 Sep 2025 15:10:01 +0100 Subject: [PATCH 14/18] simplify down to a local-only todo list fix problems with js config --- ...ck_db.ex => phx.sync.tanstack_db.setup.ex} | 15 ++- .../assets/css/app.css.eex | 3 + .../assets/js/components/todos.tsx.eex | 39 ++++++++ .../assets/js/db/auth.ts.eex | 30 ------ .../assets/js/db/collections.ts.eex | 92 ++++--------------- .../assets/js/db/mutations.ts.eex | 78 ---------------- .../assets/js/db/schema.ts.eex | 17 +--- .../assets/js/routes/__root.tsx.eex | 5 - .../assets/js/routes/index.tsx.eex | 22 +++-- .../assets/tailwind.config.js.eex | 2 +- .../assets/tsconfig.app.json.eex | 4 +- .../assets/tsconfig.node.json.eex | 4 +- .../assets/vite.config.ts.eex | 2 +- ...xs => phx.sync.tanstack_db.setup_test.exs} | 14 +-- 14 files changed, 99 insertions(+), 228 deletions(-) rename lib/mix/tasks/{phx.sync.tanstack_db.ex => phx.sync.tanstack_db.setup.ex} (96%) create mode 100644 priv/igniter/phx.sync.tanstack_db/assets/js/components/todos.tsx.eex delete mode 100644 priv/igniter/phx.sync.tanstack_db/assets/js/db/auth.ts.eex delete mode 100644 priv/igniter/phx.sync.tanstack_db/assets/js/db/mutations.ts.eex rename test/mix/tasks/{phx.sync.tanstack_db_test.exs => phx.sync.tanstack_db.setup_test.exs} (86%) diff --git a/lib/mix/tasks/phx.sync.tanstack_db.ex b/lib/mix/tasks/phx.sync.tanstack_db.setup.ex similarity index 96% rename from lib/mix/tasks/phx.sync.tanstack_db.ex rename to lib/mix/tasks/phx.sync.tanstack_db.setup.ex index 9271fea..212bbce 100644 --- a/lib/mix/tasks/phx.sync.tanstack_db.ex +++ b/lib/mix/tasks/phx.sync.tanstack_db.setup.ex @@ -1,14 +1,14 @@ -defmodule Mix.Tasks.Phx.Sync.TanstackDb.Docs do +defmodule Mix.Tasks.Phx.Sync.TanstackDb.Setup.Docs do @moduledoc false @spec short_doc() :: String.t() def short_doc do - "Convert a Phoenix application to a Tanstack DB based frontend" + "Convert a Phoenix application to use a Vite + Tanstack DB based frontend" end @spec example() :: String.t() def example do - "mix phx.sync.tanstack_db" + "mix phx.sync.tanstack_db.setup" end @spec long_doc() :: String.t() @@ -39,7 +39,7 @@ defmodule Mix.Tasks.Phx.Sync.TanstackDb.Docs do mix archive.install hex igniter_new # create a new phoenix application and install phoenix_sync in `embedded` mode - mix igniter.new my_app --install phoehix_sync --with phx.new --sync-mode embedded + mix igniter.new my_app --install phoenix_sync --with phx.new --sync-mode embedded # setup my_app to use tanstack db #{example()} @@ -54,7 +54,7 @@ defmodule Mix.Tasks.Phx.Sync.TanstackDb.Docs do end if Code.ensure_loaded?(Igniter) do - defmodule Mix.Tasks.Phx.Sync.TanstackDb do + defmodule Mix.Tasks.Phx.Sync.TanstackDb.Setup do import Igniter.Project.Application, only: [app_name: 1] @shortdoc "#{__MODULE__.Docs.short_doc()}" @@ -312,13 +312,12 @@ if Code.ensure_loaded?(Igniter) do |> create_new_file("assets/tsconfig.app.json") |> create_or_replace_file("assets/tsconfig.json") |> create_or_replace_file("assets/tailwind.config.js") - |> create_new_file("assets/js/db/auth.ts") |> create_new_file("assets/js/db/collections.ts") - |> create_new_file("assets/js/db/mutations.ts") |> create_new_file("assets/js/db/schema.ts") |> create_new_file("assets/js/routes/__root.tsx") |> create_new_file("assets/js/routes/index.tsx") |> create_new_file("assets/js/routes/about.tsx") + |> create_new_file("assets/js/components/todos.tsx") |> create_new_file("assets/js/api.ts") |> create_new_file("assets/js/app.tsx") |> create_or_replace_file("assets/css/app.css") @@ -375,7 +374,7 @@ if Code.ensure_loaded?(Igniter) do end end else - defmodule Mix.Tasks.Phx.Sync.TanstackDb do + defmodule Mix.Tasks.Phx.Sync.TanstackDb.Setup do @shortdoc "#{__MODULE__.Docs.short_doc()} | Install `igniter` to use" @moduledoc __MODULE__.Docs.long_doc() diff --git a/priv/igniter/phx.sync.tanstack_db/assets/css/app.css.eex b/priv/igniter/phx.sync.tanstack_db/assets/css/app.css.eex index e13b58e..93f4009 100644 --- a/priv/igniter/phx.sync.tanstack_db/assets/css/app.css.eex +++ b/priv/igniter/phx.sync.tanstack_db/assets/css/app.css.eex @@ -10,3 +10,6 @@ @custom-variant phx-click-loading (&:where(.phx-click-loading, .phx-click-loading *)); @custom-variant phx-submit-loading (&:where(.phx-submit-loading, .phx-submit-loading *)); @custom-variant phx-change-loading (&:where(.phx-change-loading, .phx-change-loading *)); +@tailwind base; +@tailwind components; +@tailwind utilities; diff --git a/priv/igniter/phx.sync.tanstack_db/assets/js/components/todos.tsx.eex b/priv/igniter/phx.sync.tanstack_db/assets/js/components/todos.tsx.eex new file mode 100644 index 0000000..073389a --- /dev/null +++ b/priv/igniter/phx.sync.tanstack_db/assets/js/components/todos.tsx.eex @@ -0,0 +1,39 @@ +import { useLiveQuery, eq } from "@tanstack/react-db"; +import { todoCollection } from "../db/collections"; + +export function Todos() { + const { data: todos } = useLiveQuery((query) => + query.from({ todo: todoCollection }), + ); + + const toggleTodo = (todo) => + todoCollection.update(todo.id, (draft) => { + draft.completed = !todo.completed; + }); + + return ( +
    + {todos.map((todo) => ( +
  • + toggleTodo(todo)} + id={`todo-${todo.id}`} + /> + +
  • + ))} +
+ ); +} + diff --git a/priv/igniter/phx.sync.tanstack_db/assets/js/db/auth.ts.eex b/priv/igniter/phx.sync.tanstack_db/assets/js/db/auth.ts.eex deleted file mode 100644 index 66d4c46..0000000 --- a/priv/igniter/phx.sync.tanstack_db/assets/js/db/auth.ts.eex +++ /dev/null @@ -1,30 +0,0 @@ -import { authCollection } from './collections' -import type { Auth } from './schema' - -type CurrentAuth = Auth | undefined - -type AuthResult = { - currentUserId: string | null - isAuthenticated: boolean -} - -export async function signIn(user_id: string): Promise { - await authCollection.insert({ - key: 'current', - user_id: user_id, - }) -} - -export async function signOut(): Promise { - await authCollection.delete('current') -} - -export function useAuth(): AuthResult { - const auth: CurrentAuth = authCollection.get('current') - - const currentUserId = auth !== undefined ? auth.user_id : null - const isAuthenticated = currentUserId !== null - - return { currentUserId, isAuthenticated } -} - diff --git a/priv/igniter/phx.sync.tanstack_db/assets/js/db/collections.ts.eex b/priv/igniter/phx.sync.tanstack_db/assets/js/db/collections.ts.eex index 0198f58..5a8360b 100644 --- a/priv/igniter/phx.sync.tanstack_db/assets/js/db/collections.ts.eex +++ b/priv/igniter/phx.sync.tanstack_db/assets/js/db/collections.ts.eex @@ -1,4 +1,4 @@ -import {createCollection, localStorageCollectionOptions,} from '@tanstack/react-db' +import {createCollection, localOnlyCollectionOptions,} from '@tanstack/react-db' import { ingestMutations } from './mutations' import type { Value } from '@electric-sql/client' @@ -8,83 +8,23 @@ import type { UpdateMutationFn, DeleteMutationFn, } from '@tanstack/react-db' -import {authSchema, userSchema,} from './schema' -import type { Auth, User } from './schema' - -type CollectionKey = string | number - -export const authCollection = createCollection( - localStorageCollectionOptions({ - storageKey: 'auth', - getKey: (item: Auth) => item.key, - onInsert: async () => true, - onUpdate: async () => true, - onDelete: async () => true, - schema: authSchema, +import { todoSchema } from './schema' +import type { Todo } from './schema' + + +export const todoCollection = createCollection( + localOnlyCollectionOptions({ + getKey: (todo: Todo) => todo.id, + schema: todoSchema, + initialData: [ + { id: 1, title: 'Install Phoenix', completed: true }, + { id: 2, title: 'Run mix phx.sync.tanstack_db.setup', completed: true }, + { id: 3, title: 'Run the app', completed: true }, + { id: 4, title: 'Build a to-do app', completed: true }, + { id: 5, title: 'Convert to an Electric collection backed by Phoenix.Sync', completed: false }, + ], }) ) -const headers = { - Authorization: async () => { - const auth = authCollection.get('current') - - return auth ? `Bearer ${auth.user_id}` : 'Unauthenticated' - }, -} - -async function onError(error: Error) { - const status = - 'status' in error && Number.isInteger(error.status) - ? error.status as number - : undefined - - if (status === 403 && authCollection.has('current')) { - await authCollection.delete('current') - - return { headers } - } - - if (status === 401) { - await new Promise((resolve) => authCollection.subscribeChanges(resolve)) - - return { headers } - } - - throw error -} - -const parser = { - timestamp: (dateStr: string) => { - // Timestamps sync in as naive datetime strings with no - // timezone info because they're all implicitly UTC. - const utcDateStr = dateStr.endsWith('Z') ? dateStr : `${dateStr}Z` - const date: Date = new Date(utcDateStr) - - // Cast to `Value`` because we haven't fixed the typing yet - // https://github.com/TanStack/db/pull/201 - return date as unknown as Value - }, -} - -const baseShapeOptions = { - headers, - onError, - parser, -} - -function operationHandlers() { - return { - onInsert: ingestMutations as InsertMutationFn, - onUpdate: ingestMutations as UpdateMutationFn, - onDelete: ingestMutations as DeleteMutationFn, - } -} - -function relativeUrl(path: string) { - return `${window.location.origin}${path}` -} - -// @ts-ignore -window.authCollection = authCollection diff --git a/priv/igniter/phx.sync.tanstack_db/assets/js/db/mutations.ts.eex b/priv/igniter/phx.sync.tanstack_db/assets/js/db/mutations.ts.eex deleted file mode 100644 index c7868de..0000000 --- a/priv/igniter/phx.sync.tanstack_db/assets/js/db/mutations.ts.eex +++ /dev/null @@ -1,78 +0,0 @@ -import * as api from '../api' - -import type { ElectricCollectionUtils } from '@tanstack/electric-db-collection' -import type { - Collection, - MutationFn, - PendingMutation, - Transaction, - UtilsRecord, -} from '@tanstack/react-db' - -type MutationData = Omit - -const ONE_HOUR = 60 * 60 * 1_000 - -function isElectricUtils(utils: UtilsRecord): utils is ElectricCollectionUtils { - return 'awaitTxId' in utils && typeof (utils as any).awaitTxId === 'function' -} - -function patchRelationMetadata( - result: MutationData, - collection: Collection -): MutationData { - // Set the sync metadata from the collection id, because the default - // implementation looks for a `table` param which we don't use. - const parts = collection.id.split(':') - const relation = parts.length === 2 ? parts : ['public', parts[0]] - - result.syncMetadata = { relation } - return result -} - -function buildPayload(tx: Transaction) { - const mutations = tx.mutations.map((mutation: PendingMutation) => { - const { collection, ...result } = mutation - - return mutation.type === 'insert' - ? patchRelationMetadata(result, collection) - : result - }) - - return { mutations } -} - -async function hasSyncedBack( - tx: Transaction, - txid: number, - timeout: number = ONE_HOUR -) { - const collections = new Set( - tx.mutations.map((mutation) => mutation.collection).filter(Boolean) - ) - - const promises = [...collections].map((collection) => { - const utils = collection.utils - - if (isElectricUtils(utils)) { - return utils.awaitTxId(txid, timeout) - } - - throw new Error(`Unknown collection type`, { cause: { collection } }) - }) - - await Promise.all(promises) -} - -export const ingestMutations: MutationFn = async ({ transaction }) => { - const payload = buildPayload(transaction) - const txid = await api.ingest(payload) - - if (txid === undefined) { - return - } - - await hasSyncedBack(transaction, txid) - - return { txid } -} diff --git a/priv/igniter/phx.sync.tanstack_db/assets/js/db/schema.ts.eex b/priv/igniter/phx.sync.tanstack_db/assets/js/db/schema.ts.eex index 20800da..2e17017 100644 --- a/priv/igniter/phx.sync.tanstack_db/assets/js/db/schema.ts.eex +++ b/priv/igniter/phx.sync.tanstack_db/assets/js/db/schema.ts.eex @@ -1,16 +1,9 @@ import * as z from 'zod/v4' - -export const authSchema = z.object({ - key: z.literal('current'), - user_id: z.uuid(), -}) - -export const userSchema = z.object({ - id: z.uuid(), - name: z.string() +export const todoSchema = z.object({ + id: z.number(), + title: z.string(), + completed: z.boolean(), }) -export type Auth = z.infer -export type User = z.infer - +export type Todo = z.infer diff --git a/priv/igniter/phx.sync.tanstack_db/assets/js/routes/__root.tsx.eex b/priv/igniter/phx.sync.tanstack_db/assets/js/routes/__root.tsx.eex index db80599..ce46c25 100644 --- a/priv/igniter/phx.sync.tanstack_db/assets/js/routes/__root.tsx.eex +++ b/priv/igniter/phx.sync.tanstack_db/assets/js/routes/__root.tsx.eex @@ -1,7 +1,5 @@ import { createRootRoute, Link, Outlet } from '@tanstack/react-router' import { TanStackRouterDevtools } from '@tanstack/react-router-devtools' -import { useAuth } from '../db/auth' -import { authCollection } from '../db/collections' const RootLayout = () => ( <> @@ -22,8 +20,5 @@ const RootLayout = () => ( export const Route = createRootRoute({ component: RootLayout, - loader: async () => { - await authCollection.preload() - }, }) diff --git a/priv/igniter/phx.sync.tanstack_db/assets/js/routes/index.tsx.eex b/priv/igniter/phx.sync.tanstack_db/assets/js/routes/index.tsx.eex index 2ed7b5d..0d1e226 100644 --- a/priv/igniter/phx.sync.tanstack_db/assets/js/routes/index.tsx.eex +++ b/priv/igniter/phx.sync.tanstack_db/assets/js/routes/index.tsx.eex @@ -1,13 +1,23 @@ -import { createFileRoute } from '@tanstack/react-router' +import { createFileRoute } from "@tanstack/react-router"; -export const Route = createFileRoute('/')({ +import { Todos } from "../components/todos"; + +export const Route = createFileRoute("/")({ component: Index, -}) +}); function Index() { return ( -
-

Welcome Home!

+
+
+

+ Welcome to the Sync Stack +

+
+ +
+
- ) + ); } + diff --git a/priv/igniter/phx.sync.tanstack_db/assets/tailwind.config.js.eex b/priv/igniter/phx.sync.tanstack_db/assets/tailwind.config.js.eex index b22cf46..ead377e 100644 --- a/priv/igniter/phx.sync.tanstack_db/assets/tailwind.config.js.eex +++ b/priv/igniter/phx.sync.tanstack_db/assets/tailwind.config.js.eex @@ -1,3 +1,3 @@ module.exports = { - content: ["./js/**/*.js", "../lib/**/*.*ex"], + content: ["./js/**/*.{js,jsx,ts,tsx}", "../lib/**/*.*ex"], }; diff --git a/priv/igniter/phx.sync.tanstack_db/assets/tsconfig.app.json.eex b/priv/igniter/phx.sync.tanstack_db/assets/tsconfig.app.json.eex index 62172cc..fcea430 100644 --- a/priv/igniter/phx.sync.tanstack_db/assets/tsconfig.app.json.eex +++ b/priv/igniter/phx.sync.tanstack_db/assets/tsconfig.app.json.eex @@ -20,10 +20,10 @@ "noUnusedLocals": true, "noUnusedParameters": true, "noFallthroughCasesInSwitch": true, - "noUncheckedSideEffectImports": true + "noUncheckedSideEffectImports": true, "paths": { "*": ["../deps/*"] - }, + } }, "include": ["js"] } diff --git a/priv/igniter/phx.sync.tanstack_db/assets/tsconfig.node.json.eex b/priv/igniter/phx.sync.tanstack_db/assets/tsconfig.node.json.eex index 7fbc186..b1fcf2f 100644 --- a/priv/igniter/phx.sync.tanstack_db/assets/tsconfig.node.json.eex +++ b/priv/igniter/phx.sync.tanstack_db/assets/tsconfig.node.json.eex @@ -18,10 +18,10 @@ "noUnusedLocals": true, "noUnusedParameters": true, "noFallthroughCasesInSwitch": true, - "noUncheckedSideEffectImports": true + "noUncheckedSideEffectImports": true, "paths": { "*": ["../deps/*"] - }, + } }, "include": ["vite.config.ts"] } diff --git a/priv/igniter/phx.sync.tanstack_db/assets/vite.config.ts.eex b/priv/igniter/phx.sync.tanstack_db/assets/vite.config.ts.eex index 987f03e..2b813f5 100644 --- a/priv/igniter/phx.sync.tanstack_db/assets/vite.config.ts.eex +++ b/priv/igniter/phx.sync.tanstack_db/assets/vite.config.ts.eex @@ -16,7 +16,7 @@ export default defineConfig(({ command, mode }) => { minify: isProd, sourcemap: !isProd, rollupOptions: { - input: 'js/app.tsx', + input: './js/app.tsx', output: { assetFileNames: 'assets/[name][extname]', chunkFileNames: 'assets/chunk/[name].js', diff --git a/test/mix/tasks/phx.sync.tanstack_db_test.exs b/test/mix/tasks/phx.sync.tanstack_db.setup_test.exs similarity index 86% rename from test/mix/tasks/phx.sync.tanstack_db_test.exs rename to test/mix/tasks/phx.sync.tanstack_db.setup_test.exs index bbb9c21..959f0d7 100644 --- a/test/mix/tasks/phx.sync.tanstack_db_test.exs +++ b/test/mix/tasks/phx.sync.tanstack_db.setup_test.exs @@ -1,9 +1,9 @@ -defmodule Mix.Tasks.Phx.Sync.TanstackDbTest do +defmodule Mix.Tasks.Phx.Sync.TanstackDb.SetupTest do use ExUnit.Case, async: true import Igniter.Test - import Mix.Tasks.Phx.Sync.TanstackDb, only: [template_dir: 0, template_contents: 2] + import Mix.Tasks.Phx.Sync.TanstackDb.Setup, only: [template_dir: 0, template_contents: 2] defp assert_renders_template(igniter, {template_path, render_path}) do igniter @@ -40,7 +40,7 @@ defmodule Mix.Tasks.Phx.Sync.TanstackDbTest do igniter = phx_test_project() - |> Igniter.compose_task("phx.sync.tanstack_db", []) + |> Igniter.compose_task("phx.sync.tanstack_db.setup", []) assert [] == igniter.warnings @@ -52,7 +52,7 @@ defmodule Mix.Tasks.Phx.Sync.TanstackDbTest do test "patches tasks" do igniter = phx_test_project() - |> Igniter.compose_task("phx.sync.tanstack_db", ["--sync-pnpm"]) + |> Igniter.compose_task("phx.sync.tanstack_db.setup", ["--sync-pnpm"]) assert_has_patch(igniter, "mix.exs", """ - | {:esbuild, "~> 0.10", runtime: Mix.env() == :dev}, @@ -78,7 +78,7 @@ defmodule Mix.Tasks.Phx.Sync.TanstackDbTest do test "configures watchers in dev" do igniter = phx_test_project() - |> Igniter.compose_task("phx.sync.tanstack_db", ["--sync-pnpm"]) + |> Igniter.compose_task("phx.sync.tanstack_db.setup", ["--sync-pnpm"]) assert_has_patch(igniter, "config/dev.exs", """ - | esbuild: {Esbuild, :install_and_run, [:test, ~w(--sourcemap=inline --watch)]}, @@ -99,7 +99,7 @@ defmodule Mix.Tasks.Phx.Sync.TanstackDbTest do test "uses npm if told" do igniter = phx_test_project() - |> Igniter.compose_task("phx.sync.tanstack_db", ["--no-sync-pnpm"]) + |> Igniter.compose_task("phx.sync.tanstack_db.setup", ["--no-sync-pnpm"]) assert_has_patch(igniter, "config/dev.exs", """ - | esbuild: {Esbuild, :install_and_run, [:test, ~w(--sourcemap=inline --watch)]}, @@ -120,7 +120,7 @@ defmodule Mix.Tasks.Phx.Sync.TanstackDbTest do test "removes app.js" do igniter = phx_test_project() - |> Igniter.compose_task("phx.sync.tanstack_db", ["--sync-pnpm"]) + |> Igniter.compose_task("phx.sync.tanstack_db.setup", ["--sync-pnpm"]) assert_rms(igniter, ["assets/js/app.js"]) end From bf3db27ade27cfe8af6a5e985e400655b7adf128 Mon Sep 17 00:00:00 2001 From: Garry Hill Date: Thu, 11 Sep 2025 15:20:58 +0100 Subject: [PATCH 15/18] doc next steps --- .../assets/js/db/collections.ts.eex | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/priv/igniter/phx.sync.tanstack_db/assets/js/db/collections.ts.eex b/priv/igniter/phx.sync.tanstack_db/assets/js/db/collections.ts.eex index 5a8360b..3dd11e5 100644 --- a/priv/igniter/phx.sync.tanstack_db/assets/js/db/collections.ts.eex +++ b/priv/igniter/phx.sync.tanstack_db/assets/js/db/collections.ts.eex @@ -13,6 +13,16 @@ import { todoSchema } from './schema' import type { Todo } from './schema' +// This is a local-only collection that is not synced to the server and +// immediately applies updates: +// +// https://tanstack.com/db/latest/docs/reference/functions/localonlycollectionoptions +// +// To sync your front-end with your database via Electric you should use +// an `electricCollection` as documented here: +// +// https://tanstack.com/db/latest/docs/collections/electric-collection +// export const todoCollection = createCollection( localOnlyCollectionOptions({ getKey: (todo: Todo) => todo.id, From 93f413f58895ff6798a3c033d7b031a45ca1e488 Mon Sep 17 00:00:00 2001 From: Garry Hill Date: Thu, 11 Sep 2025 15:42:01 +0100 Subject: [PATCH 16/18] update deps --- .../assets/package.json.eex | 21 ++++++++++--------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/priv/igniter/phx.sync.tanstack_db/assets/package.json.eex b/priv/igniter/phx.sync.tanstack_db/assets/package.json.eex index c9aa61a..7750767 100644 --- a/priv/igniter/phx.sync.tanstack_db/assets/package.json.eex +++ b/priv/igniter/phx.sync.tanstack_db/assets/package.json.eex @@ -17,9 +17,9 @@ "phoenix": "file:../deps/phoenix", "phoenix_html": "file:../deps/phoenix_html", "phoenix_live_view": "file:../deps/phoenix_live_view", - "@electric-sql/client": "^1.0.9", - "@tanstack/electric-db-collection": "^0.1.12", - "@tanstack/react-db": "^0.1.11", + "@electric-sql/client": "^1.0.10", + "@tanstack/electric-db-collection": "^0.1.18", + "@tanstack/react-db": "^0.1.16", "@tanstack/react-router": "^1.131.35", "@tanstack/react-router-devtools": "^1.131.35", "react": "19.1.1", @@ -28,22 +28,23 @@ "zod": "^4.1.5" }, "devDependencies": { - "tailwindcss": "^4.1.12", + "@eslint/compat": "^1.3.1", + "@eslint/js": "^9.32.0", "@tailwindcss/forms": "^0.5.10", "@tailwindcss/vite": "^4.1.12", "@tanstack/router-plugin": "^1.131.7", "@types/node": "^24.2.1", "@types/react": "^19.1.4", "@types/react-dom": "^19.1.7", - "@typescript-eslint/eslint-plugin": "^7.2.0", - "@typescript-eslint/parser": "^7.2.0", - "@vitejs/plugin-react": "^4.4.1", - "eslint": "^8.56.0", + "@typescript-eslint/eslint-plugin": "^8.38.0", + "@typescript-eslint/parser": "^8.38.0", + "@vitejs/plugin-react": "^5.0.2", + "eslint": "^9.32.0", "eslint-config-prettier": "^10.1.8", "eslint-plugin-prettier": "^5.5.4", - "eslint-plugin-react-hooks": "^5.2.0", - "eslint-plugin-react-refresh": "^0.4.20", + "eslint-plugin-react": "^7.37.5", "prettier": "^3.6.2", + "tailwindcss": "^4.1.12", "typescript": "^5.9.2", "vite": "^7.1.4" } From 86540002601fc48531898ad6799818f80ed68f8b Mon Sep 17 00:00:00 2001 From: Garry Hill Date: Mon, 15 Sep 2025 14:27:30 +0100 Subject: [PATCH 17/18] fix task name in error --- lib/mix/tasks/phx.sync.tanstack_db.setup.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/mix/tasks/phx.sync.tanstack_db.setup.ex b/lib/mix/tasks/phx.sync.tanstack_db.setup.ex index 212bbce..edcbc93 100644 --- a/lib/mix/tasks/phx.sync.tanstack_db.setup.ex +++ b/lib/mix/tasks/phx.sync.tanstack_db.setup.ex @@ -384,7 +384,7 @@ else @impl Mix.Task def run(_argv) do Mix.shell().error(""" - The task 'phx_sync.tanstack_db' requires igniter. Please install igniter and try again. + The task 'phx.sync.tanstack_db.setup' requires igniter. Please install igniter and try again. For more information, see: https://hexdocs.pm/igniter/readme.html#installation """) From fe73d07b4f8c5b1d1c5360977866c4afc1f9d7b0 Mon Sep 17 00:00:00 2001 From: Garry Hill Date: Mon, 15 Sep 2025 14:27:37 +0100 Subject: [PATCH 18/18] include priv in hex package --- mix.exs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mix.exs b/mix.exs index 352a160..e6259ea 100644 --- a/mix.exs +++ b/mix.exs @@ -121,7 +121,7 @@ defmodule Phoenix.Sync.MixProject do "Source code" => "https://github.com/electric-sql/phoenix_sync" }, licenses: ["Apache-2.0"], - files: ~w(lib .formatter.exs mix.exs README.md LICENSE) + files: ~w(lib priv .formatter.exs mix.exs README.md LICENSE) ] end