@an5/orm 1.0.8 → 1.1.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.
Files changed (73) hide show
  1. package/README.md +174 -175
  2. package/dist/an5Metadata.js +64 -0
  3. package/dist/an5Orm.d.ts +92 -3
  4. package/dist/an5Orm.js +697 -206
  5. package/dist/config.d.ts +9 -1
  6. package/dist/config.js +13 -18
  7. package/dist/errors.d.ts +1 -1
  8. package/dist/errors.js +1 -1
  9. package/dist/generator/src/api.d.ts +35 -0
  10. package/dist/generator/src/api.js +58 -0
  11. package/dist/generator/src/code-generator.d.ts +14 -13
  12. package/dist/generator/src/code-generator.js +249 -225
  13. package/dist/generator/src/config.d.ts +162 -0
  14. package/dist/generator/src/config.js +354 -0
  15. package/dist/generator/src/dialect.d.ts +88 -0
  16. package/dist/generator/src/dialect.js +318 -0
  17. package/dist/generator/src/dotnet-generator.d.ts +21 -11
  18. package/dist/generator/src/dotnet-generator.js +1053 -546
  19. package/dist/generator/src/field-types.d.ts +96 -0
  20. package/dist/generator/src/field-types.js +433 -0
  21. package/dist/generator/src/golang-generator.d.ts +36 -0
  22. package/dist/generator/src/golang-generator.js +1229 -0
  23. package/dist/generator/src/index.d.ts +1 -1
  24. package/dist/generator/src/index.js +137 -88
  25. package/dist/generator/src/metadata-generator.d.ts +11 -8
  26. package/dist/generator/src/metadata-generator.js +105 -90
  27. package/dist/generator/src/parser.d.ts +37 -11
  28. package/dist/generator/src/parser.js +208 -206
  29. package/dist/generator/src/push-apply.d.ts +37 -0
  30. package/dist/generator/src/push-apply.js +109 -0
  31. package/dist/generator/src/push-schema.d.ts +93 -0
  32. package/dist/generator/src/push-schema.js +189 -0
  33. package/dist/generator/src/python-generator.d.ts +37 -9
  34. package/dist/generator/src/python-generator.js +428 -105
  35. package/dist/generator/src/rust-generator.d.ts +38 -0
  36. package/dist/generator/src/rust-generator.js +971 -0
  37. package/dist/generator/src/suggest.d.ts +17 -0
  38. package/dist/generator/src/suggest.js +45 -0
  39. package/dist/generator/src/type-kinds.d.ts +27 -0
  40. package/dist/generator/src/type-kinds.js +72 -0
  41. package/dist/generator/src/types.d.ts +60 -27
  42. package/dist/generator/src/types.js +41 -2
  43. package/dist/index.d.ts +3 -2
  44. package/dist/index.js +19 -8
  45. package/dist/logger.js +1 -1
  46. package/dist/migration-core.d.ts +102 -0
  47. package/dist/migration-core.js +731 -0
  48. package/dist/orm.d.ts +147 -0
  49. package/dist/orm.js +1654 -0
  50. package/dist/provider-support.d.ts +27 -0
  51. package/dist/provider-support.js +50 -0
  52. package/dist/scripts/release-cli.d.ts +2 -2
  53. package/dist/scripts/release-cli.js +115 -115
  54. package/dist/sql-utils.d.ts +3 -1
  55. package/dist/sql-utils.js +94 -24
  56. package/generator/src/api.ts +45 -0
  57. package/generator/src/code-generator.ts +259 -230
  58. package/generator/src/config.ts +496 -0
  59. package/generator/src/dialect.ts +415 -0
  60. package/generator/src/dotnet-generator.ts +1071 -544
  61. package/generator/src/field-types.ts +473 -0
  62. package/generator/src/golang-generator.ts +1267 -0
  63. package/generator/src/index.ts +146 -116
  64. package/generator/src/metadata-generator.ts +116 -101
  65. package/generator/src/parser.ts +234 -202
  66. package/generator/src/push-apply.ts +148 -0
  67. package/generator/src/push-schema.ts +250 -0
  68. package/generator/src/python-generator.ts +480 -114
  69. package/generator/src/rust-generator.ts +974 -0
  70. package/generator/src/suggest.ts +41 -0
  71. package/generator/src/type-kinds.ts +78 -0
  72. package/generator/src/types.ts +77 -29
  73. package/package.json +32 -6
@@ -0,0 +1,473 @@
1
+ /**
2
+ * Field types per database provider.
3
+ *
4
+ * There used to be one `AN5_TO_TS` table shared by every provider, plus a
5
+ * separate `AN5_TYPES` list for push/migrate — and the two disagreed. So
6
+ * `INTEGER`/`BOOLEAN`/`BLOB` (SQLite-only names) were generated into the client
7
+ * and then dropped silently on push, while another provider's types (`SERIAL`,
8
+ * `JSONB`) passed validation and only died at DDL time.
9
+ *
10
+ * Each provider now has its own table, which is both the list of allowed types
11
+ * and the mapping to a TypeScript type. The provider comes from the connection
12
+ * string — see `detectProvider` in `./config`.
13
+ */
14
+ import { suggest } from './suggest';
15
+
16
+ export type Provider = 'mssql' | 'postgres' | 'mysql' | 'sqlite' | 'googlesheets';
17
+
18
+ /** Order used when printing issues and when suggesting a provider. */
19
+ export const PROVIDERS: readonly Provider[] = ['mssql', 'postgres', 'mysql', 'sqlite', 'googlesheets'];
20
+
21
+ /**
22
+ * SQL Server is the default provider: it is the only one `db:push`, `db:pull`
23
+ * and `db:migrate` actually support, and it is what existing `.an5` schemas
24
+ * were written for. With no connection string, treat the schema as SQL Server —
25
+ * the same fallback `an5Adapter` uses.
26
+ */
27
+ export const DEFAULT_PROVIDER: Provider = 'mssql';
28
+
29
+ /** Display name, used in error messages. */
30
+ export const PROVIDER_LABELS: Record<Provider, string> = {
31
+ mssql: 'SQL Server',
32
+ postgres: 'PostgreSQL',
33
+ mysql: 'MySQL',
34
+ sqlite: 'SQLite',
35
+ googlesheets: 'Google Sheets',
36
+ };
37
+
38
+ /** The TypeScript type a field is generated as. */
39
+ export type TsType =
40
+ | 'string'
41
+ | 'number'
42
+ | 'number | bigint'
43
+ | 'boolean'
44
+ | 'Date'
45
+ | 'Buffer'
46
+ | 'any'
47
+ | 'number[] | string';
48
+
49
+ /**
50
+ * Valid column types per provider: what is written in `.an5` → the TypeScript
51
+ * type generated for it.
52
+ *
53
+ * Keys are stored upper case and lookups are case-insensitive. This table is
54
+ * the single source of truth — nowhere else is allowed to enumerate types.
55
+ */
56
+ export const PROVIDER_FIELD_TYPES: Record<Provider, Readonly<Record<string, TsType>>> = {
57
+ // ─── SQL Server ────────────────────────────────────────────────────────────
58
+ mssql: {
59
+ // Strings
60
+ NVARCHAR: 'string',
61
+ VARCHAR: 'string',
62
+ CHAR: 'string',
63
+ NCHAR: 'string',
64
+ TEXT: 'string',
65
+ NTEXT: 'string',
66
+ XML: 'string',
67
+ // Numbers
68
+ INT: 'number',
69
+ SMALLINT: 'number',
70
+ TINYINT: 'number',
71
+ BIGINT: 'number | bigint',
72
+ FLOAT: 'number',
73
+ REAL: 'number',
74
+ DECIMAL: 'number',
75
+ NUMERIC: 'number',
76
+ MONEY: 'number',
77
+ SMALLMONEY: 'number',
78
+ // Boolean
79
+ BIT: 'boolean',
80
+ // Date and time
81
+ DATE: 'Date',
82
+ DATETIME: 'Date',
83
+ DATETIME2: 'Date',
84
+ SMALLDATETIME: 'Date',
85
+ DATETIMEOFFSET: 'Date',
86
+ TIME: 'Date',
87
+ // Binary
88
+ VARBINARY: 'Buffer',
89
+ BINARY: 'Buffer',
90
+ IMAGE: 'Buffer',
91
+ ROWVERSION: 'Buffer',
92
+ // `TIMESTAMP` is the legacy name of `ROWVERSION`, i.e. a rowversion — writing
93
+ // TIMESTAMP in a SQL Server schema does not mean a point in time.
94
+ TIMESTAMP: 'Buffer',
95
+ // Everything else
96
+ UNIQUEIDENTIFIER: 'string',
97
+ // `sysname` is an alias for NVARCHAR(128) and is what db:pull writes for a
98
+ // column declared that way, so it has to be valid or a pulled schema could
99
+ // not be generated again.
100
+ SYSNAME: 'string',
101
+ SQL_VARIANT: 'any',
102
+ HIERARCHYID: 'string',
103
+ GEOGRAPHY: 'string',
104
+ GEOMETRY: 'string',
105
+ VECTOR: 'number[] | string',
106
+ },
107
+
108
+ // ─── PostgreSQL ────────────────────────────────────────────────────────────
109
+ postgres: {
110
+ // Strings. `NAME` and `USER` are real PostgreSQL types but are deliberately
111
+ // left out: a model called `Name`/`User` is far more likely, and listing
112
+ // them here would turn `user User @relation(...)` into a USER column.
113
+ VARCHAR: 'string',
114
+ CHARACTER: 'string',
115
+ 'CHARACTER VARYING': 'string',
116
+ CHAR: 'string',
117
+ TEXT: 'string',
118
+ UUID: 'string',
119
+ XML: 'string',
120
+ JSON: 'any',
121
+ JSONB: 'any',
122
+ INET: 'string',
123
+ CIDR: 'string',
124
+ // Numbers. `INT` is not valid on PostgreSQL (it must be `INTEGER`/`INT4`),
125
+ // so it is absent here — a schema using `INT` is rejected instead of dying
126
+ // at DDL time.
127
+ SMALLINT: 'number',
128
+ INT2: 'number',
129
+ INT4: 'number',
130
+ INTEGER: 'number',
131
+ INT8: 'number | bigint',
132
+ BIGINT: 'number | bigint',
133
+ SMALLSERIAL: 'number',
134
+ SERIAL: 'number',
135
+ BIGSERIAL: 'number | bigint',
136
+ DECIMAL: 'number',
137
+ NUMERIC: 'number',
138
+ REAL: 'number',
139
+ 'DOUBLE PRECISION': 'number',
140
+ MONEY: 'number',
141
+ // Boolean
142
+ BOOLEAN: 'boolean',
143
+ BOOL: 'boolean',
144
+ // Date and time
145
+ DATE: 'Date',
146
+ TIME: 'Date',
147
+ TIMETZ: 'Date',
148
+ 'TIME WITH TIME ZONE': 'Date',
149
+ 'TIME WITHOUT TIME ZONE': 'Date',
150
+ TIMESTAMP: 'Date',
151
+ TIMESTAMPTZ: 'Date',
152
+ 'TIMESTAMP WITH TIME ZONE': 'Date',
153
+ 'TIMESTAMP WITHOUT TIME ZONE': 'Date',
154
+ INTERVAL: 'string',
155
+ // Binary
156
+ BYTEA: 'Buffer',
157
+ // Requires an extension: PostGIS for geometry, pgvector for vectors.
158
+ GEOGRAPHY: 'string',
159
+ GEOMETRY: 'string',
160
+ VECTOR: 'number[] | string',
161
+ },
162
+
163
+ // ─── MySQL / MariaDB ───────────────────────────────────────────────────────
164
+ mysql: {
165
+ // Strings
166
+ CHAR: 'string',
167
+ VARCHAR: 'string',
168
+ NCHAR: 'string',
169
+ NVARCHAR: 'string',
170
+ TINYTEXT: 'string',
171
+ TEXT: 'string',
172
+ MEDIUMTEXT: 'string',
173
+ LONGTEXT: 'string',
174
+ ENUM: 'string',
175
+ SET: 'string',
176
+ // Numbers
177
+ TINYINT: 'number',
178
+ SMALLINT: 'number',
179
+ MEDIUMINT: 'number',
180
+ INT: 'number',
181
+ INTEGER: 'number',
182
+ BIGINT: 'number | bigint',
183
+ // `SERIAL` is an alias for `BIGINT UNSIGNED AUTO_INCREMENT`.
184
+ SERIAL: 'number | bigint',
185
+ DECIMAL: 'number',
186
+ NUMERIC: 'number',
187
+ FIXED: 'number',
188
+ FLOAT: 'number',
189
+ DOUBLE: 'number',
190
+ 'DOUBLE PRECISION': 'number',
191
+ REAL: 'number',
192
+ // Boolean
193
+ BOOLEAN: 'boolean',
194
+ BOOL: 'boolean',
195
+ // `BIT(n)` is an n-bit field on MySQL, but it is a flag in practice and every
196
+ // other dialect's BIT is a boolean here. Mapping it to a number made the
197
+ // TypeScript client say `number` while the Go, Python, Rust and .NET clients
198
+ // said `bool` for the same column.
199
+ BIT: 'boolean',
200
+ // Date and time
201
+ DATE: 'Date',
202
+ DATETIME: 'Date',
203
+ TIMESTAMP: 'Date',
204
+ TIME: 'Date',
205
+ YEAR: 'number',
206
+ // Binary
207
+ BINARY: 'Buffer',
208
+ VARBINARY: 'Buffer',
209
+ TINYBLOB: 'Buffer',
210
+ BLOB: 'Buffer',
211
+ MEDIUMBLOB: 'Buffer',
212
+ LONGBLOB: 'Buffer',
213
+ // Everything else. MySQL has no UUID type (MariaDB does), so UUID is
214
+ // intentionally absent.
215
+ JSON: 'any',
216
+ GEOMETRY: 'string',
217
+ POINT: 'string',
218
+ LINESTRING: 'string',
219
+ POLYGON: 'string',
220
+ },
221
+
222
+ // ─── SQLite ────────────────────────────────────────────────────────────────
223
+ // SQLite does not enforce types: the declared name only picks an affinity, so
224
+ // this list is "names in common use whose affinity is derivable" rather than
225
+ // an engine enum. The point that matters: `INTEGER`/`BOOLEAN`/`BLOB` exist
226
+ // only here — SQL Server has no INTEGER, so a schema using it and pushed to
227
+ // SQL Server is now rejected.
228
+ sqlite: {
229
+ // Integers
230
+ INTEGER: 'number',
231
+ INT: 'number',
232
+ INT2: 'number',
233
+ INT8: 'number | bigint',
234
+ TINYINT: 'number',
235
+ SMALLINT: 'number',
236
+ MEDIUMINT: 'number',
237
+ BIGINT: 'number | bigint',
238
+ 'UNSIGNED BIG INT': 'number | bigint',
239
+ // Reals
240
+ REAL: 'number',
241
+ DOUBLE: 'number',
242
+ 'DOUBLE PRECISION': 'number',
243
+ FLOAT: 'number',
244
+ NUMERIC: 'number',
245
+ DECIMAL: 'number',
246
+ // Boolean
247
+ BOOLEAN: 'boolean',
248
+ BOOL: 'boolean',
249
+ // Strings
250
+ TEXT: 'string',
251
+ CLOB: 'string',
252
+ VARCHAR: 'string',
253
+ NVARCHAR: 'string',
254
+ CHAR: 'string',
255
+ NCHAR: 'string',
256
+ CHARACTER: 'string',
257
+ 'VARYING CHARACTER': 'string',
258
+ // Binary
259
+ BLOB: 'Buffer',
260
+ // Date and time — stored as TEXT and converted on read (see `SqliteEngine`).
261
+ DATE: 'Date',
262
+ DATETIME: 'Date',
263
+ TIMESTAMP: 'Date',
264
+ TIME: 'Date',
265
+ YEAR: 'number',
266
+ // Everything else
267
+ JSON: 'any',
268
+ UUID: 'string',
269
+ VECTOR: 'number[] | string',
270
+ },
271
+
272
+ // ─── Google Sheets ─────────────────────────────────────────────────────────
273
+ // Sheets has no column types: every cell is a text/number/boolean value and
274
+ // the adapter coerces it from the generated TypeScript type (`coerceCell`).
275
+ // So this list only accepts portable types — the ones the adapter still knows
276
+ // what to coerce into.
277
+ googlesheets: {
278
+ // Strings
279
+ STRING: 'string',
280
+ TEXT: 'string',
281
+ VARCHAR: 'string',
282
+ NVARCHAR: 'string',
283
+ CHAR: 'string',
284
+ NCHAR: 'string',
285
+ CLOB: 'string',
286
+ // Numbers
287
+ INT: 'number',
288
+ INTEGER: 'number',
289
+ SMALLINT: 'number',
290
+ TINYINT: 'number',
291
+ BIGINT: 'number | bigint',
292
+ FLOAT: 'number',
293
+ DOUBLE: 'number',
294
+ DECIMAL: 'number',
295
+ NUMERIC: 'number',
296
+ REAL: 'number',
297
+ // Boolean
298
+ BOOLEAN: 'boolean',
299
+ BOOL: 'boolean',
300
+ // Date and time
301
+ DATE: 'Date',
302
+ DATETIME: 'Date',
303
+ TIMESTAMP: 'Date',
304
+ TIME: 'Date',
305
+ // Binary
306
+ BYTES: 'Buffer',
307
+ BLOB: 'Buffer',
308
+ // Everything else
309
+ VECTOR: 'number[] | string',
310
+ },
311
+ };
312
+
313
+ /** The type table of one provider. */
314
+ export function fieldTypesFor(provider: Provider): Readonly<Record<string, TsType>> {
315
+ return PROVIDER_FIELD_TYPES[provider];
316
+ }
317
+
318
+ /**
319
+ * The column type to use when only the TypeScript type is known.
320
+ *
321
+ * For tooling that has to invent a type name — the agent reading a client generated
322
+ * before the metadata carried `sql` — and the table is per provider on purpose:
323
+ * `NVARCHAR` exists only on SQL Server and `TIMESTAMP` is a rowversion there, so
324
+ * naming a type from the wrong provider produces SQL the database rejects.
325
+ */
326
+ const DEFAULT_TYPE_FOR_TS: Record<Provider, Record<string, string>> = {
327
+ mssql: { string: 'NVARCHAR(255)', number: 'INT', bigint: 'BIGINT', boolean: 'BIT', Date: 'DATETIME2', Buffer: 'VARBINARY(MAX)', any: 'NVARCHAR(MAX)', vector: 'VECTOR' },
328
+ postgres: { string: 'VARCHAR(255)', number: 'INTEGER', bigint: 'BIGINT', boolean: 'BOOLEAN', Date: 'TIMESTAMPTZ', Buffer: 'BYTEA', any: 'JSONB', vector: 'VECTOR' },
329
+ mysql: { string: 'VARCHAR(255)', number: 'INT', bigint: 'BIGINT', boolean: 'BOOLEAN', Date: 'DATETIME', Buffer: 'BLOB', any: 'JSON', vector: 'JSON' },
330
+ // SQLite stores what it is given: dates as text, which is what the adapter reads
331
+ // back and converts, and numbers as a real.
332
+ sqlite: { string: 'TEXT', number: 'REAL', bigint: 'INTEGER', boolean: 'BOOLEAN', Date: 'TEXT', Buffer: 'BLOB', any: 'TEXT', vector: 'TEXT' },
333
+ googlesheets: { string: 'TEXT', number: 'INTEGER', bigint: 'INTEGER', boolean: 'BOOLEAN', Date: 'TEXT', Buffer: 'BYTES', any: 'TEXT', vector: 'TEXT' },
334
+ };
335
+
336
+ /**
337
+ * A column type for a TypeScript type, valid on `provider`.
338
+ *
339
+ * Unknown TypeScript types fall back to that provider's string type, which is the
340
+ * only choice that can hold any value.
341
+ */
342
+ export function defaultSqlTypeForTs(tsType: string, provider: Provider = DEFAULT_PROVIDER): string {
343
+ const byProvider = DEFAULT_TYPE_FOR_TS[provider];
344
+ const raw = tsType.trim();
345
+ // A `[]` means the vector type, which the generators spell `number[] | string`; it
346
+ // has to be recognised before the brackets are stripped, or it reads as a number.
347
+ if (raw.includes('[]')) return byProvider.vector!;
348
+ const key = raw.replace(/[?\]]/g, '').trim();
349
+ if (byProvider[key]) return byProvider[key];
350
+ if (key.startsWith('number')) return byProvider.bigint!;
351
+ if (key.startsWith('Date')) return byProvider.Date!;
352
+ if (key.startsWith('boolean') || key.startsWith('bool')) return byProvider.boolean!;
353
+ if (key.startsWith('Buffer')) return byProvider.Buffer!;
354
+ if (key === 'any') return byProvider.any!;
355
+ return byProvider.string!;
356
+ }
357
+
358
+ /** One bad field type, with everything needed to print it. */
359
+ export interface FieldTypeIssue {
360
+ /** `Model.field`, or just the field name when there is no model. */
361
+ path: string;
362
+ message: string;
363
+ }
364
+
365
+ /** Every bad field type reported at once, instead of stopping at the first. */
366
+ export class FieldTypeError extends Error {
367
+ readonly provider: Provider;
368
+ readonly issues: FieldTypeIssue[];
369
+
370
+ constructor(provider: Provider, issues: FieldTypeIssue[]) {
371
+ super(`Invalid field types for ${PROVIDER_LABELS[provider]}`);
372
+ this.name = 'FieldTypeError';
373
+ this.provider = provider;
374
+ this.issues = issues;
375
+ }
376
+ }
377
+
378
+ /**
379
+ * Splits `NVARCHAR(255)` into name and parameters.
380
+ *
381
+ * The name keeps every word, because `DOUBLE PRECISION` and
382
+ * `TIMESTAMP WITH TIME ZONE` are single entries in the PostgreSQL table. It is
383
+ * upper-cased and whitespace-collapsed here so lookups do not depend on how the
384
+ * schema was written.
385
+ */
386
+ export function splitTypeParams(raw: string): { base: string; params: string } {
387
+ const normalized = raw.trim().toUpperCase().replace(/\s+/g, ' ');
388
+ const match = normalized.match(/^([A-Z0-9_]+(?:\s+[A-Z0-9_]+)*)\s*(?:\(([^)]*)\))?$/);
389
+ if (!match) return { base: normalized, params: '' };
390
+ return { base: match[1]!, params: match[2] ?? '' };
391
+ }
392
+
393
+ /** Drops the `[]` (array) and `?` (nullable) suffixes. */
394
+ function stripTypeSuffixes(raw: string): string {
395
+ return raw.replace(/\[\]$/, '').replace(/\?$/, '');
396
+ }
397
+
398
+ export interface ResolvedFieldType {
399
+ /** Normalised type name, e.g. `DOUBLE PRECISION`. */
400
+ base: string;
401
+ /** The parameters in parentheses, e.g. `255` for `NVARCHAR(255)`. */
402
+ params: string;
403
+ /** The first word of `base`, used to guess "which type was meant". */
404
+ head: string;
405
+ /** The matching TypeScript type. */
406
+ ts: TsType;
407
+ }
408
+
409
+ /**
410
+ * Looks a type up in the provider's table, or null when the provider has no such
411
+ * type.
412
+ *
413
+ * Called before deciding whether a field is a relation, so an upper-case type
414
+ * that happens to match a model name (`TEXT`, `NAME`) still becomes a column.
415
+ */
416
+ export function resolveFieldType(raw: string, provider: Provider): ResolvedFieldType | null {
417
+ const { base, params } = splitTypeParams(raw);
418
+ const ts = fieldTypesFor(provider)[base];
419
+ if (ts === undefined) return null;
420
+ return { base, params, head: base.split(' ')[0]!, ts };
421
+ }
422
+
423
+ /**
424
+ * Message for a type the provider does not have.
425
+ *
426
+ * `modelNames` holds the models in the schema: an upper-case token may be a
427
+ * model name (a relation) rather than a mistyped type, so the message has to
428
+ * name both possibilities instead of guessing.
429
+ */
430
+ export function unknownFieldTypeMessage(
431
+ raw: string,
432
+ provider: Provider,
433
+ modelNames: readonly string[] = [],
434
+ ): string {
435
+ const label = `unknown type "${raw.trim()}" for ${PROVIDER_LABELS[provider]}`;
436
+ const { base } = splitTypeParams(raw);
437
+ const typeHint = suggest(base.split(' ')[0]!, Object.keys(fieldTypesFor(provider)));
438
+ if (typeHint) return `${label}; did you mean "${typeHint}"?`;
439
+
440
+ const modelHint = suggest(raw.trim(), modelNames);
441
+ if (modelHint) {
442
+ return `${label}, and no model named "${raw.trim()}" here; did you mean the model "${modelHint}"?`;
443
+ }
444
+ return `${label}, and no model named "${raw.trim()}" in this schema`;
445
+ }
446
+
447
+ /**
448
+ * The head of a field line: the column name and type, with the `@attributes`
449
+ * that may follow removed.
450
+ *
451
+ * The type is joined from every remaining token instead of just the second one,
452
+ * because a multi-word type (`DOUBLE PRECISION`, `TIMESTAMP WITH TIME ZONE`)
453
+ * truncated to its first word matches no table. Returns null when the line has
454
+ * no name or no type.
455
+ */
456
+ export function readFieldLineHead(line: string): {
457
+ name: string;
458
+ type: string;
459
+ isArray: boolean;
460
+ isOptional: boolean;
461
+ } | null {
462
+ const attributeStart = line.indexOf('@');
463
+ const tokens = (attributeStart === -1 ? line : line.slice(0, attributeStart)).trim().split(/\s+/);
464
+ const name = tokens[0];
465
+ const rawType = tokens.slice(1).join(' ');
466
+ if (!name || !rawType) return null;
467
+ return {
468
+ name,
469
+ type: stripTypeSuffixes(rawType),
470
+ isArray: rawType.endsWith('[]'),
471
+ isOptional: rawType.endsWith('?'),
472
+ };
473
+ }