From de43a1877a5eb786dbb164b1b4682eff985d1155 Mon Sep 17 00:00:00 2001 From: pallaoro Date: Thu, 3 Sep 2026 15:32:26 +0200 Subject: [PATCH 1/2] Never publish the same post to the same channel twice MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Publishing was safe to run once. Three ordinary things run it again: - the scheduler that fires a post at its scheduled time delivers at least once, so a delivery whose response is lost comes back; - the queue view flags a half-delivered post as needing attention, and the obvious answer is to retry it; - someone edits a post that has already gone out and saves it. In each case publishPost() walked every channel on the post and sent again, so the channels that had already succeeded got a second live post. There is no undo on a published post, so this could not be cleaned up afterwards. Delivery is now claimed per channel before anything is sent: - a channel already marked published is reported with the post id and link it got the first time, and nothing is sent; - the rest are claimed with a compare-and-swap on the attempts counter (a column that was written but never read), so two deliveries racing each other resolve to exactly one send. A crashed run wedges nothing: the row is still pending or failed, so the next retry claims it again; - the post's own status is rolled up by re-reading post_channels, because a retry only touches part of it and a concurrent delivery may have settled the rest. Editing a post no longer deletes and re-inserts its channel rows either. That row is where the delivery state lives, so re-inserting it dropped the link to a post that was already live, reset the channel to pending, and re-armed the duplicate. Channels that stay on the post are updated in place; only the ones actually removed are deleted. With that, Retry finally works on a partial post — the status that most needs it, and the one the button used to skip. Adds `pnpm test`: the real API against an in-memory SQLite database, in process, counting the calls made to each platform. All nine of these defects reproduce on the previous code. --- .gitignore | 1 + README.md | 23 +++ package.json | 4 +- pnpm-lock.yaml | 271 ++++++++++++++++++++++++++++ src/client/components/post-card.tsx | 14 +- src/server/index.ts | 97 ++++++++-- src/server/schema.sql | 4 + test/harness.mjs | 127 +++++++++++++ test/publish-idempotency.mjs | 124 +++++++++++++ 9 files changed, 648 insertions(+), 17 deletions(-) create mode 100644 test/harness.mjs create mode 100644 test/publish-idempotency.mjs diff --git a/.gitignore b/.gitignore index 0a95d1c..3b53fe3 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ data.db-shm .dev.vars .wrangler/ .clawnify/ +test/.server.mjs diff --git a/README.md b/README.md index ff4f5c0..2ca6c4d 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ Think of it as an open-source alternative to **Buffer**, **Hypefury**, **Typeful - **Dashboard** -- at-a-glance stats, upcoming posts, and recent drafts - **Native previews** -- see the post as it will look on each selected platform before it goes out - **Direct publishing** -- publish to X, LinkedIn, Instagram, Facebook Pages, TikTok and Bluesky through the accounts connected in Clawnify +- **Publish once, exactly once** -- a channel that has already gone out is never sent again, so retrying a half-delivered post, editing a post that is already live, or a scheduled delivery arriving twice all do the right thing instead of double-posting - **URL routing** -- bookmarkable pages (`/compose`, `/calendar`, `/queue`, `/drafts`, `/channels`, `/analytics`) ### Supported Platforms @@ -56,10 +57,29 @@ pnpm dev Open `http://localhost:5173` in your browser. The database schema is applied automatically on startup. +### Tests + +```bash +pnpm test +``` + +Runs the real API against an in-memory SQLite database, in process -- no dev +server and nothing to deploy. Covers the publishing rules above by counting the +calls the app actually makes to each platform. Needs Node 22.5+ (for +`node:sqlite`). + ### Publishing Publishing runs through the accounts connected in Clawnify -- no API keys in the app. Locally there is no credential service, so posts save and schedule but publishing reports each channel as not connected. +Each channel on a post is delivered and tracked on its own: one platform rejecting +the content marks that channel failed, with the reason, while the rest still go +out. The post then reads `partial`, and **Retry** sends only the channels that +did not make it -- a published channel is never posted to twice, whether the +retry comes from you, from an edit, or from a scheduled delivery that arrives +more than once. There is no undo on a live post, so this is enforced in the +database rather than left to the caller. + ## Tech Stack | Layer | Technology | @@ -104,6 +124,9 @@ src/ analytics-view.tsx -- Bar charts and daily activity post-card.tsx -- Reusable post preview card error-banner.tsx -- Error display +test/ + harness.mjs -- Boots the real API over an in-memory SQLite database + publish-idempotency.mjs -- Publishing is safe to run twice ``` ### API Endpoints diff --git a/package.json b/package.json index f70d199..88f299e 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,8 @@ "type": "module", "scripts": { "dev": "wrangler d1 execute open-post-db --local --file=src/server/schema.sql && concurrently -n ui,api -c cyan,green \"vite\" \"wrangler dev --port 8787\"", - "build": "vite build" + "build": "vite build", + "test": "esbuild src/server/index.ts --bundle --format=esm --platform=node --outfile=test/.server.mjs && node test/publish-idempotency.mjs" }, "dependencies": { "@clawnify/app": "^0.1.0", @@ -21,6 +22,7 @@ "devDependencies": { "@preact/preset-vite": "^2.9.0", "concurrently": "^9.0.0", + "esbuild": "^0.28.2", "typescript": "^5.7.0", "vite": "^6.0.0", "wrangler": "^4.0.0" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 71fa66d..fc00fde 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -42,6 +42,9 @@ importers: concurrently: specifier: ^9.0.0 version: 9.2.1 + esbuild: + specifier: ^0.28.2 + version: 0.28.2 typescript: specifier: ^5.7.0 version: 5.9.3 @@ -229,6 +232,12 @@ packages: cpu: [ppc64] os: [aix] + '@esbuild/aix-ppc64@0.28.2': + resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + '@esbuild/android-arm64@0.25.12': resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} engines: {node: '>=18'} @@ -241,6 +250,12 @@ packages: cpu: [arm64] os: [android] + '@esbuild/android-arm64@0.28.2': + resolution: {integrity: sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + '@esbuild/android-arm@0.25.12': resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} engines: {node: '>=18'} @@ -253,6 +268,12 @@ packages: cpu: [arm] os: [android] + '@esbuild/android-arm@0.28.2': + resolution: {integrity: sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + '@esbuild/android-x64@0.25.12': resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} engines: {node: '>=18'} @@ -265,6 +286,12 @@ packages: cpu: [x64] os: [android] + '@esbuild/android-x64@0.28.2': + resolution: {integrity: sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + '@esbuild/darwin-arm64@0.25.12': resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} engines: {node: '>=18'} @@ -277,6 +304,12 @@ packages: cpu: [arm64] os: [darwin] + '@esbuild/darwin-arm64@0.28.2': + resolution: {integrity: sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + '@esbuild/darwin-x64@0.25.12': resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} engines: {node: '>=18'} @@ -289,6 +322,12 @@ packages: cpu: [x64] os: [darwin] + '@esbuild/darwin-x64@0.28.2': + resolution: {integrity: sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + '@esbuild/freebsd-arm64@0.25.12': resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} engines: {node: '>=18'} @@ -301,6 +340,12 @@ packages: cpu: [arm64] os: [freebsd] + '@esbuild/freebsd-arm64@0.28.2': + resolution: {integrity: sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + '@esbuild/freebsd-x64@0.25.12': resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} engines: {node: '>=18'} @@ -313,6 +358,12 @@ packages: cpu: [x64] os: [freebsd] + '@esbuild/freebsd-x64@0.28.2': + resolution: {integrity: sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + '@esbuild/linux-arm64@0.25.12': resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} engines: {node: '>=18'} @@ -325,6 +376,12 @@ packages: cpu: [arm64] os: [linux] + '@esbuild/linux-arm64@0.28.2': + resolution: {integrity: sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + '@esbuild/linux-arm@0.25.12': resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} engines: {node: '>=18'} @@ -337,6 +394,12 @@ packages: cpu: [arm] os: [linux] + '@esbuild/linux-arm@0.28.2': + resolution: {integrity: sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + '@esbuild/linux-ia32@0.25.12': resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} engines: {node: '>=18'} @@ -349,6 +412,12 @@ packages: cpu: [ia32] os: [linux] + '@esbuild/linux-ia32@0.28.2': + resolution: {integrity: sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + '@esbuild/linux-loong64@0.25.12': resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} engines: {node: '>=18'} @@ -361,6 +430,12 @@ packages: cpu: [loong64] os: [linux] + '@esbuild/linux-loong64@0.28.2': + resolution: {integrity: sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + '@esbuild/linux-mips64el@0.25.12': resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} engines: {node: '>=18'} @@ -373,6 +448,12 @@ packages: cpu: [mips64el] os: [linux] + '@esbuild/linux-mips64el@0.28.2': + resolution: {integrity: sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + '@esbuild/linux-ppc64@0.25.12': resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} engines: {node: '>=18'} @@ -385,6 +466,12 @@ packages: cpu: [ppc64] os: [linux] + '@esbuild/linux-ppc64@0.28.2': + resolution: {integrity: sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + '@esbuild/linux-riscv64@0.25.12': resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} engines: {node: '>=18'} @@ -397,6 +484,12 @@ packages: cpu: [riscv64] os: [linux] + '@esbuild/linux-riscv64@0.28.2': + resolution: {integrity: sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + '@esbuild/linux-s390x@0.25.12': resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} engines: {node: '>=18'} @@ -409,6 +502,12 @@ packages: cpu: [s390x] os: [linux] + '@esbuild/linux-s390x@0.28.2': + resolution: {integrity: sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + '@esbuild/linux-x64@0.25.12': resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} engines: {node: '>=18'} @@ -421,6 +520,12 @@ packages: cpu: [x64] os: [linux] + '@esbuild/linux-x64@0.28.2': + resolution: {integrity: sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + '@esbuild/netbsd-arm64@0.25.12': resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} engines: {node: '>=18'} @@ -433,6 +538,12 @@ packages: cpu: [arm64] os: [netbsd] + '@esbuild/netbsd-arm64@0.28.2': + resolution: {integrity: sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + '@esbuild/netbsd-x64@0.25.12': resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} engines: {node: '>=18'} @@ -445,6 +556,12 @@ packages: cpu: [x64] os: [netbsd] + '@esbuild/netbsd-x64@0.28.2': + resolution: {integrity: sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + '@esbuild/openbsd-arm64@0.25.12': resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} engines: {node: '>=18'} @@ -457,6 +574,12 @@ packages: cpu: [arm64] os: [openbsd] + '@esbuild/openbsd-arm64@0.28.2': + resolution: {integrity: sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + '@esbuild/openbsd-x64@0.25.12': resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} engines: {node: '>=18'} @@ -469,6 +592,12 @@ packages: cpu: [x64] os: [openbsd] + '@esbuild/openbsd-x64@0.28.2': + resolution: {integrity: sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + '@esbuild/openharmony-arm64@0.25.12': resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} engines: {node: '>=18'} @@ -481,6 +610,12 @@ packages: cpu: [arm64] os: [openharmony] + '@esbuild/openharmony-arm64@0.28.2': + resolution: {integrity: sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + '@esbuild/sunos-x64@0.25.12': resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} engines: {node: '>=18'} @@ -493,6 +628,12 @@ packages: cpu: [x64] os: [sunos] + '@esbuild/sunos-x64@0.28.2': + resolution: {integrity: sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + '@esbuild/win32-arm64@0.25.12': resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} engines: {node: '>=18'} @@ -505,6 +646,12 @@ packages: cpu: [arm64] os: [win32] + '@esbuild/win32-arm64@0.28.2': + resolution: {integrity: sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + '@esbuild/win32-ia32@0.25.12': resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} engines: {node: '>=18'} @@ -517,6 +664,12 @@ packages: cpu: [ia32] os: [win32] + '@esbuild/win32-ia32@0.28.2': + resolution: {integrity: sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + '@esbuild/win32-x64@0.25.12': resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} engines: {node: '>=18'} @@ -529,6 +682,12 @@ packages: cpu: [x64] os: [win32] + '@esbuild/win32-x64@0.28.2': + resolution: {integrity: sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + '@hono/zod-openapi@0.18.4': resolution: {integrity: sha512-6NHMHU96Hh32B1yDhb94Z4Z5/POsmEu2AXpWLWcBq9arskRnOMt2752yEoXoADV8WUAc7H1IkNaQHGj1ytXbYw==} engines: {node: '>=16.0.0'} @@ -1212,6 +1371,11 @@ packages: engines: {node: '>=18'} hasBin: true + esbuild@0.28.2: + resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==} + engines: {node: '>=18'} + hasBin: true + escalade@3.2.0: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} @@ -1876,156 +2040,234 @@ snapshots: '@esbuild/aix-ppc64@0.27.3': optional: true + '@esbuild/aix-ppc64@0.28.2': + optional: true + '@esbuild/android-arm64@0.25.12': optional: true '@esbuild/android-arm64@0.27.3': optional: true + '@esbuild/android-arm64@0.28.2': + optional: true + '@esbuild/android-arm@0.25.12': optional: true '@esbuild/android-arm@0.27.3': optional: true + '@esbuild/android-arm@0.28.2': + optional: true + '@esbuild/android-x64@0.25.12': optional: true '@esbuild/android-x64@0.27.3': optional: true + '@esbuild/android-x64@0.28.2': + optional: true + '@esbuild/darwin-arm64@0.25.12': optional: true '@esbuild/darwin-arm64@0.27.3': optional: true + '@esbuild/darwin-arm64@0.28.2': + optional: true + '@esbuild/darwin-x64@0.25.12': optional: true '@esbuild/darwin-x64@0.27.3': optional: true + '@esbuild/darwin-x64@0.28.2': + optional: true + '@esbuild/freebsd-arm64@0.25.12': optional: true '@esbuild/freebsd-arm64@0.27.3': optional: true + '@esbuild/freebsd-arm64@0.28.2': + optional: true + '@esbuild/freebsd-x64@0.25.12': optional: true '@esbuild/freebsd-x64@0.27.3': optional: true + '@esbuild/freebsd-x64@0.28.2': + optional: true + '@esbuild/linux-arm64@0.25.12': optional: true '@esbuild/linux-arm64@0.27.3': optional: true + '@esbuild/linux-arm64@0.28.2': + optional: true + '@esbuild/linux-arm@0.25.12': optional: true '@esbuild/linux-arm@0.27.3': optional: true + '@esbuild/linux-arm@0.28.2': + optional: true + '@esbuild/linux-ia32@0.25.12': optional: true '@esbuild/linux-ia32@0.27.3': optional: true + '@esbuild/linux-ia32@0.28.2': + optional: true + '@esbuild/linux-loong64@0.25.12': optional: true '@esbuild/linux-loong64@0.27.3': optional: true + '@esbuild/linux-loong64@0.28.2': + optional: true + '@esbuild/linux-mips64el@0.25.12': optional: true '@esbuild/linux-mips64el@0.27.3': optional: true + '@esbuild/linux-mips64el@0.28.2': + optional: true + '@esbuild/linux-ppc64@0.25.12': optional: true '@esbuild/linux-ppc64@0.27.3': optional: true + '@esbuild/linux-ppc64@0.28.2': + optional: true + '@esbuild/linux-riscv64@0.25.12': optional: true '@esbuild/linux-riscv64@0.27.3': optional: true + '@esbuild/linux-riscv64@0.28.2': + optional: true + '@esbuild/linux-s390x@0.25.12': optional: true '@esbuild/linux-s390x@0.27.3': optional: true + '@esbuild/linux-s390x@0.28.2': + optional: true + '@esbuild/linux-x64@0.25.12': optional: true '@esbuild/linux-x64@0.27.3': optional: true + '@esbuild/linux-x64@0.28.2': + optional: true + '@esbuild/netbsd-arm64@0.25.12': optional: true '@esbuild/netbsd-arm64@0.27.3': optional: true + '@esbuild/netbsd-arm64@0.28.2': + optional: true + '@esbuild/netbsd-x64@0.25.12': optional: true '@esbuild/netbsd-x64@0.27.3': optional: true + '@esbuild/netbsd-x64@0.28.2': + optional: true + '@esbuild/openbsd-arm64@0.25.12': optional: true '@esbuild/openbsd-arm64@0.27.3': optional: true + '@esbuild/openbsd-arm64@0.28.2': + optional: true + '@esbuild/openbsd-x64@0.25.12': optional: true '@esbuild/openbsd-x64@0.27.3': optional: true + '@esbuild/openbsd-x64@0.28.2': + optional: true + '@esbuild/openharmony-arm64@0.25.12': optional: true '@esbuild/openharmony-arm64@0.27.3': optional: true + '@esbuild/openharmony-arm64@0.28.2': + optional: true + '@esbuild/sunos-x64@0.25.12': optional: true '@esbuild/sunos-x64@0.27.3': optional: true + '@esbuild/sunos-x64@0.28.2': + optional: true + '@esbuild/win32-arm64@0.25.12': optional: true '@esbuild/win32-arm64@0.27.3': optional: true + '@esbuild/win32-arm64@0.28.2': + optional: true + '@esbuild/win32-ia32@0.25.12': optional: true '@esbuild/win32-ia32@0.27.3': optional: true + '@esbuild/win32-ia32@0.28.2': + optional: true + '@esbuild/win32-x64@0.25.12': optional: true '@esbuild/win32-x64@0.27.3': optional: true + '@esbuild/win32-x64@0.28.2': + optional: true + '@hono/zod-openapi@0.18.4(hono@4.12.14)(zod@3.25.76)': dependencies: '@asteasolutions/zod-to-openapi': 7.3.4(zod@3.25.76) @@ -2534,6 +2776,35 @@ snapshots: '@esbuild/win32-ia32': 0.27.3 '@esbuild/win32-x64': 0.27.3 + esbuild@0.28.2: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.2 + '@esbuild/android-arm': 0.28.2 + '@esbuild/android-arm64': 0.28.2 + '@esbuild/android-x64': 0.28.2 + '@esbuild/darwin-arm64': 0.28.2 + '@esbuild/darwin-x64': 0.28.2 + '@esbuild/freebsd-arm64': 0.28.2 + '@esbuild/freebsd-x64': 0.28.2 + '@esbuild/linux-arm': 0.28.2 + '@esbuild/linux-arm64': 0.28.2 + '@esbuild/linux-ia32': 0.28.2 + '@esbuild/linux-loong64': 0.28.2 + '@esbuild/linux-mips64el': 0.28.2 + '@esbuild/linux-ppc64': 0.28.2 + '@esbuild/linux-riscv64': 0.28.2 + '@esbuild/linux-s390x': 0.28.2 + '@esbuild/linux-x64': 0.28.2 + '@esbuild/netbsd-arm64': 0.28.2 + '@esbuild/netbsd-x64': 0.28.2 + '@esbuild/openbsd-arm64': 0.28.2 + '@esbuild/openbsd-x64': 0.28.2 + '@esbuild/openharmony-arm64': 0.28.2 + '@esbuild/sunos-x64': 0.28.2 + '@esbuild/win32-arm64': 0.28.2 + '@esbuild/win32-ia32': 0.28.2 + '@esbuild/win32-x64': 0.28.2 + escalade@3.2.0: {} estree-walker@2.0.2: {} diff --git a/src/client/components/post-card.tsx b/src/client/components/post-card.tsx index e05b0fb..371e611 100644 --- a/src/client/components/post-card.tsx +++ b/src/client/components/post-card.tsx @@ -116,11 +116,21 @@ export function PostCard({ post, onEdit, onDelete, onPublish, preview }: Props) )}
- {onPublish && (post.status === "scheduled" || post.status === "failed") && ( + {/* A partial post is the one that most needs this button — some + channels went out, some didn't — and it used to be the one status + that couldn't reach it. Safe now that publishing skips channels + already delivered (publishPost, src/server/index.ts). */} + {onPublish && (post.status === "scheduled" || post.status === "failed" || post.status === "partial") && ( diff --git a/src/server/index.ts b/src/server/index.ts index 2b97002..c3a3490 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -42,9 +42,17 @@ async function publishPost(id: number): Promise<{ published: boolean; results: P if (!post) return null; // channel_content is this channel's own version of the text; NULL means it - // inherits the shared draft (posts.content). + // inherits the shared draft (posts.content). The pc.* delivery columns come + // along because this function is re-entrant: it must know which channels are + // already live before it sends anything. const channels = await query( - `SELECT c.*, pc.content AS channel_content FROM channels c + `SELECT c.*, + pc.content AS channel_content, + pc.status AS delivery_status, + pc.ref AS delivery_ref, + pc.url AS delivery_url, + pc.attempts AS delivery_attempts + FROM channels c JOIN post_channels pc ON pc.channel_id = c.id WHERE pc.post_id = ?`, [id], @@ -62,13 +70,47 @@ async function publishPost(id: number): Promise<{ published: boolean; results: P const results: PublishResult[] = []; for (const channel of channels) { + const base = { + channelId: channel.id as number, + channel: channel.name as string, + platform: channel.platform as string, + }; + + // Already live. Report it as delivered — with the link it got the first + // time — and send nothing. This is what makes the whole function safe to + // run twice: the queue delivers at least once, so a redelivery (or a user + // retrying a partial post) re-enters here with some channels already + // published, and a tweet cannot be un-posted. + if (channel.delivery_status === "published") { + results.push({ ...base, success: true, ref: channel.delivery_ref ?? undefined, url: channel.delivery_url ?? undefined }); + continue; + } + + // Claim the channel before sending: bump attempts only if it still holds + // the value we read. Two deliveries racing each other both read the same + // attempts, both try the swap, and exactly one wins — the loser skips + // rather than posting a duplicate. Using the existing attempts counter (a + // column that was written but never read) rather than a "sending" status + // means a crashed run leaves no wedged row: the status is still + // pending/failed, so the next retry simply claims it again. + // + // What this cannot cover: a crash after the platform accepted the post but + // before the result was written. Only a platform-side idempotency key + // could, and none of these APIs offers one. + const claim = await run( + `UPDATE post_channels SET attempts = attempts + 1 + WHERE post_id = ? AND channel_id = ? AND status != 'published' AND attempts = ?`, + [id, channel.id, channel.delivery_attempts ?? 0], + ); + if (claim.changes !== 1) continue; + const r = await publishToChannel(channel, channelContent(channel, post.content), imageUrls); // Persist this channel's delivery outcome on its post_channels row. + // attempts was already incremented by the claim above. await run( `UPDATE post_channels SET status = ?, ref = ?, url = ?, error = ?, - published_at = CASE WHEN ? THEN datetime('now') ELSE published_at END, - attempts = attempts + 1 + published_at = CASE WHEN ? THEN datetime('now') ELSE published_at END WHERE post_id = ? AND channel_id = ?`, [ r.success ? "published" : "failed", @@ -83,10 +125,20 @@ async function publishPost(id: number): Promise<{ published: boolean; results: P results.push(r); } - // Roll the post's own status up from the per-channel outcomes: all delivered - // → published, some delivered → partial, none → failed. - const delivered = results.filter((r) => r.success).length; - const rollup = delivered === 0 ? "failed" : delivered < results.length ? "partial" : "published"; + // Roll the post's status up from what the table now says, not from this + // run's results: a retry only touches the channels that had not gone out, + // and a concurrent delivery may have settled the rest. Re-reading is the + // only view that covers both. + const states = await query("SELECT status FROM post_channels WHERE post_id = ?", [id]); + const delivered = states.filter((s: any) => s.status === "published").length; + const inFlight = states.filter((s: any) => s.status === "pending").length; + const rollup = + delivered === states.length ? "published" + : delivered > 0 ? "partial" + // Nothing delivered but something is still pending: another delivery holds + // it. Don't call the post failed on its behalf. + : inFlight > 0 ? (post.status as string) + : "failed"; await run( `UPDATE posts SET status = ?, @@ -682,19 +734,37 @@ app.delete("/api/labels/:id", async (c) => { // ── Posts ── -// Write a post's channel rows, carrying each channel's own version of the text -// where the request supplied one. A blank override is stored as NULL — "no -// override, inherit the shared draft" — so an emptied box never publishes an -// empty post. +// Reconcile a post's channel rows against the set the request asked for, +// carrying each channel's own version of the text where it supplied one. A +// blank override is stored as NULL — "no override, inherit the shared draft" — +// so an emptied box never publishes an empty post. +// +// Channels that stay on the post are updated in place, never deleted and +// re-inserted. Their post_channels row carries the delivery state (status / +// ref / url / published_at), so re-inserting would drop the link to a post +// that is already live and reset it to "pending" — losing the link the user +// clicks through, and re-arming publishPost to send the same thing again. +// Editing a post must not be able to double-post it. async function setPostChannels( postId: number, channelIds: number[], overrides: Record | undefined, ): Promise { + // Drop only the channels the post no longer has. + if (channelIds.length) { + await run( + `DELETE FROM post_channels + WHERE post_id = ? AND channel_id NOT IN (${channelIds.map(() => "?").join(", ")})`, + [postId, ...channelIds], + ); + } else { + await run("DELETE FROM post_channels WHERE post_id = ?", [postId]); + } for (const cid of channelIds) { const own = overrides?.[String(cid)]; await run( - "INSERT INTO post_channels (post_id, channel_id, content) VALUES (?, ?, ?)", + `INSERT INTO post_channels (post_id, channel_id, content) VALUES (?, ?, ?) + ON CONFLICT(post_id, channel_id) DO UPDATE SET content = excluded.content`, [postId, cid, typeof own === "string" && own.trim() ? own : null], ); } @@ -870,7 +940,6 @@ app.put("/api/posts/:id", async (c) => { ); if (channel_ids !== undefined) { - await run("DELETE FROM post_channels WHERE post_id = ?", [id]); await setPostChannels(id, channel_ids, channel_content); } if (label_ids !== undefined) { diff --git a/src/server/schema.sql b/src/server/schema.sql index 8f2028a..97f969e 100644 --- a/src/server/schema.sql +++ b/src/server/schema.sql @@ -60,6 +60,10 @@ CREATE TABLE IF NOT EXISTS post_channels ( url TEXT, -- link to the live post (Postiz: releaseURL) error TEXT, -- platform rejection reason when status = 'failed' published_at TEXT, + -- Delivery attempts, and the claim token that makes publishing re-entrant: + -- publishPost() bumps this with a compare-and-swap before it sends, so two + -- concurrent deliveries of the same post (the queue is at-least-once) can + -- never both send to this channel. See publishPost() in src/server/index.ts. attempts INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (post_id, channel_id) ); diff --git a/test/harness.mjs b/test/harness.mjs new file mode 100644 index 0000000..1655d88 --- /dev/null +++ b/test/harness.mjs @@ -0,0 +1,127 @@ +/** + * Run this app's real Hono server against node:sqlite, in-process. + * + * The app's storage layer (@clawnify/db) already abstracts its backend behind a + * `StorageBinding` — a `query(sql, params)` call — so a third implementation + * over node:sqlite needs no change to the server at all. That is what makes an + * end-to-end check possible with no dev server, no hosted database and no + * deploy: the real Hono app, the real schema, real SQL. + * + * Requires Node 22.5+ for node:sqlite. `pnpm test` bundles the server first. + */ +import { DatabaseSync } from "node:sqlite"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const SCHEMA = readFileSync(join(HERE, "..", "src", "server", "schema.sql"), "utf8"); +const BUNDLE = join(HERE, ".server.mjs"); + +// A fresh module instance per scenario: the server holds module-level state +// (the db/credentials singletons), so scenarios would leak into each other if +// they shared one import. A cache-busting query gives each its own copy. +let instance = 0; + +function storage(db) { + return { + async query(sql, params = []) { + const head = sql.trim().slice(0, 6).toUpperCase(); + const stmt = db.prepare(sql); + if (head === "SELECT" || head === "PRAGMA" || head.startsWith("WITH")) { + return { rows: stmt.all(...params) }; + } + const r = stmt.run(...params); + return { rows: [], meta: { changes: Number(r.changes), last_row_id: Number(r.lastInsertRowid) } }; + }, + }; +} + +/** + * Stand-in for the Clawnify credential broker. Every publish the app makes is + * appended to `sends`, so "did this post go out twice" is a counted fact rather + * than something inferred from the response. + * + * `plan.linkedinOk()` is read per call so a scenario can make LinkedIn fail and + * then recover, which is how a partial post and its retry are reproduced. + */ +function broker(plan = {}) { + const sends = []; + const linkedinOk = plan.linkedinOk ?? (() => true); + return { + sends, + binding: { + async getToken() { return null; }, + async listConnected() { return []; }, + async getCredentials() { return null; }, + async executeTool(service, toolSlug, args) { + switch (toolSlug) { + case "TWITTER_USER_LOOKUP_ME": + return { data: { data: { name: "Test", username: "test" } }, error: null, successful: true }; + case "LINKEDIN_GET_MY_INFO": + return linkedinOk() + ? { data: { id: "li-me", localizedFirstName: "Test", localizedLastName: "User" }, error: null, successful: true } + : { data: null, error: "LinkedIn token expired", successful: false }; + case "TWITTER_CREATION_OF_A_POST": + sends.push({ service, toolSlug, text: args.text }); + return { data: { data: { id: `tw-${sends.length}` } }, error: null, successful: true }; + case "LINKEDIN_CREATE_LINKED_IN_POST": + sends.push({ service, toolSlug, text: args.commentary }); + return { data: { x_restli_id: `li-${sends.length}` }, error: null, successful: true }; + default: + return { data: null, error: `harness has no stub for ${toolSlug}`, successful: false }; + } + }, + }, + }; +} + +/** A running app with an empty database, plus helpers to drive it over HTTP. */ +export async function boot(plan) { + const db = new DatabaseSync(":memory:"); + db.exec(SCHEMA); + const { default: app } = await import(`${BUNDLE}?${instance++}`); + const b = broker(plan); + const env = { STORAGE: storage(db), CREDENTIALS: b.binding, CLAWNIFY_ORG_ID: "org-test" }; + + const send = async (method, path, body) => { + const res = await app.request( + path, + body === undefined + ? { method } + : { method, headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }, + env, + ); + return { status: res.status, body: await res.json().catch(() => null) }; + }; + + return { + db, + sends: b.sends, + get: (path) => send("GET", path), + post: (path, body) => send("POST", path, body ?? {}), + put: (path, body) => send("PUT", path, body), + del: (path) => send("DELETE", path), + row: (sql, ...params) => db.prepare(sql).get(...params), + rows: (sql, ...params) => db.prepare(sql).all(...params), + }; +} + +// ── Assertions ── + +const checks = []; + +export function check(name, ok, detail = "") { + checks.push({ name, ok }); + console.log(`${ok ? " ok" : "FAIL"} ${name}${detail ? ` — ${detail}` : ""}`); +} + +export function section(title) { + console.log(`\n${title}`); +} + +export function report() { + const failed = checks.filter((c) => !c.ok); + console.log(`\n${checks.length - failed.length}/${checks.length} checks passed`); + if (failed.length) process.exitCode = 1; +} diff --git a/test/publish-idempotency.mjs b/test/publish-idempotency.mjs new file mode 100644 index 0000000..f530cef --- /dev/null +++ b/test/publish-idempotency.mjs @@ -0,0 +1,124 @@ +/** + * Publishing must be safe to run twice. + * + * Three things make it run twice in production, none of them exotic: + * • The Clawnify queue that fires scheduled posts is at-least-once, so a + * delivery whose response is lost comes back. + * • The Queue view offers a Retry on a post that only partly went out. + * • Someone edits a post that is already live and saves it again. + * + * A duplicate here is not a recoverable bug: the second tweet is on the user's + * timeline and no code can take it back. So every check below counts the sends + * the app actually made, not what it reported. + * + * Run with `pnpm test`. + */ +import { boot, check, section, report } from "./harness.mjs"; + +section("A redelivery of the same post"); +{ + const h = await boot(); + const x = (await h.post("/api/channels", { name: "X", platform: "twitter" })).body; + const li = (await h.post("/api/channels", { name: "LinkedIn", platform: "linkedin" })).body; + const post = (await h.post("/api/posts", { content: "hello", channel_ids: [x.id, li.id] })).body; + + const first = await h.post(`/api/posts/${post.id}/publish`); + const afterFirst = h.sends.length; + const second = await h.post(`/api/posts/${post.id}/publish`); + + check("sends nothing the second time", afterFirst === 2 && h.sends.length === 2, + `${afterFirst} sends, then ${h.sends.length}`); + check("still reports both channels delivered", + second.body.results.length === 2 && second.body.results.every((r) => r.success)); + check("reports the original post ids, not new ones", + JSON.stringify(second.body.results.map((r) => r.ref)) === + JSON.stringify(first.body.results.map((r) => r.ref)), + `${first.body.results.map((r) => r.ref)} then ${second.body.results.map((r) => r.ref)}`); + check("leaves the post published", h.row("SELECT status FROM posts WHERE id = ?", post.id).status === "published"); + check("does not count as another attempt", + h.rows("SELECT attempts FROM post_channels WHERE post_id = ?", post.id).every((r) => r.attempts === 1)); +} + +section("Retrying a post that only partly went out"); +{ + let linkedinUp = false; + const h = await boot({ linkedinOk: () => linkedinUp }); + const x = (await h.post("/api/channels", { name: "X", platform: "twitter" })).body; + const li = (await h.post("/api/channels", { name: "LinkedIn", platform: "linkedin" })).body; + const post = (await h.post("/api/posts", { content: "hello", channel_ids: [x.id, li.id] })).body; + + await h.post(`/api/posts/${post.id}/publish`); + check("one channel failing makes the post partial", + h.row("SELECT status FROM posts WHERE id = ?", post.id).status === "partial"); + + linkedinUp = true; + await h.post(`/api/posts/${post.id}/publish`); + const tweets = h.sends.filter((s) => s.service === "twitter").length; + const linkedins = h.sends.filter((s) => s.service === "linkedin").length; + check("does not post again to the channel that succeeded", tweets === 1, `${tweets} tweets`); + check("does post to the channel that failed", linkedins === 1, `${linkedins} LinkedIn posts`); + check("rolls the post up to published", + h.row("SELECT status FROM posts WHERE id = ?", post.id).status === "published"); + const row = h.row("SELECT status, error FROM post_channels WHERE post_id = ? AND channel_id = ?", post.id, li.id); + check("clears the recovered channel's error", row.status === "published" && row.error === null, + JSON.stringify(row)); +} + +section("Editing a post that is already live"); +{ + const h = await boot(); + const x = (await h.post("/api/channels", { name: "X", platform: "twitter" })).body; + const post = (await h.post("/api/posts", { content: "hello", channel_ids: [x.id] })).body; + await h.post(`/api/posts/${post.id}/publish`); + const before = h.row("SELECT * FROM post_channels WHERE post_id = ?", post.id); + + await h.put(`/api/posts/${post.id}`, { content: "hello, edited", channel_ids: [x.id] }); + const after = h.row("SELECT * FROM post_channels WHERE post_id = ?", post.id); + check("keeps the channel marked published", after.status === "published", after.status); + check("keeps the link to the live post", after.ref === before.ref && after.url === before.url, + `${before.ref} then ${after.ref}`); + + await h.post(`/api/posts/${post.id}/publish`); + check("publishing the edit does not post a second time", h.sends.length === 1, `${h.sends.length} sends`); + + await h.put(`/api/posts/${post.id}`, { + content: "shared", channel_ids: [x.id], channel_content: { [String(x.id)]: "just for X" }, + }); + check("still saves a per-channel rewrite", + h.row("SELECT content FROM post_channels WHERE post_id = ?", post.id).content === "just for X"); +} + +section("Two deliveries arriving at once"); +{ + const h = await boot(); + const x = (await h.post("/api/channels", { name: "X", platform: "twitter" })).body; + const post = (await h.post("/api/posts", { content: "hello", channel_ids: [x.id] })).body; + await Promise.all([ + h.post(`/api/posts/${post.id}/publish`), + h.post(`/api/posts/${post.id}/publish`), + ]); + check("only one of them sends", h.sends.length === 1, `${h.sends.length} sends`); + check("the post still ends up published", + h.row("SELECT status FROM posts WHERE id = ?", post.id).status === "published"); +} + +section("Removing a channel from a post"); +{ + const h = await boot(); + const x = (await h.post("/api/channels", { name: "X", platform: "twitter" })).body; + const li = (await h.post("/api/channels", { name: "LinkedIn", platform: "linkedin" })).body; + const post = (await h.post("/api/posts", { content: "hello", channel_ids: [x.id, li.id] })).body; + await h.post(`/api/posts/${post.id}/publish`); + + await h.put(`/api/posts/${post.id}`, { channel_ids: [x.id] }); + const kept = h.rows("SELECT channel_id, status FROM post_channels WHERE post_id = ?", post.id); + check("drops only the channel that was removed", + kept.length === 1 && kept[0].channel_id === x.id && kept[0].status === "published", + JSON.stringify(kept)); + + await h.put(`/api/posts/${post.id}`, { channel_ids: [] }); + check("clearing every channel leaves no rows", + h.row("SELECT COUNT(*) AS n FROM post_channels WHERE post_id = ?", post.id).n === 0); +} + +report(); From b5bb2e12e4bcd8ef090419435d4ef99cbe86d5ae Mon Sep 17 00:00:00 2001 From: pallaoro Date: Thu, 3 Sep 2026 15:44:22 +0200 Subject: [PATCH 2/2] Note the channel-count ceiling on the channel reconcile delete --- src/server/index.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/server/index.ts b/src/server/index.ts index c3a3490..ee02e0a 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -750,7 +750,10 @@ async function setPostChannels( channelIds: number[], overrides: Record | undefined, ): Promise { - // Drop only the channels the post no longer has. + // Drop only the channels the post no longer has. The NOT IN list binds one + // parameter per channel against a 100-parameter ceiling, so this breaks at + // 100 channels on a single post — but the serial publish loop above would hit + // the request time limit long before that, so it is not the first wall. if (channelIds.length) { await run( `DELETE FROM post_channels