tempest-db-js 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -5,7 +5,7 @@
5
5
 
6
6
  📖 **Documentation:** [Português (BR)](https://mauriciobenjamin700.github.io/tempest-db-js/) · [English (US)](https://mauriciobenjamin700.github.io/tempest-db-js/en/)
7
7
 
8
- > ✅ **Status: alpha (v0.4.0), published on [npm](https://www.npmjs.com/package/tempest-db-js).** The full path works end-to-end — declarative models with **foreign keys, UNIQUE and table constraints**, typed query builder (aggregations, `DISTINCT`, upsert), **real SQLite + PostgreSQL execution**, a **MySQL** dialect, joins, relations, Alembic-style migrations (sync + **async** runner) with a `tempest-db` CLI, a typed `BaseRepository`, and an opt-in active-record layer. The public API may still shift before v1.0.
8
+ > ✅ **Status: alpha (v0.5.0), published on [npm](https://www.npmjs.com/package/tempest-db-js).** The full path works end-to-end — declarative models with **foreign keys, UNIQUE, table constraints, explicit column names and PostgreSQL arrays**, a typed query builder (aggregations, `DISTINCT`, upsert with **partial-index predicates**, **`FOR UPDATE SKIP LOCKED`**, SQL expressions in writes), **real SQLite + PostgreSQL execution**, a **MySQL** dialect, joins, relations, Alembic-style migrations (sync + **async** runner) with a `tempest-db` CLI, a typed `BaseRepository`, an opt-in active-record layer, and a `session.raw` escape hatch. The public API may still shift before v1.0.
9
9
 
10
10
  ## Why tempest-db-js
11
11
 
@@ -76,6 +76,12 @@ Typed extras, each with a [docs recipe](https://mauriciobenjamin700.github.io/te
76
76
  - **Upsert** — `insert(Row).values(...).onConflictDoUpdate(["key"], { ... })` / `.onConflictDoNothing(["key"])` (portable SQLite ↔ PostgreSQL).
77
77
  - **Active-record (opt-in)** — `activeRecord(User, session)` → `save`/`update`/`delete`/`reload` over `.data`; the plain-object default is unchanged.
78
78
  - **Query logging & errors** — `createEngine(url, { onQuery })` traces every statement; a failed statement throws `QueryExecutionError` carrying the SQL + params.
79
+ - **Durable queues** — `select(Job).where(...).limit(10).forUpdate({ skipLocked: true })` inside a transaction hands each worker a disjoint batch, and `set({ attempts: sql.raw("attempts + 1") })` increments in the database instead of read-modify-write. SQLite throws rather than emitting an unlocked `SELECT`.
80
+ - **Partial-index upsert** — `onConflictDoNothing(["consumer", "idempotencyKey"], { where: { idempotencyKey: { isNull: false } } })` repeats the index predicate PostgreSQL requires to match a **partial** unique index as a conflict target.
81
+ - **Column names** — `.name("consumer_name")` per column, or `static naming = "snake_case"` per table: a `snake_case` schema behind a `camelCase` model, mapped everywhere including the migration IR (so no false drift).
82
+ - **PostgreSQL arrays** — `column.array(column.text())` → `text[]` typed as `string[]`, with `contains` (`@>`), `containedBy` (`<@`) and `overlaps` (`&&`) in `where`.
83
+ - **Case-insensitive lookups** — `{ ieq: probe }` → `lower(col) = lower($1)`: no wildcards, matches a `lower(col)` functional index. (`ilike` is pattern matching — `{ ilike: "%" }` matches every row.)
84
+ - **Raw SQL escape hatch** — `session.raw(sql, params, { as: Model })` for the query the builder cannot yet express, always parameterized and integrated with logging, errors and transactions.
79
85
 
80
86
  ## Migrations CLI
81
87
 
@@ -106,7 +112,7 @@ HTTP integration recipes (Hono, Express, Fastify) live in the [docs](https://mau
106
112
 
107
113
  ## Roadmap
108
114
 
109
- See [ROADMAP.md](./ROADMAP.md). Shipped (v0.4.0): declarative schema with foreign keys / UNIQUE / table constraints, SQLite + PostgreSQL execution (both tested in CI, Postgres against a live database), a MySQL dialect, joins, relations, sync + async migration runners with a `tempest-db` CLI, repository, aggregations/upsert, opt-in active-record. Next: MySQL execution in CI + `RETURNING` round-trip, async CLI wiring, then `tempest-ts-sdk`.
115
+ See [ROADMAP.md](./ROADMAP.md). Shipped (v0.5.0): declarative schema with foreign keys / UNIQUE / table constraints / explicit column names / PostgreSQL arrays, SQLite + PostgreSQL execution (both tested in CI, Postgres against a live database), a MySQL dialect, row locking, SQL expressions in writes, partial-index upsert, `session.raw`, joins, relations, sync + async migration runners with a `tempest-db` CLI, repository, aggregations/upsert, opt-in active-record. Next: subqueries in `IN`/`EXISTS` and `HAVING`, MySQL execution in CI + `RETURNING` round-trip, async CLI wiring, then `tempest-ts-sdk`.
110
116
 
111
117
  ## Development
112
118
 
package/dist/bin.cjs CHANGED
@@ -100,6 +100,22 @@ function makeRevisionId(label, parents) {
100
100
  return hash.toString(16).padStart(8, "0");
101
101
  }
102
102
 
103
+ // src/expressions.ts
104
+ function renderPortableToken(token, dialect) {
105
+ switch (token) {
106
+ case "now":
107
+ return dialect === "postgresql" ? "now()" : "CURRENT_TIMESTAMP";
108
+ case "current_date":
109
+ return "CURRENT_DATE";
110
+ case "current_time":
111
+ return "CURRENT_TIME";
112
+ case "uuidv4":
113
+ if (dialect === "postgresql") return "gen_random_uuid()";
114
+ if (dialect === "mysql") return "(UUID())";
115
+ return "(lower(hex(randomblob(16))))";
116
+ }
117
+ }
118
+
103
119
  // src/migrations/ddl.ts
104
120
  function quoteId(name, dialect) {
105
121
  return dialect === "mysql" ? `\`${name.replace(/`/g, "``")}\`` : `"${name.replace(/"/g, '""')}"`;
@@ -123,6 +139,8 @@ function renderColumnType(type, dialect) {
123
139
  return "NUMERIC";
124
140
  case "blob":
125
141
  return "BLOB";
142
+ case "array":
143
+ throw new Error(unsupportedArray("sqlite"));
126
144
  default:
127
145
  return "TEXT";
128
146
  }
@@ -164,6 +182,8 @@ function renderColumnType(type, dialect) {
164
182
  return "CHAR(36)";
165
183
  case "enum":
166
184
  return `ENUM(${(meta.values ?? []).map(quoteLiteral).join(", ")})`;
185
+ case "array":
186
+ throw new Error(unsupportedArray("mysql"));
167
187
  }
168
188
  }
169
189
  switch (kind) {
@@ -202,27 +222,39 @@ function renderColumnType(type, dialect) {
202
222
  return "UUID";
203
223
  case "enum":
204
224
  return "TEXT";
225
+ case "array":
226
+ return `${renderColumnType(arrayElement(meta.element), dialect)}[]`;
227
+ }
228
+ }
229
+ function unsupportedArray(dialect) {
230
+ return `column.array() is PostgreSQL-only \u2014 ${dialect} has no native array type. Model the column as JSON there, accepting that array operators will not work.`;
231
+ }
232
+ function arrayElement(element) {
233
+ if (!element) {
234
+ throw new Error(
235
+ "An array column has no element type \u2014 build it with column.array()."
236
+ );
205
237
  }
238
+ return element;
206
239
  }
207
- function renderDefault(def, dialect) {
240
+ function renderDefault(def, dialect, type) {
208
241
  if (def.kind === "expression") {
209
242
  const expr = def.expression;
210
- if (typeof expr === "object") return expr.raw;
211
- switch (expr) {
212
- case "now":
213
- return dialect === "postgresql" ? "now()" : "CURRENT_TIMESTAMP";
214
- case "current_date":
215
- return "CURRENT_DATE";
216
- case "current_time":
217
- return "CURRENT_TIME";
218
- case "uuidv4":
219
- if (dialect === "postgresql") return "gen_random_uuid()";
220
- if (dialect === "mysql") return "(UUID())";
221
- return "(lower(hex(randomblob(16))))";
243
+ if (typeof expr === "object") {
244
+ if ("raw" in expr) return expr.raw;
245
+ throw new Error(
246
+ "sql.expr`...` binds parameters and cannot be rendered as a DEFAULT \u2014 use sql.raw()."
247
+ );
222
248
  }
249
+ return renderPortableToken(expr, dialect);
223
250
  }
224
251
  const value = def.value;
225
252
  if (value === null) return "NULL";
253
+ if (Array.isArray(value) && type?.kind === "array") {
254
+ const elementType = renderColumnType(arrayElement(type.meta.element), dialect);
255
+ const items = value.map((v) => renderDefault({ kind: "literal", value: v }, dialect));
256
+ return `ARRAY[${items.join(", ")}]::${elementType}[]`;
257
+ }
226
258
  if (typeof value === "boolean") {
227
259
  return dialect === "postgresql" ? value ? "TRUE" : "FALSE" : value ? "1" : "0";
228
260
  }
@@ -268,7 +300,9 @@ function tableConstraintClauses(table, dialect) {
268
300
  function renderColumnDef(col, dialect) {
269
301
  let sql = `${quoteId(col.name, dialect)} ${renderColumnType(col.type, dialect)}`;
270
302
  if (col.notNull) sql += " NOT NULL";
271
- if (col.default !== null) sql += ` DEFAULT ${renderDefault(col.default, dialect)}`;
303
+ if (col.default !== null) {
304
+ sql += ` DEFAULT ${renderDefault(col.default, dialect, col.type)}`;
305
+ }
272
306
  sql += columnConstraintSuffix(col, dialect);
273
307
  return sql;
274
308
  }
@@ -292,7 +326,9 @@ function renderCreateTable(table, dialect) {
292
326
  typeStmts.push(`CREATE TYPE ${quoteId(typeName, dialect)} AS ENUM (${values})`);
293
327
  let def = `${quoteId(c.name, dialect)} ${quoteId(typeName, dialect)}`;
294
328
  if (c.notNull) def += " NOT NULL";
295
- if (c.default !== null) def += ` DEFAULT ${renderDefault(c.default, dialect)}`;
329
+ if (c.default !== null) {
330
+ def += ` DEFAULT ${renderDefault(c.default, dialect, c.type)}`;
331
+ }
296
332
  return def + columnConstraintSuffix(c, dialect);
297
333
  }
298
334
  if (dialect === "postgresql" && isAutoIncrementPk(table, c)) {
@@ -436,7 +472,7 @@ function renderAlterColumn(table, to, dialect) {
436
472
  to.notNull ? `ALTER TABLE ${t} ALTER COLUMN ${c} SET NOT NULL` : `ALTER TABLE ${t} ALTER COLUMN ${c} DROP NOT NULL`
437
473
  );
438
474
  stmts.push(
439
- to.default !== null ? `ALTER TABLE ${t} ALTER COLUMN ${c} SET DEFAULT ${renderDefault(to.default, dialect)}` : `ALTER TABLE ${t} ALTER COLUMN ${c} DROP DEFAULT`
475
+ to.default !== null ? `ALTER TABLE ${t} ALTER COLUMN ${c} SET DEFAULT ${renderDefault(to.default, dialect, to.type)}` : `ALTER TABLE ${t} ALTER COLUMN ${c} DROP DEFAULT`
440
476
  );
441
477
  return stmts;
442
478
  }
@@ -611,6 +647,9 @@ function heads(migrations) {
611
647
  function isDefaultValue(value) {
612
648
  return typeof value === "object" && value !== null && "kind" in value && (value.kind === "literal" || value.kind === "expression");
613
649
  }
650
+ function bindsParameters(value) {
651
+ return value.kind === "expression" && typeof value.expression === "object" && "parts" in value.expression;
652
+ }
614
653
  function parseReference(ref, options) {
615
654
  const dot = ref.lastIndexOf(".");
616
655
  if (dot <= 0 || dot === ref.length - 1) {
@@ -624,48 +663,71 @@ function parseReference(ref, options) {
624
663
  };
625
664
  }
626
665
  var Column = class _Column {
627
- constructor(type, flags, defaultValue = null, onUpdateValue = null, reference = null) {
666
+ constructor(type, flags, defaultValue = null, onUpdateValue = null, reference = null, dbName = null) {
628
667
  this.type = type;
629
668
  this.flags = flags;
630
669
  this.defaultValue = defaultValue;
631
670
  this.onUpdateValue = onUpdateValue;
632
671
  this.reference = reference;
672
+ this.dbName = dbName;
633
673
  }
634
674
  type;
635
675
  flags;
636
676
  defaultValue;
637
677
  onUpdateValue;
638
678
  reference;
639
- primaryKey() {
679
+ dbName;
680
+ /** Clone this column with one facet replaced, carrying every other over. */
681
+ derive(patch) {
640
682
  return new _Column(
641
683
  this.type,
642
- { ...this.flags, primaryKey: true, hasDefault: true },
643
- this.defaultValue,
644
- this.onUpdateValue,
645
- this.reference
684
+ patch.flags ?? this.flags,
685
+ patch.defaultValue !== void 0 ? patch.defaultValue : this.defaultValue,
686
+ patch.onUpdateValue !== void 0 ? patch.onUpdateValue : this.onUpdateValue,
687
+ patch.reference !== void 0 ? patch.reference : this.reference,
688
+ patch.dbName !== void 0 ? patch.dbName : this.dbName
646
689
  );
647
690
  }
691
+ primaryKey() {
692
+ return this.derive({ flags: { ...this.flags, primaryKey: true, hasDefault: true } });
693
+ }
648
694
  notNull() {
649
- return new _Column(
650
- this.type,
651
- { ...this.flags, notNull: true },
652
- this.defaultValue,
653
- this.onUpdateValue,
654
- this.reference
655
- );
695
+ return this.derive({ flags: { ...this.flags, notNull: true } });
656
696
  }
657
697
  /**
658
698
  * Add a `UNIQUE` constraint to the column (mirrors SQLAlchemy's
659
699
  * `mapped_column(unique=True)`). DDL-only — does not change the inferred type.
660
700
  */
661
701
  unique() {
662
- return new _Column(
663
- this.type,
664
- { ...this.flags, unique: true },
665
- this.defaultValue,
666
- this.onUpdateValue,
667
- this.reference
668
- );
702
+ return this.derive({ flags: { ...this.flags, unique: true } });
703
+ }
704
+ /**
705
+ * Map this property to a differently-named database column, à la SQLAlchemy's
706
+ * `mapped_column("consumer_name")` (Django's `db_column`, Prisma's `@map`).
707
+ *
708
+ * The override applies everywhere the name reaches SQL — select, insert,
709
+ * update, delete, where, order by, group by, returning, conflict targets, the
710
+ * migration IR and the drift check — while the TypeScript row keeps the
711
+ * property name. Use it to keep a `snake_case` schema behind a `camelCase`
712
+ * model; {@link Model.naming} does the same for a whole table at once.
713
+ *
714
+ * @param dbName The real column name in the database.
715
+ * @returns A new column bound to that name.
716
+ * @throws Error When `dbName` is empty.
717
+ *
718
+ * @example
719
+ * ```ts
720
+ * class ApiKey extends Model {
721
+ * static tablename = "api_keys";
722
+ * consumerName = column.text().name("consumer_name").notNull();
723
+ * }
724
+ * ```
725
+ */
726
+ name(dbName) {
727
+ if (dbName.length === 0) {
728
+ throw new Error("column.name() requires a non-empty database column name.");
729
+ }
730
+ return this.derive({ dbName });
669
731
  }
670
732
  /**
671
733
  * Declare a foreign-key reference to another table's column, à la SQLAlchemy's
@@ -678,37 +740,50 @@ var Column = class _Column {
678
740
  * @throws Error When `ref` is not a valid `"table.column"` string.
679
741
  */
680
742
  references(ref, options) {
681
- return new _Column(
682
- this.type,
683
- this.flags,
684
- this.defaultValue,
685
- this.onUpdateValue,
686
- parseReference(ref, options)
687
- );
743
+ return this.derive({ reference: parseReference(ref, options) });
688
744
  }
689
745
  /**
690
746
  * Set the insert-time default: a constant value of type `T`, or a portable
691
747
  * server-side expression from {@link sql} (e.g. `sql.now()`, `sql.uuidv4()`).
748
+ *
749
+ * @param value The literal default, or a {@link sql} expression.
750
+ * @returns A new column carrying the default.
751
+ * @throws Error When given a `sql.expr` fragment — a `DEFAULT` clause has
752
+ * nowhere to bind parameters; use `sql.raw()` for a verbatim expression.
692
753
  */
693
754
  default(value) {
694
755
  const resolved = isDefaultValue(value) ? value : { kind: "literal", value };
695
- return new _Column(
696
- this.type,
697
- { ...this.flags, hasDefault: true },
698
- resolved,
699
- this.onUpdateValue,
700
- this.reference
701
- );
756
+ if (bindsParameters(resolved)) {
757
+ throw new Error(
758
+ "sql.expr`...` binds parameters and cannot be a column default \u2014 use sql.raw() for a verbatim DEFAULT expression."
759
+ );
760
+ }
761
+ return this.derive({
762
+ flags: { ...this.flags, hasDefault: true },
763
+ defaultValue: resolved
764
+ });
702
765
  }
703
766
  /**
704
767
  * Re-apply a value whenever the row is updated (e.g. an `updated_at` column
705
768
  * with `sql.now()`). Mirrors SQLAlchemy's `onupdate`.
769
+ *
770
+ * @param value The literal value, or a {@link sql} expression.
771
+ * @returns A new column carrying the on-update value.
772
+ * @throws Error When given a `sql.expr` fragment (see {@link Column.default}).
706
773
  */
707
774
  onUpdate(value) {
708
775
  const resolved = isDefaultValue(value) ? value : { kind: "literal", value };
709
- return new _Column(this.type, this.flags, this.defaultValue, resolved, this.reference);
776
+ if (bindsParameters(resolved)) {
777
+ throw new Error(
778
+ "sql.expr`...` binds parameters and cannot be an onUpdate default \u2014 use sql.raw() for a verbatim expression."
779
+ );
780
+ }
781
+ return this.derive({ onUpdateValue: resolved });
710
782
  }
711
783
  };
784
+ function toSnakeCase(name) {
785
+ return name.replace(/([A-Z]+)([A-Z][a-z])/g, "$1_$2").replace(/([a-z0-9])([A-Z])/g, "$1_$2").toLowerCase();
786
+ }
712
787
  var columnsCache = /* @__PURE__ */ new WeakMap();
713
788
  function columnsOf(model) {
714
789
  const cached = columnsCache.get(model);
@@ -723,16 +798,43 @@ function columnsOf(model) {
723
798
  columnsCache.set(model, out);
724
799
  return out;
725
800
  }
801
+ var nameMapCache = /* @__PURE__ */ new WeakMap();
802
+ function columnNamesOf(model) {
803
+ const cached = nameMapCache.get(model);
804
+ if (cached !== void 0) return cached;
805
+ const strategy = model.naming ?? "preserve";
806
+ const map = {};
807
+ const seen = /* @__PURE__ */ new Map();
808
+ let renamed = false;
809
+ for (const [prop, col] of Object.entries(columnsOf(model))) {
810
+ const dbName = col.dbName ?? (strategy === "snake_case" ? toSnakeCase(prop) : prop);
811
+ const collision = seen.get(dbName);
812
+ if (collision !== void 0) {
813
+ throw new Error(
814
+ `${model.tablename}: properties "${collision}" and "${prop}" both map to column "${dbName}".`
815
+ );
816
+ }
817
+ seen.set(dbName, prop);
818
+ map[prop] = dbName;
819
+ if (dbName !== prop) renamed = true;
820
+ }
821
+ const result = renamed ? map : null;
822
+ nameMapCache.set(model, result);
823
+ return result;
824
+ }
726
825
 
727
826
  // src/migrations/ir.ts
728
827
  function constraintName(prefix, table, columns) {
729
828
  return `${prefix}_${table}_${columns.join("_")}`;
730
829
  }
731
830
  function reflectTable(model) {
831
+ const names = columnNamesOf(model);
832
+ const toColumn = (prop) => names?.[prop] ?? prop;
732
833
  const columns = {};
733
834
  const primaryKey = [];
734
- for (const [name, col] of Object.entries(columnsOf(model))) {
835
+ for (const [prop, col] of Object.entries(columnsOf(model))) {
735
836
  const isPk = col.flags.primaryKey;
837
+ const name = toColumn(prop);
736
838
  columns[name] = {
737
839
  name,
738
840
  type: col.type,
@@ -747,15 +849,16 @@ function reflectTable(model) {
747
849
  const uniqueConstraints = [];
748
850
  const foreignKeys = [];
749
851
  for (const c of model.tableArgs?.() ?? []) {
852
+ const cols = c.columns.map(toColumn);
750
853
  if (c.kind === "unique") {
751
854
  uniqueConstraints.push({
752
- name: c.name ?? constraintName("uq", model.tablename, c.columns),
753
- columns: c.columns
855
+ name: c.name ?? constraintName("uq", model.tablename, cols),
856
+ columns: cols
754
857
  });
755
858
  } else {
756
859
  foreignKeys.push({
757
- name: c.name ?? constraintName("fk", model.tablename, c.columns),
758
- columns: c.columns,
860
+ name: c.name ?? constraintName("fk", model.tablename, cols),
861
+ columns: cols,
759
862
  refTable: c.refTable,
760
863
  refColumns: c.refColumns,
761
864
  onDelete: c.onDelete,