@easy-cms/drizzle 1.15.1 → 1.16.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
@@ -100,6 +100,8 @@ interface DrizzleAdapterOptions {
100
100
  /** A driver connection an adapter opened. */
101
101
  interface Connection {
102
102
  readonly db: DrizzleDb;
103
+ /** Connections kept for writes (`Database.writing`), when the adapter opened some. */
104
+ readonly writeDb?: DrizzleDb | undefined;
103
105
  readonly runner: SqlRunner;
104
106
  /**
105
107
  * With a single-writer dialect: identifies the database (e.g. the SQLite file), so every
@@ -156,8 +158,22 @@ interface SchemaModel {
156
158
  readonly tables: Readonly<Record<string, AnyTable>>;
157
159
  /** Changes whenever the generated schema changes. Used to detect missing migrations. */
158
160
  readonly hash: string;
161
+ /** The same for the same config on every database (`hash` adds what only one has): for copies. */
162
+ readonly configHash: string;
159
163
  /** Locales of localized fields, recorded with snapshots so a new default locale keeps data. */
160
164
  readonly localization: SchemaLocalization | null;
165
+ /** Statistics on columns that depend on each other (Postgres), recorded with snapshots. */
166
+ readonly statistics: readonly SchemaStatistics[];
167
+ }
168
+ /**
169
+ * Which values of the columns go together (`CREATE STATISTICS … (mcv)`): a field indexed with
170
+ * `status`. Without them Postgres takes the two as independent, and for a value whose documents
171
+ * are all drafts it expects published ones and reads a whole index looking for them.
172
+ */
173
+ interface SchemaStatistics {
174
+ readonly name: string;
175
+ readonly table: string;
176
+ readonly columns: readonly string[];
161
177
  }
162
178
  interface SchemaLocalization {
163
179
  readonly defaultLocale: string;
@@ -190,6 +206,11 @@ declare class Migrator {
190
206
  private snapshotOf;
191
207
  /** SQL statements that turn `prev` into `cur`, with the extensions their indexes need. */
192
208
  private diff;
209
+ /**
210
+ * Statistics that `cur` adds or `prev` had and `cur` doesn't, on Postgres: dropped before the
211
+ * tables change, made after, then the tables analyzed so the planner has them at once.
212
+ */
213
+ private statistics;
193
214
  /** Renames become drop + add unless interactive. */
194
215
  private changes;
195
216
  /**
package/dist/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  // src/database.ts
2
+ import { AsyncLocalStorage } from "async_hooks";
2
3
  import { resolve } from "path";
3
4
  import { QueryError as QueryError2 } from "@easy-cms/core";
4
5
  import { and as and3, asc as asc3, count, eq as eq3, getTableColumns, getTableName, inArray as inArray3, sql as sql2 } from "drizzle-orm";
@@ -214,7 +215,8 @@ function buildSchema(config, prefix, dialect) {
214
215
  tableNames: /* @__PURE__ */ new Set([migrationsTableName(prefix)]),
215
216
  // The hash covers the logical schema only, so it stays stable across releases for the same config.
216
217
  description: [],
217
- localization: config.localization
218
+ localization: config.localization,
219
+ statistics: []
218
220
  };
219
221
  const collections = /* @__PURE__ */ new Map();
220
222
  const tables = {};
@@ -244,9 +246,19 @@ function buildSchema(config, prefix, dialect) {
244
246
  );
245
247
  tables[globalsName] = globals;
246
248
  builder.description.push(["globals", globalsName]);
247
- const hash = createHash("sha256").update(JSON.stringify(builder.description)).digest("hex").slice(0, 16);
249
+ const hashOf = (description) => createHash("sha256").update(JSON.stringify(description)).digest("hex").slice(0, 16);
250
+ const configHash = hashOf(builder.description);
251
+ const hash = builder.statistics.length > 0 ? hashOf([...builder.description, ["statistics", builder.statistics.map((s) => s.name)]]) : configHash;
248
252
  const localization = config.localization ? { defaultLocale: config.localization.defaultLocale, locales: config.localization.locales } : null;
249
- return { collections, globals, tables, hash, localization };
253
+ return {
254
+ collections,
255
+ globals,
256
+ tables,
257
+ hash,
258
+ configHash,
259
+ localization,
260
+ statistics: builder.statistics
261
+ };
250
262
  }
251
263
  function collectTables(model, into) {
252
264
  into[model.name] = model.table;
@@ -403,7 +415,9 @@ function buildTable(builder, name, kind, fields, options) {
403
415
  } else if (field.index === "trigram") {
404
416
  indexes.push({ column, unique: false, trigram: true });
405
417
  } else if (field.index || field.unique || field.type === "slug" || field.type === "relationship" || field.type === "upload") {
406
- indexes.push({ column, unique: false });
418
+ indexes.push(
419
+ kind === "root" && options.drafts ? { column, unique: false, withStatus: true } : { column, unique: false }
420
+ );
407
421
  }
408
422
  }
409
423
  }
@@ -413,7 +427,7 @@ function buildTable(builder, name, kind, fields, options) {
413
427
  const table = dialect.table(
414
428
  name,
415
429
  columns,
416
- (t) => indexes.flatMap(({ column, unique, within, trigram }) => {
430
+ (t) => indexes.flatMap(({ column, unique, within, trigram, withStatus }) => {
417
431
  const col3 = t[column];
418
432
  if (trigram)
419
433
  return dialect.trigramIndex ? [dialect.trigramIndex(`${name}_${column}_trgm`).on(col3)] : [];
@@ -421,10 +435,19 @@ function buildTable(builder, name, kind, fields, options) {
421
435
  const columns2 = [within].flat();
422
436
  return dialect.uniqueIndex(`${name}_${columns2.join("_")}_${column}_unique`).on(...columns2.map((w) => t[w]), col3);
423
437
  }
438
+ if (withStatus)
439
+ return dialect.index(`${name}_${column}_status_idx`).on(col3, t.status);
424
440
  return unique ? dialect.uniqueIndex(`${name}_${column}_unique`).on(col3) : dialect.index(`${name}_${column}_idx`).on(col3);
425
441
  })
426
442
  );
427
443
  builder.description.push([name, kind, described, indexes]);
444
+ for (const { column, withStatus } of indexes)
445
+ if (withStatus && dialect.name === "postgres")
446
+ builder.statistics.push({
447
+ name: `${name}_${column}_status_stats`,
448
+ table: name,
449
+ columns: [column, "status"]
450
+ });
428
451
  const model = { name, kind, table, fields, columns: columnModels, children };
429
452
  return options.valueField ? { ...model, valueField: options.valueField } : model;
430
453
  }
@@ -487,14 +510,40 @@ var Migrator = class {
487
510
  }
488
511
  async snapshotOf(schema) {
489
512
  const snapshot = await this.options.dialect.snapshot(schema ? schema.tables : {});
490
- const meta = { localization: schema?.localization ?? null };
513
+ const meta = {
514
+ localization: schema?.localization ?? null,
515
+ statistics: schema?.statistics ?? []
516
+ };
491
517
  return { ...snapshot, [META]: meta };
492
518
  }
493
519
  /** SQL statements that turn `prev` into `cur`, with the extensions their indexes need. */
494
520
  async diff(prev, cur, interactive) {
495
- const statements = await this.changes(prev, cur, interactive);
521
+ const statements = [
522
+ ...this.statistics(prev, cur, "drop"),
523
+ ...await this.changes(prev, cur, interactive),
524
+ ...this.statistics(prev, cur, "create")
525
+ ];
496
526
  return statements.some((s) => s.includes("gin_trgm_ops")) ? ["CREATE EXTENSION IF NOT EXISTS pg_trgm", ...statements] : statements;
497
527
  }
528
+ /**
529
+ * Statistics that `cur` adds or `prev` had and `cur` doesn't, on Postgres: dropped before the
530
+ * tables change, made after, then the tables analyzed so the planner has them at once.
531
+ */
532
+ statistics(prev, cur, step) {
533
+ if (this.options.dialect.name !== "postgres") return [];
534
+ const before = metaOf(prev)?.statistics ?? [];
535
+ const after = metaOf(cur)?.statistics ?? [];
536
+ const q = (name) => this.q(name);
537
+ if (step === "drop")
538
+ return before.filter((s) => !after.some((a) => a.name === s.name)).map((s) => `DROP STATISTICS IF EXISTS ${q(s.name)}`);
539
+ const added = after.filter((s) => !before.some((b) => b.name === s.name));
540
+ return [
541
+ ...added.map(
542
+ (s) => `CREATE STATISTICS IF NOT EXISTS ${q(s.name)} (mcv) ON ${s.columns.map(q).join(", ")} FROM ${q(s.table)}`
543
+ ),
544
+ ...[...new Set(added.map((s) => s.table))].map((table) => `ANALYZE ${q(table)}`)
545
+ ];
546
+ }
498
547
  /** Renames become drop + add unless interactive. */
499
548
  async changes(prev, cur, interactive) {
500
549
  const { dialect } = this.options;
@@ -715,7 +764,7 @@ function extensionHint(error) {
715
764
 
716
765
  // src/query.ts
717
766
  import { QueryError } from "@easy-cms/core";
718
- import { parseId } from "@easy-cms/core/internal";
767
+ import { parseId, TRIGRAM_LIKE_MIN } from "@easy-cms/core/internal";
719
768
  import {
720
769
  and as and2,
721
770
  asc as asc2,
@@ -908,6 +957,10 @@ function operatorSQL(dialect, column, field, op, raw, path) {
908
957
  if (typeof raw !== "string") throw new QueryError(`"${path}.like" must be a string`);
909
958
  if (field && !LIKE_TYPES.has(field.type))
910
959
  throw new QueryError(`"like" works on text fields, not "${path}"`);
960
+ if (field?.index === "trigram" && dialect.trigramIndex && [...raw].length < TRIGRAM_LIKE_MIN)
961
+ throw new QueryError(
962
+ `"${path}.like" needs at least ${TRIGRAM_LIKE_MIN} characters: its trigram index can't find shorter text`
963
+ );
911
964
  return dialect.like(column, `%${escapeLike(raw)}%`);
912
965
  case "exists":
913
966
  return raw === false ? isNull(column) : isNotNull(column);
@@ -1135,11 +1188,10 @@ var DrizzleDatabase = class {
1135
1188
  this.migrator = migrator;
1136
1189
  this.tablePrefix = tablePrefix;
1137
1190
  this.countCacheMs = countCacheMs;
1138
- this.db = connection.db;
1139
1191
  for (const [slug, model] of schema.collections) {
1140
1192
  this.builders.set(
1141
1193
  slug,
1142
- new WhereBuilder(this.db, dialect, model.config.drafts === true, schema.localization)
1194
+ new WhereBuilder(connection.db, dialect, model.config.drafts === true, schema.localization)
1143
1195
  );
1144
1196
  }
1145
1197
  }
@@ -1156,7 +1208,15 @@ var DrizzleDatabase = class {
1156
1208
  * site they would all the time), so a total is up to that old, on every instance alike.
1157
1209
  */
1158
1210
  counts = /* @__PURE__ */ new Map();
1159
- db;
1211
+ /** Set inside `writing`: queries there use the connections for writes. */
1212
+ writes = new AsyncLocalStorage();
1213
+ get db() {
1214
+ const { writeDb } = this.connection;
1215
+ return writeDb && this.writes.getStore() ? writeDb : this.connection.db;
1216
+ }
1217
+ writing(fn) {
1218
+ return this.connection.writeDb ? this.writes.run(true, fn) : fn();
1219
+ }
1160
1220
  model(slug) {
1161
1221
  const model = this.schema.collections.get(slug);
1162
1222
  if (!model) throw new QueryError2(`Unknown collection "${slug}"`);
@@ -1383,7 +1443,7 @@ var DrizzleDatabase = class {
1383
1443
  });
1384
1444
  }
1385
1445
  get transfer() {
1386
- const { tables, hash } = this.schema;
1446
+ const { tables, configHash } = this.schema;
1387
1447
  const table = (name) => {
1388
1448
  const found = tables[name];
1389
1449
  if (!found) throw new QueryError2(`Unknown table "${name}"`);
@@ -1394,7 +1454,7 @@ var DrizzleDatabase = class {
1394
1454
  return columns.id ?? columns.slug;
1395
1455
  };
1396
1456
  return {
1397
- schemaHash: hash,
1457
+ schemaHash: configHash,
1398
1458
  tables: () => Object.keys(tables),
1399
1459
  count: async (name) => {
1400
1460
  const [row] = await this.db.select({ n: count() }).from(table(name));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@easy-cms/drizzle",
3
- "version": "1.15.1",
3
+ "version": "1.16.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.15.1"
26
+ "@easy-cms/core": "^1.16.0"
27
27
  },
28
28
  "devDependencies": {
29
- "@easy-cms/core": "^1.15.1"
29
+ "@easy-cms/core": "^1.16.0"
30
30
  },
31
31
  "publishConfig": {
32
32
  "access": "public"