appflare 0.2.41 → 0.2.42

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.
package/Documentation.md CHANGED
@@ -164,6 +164,7 @@ export const listPostsPage = query({
164
164
  },
165
165
  }
166
166
  : {},
167
+ orderBy: { column: "id", direction: "asc" },
167
168
  limit: args.pageSize,
168
169
  with: {
169
170
  owner: true,
@@ -250,8 +251,8 @@ export const nearbyPlaygroundItems = query({
250
251
  },
251
252
  latitudeField: "latitude",
252
253
  longitudeField: "longitude",
253
- $gte: 0,
254
- $lt: args.radiusMeters,
254
+ gte: 0,
255
+ lt: args.radiusMeters,
255
256
  },
256
257
  isActive: {
257
258
  eq: true,
@@ -268,7 +269,69 @@ export const nearbyPlaygroundItems = query({
268
269
  });
269
270
  ```
270
271
 
271
- #### E) Complex production-style query (similar to `db-features`)
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`)
272
335
 
273
336
  ```ts
274
337
  import { query } from "../../_generated/handlers";
@@ -326,8 +389,11 @@ export const queryDashboardData = query({
326
389
  - Start with one root query and compose aggregates/relations progressively.
327
390
  - Prefer server-side filtering in `where` instead of filtering on frontend.
328
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.
329
393
 
330
- ### 3.2 Mutation handler example
394
+ ### 3.2 Mutation handler examples
395
+
396
+ #### Insert
331
397
 
332
398
  ```ts
333
399
  import { mutation } from "../../_generated/handlers";
@@ -352,6 +418,37 @@ export const createPost = mutation({
352
418
  });
353
419
  ```
354
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
+
355
452
  ### 3.3 Handler file placement
356
453
 
357
454
  Put handlers under `packages/backend/src` (including nested directories). Example patterns already used in this repo:
@@ -372,6 +469,13 @@ Inside handlers, you commonly use:
372
469
 
373
470
  - `ctx.db.<table>.findMany/findFirst/insert/update/upsert/delete`
374
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)
375
479
  - `ctx.error(status, message, details)` for typed failures
376
480
 
377
481
  See real examples in:
@@ -821,6 +821,8 @@ function emitManyToManyRuntimeMetadata(definition: SchemaDefinition): string {
821
821
  junctionTable: ${quote(relation.junctionTable)},
822
822
  sourceField: ${quote(relation.sourceField ?? "")},
823
823
  targetField: ${quote(relation.targetField ?? "")},
824
+ referenceField: ${quote(relation.referenceField ?? "id")},
825
+ targetReferenceField: ${quote(relation.targetReferenceField ?? "id")},
824
826
  },`,
825
827
  );
826
828
  }
@@ -46,7 +46,7 @@ type FieldOperators<T, TFieldKey extends string = string> = {
46
46
  lte?: Comparable<T>;
47
47
  exists?: boolean;
48
48
  regex?: RegexOperand<T>;
49
- options?: string;
49
+ $options?: string;
50
50
  geoWithin?: GeoWithinOperandForField<TFieldKey>;
51
51
  includes?: T extends ReadonlyArray<infer E> ? ReadonlyArray<E> : never;
52
52
  includesAny?: T extends ReadonlyArray<infer E> ? ReadonlyArray<E> : never;
@@ -258,11 +258,31 @@ export type QueryInsertArgs<TName extends TableName> = {
258
258
  };
259
259
 
260
260
  export type QueryUpdateArgs<TName extends TableName> = {
261
- set: Partial<TableInsertModel<TName>>;
261
+ set: Partial<TableInsertModel<TName>> & ManyToManyUpdateSetFields<TName>;
262
262
  where?: WhereInput<TableModel<TName>, TName>;
263
263
  limit?: number;
264
264
  };
265
265
 
266
+ type ManyToManyUpdateSetFields<TName extends TableName> = {
267
+ [TRelationName in RuntimeRelationName<TName>]?: RuntimeRelationKind<TName, TRelationName> extends "manyToMany"
268
+ ? ManyToManyUpdateInput<TName, TRelationName>
269
+ : never;
270
+ };
271
+
272
+ type ManyToManyUpdateInput<
273
+ TSourceTable extends TableName,
274
+ TRelationName extends RuntimeRelationName<TSourceTable>,
275
+ > = {
276
+ items: Array<ManyToManyUpdateItem<TargetTableForRelation<TSourceTable, TRelationName>>>;
277
+ mode?: "merge" | "overwrite";
278
+ };
279
+
280
+ type ManyToManyUpdateItem<TTargetTable extends TableName> =
281
+ | ("id" extends keyof TableModel<TTargetTable>
282
+ ? TableModel<TTargetTable>["id"]
283
+ : never)
284
+ | Partial<TableInsertModel<TTargetTable>>;
285
+
266
286
  export type QueryDeleteArgs<TName extends TableName> = {
267
287
  where?: WhereInput<TableModel<TName>, TName>;
268
288
  limit?: number;
@@ -561,6 +561,8 @@ export function generateQueryRuntimeWriteSection(): string {
561
561
  return rows;
562
562
  },
563
563
  update: async (args: QueryUpdateArgs<TableName>) => {
564
+ const transaction = ($db as any).transaction;
565
+
564
566
  const whereFilter = buildWhereFilter(
565
567
  table,
566
568
  args.where as Record<string, unknown> | undefined,
@@ -569,27 +571,286 @@ export function generateQueryRuntimeWriteSection(): string {
569
571
 
570
572
  const setPayload = args.set as Record<string, unknown>;
571
573
  const cleanSetPayload: Record<string, unknown> = {};
574
+ const relationPayloads: Array<{
575
+ relationName: string;
576
+ relation: RuntimeRelation;
577
+ value: unknown;
578
+ }> = [];
579
+
572
580
  for (const [key, value] of Object.entries(setPayload)) {
573
- if (value !== undefined) {
574
- cleanSetPayload[key] = value;
581
+ if (value === undefined) continue;
582
+ const runtimeRelation = getRuntimeRelation(tableName, key);
583
+ if (runtimeRelation && runtimeRelation.kind === "manyToMany") {
584
+ relationPayloads.push({
585
+ relationName: key,
586
+ relation: runtimeRelation,
587
+ value,
588
+ });
589
+ continue;
575
590
  }
591
+ cleanSetPayload[key] = value;
576
592
  }
577
593
 
578
- let updateQuery: any = ($db as any)
579
- .update(table as any)
580
- .set(cleanSetPayload as any);
594
+ const normalizeRelationValue = (
595
+ value: unknown,
596
+ ): { items: unknown[]; mode: "merge" | "overwrite" } => {
597
+ if (Array.isArray(value)) {
598
+ return { items: value, mode: "merge" };
599
+ }
600
+ if (value && typeof value === "object") {
601
+ const record = value as Record<string, unknown>;
602
+ const items = Array.isArray(record.items) ? record.items : [];
603
+ const mode = record.mode === "overwrite" ? "overwrite" : "merge";
604
+ return { items, mode };
605
+ }
606
+ return { items: [], mode: "merge" };
607
+ };
608
+
609
+ const executeUpdateWithRelations = async (
610
+ tx: any,
611
+ ): Promise<Array<TableModel<TableName>>> => {
612
+ let updateQuery: any = tx
613
+ .update(table as any)
614
+ .set(cleanSetPayload as any);
581
615
 
582
- if (whereFilter) {
583
- updateQuery = updateQuery.where(whereFilter);
584
- }
585
- if (typeof args.limit === "number" && typeof updateQuery.limit === "function") {
586
- updateQuery = updateQuery.limit(args.limit);
587
- }
588
- if (typeof updateQuery.returning === "function") {
589
- updateQuery = updateQuery.returning();
616
+ if (whereFilter) {
617
+ updateQuery = updateQuery.where(whereFilter);
618
+ }
619
+ if (
620
+ typeof args.limit === "number" &&
621
+ typeof updateQuery.limit === "function"
622
+ ) {
623
+ updateQuery = updateQuery.limit(args.limit);
624
+ }
625
+ if (typeof updateQuery.returning === "function") {
626
+ updateQuery = updateQuery.returning();
627
+ }
628
+
629
+ const rows = (await updateQuery) as Array<TableModel<TableName>>;
630
+ if (rows.length === 0) return rows;
631
+
632
+ for (const row of rows) {
633
+ const parentId = (row as Record<string, unknown>)["id"];
634
+ if (parentId === undefined || parentId === null) continue;
635
+
636
+ for (const { relationName, relation, value } of relationPayloads) {
637
+ const { items, mode } = normalizeRelationValue(value);
638
+
639
+ const junctionTable = (mergedSchema as Record<string, unknown>)[
640
+ relation.junctionTable
641
+ ];
642
+ if (!junctionTable) {
643
+ throw new Error(
644
+ "Unknown junction table '" +
645
+ relation.junctionTable +
646
+ "' for relation '" +
647
+ tableName +
648
+ "." +
649
+ relationName +
650
+ "'.",
651
+ );
652
+ }
653
+
654
+ const sourceField = relation.sourceField;
655
+ const targetField = relation.targetField;
656
+ if (!sourceField || !targetField) {
657
+ throw new Error(
658
+ "Relation '" +
659
+ tableName +
660
+ "." +
661
+ relationName +
662
+ "' is missing junction metadata fields.",
663
+ );
664
+ }
665
+
666
+ const parentReferenceField = relation.referenceField ?? "id";
667
+ const parentReferenceColumn = (table as Record<string, unknown>)[parentReferenceField];
668
+ if (!parentReferenceColumn) {
669
+ throw new Error(
670
+ "Table '" +
671
+ tableName +
672
+ "' is missing column '" +
673
+ parentReferenceField +
674
+ "' for relation '" +
675
+ relationName +
676
+ "'.",
677
+ );
678
+ }
679
+
680
+ const junctionSourceColumn = (junctionTable as Record<string, unknown>)[sourceField];
681
+ if (!junctionSourceColumn) {
682
+ throw new Error(
683
+ "Junction table '" +
684
+ relation.junctionTable +
685
+ "' is missing column '" +
686
+ sourceField +
687
+ "' for relation '" +
688
+ relationName +
689
+ "'.",
690
+ );
691
+ }
692
+
693
+ if (mode === "overwrite") {
694
+ await tx
695
+ .delete(junctionTable as any)
696
+ .where(eq(junctionSourceColumn as any, (row as Record<string, unknown>)[parentReferenceField] as any));
697
+ }
698
+
699
+ const referenceField = relation.targetReferenceField ?? "id";
700
+ const targetTable = (mergedSchema as Record<string, unknown>)[
701
+ relation.targetTable
702
+ ];
703
+
704
+ const junctionTargetColumn =
705
+ (junctionTable as Record<string, unknown>)[targetField];
706
+ if (!junctionTargetColumn) {
707
+ throw new Error(
708
+ "Junction table '" +
709
+ relation.junctionTable +
710
+ "' is missing column '" +
711
+ targetField +
712
+ "'.",
713
+ );
714
+ }
715
+
716
+ for (const item of items) {
717
+ let targetId: unknown;
718
+
719
+ if (
720
+ typeof item === "string" ||
721
+ typeof item === "number" ||
722
+ typeof item === "bigint"
723
+ ) {
724
+ targetId = item;
725
+ } else if (item && typeof item === "object") {
726
+ const record = item as Record<string, unknown>;
727
+ const existingId = record[referenceField];
728
+ if (existingId !== undefined && existingId !== null) {
729
+ targetId = existingId;
730
+ } else {
731
+ if (!targetTable) {
732
+ throw new Error(
733
+ "Unknown target table '" +
734
+ relation.targetTable +
735
+ "' for relation '" +
736
+ tableName +
737
+ "." +
738
+ relationName +
739
+ "'.",
740
+ );
741
+ }
742
+ let createQuery: any = tx
743
+ .insert(targetTable as any)
744
+ .values(record as any);
745
+ if (typeof createQuery.returning === "function") {
746
+ createQuery = createQuery.returning();
747
+ }
748
+ const createdRows =
749
+ (await createQuery) as Array<Record<string, unknown>>;
750
+ const created = createdRows[0];
751
+ if (!created) {
752
+ throw new Error(
753
+ "Failed to create relation target for '" +
754
+ tableName +
755
+ "." +
756
+ relationName +
757
+ "'.",
758
+ );
759
+ }
760
+ targetId = created[referenceField];
761
+ if (targetId === undefined || targetId === null) {
762
+ throw new Error(
763
+ "Created relation target for '" +
764
+ tableName +
765
+ "." +
766
+ relationName +
767
+ "' is missing '" +
768
+ referenceField +
769
+ "'.",
770
+ );
771
+ }
772
+ }
773
+ } else {
774
+ throw new Error(
775
+ "Relation '" +
776
+ tableName +
777
+ "." +
778
+ relationName +
779
+ "' expects an id or object payload.",
780
+ );
781
+ }
782
+
783
+ if (mode === "merge") {
784
+ const parentRefValue = (row as Record<string, unknown>)[parentReferenceField];
785
+ const existingLinks = await tx
786
+ .select()
787
+ .from(junctionTable as any)
788
+ .where(
789
+ and(
790
+ eq(junctionSourceColumn as any, parentRefValue as any),
791
+ eq(junctionTargetColumn as any, targetId as any),
792
+ ),
793
+ );
794
+ if (existingLinks.length > 0) continue;
795
+ }
796
+
797
+ const parentRefValue = (row as Record<string, unknown>)[parentReferenceField];
798
+ await tx.insert(junctionTable as any).values({
799
+ [sourceField]: parentRefValue,
800
+ [targetField]: targetId,
801
+ } as any);
802
+ }
803
+ }
804
+ }
805
+
806
+ return rows;
807
+ };
808
+
809
+ let rows: Array<TableModel<TableName>>;
810
+ if (relationPayloads.length > 0) {
811
+ if (typeof transaction === "function") {
812
+ try {
813
+ rows = await transaction.call($db, (tx: any) =>
814
+ executeUpdateWithRelations(tx),
815
+ );
816
+ } catch (error) {
817
+ const message =
818
+ error instanceof Error ? error.message : String(error);
819
+ const lowered = message.toLowerCase();
820
+ if (
821
+ lowered.includes("failed query: begin") ||
822
+ lowered.includes('near "begin"') ||
823
+ lowered.includes("cannot start a transaction")
824
+ ) {
825
+ rows = await executeUpdateWithRelations($db as any);
826
+ } else {
827
+ throw error;
828
+ }
829
+ }
830
+ } else {
831
+ rows = await executeUpdateWithRelations($db as any);
832
+ }
833
+ } else {
834
+ let updateQuery: any = ($db as any)
835
+ .update(table as any)
836
+ .set(cleanSetPayload as any);
837
+
838
+ if (whereFilter) {
839
+ updateQuery = updateQuery.where(whereFilter);
840
+ }
841
+ if (
842
+ typeof args.limit === "number" &&
843
+ typeof updateQuery.limit === "function"
844
+ ) {
845
+ updateQuery = updateQuery.limit(args.limit);
846
+ }
847
+ if (typeof updateQuery.returning === "function") {
848
+ updateQuery = updateQuery.returning();
849
+ }
850
+
851
+ rows = (await updateQuery) as Array<TableModel<TableName>>;
590
852
  }
591
853
 
592
- const rows = (await updateQuery) as Array<TableModel<TableName>>;
593
854
  emitMutation(
594
855
  "update",
595
856
  {