@omnifyjp/omnify 3.22.1 → 3.23.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@omnifyjp/omnify",
3
- "version": "3.22.1",
3
+ "version": "3.23.1",
4
4
  "description": "Schema-driven code generation for Laravel, TypeScript, and SQL",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -36,10 +36,10 @@
36
36
  "zod": "^3.24.0"
37
37
  },
38
38
  "optionalDependencies": {
39
- "@omnifyjp/omnify-darwin-arm64": "3.22.1",
40
- "@omnifyjp/omnify-darwin-x64": "3.22.1",
41
- "@omnifyjp/omnify-linux-x64": "3.22.1",
42
- "@omnifyjp/omnify-linux-arm64": "3.22.1",
43
- "@omnifyjp/omnify-win32-x64": "3.22.1"
39
+ "@omnifyjp/omnify-darwin-arm64": "3.23.1",
40
+ "@omnifyjp/omnify-darwin-x64": "3.23.1",
41
+ "@omnifyjp/omnify-linux-x64": "3.23.1",
42
+ "@omnifyjp/omnify-linux-arm64": "3.23.1",
43
+ "@omnifyjp/omnify-win32-x64": "3.23.1"
44
44
  }
45
45
  }
@@ -68,10 +68,12 @@ export function generatePhp(data, overrides) {
68
68
  // we add the standalone Common / Info / Schemas files.
69
69
  files.push(...generateOpenApi(reader, config));
70
70
  }
71
- // Service layer (schemas with options.api OR options.service). Issue #57.
72
- if (reader.hasApiSchemas() || reader.hasServiceSchemas()) {
73
- files.push(...generateServices(reader, config));
74
- }
71
+ // Service layer. #81 (v3.23.0): service generation is now opt-out by
72
+ // default every project `kind: object` schema gets a base service
73
+ // unless explicitly opted out via `options.service: false`. The old
74
+ // opt-in gate here is gone because `generateServices` does its own
75
+ // filter internally; always call it and let it decide.
76
+ files.push(...generateServices(reader, config));
75
77
  // Issue #34: every `kind: enum` schema becomes a global PHP enum class.
76
78
  files.push(...generateEnums(reader, config));
77
79
  // Astrotomic translatable config (only when at least one schema has
@@ -1,11 +1,29 @@
1
1
  /**
2
- * Generates base + editable service classes for schemas with `options.api`
3
- * or `options.service`.
2
+ * Generates base + editable service classes.
4
3
  *
5
4
  * Issue #57: Full BaseService codegen with translatable, softDelete, audit,
6
5
  * DB::transaction, eagerLoad/eagerCount, lookupFields, defaultSort.
6
+ *
7
+ * Issue #81 (v3.23.0): convention-over-configuration flip. Services are now
8
+ * generated by default for every project `kind: object` schema. Opt out with
9
+ * `options.service: false` (pivots, translation sidecars, audit logs).
10
+ * Property-level flags (`searchable`, `filterable`, `sortable`, `lookupable`,
11
+ * `defaultSort: asc|desc`) are the single source of truth — the legacy
12
+ * `options.service.{searchable,filterable,defaultSort,lookupFields,eagerLoad,
13
+ * eagerCount}` keys are deprecated and emit warnings at generate time.
7
14
  */
8
15
  import { SchemaReader } from './schema-reader.js';
9
16
  import type { GeneratedFile, PhpConfig } from './types.js';
10
- /** Generate service classes for all schemas with api or service config. */
17
+ /**
18
+ * Generate service classes for every project object schema unless explicitly
19
+ * opted out via `options.service: false`. Also generates for schemas that
20
+ * still use the legacy `options.api` block (unchanged from v3.22.x).
21
+ *
22
+ * Skipped automatically:
23
+ * - kind != object (pivot, partial, enum, extend)
24
+ * - options.service === false (explicit opt-out)
25
+ * - schemas whose name matches a translation sidecar pattern
26
+ * (`*Translation` auto-generated by the translation-model-generator)
27
+ * - package-owned schemas (those get services in their owning package)
28
+ */
11
29
  export declare function generateServices(reader: SchemaReader, config: PhpConfig): GeneratedFile[];
@@ -1,28 +1,167 @@
1
1
  /**
2
- * Generates base + editable service classes for schemas with `options.api`
3
- * or `options.service`.
2
+ * Generates base + editable service classes.
4
3
  *
5
4
  * Issue #57: Full BaseService codegen with translatable, softDelete, audit,
6
5
  * DB::transaction, eagerLoad/eagerCount, lookupFields, defaultSort.
6
+ *
7
+ * Issue #81 (v3.23.0): convention-over-configuration flip. Services are now
8
+ * generated by default for every project `kind: object` schema. Opt out with
9
+ * `options.service: false` (pivots, translation sidecars, audit logs).
10
+ * Property-level flags (`searchable`, `filterable`, `sortable`, `lookupable`,
11
+ * `defaultSort: asc|desc`) are the single source of truth — the legacy
12
+ * `options.service.{searchable,filterable,defaultSort,lookupFields,eagerLoad,
13
+ * eagerCount}` keys are deprecated and emit warnings at generate time.
7
14
  */
8
15
  import { toPascalCase, toSnakeCase } from './naming-helper.js';
9
16
  import { baseFile, userFile, resolveModularBasePath, resolveModularBaseNamespace } from './types.js';
10
17
  // ============================================================================
11
18
  // Public entry point
12
19
  // ============================================================================
13
- /** Generate service classes for all schemas with api or service config. */
20
+ /**
21
+ * Generate service classes for every project object schema unless explicitly
22
+ * opted out via `options.service: false`. Also generates for schemas that
23
+ * still use the legacy `options.api` block (unchanged from v3.22.x).
24
+ *
25
+ * Skipped automatically:
26
+ * - kind != object (pivot, partial, enum, extend)
27
+ * - options.service === false (explicit opt-out)
28
+ * - schemas whose name matches a translation sidecar pattern
29
+ * (`*Translation` auto-generated by the translation-model-generator)
30
+ * - package-owned schemas (those get services in their owning package)
31
+ */
14
32
  export function generateServices(reader, config) {
15
33
  const files = [];
16
- // Merge schemas with api OR service options (union)
17
- const candidates = {
18
- ...reader.getSchemasWithApi(),
19
- ...reader.getSchemasWithService(),
20
- };
34
+ const candidates = {};
35
+ // #81 + #80 interaction (v3.23.1): iterate every project-owned schema
36
+ // directly instead of going through `getProjectObjectSchemas()`, which
37
+ // filters out KindPartial. Phantom-upstream extends (#80 self-reference
38
+ // fallback — e.g. `kind: extend, target: Product` with no upstream
39
+ // package loaded) are kept as KindPartial so downstream generators
40
+ // skip emitting tables for them, but their SERVICES still belong in
41
+ // the consumer project and must be generated here. Kind=object stays
42
+ // the opt-out default; Kind=partial/extend is included when the user
43
+ // has signalled service intent (legacy options.service block or any
44
+ // property-level service flag).
45
+ const allSchemas = reader.getSchemas();
46
+ for (const [name, schema] of Object.entries(allSchemas)) {
47
+ // Project-owned only — packages emit their own services in the
48
+ // owning package's codegen pass.
49
+ if (schema.package != null)
50
+ continue;
51
+ const kind = schema.kind ?? 'object';
52
+ // Hard exclusions: non-object-ish kinds never get services
53
+ if (kind === 'enum' || kind === 'pivot')
54
+ continue;
55
+ // #81 explicit opt-out: `service: false` on the schema
56
+ if (schema.options?.service === false) {
57
+ continue;
58
+ }
59
+ // Astrotomic translation sidecars — auto-emitted as PHP translation
60
+ // models, not full services. Heuristic by name suffix.
61
+ if (name.endsWith('Translation')) {
62
+ continue;
63
+ }
64
+ // Hidden schemas (options.hidden = true) — intentional internal types
65
+ if (schema.options?.hidden) {
66
+ continue;
67
+ }
68
+ if (kind === 'object') {
69
+ // Phase 2 default: every object schema gets a service
70
+ candidates[name] = schema;
71
+ }
72
+ else if (kind === 'partial' || kind === 'extend') {
73
+ // Phantom-upstream extend (#80 fallback). Only emit a service if the
74
+ // user signalled intent — either a legacy options.service block, or
75
+ // at least one property-level service flag (searchable / filterable /
76
+ // sortable / lookupable / defaultSort). An empty extend with no
77
+ // service config is not a service candidate — it's a stub the user
78
+ // added for property merging elsewhere.
79
+ if (schemaHasServiceIntent(schema)) {
80
+ candidates[name] = schema;
81
+ }
82
+ }
83
+ }
84
+ // Legacy `options.api` schemas — these are already in project set above
85
+ // for most cases, but belt-and-suspenders: include any that slipped
86
+ // through the kind filter.
87
+ for (const [name, schema] of Object.entries(reader.getSchemasWithApi())) {
88
+ if (!candidates[name]) {
89
+ candidates[name] = schema;
90
+ }
91
+ }
92
+ // Emit deprecation warnings once per schema for legacy service-block keys
93
+ // that now duplicate property-level metadata.
94
+ for (const [name, schema] of Object.entries(candidates)) {
95
+ warnLegacyServiceKeys(name, schema);
96
+ }
21
97
  for (const [name, schema] of Object.entries(candidates)) {
22
98
  files.push(...generateForSchema(name, schema, reader, config));
23
99
  }
24
100
  return files;
25
101
  }
102
+ /**
103
+ * #81 + #80 (v3.23.1): detect "user wants a service for this schema" signal
104
+ * on extend/partial schemas. Returns true when either:
105
+ * - the legacy options.service block is present (truthy, not false), OR
106
+ * - at least one property has a service-related flag set.
107
+ *
108
+ * Phantom-upstream extends (#80 self-reference fallback) stay as KindPartial
109
+ * for migration purposes, but the consumer project still wants a service
110
+ * layer for calling into the externally-owned table. This helper tells the
111
+ * candidate selector to include those.
112
+ */
113
+ function schemaHasServiceIntent(schema) {
114
+ const svc = schema.options?.service;
115
+ if (svc && typeof svc === 'object')
116
+ return true;
117
+ const properties = schema.properties ?? {};
118
+ for (const prop of Object.values(properties)) {
119
+ if (!prop)
120
+ continue;
121
+ if (prop.searchable || prop.filterable || prop.sortable)
122
+ return true;
123
+ if (prop.lookupable)
124
+ return true;
125
+ if (prop.defaultSort)
126
+ return true;
127
+ }
128
+ return false;
129
+ }
130
+ /**
131
+ * Print a deprecation warning (once per schema) for every legacy
132
+ * `options.service.*` key that duplicates property-level metadata.
133
+ * Each warning points at the property-level equivalent so the migration
134
+ * path is self-documenting.
135
+ */
136
+ function warnLegacyServiceKeys(name, schema) {
137
+ const svc = schema.options?.service;
138
+ if (!svc || typeof svc !== 'object')
139
+ return;
140
+ const deprecations = [];
141
+ if (svc.searchable && svc.searchable.length > 0) {
142
+ deprecations.push({ key: 'searchable', replacement: "property-level `searchable: true` on each field" });
143
+ }
144
+ if (svc.filterable && svc.filterable.length > 0) {
145
+ deprecations.push({ key: 'filterable', replacement: "property-level `filterable: true` on each field" });
146
+ }
147
+ if (svc.defaultSort !== undefined) {
148
+ deprecations.push({ key: 'defaultSort', replacement: "property-level `defaultSort: asc|desc` on exactly one field" });
149
+ }
150
+ if (svc.lookupFields && svc.lookupFields.length > 0) {
151
+ deprecations.push({ key: 'lookupFields', replacement: "property-level `lookupable: true` on each field" });
152
+ }
153
+ if (svc.eagerLoad && svc.eagerLoad.length > 0) {
154
+ deprecations.push({ key: 'eagerLoad', replacement: "override `applyListEagerLoads()` / `applyFindByIdEagerLoads()` / `applyLookupEagerLoads()` in the editable service" });
155
+ }
156
+ if (svc.eagerCount && svc.eagerCount.length > 0) {
157
+ deprecations.push({ key: 'eagerCount', replacement: "override `applyListEagerLoads()` in the editable service and call `$query->withCount(...)`" });
158
+ }
159
+ for (const d of deprecations) {
160
+ console.warn(`[omnify-ts] ${name}.options.service.${d.key} is deprecated (#81) — ` +
161
+ `use ${d.replacement} instead. The legacy key still works for this ` +
162
+ `release but will be removed in a future major version.`);
163
+ }
164
+ }
26
165
  function generateForSchema(name, schema, reader, config) {
27
166
  return [
28
167
  generateBaseService(name, schema, reader, config),
@@ -40,7 +179,11 @@ function resolveFieldLists(schema, reader, name) {
40
179
  const properties = schema.properties ?? {};
41
180
  const propertyOrder = reader.getPropertyOrder(name);
42
181
  const expandedProperties = reader.getExpandedProperties(name);
43
- const svc = schema.options?.service;
182
+ // #81: options.service can now be `false` (explicit opt-out). Narrow to
183
+ // an object here so every downstream read works the same on both shapes;
184
+ // the opt-out is filtered before this function is reached.
185
+ const svcRaw = schema.options?.service;
186
+ const svc = (svcRaw && typeof svcRaw === 'object') ? svcRaw : {};
44
187
  const searchableFields = [];
45
188
  const filterableFields = [];
46
189
  const sortableFields = [];
@@ -173,6 +316,72 @@ function reconstructRelationTokens(items) {
173
316
  result.push(current);
174
317
  return result;
175
318
  }
319
+ /**
320
+ * #81: resolve the lookup() projection from property-level `lookupable: true`
321
+ * flags. Returns snake_case column names. Always includes `id` at the head
322
+ * of the list because the caller always wants the primary key. Falls back
323
+ * to ['id', 'name', 'slug'] (filtered to actually-present columns) when no
324
+ * property is marked — matches the v3.22.x default for backward compat.
325
+ */
326
+ function resolveLookupFields(schema) {
327
+ const properties = schema.properties ?? {};
328
+ const order = schema.propertyOrder ?? Object.keys(properties);
329
+ const explicit = [];
330
+ for (const propName of order) {
331
+ const prop = properties[propName];
332
+ if (!prop)
333
+ continue;
334
+ if (prop.lookupable) {
335
+ explicit.push(toSnakeCase(propName));
336
+ }
337
+ }
338
+ if (explicit.length > 0) {
339
+ // Always prefix with `id` so the caller can key the returned rows.
340
+ return explicit.includes('id') ? explicit : ['id', ...explicit];
341
+ }
342
+ // Default: id + name + slug if those columns exist on the schema
343
+ const defaults = ['id'];
344
+ if (properties['name'])
345
+ defaults.push('name');
346
+ if (properties['slug'])
347
+ defaults.push('slug');
348
+ return defaults;
349
+ }
350
+ /**
351
+ * #81: resolve the default sort from a property-level `defaultSort: asc|desc`
352
+ * flag. At most one property per schema should carry this; if multiple are
353
+ * present, the first one wins and a warning is printed. Falls back to the
354
+ * legacy `svc.defaultSort` string (deprecation warning fired elsewhere)
355
+ * then to `-created_at`.
356
+ */
357
+ function resolveDefaultSort(schema, svc) {
358
+ const properties = schema.properties ?? {};
359
+ const order = schema.propertyOrder ?? Object.keys(properties);
360
+ let picked = null;
361
+ const collisions = [];
362
+ for (const propName of order) {
363
+ const prop = properties[propName];
364
+ if (!prop)
365
+ continue;
366
+ const dir = prop.defaultSort;
367
+ if (dir !== 'asc' && dir !== 'desc')
368
+ continue;
369
+ if (picked) {
370
+ collisions.push(propName);
371
+ continue;
372
+ }
373
+ picked = { colName: toSnakeCase(propName), direction: dir };
374
+ }
375
+ if (collisions.length > 0 && picked) {
376
+ console.warn(`[omnify-ts] ${schema.name}: multiple properties declare defaultSort ` +
377
+ `(${collisions.join(', ')}); using '${picked.colName}' and ignoring ` +
378
+ `the rest. Remove the extras to silence this warning.`);
379
+ }
380
+ if (picked) {
381
+ return picked.direction === 'desc' ? `-${picked.colName}` : picked.colName;
382
+ }
383
+ return svc.defaultSort ?? '-created_at';
384
+ }
176
385
  /** Collect properties that have non-null defaults (for create() method). */
177
386
  function resolveDefaults(schema, reader, name) {
178
387
  const properties = schema.properties ?? {};
@@ -206,11 +415,23 @@ function generateBaseService(name, schema, reader, config) {
206
415
  const modelName = toPascalCase(name);
207
416
  const options = schema.options ?? {};
208
417
  const api = options.api ?? {};
209
- const svc = options.service ?? {};
418
+ // #81: options.service can be `false` for explicit opt-out — narrow to
419
+ // a plain object so downstream reads of svc.* are always safe. The
420
+ // opt-out filter in generateServices() means we never reach this path
421
+ // for schemas that set `service: false`.
422
+ const svcRaw = options.service;
423
+ const svc = (svcRaw && typeof svcRaw === 'object') ? svcRaw : {};
210
424
  const hasSoftDelete = options.softDelete ?? false;
211
425
  const perPage = api.perPage ?? 25;
212
- const hasApiConfig = options.api != null;
213
- const lookup = api.lookup ?? (hasApiConfig ? true : (svc.lookupFields != null && svc.lookupFields.length > 0));
426
+ // #81: lookup fields come from property-level `lookupable: true`.
427
+ // Legacy `options.service.lookupFields` still honoured (deprecation warning
428
+ // fired earlier). Always emit lookup() — it's a free method on every
429
+ // generated service under the opt-out default.
430
+ const lookupFieldsFromProps = resolveLookupFields(schema);
431
+ const lookupFields = (svc.lookupFields && svc.lookupFields.length > 0)
432
+ ? [...svc.lookupFields]
433
+ : lookupFieldsFromProps;
434
+ const lookup = lookupFields.length > 0;
214
435
  // Audit detection — schema-level then global
215
436
  const schemaAudit = options.audit;
216
437
  const globalAudit = reader.getAuditConfig();
@@ -228,13 +449,14 @@ function generateBaseService(name, schema, reader, config) {
228
449
  fkColumn: `${modelSnake}_id`,
229
450
  translatableFields,
230
451
  };
231
- // Eager load / count (#75.1: rejoin YAML-split `rel:col,col` tokens)
452
+ // Eager load / count legacy YAML-declared (deprecated in #81, still
453
+ // honoured during the deprecation cycle). New code should override
454
+ // applyListEagerLoads() / applyFindByIdEagerLoads() / applyLookupEagerLoads()
455
+ // in the editable service.
232
456
  const eagerLoad = reconstructRelationTokens(svc.eagerLoad ?? []);
233
457
  const eagerCount = svc.eagerCount ?? [];
234
- // Default sort
235
- const defaultSort = svc.defaultSort ?? '-created_at';
236
- // Lookup fields
237
- const lookupFields = svc.lookupFields ?? ['id', 'name'];
458
+ // Default sort — #81: property-level `defaultSort: asc|desc` takes priority.
459
+ const defaultSort = resolveDefaultSort(schema, svc);
238
460
  // Field lists
239
461
  const { searchableFields, filterableFields, sortableFields } = resolveFieldLists(schema, reader, name);
240
462
  // Property defaults
@@ -293,6 +515,10 @@ function generateBaseService(name, schema, reader, config) {
293
515
  sections.push(buildSectionComment('Lookup'));
294
516
  sections.push(buildLookupMethod(modelName, lookupFields, filterableFields, defaultSort, translationCtx, reader, hasSoftDelete));
295
517
  }
518
+ // ── Eager Load Hooks (#81) ────────────────────────────────────────────────
519
+ sections.push('');
520
+ sections.push(buildSectionComment('Eager Load Hooks (#81)'));
521
+ sections.push(buildEagerLoadHooks());
296
522
  // ── Translatable helpers ──────────────────────────────────────────────────
297
523
  if (hasTranslatable) {
298
524
  sections.push('');
@@ -340,6 +566,63 @@ function buildSectionComment(title) {
340
566
  // ${title}
341
567
  // =========================================================================`;
342
568
  }
569
+ /**
570
+ * #81: empty protected hook methods that every read path calls after
571
+ * building its query. Consumers override these in the editable sibling
572
+ * service to apply per-method eager loads, keeping eager loading as a
573
+ * query-time concern (as every other ORM in the ecosystem does) instead
574
+ * of a schema-level one.
575
+ *
576
+ * The legacy `options.service.eagerLoad` / `eagerCount` YAML config is
577
+ * still honoured in v3.23.x — its emitted `->with(...)->withCount(...)`
578
+ * chain runs BEFORE the hook, so overrides compose additively. The YAML
579
+ * config will be removed in a future major release per the #81
580
+ * deprecation path.
581
+ */
582
+ function buildEagerLoadHooks() {
583
+ return `
584
+ /**
585
+ * Override in the editable sibling service to eager-load relations
586
+ * on the list() query. Default: no-op. Legacy schema-level eagerLoad
587
+ * configuration still applies before this hook runs.
588
+ *
589
+ * @param \\Illuminate\\Database\\Eloquent\\Builder $query
590
+ */
591
+ protected function applyListEagerLoads($query): void
592
+ {
593
+ // Override in your editable service:
594
+ // $query->with(['author', 'category'])->withCount(['comments']);
595
+ }
596
+
597
+ /**
598
+ * Override in the editable sibling service to eager-load relations on
599
+ * the findById() query. Default: no-op.
600
+ *
601
+ * @param \\Illuminate\\Database\\Eloquent\\Builder $query
602
+ */
603
+ protected function applyFindByIdEagerLoads($query): void
604
+ {
605
+ // Override in your editable service for detail-page eager loads:
606
+ // $query->with([
607
+ // 'author:id,name',
608
+ // 'category',
609
+ // 'comments.author',
610
+ // ]);
611
+ }
612
+
613
+ /**
614
+ * Override in the editable sibling service to eager-load relations on
615
+ * the lookup() query. Default: no-op. Most lookup endpoints should
616
+ * stay lean — eager loading defeats the point of a dropdown projection.
617
+ *
618
+ * @param \\Illuminate\\Database\\Eloquent\\Builder $query
619
+ */
620
+ protected function applyLookupEagerLoads($query): void
621
+ {
622
+ // Override only when the lookup result needs related data that
623
+ // can't come from the sidecar translation table.
624
+ }`;
625
+ }
343
626
  // ============================================================================
344
627
  // #77 follow-up: strict @param array-shape + @example block derivation
345
628
  // ============================================================================
@@ -733,7 +1016,7 @@ ${listExample}
733
1016
  public function list(array $filters = []): LengthAwarePaginator
734
1017
  {
735
1018
  $query = $this->model::query()`);
736
- // Eager load
1019
+ // Eager load (legacy YAML-configured, deprecated #81)
737
1020
  const eagerChain = buildEagerLoadChain(eagerLoad, eagerCount);
738
1021
  if (eagerChain) {
739
1022
  lines.push(`${eagerChain};`);
@@ -742,6 +1025,11 @@ ${listExample}
742
1025
  // Close the query chain
743
1026
  lines[lines.length - 1] += ';';
744
1027
  }
1028
+ // #81: user-overridable eager load hook. Runs after the legacy chain so
1029
+ // subclass overrides compose additively on top of whatever YAML declared.
1030
+ lines.push('');
1031
+ lines.push(' // -- Eager loads via #81 hook (override applyListEagerLoads in sibling service) --');
1032
+ lines.push(' $this->applyListEagerLoads($query);');
745
1033
  // Filterable
746
1034
  if (filterableFields.length > 0) {
747
1035
  lines.push('');
@@ -888,7 +1176,9 @@ function buildSortSection(_sortableFields, defaultSort, allowedSortColumns, tx)
888
1176
  // ── findById() ──────────────────────────────────────────────────────────────
889
1177
  function buildFindByIdMethod(modelName, eagerLoad, eagerCount, hasSoftDelete) {
890
1178
  const eagerChain = buildEagerLoadChain(eagerLoad, eagerCount);
891
- const chain = eagerChain ? `\n${eagerChain}\n ` : '';
1179
+ // Preserve a leading newline + the chain, but NO trailing whitespace so
1180
+ // the semicolon lands cleanly in `$query = ...${chain};`
1181
+ const chain = eagerChain ? `\n${eagerChain}` : '';
892
1182
  if (!hasSoftDelete) {
893
1183
  return `
894
1184
  /**
@@ -902,7 +1192,10 @@ function buildFindByIdMethod(modelName, eagerLoad, eagerCount, hasSoftDelete) {
902
1192
  */
903
1193
  public function findById(string $id): ${modelName}
904
1194
  {
905
- return $this->model::query()${chain}->findOrFail($id);
1195
+ $query = $this->model::query()${chain};
1196
+ $this->applyFindByIdEagerLoads($query);
1197
+
1198
+ return $query->findOrFail($id);
906
1199
  }`;
907
1200
  }
908
1201
  // #79: soft-deletable schemas accept an opt-in `$withTrashed` flag so admin
@@ -1248,6 +1541,10 @@ ${lookupExample}
1248
1541
  ->select([${selectFields}])
1249
1542
  ->orderBy('${sortCol}', '${sortDir}');
1250
1543
 
1544
+ // #81: override applyLookupEagerLoads in sibling service to add any
1545
+ // relations that the dropdown projection needs (keep it lean!).
1546
+ $this->applyLookupEagerLoads($query);
1547
+
1251
1548
  ${filterBlock}${softDeleteBlock}${limitBlock}
1252
1549
 
1253
1550
  // #76 followup (v3.21.2): use getAttributes() to bypass Astrotomic's
@@ -27,7 +27,12 @@ export function generateTsServices(schemas, options) {
27
27
  // Per-schema generator
28
28
  // ---------------------------------------------------------------------------
29
29
  function generateServiceFile(name, schema, options) {
30
- const svc = schema.options?.service ?? {};
30
+ // #81: options.service can be `false` for explicit opt-out. Treat `false`
31
+ // the same as an empty service block for TS codegen purposes — the Go
32
+ // side already filtered this path when emitting the PHP base, but the
33
+ // TS side runs independently and needs its own narrowing.
34
+ const svcRaw = schema.options?.service;
35
+ const svc = (svcRaw && typeof svcRaw === 'object') ? svcRaw : {};
31
36
  const api = schema.options?.api;
32
37
  const hasSoftDelete = schema.options?.softDelete ?? false;
33
38
  const hasRestore = api?.restore ?? hasSoftDelete;
@@ -117,13 +117,32 @@ export interface ApiOptions {
117
117
  readonly middleware?: readonly string[];
118
118
  readonly perPage?: number;
119
119
  }
120
- /** Service layer codegen options — controls BaseService generation. Issue #57. */
120
+ /**
121
+ * Service layer codegen options — controls BaseService generation. Issue #57.
122
+ *
123
+ * #81 (v3.23.0): every field in this interface is DEPRECATED. Use the
124
+ * property-level flags instead (searchable / filterable / sortable /
125
+ * lookupable / defaultSort), which are the single source of truth. This
126
+ * interface is kept for backward compatibility and emits deprecation
127
+ * warnings from the generator when any legacy key is set.
128
+ *
129
+ * The only keys that will survive the deprecation cycle are legitimate
130
+ * service-level overrides that property-level can't express (pagination
131
+ * strategy, per_page default, etc.) — those will be added in a future
132
+ * release when the deprecations clear.
133
+ */
121
134
  export interface ServiceOptions {
135
+ /** @deprecated #81 — use property-level `searchable: true` instead */
122
136
  readonly searchable?: readonly string[];
137
+ /** @deprecated #81 — use property-level `filterable: true` instead */
123
138
  readonly filterable?: readonly string[];
139
+ /** @deprecated #81 — use property-level `defaultSort: 'asc'|'desc'` on exactly one field */
124
140
  readonly defaultSort?: string;
141
+ /** @deprecated #81 — override applyListEagerLoads/applyFindByIdEagerLoads/applyLookupEagerLoads in the editable service instead */
125
142
  readonly eagerLoad?: readonly string[];
143
+ /** @deprecated #81 — override applyListEagerLoads hook in the editable service instead */
126
144
  readonly eagerCount?: readonly string[];
145
+ /** @deprecated #81 — use property-level `lookupable: true` instead */
127
146
  readonly lookupFields?: readonly string[];
128
147
  }
129
148
  /** Schema options. */
@@ -142,8 +161,18 @@ export interface SchemaOptions {
142
161
  readonly updatedBy?: boolean;
143
162
  readonly deletedBy?: boolean;
144
163
  };
145
- /** Service layer codegen options. Issue #57. */
146
- readonly service?: ServiceOptions;
164
+ /**
165
+ * Service layer codegen options (issue #57).
166
+ *
167
+ * #81 (v3.23.0): accepts `false` as an explicit opt-out — every `kind:
168
+ * object` project schema gets a generated base service by default now.
169
+ * Set `service: false` on pivot tables / sidecars / audit logs that
170
+ * shouldn't have a service. Set to an object only for legitimate
171
+ * service-level overrides that property-level flags can't express
172
+ * (legacy property-duplication keys emit deprecation warnings at
173
+ * generate time).
174
+ */
175
+ readonly service?: ServiceOptions | false;
147
176
  /** Schema-level default ordering — generates a global Eloquent scope. Issue #40. */
148
177
  readonly defaultOrder?: readonly OrderByItem[];
149
178
  }
@@ -264,6 +293,21 @@ export interface PropertyDefinition {
264
293
  readonly searchable?: boolean;
265
294
  readonly filterable?: boolean;
266
295
  readonly sortable?: boolean;
296
+ /**
297
+ * #81: mark a property as part of the lookup() projection (id/label/slug
298
+ * shape for type-ahead / dropdown endpoints). When at least one property
299
+ * on a schema has `lookupable: true`, the generator uses that set instead
300
+ * of the legacy `options.service.lookupFields` array.
301
+ */
302
+ readonly lookupable?: boolean;
303
+ /**
304
+ * #81: declare this property as the schema's default sort column. At most
305
+ * one property per schema should set this. The value is the direction:
306
+ * `asc` emits `defaultSort: <col>`, `desc` emits `-<col>`. Legacy
307
+ * `options.service.defaultSort` is still honoured with a deprecation
308
+ * warning.
309
+ */
310
+ readonly defaultSort?: 'asc' | 'desc';
267
311
  readonly fields?: Record<string, FieldOverride>;
268
312
  }
269
313
  /** Single column ordering. Direction defaults to 'asc'. Issue #40. */