@easy-cms/drizzle 1.8.0 → 1.10.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/dist/index.d.ts CHANGED
@@ -43,6 +43,10 @@ interface Dialect {
43
43
  uniqueIndex(name: string): {
44
44
  on(...columns: AnyColumn[]): IndexBuilder;
45
45
  };
46
+ /** An index for `like` on any substring (`index: 'trigram'`); without it, none is made. */
47
+ trigramIndex?(name: string): {
48
+ on(column: AnyColumn): IndexBuilder;
49
+ };
46
50
  /** Auto-incrementing integer primary key. */
47
51
  serial(name: string): ColumnBuilder;
48
52
  text(name: string): ColumnBuilder;
@@ -184,8 +188,10 @@ declare class Migrator {
184
188
  private get runner();
185
189
  private q;
186
190
  private snapshotOf;
187
- /** SQL statements that turn `prev` into `cur`. Renames become drop + add unless interactive. */
191
+ /** SQL statements that turn `prev` into `cur`, with the extensions their indexes need. */
188
192
  private diff;
193
+ /** Renames become drop + add unless interactive. */
194
+ private changes;
189
195
  /**
190
196
  * A new default locale changes which locale the plain column holds (`title` is the default
191
197
  * locale's, `title__en` another's). Moves the values so every locale keeps its own.
package/dist/index.js CHANGED
@@ -399,6 +399,8 @@ function buildTable(builder, name, kind, fields, options) {
399
399
  indexes.push({ column, unique: false });
400
400
  } else if (topLevel && kind === "root" && (field.unique || field.type === "slug")) {
401
401
  indexes.push({ column, unique: true });
402
+ } else if (field.index === "trigram") {
403
+ indexes.push({ column, unique: false, trigram: true });
402
404
  } else if (field.index || field.unique || field.type === "slug" || field.type === "relationship" || field.type === "upload") {
403
405
  indexes.push({ column, unique: false });
404
406
  }
@@ -410,8 +412,10 @@ function buildTable(builder, name, kind, fields, options) {
410
412
  const table = dialect.table(
411
413
  name,
412
414
  columns,
413
- (t) => indexes.map(({ column, unique, within }) => {
415
+ (t) => indexes.flatMap(({ column, unique, within, trigram }) => {
414
416
  const col3 = t[column];
417
+ if (trigram)
418
+ return dialect.trigramIndex ? [dialect.trigramIndex(`${name}_${column}_trgm`).on(col3)] : [];
415
419
  if (within) {
416
420
  const columns2 = [within].flat();
417
421
  return dialect.uniqueIndex(`${name}_${columns2.join("_")}_${column}_unique`).on(...columns2.map((w) => t[w]), col3);
@@ -485,8 +489,13 @@ var Migrator = class {
485
489
  const meta = { localization: schema?.localization ?? null };
486
490
  return { ...snapshot, [META]: meta };
487
491
  }
488
- /** SQL statements that turn `prev` into `cur`. Renames become drop + add unless interactive. */
492
+ /** SQL statements that turn `prev` into `cur`, with the extensions their indexes need. */
489
493
  async diff(prev, cur, interactive) {
494
+ const statements = await this.changes(prev, cur, interactive);
495
+ return statements.some((s) => s.includes("gin_trgm_ops")) ? ["CREATE EXTENSION IF NOT EXISTS pg_trgm", ...statements] : statements;
496
+ }
497
+ /** Renames become drop + add unless interactive. */
498
+ async changes(prev, cur, interactive) {
490
499
  const { dialect } = this.options;
491
500
  const moves = this.localeMoves(prev, metaOf(prev), metaOf(cur));
492
501
  const [from, to] = [withoutMeta(prev), withoutMeta(cur)];
@@ -572,10 +581,16 @@ var Migrator = class {
572
581
  if (statements.some((s) => DESTRUCTIVE.test(s))) {
573
582
  this.options.logger.warn("Schema push drops tables or columns; data in them is lost.");
574
583
  }
575
- await this.runner.transaction([
576
- ...statements.map((sql3) => ({ sql: sql3 })),
577
- this.record(DEV_PUSH, this.options.schema.hash, cur, true)
578
- ]);
584
+ try {
585
+ await this.runner.transaction([
586
+ ...statements.map((sql3) => ({ sql: sql3 })),
587
+ this.record(DEV_PUSH, this.options.schema.hash, cur, true)
588
+ ]);
589
+ } catch (error) {
590
+ const hint = extensionHint(error);
591
+ if (hint) throw new SchemaError(`${error.message}${hint}`);
592
+ throw error;
593
+ }
579
594
  if (statements.length > 0)
580
595
  this.options.logger.info(`Schema pushed (${statements.length} statements).`);
581
596
  }
@@ -647,7 +662,7 @@ ${BREAKPOINT}
647
662
  ]);
648
663
  } catch (error) {
649
664
  throw new SchemaError(
650
- `Migration ${migration.name} failed and was rolled back: ${error.message}`
665
+ `Migration ${migration.name} failed and was rolled back: ${error.message}${extensionHint(error)}`
651
666
  );
652
667
  }
653
668
  this.options.logger.info(`Applied ${migration.name}`);
@@ -689,6 +704,13 @@ function timestamp() {
689
704
  const pad = (n) => String(n).padStart(2, "0");
690
705
  return `${d.getUTCFullYear()}${pad(d.getUTCMonth() + 1)}${pad(d.getUTCDate())}${pad(d.getUTCHours())}${pad(d.getUTCMinutes())}${pad(d.getUTCSeconds())}`;
691
706
  }
707
+ function extensionHint(error) {
708
+ const match = /permission denied to create extension "([^"]+)"/.exec(
709
+ error?.message ?? ""
710
+ );
711
+ if (!match) return "";
712
+ return `. Ask the database's owner to run, once: CREATE EXTENSION IF NOT EXISTS ${match[1]} (with SCHEMA <yours> when this role's search_path is a schema of its own).`;
713
+ }
692
714
 
693
715
  // src/query.ts
694
716
  import { QueryError } from "@easy-cms/core";
@@ -1031,6 +1053,49 @@ var WhereBuilder = class {
1031
1053
  order.push(desc(col2(model, "id")));
1032
1054
  return order;
1033
1055
  }
1056
+ /**
1057
+ * The columns a cursor goes by, in `orderBy`'s order: the sort's, then `id`. Only plain
1058
+ * columns: a sort on a value inside blocks has nothing to compare a cursor with.
1059
+ */
1060
+ keyset(model, sort) {
1061
+ const keys = sort.map((entry) => {
1062
+ const descending = entry.startsWith("-");
1063
+ const path = descending ? entry.slice(1) : entry;
1064
+ const target = resolvePath(model, path.split("."), this.drafts, this.localization);
1065
+ if (target.kind !== "column")
1066
+ throw new QueryError(
1067
+ `A cursor can't follow a sort by "${path}"; sort by a field of the document, or use page`
1068
+ );
1069
+ return { column: target.column, descending };
1070
+ });
1071
+ keys.push({ column: col2(model, "id"), descending: true });
1072
+ return keys;
1073
+ }
1074
+ /**
1075
+ * Rows after `values` (the keys of the last row of the page before) in the keyset's order.
1076
+ * Where nulls go follows the database's default order, as `orderBy` leaves it: last in
1077
+ * ascending order on Postgres, first on SQLite.
1078
+ */
1079
+ after(keys, values) {
1080
+ const nullsHigh = this.dialect.name === "postgres";
1081
+ const equal = (key, value) => value === null ? isNull(key.column) : eq2(key.column, value);
1082
+ const later = (key, value) => {
1083
+ const nullsLater = nullsHigh !== key.descending;
1084
+ if (value === null) return nullsLater ? sql`1 = 0` : isNotNull(key.column);
1085
+ const beyond = key.descending ? lt(key.column, value) : gt(key.column, value);
1086
+ return nullsLater ? or(beyond, isNull(key.column)) : beyond;
1087
+ };
1088
+ const branches = keys.map(
1089
+ (key, i) => and2(...keys.slice(0, i).map((k, j) => equal(k, values[j])), later(key, values[i]))
1090
+ );
1091
+ const rest = or(...branches);
1092
+ const [first] = keys;
1093
+ const start = values[0];
1094
+ if (!first || start === null || start === void 0) return rest;
1095
+ const from = first.descending ? lte(first.column, start) : gte(first.column, start);
1096
+ const bound = nullsHigh !== first.descending ? or(from, isNull(first.column)) : from;
1097
+ return and2(bound, rest);
1098
+ }
1034
1099
  };
1035
1100
 
1036
1101
  // src/database.ts
@@ -1052,6 +1117,7 @@ async function connectDatabase(dialect, connection, args, options = {}) {
1052
1117
  });
1053
1118
  if (args.schema === "push") await migrator.push();
1054
1119
  else if (args.schema === "verify") await migrator.verify();
1120
+ if (dialect.name === "postgres" && usesTrigram(args.config)) await checkCtype(connection, args);
1055
1121
  const countCacheMs = (args.config.countCacheSeconds ?? 5) * 1e3;
1056
1122
  return new DrizzleDatabase(dialect, connection, schema, migrator, prefix, countCacheMs);
1057
1123
  } catch (error) {
@@ -1132,6 +1198,24 @@ var DrizzleDatabase = class {
1132
1198
  hasPrevPage: args.page > 1
1133
1199
  };
1134
1200
  }
1201
+ async findAfter(args) {
1202
+ const model = this.model(args.collection);
1203
+ const builder = this.builder(args.collection);
1204
+ const keys = builder.keyset(model.root, args.sort);
1205
+ const condition = this.where(args.collection, args.where);
1206
+ const after = args.after ? builder.after(keys, args.after) : void 0;
1207
+ const columns = Object.entries(getTableColumns(model.root.table));
1208
+ const names = keys.map(({ column }) => columns.find(([, c]) => c === column)?.[0]);
1209
+ const rows = await this.db.select().from(model.root.table).where(and3(condition, after)).orderBy(...builder.orderBy(model.root, args.sort)).limit(args.limit + 1);
1210
+ const more = rows.length > args.limit;
1211
+ const page = more ? rows.slice(0, args.limit) : rows;
1212
+ const last = page.at(-1);
1213
+ return {
1214
+ docs: await this.load(model, page),
1215
+ hasNextPage: more,
1216
+ next: last ? names.map((name) => last[name] ?? null) : null
1217
+ };
1218
+ }
1135
1219
  async findRows(args, take) {
1136
1220
  const model = this.model(args.collection);
1137
1221
  const condition = this.where(args.collection, args.where);
@@ -1326,6 +1410,26 @@ var DrizzleDatabase = class {
1326
1410
  await this.connection.close();
1327
1411
  }
1328
1412
  };
1413
+ function usesTrigram(config) {
1414
+ const search = (fields) => fields.some((field) => {
1415
+ const f = field;
1416
+ return f.index === "trigram" || (f.fields ? search(f.fields) : false) || (f.blocks ? f.blocks.some((b) => search(b.fields)) : false) || (f.tabs ? f.tabs.some((t) => search(t.fields)) : false);
1417
+ });
1418
+ return [...config.collections, ...config.globals].some((c) => search(c.fields));
1419
+ }
1420
+ async function checkCtype(connection, args) {
1421
+ try {
1422
+ const [row] = await connection.runner.query(
1423
+ "SELECT datctype FROM pg_database WHERE datname = current_database()"
1424
+ );
1425
+ const ctype = String(row?.datctype ?? "");
1426
+ if (ctype === "C" || ctype === "POSIX")
1427
+ args.logger.warn(
1428
+ `index: 'trigram' can't help searching Thai or other non-ASCII text in this database: its LC_CTYPE is ${ctype}. Create the database with LC_CTYPE 'C.UTF-8', 'en_US.UTF-8' or 'th_TH.UTF-8'.`
1429
+ );
1430
+ } catch {
1431
+ }
1432
+ }
1329
1433
  export {
1330
1434
  DEFAULT_MIGRATION_DIR,
1331
1435
  DEFAULT_TABLE_PREFIX,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@easy-cms/drizzle",
3
- "version": "1.8.0",
3
+ "version": "1.10.0",
4
4
  "description": "Shared Drizzle ORM layer for Easy CMS database adapters",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -23,10 +23,10 @@
23
23
  "drizzle-orm": "0.45.4"
24
24
  },
25
25
  "peerDependencies": {
26
- "@easy-cms/core": "^1.8.0"
26
+ "@easy-cms/core": "^1.10.0"
27
27
  },
28
28
  "devDependencies": {
29
- "@easy-cms/core": "^1.8.0"
29
+ "@easy-cms/core": "^1.10.0"
30
30
  },
31
31
  "publishConfig": {
32
32
  "access": "public"