@getstrata/core 1.0.6 → 1.0.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/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # @getstrata/core changelog
2
2
 
3
+ ## 1.0.8
4
+
5
+ - Container image starts and ships production dependencies only.
6
+
7
+ ## 1.0.7
8
+
3
9
  ## 1.0.6
4
10
 
5
11
  - Query `pluck` and `value` on `RepositoryQuery`, `ModelQuery`, and relation queries. `pluck(column)` returns `T[]`; `pluck(column, keyBy)` returns a `Map`. Models apply `$casts` and skip hydration/observers. Generated apps and lockstep packages move to `^1.0.6`.
@@ -28,6 +28,7 @@ declare class BaseRepository<TEntity extends object, PrimaryKey extends keyof TE
28
28
  perPage: number;
29
29
  } & Omit<ExtendedQueryOptions<TEntity>, "limit" | "offset">): Promise<PaginatedResult<TEntity>>;
30
30
  chunk(count: number, callback: (rows: TEntity[]) => Promise<boolean | void>, options?: Omit<ExtendedQueryOptions<TEntity>, "limit" | "offset">): Promise<void>;
31
+ chunkById(count: number, callback: (rows: TEntity[]) => Promise<boolean | undefined>, options?: Omit<ExtendedQueryOptions<TEntity>, "limit" | "offset" | "orderBy">): Promise<void>;
31
32
  cursorPaginate(options: {
32
33
  perPage: number;
33
34
  cursor?: TEntity[PrimaryKey];
@@ -38,6 +39,9 @@ declare class BaseRepository<TEntity extends object, PrimaryKey extends keyof TE
38
39
  findByIdOrThrow(id: TEntity[PrimaryKey], errorFactory?: ErrorFactory<TEntity[PrimaryKey]>): Promise<TEntity>;
39
40
  findByIds(ids: readonly TEntity[PrimaryKey][]): Promise<TEntity[]>;
40
41
  firstOrNull(where: QueryWhere<TEntity>, options?: Omit<QueryOptions<TEntity>, "where" | "limit">): Promise<TEntity | null>;
42
+ upsert(values: MutationValues<TEntity>, conflictColumns: readonly (keyof TEntity & string)[], updateColumns?: readonly (keyof TEntity & string)[]): Promise<TEntity | null>;
43
+ incrementById(id: TEntity[PrimaryKey], column: keyof TEntity & string, amount?: number, extra?: UpdateValues<TEntity, PrimaryKey>): Promise<TEntity | null>;
44
+ decrementById(id: TEntity[PrimaryKey], column: keyof TEntity & string, amount?: number, extra?: UpdateValues<TEntity, PrimaryKey>): Promise<TEntity | null>;
41
45
  create(values: MutationValues<TEntity>): Promise<TEntity>;
42
46
  updateById(id: TEntity[PrimaryKey], changes: UpdateValues<TEntity, PrimaryKey>): Promise<TEntity | null>;
43
47
  updateByIdOrThrow(id: TEntity[PrimaryKey], changes: UpdateValues<TEntity, PrimaryKey>, errorFactory?: ErrorFactory<TEntity[PrimaryKey]>): Promise<TEntity>;
@@ -53,6 +57,11 @@ declare class BaseRepository<TEntity extends object, PrimaryKey extends keyof TE
53
57
  protected countWhere(where?: QueryWhere<TEntity>, options?: Pick<QueryOptions<TEntity>, "withTrashed" | "onlyTrashed" | "joins" | "groupBy">, whereNodes?: readonly WhereNode<TEntity>[]): Promise<number>;
54
58
  protected averageColumn(column: keyof TEntity & string, where?: QueryWhere<TEntity>): Promise<number>;
55
59
  protected averageExpression(expression: string, alias: string, where?: QueryWhere<TEntity>): Promise<number>;
60
+ sum(column: keyof TEntity & string, where?: QueryWhere<TEntity>): Promise<number>;
61
+ avg(column: keyof TEntity & string, where?: QueryWhere<TEntity>): Promise<number>;
62
+ min(column: keyof TEntity & string, where?: QueryWhere<TEntity>): Promise<number>;
63
+ max(column: keyof TEntity & string, where?: QueryWhere<TEntity>): Promise<number>;
64
+ private aggregateColumn;
56
65
  protected pluckNumberValues(expression: string, alias: string, options?: QueryOptions<TEntity>): Promise<number[]>;
57
66
  protected countGroupedBy<K extends keyof TEntity & string>(column: K, where?: QueryWhere<TEntity>): Promise<Array<{
58
67
  value: TEntity[K] | null;
@@ -10,6 +10,8 @@ interface SqlDialect {
10
10
  ilikeOperator(): "ILIKE" | "LIKE";
11
11
  nullsLastSuffix(): string;
12
12
  castToText(expression: string): string;
13
+ /** Conflict handling appended to an INSERT. Empty updates mean "do nothing". */
14
+ upsertSuffix(conflictColumns: readonly string[], updateColumns: readonly string[]): string;
13
15
  }
14
16
  declare function dialectFor(driver: DatabaseDriver): SqlDialect;
15
17
  declare function currentSqlDialect(): SqlDialect;
@@ -43,7 +43,20 @@ declare class ModelQuery {
43
43
  limit(limit: number): this;
44
44
  offset(offset: number): this;
45
45
  whereNull(column: string): this;
46
+ whereNotNull(column: string): this;
46
47
  whereIn(column: string, values: readonly unknown[]): this;
48
+ whereNotIn(column: string, values: readonly unknown[]): this;
49
+ groupBy(groupBy: QueryOptions<object>["groupBy"]): this;
50
+ having(having: QueryWhere<object>): this;
51
+ join(left: `${string}.${string}`, right: `${string}.${string}`): this;
52
+ leftJoin(left: `${string}.${string}`, right: `${string}.${string}`): this;
53
+ paginate(options: {
54
+ page: number;
55
+ perPage: number;
56
+ }): Promise<{
57
+ data: Array<Model<Record<string, unknown>, "id">>;
58
+ meta: Awaited<ReturnType<RepositoryQuery<Record<string, unknown>, "id">["paginate"]>>["meta"];
59
+ }>;
47
60
  whereExists(sql: string, params?: readonly unknown[]): this;
48
61
  whereNotExists(sql: string, params?: readonly unknown[]): this;
49
62
  whereHas(name: string, constrain?: (query: AnyRelationQuery) => void): this;
@@ -60,6 +73,7 @@ declare class ModelQuery {
60
73
  withTrashed(): this;
61
74
  onlyTrashed(): this;
62
75
  get(): Promise<Array<Model<Record<string, unknown>, "id">>>;
76
+ private hydrateRows;
63
77
  first(): Promise<Model<Record<string, unknown>, "id"> | null>;
64
78
  count(): Promise<number>;
65
79
  pluck(column: string): Promise<unknown[]>;
@@ -68,6 +82,7 @@ declare class ModelQuery {
68
82
  find(id: unknown): Promise<Model<Record<string, unknown>, "id"> | null>;
69
83
  findOrFail(id: unknown, errorFactory?: (id: unknown) => Error): Promise<Model<Record<string, unknown>, "id">>;
70
84
  then(onfulfilled?: ((value: Array<Model<Record<string, unknown>, "id">>) => unknown) | null, onrejected?: ((reason: unknown) => unknown) | null): Promise<unknown>;
85
+ withCount(name: string, alias?: string): this;
71
86
  private constrainExists;
72
87
  }
73
88
  declare class Model<TEntity extends object, PrimaryKey extends keyof TEntity & string> {
@@ -44,6 +44,14 @@ declare function buildInsertQuery<TEntity extends object, PrimaryKey extends key
44
44
  text: string;
45
45
  params: unknown[];
46
46
  };
47
+ declare function buildUpsertQuery<TEntity extends object, PrimaryKey extends keyof TEntity & string>(table: TableDefinition<TEntity, PrimaryKey>, values: MutationValues<TEntity>, conflictColumns: readonly string[], updateColumns?: readonly string[]): {
48
+ text: string;
49
+ params: unknown[];
50
+ };
51
+ declare function buildIncrementQuery<TEntity extends object, PrimaryKey extends keyof TEntity & string>(table: TableDefinition<TEntity, PrimaryKey>, id: TEntity[PrimaryKey], column: keyof TEntity & string, amount: number, extra?: UpdateValues<TEntity, PrimaryKey>): {
52
+ text: string;
53
+ params: unknown[];
54
+ };
47
55
  declare function buildUpdateQuery<TEntity extends object, PrimaryKey extends keyof TEntity & string>(table: TableDefinition<TEntity, PrimaryKey>, id: TEntity[PrimaryKey], changes: UpdateValues<TEntity, PrimaryKey>): {
48
56
  text: string;
49
57
  params: unknown[];
@@ -60,4 +68,4 @@ declare function buildDeleteByIdQuery<TEntity extends object, PrimaryKey extends
60
68
  text: string;
61
69
  params: unknown[];
62
70
  };
63
- export { assertSafeProjectionExpression, buildAdvancedWhereClause, buildCountQuery, buildDeleteByIdQuery, buildGroupedCountQuery, buildInsertQuery, buildJoinClause, buildOrderByClause, buildProjectionQuery, buildQueryWhereClause, buildRestoreByIdQuery, buildSelectQuery, buildSoftDeleteByIdQuery, buildUpdateQuery, buildWhereClause, parseQualifiedColumn, qualifyColumn, quoteIdentifier, resolveQualifiedColumn, resolveSoftDeleteColumn, };
71
+ export { assertSafeProjectionExpression, buildAdvancedWhereClause, buildCountQuery, buildDeleteByIdQuery, buildGroupedCountQuery, buildIncrementQuery, buildInsertQuery, buildJoinClause, buildOrderByClause, buildProjectionQuery, buildQueryWhereClause, buildRestoreByIdQuery, buildSelectQuery, buildSoftDeleteByIdQuery, buildUpdateQuery, buildUpsertQuery, buildWhereClause, parseQualifiedColumn, qualifyColumn, quoteIdentifier, resolveQualifiedColumn, resolveSoftDeleteColumn, };
@@ -17,6 +17,8 @@ declare class RepositoryQuery<TEntity extends object, PrimaryKey extends keyof T
17
17
  limit(limit: number): this;
18
18
  whereNull(column: keyof TEntity & string): this;
19
19
  whereNotNull(column: keyof TEntity & string): this;
20
+ whereNotIn(column: keyof TEntity & string, values: readonly unknown[]): this;
21
+ withSubqueryCount(alias: string, sql: string, params?: readonly unknown[]): this;
20
22
  whereIn(column: keyof TEntity & string, values: readonly unknown[]): this;
21
23
  whereExists(sql: string, params?: readonly unknown[]): this;
22
24
  whereNotExists(sql: string, params?: readonly unknown[]): this;
@@ -2,7 +2,9 @@ type DatabaseComparable = string | number | Date;
2
2
  type DatabaseScalar = DatabaseComparable | boolean | null;
3
3
  type QueryOperator = {
4
4
  eq?: DatabaseScalar;
5
+ ne?: DatabaseScalar;
5
6
  in?: readonly DatabaseScalar[];
7
+ notIn?: readonly DatabaseScalar[];
6
8
  gt?: DatabaseComparable;
7
9
  gte?: DatabaseComparable;
8
10
  lt?: DatabaseComparable;
@@ -42,6 +44,11 @@ type QuerySelectItem = {
42
44
  kind: "literalText";
43
45
  value: string;
44
46
  as: string;
47
+ } | {
48
+ kind: "subqueryCount";
49
+ sql: string;
50
+ params: readonly unknown[];
51
+ as: string;
45
52
  } | {
46
53
  kind: "tsRank";
47
54
  table: string;
@@ -1,6 +1,6 @@
1
1
  // @bun
2
2
  // ../../src/core/database/model.ts
3
- import { NotFoundError } from "@getstrata/core/errors/http";
3
+ import { ConflictError, NotFoundError } from "@getstrata/core/errors/http";
4
4
 
5
5
  // ../../src/core/database/inflection.ts
6
6
  function singularize(word) {
@@ -60,15 +60,32 @@ function pushParam(values, value) {
60
60
  values.push(value);
61
61
  return currentSqlDialect().placeholder(values.length);
62
62
  }
63
- function buildInClause(column, values, params) {
63
+ var SUPPORTED_OPERATORS = new Set([
64
+ "eq",
65
+ "ne",
66
+ "in",
67
+ "notIn",
68
+ "gt",
69
+ "gte",
70
+ "lt",
71
+ "lte",
72
+ "isNull",
73
+ "ilike",
74
+ "tsMatch"
75
+ ]);
76
+ function buildInClause(column, values, params, negated = false) {
64
77
  if (values.length === 0) {
65
- return "1 = 0";
78
+ return negated ? "1 = 1" : "1 = 0";
66
79
  }
67
80
  const placeholders = values.map((value) => pushParam(params, value)).join(", ");
68
- return `${column} IN (${placeholders})`;
81
+ return `${column}${negated ? " NOT" : ""} IN (${placeholders})`;
69
82
  }
70
83
  function buildOperatorClauses(column, operator, params) {
71
84
  const clauses = [];
85
+ const unsupported = Object.keys(operator).filter((key) => !SUPPORTED_OPERATORS.has(key));
86
+ if (unsupported.length > 0) {
87
+ throw new Error(`Unsupported query operator(s) for ${column}: ${unsupported.join(", ")}. Supported: ${[...SUPPORTED_OPERATORS].join(", ")}.`);
88
+ }
72
89
  if (operator.isNull === true) {
73
90
  clauses.push(`${column} IS NULL`);
74
91
  }
@@ -82,9 +99,19 @@ function buildOperatorClauses(column, operator, params) {
82
99
  clauses.push(`${column} = ${pushParam(params, operator.eq)}`);
83
100
  }
84
101
  }
102
+ if (operator.ne !== undefined) {
103
+ if (operator.ne === null) {
104
+ clauses.push(`${column} IS NOT NULL`);
105
+ } else {
106
+ clauses.push(`${column} <> ${pushParam(params, operator.ne)}`);
107
+ }
108
+ }
85
109
  if (operator.in !== undefined) {
86
110
  clauses.push(buildInClause(column, operator.in, params));
87
111
  }
112
+ if (operator.notIn !== undefined) {
113
+ clauses.push(buildInClause(column, operator.notIn, params, true));
114
+ }
88
115
  if (operator.gt !== undefined) {
89
116
  clauses.push(`${column} > ${pushParam(params, operator.gt)}`);
90
117
  }
@@ -304,6 +331,10 @@ function buildSelectList(table, select, params = []) {
304
331
  if (item.kind === "literalText") {
305
332
  return `${currentSqlDialect().castToText(pushParam(params, item.value))} AS ${quoteIdentifier(item.as)}`;
306
333
  }
334
+ if (item.kind === "subqueryCount") {
335
+ const body = remapExistsSql(item.sql, item.params, params);
336
+ return `(${body}) AS ${quoteIdentifier(item.as)}`;
337
+ }
307
338
  const column = qualifyColumn(item.table, item.column);
308
339
  const placeholder = pushParam(params, item.query);
309
340
  return `ts_rank(${column}, plainto_tsquery('english', ${placeholder})) AS ${quoteIdentifier(item.as)}`;
@@ -398,6 +429,52 @@ function buildInsertQuery(table, values) {
398
429
  params
399
430
  };
400
431
  }
432
+ function buildUpsertQuery(table, values, conflictColumns, updateColumns) {
433
+ const entries = getDefinedColumnEntries(table, values);
434
+ if (entries.length === 0) {
435
+ throw new Error(`Cannot upsert into ${table.name} without any column values.`);
436
+ }
437
+ if (conflictColumns.length === 0) {
438
+ throw new Error(`Cannot upsert into ${table.name} without any conflict columns.`);
439
+ }
440
+ const insertable = new Set(entries.map(([column]) => column));
441
+ const conflict = new Set(conflictColumns);
442
+ const updatable = (updateColumns ?? entries.map(([column]) => column).filter((c) => !conflict.has(c))).filter((column) => insertable.has(column));
443
+ const params = [];
444
+ const columns = entries.map(([column]) => quoteIdentifier(column)).join(", ");
445
+ const placeholders = entries.map(([, value]) => pushParam(params, value)).join(", ");
446
+ const returningColumns = buildReturningColumns(table);
447
+ const suffix = currentSqlDialect().upsertSuffix(conflictColumns, updatable);
448
+ return {
449
+ text: `INSERT INTO ${quoteIdentifier(table.name)} (${columns}) VALUES (${placeholders})${suffix}${returningSuffix(returningColumns)}`,
450
+ params
451
+ };
452
+ }
453
+ function buildIncrementQuery(table, id, column, amount, extra = {}) {
454
+ if (!Number.isFinite(amount)) {
455
+ throw new Error("Increment amount must be a finite number.");
456
+ }
457
+ if (!table.columns.includes(column)) {
458
+ throw new Error(`Unknown column ${String(column)} on ${table.name}.`);
459
+ }
460
+ const params = [];
461
+ const target = quoteIdentifier(column);
462
+ const assignments = [`${target} = ${target} + ${pushParam(params, amount)}`];
463
+ for (const [name, value] of getDefinedColumnEntries(table, extra, {
464
+ exclude: [table.primaryKey, column]
465
+ })) {
466
+ assignments.push(`${quoteIdentifier(name)} = ${pushParam(params, value)}`);
467
+ }
468
+ const primaryKeyPlaceholder = pushParam(params, id);
469
+ const returningColumns = buildReturningColumns(table);
470
+ const scopeClauses = [];
471
+ appendSoftDeleteScope(table, {}, scopeClauses);
472
+ const scopeSuffix = scopeClauses.length > 0 ? ` AND ${scopeClauses.join(" AND ")}` : "";
473
+ return {
474
+ text: `UPDATE ${quoteIdentifier(table.name)} SET ${assignments.join(", ")} WHERE ${quoteIdentifier(table.primaryKey)} = ${primaryKeyPlaceholder}${scopeSuffix}${returningSuffix(returningColumns)}`,
475
+ params
476
+ };
477
+ }
401
478
  function buildUpdateQuery(table, id, changes) {
402
479
  const entries = getDefinedColumnEntries(table, changes, {
403
480
  exclude: [table.primaryKey]
@@ -1524,6 +1601,17 @@ function ensureBooted(model) {
1524
1601
  function getGlobalScopes(model) {
1525
1602
  return modelGlobalScopes.get(model) ?? [];
1526
1603
  }
1604
+ var PASSWORD_HASH_PATTERN = /^\$(?:2[aby]?|argon2(?:i|d|id)?)\$/;
1605
+ function isAlreadyHashed(value) {
1606
+ return PASSWORD_HASH_PATTERN.test(value);
1607
+ }
1608
+ function hashCastValue(value) {
1609
+ const plain = String(value);
1610
+ if (isAlreadyHashed(plain)) {
1611
+ return plain;
1612
+ }
1613
+ return Bun.password.hashSync(plain, { algorithm: "bcrypt", cost: 10 });
1614
+ }
1527
1615
  function hydrateValue(value, cast) {
1528
1616
  if (value === null || value === undefined) {
1529
1617
  return value;
@@ -1563,12 +1651,15 @@ function dehydrateValue(value, cast) {
1563
1651
  case "int":
1564
1652
  return value === "" ? null : Number(value);
1565
1653
  case "hashed":
1566
- return value;
1654
+ return hashCastValue(value);
1567
1655
  default:
1568
1656
  return value;
1569
1657
  }
1570
1658
  }
1571
1659
  function filterMassAssignable(fillable, guarded, input) {
1660
+ if (fillable === undefined && guarded === undefined && Object.keys(input).length > 0) {
1661
+ throw new Error("Mass assignment is not configured for this model. Declare static $fillable = [...] to allow specific columns, or static $guarded = [] to allow all of them.");
1662
+ }
1572
1663
  const resolvedGuarded = guarded ?? true;
1573
1664
  if (fillable && fillable.length > 0) {
1574
1665
  const allowed = new Set(fillable);
@@ -1670,10 +1761,38 @@ class ModelQuery {
1670
1761
  this.query.whereNull(column);
1671
1762
  return this;
1672
1763
  }
1764
+ whereNotNull(column) {
1765
+ this.query.whereNotNull(column);
1766
+ return this;
1767
+ }
1673
1768
  whereIn(column, values) {
1674
1769
  this.query.whereIn(column, values);
1675
1770
  return this;
1676
1771
  }
1772
+ whereNotIn(column, values) {
1773
+ this.query.whereNotIn(column, values);
1774
+ return this;
1775
+ }
1776
+ groupBy(groupBy) {
1777
+ this.query.groupBy(groupBy);
1778
+ return this;
1779
+ }
1780
+ having(having) {
1781
+ this.query.having(having);
1782
+ return this;
1783
+ }
1784
+ join(left, right) {
1785
+ this.query.join(left, right);
1786
+ return this;
1787
+ }
1788
+ leftJoin(left, right) {
1789
+ this.query.leftJoin(left, right);
1790
+ return this;
1791
+ }
1792
+ async paginate(options) {
1793
+ const { data, meta } = await this.query.paginate(options);
1794
+ return { data: await this.hydrateRows(data), meta };
1795
+ }
1677
1796
  whereExists(sql, params = []) {
1678
1797
  this.query.whereExists(sql, params);
1679
1798
  return this;
@@ -1731,8 +1850,10 @@ class ModelQuery {
1731
1850
  return this;
1732
1851
  }
1733
1852
  async get() {
1853
+ return await this.hydrateRows(await this.query.get());
1854
+ }
1855
+ async hydrateRows(rows) {
1734
1856
  const statics = modelStatics(this.modelClass);
1735
- const rows = await this.query.get();
1736
1857
  const models = [];
1737
1858
  for (const row of rows) {
1738
1859
  const model = statics.newFromRecord(row, true);
@@ -1784,6 +1905,23 @@ class ModelQuery {
1784
1905
  then(onfulfilled, onrejected) {
1785
1906
  return this.get().then(onfulfilled ?? undefined, onrejected ?? undefined);
1786
1907
  }
1908
+ withCount(name, alias = `${name}_count`) {
1909
+ const statics = modelStatics(this.modelClass);
1910
+ ensureBooted(this.modelClass);
1911
+ const repository = resolveModelRepository(this.modelClass);
1912
+ const dummy = statics.newFromRecord({});
1913
+ const method = dummy[name];
1914
+ if (typeof method !== "function") {
1915
+ throw new Error(`${this.modelClass.name} has no relation method ${name}().`);
1916
+ }
1917
+ const relationQuery = method.call(dummy);
1918
+ const exists = relationQuery.toExistsClause(repository.getTable().name);
1919
+ if (!exists.sql.startsWith("SELECT 1 ")) {
1920
+ throw new Error(`Cannot count relation ${name}: unexpected subquery shape.`);
1921
+ }
1922
+ this.query.withSubqueryCount(alias, `SELECT COUNT(*) ${exists.sql.slice("SELECT 1 ".length)}`, exists.params);
1923
+ return this;
1924
+ }
1787
1925
  constrainExists(name, constrain, not) {
1788
1926
  const statics = modelStatics(this.modelClass);
1789
1927
  ensureBooted(this.modelClass);
@@ -2042,11 +2180,23 @@ class Model {
2042
2180
  return modelStatics(this).newFromRecord({ ...where, ...values }, false);
2043
2181
  }
2044
2182
  static async firstOrCreate(where, values = {}) {
2045
- const existing = await Model.firstWhere.call(this, where);
2183
+ const findExisting = () => Model.firstWhere.call(this, where);
2184
+ const existing = await findExisting();
2046
2185
  if (existing) {
2047
2186
  return existing;
2048
2187
  }
2049
- return Model.create.call(this, { ...where, ...values });
2188
+ try {
2189
+ return await Model.create.call(this, { ...where, ...values });
2190
+ } catch (error) {
2191
+ if (!(error instanceof ConflictError)) {
2192
+ throw error;
2193
+ }
2194
+ const raced = await findExisting();
2195
+ if (!raced) {
2196
+ throw error;
2197
+ }
2198
+ return raced;
2199
+ }
2050
2200
  }
2051
2201
  static async updateOrCreate(where, values = {}) {
2052
2202
  const existing = await Model.firstWhere.call(this, where);
@@ -37,15 +37,32 @@ function pushParam(values, value) {
37
37
  values.push(value);
38
38
  return currentSqlDialect().placeholder(values.length);
39
39
  }
40
- function buildInClause(column, values, params) {
40
+ var SUPPORTED_OPERATORS = new Set([
41
+ "eq",
42
+ "ne",
43
+ "in",
44
+ "notIn",
45
+ "gt",
46
+ "gte",
47
+ "lt",
48
+ "lte",
49
+ "isNull",
50
+ "ilike",
51
+ "tsMatch"
52
+ ]);
53
+ function buildInClause(column, values, params, negated = false) {
41
54
  if (values.length === 0) {
42
- return "1 = 0";
55
+ return negated ? "1 = 1" : "1 = 0";
43
56
  }
44
57
  const placeholders = values.map((value) => pushParam(params, value)).join(", ");
45
- return `${column} IN (${placeholders})`;
58
+ return `${column}${negated ? " NOT" : ""} IN (${placeholders})`;
46
59
  }
47
60
  function buildOperatorClauses(column, operator, params) {
48
61
  const clauses = [];
62
+ const unsupported = Object.keys(operator).filter((key) => !SUPPORTED_OPERATORS.has(key));
63
+ if (unsupported.length > 0) {
64
+ throw new Error(`Unsupported query operator(s) for ${column}: ${unsupported.join(", ")}. Supported: ${[...SUPPORTED_OPERATORS].join(", ")}.`);
65
+ }
49
66
  if (operator.isNull === true) {
50
67
  clauses.push(`${column} IS NULL`);
51
68
  }
@@ -59,9 +76,19 @@ function buildOperatorClauses(column, operator, params) {
59
76
  clauses.push(`${column} = ${pushParam(params, operator.eq)}`);
60
77
  }
61
78
  }
79
+ if (operator.ne !== undefined) {
80
+ if (operator.ne === null) {
81
+ clauses.push(`${column} IS NOT NULL`);
82
+ } else {
83
+ clauses.push(`${column} <> ${pushParam(params, operator.ne)}`);
84
+ }
85
+ }
62
86
  if (operator.in !== undefined) {
63
87
  clauses.push(buildInClause(column, operator.in, params));
64
88
  }
89
+ if (operator.notIn !== undefined) {
90
+ clauses.push(buildInClause(column, operator.notIn, params, true));
91
+ }
65
92
  if (operator.gt !== undefined) {
66
93
  clauses.push(`${column} > ${pushParam(params, operator.gt)}`);
67
94
  }
@@ -281,6 +308,10 @@ function buildSelectList(table, select, params = []) {
281
308
  if (item.kind === "literalText") {
282
309
  return `${currentSqlDialect().castToText(pushParam(params, item.value))} AS ${quoteIdentifier(item.as)}`;
283
310
  }
311
+ if (item.kind === "subqueryCount") {
312
+ const body = remapExistsSql(item.sql, item.params, params);
313
+ return `(${body}) AS ${quoteIdentifier(item.as)}`;
314
+ }
284
315
  const column = qualifyColumn(item.table, item.column);
285
316
  const placeholder = pushParam(params, item.query);
286
317
  return `ts_rank(${column}, plainto_tsquery('english', ${placeholder})) AS ${quoteIdentifier(item.as)}`;
@@ -375,6 +406,52 @@ function buildInsertQuery(table, values) {
375
406
  params
376
407
  };
377
408
  }
409
+ function buildUpsertQuery(table, values, conflictColumns, updateColumns) {
410
+ const entries = getDefinedColumnEntries(table, values);
411
+ if (entries.length === 0) {
412
+ throw new Error(`Cannot upsert into ${table.name} without any column values.`);
413
+ }
414
+ if (conflictColumns.length === 0) {
415
+ throw new Error(`Cannot upsert into ${table.name} without any conflict columns.`);
416
+ }
417
+ const insertable = new Set(entries.map(([column]) => column));
418
+ const conflict = new Set(conflictColumns);
419
+ const updatable = (updateColumns ?? entries.map(([column]) => column).filter((c) => !conflict.has(c))).filter((column) => insertable.has(column));
420
+ const params = [];
421
+ const columns = entries.map(([column]) => quoteIdentifier(column)).join(", ");
422
+ const placeholders = entries.map(([, value]) => pushParam(params, value)).join(", ");
423
+ const returningColumns = buildReturningColumns(table);
424
+ const suffix = currentSqlDialect().upsertSuffix(conflictColumns, updatable);
425
+ return {
426
+ text: `INSERT INTO ${quoteIdentifier(table.name)} (${columns}) VALUES (${placeholders})${suffix}${returningSuffix(returningColumns)}`,
427
+ params
428
+ };
429
+ }
430
+ function buildIncrementQuery(table, id, column, amount, extra = {}) {
431
+ if (!Number.isFinite(amount)) {
432
+ throw new Error("Increment amount must be a finite number.");
433
+ }
434
+ if (!table.columns.includes(column)) {
435
+ throw new Error(`Unknown column ${String(column)} on ${table.name}.`);
436
+ }
437
+ const params = [];
438
+ const target = quoteIdentifier(column);
439
+ const assignments = [`${target} = ${target} + ${pushParam(params, amount)}`];
440
+ for (const [name, value] of getDefinedColumnEntries(table, extra, {
441
+ exclude: [table.primaryKey, column]
442
+ })) {
443
+ assignments.push(`${quoteIdentifier(name)} = ${pushParam(params, value)}`);
444
+ }
445
+ const primaryKeyPlaceholder = pushParam(params, id);
446
+ const returningColumns = buildReturningColumns(table);
447
+ const scopeClauses = [];
448
+ appendSoftDeleteScope(table, {}, scopeClauses);
449
+ const scopeSuffix = scopeClauses.length > 0 ? ` AND ${scopeClauses.join(" AND ")}` : "";
450
+ return {
451
+ text: `UPDATE ${quoteIdentifier(table.name)} SET ${assignments.join(", ")} WHERE ${quoteIdentifier(table.primaryKey)} = ${primaryKeyPlaceholder}${scopeSuffix}${returningSuffix(returningColumns)}`,
452
+ params
453
+ };
454
+ }
378
455
  function buildUpdateQuery(table, id, changes) {
379
456
  const entries = getDefinedColumnEntries(table, changes, {
380
457
  exclude: [table.primaryKey]
@@ -440,6 +517,7 @@ export {
440
517
  buildCountQuery,
441
518
  buildDeleteByIdQuery,
442
519
  buildGroupedCountQuery,
520
+ buildIncrementQuery,
443
521
  buildInsertQuery,
444
522
  buildJoinClause,
445
523
  buildOrderByClause,
@@ -449,6 +527,7 @@ export {
449
527
  buildSelectQuery,
450
528
  buildSoftDeleteByIdQuery,
451
529
  buildUpdateQuery,
530
+ buildUpsertQuery,
452
531
  buildWhereClause,
453
532
  parseQualifiedColumn,
454
533
  qualifyColumn,
@@ -61,15 +61,32 @@ function pushParam(values, value) {
61
61
  values.push(value);
62
62
  return currentSqlDialect().placeholder(values.length);
63
63
  }
64
- function buildInClause(column, values, params) {
64
+ var SUPPORTED_OPERATORS = new Set([
65
+ "eq",
66
+ "ne",
67
+ "in",
68
+ "notIn",
69
+ "gt",
70
+ "gte",
71
+ "lt",
72
+ "lte",
73
+ "isNull",
74
+ "ilike",
75
+ "tsMatch"
76
+ ]);
77
+ function buildInClause(column, values, params, negated = false) {
65
78
  if (values.length === 0) {
66
- return "1 = 0";
79
+ return negated ? "1 = 1" : "1 = 0";
67
80
  }
68
81
  const placeholders = values.map((value) => pushParam(params, value)).join(", ");
69
- return `${column} IN (${placeholders})`;
82
+ return `${column}${negated ? " NOT" : ""} IN (${placeholders})`;
70
83
  }
71
84
  function buildOperatorClauses(column, operator, params) {
72
85
  const clauses = [];
86
+ const unsupported = Object.keys(operator).filter((key) => !SUPPORTED_OPERATORS.has(key));
87
+ if (unsupported.length > 0) {
88
+ throw new Error(`Unsupported query operator(s) for ${column}: ${unsupported.join(", ")}. Supported: ${[...SUPPORTED_OPERATORS].join(", ")}.`);
89
+ }
73
90
  if (operator.isNull === true) {
74
91
  clauses.push(`${column} IS NULL`);
75
92
  }
@@ -83,9 +100,19 @@ function buildOperatorClauses(column, operator, params) {
83
100
  clauses.push(`${column} = ${pushParam(params, operator.eq)}`);
84
101
  }
85
102
  }
103
+ if (operator.ne !== undefined) {
104
+ if (operator.ne === null) {
105
+ clauses.push(`${column} IS NOT NULL`);
106
+ } else {
107
+ clauses.push(`${column} <> ${pushParam(params, operator.ne)}`);
108
+ }
109
+ }
86
110
  if (operator.in !== undefined) {
87
111
  clauses.push(buildInClause(column, operator.in, params));
88
112
  }
113
+ if (operator.notIn !== undefined) {
114
+ clauses.push(buildInClause(column, operator.notIn, params, true));
115
+ }
89
116
  if (operator.gt !== undefined) {
90
117
  clauses.push(`${column} > ${pushParam(params, operator.gt)}`);
91
118
  }
@@ -305,6 +332,10 @@ function buildSelectList(table, select, params = []) {
305
332
  if (item.kind === "literalText") {
306
333
  return `${currentSqlDialect().castToText(pushParam(params, item.value))} AS ${quoteIdentifier(item.as)}`;
307
334
  }
335
+ if (item.kind === "subqueryCount") {
336
+ const body = remapExistsSql(item.sql, item.params, params);
337
+ return `(${body}) AS ${quoteIdentifier(item.as)}`;
338
+ }
308
339
  const column = qualifyColumn(item.table, item.column);
309
340
  const placeholder = pushParam(params, item.query);
310
341
  return `ts_rank(${column}, plainto_tsquery('english', ${placeholder})) AS ${quoteIdentifier(item.as)}`;
@@ -399,6 +430,52 @@ function buildInsertQuery(table, values) {
399
430
  params
400
431
  };
401
432
  }
433
+ function buildUpsertQuery(table, values, conflictColumns, updateColumns) {
434
+ const entries = getDefinedColumnEntries(table, values);
435
+ if (entries.length === 0) {
436
+ throw new Error(`Cannot upsert into ${table.name} without any column values.`);
437
+ }
438
+ if (conflictColumns.length === 0) {
439
+ throw new Error(`Cannot upsert into ${table.name} without any conflict columns.`);
440
+ }
441
+ const insertable = new Set(entries.map(([column]) => column));
442
+ const conflict = new Set(conflictColumns);
443
+ const updatable = (updateColumns ?? entries.map(([column]) => column).filter((c) => !conflict.has(c))).filter((column) => insertable.has(column));
444
+ const params = [];
445
+ const columns = entries.map(([column]) => quoteIdentifier(column)).join(", ");
446
+ const placeholders = entries.map(([, value]) => pushParam(params, value)).join(", ");
447
+ const returningColumns = buildReturningColumns(table);
448
+ const suffix = currentSqlDialect().upsertSuffix(conflictColumns, updatable);
449
+ return {
450
+ text: `INSERT INTO ${quoteIdentifier(table.name)} (${columns}) VALUES (${placeholders})${suffix}${returningSuffix(returningColumns)}`,
451
+ params
452
+ };
453
+ }
454
+ function buildIncrementQuery(table, id, column, amount, extra = {}) {
455
+ if (!Number.isFinite(amount)) {
456
+ throw new Error("Increment amount must be a finite number.");
457
+ }
458
+ if (!table.columns.includes(column)) {
459
+ throw new Error(`Unknown column ${String(column)} on ${table.name}.`);
460
+ }
461
+ const params = [];
462
+ const target = quoteIdentifier(column);
463
+ const assignments = [`${target} = ${target} + ${pushParam(params, amount)}`];
464
+ for (const [name, value] of getDefinedColumnEntries(table, extra, {
465
+ exclude: [table.primaryKey, column]
466
+ })) {
467
+ assignments.push(`${quoteIdentifier(name)} = ${pushParam(params, value)}`);
468
+ }
469
+ const primaryKeyPlaceholder = pushParam(params, id);
470
+ const returningColumns = buildReturningColumns(table);
471
+ const scopeClauses = [];
472
+ appendSoftDeleteScope(table, {}, scopeClauses);
473
+ const scopeSuffix = scopeClauses.length > 0 ? ` AND ${scopeClauses.join(" AND ")}` : "";
474
+ return {
475
+ text: `UPDATE ${quoteIdentifier(table.name)} SET ${assignments.join(", ")} WHERE ${quoteIdentifier(table.primaryKey)} = ${primaryKeyPlaceholder}${scopeSuffix}${returningSuffix(returningColumns)}`,
476
+ params
477
+ };
478
+ }
402
479
  function buildUpdateQuery(table, id, changes) {
403
480
  const entries = getDefinedColumnEntries(table, changes, {
404
481
  exclude: [table.primaryKey]
@@ -779,6 +856,22 @@ class RepositoryQuery {
779
856
  whereNotNull(column) {
780
857
  return this.where({ [column]: { isNull: false } });
781
858
  }
859
+ whereNotIn(column, values) {
860
+ return this.where({ [column]: { notIn: values } });
861
+ }
862
+ withSubqueryCount(alias, sql, params = []) {
863
+ const table = this.repository.getTable();
864
+ const existing = this.queryOptions.select ?? table.columns.map((column) => ({
865
+ kind: "column",
866
+ table: table.name,
867
+ column
868
+ }));
869
+ this.queryOptions = {
870
+ ...this.queryOptions,
871
+ select: [...existing, { kind: "subqueryCount", sql, params, as: alias }]
872
+ };
873
+ return this;
874
+ }
782
875
  whereIn(column, values) {
783
876
  return this.where({ [column]: values });
784
877
  }
@@ -304,15 +304,32 @@ function pushParam(values, value) {
304
304
  values.push(value);
305
305
  return currentSqlDialect().placeholder(values.length);
306
306
  }
307
- function buildInClause(column, values, params) {
307
+ var SUPPORTED_OPERATORS = new Set([
308
+ "eq",
309
+ "ne",
310
+ "in",
311
+ "notIn",
312
+ "gt",
313
+ "gte",
314
+ "lt",
315
+ "lte",
316
+ "isNull",
317
+ "ilike",
318
+ "tsMatch"
319
+ ]);
320
+ function buildInClause(column, values, params, negated = false) {
308
321
  if (values.length === 0) {
309
- return "1 = 0";
322
+ return negated ? "1 = 1" : "1 = 0";
310
323
  }
311
324
  const placeholders = values.map((value) => pushParam(params, value)).join(", ");
312
- return `${column} IN (${placeholders})`;
325
+ return `${column}${negated ? " NOT" : ""} IN (${placeholders})`;
313
326
  }
314
327
  function buildOperatorClauses(column, operator, params) {
315
328
  const clauses = [];
329
+ const unsupported = Object.keys(operator).filter((key) => !SUPPORTED_OPERATORS.has(key));
330
+ if (unsupported.length > 0) {
331
+ throw new Error(`Unsupported query operator(s) for ${column}: ${unsupported.join(", ")}. Supported: ${[...SUPPORTED_OPERATORS].join(", ")}.`);
332
+ }
316
333
  if (operator.isNull === true) {
317
334
  clauses.push(`${column} IS NULL`);
318
335
  }
@@ -326,9 +343,19 @@ function buildOperatorClauses(column, operator, params) {
326
343
  clauses.push(`${column} = ${pushParam(params, operator.eq)}`);
327
344
  }
328
345
  }
346
+ if (operator.ne !== undefined) {
347
+ if (operator.ne === null) {
348
+ clauses.push(`${column} IS NOT NULL`);
349
+ } else {
350
+ clauses.push(`${column} <> ${pushParam(params, operator.ne)}`);
351
+ }
352
+ }
329
353
  if (operator.in !== undefined) {
330
354
  clauses.push(buildInClause(column, operator.in, params));
331
355
  }
356
+ if (operator.notIn !== undefined) {
357
+ clauses.push(buildInClause(column, operator.notIn, params, true));
358
+ }
332
359
  if (operator.gt !== undefined) {
333
360
  clauses.push(`${column} > ${pushParam(params, operator.gt)}`);
334
361
  }
@@ -548,6 +575,10 @@ function buildSelectList(table, select, params = []) {
548
575
  if (item.kind === "literalText") {
549
576
  return `${currentSqlDialect().castToText(pushParam(params, item.value))} AS ${quoteIdentifier(item.as)}`;
550
577
  }
578
+ if (item.kind === "subqueryCount") {
579
+ const body = remapExistsSql(item.sql, item.params, params);
580
+ return `(${body}) AS ${quoteIdentifier(item.as)}`;
581
+ }
551
582
  const column = qualifyColumn(item.table, item.column);
552
583
  const placeholder = pushParam(params, item.query);
553
584
  return `ts_rank(${column}, plainto_tsquery('english', ${placeholder})) AS ${quoteIdentifier(item.as)}`;
@@ -642,6 +673,52 @@ function buildInsertQuery(table, values) {
642
673
  params
643
674
  };
644
675
  }
676
+ function buildUpsertQuery(table, values, conflictColumns, updateColumns) {
677
+ const entries = getDefinedColumnEntries(table, values);
678
+ if (entries.length === 0) {
679
+ throw new Error(`Cannot upsert into ${table.name} without any column values.`);
680
+ }
681
+ if (conflictColumns.length === 0) {
682
+ throw new Error(`Cannot upsert into ${table.name} without any conflict columns.`);
683
+ }
684
+ const insertable = new Set(entries.map(([column]) => column));
685
+ const conflict = new Set(conflictColumns);
686
+ const updatable = (updateColumns ?? entries.map(([column]) => column).filter((c) => !conflict.has(c))).filter((column) => insertable.has(column));
687
+ const params = [];
688
+ const columns = entries.map(([column]) => quoteIdentifier(column)).join(", ");
689
+ const placeholders = entries.map(([, value]) => pushParam(params, value)).join(", ");
690
+ const returningColumns = buildReturningColumns(table);
691
+ const suffix = currentSqlDialect().upsertSuffix(conflictColumns, updatable);
692
+ return {
693
+ text: `INSERT INTO ${quoteIdentifier(table.name)} (${columns}) VALUES (${placeholders})${suffix}${returningSuffix(returningColumns)}`,
694
+ params
695
+ };
696
+ }
697
+ function buildIncrementQuery(table, id, column, amount, extra = {}) {
698
+ if (!Number.isFinite(amount)) {
699
+ throw new Error("Increment amount must be a finite number.");
700
+ }
701
+ if (!table.columns.includes(column)) {
702
+ throw new Error(`Unknown column ${String(column)} on ${table.name}.`);
703
+ }
704
+ const params = [];
705
+ const target = quoteIdentifier(column);
706
+ const assignments = [`${target} = ${target} + ${pushParam(params, amount)}`];
707
+ for (const [name, value] of getDefinedColumnEntries(table, extra, {
708
+ exclude: [table.primaryKey, column]
709
+ })) {
710
+ assignments.push(`${quoteIdentifier(name)} = ${pushParam(params, value)}`);
711
+ }
712
+ const primaryKeyPlaceholder = pushParam(params, id);
713
+ const returningColumns = buildReturningColumns(table);
714
+ const scopeClauses = [];
715
+ appendSoftDeleteScope(table, {}, scopeClauses);
716
+ const scopeSuffix = scopeClauses.length > 0 ? ` AND ${scopeClauses.join(" AND ")}` : "";
717
+ return {
718
+ text: `UPDATE ${quoteIdentifier(table.name)} SET ${assignments.join(", ")} WHERE ${quoteIdentifier(table.primaryKey)} = ${primaryKeyPlaceholder}${scopeSuffix}${returningSuffix(returningColumns)}`,
719
+ params
720
+ };
721
+ }
645
722
  function buildUpdateQuery(table, id, changes) {
646
723
  const entries = getDefinedColumnEntries(table, changes, {
647
724
  exclude: [table.primaryKey]
@@ -2,6 +2,26 @@
2
2
  // ../../src/core/http/parseMultipartUpload.ts
3
3
  import { BadRequestError, PayloadTooLargeError } from "@getstrata/core/errors/http";
4
4
 
5
+ // ../../src/core/runtime/appEnv.ts
6
+ var NON_PRODUCTION_APP_ENVS = new Set(["local", "development", "dev", "test", "testing", "ci"]);
7
+ function normalizeEnvValue(value) {
8
+ return (value ?? "").trim().toLowerCase();
9
+ }
10
+ function isProductionEnv(env = process.env) {
11
+ const appEnv = normalizeEnvValue(env.APP_ENV);
12
+ const nodeEnv = normalizeEnvValue(env.NODE_ENV);
13
+ if (appEnv === "production" || nodeEnv === "production") {
14
+ return true;
15
+ }
16
+ if (appEnv === "") {
17
+ return false;
18
+ }
19
+ return !NON_PRODUCTION_APP_ENVS.has(appEnv);
20
+ }
21
+ function envFlagEnabled(value) {
22
+ return value === "true";
23
+ }
24
+
5
25
  // ../../src/core/http/uploads.ts
6
26
  var DEFAULT_MAX_UPLOAD_BYTES = 5 * 1024 * 1024;
7
27
  var ALLOWED_UPLOAD_MIME_TYPES = new Set([
@@ -33,7 +53,7 @@ function normalizeMimeType(mimeType) {
33
53
  function isAllowedMimeType(mimeType) {
34
54
  const normalized = normalizeMimeType(mimeType);
35
55
  if (!normalized || normalized === "application/octet-stream") {
36
- return true;
56
+ return envFlagEnabled(process.env.UPLOAD_ALLOW_UNKNOWN_MIME);
37
57
  }
38
58
  return ALLOWED_UPLOAD_MIME_TYPES.has(normalized);
39
59
  }
package/dist/index.js CHANGED
@@ -2071,6 +2071,9 @@ function assertSafeIdentifier(identifier) {
2071
2071
  }
2072
2072
  return identifier;
2073
2073
  }
2074
+ function quoteIdentifierFor(dialect, column) {
2075
+ return dialect.quoteIdentifier(column);
2076
+ }
2074
2077
  var postgresDialect = {
2075
2078
  driver: "pgsql",
2076
2079
  placeholder(index) {
@@ -2096,6 +2099,17 @@ var postgresDialect = {
2096
2099
  },
2097
2100
  castToText(expression) {
2098
2101
  return `${expression}::text`;
2102
+ },
2103
+ upsertSuffix(conflictColumns, updateColumns) {
2104
+ const target = conflictColumns.map((column) => quoteIdentifierFor(this, column)).join(", ");
2105
+ if (updateColumns.length === 0) {
2106
+ return ` ON CONFLICT (${target}) DO NOTHING`;
2107
+ }
2108
+ const assignments = updateColumns.map((column) => {
2109
+ const quoted = quoteIdentifierFor(this, column);
2110
+ return `${quoted} = excluded.${quoted}`;
2111
+ }).join(", ");
2112
+ return ` ON CONFLICT (${target}) DO UPDATE SET ${assignments}`;
2099
2113
  }
2100
2114
  };
2101
2115
  var mysqlDialect = {
@@ -2123,6 +2137,17 @@ var mysqlDialect = {
2123
2137
  },
2124
2138
  castToText(expression) {
2125
2139
  return `CAST(${expression} AS CHAR)`;
2140
+ },
2141
+ upsertSuffix(conflictColumns, updateColumns) {
2142
+ if (updateColumns.length === 0) {
2143
+ const anchor = quoteIdentifierFor(this, conflictColumns[0] ?? "");
2144
+ return ` ON DUPLICATE KEY UPDATE ${anchor} = ${anchor}`;
2145
+ }
2146
+ const assignments = updateColumns.map((column) => {
2147
+ const quoted = quoteIdentifierFor(this, column);
2148
+ return `${quoted} = VALUES(${quoted})`;
2149
+ }).join(", ");
2150
+ return ` ON DUPLICATE KEY UPDATE ${assignments}`;
2126
2151
  }
2127
2152
  };
2128
2153
  var sqliteDialect = {
@@ -2150,6 +2175,17 @@ var sqliteDialect = {
2150
2175
  },
2151
2176
  castToText(expression) {
2152
2177
  return `CAST(${expression} AS TEXT)`;
2178
+ },
2179
+ upsertSuffix(conflictColumns, updateColumns) {
2180
+ const target = conflictColumns.map((column) => quoteIdentifierFor(this, column)).join(", ");
2181
+ if (updateColumns.length === 0) {
2182
+ return ` ON CONFLICT (${target}) DO NOTHING`;
2183
+ }
2184
+ const assignments = updateColumns.map((column) => {
2185
+ const quoted = quoteIdentifierFor(this, column);
2186
+ return `${quoted} = excluded.${quoted}`;
2187
+ }).join(", ");
2188
+ return ` ON CONFLICT (${target}) DO UPDATE SET ${assignments}`;
2153
2189
  }
2154
2190
  };
2155
2191
  var dialects = {
@@ -2216,15 +2252,32 @@ function pushParam(values, value) {
2216
2252
  values.push(value);
2217
2253
  return currentSqlDialect().placeholder(values.length);
2218
2254
  }
2219
- function buildInClause(column, values, params) {
2255
+ var SUPPORTED_OPERATORS = new Set([
2256
+ "eq",
2257
+ "ne",
2258
+ "in",
2259
+ "notIn",
2260
+ "gt",
2261
+ "gte",
2262
+ "lt",
2263
+ "lte",
2264
+ "isNull",
2265
+ "ilike",
2266
+ "tsMatch"
2267
+ ]);
2268
+ function buildInClause(column, values, params, negated = false) {
2220
2269
  if (values.length === 0) {
2221
- return "1 = 0";
2270
+ return negated ? "1 = 1" : "1 = 0";
2222
2271
  }
2223
2272
  const placeholders = values.map((value) => pushParam(params, value)).join(", ");
2224
- return `${column} IN (${placeholders})`;
2273
+ return `${column}${negated ? " NOT" : ""} IN (${placeholders})`;
2225
2274
  }
2226
2275
  function buildOperatorClauses(column, operator, params) {
2227
2276
  const clauses = [];
2277
+ const unsupported = Object.keys(operator).filter((key) => !SUPPORTED_OPERATORS.has(key));
2278
+ if (unsupported.length > 0) {
2279
+ throw new Error(`Unsupported query operator(s) for ${column}: ${unsupported.join(", ")}. Supported: ${[...SUPPORTED_OPERATORS].join(", ")}.`);
2280
+ }
2228
2281
  if (operator.isNull === true) {
2229
2282
  clauses.push(`${column} IS NULL`);
2230
2283
  }
@@ -2238,9 +2291,19 @@ function buildOperatorClauses(column, operator, params) {
2238
2291
  clauses.push(`${column} = ${pushParam(params, operator.eq)}`);
2239
2292
  }
2240
2293
  }
2294
+ if (operator.ne !== undefined) {
2295
+ if (operator.ne === null) {
2296
+ clauses.push(`${column} IS NOT NULL`);
2297
+ } else {
2298
+ clauses.push(`${column} <> ${pushParam(params, operator.ne)}`);
2299
+ }
2300
+ }
2241
2301
  if (operator.in !== undefined) {
2242
2302
  clauses.push(buildInClause(column, operator.in, params));
2243
2303
  }
2304
+ if (operator.notIn !== undefined) {
2305
+ clauses.push(buildInClause(column, operator.notIn, params, true));
2306
+ }
2244
2307
  if (operator.gt !== undefined) {
2245
2308
  clauses.push(`${column} > ${pushParam(params, operator.gt)}`);
2246
2309
  }
@@ -2460,6 +2523,10 @@ function buildSelectList(table, select, params = []) {
2460
2523
  if (item.kind === "literalText") {
2461
2524
  return `${currentSqlDialect().castToText(pushParam(params, item.value))} AS ${quoteIdentifier(item.as)}`;
2462
2525
  }
2526
+ if (item.kind === "subqueryCount") {
2527
+ const body = remapExistsSql(item.sql, item.params, params);
2528
+ return `(${body}) AS ${quoteIdentifier(item.as)}`;
2529
+ }
2463
2530
  const column = qualifyColumn(item.table, item.column);
2464
2531
  const placeholder = pushParam(params, item.query);
2465
2532
  return `ts_rank(${column}, plainto_tsquery('english', ${placeholder})) AS ${quoteIdentifier(item.as)}`;
@@ -2554,6 +2621,52 @@ function buildInsertQuery(table, values) {
2554
2621
  params
2555
2622
  };
2556
2623
  }
2624
+ function buildUpsertQuery(table, values, conflictColumns, updateColumns) {
2625
+ const entries = getDefinedColumnEntries(table, values);
2626
+ if (entries.length === 0) {
2627
+ throw new Error(`Cannot upsert into ${table.name} without any column values.`);
2628
+ }
2629
+ if (conflictColumns.length === 0) {
2630
+ throw new Error(`Cannot upsert into ${table.name} without any conflict columns.`);
2631
+ }
2632
+ const insertable = new Set(entries.map(([column]) => column));
2633
+ const conflict = new Set(conflictColumns);
2634
+ const updatable = (updateColumns ?? entries.map(([column]) => column).filter((c) => !conflict.has(c))).filter((column) => insertable.has(column));
2635
+ const params = [];
2636
+ const columns = entries.map(([column]) => quoteIdentifier(column)).join(", ");
2637
+ const placeholders = entries.map(([, value]) => pushParam(params, value)).join(", ");
2638
+ const returningColumns = buildReturningColumns(table);
2639
+ const suffix = currentSqlDialect().upsertSuffix(conflictColumns, updatable);
2640
+ return {
2641
+ text: `INSERT INTO ${quoteIdentifier(table.name)} (${columns}) VALUES (${placeholders})${suffix}${returningSuffix(returningColumns)}`,
2642
+ params
2643
+ };
2644
+ }
2645
+ function buildIncrementQuery(table, id, column, amount, extra = {}) {
2646
+ if (!Number.isFinite(amount)) {
2647
+ throw new Error("Increment amount must be a finite number.");
2648
+ }
2649
+ if (!table.columns.includes(column)) {
2650
+ throw new Error(`Unknown column ${String(column)} on ${table.name}.`);
2651
+ }
2652
+ const params = [];
2653
+ const target = quoteIdentifier(column);
2654
+ const assignments = [`${target} = ${target} + ${pushParam(params, amount)}`];
2655
+ for (const [name, value] of getDefinedColumnEntries(table, extra, {
2656
+ exclude: [table.primaryKey, column]
2657
+ })) {
2658
+ assignments.push(`${quoteIdentifier(name)} = ${pushParam(params, value)}`);
2659
+ }
2660
+ const primaryKeyPlaceholder = pushParam(params, id);
2661
+ const returningColumns = buildReturningColumns(table);
2662
+ const scopeClauses = [];
2663
+ appendSoftDeleteScope(table, {}, scopeClauses);
2664
+ const scopeSuffix = scopeClauses.length > 0 ? ` AND ${scopeClauses.join(" AND ")}` : "";
2665
+ return {
2666
+ text: `UPDATE ${quoteIdentifier(table.name)} SET ${assignments.join(", ")} WHERE ${quoteIdentifier(table.primaryKey)} = ${primaryKeyPlaceholder}${scopeSuffix}${returningSuffix(returningColumns)}`,
2667
+ params
2668
+ };
2669
+ }
2557
2670
  function buildUpdateQuery(table, id, changes) {
2558
2671
  const entries = getDefinedColumnEntries(table, changes, {
2559
2672
  exclude: [table.primaryKey]
@@ -2958,6 +3071,22 @@ class RepositoryQuery {
2958
3071
  whereNotNull(column) {
2959
3072
  return this.where({ [column]: { isNull: false } });
2960
3073
  }
3074
+ whereNotIn(column, values) {
3075
+ return this.where({ [column]: { notIn: values } });
3076
+ }
3077
+ withSubqueryCount(alias, sql, params = []) {
3078
+ const table = this.repository.getTable();
3079
+ const existing = this.queryOptions.select ?? table.columns.map((column) => ({
3080
+ kind: "column",
3081
+ table: table.name,
3082
+ column
3083
+ }));
3084
+ this.queryOptions = {
3085
+ ...this.queryOptions,
3086
+ select: [...existing, { kind: "subqueryCount", sql, params, as: alias }]
3087
+ };
3088
+ return this;
3089
+ }
2961
3090
  whereIn(column, values) {
2962
3091
  return this.where({ [column]: values });
2963
3092
  }
@@ -3266,6 +3395,29 @@ class BaseRepository {
3266
3395
  offset += count;
3267
3396
  }
3268
3397
  }
3398
+ async chunkById(count, callback, options = {}) {
3399
+ if (!Number.isInteger(count) || count <= 0) {
3400
+ throw new Error("Chunk size must be a positive integer.");
3401
+ }
3402
+ let cursor;
3403
+ while (true) {
3404
+ const { data, meta } = await this.cursorPaginate({
3405
+ ...options,
3406
+ perPage: count,
3407
+ ...cursor === undefined ? {} : { cursor }
3408
+ });
3409
+ if (data.length === 0) {
3410
+ return;
3411
+ }
3412
+ if (await callback(data) === false) {
3413
+ return;
3414
+ }
3415
+ if (!meta.has_more || meta.next_cursor === null) {
3416
+ return;
3417
+ }
3418
+ cursor = meta.next_cursor;
3419
+ }
3420
+ }
3269
3421
  async cursorPaginate(options) {
3270
3422
  const {
3271
3423
  perPage,
@@ -3329,6 +3481,23 @@ class BaseRepository {
3329
3481
  const [record] = await this.findAll({ ...options, where, limit: 1 });
3330
3482
  return record ?? null;
3331
3483
  }
3484
+ async upsert(values, conflictColumns, updateColumns) {
3485
+ return await withDatabaseErrorHandling(async () => {
3486
+ const { text, params } = buildUpsertQuery(this.table, values, conflictColumns, updateColumns);
3487
+ const [record] = await this.connection.unsafe(text, params);
3488
+ return record ?? null;
3489
+ });
3490
+ }
3491
+ async incrementById(id, column, amount = 1, extra = {}) {
3492
+ return await withDatabaseErrorHandling(async () => {
3493
+ const { text, params } = buildIncrementQuery(this.table, id, column, amount, extra);
3494
+ const [record] = await this.connection.unsafe(text, params);
3495
+ return record ?? null;
3496
+ });
3497
+ }
3498
+ async decrementById(id, column, amount = 1, extra = {}) {
3499
+ return await this.incrementById(id, column, -amount, extra);
3500
+ }
3332
3501
  async create(values) {
3333
3502
  return await withDatabaseErrorHandling(async () => {
3334
3503
  const { text, params } = buildInsertQuery(this.table, values);
@@ -3431,7 +3600,23 @@ class BaseRepository {
3431
3600
  async averageExpression(expression, alias, where = {}) {
3432
3601
  const { text, params } = buildProjectionQuery(this.table, expression, alias, { where });
3433
3602
  const [row] = await this.connection.unsafe(text, params);
3434
- return Math.round(Number(row?.[alias] ?? 0));
3603
+ return Number(row?.[alias] ?? 0);
3604
+ }
3605
+ async sum(column, where = {}) {
3606
+ return await this.aggregateColumn("SUM", column, where);
3607
+ }
3608
+ async avg(column, where = {}) {
3609
+ return await this.aggregateColumn("AVG", column, where);
3610
+ }
3611
+ async min(column, where = {}) {
3612
+ return await this.aggregateColumn("MIN", column, where);
3613
+ }
3614
+ async max(column, where = {}) {
3615
+ return await this.aggregateColumn("MAX", column, where);
3616
+ }
3617
+ async aggregateColumn(fn, column, where) {
3618
+ const qualifiedColumn = qualifyColumn(this.table.name, column);
3619
+ return await this.averageExpression(`${fn}(${qualifiedColumn})`, "value", where);
3435
3620
  }
3436
3621
  async pluckNumberValues(expression, alias, options = {}) {
3437
3622
  const { text, params } = buildProjectionQuery(this.table, expression, alias, options);
@@ -4711,6 +4896,17 @@ function ensureBooted(model) {
4711
4896
  function getGlobalScopes(model) {
4712
4897
  return modelGlobalScopes.get(model) ?? [];
4713
4898
  }
4899
+ var PASSWORD_HASH_PATTERN = /^\$(?:2[aby]?|argon2(?:i|d|id)?)\$/;
4900
+ function isAlreadyHashed(value) {
4901
+ return PASSWORD_HASH_PATTERN.test(value);
4902
+ }
4903
+ function hashCastValue(value) {
4904
+ const plain = String(value);
4905
+ if (isAlreadyHashed(plain)) {
4906
+ return plain;
4907
+ }
4908
+ return Bun.password.hashSync(plain, { algorithm: "bcrypt", cost: 10 });
4909
+ }
4714
4910
  function hydrateValue(value, cast) {
4715
4911
  if (value === null || value === undefined) {
4716
4912
  return value;
@@ -4750,12 +4946,15 @@ function dehydrateValue(value, cast) {
4750
4946
  case "int":
4751
4947
  return value === "" ? null : Number(value);
4752
4948
  case "hashed":
4753
- return value;
4949
+ return hashCastValue(value);
4754
4950
  default:
4755
4951
  return value;
4756
4952
  }
4757
4953
  }
4758
4954
  function filterMassAssignable(fillable, guarded, input) {
4955
+ if (fillable === undefined && guarded === undefined && Object.keys(input).length > 0) {
4956
+ throw new Error("Mass assignment is not configured for this model. Declare static $fillable = [...] to allow specific columns, or static $guarded = [] to allow all of them.");
4957
+ }
4759
4958
  const resolvedGuarded = guarded ?? true;
4760
4959
  if (fillable && fillable.length > 0) {
4761
4960
  const allowed = new Set(fillable);
@@ -4857,10 +5056,38 @@ class ModelQuery {
4857
5056
  this.query.whereNull(column);
4858
5057
  return this;
4859
5058
  }
5059
+ whereNotNull(column) {
5060
+ this.query.whereNotNull(column);
5061
+ return this;
5062
+ }
4860
5063
  whereIn(column, values) {
4861
5064
  this.query.whereIn(column, values);
4862
5065
  return this;
4863
5066
  }
5067
+ whereNotIn(column, values) {
5068
+ this.query.whereNotIn(column, values);
5069
+ return this;
5070
+ }
5071
+ groupBy(groupBy) {
5072
+ this.query.groupBy(groupBy);
5073
+ return this;
5074
+ }
5075
+ having(having) {
5076
+ this.query.having(having);
5077
+ return this;
5078
+ }
5079
+ join(left, right) {
5080
+ this.query.join(left, right);
5081
+ return this;
5082
+ }
5083
+ leftJoin(left, right) {
5084
+ this.query.leftJoin(left, right);
5085
+ return this;
5086
+ }
5087
+ async paginate(options) {
5088
+ const { data, meta } = await this.query.paginate(options);
5089
+ return { data: await this.hydrateRows(data), meta };
5090
+ }
4864
5091
  whereExists(sql, params = []) {
4865
5092
  this.query.whereExists(sql, params);
4866
5093
  return this;
@@ -4918,8 +5145,10 @@ class ModelQuery {
4918
5145
  return this;
4919
5146
  }
4920
5147
  async get() {
5148
+ return await this.hydrateRows(await this.query.get());
5149
+ }
5150
+ async hydrateRows(rows) {
4921
5151
  const statics = modelStatics(this.modelClass);
4922
- const rows = await this.query.get();
4923
5152
  const models = [];
4924
5153
  for (const row of rows) {
4925
5154
  const model = statics.newFromRecord(row, true);
@@ -4971,6 +5200,23 @@ class ModelQuery {
4971
5200
  then(onfulfilled, onrejected) {
4972
5201
  return this.get().then(onfulfilled ?? undefined, onrejected ?? undefined);
4973
5202
  }
5203
+ withCount(name, alias = `${name}_count`) {
5204
+ const statics = modelStatics(this.modelClass);
5205
+ ensureBooted(this.modelClass);
5206
+ const repository = resolveModelRepository(this.modelClass);
5207
+ const dummy = statics.newFromRecord({});
5208
+ const method = dummy[name];
5209
+ if (typeof method !== "function") {
5210
+ throw new Error(`${this.modelClass.name} has no relation method ${name}().`);
5211
+ }
5212
+ const relationQuery = method.call(dummy);
5213
+ const exists = relationQuery.toExistsClause(repository.getTable().name);
5214
+ if (!exists.sql.startsWith("SELECT 1 ")) {
5215
+ throw new Error(`Cannot count relation ${name}: unexpected subquery shape.`);
5216
+ }
5217
+ this.query.withSubqueryCount(alias, `SELECT COUNT(*) ${exists.sql.slice("SELECT 1 ".length)}`, exists.params);
5218
+ return this;
5219
+ }
4974
5220
  constrainExists(name, constrain, not) {
4975
5221
  const statics = modelStatics(this.modelClass);
4976
5222
  ensureBooted(this.modelClass);
@@ -5229,11 +5475,23 @@ class Model {
5229
5475
  return modelStatics(this).newFromRecord({ ...where, ...values }, false);
5230
5476
  }
5231
5477
  static async firstOrCreate(where, values = {}) {
5232
- const existing = await Model.firstWhere.call(this, where);
5478
+ const findExisting = () => Model.firstWhere.call(this, where);
5479
+ const existing = await findExisting();
5233
5480
  if (existing) {
5234
5481
  return existing;
5235
5482
  }
5236
- return Model.create.call(this, { ...where, ...values });
5483
+ try {
5484
+ return await Model.create.call(this, { ...where, ...values });
5485
+ } catch (error) {
5486
+ if (!(error instanceof ConflictError)) {
5487
+ throw error;
5488
+ }
5489
+ const raced = await findExisting();
5490
+ if (!raced) {
5491
+ throw error;
5492
+ }
5493
+ return raced;
5494
+ }
5237
5495
  }
5238
5496
  static async updateOrCreate(where, values = {}) {
5239
5497
  const existing = await Model.firstWhere.call(this, where);
@@ -7603,7 +7861,7 @@ function normalizeMimeType(mimeType) {
7603
7861
  function isAllowedMimeType(mimeType) {
7604
7862
  const normalized = normalizeMimeType(mimeType);
7605
7863
  if (!normalized || normalized === "application/octet-stream") {
7606
- return true;
7864
+ return envFlagEnabled(process.env.UPLOAD_ALLOW_UNKNOWN_MIME);
7607
7865
  }
7608
7866
  return ALLOWED_UPLOAD_MIME_TYPES.has(normalized);
7609
7867
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@getstrata/core",
3
- "version": "1.0.6",
3
+ "version": "1.0.8",
4
4
  "description": "Strata Bun framework public API",
5
5
  "type": "module",
6
6
  "license": "MIT",