@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.
- package/db.win32-x64-msvc.node +0 -0
- package/dist/BaseRepository.d.ts.map +1 -1
- package/dist/BaseRepository.js +93 -8
- package/dist/BaseRepository.js.map +1 -1
- package/dist/adapters/NapiDbAdapter.d.ts.map +1 -1
- package/dist/adapters/NapiDbAdapter.js +18 -6
- package/dist/adapters/NapiDbAdapter.js.map +1 -1
- package/dist/decorators/entity.d.ts +38 -5
- package/dist/decorators/entity.d.ts.map +1 -1
- package/dist/decorators/entity.js +40 -9
- package/dist/decorators/entity.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/schema/MigrationRunner.d.ts.map +1 -1
- package/dist/schema/MigrationRunner.js.map +1 -1
- package/index.win32-x64-msvc.node +0 -0
- package/package.json +1 -1
- package/src/BaseRepository.ts +97 -8
- package/src/adapters/NapiDbAdapter.ts +18 -8
- package/src/decorators/entity.ts +53 -12
- package/src/index.ts +1 -0
- package/src/schema/MigrationRunner.ts +1 -0
package/src/BaseRepository.ts
CHANGED
|
@@ -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
|
-
[
|
|
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
|
-
{
|
|
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
|
-
{
|
|
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
|
-
{
|
|
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
|
-
{
|
|
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
|
-
{
|
|
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
|
-
"
|
|
56
|
-
"and so does building the placeholders yourself:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
450
|
-
|
|
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
|
-
|
|
466
|
-
|
|
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)
|
package/src/decorators/entity.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
/**
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
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
|
-
*
|
|
375
|
-
*
|
|
376
|
-
*
|
|
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
|
-
|
|
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
|
@@ -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[]>;
|