appflare 0.2.47 → 0.2.48

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 (139) hide show
  1. package/Documentation.md +898 -898
  2. package/cli/commands/index.ts +247 -247
  3. package/cli/generate.ts +360 -360
  4. package/cli/index.ts +120 -120
  5. package/cli/load-config.ts +184 -184
  6. package/cli/schema-compiler.ts +1366 -1366
  7. package/cli/templates/auth/README.md +156 -156
  8. package/cli/templates/auth/config.ts +61 -61
  9. package/cli/templates/auth/route-config.ts +1 -1
  10. package/cli/templates/auth/route-handler.ts +1 -1
  11. package/cli/templates/auth/route-request-utils.ts +5 -5
  12. package/cli/templates/auth/route.config.ts +18 -18
  13. package/cli/templates/auth/route.handler.ts +18 -18
  14. package/cli/templates/auth/route.request-utils.ts +55 -55
  15. package/cli/templates/auth/route.ts +14 -14
  16. package/cli/templates/core/README.md +266 -266
  17. package/cli/templates/core/app-creation.ts +19 -19
  18. package/cli/templates/core/client/appflare.ts +112 -112
  19. package/cli/templates/core/client/handlers/index.ts +763 -763
  20. package/cli/templates/core/client/handlers.ts +1 -1
  21. package/cli/templates/core/client/index.ts +7 -7
  22. package/cli/templates/core/client/storage.ts +195 -195
  23. package/cli/templates/core/client/types.ts +187 -187
  24. package/cli/templates/core/client-modules/appflare.ts +1 -1
  25. package/cli/templates/core/client-modules/handlers.ts +1 -1
  26. package/cli/templates/core/client-modules/index.ts +1 -1
  27. package/cli/templates/core/client-modules/storage.ts +1 -1
  28. package/cli/templates/core/client-modules/types.ts +1 -1
  29. package/cli/templates/core/client.artifacts.ts +39 -39
  30. package/cli/templates/core/client.ts +4 -4
  31. package/cli/templates/core/drizzle.ts +15 -15
  32. package/cli/templates/core/export.ts +14 -14
  33. package/cli/templates/core/handlers.route.ts +24 -24
  34. package/cli/templates/core/handlers.ts +1 -1
  35. package/cli/templates/core/imports.ts +9 -9
  36. package/cli/templates/core/server.ts +38 -38
  37. package/cli/templates/core/types.ts +6 -6
  38. package/cli/templates/core/wrangler.ts +109 -109
  39. package/cli/templates/dashboard/builders/functions/index.ts +17 -17
  40. package/cli/templates/dashboard/builders/functions/render-page/header.ts +20 -20
  41. package/cli/templates/dashboard/builders/functions/render-page/index.ts +33 -33
  42. package/cli/templates/dashboard/builders/functions/render-page/request-panel.ts +271 -271
  43. package/cli/templates/dashboard/builders/functions/render-page/result-panel.ts +85 -85
  44. package/cli/templates/dashboard/builders/functions/render-page/scripts.ts +703 -703
  45. package/cli/templates/dashboard/builders/functions/tree-builder.ts +47 -47
  46. package/cli/templates/dashboard/builders/navigation.ts +155 -155
  47. package/cli/templates/dashboard/builders/storage/index.ts +13 -13
  48. package/cli/templates/dashboard/builders/storage/routes/create-directory-route.ts +29 -29
  49. package/cli/templates/dashboard/builders/storage/routes/delete-route.ts +18 -18
  50. package/cli/templates/dashboard/builders/storage/routes/download-route.ts +23 -23
  51. package/cli/templates/dashboard/builders/storage/routes/index.ts +22 -22
  52. package/cli/templates/dashboard/builders/storage/routes/list-route.ts +25 -25
  53. package/cli/templates/dashboard/builders/storage/routes/preview-route.ts +21 -21
  54. package/cli/templates/dashboard/builders/storage/routes/upload-route.ts +21 -21
  55. package/cli/templates/dashboard/builders/storage/runtime/helpers.ts +72 -72
  56. package/cli/templates/dashboard/builders/storage/runtime/storage-page.ts +130 -130
  57. package/cli/templates/dashboard/builders/table-routes/common/drawer-panel.ts +27 -27
  58. package/cli/templates/dashboard/builders/table-routes/common/pagination.ts +30 -30
  59. package/cli/templates/dashboard/builders/table-routes/common/search-bar.ts +23 -23
  60. package/cli/templates/dashboard/builders/table-routes/fragments.ts +217 -217
  61. package/cli/templates/dashboard/builders/table-routes/helpers.ts +45 -45
  62. package/cli/templates/dashboard/builders/table-routes/index.ts +8 -8
  63. package/cli/templates/dashboard/builders/table-routes/table/actions-cell.ts +71 -71
  64. package/cli/templates/dashboard/builders/table-routes/table/get-route.ts +291 -291
  65. package/cli/templates/dashboard/builders/table-routes/table/index.ts +80 -80
  66. package/cli/templates/dashboard/builders/table-routes/table/post-routes.ts +163 -163
  67. package/cli/templates/dashboard/builders/table-routes/table-route.ts +7 -7
  68. package/cli/templates/dashboard/builders/table-routes/users/get-route.ts +69 -69
  69. package/cli/templates/dashboard/builders/table-routes/users/html/modals.ts +57 -57
  70. package/cli/templates/dashboard/builders/table-routes/users/html/page.ts +27 -27
  71. package/cli/templates/dashboard/builders/table-routes/users/html/table.ts +128 -128
  72. package/cli/templates/dashboard/builders/table-routes/users/index.ts +32 -32
  73. package/cli/templates/dashboard/builders/table-routes/users/post-routes.ts +150 -150
  74. package/cli/templates/dashboard/builders/table-routes/users/redirect.ts +14 -14
  75. package/cli/templates/dashboard/builders/table-routes/users-route.ts +10 -10
  76. package/cli/templates/dashboard/components/dashboard-home.ts +23 -23
  77. package/cli/templates/dashboard/components/layout.ts +420 -420
  78. package/cli/templates/dashboard/components/login-page.ts +65 -65
  79. package/cli/templates/dashboard/index.ts +61 -61
  80. package/cli/templates/dashboard/types.ts +9 -9
  81. package/cli/templates/handlers/README.md +353 -353
  82. package/cli/templates/handlers/auth.ts +37 -37
  83. package/cli/templates/handlers/execution.ts +42 -42
  84. package/cli/templates/handlers/generators/context/context-creation.ts +101 -101
  85. package/cli/templates/handlers/generators/context/error-helpers.ts +11 -11
  86. package/cli/templates/handlers/generators/context/scheduler.ts +24 -24
  87. package/cli/templates/handlers/generators/context/storage-api.ts +82 -82
  88. package/cli/templates/handlers/generators/context/storage-helpers.ts +59 -59
  89. package/cli/templates/handlers/generators/context/types.ts +40 -40
  90. package/cli/templates/handlers/generators/context.ts +43 -43
  91. package/cli/templates/handlers/generators/execution.ts +15 -15
  92. package/cli/templates/handlers/generators/handlers.ts +14 -14
  93. package/cli/templates/handlers/generators/registration/modules/cron.ts +35 -35
  94. package/cli/templates/handlers/generators/registration/modules/realtime/auth.ts +75 -75
  95. package/cli/templates/handlers/generators/registration/modules/realtime/durable-object.ts +144 -144
  96. package/cli/templates/handlers/generators/registration/modules/realtime/index.ts +14 -14
  97. package/cli/templates/handlers/generators/registration/modules/realtime/publisher.ts +102 -102
  98. package/cli/templates/handlers/generators/registration/modules/realtime/routes.ts +164 -164
  99. package/cli/templates/handlers/generators/registration/modules/realtime/types.ts +30 -30
  100. package/cli/templates/handlers/generators/registration/modules/realtime/utils.ts +510 -510
  101. package/cli/templates/handlers/generators/registration/modules/scheduler.ts +65 -65
  102. package/cli/templates/handlers/generators/registration/modules/storage.ts +199 -199
  103. package/cli/templates/handlers/generators/registration/sections.ts +210 -210
  104. package/cli/templates/handlers/generators/types/context.ts +121 -121
  105. package/cli/templates/handlers/generators/types/core.ts +108 -106
  106. package/cli/templates/handlers/generators/types/operations.ts +135 -135
  107. package/cli/templates/handlers/generators/types/query-definitions/filter-and-where-types.ts +291 -291
  108. package/cli/templates/handlers/generators/types/query-definitions/query-api-types.ts +135 -135
  109. package/cli/templates/handlers/generators/types/query-definitions/query-helper-functions.ts +1382 -1382
  110. package/cli/templates/handlers/generators/types/query-definitions/schema-and-table-types.ts +278 -278
  111. package/cli/templates/handlers/generators/types/query-definitions.ts +13 -13
  112. package/cli/templates/handlers/generators/types/query-runtime/handled-error.ts +13 -13
  113. package/cli/templates/handlers/generators/types/query-runtime/runtime-aggregate-and-footer.ts +174 -174
  114. package/cli/templates/handlers/generators/types/query-runtime/runtime-read.ts +158 -157
  115. package/cli/templates/handlers/generators/types/query-runtime/runtime-setup.ts +45 -45
  116. package/cli/templates/handlers/generators/types/query-runtime/runtime-write.ts +958 -958
  117. package/cli/templates/handlers/generators/types/query-runtime.ts +15 -15
  118. package/cli/templates/handlers/index.ts +47 -47
  119. package/cli/templates/handlers/operations.ts +116 -116
  120. package/cli/templates/handlers/registration.ts +91 -91
  121. package/cli/templates/handlers/types.ts +17 -17
  122. package/cli/templates/handlers/utils.ts +48 -48
  123. package/cli/types.ts +110 -110
  124. package/cli/utils/handler-discovery.ts +501 -501
  125. package/cli/utils/json-utils.ts +24 -24
  126. package/cli/utils/path-utils.ts +19 -19
  127. package/cli/utils/schema-discovery.ts +399 -399
  128. package/dist/cli/index.js +6 -4
  129. package/dist/cli/index.mjs +6 -4
  130. package/index.ts +18 -18
  131. package/package.json +58 -58
  132. package/react/index.ts +5 -5
  133. package/react/use-infinite-query.ts +255 -255
  134. package/react/use-mutation.ts +89 -89
  135. package/react/use-query.ts +210 -210
  136. package/schema.ts +641 -641
  137. package/test-better-auth-hash.ts +2 -2
  138. package/tsconfig.json +6 -6
  139. package/tsup.config.ts +82 -82
package/Documentation.md CHANGED
@@ -1,898 +1,898 @@
1
- # Appflare Documentation
2
-
3
- This guide explains how to build backend handlers, run schema and database migrations, and consume your generated Appflare client in frontend apps (both plain TypeScript/JavaScript and React).
4
-
5
- All examples are aligned with the current workspace structure and APIs.
6
-
7
- ---
8
-
9
- ## 1) How Appflare works (high level)
10
-
11
- Appflare follows a generate-first workflow:
12
-
13
- 1. You write backend schema and handlers in your backend package.
14
- 2. You run the Appflare CLI.
15
- 3. Appflare generates runtime artifacts under your backend out directory (for example `_generated`).
16
- 4. Frontend imports the generated client and calls typed query/mutation routes.
17
-
18
- In this workspace, the backend uses:
19
-
20
- - `packages/backend/appflare.config.ts`
21
- - `packages/backend/schema.ts`
22
- - `packages/backend/src/**` for handlers
23
- - Generated output in `packages/backend/_generated/**`
24
-
25
- ---
26
-
27
- ## 2) Required backend files
28
-
29
- ### 2.1 Appflare config
30
-
31
- Your main config is `packages/backend/appflare.config.ts`.
32
-
33
- Important fields:
34
-
35
- - `scanDir`: where handlers are discovered (currently `./src`)
36
- - `outDir`: where generated artifacts are written (currently `./_generated`)
37
- - `schemaDsl.entry`: schema entry file (currently `./schema.ts`)
38
- - `schema`: schema files used by drizzle generation
39
- - `database`, `kv`, `r2`, `auth`, `scheduler`: runtime binding and feature configuration
40
- - `wranglerOverrides`: final Worker deployment settings
41
-
42
- ### 2.2 Schema
43
-
44
- Schema is defined with `schema`, `table`, and `v` from Appflare.
45
-
46
- Example source: `packages/backend/schema.ts`.
47
-
48
- ### 2.3 Relation helpers (`v.one`, `v.many`, `v.manyToMany`)
49
-
50
- - `v.one("target")` creates a single-reference relation and infers a local FK field.
51
- - `v.many("target")` is inverse one-to-many and infers an FK on the target table.
52
- - `v.manyToMany("target")` creates a many-to-many relation by synthesizing a junction table.
53
-
54
- Many-to-many example:
55
-
56
- ```ts
57
- export const schemas = schema({
58
- pets: table({
59
- id: v.uuid(),
60
- trips: v.manyToMany("trips"),
61
- }),
62
- trips: table({
63
- id: v.uuid(),
64
- pets: v.manyToMany("pets"),
65
- }),
66
- });
67
- ```
68
-
69
- Default behavior for `v.manyToMany`:
70
-
71
- - Generates one deterministic junction table per pair.
72
- - Junction rows are pure links (two FK columns, no payload columns).
73
- - Reciprocal declarations must agree on options (`junctionTable`, field names, FK actions), otherwise generation throws a conflict error.
74
-
75
- ---
76
-
77
- ## 3) How to create handlers
78
-
79
- Handlers are created with generated helpers:
80
-
81
- - `query(...)` for read endpoints
82
- - `mutation(...)` for write endpoints
83
-
84
- Import them from your generated handlers module:
85
-
86
- ```ts
87
- import { query, mutation } from "../_generated/handlers";
88
- ```
89
-
90
- ### 3.1 Query handler example
91
-
92
- ```ts
93
- import { query } from "../../_generated/handlers";
94
- import * as z from "zod";
95
-
96
- export const getUserProfile = query({
97
- args: {
98
- userId: z.string(),
99
- },
100
- handler: async (ctx, args) => {
101
- const user = await ctx.db.users.findFirst({
102
- where: { id: args.userId },
103
- });
104
-
105
- if (!user) {
106
- ctx.error(404, "User not found", { userId: args.userId });
107
- }
108
-
109
- return user;
110
- },
111
- });
112
- ```
113
-
114
- ### 3.1.1 More query examples (basic to advanced)
115
-
116
- #### A) Search + relation include + limit
117
-
118
- ```ts
119
- import { query } from "../../_generated/handlers";
120
- import * as z from "zod";
121
-
122
- export const searchPosts = query({
123
- args: {
124
- search: z.string().optional(),
125
- ownerId: z.string().optional(),
126
- limit: z.number().int().min(1).max(100).default(25),
127
- },
128
- handler: async (ctx, args) => {
129
- return ctx.db.posts.findMany({
130
- where: {
131
- title: {
132
- regex: args.search ?? "",
133
- $options: "i",
134
- },
135
- ...(args.ownerId ? { ownerId: args.ownerId } : {}),
136
- },
137
- with: {
138
- owner: true,
139
- comments: true,
140
- },
141
- limit: args.limit,
142
- });
143
- },
144
- });
145
- ```
146
-
147
- #### B) Cursor/pagination-style query
148
-
149
- ```ts
150
- import { query } from "../../_generated/handlers";
151
- import * as z from "zod";
152
-
153
- export const listPostsPage = query({
154
- args: {
155
- cursor: z.number().int().optional(),
156
- pageSize: z.number().int().min(1).max(50).default(20),
157
- },
158
- handler: async (ctx, args) => {
159
- const rows = await ctx.db.posts.findMany({
160
- where: args.cursor
161
- ? {
162
- id: {
163
- gt: args.cursor,
164
- },
165
- }
166
- : {},
167
- orderBy: { column: "id", direction: "asc" },
168
- limit: args.pageSize,
169
- with: {
170
- owner: true,
171
- },
172
- });
173
-
174
- const nextCursor = rows.length > 0 ? rows[rows.length - 1]?.id : undefined;
175
-
176
- return {
177
- rows,
178
- nextCursor,
179
- hasMore: rows.length === args.pageSize,
180
- };
181
- },
182
- });
183
- ```
184
-
185
- #### C) Aggregate-heavy query (`count`, `avg`, relation path)
186
-
187
- ```ts
188
- import { query } from "../../_generated/handlers";
189
- import * as z from "zod";
190
-
191
- export const getPostStats = query({
192
- args: {
193
- ownerId: z.string().optional(),
194
- },
195
- handler: async (ctx, args) => {
196
- const totalPosts = await ctx.db.posts.count({
197
- where: args.ownerId ? { ownerId: args.ownerId } : {},
198
- });
199
-
200
- const uniqueOwners = await ctx.db.posts.count({
201
- field: "ownerId",
202
- distinct: true,
203
- });
204
-
205
- const averagePostId = await ctx.db.posts.avg({
206
- field: "id",
207
- });
208
-
209
- const averageCommentId = await ctx.db.posts.avg({
210
- field: "comments.id",
211
- with: {
212
- comments: {
213
- where: {
214
- id: {
215
- gte: 10000,
216
- },
217
- },
218
- },
219
- },
220
- });
221
-
222
- return {
223
- totalPosts,
224
- uniqueOwners,
225
- averagePostId,
226
- averageCommentId,
227
- };
228
- },
229
- });
230
- ```
231
-
232
- #### D) Geo query (`geoWithin`) + filter composition
233
-
234
- ```ts
235
- import { query } from "../../_generated/handlers";
236
- import * as z from "zod";
237
-
238
- export const nearbyPlaygroundItems = query({
239
- args: {
240
- latitude: z.number(),
241
- longitude: z.number(),
242
- radiusMeters: z.number().positive().default(5000),
243
- },
244
- handler: async (ctx, args) => {
245
- const rows = await ctx.db.queryPlayground.findMany({
246
- where: {
247
- geoWithin: {
248
- $geometry: {
249
- latitude: args.latitude,
250
- longitude: args.longitude,
251
- },
252
- latitudeField: "latitude",
253
- longitudeField: "longitude",
254
- gte: 0,
255
- lt: args.radiusMeters,
256
- },
257
- isActive: {
258
- eq: true,
259
- },
260
- },
261
- limit: 100,
262
- });
263
-
264
- return {
265
- count: rows.length,
266
- rows,
267
- };
268
- },
269
- });
270
- ```
271
-
272
- #### F) Query with `orderBy`
273
-
274
- The `orderBy` field accepts an object or array of objects with `column` and optional `direction`:
275
-
276
- ```ts
277
- import { query } from "../../_generated/handlers";
278
- import * as z from "zod";
279
-
280
- export const getTopUsers = query({
281
- args: {
282
- minScore: z.number().optional(),
283
- limit: z.number().int().min(1).max(100).default(10),
284
- },
285
- handler: async (ctx, args) => {
286
- return ctx.db.users.findMany({
287
- where: args.minScore ? { score: { gte: args.minScore } } : {},
288
- orderBy: { column: "score", direction: "desc" },
289
- limit: args.limit,
290
- });
291
- },
292
- });
293
- ```
294
-
295
- Multiple sort keys are supported with an array:
296
-
297
- ```ts
298
- orderBy: [
299
- { column: "score", direction: "desc" },
300
- { column: "name", direction: "asc" },
301
- ],
302
- ```
303
-
304
- #### G) Array column operators (`includes`, `includesAny`, `length`)
305
-
306
- For JSON array columns, use array-specific operators:
307
-
308
- ```ts
309
- import { query } from "../../_generated/handlers";
310
- import * as z from "zod";
311
-
312
- export const findProducts = query({
313
- args: {
314
- color: z.string().optional(),
315
- tags: z.array(z.string()).optional(),
316
- minTagCount: z.number().int().optional(),
317
- },
318
- handler: async (ctx, args) => {
319
- return ctx.db.products.findMany({
320
- where: {
321
- ...(args.tags ? { tags: { includes: args.tags } } : {}),
322
- ...(args.minTagCount ? { tags: { length: args.minTagCount } } : {}),
323
- },
324
- });
325
- },
326
- });
327
- ```
328
-
329
- - `includes` — row's array must contain **all** specified values
330
- - `includesAny` — row's array must contain **at least one** of the specified values
331
- - `length` — matches the array length exactly
332
- - `eq` / `ne` — exact match on the whole JSON array
333
-
334
- #### H) Complex production-style query (similar to `db-features`)
335
-
336
- ```ts
337
- import { query } from "../../_generated/handlers";
338
- import * as z from "zod";
339
-
340
- export const queryDashboardData = query({
341
- args: {
342
- userId: z.string().optional(),
343
- search: z.string().optional(),
344
- },
345
- handler: async (ctx, args) => {
346
- const posts = await ctx.db.posts.findMany({
347
- where: {
348
- ownerId: args.userId,
349
- title: {
350
- regex: args.search ?? "test",
351
- $options: "i",
352
- },
353
- id: { gt: 0 },
354
- },
355
- with: {
356
- comments: true,
357
- owner: true,
358
- },
359
- limit: 25,
360
- });
361
-
362
- const postsWithCommentStats = await ctx.db.posts.findMany({
363
- with: {
364
- comments: {
365
- _count: true,
366
- _avg: { id: true },
367
- },
368
- },
369
- limit: 10,
370
- });
371
-
372
- const postsTotal = await ctx.db.posts.count({
373
- where: { id: { $gte: 1 } },
374
- });
375
-
376
- return {
377
- posts,
378
- postsTotal,
379
- postsWithCommentStats,
380
- };
381
- },
382
- });
383
- ```
384
-
385
- ### 3.1.2 Query design tips for complex handlers
386
-
387
- - Keep args schema strict (defaults, min/max, optional fields).
388
- - Return stable shapes (avoid switching response shape by condition).
389
- - Start with one root query and compose aggregates/relations progressively.
390
- - Prefer server-side filtering in `where` instead of filtering on frontend.
391
- - For heavy queries, add `limit`, cursor args, and response metadata (`nextCursor`, `hasMore`).
392
- - Use `orderBy` with cursor-based pagination to ensure deterministic ordering across pages.
393
-
394
- ### 3.2 Mutation handler examples
395
-
396
- #### Insert
397
-
398
- ```ts
399
- import { mutation } from "../../_generated/handlers";
400
- import * as z from "zod";
401
-
402
- export const createPost = mutation({
403
- args: {
404
- title: z.string().min(1),
405
- slug: z.string().min(1),
406
- },
407
- handler: async (ctx, args) => {
408
- const inserted = await ctx.db.posts.insert({
409
- values: {
410
- title: args.title,
411
- slug: args.slug,
412
- ownerId: "some-user-id",
413
- },
414
- });
415
-
416
- return { created: inserted.length };
417
- },
418
- });
419
- ```
420
-
421
- #### Upsert
422
-
423
- ```ts
424
- import { mutation } from "../../_generated/handlers";
425
- import * as z from "zod";
426
-
427
- export const upsertPost = mutation({
428
- args: {
429
- slug: z.string().min(1),
430
- title: z.string().min(1),
431
- },
432
- handler: async (ctx, args) => {
433
- const result = await ctx.db.posts.upsert({
434
- values: {
435
- slug: args.slug,
436
- title: args.title,
437
- ownerId: "some-user-id",
438
- },
439
- target: "slug",
440
- set: { title: args.title },
441
- });
442
-
443
- return { updated: result.length };
444
- },
445
- });
446
- ```
447
-
448
- - `target` — conflict column(s) to detect existing rows
449
- - `set` — columns to update on conflict (omit to keep existing values)
450
- - Supports single or array of values
451
-
452
- ### 3.3 Handler file placement
453
-
454
- Put handlers under `packages/backend/src` (including nested directories). Example patterns already used in this repo:
455
-
456
- - `packages/backend/src/test.ts`
457
- - `packages/backend/src/queries/db-features.ts`
458
- - `packages/backend/src/mutations/db-features.ts`
459
- - `packages/backend/src/bun/test.ts`
460
-
461
- Generated client route names follow directory + file + export naming. For example:
462
-
463
- - query from `src/test.ts` export `getTest` becomes `appflare.queries.test.getTest`
464
- - mutation from `src/mutations/db-features.ts` export `testMutationFeatures` becomes `appflare.mutations["db-features"].testMutationFeatures`
465
-
466
- ### 3.4 Context utilities available in handlers
467
-
468
- Inside handlers, you commonly use:
469
-
470
- - `ctx.db.<table>.findMany/findFirst/insert/update/upsert/delete`
471
- - aggregate helpers like `count` and `avg`
472
- - `count` supports `where`, `field`, `distinct`, and `with` for filtered relation counts
473
- - `avg` supports `where`, `field`, `distinct`, and `with` for filtered relation averages
474
- - `where` supports shorthand operators: `eq`, `ne`, `in`, `nin`, `gt`, `gte`, `lt`, `lte`, `exists`, `regex`, `$options`, `includes`, `includesAny`, `length`
475
- - `with` supports `_count` and `_avg` for relation-level aggregate results (returned as `XxxAggregate`)
476
- - `orderBy` accepts `{ column, direction }` or array thereof
477
- - `geoWithin` for geospatial distance queries (Haversine formula)
478
- - `upsert` supports `target` (conflict columns) and `set` (on-conflict update columns)
479
- - `ctx.error(status, message, details)` for typed failures
480
-
481
- #### Updating manyToMany relations
482
-
483
- The `update` operation supports managing manyToMany relations via the `set` payload. Each manyToMany relation field accepts an object with `items` and optional `mode`:
484
-
485
- ```ts
486
- // Merge mode (default) — adds new links, keeps existing ones
487
- await ctx.db.family.update({
488
- where: { primaryUserId: userId },
489
- set: {
490
- members: {
491
- items: [
492
- "existing-user-id", // link by ID
493
- { id: "another-user-id" }, // link by ID (object form)
494
- { name: "New User", email: "..." }, // create new user, then link
495
- ],
496
- mode: "merge",
497
- },
498
- },
499
- });
500
-
501
- // Overwrite mode — deletes all existing links, keeps only new ones
502
- await ctx.db.family.update({
503
- where: { primaryUserId: userId },
504
- set: {
505
- members: {
506
- items: ["user-id-1", "user-id-2"],
507
- mode: "overwrite",
508
- },
509
- },
510
- });
511
- ```
512
-
513
- - `items` — array of IDs (string/number) or partial objects. Objects without an `id` field are created in the target table before linking.
514
- - `mode` — `"merge"` (default) keeps existing links and adds new ones; `"overwrite"` deletes all existing links for the parent record before inserting new ones.
515
- - All relation updates run inside a transaction when present.
516
-
517
- See real examples in:
518
-
519
- - `packages/backend/src/queries/db-features.ts`
520
- - `packages/backend/src/mutations/db-features.ts`
521
-
522
- ---
523
-
524
- ## 4) Generate artifacts
525
-
526
- From backend package:
527
-
528
- ```bash
529
- cd packages/backend
530
- bun ../appflare/cli dev
531
- ```
532
-
533
- Or via scripts in `packages/backend/package.json`:
534
-
535
- ```bash
536
- bun run build
537
- ```
538
-
539
- What gets generated (core set):
540
-
541
- - `_generated/server.js`
542
- - `_generated/client.js`
543
- - `_generated/auth.config.js`
544
- - `_generated/drizzle.config.js`
545
- - `_generated/handlers.js`
546
- - `_generated/handlers.context.js`
547
- - `_generated/handlers.execution.js`
548
- - `_generated/handlers.routes.js`
549
- - `_generated/client/**`
550
-
551
- ### Watch mode
552
-
553
- To regenerate on file changes:
554
-
555
- ```bash
556
- cd packages/backend
557
- bun ../appflare/cli dev --watch
558
- ```
559
-
560
- ---
561
-
562
- ## 5) How to migrate database schema
563
-
564
- Appflare migration flow wraps two steps:
565
-
566
- 1. Generate drizzle migrations
567
- 2. Apply to D1 via Wrangler
568
-
569
- ### 5.1 Standard migrate
570
-
571
- ```bash
572
- cd packages/backend
573
- bun ../appflare/cli migrate
574
- ```
575
-
576
- Or script:
577
-
578
- ```bash
579
- bun run migrate
580
- ```
581
-
582
- ### 5.2 Choose target environment
583
-
584
- Use exactly one of these flags:
585
-
586
- - `--local`
587
- - `--remote`
588
- - `--preview`
589
-
590
- Examples:
591
-
592
- ```bash
593
- bun ../appflare/cli migrate --local
594
- bun ../appflare/cli migrate --remote
595
- bun ../appflare/cli migrate --preview
596
- ```
597
-
598
- ### 5.3 Typical change workflow
599
-
600
- 1. Update `schema.ts`.
601
- 2. Regenerate artifacts:
602
- - `bun ../appflare/cli dev`
603
- 3. Run migration:
604
- - `bun ../appflare/cli migrate --local` (or remote/preview)
605
- 4. Verify app behavior in `wrangler dev`.
606
-
607
- ---
608
-
609
- ## 6) Frontend usage (plain TypeScript/JavaScript)
610
-
611
- Use the generated backend client directly.
612
-
613
- ### 6.1 Create client instance
614
-
615
- ```ts
616
- import { Appflare } from "appflare-backend/_generated/client";
617
-
618
- const appflare = new Appflare({
619
- endpoint: "http://127.0.0.1:8787",
620
- wsEndpoint: "ws://127.0.0.1:8787",
621
- onGetAuthToken: async () => localStorage.getItem("appflare-auth-token") ?? "",
622
- onSetAuthToken: async (token) => {
623
- localStorage.setItem("appflare-auth-token", token);
624
- },
625
- });
626
- ```
627
-
628
- ### 6.2 Run a query
629
-
630
- ```ts
631
- const result = await appflare.queries.test.getTest.run({ id: "test" });
632
-
633
- if (result.error) {
634
- console.error(result.error.status, result.error.message);
635
- } else {
636
- console.log(result.data);
637
- }
638
- ```
639
-
640
- ### 6.2.1 More frontend query call examples
641
-
642
- #### A) Query with filters
643
-
644
- ```ts
645
- const result = await appflare.queries["db-features"].testQueryFeatures.run({
646
- search: "test",
647
- userId: "as3xNgfPVzrooSuSwn1ZSEKNA92Cjp4V",
648
- });
649
-
650
- if (!result.error) {
651
- console.log(result.data.postsCount, result.data.uniqueOwnerCount);
652
- }
653
- ```
654
-
655
- #### B) Query with request options
656
-
657
- ```ts
658
- const result = await appflare.queries.test.getTest.run(
659
- { id: "test" },
660
- {
661
- headers: {
662
- "x-trace-id": crypto.randomUUID(),
663
- },
664
- },
665
- );
666
- ```
667
-
668
- #### C) Query with realtime and explicit auth token
669
-
670
- ```ts
671
- const sub = appflare.queries.test.getTest.subscribe({
672
- args: { id: "test" },
673
- authToken: "token-from-auth-flow",
674
- onChange: (data) => {
675
- console.log("fresh data", data);
676
- },
677
- });
678
-
679
- setTimeout(() => sub.remove(), 30000);
680
- ```
681
-
682
- ### 6.3 Run a mutation
683
-
684
- ```ts
685
- const result = await appflare.mutations.test.newTest.run({});
686
-
687
- if (result.error) {
688
- console.error(result.error.message);
689
- } else {
690
- console.log(result.data);
691
- }
692
- ```
693
-
694
- ### 6.4 Realtime subscribe to a query
695
-
696
- ```ts
697
- const sub = appflare.queries.test.getTest.subscribe({
698
- args: { id: "test" },
699
- onChange: (data, event) => {
700
- console.log("update", event.payload.queryName, data);
701
- },
702
- onError: (error) => {
703
- console.error("subscription error", error);
704
- },
705
- });
706
-
707
- // later
708
- sub.remove();
709
- ```
710
-
711
- ---
712
-
713
- ## 7) How to use with React
714
-
715
- Appflare ships React hooks in `appflare/react`:
716
-
717
- - `useQuery`
718
- - `useInfiniteQuery`
719
- - `useMutation`
720
-
721
- These are thin wrappers around TanStack Query.
722
-
723
- ### 7.1 Setup requirements
724
-
725
- Install peer requirements in your frontend app:
726
-
727
- ```bash
728
- bun add @tanstack/react-query react
729
- ```
730
-
731
- Wrap app with `QueryClientProvider`.
732
-
733
- ```tsx
734
- import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
735
-
736
- const queryClient = new QueryClient();
737
-
738
- export function Providers({ children }: { children: React.ReactNode }) {
739
- return (
740
- <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
741
- );
742
- }
743
- ```
744
-
745
- ### 7.2 React query usage
746
-
747
- ```tsx
748
- import { useQuery } from "appflare/react";
749
- import { appflare } from "./appflare-client";
750
-
751
- export function TestScreen() {
752
- const query = useQuery(
753
- appflare.queries.test.getTest,
754
- { id: "test" },
755
- {
756
- realtime: { enabled: true },
757
- },
758
- );
759
-
760
- if (query.isLoading) return <div>Loading...</div>;
761
- if (query.error) return <div>{query.error.message}</div>;
762
-
763
- return <pre>{JSON.stringify(query.data, null, 2)}</pre>;
764
- }
765
- ```
766
-
767
- ### 7.3 React mutation usage
768
-
769
- ```tsx
770
- import { useMutation } from "appflare/react";
771
- import { appflare } from "./appflare-client";
772
-
773
- export function CreatePostButton() {
774
- const mutation = useMutation(appflare.mutations.test.newTest, {
775
- onSuccess: (data) => console.log("created", data),
776
- });
777
-
778
- return (
779
- <button onClick={() => mutation.mutate()} disabled={mutation.isPending}>
780
- Create
781
- </button>
782
- );
783
- }
784
- ```
785
-
786
- ### 7.4 React infinite query usage
787
-
788
- ```tsx
789
- import { useInfiniteQuery } from "appflare/react";
790
- import { appflare } from "./appflare-client";
791
-
792
- const result = useInfiniteQuery(
793
- appflare.queries.test.getTest,
794
- { id: "test" },
795
- {
796
- pageParamToArgs: (baseArgs, page) => ({ ...baseArgs, page }),
797
- queryOptions: {
798
- initialPageParam: 1,
799
- getNextPageParam: (lastPage, pages) => pages.length + 1,
800
- },
801
- },
802
- );
803
- ```
804
-
805
- ### 7.5 Realtime with React hooks
806
-
807
- Both `useQuery` and `useInfiniteQuery` support:
808
-
809
- ```ts
810
- realtime: {
811
- enabled: true,
812
- authToken: "optional-token",
813
- requestOptions: { headers: { "x-custom": "1" } },
814
- onChange: (data, update) => {},
815
- onError: (error) => {},
816
- }
817
- ```
818
-
819
- When enabled, hooks subscribe via generated query `.subscribe(...)` and keep query cache updated automatically.
820
-
821
- ---
822
-
823
- ## 8) Frontend app helper pattern
824
-
825
- A good pattern is to keep one shared client factory in a single file.
826
-
827
- Example in this workspace:
828
-
829
- - `apps/app/lib/appflare.ts`
830
-
831
- This file centralizes:
832
-
833
- - endpoint/wsEndpoint selection (web/mobile)
834
- - token storage and retrieval
835
- - exported hook wrappers
836
-
837
- ---
838
-
839
- ## 9) Common commands cheat sheet
840
-
841
- From `packages/backend`:
842
-
843
- ```bash
844
- # Generate once
845
- bun ../appflare/cli build -c appflare.config.ts
846
-
847
- # Generate in dev mode
848
- bun ../appflare/cli dev -c appflare.config.ts
849
-
850
- # Generate + watch
851
- bun ../appflare/cli dev -c appflare.config.ts --watch
852
-
853
- # Migrate local D1
854
- bun ../appflare/cli migrate -c appflare.config.ts --local
855
-
856
- # Migrate remote D1
857
- bun ../appflare/cli migrate -c appflare.config.ts --remote
858
- ```
859
-
860
- Or use backend scripts:
861
-
862
- ```bash
863
- bun run build
864
- bun run dev
865
- bun run migrate
866
- ```
867
-
868
- ---
869
-
870
- ## 10) Troubleshooting
871
-
872
- ### Generated client has missing routes
873
-
874
- - Ensure handler file is under `scanDir`.
875
- - Ensure export uses `query(...)` or `mutation(...)`.
876
- - Run `bun ../appflare/cli dev` again.
877
-
878
- ### Realtime not receiving updates
879
-
880
- - Ensure query has `.subscribe` in generated client.
881
- - Ensure `wsEndpoint` is set correctly.
882
- - Ensure valid auth token is available if your runtime requires auth.
883
-
884
- ### Migration fails
885
-
886
- - Confirm database values in `appflare.config.ts`.
887
- - Use one environment flag only (`--local`, `--remote`, or `--preview`).
888
- - Re-run generation before migration if schema changed.
889
-
890
- ---
891
-
892
- ## 11) Recommended development loop
893
-
894
- 1. Edit schema and handlers.
895
- 2. Run generator (`dev` or `dev --watch`).
896
- 3. Run migrations.
897
- 4. Start backend (`wrangler dev`).
898
- 5. Use generated client in frontend and iterate.
1
+ # Appflare Documentation
2
+
3
+ This guide explains how to build backend handlers, run schema and database migrations, and consume your generated Appflare client in frontend apps (both plain TypeScript/JavaScript and React).
4
+
5
+ All examples are aligned with the current workspace structure and APIs.
6
+
7
+ ---
8
+
9
+ ## 1) How Appflare works (high level)
10
+
11
+ Appflare follows a generate-first workflow:
12
+
13
+ 1. You write backend schema and handlers in your backend package.
14
+ 2. You run the Appflare CLI.
15
+ 3. Appflare generates runtime artifacts under your backend out directory (for example `_generated`).
16
+ 4. Frontend imports the generated client and calls typed query/mutation routes.
17
+
18
+ In this workspace, the backend uses:
19
+
20
+ - `packages/backend/appflare.config.ts`
21
+ - `packages/backend/schema.ts`
22
+ - `packages/backend/src/**` for handlers
23
+ - Generated output in `packages/backend/_generated/**`
24
+
25
+ ---
26
+
27
+ ## 2) Required backend files
28
+
29
+ ### 2.1 Appflare config
30
+
31
+ Your main config is `packages/backend/appflare.config.ts`.
32
+
33
+ Important fields:
34
+
35
+ - `scanDir`: where handlers are discovered (currently `./src`)
36
+ - `outDir`: where generated artifacts are written (currently `./_generated`)
37
+ - `schemaDsl.entry`: schema entry file (currently `./schema.ts`)
38
+ - `schema`: schema files used by drizzle generation
39
+ - `database`, `kv`, `r2`, `auth`, `scheduler`: runtime binding and feature configuration
40
+ - `wranglerOverrides`: final Worker deployment settings
41
+
42
+ ### 2.2 Schema
43
+
44
+ Schema is defined with `schema`, `table`, and `v` from Appflare.
45
+
46
+ Example source: `packages/backend/schema.ts`.
47
+
48
+ ### 2.3 Relation helpers (`v.one`, `v.many`, `v.manyToMany`)
49
+
50
+ - `v.one("target")` creates a single-reference relation and infers a local FK field.
51
+ - `v.many("target")` is inverse one-to-many and infers an FK on the target table.
52
+ - `v.manyToMany("target")` creates a many-to-many relation by synthesizing a junction table.
53
+
54
+ Many-to-many example:
55
+
56
+ ```ts
57
+ export const schemas = schema({
58
+ pets: table({
59
+ id: v.uuid(),
60
+ trips: v.manyToMany("trips"),
61
+ }),
62
+ trips: table({
63
+ id: v.uuid(),
64
+ pets: v.manyToMany("pets"),
65
+ }),
66
+ });
67
+ ```
68
+
69
+ Default behavior for `v.manyToMany`:
70
+
71
+ - Generates one deterministic junction table per pair.
72
+ - Junction rows are pure links (two FK columns, no payload columns).
73
+ - Reciprocal declarations must agree on options (`junctionTable`, field names, FK actions), otherwise generation throws a conflict error.
74
+
75
+ ---
76
+
77
+ ## 3) How to create handlers
78
+
79
+ Handlers are created with generated helpers:
80
+
81
+ - `query(...)` for read endpoints
82
+ - `mutation(...)` for write endpoints
83
+
84
+ Import them from your generated handlers module:
85
+
86
+ ```ts
87
+ import { query, mutation } from "../_generated/handlers";
88
+ ```
89
+
90
+ ### 3.1 Query handler example
91
+
92
+ ```ts
93
+ import { query } from "../../_generated/handlers";
94
+ import * as z from "zod";
95
+
96
+ export const getUserProfile = query({
97
+ args: {
98
+ userId: z.string(),
99
+ },
100
+ handler: async (ctx, args) => {
101
+ const user = await ctx.db.users.findFirst({
102
+ where: { id: args.userId },
103
+ });
104
+
105
+ if (!user) {
106
+ ctx.error(404, "User not found", { userId: args.userId });
107
+ }
108
+
109
+ return user;
110
+ },
111
+ });
112
+ ```
113
+
114
+ ### 3.1.1 More query examples (basic to advanced)
115
+
116
+ #### A) Search + relation include + limit
117
+
118
+ ```ts
119
+ import { query } from "../../_generated/handlers";
120
+ import * as z from "zod";
121
+
122
+ export const searchPosts = query({
123
+ args: {
124
+ search: z.string().optional(),
125
+ ownerId: z.string().optional(),
126
+ limit: z.number().int().min(1).max(100).default(25),
127
+ },
128
+ handler: async (ctx, args) => {
129
+ return ctx.db.posts.findMany({
130
+ where: {
131
+ title: {
132
+ regex: args.search ?? "",
133
+ $options: "i",
134
+ },
135
+ ...(args.ownerId ? { ownerId: args.ownerId } : {}),
136
+ },
137
+ with: {
138
+ owner: true,
139
+ comments: true,
140
+ },
141
+ limit: args.limit,
142
+ });
143
+ },
144
+ });
145
+ ```
146
+
147
+ #### B) Cursor/pagination-style query
148
+
149
+ ```ts
150
+ import { query } from "../../_generated/handlers";
151
+ import * as z from "zod";
152
+
153
+ export const listPostsPage = query({
154
+ args: {
155
+ cursor: z.number().int().optional(),
156
+ pageSize: z.number().int().min(1).max(50).default(20),
157
+ },
158
+ handler: async (ctx, args) => {
159
+ const rows = await ctx.db.posts.findMany({
160
+ where: args.cursor
161
+ ? {
162
+ id: {
163
+ gt: args.cursor,
164
+ },
165
+ }
166
+ : {},
167
+ orderBy: { column: "id", direction: "asc" },
168
+ limit: args.pageSize,
169
+ with: {
170
+ owner: true,
171
+ },
172
+ });
173
+
174
+ const nextCursor = rows.length > 0 ? rows[rows.length - 1]?.id : undefined;
175
+
176
+ return {
177
+ rows,
178
+ nextCursor,
179
+ hasMore: rows.length === args.pageSize,
180
+ };
181
+ },
182
+ });
183
+ ```
184
+
185
+ #### C) Aggregate-heavy query (`count`, `avg`, relation path)
186
+
187
+ ```ts
188
+ import { query } from "../../_generated/handlers";
189
+ import * as z from "zod";
190
+
191
+ export const getPostStats = query({
192
+ args: {
193
+ ownerId: z.string().optional(),
194
+ },
195
+ handler: async (ctx, args) => {
196
+ const totalPosts = await ctx.db.posts.count({
197
+ where: args.ownerId ? { ownerId: args.ownerId } : {},
198
+ });
199
+
200
+ const uniqueOwners = await ctx.db.posts.count({
201
+ field: "ownerId",
202
+ distinct: true,
203
+ });
204
+
205
+ const averagePostId = await ctx.db.posts.avg({
206
+ field: "id",
207
+ });
208
+
209
+ const averageCommentId = await ctx.db.posts.avg({
210
+ field: "comments.id",
211
+ with: {
212
+ comments: {
213
+ where: {
214
+ id: {
215
+ gte: 10000,
216
+ },
217
+ },
218
+ },
219
+ },
220
+ });
221
+
222
+ return {
223
+ totalPosts,
224
+ uniqueOwners,
225
+ averagePostId,
226
+ averageCommentId,
227
+ };
228
+ },
229
+ });
230
+ ```
231
+
232
+ #### D) Geo query (`geoWithin`) + filter composition
233
+
234
+ ```ts
235
+ import { query } from "../../_generated/handlers";
236
+ import * as z from "zod";
237
+
238
+ export const nearbyPlaygroundItems = query({
239
+ args: {
240
+ latitude: z.number(),
241
+ longitude: z.number(),
242
+ radiusMeters: z.number().positive().default(5000),
243
+ },
244
+ handler: async (ctx, args) => {
245
+ const rows = await ctx.db.queryPlayground.findMany({
246
+ where: {
247
+ geoWithin: {
248
+ $geometry: {
249
+ latitude: args.latitude,
250
+ longitude: args.longitude,
251
+ },
252
+ latitudeField: "latitude",
253
+ longitudeField: "longitude",
254
+ gte: 0,
255
+ lt: args.radiusMeters,
256
+ },
257
+ isActive: {
258
+ eq: true,
259
+ },
260
+ },
261
+ limit: 100,
262
+ });
263
+
264
+ return {
265
+ count: rows.length,
266
+ rows,
267
+ };
268
+ },
269
+ });
270
+ ```
271
+
272
+ #### F) Query with `orderBy`
273
+
274
+ The `orderBy` field accepts an object or array of objects with `column` and optional `direction`:
275
+
276
+ ```ts
277
+ import { query } from "../../_generated/handlers";
278
+ import * as z from "zod";
279
+
280
+ export const getTopUsers = query({
281
+ args: {
282
+ minScore: z.number().optional(),
283
+ limit: z.number().int().min(1).max(100).default(10),
284
+ },
285
+ handler: async (ctx, args) => {
286
+ return ctx.db.users.findMany({
287
+ where: args.minScore ? { score: { gte: args.minScore } } : {},
288
+ orderBy: { column: "score", direction: "desc" },
289
+ limit: args.limit,
290
+ });
291
+ },
292
+ });
293
+ ```
294
+
295
+ Multiple sort keys are supported with an array:
296
+
297
+ ```ts
298
+ orderBy: [
299
+ { column: "score", direction: "desc" },
300
+ { column: "name", direction: "asc" },
301
+ ],
302
+ ```
303
+
304
+ #### G) Array column operators (`includes`, `includesAny`, `length`)
305
+
306
+ For JSON array columns, use array-specific operators:
307
+
308
+ ```ts
309
+ import { query } from "../../_generated/handlers";
310
+ import * as z from "zod";
311
+
312
+ export const findProducts = query({
313
+ args: {
314
+ color: z.string().optional(),
315
+ tags: z.array(z.string()).optional(),
316
+ minTagCount: z.number().int().optional(),
317
+ },
318
+ handler: async (ctx, args) => {
319
+ return ctx.db.products.findMany({
320
+ where: {
321
+ ...(args.tags ? { tags: { includes: args.tags } } : {}),
322
+ ...(args.minTagCount ? { tags: { length: args.minTagCount } } : {}),
323
+ },
324
+ });
325
+ },
326
+ });
327
+ ```
328
+
329
+ - `includes` — row's array must contain **all** specified values
330
+ - `includesAny` — row's array must contain **at least one** of the specified values
331
+ - `length` — matches the array length exactly
332
+ - `eq` / `ne` — exact match on the whole JSON array
333
+
334
+ #### H) Complex production-style query (similar to `db-features`)
335
+
336
+ ```ts
337
+ import { query } from "../../_generated/handlers";
338
+ import * as z from "zod";
339
+
340
+ export const queryDashboardData = query({
341
+ args: {
342
+ userId: z.string().optional(),
343
+ search: z.string().optional(),
344
+ },
345
+ handler: async (ctx, args) => {
346
+ const posts = await ctx.db.posts.findMany({
347
+ where: {
348
+ ownerId: args.userId,
349
+ title: {
350
+ regex: args.search ?? "test",
351
+ $options: "i",
352
+ },
353
+ id: { gt: 0 },
354
+ },
355
+ with: {
356
+ comments: true,
357
+ owner: true,
358
+ },
359
+ limit: 25,
360
+ });
361
+
362
+ const postsWithCommentStats = await ctx.db.posts.findMany({
363
+ with: {
364
+ comments: {
365
+ _count: true,
366
+ _avg: { id: true },
367
+ },
368
+ },
369
+ limit: 10,
370
+ });
371
+
372
+ const postsTotal = await ctx.db.posts.count({
373
+ where: { id: { $gte: 1 } },
374
+ });
375
+
376
+ return {
377
+ posts,
378
+ postsTotal,
379
+ postsWithCommentStats,
380
+ };
381
+ },
382
+ });
383
+ ```
384
+
385
+ ### 3.1.2 Query design tips for complex handlers
386
+
387
+ - Keep args schema strict (defaults, min/max, optional fields).
388
+ - Return stable shapes (avoid switching response shape by condition).
389
+ - Start with one root query and compose aggregates/relations progressively.
390
+ - Prefer server-side filtering in `where` instead of filtering on frontend.
391
+ - For heavy queries, add `limit`, cursor args, and response metadata (`nextCursor`, `hasMore`).
392
+ - Use `orderBy` with cursor-based pagination to ensure deterministic ordering across pages.
393
+
394
+ ### 3.2 Mutation handler examples
395
+
396
+ #### Insert
397
+
398
+ ```ts
399
+ import { mutation } from "../../_generated/handlers";
400
+ import * as z from "zod";
401
+
402
+ export const createPost = mutation({
403
+ args: {
404
+ title: z.string().min(1),
405
+ slug: z.string().min(1),
406
+ },
407
+ handler: async (ctx, args) => {
408
+ const inserted = await ctx.db.posts.insert({
409
+ values: {
410
+ title: args.title,
411
+ slug: args.slug,
412
+ ownerId: "some-user-id",
413
+ },
414
+ });
415
+
416
+ return { created: inserted.length };
417
+ },
418
+ });
419
+ ```
420
+
421
+ #### Upsert
422
+
423
+ ```ts
424
+ import { mutation } from "../../_generated/handlers";
425
+ import * as z from "zod";
426
+
427
+ export const upsertPost = mutation({
428
+ args: {
429
+ slug: z.string().min(1),
430
+ title: z.string().min(1),
431
+ },
432
+ handler: async (ctx, args) => {
433
+ const result = await ctx.db.posts.upsert({
434
+ values: {
435
+ slug: args.slug,
436
+ title: args.title,
437
+ ownerId: "some-user-id",
438
+ },
439
+ target: "slug",
440
+ set: { title: args.title },
441
+ });
442
+
443
+ return { updated: result.length };
444
+ },
445
+ });
446
+ ```
447
+
448
+ - `target` — conflict column(s) to detect existing rows
449
+ - `set` — columns to update on conflict (omit to keep existing values)
450
+ - Supports single or array of values
451
+
452
+ ### 3.3 Handler file placement
453
+
454
+ Put handlers under `packages/backend/src` (including nested directories). Example patterns already used in this repo:
455
+
456
+ - `packages/backend/src/test.ts`
457
+ - `packages/backend/src/queries/db-features.ts`
458
+ - `packages/backend/src/mutations/db-features.ts`
459
+ - `packages/backend/src/bun/test.ts`
460
+
461
+ Generated client route names follow directory + file + export naming. For example:
462
+
463
+ - query from `src/test.ts` export `getTest` becomes `appflare.queries.test.getTest`
464
+ - mutation from `src/mutations/db-features.ts` export `testMutationFeatures` becomes `appflare.mutations["db-features"].testMutationFeatures`
465
+
466
+ ### 3.4 Context utilities available in handlers
467
+
468
+ Inside handlers, you commonly use:
469
+
470
+ - `ctx.db.<table>.findMany/findFirst/insert/update/upsert/delete`
471
+ - aggregate helpers like `count` and `avg`
472
+ - `count` supports `where`, `field`, `distinct`, and `with` for filtered relation counts
473
+ - `avg` supports `where`, `field`, `distinct`, and `with` for filtered relation averages
474
+ - `where` supports shorthand operators: `eq`, `ne`, `in`, `nin`, `gt`, `gte`, `lt`, `lte`, `exists`, `regex`, `$options`, `includes`, `includesAny`, `length`
475
+ - `with` supports `_count` and `_avg` for relation-level aggregate results (returned as `XxxAggregate`)
476
+ - `orderBy` accepts `{ column, direction }` or array thereof
477
+ - `geoWithin` for geospatial distance queries (Haversine formula)
478
+ - `upsert` supports `target` (conflict columns) and `set` (on-conflict update columns)
479
+ - `ctx.error(status, message, details)` for typed failures
480
+
481
+ #### Updating manyToMany relations
482
+
483
+ The `update` operation supports managing manyToMany relations via the `set` payload. Each manyToMany relation field accepts an object with `items` and optional `mode`:
484
+
485
+ ```ts
486
+ // Merge mode (default) — adds new links, keeps existing ones
487
+ await ctx.db.family.update({
488
+ where: { primaryUserId: userId },
489
+ set: {
490
+ members: {
491
+ items: [
492
+ "existing-user-id", // link by ID
493
+ { id: "another-user-id" }, // link by ID (object form)
494
+ { name: "New User", email: "..." }, // create new user, then link
495
+ ],
496
+ mode: "merge",
497
+ },
498
+ },
499
+ });
500
+
501
+ // Overwrite mode — deletes all existing links, keeps only new ones
502
+ await ctx.db.family.update({
503
+ where: { primaryUserId: userId },
504
+ set: {
505
+ members: {
506
+ items: ["user-id-1", "user-id-2"],
507
+ mode: "overwrite",
508
+ },
509
+ },
510
+ });
511
+ ```
512
+
513
+ - `items` — array of IDs (string/number) or partial objects. Objects without an `id` field are created in the target table before linking.
514
+ - `mode` — `"merge"` (default) keeps existing links and adds new ones; `"overwrite"` deletes all existing links for the parent record before inserting new ones.
515
+ - All relation updates run inside a transaction when present.
516
+
517
+ See real examples in:
518
+
519
+ - `packages/backend/src/queries/db-features.ts`
520
+ - `packages/backend/src/mutations/db-features.ts`
521
+
522
+ ---
523
+
524
+ ## 4) Generate artifacts
525
+
526
+ From backend package:
527
+
528
+ ```bash
529
+ cd packages/backend
530
+ bun ../appflare/cli dev
531
+ ```
532
+
533
+ Or via scripts in `packages/backend/package.json`:
534
+
535
+ ```bash
536
+ bun run build
537
+ ```
538
+
539
+ What gets generated (core set):
540
+
541
+ - `_generated/server.js`
542
+ - `_generated/client.js`
543
+ - `_generated/auth.config.js`
544
+ - `_generated/drizzle.config.js`
545
+ - `_generated/handlers.js`
546
+ - `_generated/handlers.context.js`
547
+ - `_generated/handlers.execution.js`
548
+ - `_generated/handlers.routes.js`
549
+ - `_generated/client/**`
550
+
551
+ ### Watch mode
552
+
553
+ To regenerate on file changes:
554
+
555
+ ```bash
556
+ cd packages/backend
557
+ bun ../appflare/cli dev --watch
558
+ ```
559
+
560
+ ---
561
+
562
+ ## 5) How to migrate database schema
563
+
564
+ Appflare migration flow wraps two steps:
565
+
566
+ 1. Generate drizzle migrations
567
+ 2. Apply to D1 via Wrangler
568
+
569
+ ### 5.1 Standard migrate
570
+
571
+ ```bash
572
+ cd packages/backend
573
+ bun ../appflare/cli migrate
574
+ ```
575
+
576
+ Or script:
577
+
578
+ ```bash
579
+ bun run migrate
580
+ ```
581
+
582
+ ### 5.2 Choose target environment
583
+
584
+ Use exactly one of these flags:
585
+
586
+ - `--local`
587
+ - `--remote`
588
+ - `--preview`
589
+
590
+ Examples:
591
+
592
+ ```bash
593
+ bun ../appflare/cli migrate --local
594
+ bun ../appflare/cli migrate --remote
595
+ bun ../appflare/cli migrate --preview
596
+ ```
597
+
598
+ ### 5.3 Typical change workflow
599
+
600
+ 1. Update `schema.ts`.
601
+ 2. Regenerate artifacts:
602
+ - `bun ../appflare/cli dev`
603
+ 3. Run migration:
604
+ - `bun ../appflare/cli migrate --local` (or remote/preview)
605
+ 4. Verify app behavior in `wrangler dev`.
606
+
607
+ ---
608
+
609
+ ## 6) Frontend usage (plain TypeScript/JavaScript)
610
+
611
+ Use the generated backend client directly.
612
+
613
+ ### 6.1 Create client instance
614
+
615
+ ```ts
616
+ import { Appflare } from "appflare-backend/_generated/client";
617
+
618
+ const appflare = new Appflare({
619
+ endpoint: "http://127.0.0.1:8787",
620
+ wsEndpoint: "ws://127.0.0.1:8787",
621
+ onGetAuthToken: async () => localStorage.getItem("appflare-auth-token") ?? "",
622
+ onSetAuthToken: async (token) => {
623
+ localStorage.setItem("appflare-auth-token", token);
624
+ },
625
+ });
626
+ ```
627
+
628
+ ### 6.2 Run a query
629
+
630
+ ```ts
631
+ const result = await appflare.queries.test.getTest.run({ id: "test" });
632
+
633
+ if (result.error) {
634
+ console.error(result.error.status, result.error.message);
635
+ } else {
636
+ console.log(result.data);
637
+ }
638
+ ```
639
+
640
+ ### 6.2.1 More frontend query call examples
641
+
642
+ #### A) Query with filters
643
+
644
+ ```ts
645
+ const result = await appflare.queries["db-features"].testQueryFeatures.run({
646
+ search: "test",
647
+ userId: "as3xNgfPVzrooSuSwn1ZSEKNA92Cjp4V",
648
+ });
649
+
650
+ if (!result.error) {
651
+ console.log(result.data.postsCount, result.data.uniqueOwnerCount);
652
+ }
653
+ ```
654
+
655
+ #### B) Query with request options
656
+
657
+ ```ts
658
+ const result = await appflare.queries.test.getTest.run(
659
+ { id: "test" },
660
+ {
661
+ headers: {
662
+ "x-trace-id": crypto.randomUUID(),
663
+ },
664
+ },
665
+ );
666
+ ```
667
+
668
+ #### C) Query with realtime and explicit auth token
669
+
670
+ ```ts
671
+ const sub = appflare.queries.test.getTest.subscribe({
672
+ args: { id: "test" },
673
+ authToken: "token-from-auth-flow",
674
+ onChange: (data) => {
675
+ console.log("fresh data", data);
676
+ },
677
+ });
678
+
679
+ setTimeout(() => sub.remove(), 30000);
680
+ ```
681
+
682
+ ### 6.3 Run a mutation
683
+
684
+ ```ts
685
+ const result = await appflare.mutations.test.newTest.run({});
686
+
687
+ if (result.error) {
688
+ console.error(result.error.message);
689
+ } else {
690
+ console.log(result.data);
691
+ }
692
+ ```
693
+
694
+ ### 6.4 Realtime subscribe to a query
695
+
696
+ ```ts
697
+ const sub = appflare.queries.test.getTest.subscribe({
698
+ args: { id: "test" },
699
+ onChange: (data, event) => {
700
+ console.log("update", event.payload.queryName, data);
701
+ },
702
+ onError: (error) => {
703
+ console.error("subscription error", error);
704
+ },
705
+ });
706
+
707
+ // later
708
+ sub.remove();
709
+ ```
710
+
711
+ ---
712
+
713
+ ## 7) How to use with React
714
+
715
+ Appflare ships React hooks in `appflare/react`:
716
+
717
+ - `useQuery`
718
+ - `useInfiniteQuery`
719
+ - `useMutation`
720
+
721
+ These are thin wrappers around TanStack Query.
722
+
723
+ ### 7.1 Setup requirements
724
+
725
+ Install peer requirements in your frontend app:
726
+
727
+ ```bash
728
+ bun add @tanstack/react-query react
729
+ ```
730
+
731
+ Wrap app with `QueryClientProvider`.
732
+
733
+ ```tsx
734
+ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
735
+
736
+ const queryClient = new QueryClient();
737
+
738
+ export function Providers({ children }: { children: React.ReactNode }) {
739
+ return (
740
+ <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
741
+ );
742
+ }
743
+ ```
744
+
745
+ ### 7.2 React query usage
746
+
747
+ ```tsx
748
+ import { useQuery } from "appflare/react";
749
+ import { appflare } from "./appflare-client";
750
+
751
+ export function TestScreen() {
752
+ const query = useQuery(
753
+ appflare.queries.test.getTest,
754
+ { id: "test" },
755
+ {
756
+ realtime: { enabled: true },
757
+ },
758
+ );
759
+
760
+ if (query.isLoading) return <div>Loading...</div>;
761
+ if (query.error) return <div>{query.error.message}</div>;
762
+
763
+ return <pre>{JSON.stringify(query.data, null, 2)}</pre>;
764
+ }
765
+ ```
766
+
767
+ ### 7.3 React mutation usage
768
+
769
+ ```tsx
770
+ import { useMutation } from "appflare/react";
771
+ import { appflare } from "./appflare-client";
772
+
773
+ export function CreatePostButton() {
774
+ const mutation = useMutation(appflare.mutations.test.newTest, {
775
+ onSuccess: (data) => console.log("created", data),
776
+ });
777
+
778
+ return (
779
+ <button onClick={() => mutation.mutate()} disabled={mutation.isPending}>
780
+ Create
781
+ </button>
782
+ );
783
+ }
784
+ ```
785
+
786
+ ### 7.4 React infinite query usage
787
+
788
+ ```tsx
789
+ import { useInfiniteQuery } from "appflare/react";
790
+ import { appflare } from "./appflare-client";
791
+
792
+ const result = useInfiniteQuery(
793
+ appflare.queries.test.getTest,
794
+ { id: "test" },
795
+ {
796
+ pageParamToArgs: (baseArgs, page) => ({ ...baseArgs, page }),
797
+ queryOptions: {
798
+ initialPageParam: 1,
799
+ getNextPageParam: (lastPage, pages) => pages.length + 1,
800
+ },
801
+ },
802
+ );
803
+ ```
804
+
805
+ ### 7.5 Realtime with React hooks
806
+
807
+ Both `useQuery` and `useInfiniteQuery` support:
808
+
809
+ ```ts
810
+ realtime: {
811
+ enabled: true,
812
+ authToken: "optional-token",
813
+ requestOptions: { headers: { "x-custom": "1" } },
814
+ onChange: (data, update) => {},
815
+ onError: (error) => {},
816
+ }
817
+ ```
818
+
819
+ When enabled, hooks subscribe via generated query `.subscribe(...)` and keep query cache updated automatically.
820
+
821
+ ---
822
+
823
+ ## 8) Frontend app helper pattern
824
+
825
+ A good pattern is to keep one shared client factory in a single file.
826
+
827
+ Example in this workspace:
828
+
829
+ - `apps/app/lib/appflare.ts`
830
+
831
+ This file centralizes:
832
+
833
+ - endpoint/wsEndpoint selection (web/mobile)
834
+ - token storage and retrieval
835
+ - exported hook wrappers
836
+
837
+ ---
838
+
839
+ ## 9) Common commands cheat sheet
840
+
841
+ From `packages/backend`:
842
+
843
+ ```bash
844
+ # Generate once
845
+ bun ../appflare/cli build -c appflare.config.ts
846
+
847
+ # Generate in dev mode
848
+ bun ../appflare/cli dev -c appflare.config.ts
849
+
850
+ # Generate + watch
851
+ bun ../appflare/cli dev -c appflare.config.ts --watch
852
+
853
+ # Migrate local D1
854
+ bun ../appflare/cli migrate -c appflare.config.ts --local
855
+
856
+ # Migrate remote D1
857
+ bun ../appflare/cli migrate -c appflare.config.ts --remote
858
+ ```
859
+
860
+ Or use backend scripts:
861
+
862
+ ```bash
863
+ bun run build
864
+ bun run dev
865
+ bun run migrate
866
+ ```
867
+
868
+ ---
869
+
870
+ ## 10) Troubleshooting
871
+
872
+ ### Generated client has missing routes
873
+
874
+ - Ensure handler file is under `scanDir`.
875
+ - Ensure export uses `query(...)` or `mutation(...)`.
876
+ - Run `bun ../appflare/cli dev` again.
877
+
878
+ ### Realtime not receiving updates
879
+
880
+ - Ensure query has `.subscribe` in generated client.
881
+ - Ensure `wsEndpoint` is set correctly.
882
+ - Ensure valid auth token is available if your runtime requires auth.
883
+
884
+ ### Migration fails
885
+
886
+ - Confirm database values in `appflare.config.ts`.
887
+ - Use one environment flag only (`--local`, `--remote`, or `--preview`).
888
+ - Re-run generation before migration if schema changed.
889
+
890
+ ---
891
+
892
+ ## 11) Recommended development loop
893
+
894
+ 1. Edit schema and handlers.
895
+ 2. Run generator (`dev` or `dev --watch`).
896
+ 3. Run migrations.
897
+ 4. Start backend (`wrangler dev`).
898
+ 5. Use generated client in frontend and iterate.