Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 3 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -50,14 +50,13 @@ jobs:
steps:
- uses: actions/checkout@v6

# This immutable revision is the final schema contract from DevKit PR #25.
# API_SCHEMA_TEST_REF remains an explicit transition override once that
# contract is present on DevKit main.
# Migrations 020/021 supply player snapshots and inline base storage.
# Pin the reviewed contract so CI cannot silently test an older schema.
- name: Checkout authoritative schema and fixture harness
uses: actions/checkout@v6
with:
repository: ClashKingInc/DevKit
ref: ${{ vars.API_SCHEMA_TEST_REF || '7a2709adb648774564c239c495dac07e2b085461' }}
ref: 899b62f64b365a5a861e34178e34ed50c5c52d13
path: .schema-test
token: ${{ secrets.SCHEMA_REPO_TOKEN || github.token }}

Expand Down
9 changes: 9 additions & 0 deletions docs/inline-base-storage.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# Inline shared base data

Requires DevKit migration 021. Images, votes and downloads are stored on the shared `bases` row; `base_images` and `base_votes` are removed. Personal saves stay in `user_saved_bases`.

Dashboard create/edit/read, mobile library reads, bot vote/remove-vote, legacy resolution and image staging now use the inline fields. Existing response shapes remain unchanged, so App, Dashboard and Bot clients do not require a contract migration. Voter identities are not added to public responses.

Images use ordered URLs (up to four), with internal empty slots supported during legacy staging. Votes use a user-ID keyed JSON object holding vote and its existing update timestamp. Downloads retain their immutable first-download map. SQL updates change only the relevant field/key, so votes cannot overwrite downloads or images.

Cutover must drain old base traffic, apply 021, deploy this API and resume traffic. Old base handlers cannot run against 021 because their tables are removed. The existing leaderboard rollout prerequisites for migration 020 still apply. No production migration or deployment is part of these commits.
11 changes: 11 additions & 0 deletions docs/player-leaderboard-snapshots.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# Player leaderboard snapshot rollout

The Town Hall and league endpoints keep their existing response shape and 500-row maximum. Instead of sorting `basic_player` per request, they read DevKit migration 020's `player_townhall_leaderboards` and `player_league_leaderboards`. Tracking refreshes both every six hours, with an extra refresh after its guarded Monday trophy reset. Clan display data still comes from `basic_clan`.

Deploy in this order: DevKit migration 020, Tracking population, then this API. Both materialized views must report `ispopulated=true` in `pg_matviews` first. There is no fallback to a live sorting query if the migration/population is missing. No production changes are performed by this PR.

TH snapshots sort ranked league descending, trophies descending, then tag. Exact league snapshots sort trophies descending, then tag. Unknown and unranked leagues are excluded; zero-trophy ranked players remain eligible. The snapshots cover TH 7–18 and league IDs 105000001–105000036. Live Legend endpoints are unchanged.

Army-link normalization now accepts and preserves Warden mode, for example `h2m1p16e5_41`. This fixes the API's matching validation gap alongside Tracking's fix. Family matching still ignores mode; no new response fields or database mode columns are introduced.

Validation: full unit suite, workspace typecheck/lint, and `public-data.test.ts` against the schema-owned disposable Timescale harness. The PostgreSQL test proves that a profile change does not alter the board until refresh, then does after refresh. Cloudflare guidance was followed by retaining the existing request-scoped SQL layer and parameterized values; no Worker bindings were added.
14 changes: 7 additions & 7 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@clashking/api-workspace",
"version": "0.1.0-rc.18",
"version": "0.1.0-rc.26",
"private": true,
"type": "module",
"workspaces": [
Expand Down
4 changes: 2 additions & 2 deletions packages/api-client/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@clashking/api-client",
"version": "0.1.0-rc.25",
"version": "0.1.0-rc.26",
"license": "SEE LICENSE IN LICENSE",
"description": "Effect-based transport, auth, service-binding execution, and decoding for the ClashKing v2 API",
"repository": {
Expand All @@ -26,7 +26,7 @@
"typecheck": "tsc -p tsconfig.json --noEmit --pretty false"
},
"dependencies": {
"@clashking/api-contracts": "0.1.0-rc.25",
"@clashking/api-contracts": "0.1.0-rc.26",
"effect": "4.0.0-rc.112"
},
"devDependencies": {
Expand Down
2 changes: 1 addition & 1 deletion packages/api-contracts/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@clashking/api-contracts",
"version": "0.1.0-rc.25",
"version": "0.1.0-rc.26",
"license": "SEE LICENSE IN LICENSE",
"description": "Browser-safe Effect schemas and endpoint metadata for the ClashKing v2 API",
"repository": {
Expand Down
2 changes: 1 addition & 1 deletion scripts/test-postgres.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ test("CI reads the authoritative nested Go module and runs runner checks",()=>{
assert.doesNotMatch(workflow,/go-version-file: \.schema-test\/go\.mod/u)
assert.match(workflow,/npm run test:scripts/u)
assert.match(workflow,/test-postgres\.sh --check \.schema-test/u)
assert.match(workflow,/API_SCHEMA_TEST_REF \|\| '7a2709adb648774564c239c495dac07e2b085461'/u)
assert.match(workflow,/ref: 899b62f64b365a5a861e34178e34ed50c5c52d13/u)
})

test("CI and package preparation retain strict peer dependency validation",()=>{
Expand Down
4 changes: 2 additions & 2 deletions workers/api/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@clashking/api-worker",
"version": "0.1.0-rc.25",
"version": "0.1.0-rc.26",
"private": true,
"type": "module",
"scripts": {
Expand All @@ -12,7 +12,7 @@
"types": "wrangler types --cwd ../.. workers/api/worker-configuration.d.ts"
},
"dependencies": {
"@clashking/api-contracts": "0.1.0-rc.25",
"@clashking/api-contracts": "0.1.0-rc.26",
"@effect/sql-pg": "4.0.0-rc.112",
"bcryptjs": "3.0.3",
"effect": "4.0.0-rc.112",
Expand Down
6 changes: 6 additions & 0 deletions workers/api/src/army-link.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,12 @@ import { describe, expect, it } from "vitest"
import { normalizeArmyLink, parseArmyLinkQuery } from "./army-link.js"

describe("army link normalization", () => {
it("preserves Warden mode in share codes", () => {
expect(normalizeArmyLink("u2x0h2m1p16e41_5")).toBe("h2m1p16e5_41u2x0")
expect(normalizeArmyLink("h2m0")).toBe("h2m0")
expect(() => normalizeArmyLink("h2m")).toThrow()
expect(() => normalizeArmyLink("h2m1m0")).toThrow()
})
it("matches the canonical code vector after reordering a link", async () => {
const normalized = normalizeArmyLink("https://link.clashofclans.com/en?action=CopyArmy&army=u2x1-10x0s4x35d1x70i3x53h1p9e39-0p4e14_8")
expect(normalized).toBe("h0p4e8_14-1p9e39i3x53d1x70u10x0-2x1s4x35")
Expand Down
13 changes: 7 additions & 6 deletions workers/api/src/army-link.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,24 +14,25 @@ export const normalizeArmyLink = (input: string): string => {
|| url.searchParams.get("action") !== "CopyArmy" || url.searchParams.getAll("army").length !== 1) throw invalid()
payload = url.searchParams.get("army") ?? ""
}
if (!payload || !/^[hidus0-9xpe_-]+$/u.test(payload)) throw invalid()
if (!payload || !/^[hidus0-9xpem_-]+$/u.test(payload)) throw invalid()
const sections = [...payload.matchAll(/([hidus])([^hidus]*)/gu)]
if (sections.map((part) => part[0]).join("") !== payload) throw invalid()
const integer = (value: string) => {
const n = Number(value)
if (!Number.isSafeInteger(n) || n < 0 || n > 2_147_483_647) throw invalid()
return n
}
const heroes: Array<{ id: number; pet: number; equipment: number[] }> = []
const heroes: Array<{ id: number; mode?: number; pet: number; equipment: number[] }> = []
const items = new Map<string, Map<number, number>>()
for (const [, marker, body] of sections) {
if (!body) throw invalid()
for (const part of body.split("-")) {
if (marker === "h") {
const match = /^(\d+)(?:p(\d+))?(?:e(\d+(?:_\d+)*))?$/u.exec(part)
const match = /^(\d+)(?:m(\d+))?(?:p(\d+))?(?:e(\d+(?:_\d+)*))?$/u.exec(part)
if (!match) throw invalid()
heroes.push({ id: integer(match[1]!), pet: match[2] === undefined ? -1 : integer(match[2]),
equipment: match[3]?.split("_").map(integer).sort((a, b) => a - b) ?? [] })
heroes.push({ id: integer(match[1]!), ...(match[2] === undefined ? {} : { mode: integer(match[2]) }),
pet: match[3] === undefined ? -1 : integer(match[3]),
equipment: match[4]?.split("_").map(integer).sort((a, b) => a - b) ?? [] })
} else {
const match = /^(\d+)x(\d+)$/u.exec(part)
if (!match) throw invalid()
Expand All @@ -46,7 +47,7 @@ export const normalizeArmyLink = (input: string): string => {
}
heroes.sort((a, b) => a.id - b.id || a.pet - b.pet
|| (a.equipment.join("_") < b.equipment.join("_") ? -1 : a.equipment.join("_") > b.equipment.join("_") ? 1 : 0))
const parts = heroes.length ? ["h" + heroes.map((hero) => `${hero.id}${hero.pet >= 0 ? `p${hero.pet}` : ""}${hero.equipment.length ? `e${hero.equipment.join("_")}` : ""}`).join("-")] : []
const parts = heroes.length ? ["h" + heroes.map((hero) => `${hero.id}${hero.mode === undefined ? "" : `m${hero.mode}`}${hero.pet >= 0 ? `p${hero.pet}` : ""}${hero.equipment.length ? `e${hero.equipment.join("_")}` : ""}`).join("-")] : []

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Include mode when ordering hero entries

When an accepted code contains otherwise-identical hero entries that differ only by mode, the comparator returns zero while the newly emitted value includes mode, so input order leaks into the supposedly canonical result: h2m1-h2m0 and h2m0-h2m1 normalize differently. Because the normalized string is used for exact army_family_members.share_code lookups, a reordered equivalent query can incorrectly miss its family; include mode in the comparator or reject duplicate hero IDs.

Useful? React with 👍 / 👎.

for (const marker of ["i", "d", "u", "s"]) {
const section = items.get(marker)
if (section?.size) parts.push(marker + [...section].sort(([a], [b]) => a - b).map(([id, count]) => `${count}x${id}`).join("-"))
Expand Down
33 changes: 13 additions & 20 deletions workers/api/src/bot-adjacent-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,23 +128,16 @@ export class BotAdjacentStore extends Context.Service<BotAdjacentStore, {
WHERE sc.server_id = ${serverId} ORDER BY clan.name, sc.tag`),
upsertBaseVote: (baseId, voterId, direction) => Effect.gen(function* () {
const vote = direction === "up" ? 1 : -1
const rows = yield* db(sql.unsafe<{ baseId: string; voterId: string; direction: "up" | "down" }>(`WITH target AS (
SELECT id FROM bases WHERE id=$1::bigint
), changed AS (
INSERT INTO base_votes(base_id,user_id,vote) SELECT id,$2,$3 FROM target
ON CONFLICT (base_id,user_id) DO UPDATE SET vote=excluded.vote,updated_at=now()
RETURNING base_id
) SELECT changed.base_id::text "baseId",$2::text "voterId",$4::text direction FROM changed`,
const rows = yield* db(sql.unsafe<{ baseId: string; voterId: string; direction: "up" | "down" }>(`UPDATE bases
SET votes=jsonb_set(votes,ARRAY[$2::text],jsonb_build_object('vote',$3::integer,'updatedAt',now()))
WHERE id=$1::bigint RETURNING id::text "baseId",$2::text "voterId",$4::text direction`,
[baseId, voterId, vote, direction]))
if (rows[0] === undefined) return yield* new NotFound({ message: "Base not found" })
return rows[0]
}),
removeBaseVote: (baseId, voterId) => Effect.gen(function* () {
const rows = yield* db(sql.unsafe<{ id: string }>(`WITH target AS (
SELECT id FROM bases WHERE id=$1::bigint
), removed AS (
DELETE FROM base_votes vote USING target WHERE vote.base_id=target.id AND vote.user_id=$2 RETURNING vote.base_id
) SELECT id::text FROM target`, [baseId, voterId]))
const rows = yield* db(sql.unsafe<{ id: string }>(`UPDATE bases SET votes=votes-$2::text
WHERE id=$1::bigint RETURNING id::text`, [baseId, voterId]))
if (rows.length === 0) return yield* new NotFound({ message: "Base not found" })
}),
recordBaseDownload: (baseId, userId) => Effect.gen(function* () {
Expand All @@ -165,9 +158,8 @@ export class BotAdjacentStore extends Context.Service<BotAdjacentStore, {
}),
resolveLegacyBase: (messageId) => Effect.gen(function* () {
const rows = yield* db(sql.unsafe<{ id: string; message_id: string; server_id: string | null; channel_id: string | null; base_link: string; description: string; images: string[] }>(`SELECT base.id::text,base.message_id,base.server_id,base.channel_id,base.base_link,base.description,
COALESCE(array_agg(image.image_url ORDER BY image.position) FILTER (WHERE image.image_url IS NOT NULL),'{}'::text[]) images
FROM bases base LEFT JOIN base_images image ON image.base_id=base.id WHERE base.message_id=$1
GROUP BY base.id`, [messageId]))
array_remove(base.images,NULL) images
FROM bases base WHERE base.message_id=$1`, [messageId]))
const row = rows[0]
if (row === undefined) return yield* new NotFound({ message: "Base not found" })
return { id: row.id, messageId: row.message_id, serverId: row.server_id, channelId: row.channel_id,
Expand All @@ -180,8 +172,7 @@ export class BotAdjacentStore extends Context.Service<BotAdjacentStore, {
if (source.protocol !== "https:" || !["cdn.discordapp.com", "media.discordapp.net"].includes(source.hostname)) {
return yield* new InvalidRequest({ message: "Invalid Discord attachment URL" })
}
const existing = yield* db(sql.unsafe<{ image_url: string | null }>(`SELECT image.image_url FROM bases base
LEFT JOIN base_images image ON image.base_id=base.id AND image.position=$2 WHERE base.id=$1::bigint`, [baseId, position]))
const existing = yield* db(sql.unsafe<{ image_url: string | null }>(`SELECT images[$2::integer] image_url FROM bases WHERE id=$1::bigint`, [baseId, position]))
if (existing[0] === undefined) return yield* new NotFound({ message: "Base not found" })
if (existing[0].image_url !== null) return { baseId, position, imageUrl: existing[0].image_url }
const response = yield* Effect.tryPromise({
Expand All @@ -202,9 +193,11 @@ export class BotAdjacentStore extends Context.Service<BotAdjacentStore, {
if (blob.size > MAX_DASHBOARD_UPLOAD) return yield* new PayloadTooLarge({ message: "Base attachment exceeds 25 MB" })
const filename = `base_${baseId}_${position}_${crypto.randomUUID()}.${extension}`
const uploaded = yield* uploadMediaFile(bindings, filename, new File([blob], filename, { type: contentType }))
const staged = yield* db(sql.unsafe<{ image_url: string }>(`INSERT INTO base_images(base_id,position,image_url) VALUES ($1::bigint,$2,$3)
ON CONFLICT (base_id,position) DO NOTHING RETURNING image_url`, [baseId, position, uploaded.url]))
const imageUrl = staged[0]?.image_url ?? (yield* db(sql.unsafe<{ image_url: string }>("SELECT image_url FROM base_images WHERE base_id=$1::bigint AND position=$2", [baseId, position])))[0]?.image_url
const staged = yield* db(sql.unsafe<{ image_url: string }>(`UPDATE bases SET images=ARRAY(
SELECT CASE WHEN slot=$2::integer THEN $3::text ELSE images[slot] END
FROM generate_series(1,GREATEST(cardinality(images),$2::integer)) slot)
WHERE id=$1::bigint AND images[$2::integer] IS NULL RETURNING images[$2::integer] image_url`, [baseId, position, uploaded.url]))
const imageUrl = staged[0]?.image_url ?? (yield* db(sql.unsafe<{ image_url: string }>("SELECT images[$2::integer] image_url FROM bases WHERE id=$1::bigint", [baseId, position])))[0]?.image_url
if (imageUrl === undefined) return yield* new NotFound({ message: "Base not found" })
return { baseId, position, imageUrl }
}),
Expand Down
8 changes: 4 additions & 4 deletions workers/api/src/dashboard-server-bases.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,12 +126,12 @@ describe("managed base updates", () => {
expect(f.mediaGet).not.toHaveBeenCalled()
})

it("replaces the editable fields and image rows in one transaction", async () => {
it("replaces editable fields and inline images in one transaction", async () => {
const f = fixture()
const value = await f.run(UpdateBaseEndpoint, { body: editable })
expect(Schema.decodeUnknownSync(UpdateBaseEndpoint.response)(value)).toMatchObject({ id: baseId, serverId })
expect(f.events).toEqual(["SELECT", "PATCH", "UPDATE", "DELETE", "INSERT", "SELECT"])
expect(f.query.mock.calls[1]?.[1]).toEqual([body.baseLink, editable.description, baseId, serverId])
expect(f.events).toEqual(["SELECT", "PATCH", "UPDATE", "SELECT"])
expect(f.query.mock.calls[1]?.[1]).toEqual([body.baseLink, editable.description, baseId, serverId, editable.images])
expect(f.discord).toHaveBeenCalledWith(`/channels/${channelId}/messages/${messageId}`, { method: "PATCH", files: [expect.any(File)], body: {
content: editable.description, embeds: [], allowed_mentions: { parse: [] }, attachments: [{ id: 0, filename: "base_test.png" }],
components: [{ type: 1, components: [
Expand Down Expand Up @@ -221,7 +221,7 @@ describe("immutable base creation", () => {
const f = fixture()
const value = await f.run(CreateBaseEndpoint)
expect(Schema.decodeUnknownSync(CreateBaseEndpoint.response)(value)).toMatchObject({ messageId, serverId })
expect(f.events).toEqual(["GET Discord", "SELECT", "POST", "INSERT", "INSERT", "SELECT"])
expect(f.events).toEqual(["GET Discord", "SELECT", "POST", "INSERT", "SELECT"])
expect(f.discord.mock.calls[1]).toEqual([`/channels/${channelId}/messages`, { method: "POST", files: [expect.any(File)], body: {
content: body.description, embeds: [], allowed_mentions: { parse: [] }, attachments: [{ id: 0, filename: "base_test.png" }], components: [{ type: 1, components: [
{ type: 2, style: 1, label: "Get Link", emoji: { name: "🔗" }, custom_id: `base:link:${baseId}` },
Expand Down
Loading
Loading