@btst/stack 3.1.1 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/README.md +2 -2
  2. package/dist/packages/stack/src/plugins/ai-chat/client/hooks/chat-hooks.cjs +5 -5
  3. package/dist/packages/stack/src/plugins/ai-chat/client/hooks/chat-hooks.mjs +5 -5
  4. package/dist/packages/stack/src/plugins/cms/api/plugin.cjs +12 -0
  5. package/dist/packages/stack/src/plugins/cms/api/plugin.mjs +12 -0
  6. package/dist/packages/stack/src/plugins/kanban/api/getters.cjs +7 -5
  7. package/dist/packages/stack/src/plugins/kanban/api/getters.mjs +7 -5
  8. package/dist/packages/stack/src/plugins/media/client/hooks/use-media.cjs +10 -8
  9. package/dist/packages/stack/src/plugins/media/client/hooks/use-media.mjs +10 -8
  10. package/dist/plugins/blog/api/index.d.cts +2 -2
  11. package/dist/plugins/blog/api/index.d.mts +2 -2
  12. package/dist/plugins/blog/api/index.d.ts +2 -2
  13. package/dist/plugins/blog/client/hooks/index.d.cts +2 -2
  14. package/dist/plugins/blog/client/hooks/index.d.mts +2 -2
  15. package/dist/plugins/blog/client/hooks/index.d.ts +2 -2
  16. package/dist/plugins/blog/client/index.d.cts +2 -2
  17. package/dist/plugins/blog/client/index.d.mts +2 -2
  18. package/dist/plugins/blog/client/index.d.ts +2 -2
  19. package/dist/plugins/blog/query-keys.d.cts +2 -2
  20. package/dist/plugins/blog/query-keys.d.mts +2 -2
  21. package/dist/plugins/blog/query-keys.d.ts +2 -2
  22. package/dist/plugins/cms/api/index.d.cts +1 -1
  23. package/dist/plugins/cms/api/index.d.mts +1 -1
  24. package/dist/plugins/cms/api/index.d.ts +1 -1
  25. package/dist/plugins/cms/query-keys.d.cts +1 -1
  26. package/dist/plugins/cms/query-keys.d.mts +1 -1
  27. package/dist/plugins/cms/query-keys.d.ts +1 -1
  28. package/dist/plugins/ui-builder/client/hooks/index.d.cts +1 -1
  29. package/dist/plugins/ui-builder/client/hooks/index.d.mts +1 -1
  30. package/dist/plugins/ui-builder/client/hooks/index.d.ts +1 -1
  31. package/dist/plugins/ui-builder/client/index.d.cts +1 -1
  32. package/dist/plugins/ui-builder/client/index.d.mts +1 -1
  33. package/dist/plugins/ui-builder/client/index.d.ts +1 -1
  34. package/dist/plugins/ui-builder/query-keys.d.cts +1 -1
  35. package/dist/plugins/ui-builder/query-keys.d.mts +1 -1
  36. package/dist/plugins/ui-builder/query-keys.d.ts +1 -1
  37. package/dist/shared/{stack.BTStHVTy.d.cts → stack.BGzgFx3R.d.cts} +5 -5
  38. package/dist/shared/{stack.Ce6V7eDj.d.cts → stack.BOZRaVpu.d.cts} +37 -37
  39. package/dist/shared/{stack.94kN3pEK.d.mts → stack.CjCSDfWy.d.mts} +5 -5
  40. package/dist/shared/{stack.D5b9l24t.d.ts → stack.DlBoNqYu.d.ts} +37 -37
  41. package/dist/shared/{stack.DfrRYOIl.d.ts → stack.Dpg_JjIo.d.ts} +5 -5
  42. package/dist/shared/{stack.CTneIW0u.d.mts → stack.Dvy7FmaA.d.mts} +37 -37
  43. package/package.json +11 -7
  44. package/src/__tests__/package-metadata.test.ts +4 -4
  45. package/src/plugins/ai-chat/__tests__/hooks.test.tsx +70 -54
  46. package/src/plugins/ai-chat/client/hooks/chat-hooks.tsx +6 -9
  47. package/src/plugins/cms/__tests__/sync-content-types.test.ts +131 -0
  48. package/src/plugins/cms/api/plugin.ts +30 -0
  49. package/src/plugins/kanban/__tests__/getters.test.ts +14 -1
  50. package/src/plugins/kanban/api/getters.ts +9 -7
  51. package/src/plugins/kanban/types.ts +4 -6
  52. package/src/plugins/media/client/hooks/use-media.tsx +7 -8
  53. package/dist/shared/{stack.lsbdKRj7.d.mts → stack.B8PG21zX.d.mts} +16 -16
  54. package/dist/shared/{stack.CKc_Bais.d.cts → stack.Csi-4Mgz.d.cts} +7 -7
  55. package/dist/shared/{stack.CKc_Bais.d.mts → stack.Csi-4Mgz.d.mts} +7 -7
  56. package/dist/shared/{stack.CKc_Bais.d.ts → stack.Csi-4Mgz.d.ts} +7 -7
  57. package/dist/shared/{stack.BMlTZnZp.d.ts → stack.CyalJ0j6.d.ts} +16 -16
  58. package/dist/shared/{stack.BV0uNV4f.d.cts → stack.D6Pg-ufX.d.cts} +16 -16
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@btst/stack",
3
- "version": "3.1.1",
3
+ "version": "4.0.0",
4
4
  "description": "A composable, plugin-based library for building full-stack applications.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -1067,7 +1067,7 @@
1067
1067
  }
1068
1068
  },
1069
1069
  "dependencies": {
1070
- "@btst/db": "2.2.3",
1070
+ "@btst/db": "3.0.0",
1071
1071
  "@milkdown/crepe": "^7.17.1",
1072
1072
  "@milkdown/kit": "^7.17.1",
1073
1073
  "remend": "^1.0.1",
@@ -1088,7 +1088,7 @@
1088
1088
  "@tanstack/react-router": ">=1.0.0",
1089
1089
  "@vercel/blob": ">=0.14.0",
1090
1090
  "ai": ">=5.0.0",
1091
- "better-call": "1.3.6",
1091
+ "better-call": "1.4.0",
1092
1092
  "class-variance-authority": ">=0.7.0",
1093
1093
  "clsx": ">=2.1.0",
1094
1094
  "cmdk": ">=1.1.0",
@@ -1097,8 +1097,8 @@
1097
1097
  "katex": ">=0.16.0",
1098
1098
  "lucide-react": ">=0.469.0",
1099
1099
  "next": ">=15.0.0",
1100
- "react": "^18.0.0 || ^19.0.0",
1101
- "react-dom": "^18.0.0 || ^19.0.0",
1100
+ "react": ">=19.2.6",
1101
+ "react-dom": ">=19.2.6",
1102
1102
  "react-error-boundary": ">=4.0.0",
1103
1103
  "react-hook-form": ">=7.55.0",
1104
1104
  "react-markdown": ">=9.1.0",
@@ -1110,7 +1110,7 @@
1110
1110
  "remark-math": ">=6.0.0",
1111
1111
  "sonner": ">=2.0.0",
1112
1112
  "tailwind-merge": ">=2.6.0",
1113
- "tailwindcss": ">=3.0.0",
1113
+ "tailwindcss": ">=4.3.2",
1114
1114
  "zod": ">=4.2.0"
1115
1115
  },
1116
1116
  "peerDependenciesMeta": {
@@ -1137,7 +1137,10 @@
1137
1137
  "@ai-sdk/react": "^2.0.94",
1138
1138
  "@aws-sdk/client-s3": "^3.1011.0",
1139
1139
  "@aws-sdk/s3-request-presigner": "^3.1011.0",
1140
- "@btst/adapter-memory": "2.2.3",
1140
+ "@better-auth/core": "1.7.6",
1141
+ "@better-auth/utils": "0.4.2",
1142
+ "@better-fetch/fetch": "1.3.2",
1143
+ "@btst/adapter-memory": "3.0.0",
1141
1144
  "@btst/yar": "1.3.2",
1142
1145
  "@tanstack/react-router": "1.168.10",
1143
1146
  "@types/react": "^19.0.0",
@@ -1146,6 +1149,7 @@
1146
1149
  "@vercel/blob": "^0.27.3",
1147
1150
  "@workspace/ui": "workspace:*",
1148
1151
  "ai": "^5.0.94",
1152
+ "better-auth": "1.7.6",
1149
1153
  "better-call": "catalog:",
1150
1154
  "jsdom": "^28.1.0",
1151
1155
  "knip": "^5.61.2",
@@ -11,7 +11,7 @@ import { z } from "zod";
11
11
  const execFileAsync = promisify(execFile);
12
12
 
13
13
  describe("published dependency alignment", () => {
14
- it("uses the Better DB release that shares the Better Auth 1.6.16 cohort", async () => {
14
+ it("uses the Better DB release that shares the Better Auth 1.7.6 cohort", async () => {
15
15
  const manifest = JSON.parse(
16
16
  await readFile(resolve("package.json"), "utf8"),
17
17
  ) as {
@@ -20,11 +20,11 @@ describe("published dependency alignment", () => {
20
20
  peerDependencies?: Record<string, string>;
21
21
  };
22
22
 
23
- expect(manifest.dependencies?.["@btst/db"]).toBe("2.2.3");
24
- expect(manifest.devDependencies?.["@btst/adapter-memory"]).toBe("2.2.3");
23
+ expect(manifest.dependencies?.["@btst/db"]).toBe("3.0.0");
24
+ expect(manifest.devDependencies?.["@btst/adapter-memory"]).toBe("3.0.0");
25
25
  expect(manifest.devDependencies?.["@btst/yar"]).toBe("1.3.2");
26
26
  expect(manifest.peerDependencies?.["@btst/yar"]).toBe(">=1.3.2");
27
- expect(manifest.peerDependencies?.["better-call"]).toBe("1.3.6");
27
+ expect(manifest.peerDependencies?.["better-call"]).toBe("1.4.0");
28
28
  });
29
29
  });
30
30
 
@@ -177,58 +177,74 @@ describe("useRenameConversationForm", () => {
177
177
  });
178
178
 
179
179
  describe("useDeleteConversation", () => {
180
- it("refreshes the list and evicts only the deleted detail in the starting identity partition", async () => {
181
- const identityPartition = "anonymous" as const;
182
- const otherConversation = { ...conversation, id: "conv-2", title: "Other" };
183
- const otherIdentity = { id: "other-user", role: "user" } as const;
184
- const listKey = buildQueryKey(
185
- "conversations",
186
- "list",
187
- aiChatResources.conversations.queries.list,
188
- [identityPartition],
189
- );
190
- const deletedDetailKey = buildQueryKey(
191
- "conversations",
192
- "detail",
193
- aiChatResources.conversations.queries.detail,
194
- [conversation.id, identityPartition],
195
- );
196
- const otherDetailKey = buildQueryKey(
197
- "conversations",
198
- "detail",
199
- aiChatResources.conversations.queries.detail,
200
- [otherConversation.id, identityPartition],
201
- );
202
- const otherIdentityDetailKey = buildQueryKey(
203
- "conversations",
204
- "detail",
205
- aiChatResources.conversations.queries.detail,
206
- [conversation.id, otherIdentity],
207
- );
208
- let listFetches = 0;
209
- await queryClient.fetchQuery({
210
- queryKey: listKey,
211
- queryFn: async () => {
212
- listFetches += 1;
213
- return [conversation, otherConversation];
214
- },
215
- });
216
- listFetches = 0;
217
- queryClient.setQueryData(deletedDetailKey, conversation);
218
- queryClient.setQueryData(otherDetailKey, otherConversation);
219
- queryClient.setQueryData(otherIdentityDetailKey, conversation);
220
- fetchMock.mockResolvedValue(jsonResponse({ success: true }));
221
- const getMutation = await renderDeleteProbe();
222
-
223
- await act(async () => {
224
- await getMutation().mutateAsync({ id: conversation.id });
225
- });
226
-
227
- expect(listFetches).toBe(1);
228
- expect(queryClient.getQueryData(deletedDetailKey)).toBeUndefined();
229
- expect(queryClient.getQueryData(otherDetailKey)).toEqual(otherConversation);
230
- expect(queryClient.getQueryData(otherIdentityDetailKey)).toEqual(
231
- conversation,
232
- );
233
- });
180
+ it.each(["mutate", "mutateAsync"] as const)(
181
+ "%s forwards callbacks and evicts only the deleted detail in the starting identity partition",
182
+ async (method) => {
183
+ const identityPartition = "anonymous" as const;
184
+ const otherConversation = {
185
+ ...conversation,
186
+ id: "conv-2",
187
+ title: "Other",
188
+ };
189
+ const otherIdentity = { id: "other-user", role: "user" } as const;
190
+ const listKey = buildQueryKey(
191
+ "conversations",
192
+ "list",
193
+ aiChatResources.conversations.queries.list,
194
+ [identityPartition],
195
+ );
196
+ const deletedDetailKey = buildQueryKey(
197
+ "conversations",
198
+ "detail",
199
+ aiChatResources.conversations.queries.detail,
200
+ [conversation.id, identityPartition],
201
+ );
202
+ const otherDetailKey = buildQueryKey(
203
+ "conversations",
204
+ "detail",
205
+ aiChatResources.conversations.queries.detail,
206
+ [otherConversation.id, identityPartition],
207
+ );
208
+ const otherIdentityDetailKey = buildQueryKey(
209
+ "conversations",
210
+ "detail",
211
+ aiChatResources.conversations.queries.detail,
212
+ [conversation.id, otherIdentity],
213
+ );
214
+ let listFetches = 0;
215
+ await queryClient.fetchQuery({
216
+ queryKey: listKey,
217
+ queryFn: async () => {
218
+ listFetches += 1;
219
+ return [conversation, otherConversation];
220
+ },
221
+ });
222
+ listFetches = 0;
223
+ queryClient.setQueryData(deletedDetailKey, conversation);
224
+ queryClient.setQueryData(otherDetailKey, otherConversation);
225
+ queryClient.setQueryData(otherIdentityDetailKey, conversation);
226
+ fetchMock.mockResolvedValue(jsonResponse({ success: true }));
227
+ const getMutation = await renderDeleteProbe();
228
+ const onSuccess = vi.fn();
229
+
230
+ await act(async () => {
231
+ await getMutation()[method]({ id: conversation.id }, { onSuccess });
232
+ await vi.waitFor(() =>
233
+ expect(queryClient.getQueryData(deletedDetailKey)).toBeUndefined(),
234
+ );
235
+ });
236
+
237
+ expect(onSuccess).toHaveBeenCalledTimes(1);
238
+ expect(onSuccess.mock.calls[0]?.[1]).toEqual({ id: conversation.id });
239
+
240
+ expect(listFetches).toBe(1);
241
+ expect(queryClient.getQueryData(deletedDetailKey)).toBeUndefined();
242
+ expect(queryClient.getQueryData(otherDetailKey)).toEqual(
243
+ otherConversation,
244
+ );
245
+ expect(queryClient.getQueryData(otherIdentityDetailKey)).toEqual(
246
+ conversation,
247
+ );
248
+ },
249
+ );
234
250
  });
@@ -146,25 +146,22 @@ function withCurrentHistoryRefresh<TData, TVariables>(
146
146
  refresh: ReturnType<typeof useCurrentHistoryRefresh>,
147
147
  queryKey?: readonly unknown[],
148
148
  afterSuccess?: (
149
- variables: TVariables,
149
+ variables: Parameters<typeof mutation.mutateAsync>[0],
150
150
  startedAs: ReturnType<typeof useAiChatIdentityPartition>,
151
151
  ) => void,
152
152
  ): UseMutationResult<TData, Error, TVariables> {
153
- const mutateAsync: typeof mutation.mutateAsync = async (
154
- variables,
155
- options,
156
- ) => {
153
+ const mutateAsync: typeof mutation.mutateAsync = async (...args) => {
157
154
  const startedAs = refresh.currentPartition();
158
- const result = await mutation.mutateAsync(variables, options);
155
+ const result = await mutation.mutateAsync(...args);
159
156
  try {
160
157
  await refresh.refreshAfterSuccess(startedAs, queryKey);
161
158
  } finally {
162
- afterSuccess?.(variables, startedAs);
159
+ afterSuccess?.(args[0], startedAs);
163
160
  }
164
161
  return result;
165
162
  };
166
- const mutate: typeof mutation.mutate = (variables, options) => {
167
- void mutateAsync(variables, options).catch(() => {});
163
+ const mutate: typeof mutation.mutate = (...args) => {
164
+ void mutateAsync(...args).catch(() => {});
168
165
  };
169
166
  return { ...mutation, mutate, mutateAsync };
170
167
  }
@@ -0,0 +1,131 @@
1
+ import { createMemoryAdapter } from "@btst/adapter-memory";
2
+ import type { DBAdapter } from "@btst/db";
3
+ import { describe, expect, it, vi } from "vitest";
4
+ import { z } from "zod";
5
+ import { createBackendStack } from "../../../api";
6
+ import { cmsBackendPlugin } from "../api";
7
+ import type { ContentType } from "../types";
8
+
9
+ const contentTypes = [
10
+ {
11
+ name: "Article",
12
+ slug: "article",
13
+ schema: z.object({
14
+ title: z.string(),
15
+ status: z.enum(["draft", "published"]),
16
+ }),
17
+ },
18
+ ];
19
+ function coldStart(adapter?: DBAdapter) {
20
+ return createBackendStack({
21
+ basePath: "/api",
22
+ plugins: { cms: cmsBackendPlugin({ contentTypes }) },
23
+ adapter: (schema) => adapter ?? createMemoryAdapter(schema)({}),
24
+ });
25
+ }
26
+ async function fixture() {
27
+ const stack = coldStart();
28
+ await stack.trusted.cms.listContentTypes({});
29
+ const stored = await stack.adapter.findOne<ContentType>({
30
+ model: "contentType",
31
+ where: [{ field: "slug", value: "article" }],
32
+ });
33
+ if (!stored) throw new Error("Missing seeded content type");
34
+ return { adapter: stack.adapter, stored };
35
+ }
36
+
37
+ describe("CMS content-type synchronization", () => {
38
+ it("performs no updates on repeated cold starts and preserves serialized data", async () => {
39
+ const { adapter } = await fixture();
40
+ const update = vi.spyOn(adapter, "update");
41
+ const first = await coldStart(adapter).trusted.cms.listContentTypes({});
42
+ const second = await coldStart(adapter).trusted.cms.listContentTypes({});
43
+ expect(update).not.toHaveBeenCalled();
44
+ expect(JSON.stringify(second)).toBe(JSON.stringify(first));
45
+ });
46
+
47
+ it("ignores JSON whitespace and nested object key order", async () => {
48
+ const { adapter, stored } = await fixture();
49
+ const reordered = JSON.stringify(
50
+ JSON.parse(stored.jsonSchema),
51
+ (key, value) =>
52
+ key !== "properties" &&
53
+ value &&
54
+ typeof value === "object" &&
55
+ !Array.isArray(value)
56
+ ? Object.fromEntries(Object.entries(value).reverse())
57
+ : value,
58
+ 2,
59
+ );
60
+ await adapter.update({
61
+ model: "contentType",
62
+ where: [{ field: "id", value: stored.id }],
63
+ update: { jsonSchema: reordered },
64
+ });
65
+ const update = vi.spyOn(adapter, "update");
66
+ await coldStart(adapter).trusted.cms.listContentTypes({});
67
+ expect(update).not.toHaveBeenCalled();
68
+ });
69
+
70
+ it.each([
71
+ ["name", "Old article"],
72
+ ["description", "Old description"],
73
+ ["jsonSchema", '{"type":"object","properties":{}}'],
74
+ ["jsonSchema", "invalid legacy JSON"],
75
+ ["fieldConfig", '{"title":{"fieldType":"textarea"}}'],
76
+ ["autoFormVersion", 1],
77
+ ] as const)("updates changed %s exactly once", async (field, value) => {
78
+ const { adapter, stored } = await fixture();
79
+ await adapter.update({
80
+ model: "contentType",
81
+ where: [{ field: "id", value: stored.id }],
82
+ update: { [field]: value, updatedAt: new Date("2000-01-01") },
83
+ });
84
+ const update = vi.spyOn(adapter, "update");
85
+ await coldStart(adapter).trusted.cms.listContentTypes({});
86
+ expect(update).toHaveBeenCalledTimes(1);
87
+ expect(update.mock.calls[0]?.[0].update).toMatchObject({
88
+ name: "Article",
89
+ description: null,
90
+ jsonSchema: stored.jsonSchema,
91
+ fieldConfig: null,
92
+ autoFormVersion: 2,
93
+ });
94
+ expect(update.mock.calls[0]?.[0].update.updatedAt).not.toEqual(
95
+ new Date("2000-01-01"),
96
+ );
97
+ await coldStart(adapter).trusted.cms.listContentTypes({});
98
+ expect(update).toHaveBeenCalledTimes(1);
99
+ });
100
+
101
+ it("updates reordered schema properties because they control form field order", async () => {
102
+ const { adapter, stored } = await fixture();
103
+ const schema = JSON.parse(stored.jsonSchema);
104
+ schema.properties = Object.fromEntries(
105
+ Object.entries(schema.properties).reverse(),
106
+ );
107
+ await adapter.update({
108
+ model: "contentType",
109
+ where: [{ field: "id", value: stored.id }],
110
+ update: { jsonSchema: JSON.stringify(schema) },
111
+ });
112
+ const update = vi.spyOn(adapter, "update");
113
+ await coldStart(adapter).trusted.cms.listContentTypes({});
114
+ expect(update).toHaveBeenCalledTimes(1);
115
+ expect(update.mock.calls[0]?.[0].update.jsonSchema).toBe(stored.jsonSchema);
116
+ });
117
+
118
+ it("preserves meaningful array ordering in schema comparisons", async () => {
119
+ const { adapter, stored } = await fixture();
120
+ const schema = JSON.parse(stored.jsonSchema);
121
+ schema.properties.status.enum.reverse();
122
+ await adapter.update({
123
+ model: "contentType",
124
+ where: [{ field: "id", value: stored.id }],
125
+ update: { jsonSchema: JSON.stringify(schema) },
126
+ });
127
+ const update = vi.spyOn(adapter, "update");
128
+ await coldStart(adapter).trusted.cms.listContentTypes({});
129
+ expect(update).toHaveBeenCalledTimes(1);
130
+ });
131
+ });
@@ -63,6 +63,28 @@ interface CMSPrefetchForRoute {
63
63
  ): Promise<void>;
64
64
  }
65
65
 
66
+ /** Preserve form field order while normalizing JSON metadata key order. */
67
+ function schemasEqual(stored: string, configured: string): boolean {
68
+ const normalize = (key: string, value: unknown): unknown =>
69
+ key !== "properties" &&
70
+ value &&
71
+ typeof value === "object" &&
72
+ !Array.isArray(value)
73
+ ? Object.fromEntries(
74
+ Object.entries(value).sort(([a], [b]) => a.localeCompare(b)),
75
+ )
76
+ : value;
77
+ try {
78
+ return (
79
+ JSON.stringify(JSON.parse(stored), normalize) ===
80
+ JSON.stringify(JSON.parse(configured), normalize)
81
+ );
82
+ } catch {
83
+ // A malformed stored schema still needs repair from the code definition.
84
+ return false;
85
+ }
86
+ }
87
+
66
88
  async function syncContentTypes(
67
89
  adapter: Adapter,
68
90
  config: CMSBackendConfig,
@@ -76,6 +98,14 @@ async function syncContentTypes(
76
98
  ],
77
99
  });
78
100
  if (existing) {
101
+ if (
102
+ existing.name === definition.name &&
103
+ (existing.description ?? null) === (definition.description ?? null) &&
104
+ schemasEqual(existing.jsonSchema, jsonSchema) &&
105
+ existing.fieldConfig == null &&
106
+ existing.autoFormVersion === 2
107
+ )
108
+ continue;
79
109
  await adapter.update({
80
110
  model: "contentType",
81
111
  where: [{ field: "id", value: existing.id, operator: "eq" as const }],
@@ -3,7 +3,7 @@ import { createMemoryAdapter } from "@btst/adapter-memory";
3
3
  import { defineDb } from "@btst/db";
4
4
  import type { DBAdapter as Adapter } from "@btst/db";
5
5
  import { kanbanSchema } from "../db";
6
- import { getAllBoards, getBoardById } from "../api/getters";
6
+ import { getAllBoards, getBoardById, getBoardSummaries } from "../api/getters";
7
7
 
8
8
  const createTestAdapter = (): Adapter => {
9
9
  const db = defineDb({}).use(kanbanSchema);
@@ -90,6 +90,7 @@ describe("kanban getters", () => {
90
90
  expect(total).toBe(1);
91
91
  expect(boards[0]!.slug).toBe("my-board");
92
92
  expect(boards[0]!.columns).toHaveLength(1);
93
+ expect(boards[0]).not.toHaveProperty("kanbanColumn");
93
94
  expect(boards[0]!.columns[0]!.title).toBe("To Do");
94
95
  expect(boards[0]!.columns[0]!.tasks).toHaveLength(1);
95
96
  expect(boards[0]!.columns[0]!.tasks[0]!.title).toBe("Task 1");
@@ -139,6 +140,18 @@ describe("kanban getters", () => {
139
140
  });
140
141
  });
141
142
 
143
+ it("returns joined column summaries without leaking raw relations or tasks", async () => {
144
+ const board = await createBoard(adapter, "Board", "board");
145
+ const column = await createColumn(adapter, board.id, "To Do", 0);
146
+ await createTask(adapter, column.id, "Private task", 0);
147
+
148
+ const { items } = await getBoardSummaries(adapter);
149
+ expect(items[0]?.columns).toHaveLength(1);
150
+ expect(items[0]?.columns[0]?.title).toBe("To Do");
151
+ expect(items[0]).not.toHaveProperty("kanbanColumn");
152
+ expect(items[0]?.columns[0]).not.toHaveProperty("tasks");
153
+ });
154
+
142
155
  describe("getBoardById", () => {
143
156
  it("returns null when board does not exist", async () => {
144
157
  const board = await getBoardById(adapter, "nonexistent");
@@ -65,15 +65,15 @@ async function getBoardRows(
65
65
  }
66
66
 
67
67
  /**
68
- * Given a raw board record (with a `column` join), fetches tasks for every
68
+ * Given a raw board record (with a `kanbanColumn` join), fetches tasks for every
69
69
  * column in parallel and returns the sorted columns with their tasks attached.
70
- * Strips the raw `column` join field from the returned board object.
70
+ * Strips the raw `kanbanColumn` join field from the returned board object.
71
71
  */
72
72
  async function hydrateColumnsWithTasks(
73
73
  adapter: Adapter,
74
74
  board: BoardWithKanbanColumn,
75
75
  ): Promise<BoardWithColumns> {
76
- const columnIds = (board.column || []).map((c) => c.id);
76
+ const columnIds = (board.kanbanColumn || []).map((c) => c.id);
77
77
  const tasksByColumn = new Map<string, Task[]>();
78
78
 
79
79
  if (columnIds.length > 0) {
@@ -97,11 +97,11 @@ async function hydrateColumnsWithTasks(
97
97
  }
98
98
  }
99
99
 
100
- const columns: ColumnWithTasks[] = (board.column || [])
100
+ const columns: ColumnWithTasks[] = (board.kanbanColumn || [])
101
101
  .sort((a, b) => a.order - b.order)
102
102
  .map((col) => ({ ...col, tasks: tasksByColumn.get(col.id) || [] }));
103
103
 
104
- const { column: _, ...boardWithoutJoin } = board;
104
+ const { kanbanColumn: _, ...boardWithoutJoin } = board;
105
105
  return { ...boardWithoutJoin, columns };
106
106
  }
107
107
 
@@ -131,9 +131,11 @@ export async function getBoardSummaries(
131
131
  const result = await getBoardRows(adapter, params);
132
132
  return {
133
133
  ...result,
134
- items: result.items.map(({ column = [], ...board }) => ({
134
+ items: result.items.map(({ kanbanColumn = [], ...board }) => ({
135
135
  ...board,
136
- columns: [...column].sort((left, right) => left.order - right.order),
136
+ columns: [...kanbanColumn].sort(
137
+ (left, right) => left.order - right.order,
138
+ ),
137
139
  })),
138
140
  };
139
141
  }
@@ -85,20 +85,18 @@ export type BoardWithColumnsOnly = Board & {
85
85
 
86
86
  /**
87
87
  * Board with joined column relationships from the database
88
- * Note: The adapter returns joined data under the schema key name ("column"),
89
- * not the model name ("kanbanColumn")
88
+ * Better DB 3 returns joined data under the canonical model name.
90
89
  */
91
90
  export type BoardWithKanbanColumn = Board & {
92
- column?: Column[];
91
+ kanbanColumn?: Column[];
93
92
  };
94
93
 
95
94
  /**
96
95
  * Column with joined task relationships from the database
97
- * Note: The adapter returns joined data under the schema key name ("task"),
98
- * not the model name ("kanbanTask")
96
+ * Better DB 3 returns joined data under the canonical model name.
99
97
  */
100
98
  export type ColumnWithKanbanTask = Column & {
101
- task?: Task[];
99
+ kanbanTask?: Task[];
102
100
  };
103
101
 
104
102
  // Serialized types for API responses (dates as strings)
@@ -157,12 +157,10 @@ function withCurrentListRefresh<TData, TVariables>(
157
157
  mapResult: (data: TData) => TData = (data) => data,
158
158
  data: TData | undefined = mutation.data,
159
159
  ): UseMutationResult<TData, Error, TVariables> {
160
- const mutateAsync: typeof mutation.mutateAsync = async (
161
- variables,
162
- options,
163
- ) => {
160
+ const mutateAsync: typeof mutation.mutateAsync = async (...args) => {
161
+ const options = args[1];
164
162
  const startedAs = listRefresh.currentScope();
165
- const result = await mutation.mutateAsync(variables, {
163
+ args[1] = {
166
164
  ...options,
167
165
  onSuccess: options?.onSuccess
168
166
  ? (data, ...args) => options.onSuccess?.(mapResult(data), ...args)
@@ -174,14 +172,15 @@ function withCurrentListRefresh<TData, TVariables>(
174
172
  ...args,
175
173
  )
176
174
  : undefined,
177
- });
175
+ };
176
+ const result = await mutation.mutateAsync(...args);
178
177
  await listRefresh.refreshAfterSuccess(startedAs);
179
178
  return mapResult(result);
180
179
  };
181
- const mutate: typeof mutation.mutate = (variables, options) => {
180
+ const mutate: typeof mutation.mutate = (...args) => {
182
181
  // Each invocation owns an awaited promise, so a later mutation cannot
183
182
  // detach the successful call's required cache refresh.
184
- void mutateAsync(variables, options).catch(() => {});
183
+ void mutateAsync(...args).catch(() => {});
185
184
  };
186
185
  return {
187
186
  ...mutation,