@c9up/atlas 0.3.6 → 0.3.8

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.
@@ -198,6 +198,30 @@ const POSTGRES_CAST_TYPES = new Set([
198
198
  "float",
199
199
  ]);
200
200
 
201
+ /**
202
+ * Parse a JSON column's text, or hand back the text unchanged.
203
+ *
204
+ * Unparseable text is a row the column's type says cannot exist — on SQLite it
205
+ * can, because the column is TEXT there and nothing enforces the shape. Failing
206
+ * the hydration would make the whole row unreadable, including the columns that
207
+ * are fine, so the raw string is returned: visible at the use site, and nothing
208
+ * is lost or rewritten.
209
+ */
210
+ function parseJsonColumn(text: string): unknown {
211
+ try {
212
+ return JSON.parse(text);
213
+ } catch {
214
+ return text;
215
+ }
216
+ }
217
+
218
+ /** Whether a declared `@Column({ type })` is a JSON column. */
219
+ function isJsonType(type: string | undefined): boolean {
220
+ if (type === undefined) return false;
221
+ const normalized = type.trim().toLowerCase();
222
+ return normalized === "json" || normalized === "jsonb";
223
+ }
224
+
201
225
  /**
202
226
  * Snake column → logical type for params needing a Postgres `$N::<type>` cast.
203
227
  * sqlx binds JS strings as `text`; Postgres won't implicitly coerce that to
@@ -266,6 +290,14 @@ export class BaseRepository<T extends BaseEntity> {
266
290
  #columnMap: Map<string, string>; // property/db name → resolved db column (cached)
267
291
  #columnByDbName: Map<string, string>; // resolved db column → property (for hydrate)
268
292
  #dateColumns: Record<string, DateColumnConfig>;
293
+ /**
294
+ * Properties declared `@Column({ type: 'json' })` / `'jsonb'`.
295
+ *
296
+ * They get the same treatment date columns get: serialised on the way down,
297
+ * parsed on the way back, without the application writing a `prepare` /
298
+ * `consume` pair for a conversion the declared type already states.
299
+ */
300
+ #jsonColumns: Set<string>;
269
301
  /** Snake column → logical type for params needing a Postgres `::cast`. */
270
302
  #castTypes: Record<string, string>;
271
303
  /**
@@ -343,6 +375,11 @@ export class BaseRepository<T extends BaseEntity> {
343
375
  this.#columns = columnsMeta.map((c) => c.propertyKey);
344
376
  this.#softDeletes = hasSoftDeletes(entityClass);
345
377
  this.#dateColumns = getDateColumnConfig(entityClass);
378
+ this.#jsonColumns = new Set(
379
+ columnsMeta
380
+ .filter((col) => isJsonType(col.type))
381
+ .map((col) => col.propertyKey),
382
+ );
346
383
 
347
384
  // Lift per-column `prepare` / `consume` callbacks directly from metadata.
348
385
  // No global registry, no late-registration concern: callbacks are baked
@@ -1419,6 +1456,18 @@ export class BaseRepository<T extends BaseEntity> {
1419
1456
  // Branch order mirrors Lucid's `prepareDateColumn` (strings pass through,
1420
1457
  // `DateTime` is formatted, anything else throws naming the column) — see
1421
1458
  // `#prepareDateString` for the one named deviation.
1459
+ // A JSON column takes the value as JSON text — objects and arrays alike.
1460
+ // The binder underneath is typed strictly, so nothing downstream guesses
1461
+ // what a JS value meant, and doing the encoding here is what makes the
1462
+ // three dialects behave identically. A string is passed through: it is
1463
+ // already JSON text, or a scalar the column accepts as-is.
1464
+ if (
1465
+ this.#jsonColumns.has(propertyKey) &&
1466
+ typeof value === "object" &&
1467
+ value !== null
1468
+ ) {
1469
+ return JSON.stringify(value);
1470
+ }
1422
1471
  const dateColumn = this.#dateColumns[propertyKey];
1423
1472
  if (dateColumn && value != null) {
1424
1473
  // `@column.date()` persists the date alone, like Lucid's
@@ -1500,6 +1549,14 @@ export class BaseRepository<T extends BaseEntity> {
1500
1549
  if (this.#dateColumns[propertyKey] && value != null) {
1501
1550
  return dateTimeAtlasAdapter.consume(value);
1502
1551
  }
1552
+ // A JSON column hydrates to the value it holds, not to its text. The
1553
+ // native Postgres pool already decodes `json`/`jsonb` to a real value;
1554
+ // this is what makes MySQL and SQLite — where the column is TEXT — agree
1555
+ // with it, instead of handing the application a string on two dialects
1556
+ // out of three.
1557
+ if (this.#jsonColumns.has(propertyKey) && typeof value === "string") {
1558
+ return parseJsonColumn(value);
1559
+ }
1503
1560
  return value;
1504
1561
  }
1505
1562
 
@@ -1538,13 +1595,25 @@ export class BaseRepository<T extends BaseEntity> {
1538
1595
  const now = DateTime.now();
1539
1596
  await this.#runUpdate(
1540
1597
  [[this.#dbColumn("deletedAt"), now.toISO()]],
1541
- [{ column: this.#primaryKey, operator: "=", value: pk, type: "and" }],
1598
+ [
1599
+ {
1600
+ column: this.#dbColumn(this.#primaryKey),
1601
+ operator: "=",
1602
+ value: pk,
1603
+ type: "and",
1604
+ },
1605
+ ],
1542
1606
  );
1543
1607
  // In-memory value is a Chronos DateTime, matching how date columns hydrate.
1544
1608
  entity.setProp("deletedAt", now);
1545
1609
  } else {
1546
1610
  await this.#runDelete([
1547
- { column: this.#primaryKey, operator: "=", value: pk, type: "and" },
1611
+ {
1612
+ column: this.#dbColumn(this.#primaryKey),
1613
+ operator: "=",
1614
+ value: pk,
1615
+ type: "and",
1616
+ },
1548
1617
  ]);
1549
1618
  }
1550
1619
  entity.markAsDeleted();
@@ -1562,7 +1631,7 @@ export class BaseRepository<T extends BaseEntity> {
1562
1631
  await fireHooks(this.#entityClass, "beforeDelete", entity);
1563
1632
  await this.#runDelete([
1564
1633
  {
1565
- column: this.#primaryKey,
1634
+ column: this.#dbColumn(this.#primaryKey),
1566
1635
  operator: "=",
1567
1636
  value: entity[this.#primaryKey],
1568
1637
  type: "and",
@@ -1579,7 +1648,7 @@ export class BaseRepository<T extends BaseEntity> {
1579
1648
  [[this.#dbColumn("deletedAt"), null]],
1580
1649
  [
1581
1650
  {
1582
- column: this.#primaryKey,
1651
+ column: this.#dbColumn(this.#primaryKey),
1583
1652
  operator: "=",
1584
1653
  value: entity[this.#primaryKey],
1585
1654
  type: "and",
@@ -1597,7 +1666,12 @@ export class BaseRepository<T extends BaseEntity> {
1597
1666
  ): Promise<void> {
1598
1667
  const set = this.#buildSetPairs(data);
1599
1668
  await this.#runUpdate(set, [
1600
- { column: this.#primaryKey, operator: "=", value: id, type: "and" },
1669
+ {
1670
+ column: this.#dbColumn(this.#primaryKey),
1671
+ operator: "=",
1672
+ value: id,
1673
+ type: "and",
1674
+ },
1601
1675
  ]);
1602
1676
  }
1603
1677
 
@@ -1646,7 +1720,12 @@ export class BaseRepository<T extends BaseEntity> {
1646
1720
  ): Promise<void> {
1647
1721
  const set = this.#buildIncrementPairs(columnOrMap, amount, "increment");
1648
1722
  await this.#runUpdate(set, [
1649
- { column: this.#primaryKey, operator: "=", value: id, type: "and" },
1723
+ {
1724
+ column: this.#dbColumn(this.#primaryKey),
1725
+ operator: "=",
1726
+ value: id,
1727
+ type: "and",
1728
+ },
1650
1729
  ]);
1651
1730
  }
1652
1731
 
@@ -1667,7 +1746,12 @@ export class BaseRepository<T extends BaseEntity> {
1667
1746
  ): Promise<void> {
1668
1747
  const set = this.#buildIncrementPairs(columnOrMap, amount, "decrement");
1669
1748
  await this.#runUpdate(set, [
1670
- { column: this.#primaryKey, operator: "=", value: id, type: "and" },
1749
+ {
1750
+ column: this.#dbColumn(this.#primaryKey),
1751
+ operator: "=",
1752
+ value: id,
1753
+ type: "and",
1754
+ },
1671
1755
  ]);
1672
1756
  }
1673
1757
 
@@ -1919,7 +2003,12 @@ export class BaseRepository<T extends BaseEntity> {
1919
2003
  }
1920
2004
 
1921
2005
  await this.#runUpdate(setPairs, [
1922
- { column: this.#primaryKey, operator: "=", value: pk, type: "and" },
2006
+ {
2007
+ column: this.#dbColumn(this.#primaryKey),
2008
+ operator: "=",
2009
+ value: pk,
2010
+ type: "and",
2011
+ },
1923
2012
  ]);
1924
2013
  // Re-snapshot after a successful UPDATE.
1925
2014
  entity.markAsPersisted();
@@ -45,6 +45,11 @@ function napiReplacer(_key: string, value: unknown): unknown {
45
45
  *
46
46
  * Only the TOP level is checked: a nested array is a JSON value inside an
47
47
  * object being bound to a `json`/`jsonb` column, which is legitimate.
48
+ *
49
+ * A column declared `@Column({ type: 'json' })` never reaches here as an array:
50
+ * the repository serialises it on the way down, the way it lowers a date
51
+ * column. This fires for the list that was meant to be an `IN`, and for a raw
52
+ * query with no entity behind it to say what the column holds.
48
53
  */
49
54
  function assertNoArrayParams(params: readonly unknown[] | undefined): void {
50
55
  if (!params) return;
@@ -52,8 +57,11 @@ function assertNoArrayParams(params: readonly unknown[] | undefined): void {
52
57
  if (!Array.isArray(value)) continue;
53
58
  throw new Error(
54
59
  `[E_ARRAY_PARAM] Parameter $${index + 1} is an array, which cannot be bound as a single value. ` +
55
- "Expand it into one placeholder per element — `whereIn(column, values)` does this, " +
56
- "and so does building the placeholders yourself: `IN (${values.map((_, i) => '$' + (i + 1)).join(', ')})`. " +
60
+ "For a list of values, expand it into one placeholder per element — `whereIn(column, values)` " +
61
+ "does this, and so does building the placeholders yourself: " +
62
+ `\`IN (\${values.map((_, i) => '$' + (i + 1)).join(', ')})\`. ` +
63
+ "For a JSON column, declare it — `@Column({ type: 'jsonb' })` — and the array is serialised " +
64
+ "and parsed for you; on a raw query with no entity behind it, pass `JSON.stringify(value)`. " +
57
65
  "Binding the array itself sends Postgres text where it expects an array, and it reports " +
58
66
  "a nonsensical dimension count rather than a type error.",
59
67
  );
@@ -303,9 +311,10 @@ export async function createNapiConnection(
303
311
  sql: string,
304
312
  params: unknown[] = [],
305
313
  ): Promise<{ rowsAffected: number; lastInsertId?: number }> {
314
+ assertNoArrayParams(params);
306
315
  const json = await native.execute(
307
316
  sql,
308
- (assertNoArrayParams(params), JSON.stringify(params, napiReplacer)),
317
+ JSON.stringify(params, napiReplacer),
309
318
  );
310
319
  const r = JSON.parse(json);
311
320
  return {
@@ -318,9 +327,10 @@ export async function createNapiConnection(
318
327
  sql: string,
319
328
  params: unknown[] = [],
320
329
  ): Promise<T[]> {
330
+ assertNoArrayParams(params);
321
331
  const json = await native.query(
322
332
  sql,
323
- (assertNoArrayParams(params), JSON.stringify(params, napiReplacer)),
333
+ JSON.stringify(params, napiReplacer),
324
334
  );
325
335
  return JSON.parse(json, napiReviver) as T[];
326
336
  },
@@ -446,8 +456,8 @@ export async function createNapiConnection(
446
456
  meta?: QueryMeta,
447
457
  ): Promise<T[]> {
448
458
  return observed(sql, params, meta, async () => {
449
- const paramsJson =
450
- (assertNoArrayParams(params), JSON.stringify(params, napiReplacer));
459
+ assertNoArrayParams(params);
460
+ const paramsJson = JSON.stringify(params, napiReplacer);
451
461
  const json =
452
462
  meta?.serverTimeoutMs != null
453
463
  ? await db.queryTimed(sql, paramsJson, meta.serverTimeoutMs)
@@ -462,8 +472,8 @@ export async function createNapiConnection(
462
472
  meta?: QueryMeta,
463
473
  ): Promise<{ rowsAffected: number; lastInsertId?: number }> {
464
474
  return observed(sql, params, meta, async () => {
465
- const paramsJson =
466
- (assertNoArrayParams(params), JSON.stringify(params, napiReplacer));
475
+ assertNoArrayParams(params);
476
+ const paramsJson = JSON.stringify(params, napiReplacer);
467
477
  const json =
468
478
  meta?.serverTimeoutMs != null
469
479
  ? await db.executeTimed(sql, paramsJson, meta.serverTimeoutMs)
@@ -214,7 +214,7 @@ export function Entity(tableName: string): ClassDecorator {
214
214
  }
215
215
 
216
216
  /** @Column() — marks a property as a database column. */
217
- export function Column(options?: ColumnOptions): PropertyDecorator {
217
+ function ColumnBase(options?: ColumnOptions): PropertyDecorator {
218
218
  return (target, propertyKey) => {
219
219
  const columns: ColumnMetadata[] =
220
220
  Reflect.getOwnMetadata(COLUMNS_KEY, target.constructor) ?? [];
@@ -362,23 +362,64 @@ function columnDateTime(options?: DateTimeColumnOptions): PropertyDecorator {
362
362
  };
363
363
  }
364
364
 
365
- /** Namespace access so users write `@column.date()` / `@column.dateTime()`. */
366
- const columnWithSubs = Column as typeof Column & {
367
- date: typeof columnDate;
368
- dateTime: typeof columnDateTime;
369
- };
370
- columnWithSubs.date = columnDate;
371
- columnWithSubs.dateTime = columnDateTime;
365
+ /** Options for `@Column.json()`. */
366
+ export interface JsonColumnOptions extends ColumnOptions {
367
+ /**
368
+ * The SQL type the column is declared with. `jsonb` by default — the binary
369
+ * form, which is what a Postgres schema almost always wants. Pass `'json'`
370
+ * for the textual one.
371
+ */
372
+ type?: "json" | "jsonb";
373
+ }
372
374
 
373
375
  /**
374
- * `Column` exposed with its sub-decorators (`Column.date()`, `Column.dateTime()`).
375
- * Alias exports so TS users can `import { column } from '@c9up/atlas'` for a
376
- * Lucid-style lowercase naming when they prefer.
376
+ * `@Column.json()` a column holding a JSON document.
377
+ *
378
+ * The property keeps its JavaScript shape on both sides: assign an object or an
379
+ * array, read an object or an array back. atlas serialises the value when it
380
+ * writes the row and parses it when it reads one, so nothing in the application
381
+ * converts by hand, and every dialect behaves the same — Postgres decodes JSON
382
+ * itself, while SQLite and MySQL store text.
383
+ *
384
+ * @Column.json() declare metadata: Record<string, unknown> | null
385
+ * @Column.json({ type: 'json' }) declare tags: string[] | null
386
+ *
387
+ * A `prepare` or `consume` given here still wins: the column is yours to
388
+ * encode differently when the document needs it.
389
+ *
390
+ * Declaring `@Column({ type: 'jsonb' })` does the same thing — the type is what
391
+ * atlas reads — but this says it in one place and cannot be mistyped.
377
392
  */
378
- export const column = Column as typeof Column & {
393
+ function columnJson(options?: JsonColumnOptions): PropertyDecorator {
394
+ return (target, propertyKey) => {
395
+ Column({ ...options, type: options?.type ?? "jsonb" })(target, propertyKey);
396
+ };
397
+ }
398
+
399
+ /**
400
+ * `@Column()` with its sub-decorators attached: `Column.date()`,
401
+ * `Column.dateTime()`, `Column.json()`.
402
+ *
403
+ * The type carries them too — attaching them to the plain function left
404
+ * `Column.date` working at runtime but unknown to TypeScript, so only the
405
+ * lowercase alias type-checked.
406
+ */
407
+ const ColumnWithSubs = ColumnBase as typeof ColumnBase & {
379
408
  date: typeof columnDate;
380
409
  dateTime: typeof columnDateTime;
410
+ json: typeof columnJson;
381
411
  };
412
+ ColumnWithSubs.date = columnDate;
413
+ ColumnWithSubs.dateTime = columnDateTime;
414
+ ColumnWithSubs.json = columnJson;
415
+
416
+ export const Column = ColumnWithSubs;
417
+
418
+ /**
419
+ * Also exported lowercase, so `import { column } from '@c9up/atlas'` reads the
420
+ * way a schema definition usually does. The same decorator, either spelling.
421
+ */
422
+ export const column = ColumnWithSubs;
382
423
 
383
424
  /** Read the date-column configuration map for an entity class (walks prototype chain). */
384
425
  export function getDateColumnConfig(
package/src/index.ts CHANGED
@@ -72,6 +72,7 @@ export type {
72
72
  DateColumnConfig,
73
73
  DateTimeColumnOptions,
74
74
  EntityMetadata,
75
+ JsonColumnOptions,
75
76
  ManyToManyOptions,
76
77
  RelationMetadata,
77
78
  } from "./decorators/entity.js";
@@ -93,6 +93,7 @@ export interface DatabaseAdapter {
93
93
  execute(
94
94
  sql: string,
95
95
  params?: unknown[],
96
+ // biome-ignore lint/suspicious/noConfusingVoidType: `void` is the point — an implementation that returns nothing must satisfy this, and `undefined` rejects a `Promise<void>`.
96
97
  ): Promise<void | { rowsAffected: number }>;
97
98
  /** Query rows with optional parameterized values. */
98
99
  query<T>(sql: string, params?: unknown[]): Promise<T[]>;