@supalive/codegen 1.2.1 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { n as generateToDisk, r as readEntryConfig } from "./src-DIj2CWMc.js";
2
+ import { n as generateToDisk, r as readEntryConfig } from "./src-O2U-Keyx.js";
3
3
  import path from "node:path";
4
4
  //#region src/cli.ts
5
5
  function parseArgs(argv) {
@@ -35,6 +35,9 @@ function parseArgs(argv) {
35
35
  case "--include-internal":
36
36
  args.includeInternal = true;
37
37
  break;
38
+ case "--schema-module":
39
+ args.schemaModule = argv[++i];
40
+ break;
38
41
  default:
39
42
  if (a.startsWith("-")) throw new Error(`Unknown option: ${a}`);
40
43
  if (args.entry) throw new Error(`Unexpected extra argument: ${a}`);
@@ -55,6 +58,8 @@ Options:
55
58
  --config <name> Config object export name (default: dartCodegen)
56
59
  --tsconfig <path> tsconfig.json used to resolve types
57
60
  --include-internal Also generate internal (server-only) procedures
61
+ --schema-module <path> Module that registers defineSchema tables, for
62
+ schema-aware enum names (relative to the entry)
58
63
  -h, --help Show this help
59
64
 
60
65
  The entry file may export a \`dartCodegen\` object with any of the above
@@ -68,14 +73,16 @@ async function main() {
68
73
  const fromEntry = readEntryConfig(args.entry, args.configExport, args.tsconfig);
69
74
  const entryDir = path.dirname(path.resolve(args.entry));
70
75
  const output = args.output ?? (fromEntry.output ? path.resolve(entryDir, fromEntry.output) : void 0);
71
- const { files, config } = generateToDisk({
76
+ const schemaModule = args.schemaModule ? path.resolve(args.schemaModule) : fromEntry.schemaModule ? path.resolve(entryDir, fromEntry.schemaModule) : void 0;
77
+ const { files, config } = await generateToDisk({
72
78
  entry: args.entry,
73
79
  output,
74
80
  router: args.router ?? fromEntry.router,
75
81
  clientClassName: args.clientClass ?? fromEntry.clientClassName,
76
82
  includeInternal: args.includeInternal ?? fromEntry.includeInternal,
77
83
  tsconfig: args.tsconfig ?? fromEntry.tsconfig,
78
- numericOverrides: fromEntry.numericOverrides
84
+ numericOverrides: fromEntry.numericOverrides,
85
+ schemaModule
79
86
  });
80
87
  console.log(`Generated ${files.length} file(s) into ${config.output}:\n` + files.map((f) => ` - ${f.path}`).join("\n"));
81
88
  }
package/dist/index.d.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import { SchemaDefinition } from "@supalive/core/client";
2
+
1
3
  //#region src/config.d.ts
2
4
  interface SupaliveDartConfig {
3
5
  /** Output directory for generated Dart files. */
@@ -15,11 +17,21 @@ interface SupaliveDartConfig {
15
17
  * distinction (outputs). Keyed by `ModelName.fieldName`.
16
18
  */
17
19
  numericOverrides?: Record<string, "int" | "double" | "bigint">;
20
+ /**
21
+ * Path to a module that, when imported, registers every `defineSchema` table
22
+ * (typically the schema barrel, e.g. `"../schema/index.ts"`). Resolved
23
+ * relative to this entry file's directory. When set, the generator
24
+ * runtime-imports it (with schema tracking on) and names string-literal enums
25
+ * after their originating table+column (`StaffStatus`, `PosDeviceStatus`)
26
+ * instead of structurally. Best-effort: import failures degrade gracefully.
27
+ */
28
+ schemaModule?: string;
18
29
  }
19
- interface ResolvedConfig extends Required<Omit<SupaliveDartConfig, "tsconfig" | "numericOverrides">> {
30
+ interface ResolvedConfig extends Required<Omit<SupaliveDartConfig, "tsconfig" | "numericOverrides" | "schemaModule">> {
20
31
  entry: string;
21
32
  tsconfig?: string;
22
33
  numericOverrides: Record<string, "int" | "double" | "bigint">;
34
+ schemaModule?: string;
23
35
  }
24
36
  //#endregion
25
37
  //#region src/ir.d.ts
@@ -107,9 +119,48 @@ interface EmittedFile {
107
119
  }
108
120
  declare function emit(ir: ClientIR): EmittedFile[];
109
121
  //#endregion
122
+ //#region src/schema-enums.d.ts
123
+ /** One enum-typed column discovered in a tracked schema. */
124
+ interface SchemaEnumEntry {
125
+ /** SQL table name, e.g. "pos_devices". */
126
+ table: string;
127
+ /** PascalCase singular table stem, e.g. "PosDevice". */
128
+ tableStem: string;
129
+ /** camelCase column/field key, e.g. "status". */
130
+ field: string;
131
+ /** Canonical Dart enum name, e.g. "PosDeviceStatus". */
132
+ enumName: string;
133
+ /** Values in schema (canonical) order. */
134
+ values: string[];
135
+ /** Order-independent value-set signature, for matching. */
136
+ setSig: string;
137
+ }
138
+ declare class SchemaEnumRegistry {
139
+ private readonly byField;
140
+ private constructor();
141
+ static empty(): SchemaEnumRegistry;
142
+ get size(): number;
143
+ /**
144
+ * Resolve a string-literal union to a schema enum, or `undefined` when there
145
+ * is no confident match. Requires the field name and the exact value set to
146
+ * agree; when several tables share both (e.g. `status: active|disabled` on
147
+ * staff/tenants/stores/pos_devices) the result model's `stem` disambiguates.
148
+ */
149
+ match(field: string, values: string[], stem: string): SchemaEnumEntry | undefined;
150
+ static fromRegistry(registry: Map<string, SchemaDefinition<string, any, any>>): SchemaEnumRegistry;
151
+ }
152
+ /**
153
+ * Runtime-import the configured `schemaModule` with schema tracking enabled and
154
+ * build a {@link SchemaEnumRegistry}. Best-effort: any failure (no module
155
+ * configured, no `@supalive/core`, no TypeScript loader for a `.ts` module)
156
+ * downgrades to an empty registry so generation still succeeds with structural
157
+ * names.
158
+ */
159
+ declare function loadSchemaEnums(config: ResolvedConfig): Promise<SchemaEnumRegistry>;
160
+ //#endregion
110
161
  //#region src/extract.d.ts
111
162
  /** Load the router from the entry file and extract a ClientIR. */
112
- declare function extractClient(config: ResolvedConfig): ClientIR;
163
+ declare function extractClient(config: ResolvedConfig, schemaEnums?: SchemaEnumRegistry): ClientIR;
113
164
  //#endregion
114
165
  //#region src/index.d.ts
115
166
  interface GenerateOptions extends SupaliveDartConfig {
@@ -122,10 +173,14 @@ interface GenerateResult {
122
173
  }
123
174
  /** Resolve a partial config against defaults, anchoring relative paths. */
124
175
  declare function resolveConfig(opts: GenerateOptions): ResolvedConfig;
125
- /** Extract + emit. Does not touch the filesystem. */
126
- declare function generate(opts: GenerateOptions): GenerateResult;
176
+ /**
177
+ * Extract + emit. Does not touch the filesystem, and stays synchronous. Pass a
178
+ * pre-loaded {@link SchemaEnumRegistry} to get schema-aware enum names; omit it
179
+ * (the default) for purely structural naming.
180
+ */
181
+ declare function generate(opts: GenerateOptions, schemaEnums?: SchemaEnumRegistry): GenerateResult;
127
182
  /** Extract + emit + write the files to `config.output`. */
128
- declare function generateToDisk(opts: GenerateOptions): GenerateResult;
183
+ declare function generateToDisk(opts: GenerateOptions): Promise<GenerateResult>;
129
184
  /**
130
185
  * Best-effort static read of a config object exported from the entry file,
131
186
  * so users can keep `router` + codegen options next to the router itself:
@@ -137,4 +192,4 @@ declare function generateToDisk(opts: GenerateOptions): GenerateResult;
137
192
  */
138
193
  declare function readEntryConfig(entry: string, exportName?: string, tsconfig?: string): SupaliveDartConfig;
139
194
  //#endregion
140
- export { ClientIR, DartType, type EmittedFile, EnumIR, EnumValueIR, FieldIR, GenerateOptions, GenerateResult, ModelIR, ProcedureIR, ProcedureKind, type ResolvedConfig, type SupaliveDartConfig, emit, extractClient, generate, generateToDisk, readEntryConfig, resolveConfig };
195
+ export { ClientIR, DartType, type EmittedFile, EnumIR, EnumValueIR, FieldIR, GenerateOptions, GenerateResult, ModelIR, ProcedureIR, ProcedureKind, type ResolvedConfig, SchemaEnumRegistry, type SupaliveDartConfig, emit, extractClient, generate, generateToDisk, loadSchemaEnums, readEntryConfig, resolveConfig };
package/dist/index.js CHANGED
@@ -1,2 +1,2 @@
1
- import { a as emit, i as resolveConfig, n as generateToDisk, o as extractClient, r as readEntryConfig, t as generate } from "./src-DIj2CWMc.js";
2
- export { emit, extractClient, generate, generateToDisk, readEntryConfig, resolveConfig };
1
+ import { a as emit, c as loadSchemaEnums, i as resolveConfig, n as generateToDisk, o as extractClient, r as readEntryConfig, s as SchemaEnumRegistry, t as generate } from "./src-O2U-Keyx.js";
2
+ export { SchemaEnumRegistry, emit, extractClient, generate, generateToDisk, loadSchemaEnums, readEntryConfig, resolveConfig };
@@ -1,6 +1,9 @@
1
+ import { createRequire } from "node:module";
1
2
  import fs from "node:fs";
2
3
  import path from "node:path";
3
4
  import { Node, Project, ts } from "ts-morph";
5
+ import { pathToFileURL } from "node:url";
6
+ import { schemaRegistry, trackSchema } from "@supalive/core/procedure";
4
7
  //#region src/zod-schema.ts
5
8
  /**
6
9
  * Map each procedure to the doc comment on its declaration, so the generated
@@ -185,9 +188,177 @@ function findZodObjectLiteral(node) {
185
188
  return cur && Node.isObjectLiteralExpression(cur) ? cur : void 0;
186
189
  }
187
190
  //#endregion
191
+ //#region src/names.ts
192
+ /** PascalCase an arbitrary string (splitting on any non-alphanumeric run). */
193
+ function pascal(s) {
194
+ return s.replace(/[^A-Za-z0-9]+/g, " ").trim().split(/\s+/).map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join("");
195
+ }
196
+ /** camelCase an arbitrary string. */
197
+ function camel(s) {
198
+ const p = pascal(s);
199
+ return p.charAt(0).toLowerCase() + p.slice(1);
200
+ }
201
+ /**
202
+ * Best-effort English singularization of a single trailing noun, used to turn a
203
+ * (usually plural) table name into a clean type stem: `pos_devices → PosDevice`,
204
+ * `settlement_entries → SettlementEntry`, `card_batches → CardBatch`. Leaves
205
+ * words that don't look plural untouched (`staff`, `status`).
206
+ */
207
+ function singularizeWord(w) {
208
+ if (w.length > 4 && /(?:ch|sh|ss|x|s)es$/i.test(w)) return w.slice(0, -2);
209
+ if (w.length > 3 && /ies$/i.test(w)) return w.slice(0, -3) + "y";
210
+ if (/ss$/i.test(w)) return w;
211
+ if (w.length > 1 && /s$/i.test(w)) return w.slice(0, -1);
212
+ return w;
213
+ }
214
+ //#endregion
215
+ //#region src/schema-enums.ts
216
+ function setSignature(values) {
217
+ return [...values].sort().join("");
218
+ }
219
+ var SchemaEnumRegistry = class SchemaEnumRegistry {
220
+ byField = /* @__PURE__ */ new Map();
221
+ constructor(entries) {
222
+ for (const e of entries) {
223
+ const arr = this.byField.get(e.field);
224
+ if (arr) arr.push(e);
225
+ else this.byField.set(e.field, [e]);
226
+ }
227
+ }
228
+ static empty() {
229
+ return new SchemaEnumRegistry([]);
230
+ }
231
+ get size() {
232
+ let n = 0;
233
+ for (const arr of this.byField.values()) n += arr.length;
234
+ return n;
235
+ }
236
+ /**
237
+ * Resolve a string-literal union to a schema enum, or `undefined` when there
238
+ * is no confident match. Requires the field name and the exact value set to
239
+ * agree; when several tables share both (e.g. `status: active|disabled` on
240
+ * staff/tenants/stores/pos_devices) the result model's `stem` disambiguates.
241
+ */
242
+ match(field, values, stem) {
243
+ const candidates = this.byField.get(camel(field));
244
+ if (!candidates) return void 0;
245
+ const sig = setSignature(values);
246
+ const matches = candidates.filter((c) => c.setSig === sig);
247
+ if (matches.length === 0) return void 0;
248
+ if (matches.length === 1) return matches[0];
249
+ const pick = (test) => {
250
+ let best;
251
+ for (const m of matches) if (test(m.tableStem) && (!best || m.tableStem.length > best.tableStem.length)) best = m;
252
+ return best;
253
+ };
254
+ return pick((t) => stem.endsWith(t)) ?? pick((t) => stem.includes(t));
255
+ }
256
+ static fromRegistry(registry) {
257
+ const entries = [];
258
+ for (const def of registry.values()) {
259
+ const table = def.table;
260
+ const shape = def.schema.shape;
261
+ if (typeof table !== "string" || !shape || typeof shape !== "object") continue;
262
+ const tableStem = tableStemName(table);
263
+ for (const [fieldKey, zodType] of Object.entries(shape)) {
264
+ const values = readEnumValues(zodType);
265
+ if (!values || values.length === 0) continue;
266
+ entries.push({
267
+ table,
268
+ tableStem,
269
+ field: camel(fieldKey),
270
+ enumName: tableStem + pascal(fieldKey),
271
+ values,
272
+ setSig: setSignature(values)
273
+ });
274
+ }
275
+ }
276
+ return new SchemaEnumRegistry(entries);
277
+ }
278
+ };
279
+ /** "pos_devices" → "PosDevice"; "settlement_entries" → "SettlementEntry". */
280
+ function tableStemName(table) {
281
+ const parts = table.split(/[^A-Za-z0-9]+/).filter(Boolean);
282
+ if (parts.length === 0) return pascal(table);
283
+ const last = parts.length - 1;
284
+ return parts.map((p, i) => i === last ? pascal(singularizeWord(p)) : pascal(p)).join("");
285
+ }
286
+ /** Read a Zod enum's values, unwrapping optional/nullable/default wrappers.
287
+ * Realm-independent: never uses `instanceof` (the schema's Zod may be a
288
+ * different module instance than any the codegen could import). */
289
+ function readEnumValues(field) {
290
+ const e = unwrapToEnum(field);
291
+ if (!e) return void 0;
292
+ try {
293
+ const opts = e.options;
294
+ if (Array.isArray(opts)) return opts.map((v) => String(v));
295
+ } catch {}
296
+ const def = zodDef(e);
297
+ const entries = def?.entries ?? def?.values;
298
+ if (entries && typeof entries === "object") return Object.values(entries).map((v) => String(v));
299
+ }
300
+ function zodDef(node) {
301
+ const n = node;
302
+ return n?._zod?.def ?? n?.def;
303
+ }
304
+ const WRAPPERS = /* @__PURE__ */ new Set([
305
+ "optional",
306
+ "nullable",
307
+ "default",
308
+ "catch",
309
+ "readonly",
310
+ "nonoptional",
311
+ "prefault"
312
+ ]);
313
+ function unwrapToEnum(field) {
314
+ let cur = field;
315
+ for (let guard = 0; cur && guard < 12; guard++) {
316
+ const def = zodDef(cur);
317
+ if (!def) return void 0;
318
+ if (def.type === "enum") return cur;
319
+ if (def.type && WRAPPERS.has(def.type) && def.innerType) {
320
+ cur = def.innerType;
321
+ continue;
322
+ }
323
+ return;
324
+ }
325
+ }
326
+ /**
327
+ * Runtime-import the configured `schemaModule` with schema tracking enabled and
328
+ * build a {@link SchemaEnumRegistry}. Best-effort: any failure (no module
329
+ * configured, no `@supalive/core`, no TypeScript loader for a `.ts` module)
330
+ * downgrades to an empty registry so generation still succeeds with structural
331
+ * names.
332
+ */
333
+ async function loadSchemaEnums(config) {
334
+ if (!config.schemaModule) return SchemaEnumRegistry.empty();
335
+ try {
336
+ const require_ = createRequire(config.entry);
337
+ pathToFileURL(require_.resolve("@supalive/core/schema-sql")).href;
338
+ let unregister;
339
+ try {
340
+ unregister = (await import(pathToFileURL(require_.resolve("tsx/esm/api")).href)).register();
341
+ } catch {}
342
+ try {
343
+ trackSchema();
344
+ await import(pathToFileURL(config.schemaModule).href);
345
+ const registry = schemaRegistry;
346
+ if (!registry || registry.size === 0) return SchemaEnumRegistry.empty();
347
+ return SchemaEnumRegistry.fromRegistry(registry);
348
+ } finally {
349
+ if (unregister) try {
350
+ await unregister();
351
+ } catch {}
352
+ }
353
+ } catch (err) {
354
+ console.warn(`[supalive-codegen] Could not load schemaModule (${config.schemaModule}) for schema-aware enum names; falling back to structural names. ${err instanceof Error ? err.message : String(err)}`);
355
+ return SchemaEnumRegistry.empty();
356
+ }
357
+ }
358
+ //#endregion
188
359
  //#region src/extract.ts
189
360
  /** Load the router from the entry file and extract a ClientIR. */
190
- function extractClient(config) {
361
+ function extractClient(config, schemaEnums = SchemaEnumRegistry.empty()) {
191
362
  const project = new Project({
192
363
  tsConfigFilePath: config.tsconfig,
193
364
  skipAddingFilesFromTsConfig: config.tsconfig ? false : true,
@@ -207,7 +378,7 @@ function extractClient(config) {
207
378
  const proceduresType = proceduresSym.getTypeAtLocation(routerDecl);
208
379
  const argMeta = scanZodArgMeta(routerDecl);
209
380
  const procDocs = scanProcedureDocs(routerDecl);
210
- const extractor = new Extractor(routerDecl, config, argMeta, project.getTypeChecker());
381
+ const extractor = new Extractor(routerDecl, config, argMeta, project.getTypeChecker(), schemaEnums);
211
382
  const procedures = [];
212
383
  for (const procSym of proceduresType.getProperties()) {
213
384
  const name = procSym.getName();
@@ -232,17 +403,20 @@ var Extractor = class {
232
403
  config;
233
404
  argMeta;
234
405
  tc;
406
+ schemaEnums;
235
407
  modelRegistry = /* @__PURE__ */ new Map();
236
- enumRegistry = /* @__PURE__ */ new Map();
237
- /** Value-set signature already-registered enum name, for structural dedup. */
238
- enumBySignature = /* @__PURE__ */ new Map();
408
+ /** Discovered enums, keyed by a dedup key (schema name, or stem∙field∙set). */
409
+ enumGroups = /* @__PURE__ */ new Map();
410
+ finalEnums = [];
411
+ enumsFinalized = false;
239
412
  nameCounts = /* @__PURE__ */ new Map();
240
413
  visiting = /* @__PURE__ */ new Set();
241
- constructor(locationNode, config, argMeta, tc) {
414
+ constructor(locationNode, config, argMeta, tc, schemaEnums) {
242
415
  this.locationNode = locationNode;
243
416
  this.config = config;
244
417
  this.argMeta = argMeta;
245
418
  this.tc = tc;
419
+ this.schemaEnums = schemaEnums;
246
420
  }
247
421
  /** Doc comment attached to a symbol's declaration (resolves TS aliases). */
248
422
  symbolDoc(sym) {
@@ -255,10 +429,12 @@ var Extractor = class {
255
429
  }
256
430
  }
257
431
  models() {
432
+ this.finalizeEnums();
258
433
  return [...this.modelRegistry.values()];
259
434
  }
260
435
  enums() {
261
- return [...this.enumRegistry.values()];
436
+ this.finalizeEnums();
437
+ return [...this.finalEnums].sort((a, b) => a.name.localeCompare(b.name));
262
438
  }
263
439
  procedure(name, procType) {
264
440
  const kind = this.literalString(procType, "procedureType");
@@ -298,7 +474,7 @@ var Extractor = class {
298
474
  walk(type, hint) {
299
475
  const { core } = splitNullish(type);
300
476
  if (core.length === 0) return { kind: "dynamic" };
301
- if (core.length > 1) return this.walkUnion(core, hint);
477
+ if (core.length > 1) return this.walkUnion(core, { hint });
302
478
  return this.walkSingle(core[0], hint);
303
479
  }
304
480
  walkSingle(type, hint) {
@@ -326,7 +502,7 @@ var Extractor = class {
326
502
  if (type.isUnion()) {
327
503
  const { core } = splitNullish(type);
328
504
  if (core.length === 1) return this.walkSingle(core[0], hint);
329
- return this.walkUnion(core, hint);
505
+ return this.walkUnion(core, { hint });
330
506
  }
331
507
  if (type.isObject()) {
332
508
  const props = type.getProperties();
@@ -342,10 +518,10 @@ var Extractor = class {
342
518
  }
343
519
  return { kind: "dynamic" };
344
520
  }
345
- walkUnion(members, hint, enumHint) {
521
+ walkUnion(members, ctx) {
346
522
  if (members.every((m) => m.getFlags() & (ts.TypeFlags.BooleanLiteral | ts.TypeFlags.Boolean))) return { kind: "bool" };
347
- if (members.every((m) => m.getFlags() & ts.TypeFlags.StringLiteral)) return this.registerEnum(enumHint ?? hint, members.map((m) => String(m.getLiteralValue())));
348
- if (members.every((m) => m.isObject())) return this.registerSealed(hint, members);
523
+ if (members.every((m) => m.getFlags() & ts.TypeFlags.StringLiteral)) return this.registerEnum(members.map((m) => String(m.getLiteralValue())), ctx);
524
+ if (members.every((m) => m.isObject())) return this.registerSealed(ctx.hint, members);
349
525
  return { kind: "dynamic" };
350
526
  }
351
527
  walkObject(type, hint) {
@@ -371,7 +547,11 @@ var Extractor = class {
371
547
  let dartType;
372
548
  if (core.length === 0) dartType = { kind: "dynamic" };
373
549
  else if (core.length === 1) dartType = this.walkSingle(core[0], name + pascal(jsonKey));
374
- else dartType = this.walkUnion(core, name + pascal(jsonKey), pascal(jsonKey));
550
+ else dartType = this.walkUnion(core, {
551
+ hint: name + pascal(jsonKey),
552
+ stem: cleanStem(name),
553
+ field: jsonKey
554
+ });
375
555
  const override = this.config.numericOverrides[`${name}.${jsonKey}`];
376
556
  if (override && (dartType.kind === "double" || dartType.kind === "int")) dartType = { kind: override };
377
557
  fields.push({
@@ -416,26 +596,106 @@ var Extractor = class {
416
596
  name
417
597
  };
418
598
  }
419
- registerEnum(hint, values) {
420
- const signature = values.join("\0");
421
- const existing = this.enumBySignature.get(signature);
422
- if (existing) return {
599
+ /**
600
+ * Register a string-literal union as an enum. Two tiers:
601
+ * 1. Schema-backed the field name + value set match a tracked `defineSchema`
602
+ * column, so we use its canonical `<Table><Column>` name and reuse it
603
+ * everywhere that column appears. Values follow the schema's order.
604
+ * 2. Structural fallback — no schema match; named later in `finalizeEnums`
605
+ * from the model stem + field (bare when the field name is globally
606
+ * unique). Deduped per `(stem, field, value-set)`.
607
+ * Returns a shared DartType instance whose `name` is filled in at finalize.
608
+ */
609
+ registerEnum(values, ctx) {
610
+ if (ctx.field) {
611
+ const hit = this.schemaEnums.match(ctx.field, values, ctx.stem ?? "");
612
+ if (hit) {
613
+ const key = `schema${hit.enumName}`;
614
+ const existing = this.enumGroups.get(key);
615
+ if (existing) return existing.type;
616
+ const type = {
617
+ kind: "enum",
618
+ name: hit.enumName
619
+ };
620
+ this.enumGroups.set(key, {
621
+ type,
622
+ values: hit.values,
623
+ schemaName: hit.enumName,
624
+ stem: ctx.stem,
625
+ field: ctx.field,
626
+ hint: ctx.hint
627
+ });
628
+ return type;
629
+ }
630
+ }
631
+ const key = `fallback${ctx.stem ?? ""}${ctx.field ?? ctx.hint}${values.join(" ")}`;
632
+ const existing = this.enumGroups.get(key);
633
+ if (existing) return existing.type;
634
+ const type = {
423
635
  kind: "enum",
424
- name: existing
636
+ name: key
425
637
  };
426
- const name = this.uniqueHint(hint);
427
- this.enumRegistry.set(name, {
638
+ this.enumGroups.set(key, {
639
+ type,
640
+ values,
641
+ stem: ctx.stem,
642
+ field: ctx.field,
643
+ hint: ctx.hint
644
+ });
645
+ return type;
646
+ }
647
+ /**
648
+ * Assign every enum its final Dart name and materialize the `EnumIR` list.
649
+ * Idempotent — mutates the shared DartType instances in place, so every
650
+ * referencing model field / procedure result sees the resolved name.
651
+ */
652
+ finalizeEnums() {
653
+ if (this.enumsFinalized) return;
654
+ this.enumsFinalized = true;
655
+ const used = new Set(this.modelRegistry.keys());
656
+ const groups = [...this.enumGroups.values()];
657
+ const schemaGroups = groups.filter((g) => g.schemaName).sort((a, b) => a.schemaName.localeCompare(b.schemaName));
658
+ for (const g of schemaGroups) {
659
+ const name = this.ensureUnique(g.schemaName, used);
660
+ g.type.name = name;
661
+ this.pushEnumIR(name, g.values);
662
+ }
663
+ const fallbackGroups = groups.filter((g) => !g.schemaName).sort((a, b) => fallbackBase(a).localeCompare(fallbackBase(b)));
664
+ const baseCount = /* @__PURE__ */ new Map();
665
+ for (const g of fallbackGroups) {
666
+ const base = fallbackBase(g);
667
+ baseCount.set(base, (baseCount.get(base) ?? 0) + 1);
668
+ }
669
+ for (const g of fallbackGroups) {
670
+ const base = fallbackBase(g);
671
+ let name;
672
+ if (baseCount.get(base) === 1 && !used.has(base)) name = base;
673
+ else if (g.stem && g.field) name = pascal(g.stem) + pascal(g.field);
674
+ else name = pascal(g.hint);
675
+ name = this.ensureUnique(name, used);
676
+ g.type.name = name;
677
+ this.pushEnumIR(name, g.values);
678
+ }
679
+ }
680
+ ensureUnique(name, used) {
681
+ if (!used.has(name)) {
682
+ used.add(name);
683
+ return name;
684
+ }
685
+ let i = 2;
686
+ while (used.has(`${name}${i}`)) i++;
687
+ const unique = `${name}${i}`;
688
+ used.add(unique);
689
+ return unique;
690
+ }
691
+ pushEnumIR(name, values) {
692
+ this.finalEnums.push({
428
693
  name,
429
694
  values: values.map((wire) => ({
430
695
  dartName: enumValueName(wire),
431
696
  wire
432
697
  }))
433
698
  });
434
- this.enumBySignature.set(signature, name);
435
- return {
436
- kind: "enum",
437
- name
438
- };
439
699
  }
440
700
  registerSealed(hint, members) {
441
701
  const name = this.uniqueHint(hint);
@@ -454,7 +714,11 @@ var Extractor = class {
454
714
  const propType = prop.getTypeAtLocation(this.locationNode);
455
715
  const declaredOptional = (prop.getFlags() & ts.SymbolFlags.Optional) !== 0;
456
716
  const { core, hasNull, hasUndefined } = splitNullish(propType);
457
- const dt = core.length === 0 ? { kind: "dynamic" } : core.length === 1 ? this.walkSingle(core[0], name + pascal(jsonKey)) : this.walkUnion(core, name + pascal(jsonKey), pascal(jsonKey));
717
+ const dt = core.length === 0 ? { kind: "dynamic" } : core.length === 1 ? this.walkSingle(core[0], name + pascal(jsonKey)) : this.walkUnion(core, {
718
+ hint: name + pascal(jsonKey),
719
+ stem: cleanStem(name),
720
+ field: jsonKey
721
+ });
458
722
  const acc = fieldMap.get(jsonKey) ?? {
459
723
  types: [],
460
724
  present: 0,
@@ -543,19 +807,34 @@ function splitNullish(type) {
543
807
  function uniqueName(symName, registry, _type) {
544
808
  return pascal(symName);
545
809
  }
546
- function pascal(s) {
547
- return s.replace(/[^A-Za-z0-9]+/g, " ").trim().split(/\s+/).map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join("");
548
- }
549
- function camel(s) {
550
- const p = pascal(s);
551
- return p.charAt(0).toLowerCase() + p.slice(1);
552
- }
553
810
  function singular(hint) {
554
811
  if (hint.endsWith("ies")) return hint.slice(0, -3) + "y";
555
812
  if (hint.endsWith("s") && !hint.endsWith("ss")) return hint.slice(0, -1);
556
813
  return hint + "Item";
557
814
  }
558
815
  /**
816
+ * A clean type stem for a generated model name, used both to name structural
817
+ * enums and to disambiguate schema matches: strip the trailing generated
818
+ * suffix (`ResultItem`/`Result`/`Args`) and de-pluralize, so
819
+ * `PosDevicesResultItem → PosDevice`, `CurrentCashSessionResult → CurrentCashSession`.
820
+ */
821
+ function cleanStem(modelName) {
822
+ let s = modelName;
823
+ for (const suffix of [
824
+ "ResultItem",
825
+ "Result",
826
+ "Args"
827
+ ]) if (s.length > suffix.length && s.endsWith(suffix)) {
828
+ s = s.slice(0, -suffix.length);
829
+ break;
830
+ }
831
+ return singularizeWord(s);
832
+ }
833
+ /** The bare candidate name for a structural (non-schema) enum group. */
834
+ function fallbackBase(g) {
835
+ return pascal(g.field ?? g.hint);
836
+ }
837
+ /**
559
838
  * Dart reserved words — none may be used as a bare identifier (e.g. an enum
560
839
  * constant), so a wire value like `void` must be escaped.
561
840
  */
@@ -700,7 +979,6 @@ function emitModels(ir) {
700
979
  const out = [HEADER];
701
980
  if (modelsUseBytes(ir)) out.push("import 'dart:typed_data';\n");
702
981
  out.push("import 'package:supalive_client/supalive_client.dart';\n");
703
- out.push("const Object _undefined = Object();\n");
704
982
  for (const e of ir.enums) out.push(emitEnum(e));
705
983
  for (const m of ir.models) out.push(emitModel(m));
706
984
  return out.join("\n");
@@ -736,7 +1014,7 @@ ${values};
736
1014
  }
737
1015
  /** Dart declared type for a field, applying nullability. */
738
1016
  function fieldType(f) {
739
- if (f.optional && f.nullable) return `Optional<${typeName(f.type)}>`;
1017
+ if (f.optional && f.nullable) return `Option<${typeName(f.type)}>`;
740
1018
  const base = typeName(f.type);
741
1019
  const nullable = f.optional || f.nullable;
742
1020
  if (base === "Object?") return "Object?";
@@ -744,17 +1022,17 @@ function fieldType(f) {
744
1022
  }
745
1023
  function emitModel(m) {
746
1024
  const ctorParams = m.fields.map((f) => {
747
- if (f.optional && f.nullable) return ` this.${f.name} = const Optional.absent()`;
1025
+ if (f.optional && f.nullable) return ` this.${f.name} = const .undefined()`;
748
1026
  return !(f.optional || f.nullable) ? ` required this.${f.name}` : ` this.${f.name}`;
749
1027
  }).join(",\n");
750
1028
  const fields = m.fields.map((f) => `${docComment(f.doc, " ")} final ${fieldType(f)} ${f.name};`).join("\n");
751
1029
  const fromJson = m.fields.map((f) => emitFromJsonField(f)).join("\n");
752
1030
  const toJson = m.fields.map((f) => emitToJsonField(f)).join("\n");
753
- const copyParams = m.fields.map((f) => ` Object? ${f.name} = _undefined`).join(",\n");
1031
+ const isNullableType = (f) => fieldType(f).endsWith("?");
1032
+ const copyParams = m.fields.map((f) => isNullableType(f) ? ` Option<${typeName(f.type)}> ${f.name} = const .undefined()` : ` ${fieldType(f)}? ${f.name}`).join(",\n");
754
1033
  const copyArgs = m.fields.map((f) => {
755
- const ft = fieldType(f);
756
- const value = ft === "Object?" ? f.name : `${f.name} as ${ft}`;
757
- return ` ${f.name}: identical(${f.name}, _undefined) ? this.${f.name} : ${value}`;
1034
+ if (isNullableType(f)) return ` ${f.name}: ${f.name}.isUndefined ? this.${f.name} : ${f.name}.casted()`;
1035
+ return ` ${f.name}: ${f.name} ?? this.${f.name}`;
758
1036
  }).join(",\n");
759
1037
  const isDeep = (f) => {
760
1038
  if (f.optional && f.nullable) return false;
@@ -797,23 +1075,23 @@ ${toJson}
797
1075
  }
798
1076
  function emitFromJsonField(f) {
799
1077
  const key = `json['${f.jsonKey}']`;
800
- if (f.optional && f.nullable) {
801
- const inner = typeName(f.type);
802
- return ` ${f.name}: json.containsKey('${f.jsonKey}')
1078
+ if (f.optional && f.nullable) return ` ${f.name}: json.containsKey('${f.jsonKey}')
803
1079
  ? (${key} == null
804
- ? const Optional<${inner}>.ofNull()
805
- : Optional<${inner}>.of(${decodeNonNull(f.type, key)}))
806
- : const Optional<${inner}>.absent(),`;
807
- }
1080
+ ? const Option.nil()
1081
+ : Option.value(${decodeNonNull(f.type, key)}))
1082
+ : const Option.undefined(),`;
808
1083
  if (f.optional || f.nullable) return ` ${f.name}: ${decodeNullable(f.type, key)},`;
809
1084
  return ` ${f.name}: ${decodeNonNull(f.type, key)},`;
810
1085
  }
811
1086
  function emitToJsonField(f) {
812
1087
  const key = `'${f.jsonKey}'`;
813
- if (f.optional && f.nullable) return ` if (${f.name}.isPresent) {
814
- final v = ${f.name}.value;
1088
+ if (f.optional && f.nullable) {
1089
+ const inner = typeName(f.type);
1090
+ return ` if (!${f.name}.isUndefined) {
1091
+ final v = ${f.name}.casted<${inner}?>();
815
1092
  json[${key}] = v == null ? null : ${encodeNonNull(f.type, "v")};
816
1093
  }`;
1094
+ }
817
1095
  if (f.optional) return ` if (${f.name} != null) json[${key}] = ${encodeNonNull(f.type, `${f.name}!`)};`;
818
1096
  if (f.nullable) return ` json[${key}] = ${encodeNullable(f.type, f.name)};`;
819
1097
  return ` json[${key}] = ${encodeNonNull(f.type, f.name)};`;
@@ -895,20 +1173,25 @@ function resolveConfig(opts) {
895
1173
  includeInternal: opts.includeInternal ?? DEFAULTS.includeInternal,
896
1174
  clientClassName: opts.clientClassName ?? DEFAULTS.clientClassName,
897
1175
  tsconfig: opts.tsconfig ? path.resolve(opts.tsconfig) : void 0,
898
- numericOverrides: opts.numericOverrides ?? {}
1176
+ numericOverrides: opts.numericOverrides ?? {},
1177
+ schemaModule: opts.schemaModule ? path.resolve(opts.schemaModule) : void 0
899
1178
  };
900
1179
  }
901
- /** Extract + emit. Does not touch the filesystem. */
902
- function generate(opts) {
1180
+ /**
1181
+ * Extract + emit. Does not touch the filesystem, and stays synchronous. Pass a
1182
+ * pre-loaded {@link SchemaEnumRegistry} to get schema-aware enum names; omit it
1183
+ * (the default) for purely structural naming.
1184
+ */
1185
+ function generate(opts, schemaEnums) {
903
1186
  const config = resolveConfig(opts);
904
1187
  return {
905
- files: emit(extractClient(config)),
1188
+ files: emit(extractClient(config, schemaEnums)),
906
1189
  config
907
1190
  };
908
1191
  }
909
1192
  /** Extract + emit + write the files to `config.output`. */
910
- function generateToDisk(opts) {
911
- const result = generate(opts);
1193
+ async function generateToDisk(opts) {
1194
+ const result = generate(opts, await loadSchemaEnums(resolveConfig(opts)));
912
1195
  fs.mkdirSync(result.config.output, { recursive: true });
913
1196
  for (const file of result.files) fs.writeFileSync(path.join(result.config.output, file.path), file.contents, "utf8");
914
1197
  return result;
@@ -955,4 +1238,4 @@ function evalLiteral(node) {
955
1238
  if (text === "false") return false;
956
1239
  }
957
1240
  //#endregion
958
- export { emit as a, resolveConfig as i, generateToDisk as n, extractClient as o, readEntryConfig as r, generate as t };
1241
+ export { emit as a, loadSchemaEnums as c, resolveConfig as i, generateToDisk as n, extractClient as o, readEntryConfig as r, SchemaEnumRegistry as s, generate as t };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@supalive/codegen",
3
- "version": "1.2.1",
3
+ "version": "1.3.0",
4
4
  "description": "Generate type-safe client code (Dart, and more) from a Supalive TypeScript router.",
5
5
  "author": "Rebaz Raouf",
6
6
  "license": "MIT",
@@ -21,7 +21,7 @@
21
21
  "type-safe"
22
22
  ],
23
23
  "bin": {
24
- "supalive-dart": "./dist/cli.js"
24
+ "supalive-codegen": "./dist/cli.js"
25
25
  },
26
26
  "main": "./dist/index.js",
27
27
  "types": "./dist/index.d.ts",
@@ -45,7 +45,8 @@
45
45
  "test": "vitest --run"
46
46
  },
47
47
  "dependencies": {
48
- "ts-morph": "^24.0.0"
48
+ "ts-morph": "^24.0.0",
49
+ "@supalive/core": "*"
49
50
  },
50
51
  "devDependencies": {
51
52
  "tsdown": "^0.22.3",