litedbmodel 2.2.5 → 2.2.7

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 (116) hide show
  1. package/README.md +145 -86
  2. package/dist/Column.d.ts +17 -4
  3. package/dist/Column.d.ts.map +1 -1
  4. package/dist/Column.js.map +1 -1
  5. package/dist/DBModel.d.ts +13 -14
  6. package/dist/DBModel.d.ts.map +1 -1
  7. package/dist/DBModel.js +20 -21
  8. package/dist/DBModel.js.map +1 -1
  9. package/dist/LazyRelation.d.ts +2 -2
  10. package/dist/LazyRelation.js +2 -2
  11. package/dist/decorators.d.ts +120 -60
  12. package/dist/decorators.d.ts.map +1 -1
  13. package/dist/decorators.js +379 -248
  14. package/dist/decorators.js.map +1 -1
  15. package/dist/index.cjs +283 -217
  16. package/dist/index.d.ts +2 -2
  17. package/dist/index.d.ts.map +1 -1
  18. package/dist/index.js.map +1 -1
  19. package/dist/scp/coltype.d.ts +14 -5
  20. package/dist/scp/coltype.d.ts.map +1 -1
  21. package/dist/scp/coltype.js +35 -36
  22. package/dist/scp/coltype.js.map +1 -1
  23. package/dist/scp/decorator-adapter.d.ts +11 -15
  24. package/dist/scp/decorator-adapter.d.ts.map +1 -1
  25. package/dist/scp/decorator-adapter.js +11 -15
  26. package/dist/scp/decorator-adapter.js.map +1 -1
  27. package/dist/scp/index.cjs +186 -193
  28. package/dist/scp/index.mjs +186 -193
  29. package/dist/scp/makesql/json-array.d.ts +6 -0
  30. package/dist/scp/makesql/json-array.d.ts.map +1 -1
  31. package/dist/scp/makesql/json-array.js +17 -2
  32. package/dist/scp/makesql/json-array.js.map +1 -1
  33. package/dist/scp/makesql/tx.d.ts.map +1 -1
  34. package/dist/scp/makesql/tx.js +1 -3
  35. package/dist/scp/makesql/tx.js.map +1 -1
  36. package/dist/scp/relation.d.ts.map +1 -1
  37. package/dist/scp/relation.js +8 -10
  38. package/dist/scp/relation.js.map +1 -1
  39. package/docs/BENCHMARK.md +3 -3
  40. package/docs/COMPARISON.md +7 -7
  41. package/docs/api/README.md +18 -18
  42. package/docs/api/classes/Conditions.md +1 -1
  43. package/docs/api/classes/DBHandler.md +1 -1
  44. package/docs/api/classes/DBModel.md +82 -82
  45. package/docs/api/classes/LimitExceededError.md +1 -1
  46. package/docs/api/classes/Middleware.md +1 -1
  47. package/docs/api/classes/SqlRaw.md +1 -1
  48. package/docs/api/classes/SqlRef.md +1 -1
  49. package/docs/api/classes/Values.md +1 -1
  50. package/docs/api/classes/WriteInReadOnlyContextError.md +1 -1
  51. package/docs/api/classes/WriteOutsideTransactionError.md +1 -1
  52. package/docs/api/functions/belongsTo.md +10 -4
  53. package/docs/api/functions/closeAllPools.md +1 -1
  54. package/docs/api/functions/condsToRecord.md +2 -2
  55. package/docs/api/functions/createMiddleware.md +1 -1
  56. package/docs/api/functions/createPostgresDriver.md +1 -1
  57. package/docs/api/functions/createSqliteDriver.md +1 -1
  58. package/docs/api/functions/dbCast.md +1 -1
  59. package/docs/api/functions/dbCastIn.md +1 -1
  60. package/docs/api/functions/dbDynamic.md +1 -1
  61. package/docs/api/functions/dbFalse.md +1 -1
  62. package/docs/api/functions/dbImmediate.md +1 -1
  63. package/docs/api/functions/dbIn.md +1 -1
  64. package/docs/api/functions/dbNotNull.md +1 -1
  65. package/docs/api/functions/dbNow.md +1 -1
  66. package/docs/api/functions/dbNull.md +1 -1
  67. package/docs/api/functions/dbRaw.md +1 -1
  68. package/docs/api/functions/dbTrue.md +1 -1
  69. package/docs/api/functions/dbUuid.md +1 -1
  70. package/docs/api/functions/dbUuidIn.md +1 -1
  71. package/docs/api/functions/formatLocalDate.md +1 -1
  72. package/docs/api/functions/formatUTCDate.md +1 -1
  73. package/docs/api/functions/getDBConfig.md +1 -1
  74. package/docs/api/functions/getDBHandler.md +1 -1
  75. package/docs/api/functions/getTransactionConnection.md +1 -1
  76. package/docs/api/functions/getTransactionContext.md +1 -1
  77. package/docs/api/functions/hasMany.md +11 -5
  78. package/docs/api/functions/hasOne.md +10 -4
  79. package/docs/api/functions/initDBHandler.md +1 -1
  80. package/docs/api/functions/isAnySqlFragment.md +1 -1
  81. package/docs/api/functions/isConnectionError.md +1 -1
  82. package/docs/api/functions/isSqlCondition.md +1 -1
  83. package/docs/api/functions/isSqlFragment.md +1 -1
  84. package/docs/api/functions/isSqlRaw.md +1 -1
  85. package/docs/api/functions/isSqlRef.md +1 -1
  86. package/docs/api/functions/isSqlTypedFragment.md +1 -1
  87. package/docs/api/functions/model.md +8 -155
  88. package/docs/api/functions/parentRef.md +1 -1
  89. package/docs/api/functions/sql.md +1 -1
  90. package/docs/api/globals.md +96 -58
  91. package/docs/api/interfaces/Column.md +1 -1
  92. package/docs/api/interfaces/ColumnDecorator.md +81 -0
  93. package/docs/api/interfaces/ColumnOptions.md +6 -10
  94. package/docs/api/interfaces/CreatedMiddlewareClass.md +1 -1
  95. package/docs/api/interfaces/DBConfig.md +1 -1
  96. package/docs/api/interfaces/DBConfigOptions.md +1 -1
  97. package/docs/api/interfaces/DeleteOptions.md +1 -1
  98. package/docs/api/interfaces/InsertOptions.md +1 -1
  99. package/docs/api/interfaces/LimitConfig.md +1 -1
  100. package/docs/api/interfaces/MiddlewareConfig.md +1 -1
  101. package/docs/api/interfaces/ModelClassDecorator.md +64 -0
  102. package/docs/api/interfaces/ModelOptions.md +1 -1
  103. package/docs/api/interfaces/PkeyResult.md +1 -1
  104. package/docs/api/interfaces/RelationDecorator.md +72 -0
  105. package/docs/api/interfaces/SelectOptions.md +1 -1
  106. package/docs/api/interfaces/SqlCondition.md +1 -1
  107. package/docs/api/interfaces/SqlFragment.md +1 -1
  108. package/docs/api/interfaces/SqlTypedFragment.md +1 -1
  109. package/docs/api/interfaces/TransactionOptions.md +1 -1
  110. package/docs/api/interfaces/UpdateManyOptions.md +1 -1
  111. package/docs/api/interfaces/UpdateOptions.md +1 -1
  112. package/docs/api-guide.md +17 -17
  113. package/docs/architecture.md +3 -3
  114. package/docs/sql-tagged-template.md +5 -5
  115. package/eslint-plugin/rules/require-declare-for-relations.js +59 -4
  116. package/package.json +11 -10
@@ -27,16 +27,15 @@ const TypeCast_1 = require("./TypeCast");
27
27
  const coltype_1 = require("./scp/coltype");
28
28
  // ── v1 read type contract (issue #9), aligned to the v2 SCP read de-box ────────
29
29
  // The legacy v1 `DBModel` decorator read path historically materialized `@column.bigint()` to a JS
30
- // `bigint` (not JSON-safe) and `@column.datetime()` / an auto-inferred `Date` to a JS `Date`
31
- // (TZ-shifted). Both carry the SAME i64-rounding / date-corruption hazards the v2 SCP read path
30
+ // `bigint` (not JSON-safe) and `@column.datetime()` to a JS `Date` (TZ-shifted). Both carry the SAME i64-rounding / date-corruption hazards the v2 SCP read path
32
31
  // already closed. Per the owner (2026-07-15), v1 is realigned to the v2 read TYPE CONTRACT — and it
33
32
  // does so by REUSING the v2 materializer (`materializeCell`, `src/scp/coltype.ts`), NOT a divergent
34
33
  // v1 coercion. The v1 decorator IS the static type source (unlike v2, whose SoT is the SQL DDL), so
35
34
  // each read-affected decorator variant pins its v2 `MaterializeClass` and routes the raw driver cell
36
35
  // through `materializeCell`:
37
- // - `@column.bigint()` / auto `BigInt` → `int64` → EXACT decimal STRING (no i64 rounding, JSON-safe)
38
- // - `@column.datetime()` / auto `Date` → `date` → TZ-attached STRING (NOT a TZ-shifted JS Date)
39
- // - `@column.boolean()` / auto `Boolean` → `bool` → JS boolean
36
+ // - `@column.bigint()` → `int` → JS bigint (bc's `int` value model, checked i64)
37
+ // - `@column.datetime()` → `date` → TZ-attached STRING (NOT a TZ-shifted JS Date)
38
+ // - `@column.boolean()` → `bool` → JS boolean
40
39
  // `null`/`undefined` pass through (nullable columns) exactly as `materializeCell` and the prior v1
41
40
  // casts did. Fail-closed like v2: a driver cell that cannot be coerced to the declared class throws
42
41
  // (a declared BIGINT column returning a non-integer string is a driver-contract violation, not a
@@ -48,183 +47,196 @@ const coltype_1 = require("./scp/coltype");
48
47
  // ============================================
49
48
  const COLUMNS_KEY = Symbol('litedbmodel:columns');
50
49
  const RELATIONS_KEY = Symbol('litedbmodel:relations');
50
+ // ============================================
51
+ // Decorator Protocol Adapter (legacy + TC39 standard)
52
+ // ============================================
51
53
  /**
52
- * Infer type cast function from design:type metadata
53
- * Returns undefined if type cannot be inferred (Array, Object, etc.)
54
- * Note: All type casts preserve null (DB NULL -> JS null)
54
+ * TC39 (standard) decorators carry NO `design:type`: `emitDecoratorMetadata` is a legacy-decorator
55
+ * feature, and esbuild — which powers `tsx`, `vite` and `vitest` — does not implement it even for
56
+ * legacy decorators. A column's type is therefore declared by its `@column.*` FAMILY, which is the
57
+ * single source of truth on every toolchain (`.text()` / `.number()` / `.datetime()` / …). The bare
58
+ * `@column()` carries no type and is rejected at decoration time — see {@link requireColumnFamily}.
59
+ *
60
+ * Standard decorators reach the class through `context.metadata`: the SAME object is handed to every
61
+ * member decorator of a class and then to the class decorator, in that order. TypeScript only creates
62
+ * it when `Symbol.metadata` exists (esbuild always creates it), so the polyfill below — the one the
63
+ * TC39 proposal prescribes — makes the two emits behave identically.
55
64
  */
56
- function inferTypeCastFromDesignType(target, propertyKey) {
57
- const designType = Reflect.getMetadata('design:type', target, propertyKey);
58
- if (!designType)
59
- return undefined;
60
- switch (designType) {
61
- case Boolean:
62
- return {
63
- // v2 read contract: BOOLEAN → boolean, via the shared materializer.
64
- typeCast: (v) => {
65
- if (v === undefined)
66
- return undefined;
67
- return (0, coltype_1.materializeCell)(v, 'bool');
68
- },
69
- sqlCast: 'boolean',
70
- };
71
- case Date:
72
- // Auto-inferred Date: same behavior as @column.datetime() — v2 read contract materializes the
73
- // date family to a TZ-attached STRING (NOT a TZ-shifted JS Date), via the shared materializer.
74
- return {
75
- typeCast: (v) => {
76
- if (v === undefined)
77
- return undefined;
78
- return (0, coltype_1.materializeCell)(v, 'date');
79
- },
80
- // Delegate to driver-specific serializeDatetime
81
- serialize: (val, typeCast) => {
82
- if (val === null || val === undefined)
83
- return null;
84
- if (val instanceof Date) {
85
- return typeCast?.serializeDatetime(val) ?? val;
86
- }
87
- return val;
88
- },
89
- sqlCast: 'timestamp',
90
- };
91
- case Number:
92
- return {
93
- typeCast: (v) => {
94
- if (v === undefined)
95
- return undefined;
96
- if (v === null)
97
- return null;
98
- const n = Number(v);
99
- return isNaN(n) ? null : n;
100
- },
101
- };
102
- case BigInt:
103
- return {
104
- // v2 read contract: BIGINT/INT8 → EXACT decimal STRING (no i64 rounding, JSON-safe), via the
105
- // shared materializer. NOTE: the TS field type stays `bigint` in existing models for source
106
- // compat, but the VALUE is now the exact string — the whole point of the #9 realignment.
107
- typeCast: (v) => {
108
- if (v === undefined)
109
- return undefined;
110
- return (0, coltype_1.materializeCell)(v, 'int64');
111
- },
112
- sqlCast: 'bigint',
113
- };
114
- // Array, Object, String, and other types require explicit specification
115
- default:
116
- return undefined;
117
- }
65
+ if (typeof Symbol === 'function' && !Symbol.metadata) {
66
+ // `configurable: true`, as the TC39 proposal's own `Symbol.metadata ??= Symbol('Symbol.metadata')`
67
+ // leaves it: a library has no business locking a global well-known symbol. Locking it made a
68
+ // polyfill loaded LATER throw `Cannot redefine property: metadata`. `Symbol.for` keeps the value
69
+ // identical across copies of this package, so a later polyfill that reuses it is a no-op.
70
+ Object.defineProperty(Symbol, 'metadata', {
71
+ value: Symbol.for('Symbol.metadata'),
72
+ writable: false,
73
+ enumerable: false,
74
+ configurable: true,
75
+ });
118
76
  }
119
77
  /**
120
- * Derive the §4.1 SQL-type token from a field's TS `design:type` (Phase F-2 / #105 option B), for a
121
- * column with NO explicit `sqlCast` family. This types the SCP typed-read de-box for a bare `@column()`
122
- * (the README shape) — extending the SAME `design:type` source `inferTypeCastFromDesignType` reads for
123
- * the read cast, so it stays consistent with v1's read contract (String→TEXT→string, Number→INTEGER→
124
- * number, Boolean→BOOLEAN, Date→TIMESTAMP, BigInt→BIGINT). Returns `undefined` when `design:type` is
125
- * absent (no `emitDecoratorMetadata`) or is a family with no unambiguous SQL type (Array/Object) — the
126
- * adapter then falls back to its documented default / the `columnTypes` escape hatch (REAL/DECIMAL/etc.).
78
+ * Which decorator protocol a decorator was invoked under. Legacy passes `(prototype, propertyKey)`;
79
+ * standard passes `(value, context)` where `context.kind` names the member being decorated. The
80
+ * `kind` probe is the discriminator both emits guarantee.
81
+ * @internal
127
82
  */
128
- function inferBaseSqlTypeFromDesignType(target, propertyKey) {
129
- const designType = Reflect.getMetadata('design:type', target, propertyKey);
130
- if (!designType)
131
- return undefined;
132
- switch (designType) {
133
- case String:
134
- return 'TEXT';
135
- case Number:
136
- return 'INTEGER';
137
- case Boolean:
138
- return 'BOOLEAN';
139
- case Date:
140
- return 'TIMESTAMP';
141
- case BigInt:
142
- return 'BIGINT';
143
- default:
144
- // Array / Object / unknown — no unambiguous scalar SQL type. The adapter's escape hatch pins it.
145
- return undefined;
83
+ function isStandardContext(second) {
84
+ return typeof second === 'object' && second !== null && typeof second.kind === 'string';
85
+ }
86
+ /**
87
+ * The TC39 metadata bag of the class currently being defined. Fail-closed: a toolchain that hands a
88
+ * decorator no bag cannot register a column at all, and silently dropping it is exactly the class of
89
+ * defect this file is being repaired for (issue #287) — so it throws, naming the cause.
90
+ */
91
+ function classMetadataBag(context, propertyKey) {
92
+ const bag = context.metadata;
93
+ if (bag === undefined) {
94
+ throw new Error(`litedbmodel: the standard-decorator context for '${propertyKey}' carries no \`context.metadata\`. ` +
95
+ `litedbmodel defines \`Symbol.metadata\` on import, which every current emit needs to create the ` +
96
+ `bag — import litedbmodel (or 'litedbmodel/decorators') BEFORE the module that defines the model.`);
146
97
  }
98
+ return bag;
147
99
  }
148
100
  /**
149
- * Register column metadata on the model class
101
+ * The COLUMN map a decorator registers into, per protocol.
102
+ *
103
+ * A subclass must NOT write into the map it INHERITS: `Reflect.getMetadata` walks the prototype chain
104
+ * (and a TC39 metadata bag prototype-chains by construction), so mutating the value it returns leaks a
105
+ * subclass's columns into its base — and from there into every SIBLING subclass that extends the same
106
+ * decorated base. Both resolvers therefore COPY the inherited map on first write and own the copy.
150
107
  */
151
- function registerColumn(target, propertyKey, options) {
152
- const constructor = target.constructor;
153
- // Get or create columns map
154
- const columns = Reflect.getMetadata(COLUMNS_KEY, constructor) || new Map();
155
- // Auto-infer type info if not explicitly provided
156
- let finalTypeCast = options.typeCast;
157
- let finalSerialize = options.serialize;
158
- let finalSqlCast = options.sqlCast;
159
- if (!options.skipAutoInfer) {
160
- const inferred = inferTypeCastFromDesignType(target, propertyKey);
161
- if (inferred) {
162
- // Use inferred values only if not explicitly provided
163
- if (!finalTypeCast)
164
- finalTypeCast = inferred.typeCast;
165
- if (!finalSerialize)
166
- finalSerialize = inferred.serialize;
167
- if (!finalSqlCast)
168
- finalSqlCast = inferred.sqlCast;
169
- }
108
+ function legacyColumnMap(constructor) {
109
+ const own = Reflect.getOwnMetadata(COLUMNS_KEY, constructor);
110
+ if (own !== undefined)
111
+ return own;
112
+ const inherited = Reflect.getMetadata(COLUMNS_KEY, constructor);
113
+ const map = new Map(inherited);
114
+ Reflect.defineMetadata(COLUMNS_KEY, map, constructor);
115
+ return map;
116
+ }
117
+ /** @see legacyColumnMap */
118
+ function standardColumnMap(bag) {
119
+ if (!Object.prototype.hasOwnProperty.call(bag, COLUMNS_KEY)) {
120
+ bag[COLUMNS_KEY] = new Map(bag[COLUMNS_KEY]);
170
121
  }
171
- // Phase F-2 (#105 option B): record the base SQL-type token from the field's TS `design:type` so the
172
- // SCP typed-read de-box can type a bare `@column()` (no explicit sqlCast family) correctly — the fix
173
- // for F1's blanket-INTEGER default (which threw `materialize int32` on a live string column). Recorded
174
- // always (independent of the auto-infer skip / explicit sqlCast); the adapter consults it only when
175
- // there is no explicit `sqlCast` family and no `columnTypes` override.
176
- const baseSqlType = inferBaseSqlTypeFromDesignType(target, propertyKey);
122
+ return bag[COLUMNS_KEY];
123
+ }
124
+ /** The RELATION list a decorator registers into. Copy-on-inherit, exactly as the column map. */
125
+ function legacyRelationList(constructor) {
126
+ const own = Reflect.getOwnMetadata(RELATIONS_KEY, constructor);
127
+ if (own !== undefined)
128
+ return own;
129
+ const inherited = Reflect.getMetadata(RELATIONS_KEY, constructor);
130
+ const list = inherited !== undefined ? [...inherited] : [];
131
+ Reflect.defineMetadata(RELATIONS_KEY, list, constructor);
132
+ return list;
133
+ }
134
+ /** @see legacyRelationList */
135
+ function standardRelationList(bag) {
136
+ if (!Object.prototype.hasOwnProperty.call(bag, RELATIONS_KEY)) {
137
+ const inherited = bag[RELATIONS_KEY];
138
+ bag[RELATIONS_KEY] = inherited !== undefined ? [...inherited] : [];
139
+ }
140
+ return bag[RELATIONS_KEY];
141
+ }
142
+ /**
143
+ * Record one column on the class being defined. The ONE place a `@column.*` family lands, for both
144
+ * decorator protocols — the protocol only decides WHICH map is handed in ({@link legacyColumnMap} /
145
+ * {@link standardColumnMap}).
146
+ */
147
+ function registerColumn(columns, propertyKey, options) {
177
148
  columns.set(propertyKey, {
178
149
  columnName: options.columnName,
179
- typeCast: finalTypeCast,
180
- serialize: finalSerialize,
150
+ typeCast: options.typeCast,
151
+ serialize: options.serialize,
181
152
  primaryKey: options.primaryKey,
182
153
  autoIncrement: options.autoIncrement,
183
- sqlCast: finalSqlCast,
184
- ...(baseSqlType !== undefined ? { baseSqlType } : {}),
154
+ sqlCast: options.sqlCast,
155
+ ...(options.baseSqlType !== undefined ? { baseSqlType: options.baseSqlType } : {}),
185
156
  });
186
- Reflect.defineMetadata(COLUMNS_KEY, columns, constructor);
157
+ }
158
+ /** Parse the `columnName | ColumnOptions` argument every family accepts. */
159
+ function parseColumnOptions(columnNameOrOptions, propKey) {
160
+ if (typeof columnNameOrOptions === 'string')
161
+ return { columnName: columnNameOrOptions };
162
+ if (columnNameOrOptions) {
163
+ return {
164
+ columnName: columnNameOrOptions.columnName || propKey,
165
+ primaryKey: columnNameOrOptions.primaryKey,
166
+ autoIncrement: columnNameOrOptions.autoIncrement,
167
+ };
168
+ }
169
+ return { columnName: propKey };
187
170
  }
188
171
  /**
189
- * Create a column decorator with optional type cast and serialize
172
+ * Reject a write a TEXT-shaped family cannot carry.
173
+ *
174
+ * {@link WriteValue} widens every string-reading column to also accept the `Date` / `bigint` that a
175
+ * datetime or bigint column serializes, because `Column<V>` carries only the READ type — the type
176
+ * system cannot tell `@column.text()` from `@column.datetime()` (both read `string`). The FAMILY can,
177
+ * so it says so here: a `Date` handed to a text column reaches the driver unserialized and PostgreSQL
178
+ * stores `{}` — silent corruption, measured. Fail closed, naming the family that would have taken it.
179
+ */
180
+ function rejectNonText(family, val) {
181
+ if (val === null || val === undefined)
182
+ return null;
183
+ if (val instanceof Date) {
184
+ throw new Error(`litedbmodel: @column.${family}() received a Date. A text column cannot serialize one — the ` +
185
+ 'driver would store an object literal. Use @column.datetime() / @column.date() for a date ' +
186
+ 'column, or format the value yourself.');
187
+ }
188
+ if (typeof val === 'object') {
189
+ throw new Error(`litedbmodel: @column.${family}() received an object. Use @column.json<T>() for a JSON column, ` +
190
+ 'or @column.custom() with a serializer.');
191
+ }
192
+ return val;
193
+ }
194
+ /**
195
+ * Build one `@column.*` family decorator. The returned decorator accepts BOTH protocols: legacy hands
196
+ * it `(prototype, propertyKey)`, TC39 standard hands it `(undefined, context)`. Everything that
197
+ * describes the COLUMN (cast, serializer, SQL family) is fixed here by the family itself — nothing is
198
+ * read back from the compiler, so every toolchain produces the same model.
199
+ *
190
200
  * @param typeCast - Function to convert DB value to JS value (read)
191
201
  * @param serialize - Function to convert JS value to DB value (write)
192
- * @param skipAutoInfer - If true, skip auto-inference even if typeCast is undefined
193
- * @param sqlCast - SQL type for automatic casting in conditions (e.g., 'uuid')
202
+ * @param sqlCast - SQL type family for casting in conditions / typed reads (e.g. `'uuid'`)
203
+ * @param baseSqlType - §4.1 SQL-type token for a family that applies no cast (`@column.text()`)
194
204
  */
195
- function createColumnDecorator(typeCast, serialize, skipAutoInfer = false, sqlCast) {
205
+ function createColumnDecorator(typeCast, serialize, sqlCast, baseSqlType) {
196
206
  return function (columnNameOrOptions) {
197
- return function (target, propertyKey) {
198
- const propKey = String(propertyKey);
199
- // If typeCast is explicitly provided, skip auto-inference
200
- const shouldSkipAutoInfer = skipAutoInfer || typeCast !== undefined;
201
- // Parse options
202
- let columnName;
203
- let primaryKey;
204
- let autoIncrement;
205
- if (typeof columnNameOrOptions === 'string') {
206
- columnName = columnNameOrOptions;
207
- }
208
- else if (columnNameOrOptions) {
209
- columnName = columnNameOrOptions.columnName || propKey;
210
- primaryKey = columnNameOrOptions.primaryKey;
211
- autoIncrement = columnNameOrOptions.autoIncrement;
212
- }
213
- else {
214
- columnName = propKey;
207
+ return function (targetOrValue, keyOrContext) {
208
+ if (isStandardContext(keyOrContext)) {
209
+ const propKey = String(keyOrContext.name);
210
+ const parsed = parseColumnOptions(columnNameOrOptions, propKey);
211
+ registerColumn(standardColumnMap(classMetadataBag(keyOrContext, propKey)), propKey, {
212
+ ...parsed, typeCast, serialize, sqlCast, baseSqlType,
213
+ });
214
+ return;
215
215
  }
216
- registerColumn(target, propKey, {
217
- columnName,
218
- typeCast,
219
- serialize,
220
- skipAutoInfer: shouldSkipAutoInfer,
221
- primaryKey,
222
- autoIncrement,
223
- sqlCast,
216
+ const propKey = String(keyOrContext);
217
+ const parsed = parseColumnOptions(columnNameOrOptions, propKey);
218
+ registerColumn(legacyColumnMap(targetOrValue.constructor), propKey, {
219
+ ...parsed, typeCast, serialize, sqlCast, baseSqlType,
224
220
  });
225
221
  };
226
222
  };
227
223
  }
224
+ /**
225
+ * The bare `@column()` — REMOVED. It declared no type: it relied on `design:type`, which only exists
226
+ * under legacy decorators AND `emitDecoratorMetadata`, which esbuild (tsx / vite / vitest) does not
227
+ * implement and TC39 standard decorators do not have. Where that metadata was absent the column
228
+ * silently got NO cast and the raw driver value reached the caller — an `integer` read back as a
229
+ * `bigint`, a `numeric` as a `string` (issue #286). A column states its own type instead.
230
+ */
231
+ function requireColumnFamily() {
232
+ throw new Error('litedbmodel: `@column()` no longer declares a column — a column must state its type with a ' +
233
+ 'family: @column.text() / .number() / .boolean() / .bigint() / .datetime() / .date() / .uuid() / ' +
234
+ '.json() / .stringArray() / .intArray() / .numericArray() / .booleanArray() / .datetimeArray() / ' +
235
+ '.passthrough() / .custom(). Every family takes the same argument as `@column()` did ' +
236
+ "(`@column.text('db_name')`, `@column.number({ primaryKey: true })`). The bare form inferred the " +
237
+ 'type from `emitDecoratorMetadata`, which esbuild (tsx/vite/vitest) never emits and standard ' +
238
+ 'decorators do not have, so it silently produced untyped reads.');
239
+ }
228
240
  /**
229
241
  * Serialize array using driver's type cast if available.
230
242
  * Falls back to PostgreSQL format for backward compatibility.
@@ -293,85 +305,107 @@ function serializeJson(val, typeCast) {
293
305
  /**
294
306
  * Column decorator for defining model properties.
295
307
  *
296
- * **Auto-inference**: For simple types (boolean, number, Date, bigint),
297
- * type conversion is automatically inferred from the TypeScript property type.
298
- * No need to use explicit variants like `@column.boolean()`.
308
+ * **A column declares its own type with a family** — the family decides both the TypeScript type the
309
+ * model declares and the value `find()` gives back, on every toolchain and under either decorator
310
+ * protocol. Nothing is inferred from the TypeScript type annotation.
299
311
  *
300
- * Auto-inferred types:
301
312
  * ```typescript
302
- * @column() id?: number; // Auto: Number conversion
303
- * @column() name?: string; // No conversion needed
304
- * @column() is_active?: boolean; // Auto: Boolean conversion
305
- * @column() created_at?: Date; // Auto: DateTime conversion
306
- * @column() large_id?: bigint; // Auto: BigInt conversion
307
- * @column('custom_name') prop?: string; // Custom column name
313
+ * @column.number({ primaryKey: true, autoIncrement: true }) id?: number;
314
+ * @column.text() name?: string; // TEXT / VARCHAR / CHAR / ENUM — no cast needed
315
+ * @column.text('custom_name') prop?: string; // custom column name
316
+ * @column.boolean() is_active?: boolean;
317
+ * @column.datetime() created_at?: string; // TZ-attached string, NOT a JS Date
318
+ * @column.date() birth_date?: string; // 'YYYY-MM-DD'
319
+ * @column.bigint() large_id?: bigint; // bc `int` — a JS bigint
320
+ * @column.uuid() ext_id?: string;
321
+ * @column.stringArray() tags?: string[];
322
+ * @column.intArray() scores?: number[];
323
+ * @column.json<MyType>() data?: MyType;
324
+ * @column.date() birth_date?: string; // 'YYYY-MM-DD' — not a datetime
325
+ * @column.passthrough() payload?: unknown; // BYTEA / BLOB — no cast exists
326
+ * @column.custom((v) => String(v).toUpperCase()) status?: string;
308
327
  * ```
309
328
  *
310
- * Explicit type conversion required (cannot be auto-inferred):
311
- * ```typescript
312
- * @column.stringArray() tags?: string[]; // Array element type unknown
313
- * @column.intArray() scores?: number[]; // Array element type unknown
314
- * @column.json<MyType>() data?: MyType; // Generic type unknown
315
- * @column.date() birth_date?: string; // date vs datetime distinction
316
- * ```
317
- *
318
- * Note: The explicit variants (`@column.boolean()`, `@column.datetime()`, etc.)
319
- * still work and can be used when you want to be explicit about the conversion.
329
+ * Every family takes what the removed bare `@column()` took: a column name, or {@link ColumnOptions}.
330
+ * The bare form inferred the type from `emitDecoratorMetadata`, which esbuild (tsx / vite / vitest)
331
+ * never emits and TC39 standard decorators do not have, so it silently produced untyped reads; it now
332
+ * reports the migration instead (see {@link requireColumnFamily}).
320
333
  *
321
334
  * @category Decorators
322
335
  */
323
336
  exports.column = Object.assign(
324
- // Basic @column() - no type conversion
325
- createColumnDecorator(), {
337
+ // The bare `@column()` declared no type — it is rejected, with the migration in the message.
338
+ (_columnNameOrOptions) => requireColumnFamily(), {
326
339
  // ============================================
327
340
  // Primitive Types
328
341
  // ============================================
342
+ /**
343
+ * Text type (TEXT / VARCHAR / CHAR / ENUM / citext) — the driver value is already a string, so no
344
+ * cast is applied. The family still DECLARES the column so the typed read types it as `TEXT`.
345
+ * @example @column.text() name?: string;
346
+ * @example @column.text('user_name') name?: string;
347
+ */
348
+ text: (columnNameOrOptions) => createColumnDecorator(
349
+ // The family DECLARES `string`, so it must hand back one. Most drivers already do, but SQLite
350
+ // is dynamically typed: it returns a JS number for a `NUMERIC`/`DECIMAL` column whatever the
351
+ // declaration says, and the declared type would be a lie on that dialect alone.
352
+ (v) => {
353
+ if (v === undefined || v === null)
354
+ return v;
355
+ if (typeof v === 'string')
356
+ return v;
357
+ if (typeof v === 'number' || typeof v === 'bigint' || typeof v === 'boolean')
358
+ return String(v);
359
+ throw new Error(`materialize text: driver returned ${typeof v} for a text column. Use @column.passthrough() ` +
360
+ 'for a BYTEA/BLOB column, or @column.json<T>() for JSON.');
361
+ }, (val) => rejectNonText('text', val), // …and it cannot SERIALIZE a Date or an object
362
+ undefined, // no sqlCast family (a text literal needs no SQL cast)
363
+ 'TEXT' // §4.1 token: types the SCP typed read
364
+ )(columnNameOrOptions),
329
365
  /**
330
366
  * Boolean type conversion
331
367
  * Converts 't'/'f', 'true'/'false', 1/0 to boolean
332
368
  * Preserves null for nullable columns, undefined stays undefined
333
369
  * @example @column.boolean() is_active?: boolean;
334
370
  */
335
- boolean: (columnName) => createColumnDecorator(
371
+ boolean: (columnNameOrOptions) => createColumnDecorator(
336
372
  // v2 read contract: BOOLEAN → boolean, via the shared materializer (issue #9).
337
373
  (v) => {
338
374
  if (v === undefined)
339
375
  return undefined;
340
376
  return (0, coltype_1.materializeCell)(v, 'bool');
341
377
  }, undefined, // no custom serialize
342
- false, // allow auto-inference
343
378
  'boolean' // sqlCast for updateMany type inference
344
- )(columnName),
379
+ )(columnNameOrOptions),
345
380
  /**
346
381
  * Number type conversion (from string)
347
382
  * Preserves null for nullable columns, undefined stays undefined
348
383
  * @example @column.number() amount?: number;
349
384
  */
350
- number: (columnName) => createColumnDecorator((v) => {
385
+ number: (columnNameOrOptions) => createColumnDecorator((v) => {
351
386
  if (v === undefined)
352
387
  return undefined;
353
388
  if (v === null)
354
389
  return null;
355
390
  const n = Number(v);
356
391
  return isNaN(n) ? null : n;
357
- })(columnName),
392
+ })(columnNameOrOptions),
358
393
  /**
359
394
  * BigInt type conversion
360
395
  * Preserves null for nullable columns, undefined stays undefined
361
- * @example @column.bigint() large_id?: bigint;
396
+ * @example @column.bigint() large_id?: bigint; // bc `int` — a JS bigint, checked i64
362
397
  */
363
- bigint: (columnName) => createColumnDecorator(
364
- // v2 read contract: BIGINT/INT8 → EXACT decimal STRING (no i64 rounding, JSON-safe), via the
365
- // shared materializer (issue #9). The declared TS field type may still be `bigint` in existing
366
- // models, but the runtime value is now the exact string — that IS the realignment.
398
+ bigint: (columnNameOrOptions) => createColumnDecorator(
399
+ // An integer column reads back as a JS `bigint` — behavior-contracts' `int` value model
400
+ // (`int` = bigint, checked i64), which is what the drivers already hand over. `JSON.stringify`
401
+ // does not serialize a bigint: give the field a `toJSON`, or convert at the boundary.
367
402
  (v) => {
368
403
  if (v === undefined)
369
404
  return undefined;
370
- return (0, coltype_1.materializeCell)(v, 'int64');
405
+ return (0, coltype_1.materializeCell)(v, 'int');
371
406
  }, undefined, // no custom serialize
372
- false, // allow auto-inference
373
407
  'bigint' // sqlCast for WHERE/INSERT type casting
374
- )(columnName),
408
+ )(columnNameOrOptions),
375
409
  // ============================================
376
410
  // Date/Time Types
377
411
  // ============================================
@@ -383,9 +417,9 @@ createColumnDecorator(), {
383
417
  * - PostgreSQL: Serializes to ISO 8601 UTC string with 'Z' suffix for explicit timezone
384
418
  * - MySQL/SQLite: Passes Date object to driver (driver-dependent timezone handling)
385
419
  *
386
- * @example @column.datetime() created_at?: Date;
420
+ * @example @column.datetime() created_at?: string; // TZ-attached string, NOT a JS Date
387
421
  */
388
- datetime: (columnName) => createColumnDecorator(
422
+ datetime: (columnNameOrOptions) => createColumnDecorator(
389
423
  // v2 read contract: DATE/TIMESTAMP/TIMESTAMPTZ/DATETIME/TIME → TZ-attached STRING (NOT a
390
424
  // TZ-shifted JS Date), via the shared materializer (issue #9). A driver already returning the
391
425
  // native textual form passes through; a JS Date is rendered to its lossless ISO instant.
@@ -402,9 +436,8 @@ createColumnDecorator(), {
402
436
  return typeCast?.serializeDatetime(val) ?? val;
403
437
  }
404
438
  return val;
405
- }, false, // allow auto-inference
406
- 'timestamp' // sqlCast for updateMany type inference
407
- )(columnName),
439
+ }, 'timestamp' // sqlCast for updateMany type inference
440
+ )(columnNameOrOptions),
408
441
  /**
409
442
  * Date type conversion — returns YYYY-MM-DD string.
410
443
  * Preserves null for nullable columns, undefined stays undefined.
@@ -412,9 +445,9 @@ createColumnDecorator(), {
412
445
  * DB values (Date object or string) are normalized to 'YYYY-MM-DD' string.
413
446
  * On write, string values are passed through; Date objects are formatted as 'YYYY-MM-DD'.
414
447
  *
415
- * @example @column.date() birth_date?: string;
448
+ * @example @column.date() birth_date?: string; // 'YYYY-MM-DD'
416
449
  */
417
- date: (columnName) => createColumnDecorator((v) => {
450
+ date: (columnNameOrOptions) => createColumnDecorator((v) => {
418
451
  if (v === undefined)
419
452
  return undefined;
420
453
  if (v === null)
@@ -440,9 +473,8 @@ createColumnDecorator(), {
440
473
  return isNaN(val.getTime()) ? null : (0, TypeCast_1.formatLocalDate)(val);
441
474
  }
442
475
  return val;
443
- }, false, // allow auto-inference
444
- 'date' // sqlCast for updateMany type inference
445
- )(columnName),
476
+ }, 'date' // sqlCast for updateMany type inference
477
+ )(columnNameOrOptions),
446
478
  // ============================================
447
479
  // Array Types
448
480
  // ============================================
@@ -451,60 +483,65 @@ createColumnDecorator(), {
451
483
  * Preserves null for nullable columns, undefined stays undefined
452
484
  * @example @column.stringArray() tags?: string[];
453
485
  */
454
- stringArray: (columnName) => createColumnDecorator((v) => {
486
+ stringArray: (columnNameOrOptions) => createColumnDecorator((v) => {
455
487
  if (v === undefined)
456
488
  return undefined;
457
489
  if (v === null)
458
490
  return null;
459
491
  return (0, TypeCast_1.castToStringArray)(v);
460
- }, serializeArray, false, 'text[]')(columnName),
492
+ }, serializeArray, 'text[]')(columnNameOrOptions),
461
493
  /**
462
494
  * Integer array type conversion (integer[])
463
495
  * Preserves null for nullable columns, undefined stays undefined
464
496
  * @example @column.intArray() scores?: number[];
465
497
  */
466
- intArray: (columnName) => createColumnDecorator((v) => {
498
+ intArray: (columnNameOrOptions) => createColumnDecorator((v) => {
467
499
  if (v === undefined)
468
500
  return undefined;
469
501
  if (v === null)
470
502
  return null;
471
503
  return (0, TypeCast_1.castToIntegerArray)(v);
472
- }, serializeArray, false, 'int[]')(columnName),
504
+ }, serializeArray, 'int[]')(columnNameOrOptions),
473
505
  /**
474
506
  * Numeric array type conversion (numeric[], allows null elements)
475
507
  * Preserves null for nullable columns, undefined stays undefined
476
508
  * @example @column.numericArray() values?: (number | null)[];
477
509
  */
478
- numericArray: (columnName) => createColumnDecorator((v) => {
510
+ numericArray: (columnNameOrOptions) => createColumnDecorator((v) => {
479
511
  if (v === undefined)
480
512
  return undefined;
481
513
  if (v === null)
482
514
  return null;
483
515
  return (0, TypeCast_1.castToNumericArray)(v);
484
- }, serializeArray, false, 'numeric[]')(columnName),
516
+ }, serializeArray, 'numeric[]')(columnNameOrOptions),
485
517
  /**
486
518
  * Boolean array type conversion (boolean[])
487
519
  * Preserves null for nullable columns, undefined stays undefined
488
520
  * @example @column.booleanArray() flags?: (boolean | null)[];
489
521
  */
490
- booleanArray: (columnName) => createColumnDecorator((v) => {
522
+ booleanArray: (columnNameOrOptions) => createColumnDecorator((v) => {
491
523
  if (v === undefined)
492
524
  return undefined;
493
525
  if (v === null)
494
526
  return null;
495
527
  return (0, TypeCast_1.castToBooleanArray)(v);
496
- }, serializeBooleanArray, false, 'boolean[]')(columnName),
528
+ }, serializeBooleanArray, 'boolean[]')(columnNameOrOptions),
497
529
  /**
498
530
  * DateTime array type conversion (timestamp[])
499
531
  * Preserves null for nullable columns, undefined stays undefined
500
- * @example @column.datetimeArray() event_dates?: (Date | null)[];
532
+ * @example @column.datetimeArray() event_dates?: (string | null)[];
501
533
  */
502
- datetimeArray: (columnName) => createColumnDecorator((v) => {
534
+ datetimeArray: (columnNameOrOptions) => createColumnDecorator(
535
+ // Element-wise the SAME contract as `@column.datetime()`: a TZ-attached STRING, never a
536
+ // TZ-shifted JS Date. The array family used to be the one place a `Date` still came back, so
537
+ // `JSON.stringify` of a row silently re-rendered those elements in UTC — the exact hazard #9
538
+ // closed for the scalar.
539
+ (v) => {
503
540
  if (v === undefined)
504
541
  return undefined;
505
542
  if (v === null)
506
543
  return null;
507
- return (0, TypeCast_1.castToDatetimeArray)(v);
544
+ return (0, TypeCast_1.castToDatetimeArray)(v).map((d) => (d === null ? null : (0, coltype_1.materializeCell)(d, 'date')));
508
545
  },
509
546
  // DateTime arrays serialize each Date to ISO string
510
547
  (val) => {
@@ -514,7 +551,7 @@ createColumnDecorator(), {
514
551
  return val;
515
552
  const mapped = val.map(v => v === null ? 'NULL' : (v instanceof Date ? v.toISOString() : String(v)));
516
553
  return `{${mapped.join(',')}}`;
517
- })(columnName),
554
+ })(columnNameOrOptions),
518
555
  // ============================================
519
556
  // JSON Types
520
557
  // ============================================
@@ -524,13 +561,13 @@ createColumnDecorator(), {
524
561
  * @example @column.json() metadata?: Record<string, unknown>;
525
562
  * @example @column.json<UserSettings>() settings?: UserSettings;
526
563
  */
527
- json: (columnName) => createColumnDecorator((v) => {
564
+ json: (columnNameOrOptions) => createColumnDecorator((v) => {
528
565
  if (v === undefined)
529
566
  return undefined;
530
567
  if (v === null)
531
568
  return null;
532
569
  return (0, TypeCast_1.castToJson)(v);
533
- }, serializeJson, false, 'jsonb')(columnName),
570
+ }, serializeJson, 'jsonb')(columnNameOrOptions),
534
571
  // ============================================
535
572
  // UUID Type (PostgreSQL)
536
573
  // ============================================
@@ -560,37 +597,71 @@ createColumnDecorator(), {
560
597
  return null;
561
598
  // UUID values from DB are typically already strings
562
599
  return String(v);
563
- }, undefined, // No serialization needed - handled by sqlCast
564
- true, // Skip auto-inference
600
+ }, (val) => rejectNonText('uuid', val), // the ::uuid cast handles the rest
565
601
  'uuid' // SQL type for casting
566
602
  )(columnNameOrOptions),
567
603
  // ============================================
568
604
  // Custom Type Conversion
569
605
  // ============================================
606
+ /**
607
+ * The driver's value, UNCAST — for a column whose type the library has no cast for (`BYTEA` /
608
+ * `BLOB` arrives as a driver Buffer, a vendor type as whatever the driver decodes). It states
609
+ * "this column is passed through", which is a DECLARATION; the removed bare `@column()` was the
610
+ * absence of one, and produced this same passthrough by accident for every unresolvable type.
611
+ * @example @column.passthrough() payload?: unknown;
612
+ */
613
+ passthrough: (columnNameOrOptions) => createColumnDecorator()(columnNameOrOptions),
570
614
  /**
571
615
  * Custom type conversion with user-provided function
572
616
  * @example @column.custom((v) => String(v).toUpperCase()) status?: string;
573
617
  * @example @column.custom((v) => v, (v) => JSON.stringify(v)) data?: MyType; // with serializer
574
618
  */
575
- custom: (castFn, serializeFn, columnName) => createColumnDecorator(castFn, serializeFn)(columnName),
619
+ custom: (castFn, serializeFn, columnNameOrOptions) => createColumnDecorator(castFn, serializeFn)(columnNameOrOptions),
576
620
  });
577
621
  // ============================================
578
622
  // Relation Decorators
579
623
  // ============================================
580
624
  /**
581
- * Register relation metadata on the model class
625
+ * Record one relation on the class being defined — the ONE place `@hasMany` / `@belongsTo` / `@hasOne`
626
+ * land, for both decorator protocols. The getter itself is installed once, on the prototype, by
627
+ * {@link applyModelDecorator}; see {@link createRelationDecorator} for why the standard protocol needs
628
+ * one extra step to let that getter through.
582
629
  */
583
- function registerRelation(target, propertyKey, type, keysFactory, options) {
584
- const constructor = target.constructor;
585
- // Get or create relations array
586
- const relations = Reflect.getMetadata(RELATIONS_KEY, constructor) || [];
587
- relations.push({
588
- propertyKey,
589
- type,
590
- keysFactory,
591
- options,
592
- });
593
- Reflect.defineMetadata(RELATIONS_KEY, relations, constructor);
630
+ function registerRelation(relations, propertyKey, type, keysFactory, options) {
631
+ relations.push({ propertyKey, type, keysFactory, options });
632
+ }
633
+ /**
634
+ * Build a relation decorator that accepts BOTH protocols.
635
+ *
636
+ * Under legacy decorators a relation is declared `declare posts: Promise<Post[]>` — `declare` emits no
637
+ * field, so the prototype getter `@model` installs is what the instance sees. TC39 standard decorators
638
+ * REJECT a decorated `declare` field (TS1206), so the relation is declared `posts!: Promise<Post[]>`
639
+ * instead — and a class field IS defined on the instance (as `undefined`), which would SHADOW that
640
+ * prototype getter. The initializer below deletes the shadowing own property, so both protocols end up
641
+ * reading the exact same getter; the getter is not duplicated for the standard path.
642
+ */
643
+ function createRelationDecorator(type, keys, options) {
644
+ return function (targetOrValue, keyOrContext) {
645
+ if (isStandardContext(keyOrContext)) {
646
+ const propKey = String(keyOrContext.name);
647
+ registerRelation(standardRelationList(classMetadataBag(keyOrContext, propKey)), propKey, type, keys, options);
648
+ if (typeof keyOrContext.addInitializer !== 'function') {
649
+ throw new Error(`litedbmodel: the standard-decorator context for relation '${propKey}' has no ` +
650
+ '`addInitializer`, so the class field cannot be removed and would shadow the relation getter.');
651
+ }
652
+ keyOrContext.addInitializer(function () {
653
+ // Only under DEFINE semantics does the field land as an own property that shadows the
654
+ // prototype getter; under SET semantics the accessor's setter already swallowed it, and
655
+ // deleting a property that is not there still costs the object its shape.
656
+ if (Object.prototype.hasOwnProperty.call(this, propKey)) {
657
+ delete this[propKey];
658
+ }
659
+ });
660
+ return;
661
+ }
662
+ const propKey = String(keyOrContext);
663
+ registerRelation(legacyRelationList(targetOrValue.constructor), propKey, type, keys, options);
664
+ };
594
665
  }
595
666
  /**
596
667
  * HasMany relation decorator (1:N).
@@ -601,7 +672,7 @@ function registerRelation(target, propertyKey, type, keysFactory, options) {
601
672
  *
602
673
  * @example
603
674
  * ```typescript
604
- * // Single key relation
675
+ * // Single key relation (legacy decorators: `declare`; standard decorators: `posts!: …`)
605
676
  * @hasMany(() => [User.id, Post.author_id])
606
677
  * declare posts: Promise<Post[]>;
607
678
  *
@@ -623,9 +694,7 @@ function registerRelation(target, propertyKey, type, keysFactory, options) {
623
694
  * @category Decorators
624
695
  */
625
696
  function hasMany(keys, options) {
626
- return function (target, propertyKey) {
627
- registerRelation(target, String(propertyKey), 'hasMany', keys, options);
628
- };
697
+ return createRelationDecorator('hasMany', keys, options);
629
698
  }
630
699
  /**
631
700
  * BelongsTo relation decorator (N:1).
@@ -651,9 +720,7 @@ function hasMany(keys, options) {
651
720
  * @category Decorators
652
721
  */
653
722
  function belongsTo(keys, options) {
654
- return function (target, propertyKey) {
655
- registerRelation(target, String(propertyKey), 'belongsTo', keys, options);
656
- };
723
+ return createRelationDecorator('belongsTo', keys, options);
657
724
  }
658
725
  /**
659
726
  * HasOne relation decorator (1:1).
@@ -679,9 +746,7 @@ function belongsTo(keys, options) {
679
746
  * @category Decorators
680
747
  */
681
748
  function hasOne(keys, options) {
682
- return function (target, propertyKey) {
683
- registerRelation(target, String(propertyKey), 'hasOne', keys, options);
684
- };
749
+ return createRelationDecorator('hasOne', keys, options);
685
750
  }
686
751
  /**
687
752
  * Get relation metadata from a model class
@@ -693,16 +758,62 @@ function getRelationMeta(modelClass) {
693
758
  []);
694
759
  }
695
760
  // Implementation
696
- function model(tableNameOrConstructor, options) {
761
+ function model(tableNameOrConstructor,
762
+ // `@model('t', options)` passes ModelOptions; a bare `@model` under the standard protocol passes the
763
+ // ClassDecoratorContext in this position instead.
764
+ optionsOrContext) {
697
765
  // Called as @model('table_name') or @model('table_name', options)
698
766
  if (typeof tableNameOrConstructor === 'string') {
699
767
  const tableName = tableNameOrConstructor;
700
- return function (constructor) {
701
- return applyModelDecorator(constructor, tableName, options);
768
+ const options = optionsOrContext;
769
+ return function (constructor, context) {
770
+ return applyToClass(constructor, context, tableName, options);
702
771
  };
703
772
  }
704
- // Called as @model (without parentheses or arguments)
705
- return applyModelDecorator(tableNameOrConstructor);
773
+ // Called as `@model` without parentheses. The standard protocol invokes a CLASS decorator as
774
+ // `(value, context)`, so the second argument here is that CONTEXT, not `ModelOptions` — reading it
775
+ // as options and applying the legacy path registered a model with NO columns at all, silently.
776
+ return applyToClass(tableNameOrConstructor, optionsOrContext);
777
+ }
778
+ /**
779
+ * Apply `@model` under either decorator protocol. The ONE place the class decorator's protocol
780
+ * difference lives, so `@model` and `@model('t')` cannot diverge.
781
+ *
782
+ * Under the standard protocol the class is not finished when its decorator runs: a bundler that
783
+ * preserves names (esbuild's `--keep-names`, which `tsx` turns on) re-defines `Class.name` AFTER the
784
+ * decorator returns. Installing the static column accessors here would either be clobbered by that,
785
+ * or — since they are non-configurable — make it throw `Cannot redefine property: name` on any model
786
+ * with a `name` column. A class decorator's extra initializer is the one hook that runs after the
787
+ * class is fully formed, so the model is assembled there.
788
+ */
789
+ function applyToClass(constructor, context, tableName, options) {
790
+ const members = modelMembers(constructor, context);
791
+ if (!isStandardContext(context))
792
+ return applyModelDecorator(constructor, members, tableName, options);
793
+ // Fail-closed, exactly as `classMetadataBag` does: assembling nothing and returning the class is how
794
+ // a model ends up with no columns and no error — the defect this file exists to remove.
795
+ if (typeof context.addInitializer !== 'function') {
796
+ throw new Error(`litedbmodel: the standard-decorator context for '${constructor.name}' has no ` +
797
+ '`addInitializer`, so `@model` cannot assemble the model after the class is defined.');
798
+ }
799
+ context.addInitializer(function () {
800
+ applyModelDecorator(this, members, tableName, options);
801
+ });
802
+ return constructor;
803
+ }
804
+ /** The columns + relations `@column.*` / `@hasMany` … recorded for this class, per protocol. */
805
+ function modelMembers(constructor, context) {
806
+ if (isStandardContext(context)) {
807
+ const bag = classMetadataBag(context, String(constructor.name ?? 'model'));
808
+ return {
809
+ columns: bag[COLUMNS_KEY] ?? new Map(),
810
+ relations: bag[RELATIONS_KEY] ?? [],
811
+ };
812
+ }
813
+ return {
814
+ columns: Reflect.getMetadata(COLUMNS_KEY, constructor) ?? new Map(),
815
+ relations: Reflect.getMetadata(RELATIONS_KEY, constructor) ?? [],
816
+ };
706
817
  }
707
818
  /**
708
819
  * Check if keys are composite (array of pairs) or single pair
@@ -738,9 +849,8 @@ function parseKeys(keys) {
738
849
  /**
739
850
  * Internal function to apply the model decorator
740
851
  */
741
- function applyModelDecorator(constructor, tableName, options) {
742
- const columns = Reflect.getMetadata(COLUMNS_KEY, constructor) || new Map();
743
- const relations = Reflect.getMetadata(RELATIONS_KEY, constructor) || [];
852
+ function applyModelDecorator(constructor, members, tableName, options) {
853
+ const { columns, relations } = members;
744
854
  const modelName = constructor.name;
745
855
  // 0. Set TABLE_NAME if provided
746
856
  if (tableName) {
@@ -877,6 +987,27 @@ function applyModelDecorator(constructor, tableName, options) {
877
987
  relationName: propertyKey,
878
988
  });
879
989
  },
990
+ /**
991
+ * A relation is a getter, but a class FIELD declaring it still writes to the property. Which
992
+ * write it is depends on `useDefineForClassFields`: with define semantics (the default from
993
+ * target ES2022) the field is `defineProperty`-d onto the instance, bypassing this setter — the
994
+ * standard protocol's initializer removes that shadowing property. With SET semantics (the
995
+ * default below ES2022) the field ASSIGNS, and an accessor with no setter throws
996
+ * `Cannot set property … which has only a getter` on EVERY instantiation.
997
+ *
998
+ * So: the field's own `undefined` is ignored, and any other value is honoured as an own
999
+ * property — priming a relation (a loaded graph, a test double) reads back what was set.
1000
+ */
1001
+ set: function (value) {
1002
+ if (value === undefined)
1003
+ return;
1004
+ Object.defineProperty(this, propertyKey, {
1005
+ value,
1006
+ writable: true,
1007
+ enumerable: true,
1008
+ configurable: true,
1009
+ });
1010
+ },
880
1011
  enumerable: true,
881
1012
  configurable: false,
882
1013
  });