fastify-auto-sqlapi 0.0.5 → 0.1.1

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 (119) hide show
  1. package/AGENTS.md +15 -2
  2. package/AGENTS_BACKEND.md +144 -20
  3. package/AGENTS_FRONTEND.md +161 -67
  4. package/README.md +47 -26
  5. package/dist/bin/generate-schema.js +9 -26
  6. package/dist/bin/generate-tables.js +7 -30
  7. package/dist/bin/utils.d.ts +28 -0
  8. package/dist/bin/utils.d.ts.map +1 -1
  9. package/dist/bin/utils.js +69 -0
  10. package/dist/index.d.ts +1 -1
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/lib/cli/load-dependency.d.ts +8 -0
  13. package/dist/lib/cli/load-dependency.d.ts.map +1 -0
  14. package/dist/lib/cli/load-dependency.js +21 -0
  15. package/dist/lib/cli/mysql-introspect.d.ts.map +1 -1
  16. package/dist/lib/cli/mysql-introspect.js +2 -18
  17. package/dist/lib/cli/pg-introspect.d.ts.map +1 -1
  18. package/dist/lib/cli/pg-introspect.js +2 -17
  19. package/dist/lib/db.d.ts +1 -0
  20. package/dist/lib/db.d.ts.map +1 -1
  21. package/dist/lib/db.js +33 -55
  22. package/dist/lib/dialect.d.ts +17 -0
  23. package/dist/lib/dialect.d.ts.map +1 -1
  24. package/dist/lib/dialect.js +32 -0
  25. package/dist/lib/engine/bulk/bulk-upsert.d.ts.map +1 -1
  26. package/dist/lib/engine/bulk/bulk-upsert.js +15 -43
  27. package/dist/lib/engine/rest/delete.d.ts.map +1 -1
  28. package/dist/lib/engine/rest/delete.js +5 -10
  29. package/dist/lib/engine/rest/get.d.ts.map +1 -1
  30. package/dist/lib/engine/rest/get.js +3 -5
  31. package/dist/lib/engine/rest/insert.d.ts.map +1 -1
  32. package/dist/lib/engine/rest/insert.js +11 -31
  33. package/dist/lib/engine/rest/update.d.ts.map +1 -1
  34. package/dist/lib/engine/rest/update.js +13 -46
  35. package/dist/lib/engine/search/search.d.ts.map +1 -1
  36. package/dist/lib/engine/search/search.js +553 -229
  37. package/dist/lib/engine/write-helpers.d.ts +39 -2
  38. package/dist/lib/engine/write-helpers.d.ts.map +1 -1
  39. package/dist/lib/engine/write-helpers.js +55 -12
  40. package/dist/lib/errors.d.ts +11 -0
  41. package/dist/lib/errors.d.ts.map +1 -0
  42. package/dist/lib/errors.js +12 -0
  43. package/dist/lib/schema/bulk-upsert.d.ts.map +1 -1
  44. package/dist/lib/schema/bulk-upsert.js +9 -25
  45. package/dist/lib/schema/helpers.d.ts +28 -1
  46. package/dist/lib/schema/helpers.d.ts.map +1 -1
  47. package/dist/lib/schema/helpers.js +38 -0
  48. package/dist/lib/schema/insert.d.ts.map +1 -1
  49. package/dist/lib/schema/insert.js +9 -19
  50. package/dist/lib/schema/search.d.ts.map +1 -1
  51. package/dist/lib/schema/search.js +138 -50
  52. package/dist/lib/schema/update.d.ts.map +1 -1
  53. package/dist/lib/schema/update.js +9 -25
  54. package/dist/lib/sql-api.d.ts +6 -6
  55. package/dist/lib/sql-api.d.ts.map +1 -1
  56. package/dist/lib/sql-api.js +8 -8
  57. package/dist/lib/table-helpers.d.ts +6 -1
  58. package/dist/lib/table-helpers.d.ts.map +1 -1
  59. package/dist/lib/table-helpers.js +42 -2
  60. package/dist/lib/tenant.d.ts +23 -0
  61. package/dist/lib/tenant.d.ts.map +1 -1
  62. package/dist/lib/tenant.js +57 -9
  63. package/dist/routes/auto/bulk-delete.routes.d.ts.map +1 -1
  64. package/dist/routes/auto/bulk-delete.routes.js +17 -23
  65. package/dist/routes/auto/bulk-upsert.routes.d.ts.map +1 -1
  66. package/dist/routes/auto/bulk-upsert.routes.js +16 -22
  67. package/dist/routes/auto/delete.routes.d.ts.map +1 -1
  68. package/dist/routes/auto/delete.routes.js +18 -24
  69. package/dist/routes/auto/get.routes.d.ts.map +1 -1
  70. package/dist/routes/auto/get.routes.js +16 -23
  71. package/dist/routes/auto/insert.routes.d.ts.map +1 -1
  72. package/dist/routes/auto/insert.routes.js +19 -26
  73. package/dist/routes/auto/route-helpers.d.ts +27 -2
  74. package/dist/routes/auto/route-helpers.d.ts.map +1 -1
  75. package/dist/routes/auto/route-helpers.js +39 -1
  76. package/dist/routes/auto/search.routes.d.ts.map +1 -1
  77. package/dist/routes/auto/search.routes.js +47 -47
  78. package/dist/routes/auto/update.routes.d.ts.map +1 -1
  79. package/dist/routes/auto/update.routes.js +20 -27
  80. package/dist/types/cli.d.ts +22 -0
  81. package/dist/types/cli.d.ts.map +1 -0
  82. package/dist/types/cli.js +1 -0
  83. package/dist/types/computed.d.ts +29 -0
  84. package/dist/types/computed.d.ts.map +1 -0
  85. package/dist/types/computed.js +1 -0
  86. package/dist/types/conditions.d.ts +16 -0
  87. package/dist/types/conditions.d.ts.map +1 -0
  88. package/dist/types/conditions.js +1 -0
  89. package/dist/types/db.d.ts +22 -0
  90. package/dist/types/db.d.ts.map +1 -0
  91. package/dist/types/db.js +1 -0
  92. package/dist/types/dml.d.ts +64 -0
  93. package/dist/types/dml.d.ts.map +1 -0
  94. package/dist/types/dml.js +1 -0
  95. package/dist/types/join.d.ts +44 -0
  96. package/dist/types/join.d.ts.map +1 -0
  97. package/dist/types/join.js +1 -0
  98. package/dist/types/plugin.d.ts +20 -0
  99. package/dist/types/plugin.d.ts.map +1 -0
  100. package/dist/types/plugin.js +1 -0
  101. package/dist/types/schema.d.ts +10 -0
  102. package/dist/types/schema.d.ts.map +1 -0
  103. package/dist/types/schema.js +1 -0
  104. package/dist/types/search.d.ts +45 -0
  105. package/dist/types/search.d.ts.map +1 -0
  106. package/dist/types/search.js +1 -0
  107. package/dist/types/table.d.ts +52 -0
  108. package/dist/types/table.d.ts.map +1 -0
  109. package/dist/types/table.js +7 -0
  110. package/dist/types/tenant.d.ts +19 -0
  111. package/dist/types/tenant.d.ts.map +1 -0
  112. package/dist/types/tenant.js +1 -0
  113. package/dist/types/validation.d.ts +21 -0
  114. package/dist/types/validation.d.ts.map +1 -0
  115. package/dist/types/validation.js +1 -0
  116. package/dist/types.d.ts +12 -277
  117. package/dist/types.d.ts.map +1 -1
  118. package/dist/types.js +12 -7
  119. package/package.json +3 -3
package/AGENTS.md CHANGED
@@ -8,9 +8,11 @@ The plugin generates REST endpoints (search, get, insert, update, delete, bulk u
8
8
 
9
9
  ## Documentation
10
10
 
11
- - **[AGENTS_BACKEND.md](./AGENTS_BACKEND.md)** — Setup workflow, CLI, schema/table generation, `defineTable()` complete reference, `buildRelation`, `extraFilters` + `extendedCondition`, **SqlApi** (programmatic high-level API for custom routes), ConditionBuilder API, QueryClient API, Swagger, multi-tenant configuration, **validation** (`validate` + `validateBulk`), hooks, key conventions, common backend patterns, FAQ/gotchas, dialect differences.
11
+ - **[BREAKING_CHANGES.md](./BREAKING_CHANGES.md)** — **READ FIRST when migrating.** Maps the old join API (`joinFilters`, `joins`, `joinGroups`) to the new alias-based one (`joinMustExist`, `joinMultiple`, `joinGroup`, `joinLeft`). Covers `buildRelation` new options-object signature (alias defaults to `joinSchema.tableName`, plus `selection` and `unique`), request/response key renames, secondaries key renames, dotted notation rules, and validation/400 errors. Common to backend and frontend.
12
12
 
13
- - **[AGENTS_FRONTEND.md](./AGENTS_FRONTEND.md)** — Generated endpoints overview, search (POST with body filters, conditions/advanced filters using ConditionBuilder methods, query params for ordering/pagination/aggregation), joinFilters (EXISTS-based filtering by related tables), GET, INSERT (with secondaries), UPDATE (with secondaries + deletions), DELETE, bulk upsert, bulk delete, response shapes (PK-only), **validation errors** (structured field-level 400 responses), joins usage, joinGroups/aggregations, pagination, computed values.
13
+ - **[AGENTS_BACKEND.md](./AGENTS_BACKEND.md)** — Setup workflow, CLI, schema/table generation, `defineTable()` complete reference, `buildRelation` (alias + unique), `extraFilters` + `extendedCondition`, **SqlApi** (programmatic high-level API for custom routes), ConditionBuilder API, QueryClient API, Swagger, multi-tenant configuration, **validation** (`validate` + `validateBulk`), hooks, key conventions, common backend patterns, FAQ/gotchas, dialect differences.
14
+
15
+ - **[AGENTS_FRONTEND.md](./AGENTS_FRONTEND.md)** — Generated endpoints overview, search (POST with body filters, conditions/advanced filters using ConditionBuilder methods, query params for ordering/pagination/aggregation), `joinMustExist` (EXISTS-based filtering by related tables), `joinMultiple` (fetch child rows), `joinGroup` (aggregations), `joinLeft` (embed N:1 parent inline), GET, INSERT (with secondaries), UPDATE (with secondaries + deletions), DELETE, bulk upsert, bulk delete, response shapes (PK-only), **validation errors** (structured field-level 400 responses), pagination, computed values.
14
16
 
15
17
  ## Quick Reference
16
18
 
@@ -54,3 +56,14 @@ export const TableCustomer = defineTable({
54
56
  ...exportTableInfo(Schema),
55
57
  });
56
58
  ```
59
+
60
+ ### Joins at a glance
61
+
62
+ | Family | Direction | Cardinality | Output |
63
+ |--------|-----------|-------------|--------|
64
+ | `joinMustExist` | child → main | 1:N | filters main via EXISTS |
65
+ | `joinMultiple` | child → main | 1:N | side query, child rows in `result.joinMultiple.<alias>` |
66
+ | `joinGroup` | child → main | 1:N | aggregations in `result.joinGroup.<alias>` |
67
+ | `joinLeft` | parent → main | N:1 | real LEFT JOIN (on demand), parent rows in `result.joinLeft.<alias>` |
68
+
69
+ Declare relations with `buildRelation(M, mF, J, jF, options?)`. Options are all optional: `alias` defaults to `joinSchema.tableName` (override only when joining the same table twice or to use a friendlier name), `selection` defaults to `'*'`, `unique` defaults to `false`. Set `unique: true` for N:1 (parent) relations to enable `joinLeft`. See [BREAKING_CHANGES.md](./BREAKING_CHANGES.md) for the full migration guide.
package/AGENTS_BACKEND.md CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  How to set up schemas, tables, and configure the plugin.
4
4
 
5
+ > ⚠️ **Migrating from a previous version?** The join API was redesigned (no backward compat). See **[BREAKING_CHANGES.md](./BREAKING_CHANGES.md)** for the full migration guide — request/response key renames, `buildRelation` signature, and validation rules. Common to backend and frontend.
6
+
5
7
  ## Setup Workflow
6
8
 
7
9
  ### 1. Install
@@ -234,13 +236,20 @@ export const TableCustomer = defineTable({
234
236
  excludeFromCreation: ['id'], // Fields omitted from INSERT — MUST include auto-increment PKs
235
237
  distinctResults: true, // Use SELECT DISTINCT
236
238
 
237
- // JOINS — relations to other tables
238
- allowedReadJoins: [ // Available for search queries (joins, joinFilters, joinGroups)
239
- buildRelation(SchemaCustomer, 'id', SchemaOrder, 'customerId'),
240
- buildRelation(SchemaCustomer, 'id', SchemaAddress, 'customerId', 'id, city, zip'),
239
+ // JOINS — relations to other tables. Alias defaults to joinSchema.tableName —
240
+ // declare it explicitly only when joining the same table twice or when you want
241
+ // a friendlier name. Set `unique: true` for N:1 (parent) relations to enable `joinLeft`.
242
+ allowedReadJoins: [ // Available for search: joinMustExist / joinMultiple / joinGroup / joinLeft
243
+ buildRelation(SchemaCustomer, 'id', SchemaOrder, 'customerId'), // alias = 'order' (default)
244
+ buildRelation(SchemaCustomer, 'id', SchemaAddress, 'customerId', { selection: 'id, city, zip' }), // alias = 'address'
245
+ // N:1 example (enables joinLeft):
246
+ // buildRelation(SchemaSession, 'userId', SchemaUser, 'id', { unique: true }), // alias = 'user'
247
+ // Alias multipli sulla stessa tabella (richiede alias espliciti):
248
+ // buildRelation(SchemaSession, 'createdBy', SchemaUser, 'id', { alias: 'creator', unique: true }),
249
+ // buildRelation(SchemaSession, 'updatedBy', SchemaUser, 'id', { alias: 'updater', unique: true }),
241
250
  ],
242
- allowedWriteJoins: [ // Available for insert/update secondaries
243
- buildRelation(SchemaCustomer, 'id', SchemaOrder, 'customerId'),
251
+ allowedWriteJoins: [ // Available for insert/update secondaries (alias is the request body key)
252
+ buildRelation(SchemaCustomer, 'id', SchemaOrder, 'customerId'), // alias = 'order'
244
253
  ],
245
254
 
246
255
  // UPSERT — ON CONFLICT resolution
@@ -271,8 +280,9 @@ export const TableCustomer = defineTable({
271
280
  if (main.name === '') {
272
281
  errors.push(['name', 'required', 'cannot be empty']);
273
282
  }
274
- // Cross-entity: validate secondaries (e.g. date overlap in periods)
275
- if (secondaries?.customer_order) {
283
+ // Cross-entity: validate secondaries (e.g. date overlap in periods).
284
+ // Note: secondaries are keyed by alias (declared in allowedWriteJoins), not by tableName.
285
+ if (secondaries?.orders) {
276
286
  // ... check overlaps, business rules across related records
277
287
  }
278
288
  return errors;
@@ -308,14 +318,42 @@ export const TableCustomer = defineTable({
308
318
  ### buildRelation signature
309
319
 
310
320
  ```typescript
311
- buildRelation(mainSchema, mainField, joinSchema, joinField, selection?)
321
+ buildRelation(mainSchema, mainField, joinSchema, joinField, options?)
322
+
323
+ // where options is fully optional:
324
+ // {
325
+ // alias?: string; // default = joinSchema.tableName. The key used in request/response
326
+ // // payloads, secondaries/deletions, and dotted-notation orderBy/conditions.
327
+ // selection?: string; // default '*'. Comma-separated columns: 'id, name, total'.
328
+ // unique?: boolean; // default false.
329
+ // // false → 1:N (child→main). Allowed in joinMustExist/joinMultiple/joinGroup.
330
+ // // true → N:1 (parent→main). Allowed in joinLeft only.
331
+ // }
312
332
  ```
313
333
 
314
334
  - `mainField`: field(s) in main table (string or string[])
315
- - `joinField`: field in join table that references the main
316
- - `selection`: SQL columns to select (default `'*'`), e.g. `'id, name, total'`
335
+ - `joinField`: field in join table that joins to `mainField`
336
+ - For 1:N (child→main): `joinField` is the FK on the child table → `mainField` is the PK on main
337
+ - For N:1 (parent→main): `joinField` is the PK on the parent table → `mainField` is the FK on main
338
+
339
+ **When to declare an explicit `alias`**: only when (a) you join the same table multiple times in the same `allowedReadJoins`/`allowedWriteJoins`, or (b) you want a friendlier name in the public API surface than the SQL table name (e.g. `'orders'` instead of `'customer_order'`). Otherwise omit it — the default is `joinSchema.tableName`.
317
340
 
318
- The join works as: `SELECT {selection} FROM {joinTable} WHERE {joinField} IN ({mainField values})`
341
+ **Choosing `unique`**: if `joinField` is the PK (or part of the composite PK) of `joinSchema`, the relation is N:1 — you almost certainly want `unique: true` so the alias is usable in `joinLeft`.
342
+
343
+ **Aliasing the same table twice**: declare two `buildRelation` entries with different `alias`. Example: a `session` table referencing `user` for both `createdBy` and `updatedBy`:
344
+
345
+ ```typescript
346
+ allowedReadJoins: [
347
+ buildRelation(SchemaSession, 'createdBy', SchemaUser, 'id', { alias: 'creator', unique: true }),
348
+ buildRelation(SchemaSession, 'updatedBy', SchemaUser, 'id', { alias: 'updater', unique: true }),
349
+ ]
350
+ ```
351
+
352
+ If two entries in the same `allowedReadJoins`/`allowedWriteJoins` resolve to the same alias (explicit or implicit), `defineTable` throws at startup. So you cannot accidentally have two relations sharing an alias — the error tells you to disambiguate explicitly.
353
+
354
+ The 1:N joins (`joinMustExist` / `joinMultiple` / `joinGroup`) work as: `SELECT {selection} FROM {childTable} WHERE {childFK} IN ({mainPK values})`. The N:1 join (`joinLeft`) adds `LEFT JOIN {parentTable} AS {alias} ON {alias}.{parentPK} = {main}.{mainFK}` to the main query, but only when the request actually needs it (filters/orderBy on parent fields). Otherwise a side query `SELECT FROM {parentTable} WHERE {parentPK} IN (distinct main FK values)` is used.
355
+
356
+ **`joinLeft` limitation**: `extraFilters` declared via `extendedCondition` on the parent table are not applied inside `joinLeft.filters` (only schema fields). The other join families fully support extraFilters.
319
357
 
320
358
  ### extraFilters + extendedCondition
321
359
 
@@ -351,6 +389,89 @@ export const TableCustomer = defineTable({
351
389
 
352
390
  ---
353
391
 
392
+ ## Computed Fields (extension system)
393
+
394
+ `computedFields` lets you declare **virtual fields** as SQL expressions on a per-table basis. Each computed becomes usable like a regular schema field across the search API: `filters` (equality), `conditions` (operators), `orderBy` (1-parte), `computeMin/Max/Sum/Avg`, and (opt-in) in `selectComputed` for the main response. Same machinery serves JSON column extraction, derived strings, dialect-aware date/calendar bucketing — without growing the library case-by-case.
395
+
396
+ ```typescript
397
+ import { defineTable, exportTableInfo, Type } from 'fastify-auto-sqlapi';
398
+ import { SchemaCustomer as Schema } from '../schemas/SchemaCustomer';
399
+
400
+ export const TableCustomer = defineTable({
401
+ primary: 'id',
402
+ ...exportTableInfo(Schema),
403
+ computedFields: {
404
+ // JSON path extraction — dialect-aware (Postgres -> arrow, MySQL -> JSON_EXTRACT).
405
+ statusFromMeta: ({ db, qiCol }) => ({
406
+ expr: db.dialectName === 'postgres'
407
+ ? `${qiCol('metadata')}->>'status'`
408
+ : `JSON_UNQUOTE(JSON_EXTRACT(${qiCol('metadata')}, '$.status'))`,
409
+ values: [],
410
+ type: Type.String(),
411
+ }),
412
+ // Derived string column — dialect-aware concat.
413
+ fullName: ({ db, qiCol }) => ({
414
+ expr: db.dialectName === 'postgres'
415
+ ? `${qiCol('firstName')} || ' ' || ${qiCol('lastName')}`
416
+ : `CONCAT(${qiCol('firstName')}, ' ', ${qiCol('lastName')})`,
417
+ values: [],
418
+ type: Type.String(),
419
+ }),
420
+ },
421
+ });
422
+ ```
423
+
424
+ Client side, the computed name behaves like any other field:
425
+
426
+ ```json
427
+ {
428
+ "filters": { "statusFromMeta": "active" },
429
+ "conditions": [
430
+ { "field": "fullName", "method": "isLike", "params": ["%Mario%"] }
431
+ ],
432
+ "orderBy": "fullName ASC",
433
+ "selectComputed": ["fullName", "statusFromMeta"]
434
+ }
435
+ ```
436
+
437
+ The values returned by `selectComputed` appear as extra fields on each `main[i]` row.
438
+
439
+ ### `ComputedFieldFn` signature
440
+
441
+ ```typescript
442
+ type ComputedFieldFn = (ctx: {
443
+ db: QueryClient; // dialect-aware (qi, ph, dialectName, dateTrunc, ...)
444
+ qiCol(field: string, opts?: { qualifier?: string }): string;
445
+ }) => {
446
+ expr: string; // SQL fragment
447
+ values: unknown[]; // bound values (see limitations below)
448
+ type: TSchema; // REQUIRED — used by Swagger and body validation
449
+ };
450
+ ```
451
+
452
+ `qiCol(field)` returns a properly-quoted column reference, optionally prefixed by an alias qualifier — the engine passes the alias automatically when the computed is invoked inside a `joinLeft` LEFT JOIN, so the same function works both inline on the main query and as a parent-side column.
453
+
454
+ ### Validation & startup checks
455
+
456
+ `defineTable` throws synchronously if a computed name collides with a schema field or an `extraFilters` key on the same table. The error message tells you which name to change.
457
+
458
+ ### Side queries (`joinMustExist`, `joinMultiple`, `joinGroup`, `joinLeft`)
459
+
460
+ Computed fields are **per-table**: a side query operating on a join target reads `joinTableConf.computedFields`, not the main table's. So if you need `statusFromMeta` on `joinLeft.user.filters`, declare it on `TableUser`, not on `TableSession`.
461
+
462
+ For `joinLeft` specifically, the computed expr is automatically alias-prefixed by `qiCol` (the engine forwards the alias). For the other three families the side query is a separate SELECT, so plain column references suffice.
463
+
464
+ ### Limitations (first round, by design)
465
+
466
+ - **Bound `values` in computed expressions** are supported only on `filters`/`conditions` (main and `joinMustExist`/`joinMultiple`/`joinGroup`). They are rejected with 400 on `selectComputed`, `computeMin/Max/Sum/Avg`, `orderBy`, and `joinLeft.filters`/`conditions` — those paths require placeholder coordination not done in the first round. Most use cases (JSON extraction, concat, dateTrunc, simple ops) need no placeholders.
467
+ - **Computed CAN be used in `joinGroup.aggregations.by`** (just pass the computed name as a string). Bound `values` are not supported in this position (rejected with 400). The existing FK-correlation rule for `orderBy <alias>.<fn>.<field>` still applies: 3-parti aggregation orderBy on a `by` that isn't the correlation FK is rejected — by definition a computed-by produces multiple groups per main row, so this is never valid.
468
+ - **Computed cannot be used as aggregation function** (`sum`/`min`/`max`/...). The values inside `aggregations.sum: ['<name>']` must be schema field names. To aggregate on a derived expression, declare the derivation as a computed and pass the computed name as the field, BUT only via `computeMin/Max/Sum/Avg` (top-level main aggregates), not the joinGroup ones.
469
+ - **No chained computed** (a computed referencing another computed). Flat-only.
470
+ - **Read-only**. Computed fields are not usable in insert/update bodies — the consumer's `expr` is for SELECT/WHERE/ORDER BY, never for writes.
471
+ - **Same expression evaluated multiple times** when used in WHERE + ORDER BY + SELECT. Cheap exprs are fine; for expensive ones, query planner CSE often helps.
472
+
473
+ ---
474
+
354
475
  ## ConditionBuilder API
355
476
 
356
477
  Used in `extendedCondition` callbacks, hooks, and exposed via the `conditions` field in the search API (see [AGENTS_FRONTEND.md](./AGENTS_FRONTEND.md#conditions-advanced-filters)).
@@ -401,7 +522,7 @@ app.get('/billing/:customerId', async (request) => {
401
522
  const customerId = Number(request.params.customerId);
402
523
  return app.sqlApi.search('subscription', {
403
524
  filters: { customerId, status: 'active' },
404
- joinFilters: { customer_order: { status: 'pending' } },
525
+ joinMustExist: { orders: { filters: { status: 'pending' } } },
405
526
  paginator: { page: 1, itemsPerPage: 50 },
406
527
  }, request);
407
528
  });
@@ -431,10 +552,13 @@ const sqlApi = createSqlApi(db, dbTables, { dialect: 'mysql' });
431
552
 
432
553
  ```typescript
433
554
  // Search — full filter, join, pagination, aggregation support
434
- // orderBy supports dot notation for joinGroup aggregations: "session.sum.duration DESC"
435
- // (requires the joinGroup to be declared in the same request, see AGENTS_FRONTEND.md)
555
+ // orderBy supports two dotted notations:
556
+ // - 3-parti `<alias>.<fn>.<field>` for joinGroup aggregations (must be declared in the request body)
557
+ // - 2-parti `<alias>.<field>` for joinLeft parent fields
558
+ // See AGENTS_FRONTEND.md and BREAKING_CHANGES.md for the full reference.
436
559
  sqlApi.search(tableName, {
437
- filters?, joinFilters?, joins?, joinGroups?,
560
+ filters?, conditions?,
561
+ joinMustExist?, joinMultiple?, joinGroup?, joinLeft?,
438
562
  orderBy?, paginator?, computeMin?, computeMax?, computeSum?, computeAvg?,
439
563
  }, request?): Promise<SearchResult>
440
564
 
@@ -640,7 +764,7 @@ const TableSession = defineTable({
640
764
  primary: 'id',
641
765
  ...exportTableInfo(SchemaSession),
642
766
  allowedWriteJoins: [
643
- buildRelation(SchemaSession, 'id', SchemaPeriod, 'sessionId'),
767
+ buildRelation(SchemaSession, 'id', SchemaPeriod, 'sessionId', { alias: 'periods' }),
644
768
  ],
645
769
  validate: async (db, req, main, secondaries) => {
646
770
  // ValidationError is a tuple: [field, code] or [field, code, message]
@@ -660,13 +784,13 @@ const TableSession = defineTable({
660
784
  }
661
785
  }
662
786
 
663
- // Cross-entity: validate periods don't overlap
664
- const periods = secondaries?.session_period;
787
+ // Cross-entity: validate periods don't overlap (secondaries keyed by alias)
788
+ const periods = secondaries?.periods;
665
789
  if (periods?.length) {
666
790
  for (let i = 0; i < periods.length; i++) {
667
791
  for (let j = i + 1; j < periods.length; j++) {
668
792
  if (periods[i].startDate < periods[j].endDate && periods[j].startDate < periods[i].endDate) {
669
- errors.push([`session_period[${j}].startDate`, 'overlap', 'overlaps with another period']);
793
+ errors.push([`periods[${j}].startDate`, 'overlap', 'overlaps with another period']);
670
794
  }
671
795
  }
672
796
  }