@easy-cms/drizzle 1.15.0 → 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";
@@ -15,6 +16,7 @@ function getAt(data, path) {
15
16
  return current;
16
17
  }
17
18
  function setAt(data, path, value) {
19
+ if (path.includes("__proto__")) throw new Error(`Invalid field path: ${path.join(".")}`);
18
20
  let current = data;
19
21
  for (const key of path.slice(0, -1)) {
20
22
  if (typeof current[key] !== "object" || current[key] === null) current[key] = {};
@@ -213,7 +215,8 @@ function buildSchema(config, prefix, dialect) {
213
215
  tableNames: /* @__PURE__ */ new Set([migrationsTableName(prefix)]),
214
216
  // The hash covers the logical schema only, so it stays stable across releases for the same config.
215
217
  description: [],
216
- localization: config.localization
218
+ localization: config.localization,
219
+ statistics: []
217
220
  };
218
221
  const collections = /* @__PURE__ */ new Map();
219
222
  const tables = {};
@@ -243,9 +246,19 @@ function buildSchema(config, prefix, dialect) {
243
246
  );
244
247
  tables[globalsName] = globals;
245
248
  builder.description.push(["globals", globalsName]);
246
- 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;
247
252
  const localization = config.localization ? { defaultLocale: config.localization.defaultLocale, locales: config.localization.locales } : null;
248
- 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
+ };
249
262
  }
250
263
  function collectTables(model, into) {
251
264
  into[model.name] = model.table;
@@ -402,7 +415,9 @@ function buildTable(builder, name, kind, fields, options) {
402
415
  } else if (field.index === "trigram") {
403
416
  indexes.push({ column, unique: false, trigram: true });
404
417
  } else if (field.index || field.unique || field.type === "slug" || field.type === "relationship" || field.type === "upload") {
405
- indexes.push({ column, unique: false });
418
+ indexes.push(
419
+ kind === "root" && options.drafts ? { column, unique: false, withStatus: true } : { column, unique: false }
420
+ );
406
421
  }
407
422
  }
408
423
  }
@@ -412,7 +427,7 @@ function buildTable(builder, name, kind, fields, options) {
412
427
  const table = dialect.table(
413
428
  name,
414
429
  columns,
415
- (t) => indexes.flatMap(({ column, unique, within, trigram }) => {
430
+ (t) => indexes.flatMap(({ column, unique, within, trigram, withStatus }) => {
416
431
  const col3 = t[column];
417
432
  if (trigram)
418
433
  return dialect.trigramIndex ? [dialect.trigramIndex(`${name}_${column}_trgm`).on(col3)] : [];
@@ -420,10 +435,19 @@ function buildTable(builder, name, kind, fields, options) {
420
435
  const columns2 = [within].flat();
421
436
  return dialect.uniqueIndex(`${name}_${columns2.join("_")}_${column}_unique`).on(...columns2.map((w) => t[w]), col3);
422
437
  }
438
+ if (withStatus)
439
+ return dialect.index(`${name}_${column}_status_idx`).on(col3, t.status);
423
440
  return unique ? dialect.uniqueIndex(`${name}_${column}_unique`).on(col3) : dialect.index(`${name}_${column}_idx`).on(col3);
424
441
  })
425
442
  );
426
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
+ });
427
451
  const model = { name, kind, table, fields, columns: columnModels, children };
428
452
  return options.valueField ? { ...model, valueField: options.valueField } : model;
429
453
  }
@@ -486,14 +510,40 @@ var Migrator = class {
486
510
  }
487
511
  async snapshotOf(schema) {
488
512
  const snapshot = await this.options.dialect.snapshot(schema ? schema.tables : {});
489
- const meta = { localization: schema?.localization ?? null };
513
+ const meta = {
514
+ localization: schema?.localization ?? null,
515
+ statistics: schema?.statistics ?? []
516
+ };
490
517
  return { ...snapshot, [META]: meta };
491
518
  }
492
519
  /** SQL statements that turn `prev` into `cur`, with the extensions their indexes need. */
493
520
  async diff(prev, cur, interactive) {
494
- 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
+ ];
495
526
  return statements.some((s) => s.includes("gin_trgm_ops")) ? ["CREATE EXTENSION IF NOT EXISTS pg_trgm", ...statements] : statements;
496
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
+ }
497
547
  /** Renames become drop + add unless interactive. */
498
548
  async changes(prev, cur, interactive) {
499
549
  const { dialect } = this.options;
@@ -714,7 +764,7 @@ function extensionHint(error) {
714
764
 
715
765
  // src/query.ts
716
766
  import { QueryError } from "@easy-cms/core";
717
- import { parseId } from "@easy-cms/core/internal";
767
+ import { parseId, TRIGRAM_LIKE_MIN } from "@easy-cms/core/internal";
718
768
  import {
719
769
  and as and2,
720
770
  asc as asc2,
@@ -907,6 +957,10 @@ function operatorSQL(dialect, column, field, op, raw, path) {
907
957
  if (typeof raw !== "string") throw new QueryError(`"${path}.like" must be a string`);
908
958
  if (field && !LIKE_TYPES.has(field.type))
909
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
+ );
910
964
  return dialect.like(column, `%${escapeLike(raw)}%`);
911
965
  case "exists":
912
966
  return raw === false ? isNull(column) : isNotNull(column);
@@ -1134,11 +1188,10 @@ var DrizzleDatabase = class {
1134
1188
  this.migrator = migrator;
1135
1189
  this.tablePrefix = tablePrefix;
1136
1190
  this.countCacheMs = countCacheMs;
1137
- this.db = connection.db;
1138
1191
  for (const [slug, model] of schema.collections) {
1139
1192
  this.builders.set(
1140
1193
  slug,
1141
- new WhereBuilder(this.db, dialect, model.config.drafts === true, schema.localization)
1194
+ new WhereBuilder(connection.db, dialect, model.config.drafts === true, schema.localization)
1142
1195
  );
1143
1196
  }
1144
1197
  }
@@ -1155,7 +1208,15 @@ var DrizzleDatabase = class {
1155
1208
  * site they would all the time), so a total is up to that old, on every instance alike.
1156
1209
  */
1157
1210
  counts = /* @__PURE__ */ new Map();
1158
- 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
+ }
1159
1220
  model(slug) {
1160
1221
  const model = this.schema.collections.get(slug);
1161
1222
  if (!model) throw new QueryError2(`Unknown collection "${slug}"`);
@@ -1382,7 +1443,7 @@ var DrizzleDatabase = class {
1382
1443
  });
1383
1444
  }
1384
1445
  get transfer() {
1385
- const { tables, hash } = this.schema;
1446
+ const { tables, configHash } = this.schema;
1386
1447
  const table = (name) => {
1387
1448
  const found = tables[name];
1388
1449
  if (!found) throw new QueryError2(`Unknown table "${name}"`);
@@ -1393,7 +1454,7 @@ var DrizzleDatabase = class {
1393
1454
  return columns.id ?? columns.slug;
1394
1455
  };
1395
1456
  return {
1396
- schemaHash: hash,
1457
+ schemaHash: configHash,
1397
1458
  tables: () => Object.keys(tables),
1398
1459
  count: async (name) => {
1399
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.0",
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.0"
26
+ "@easy-cms/core": "^1.16.0"
27
27
  },
28
28
  "devDependencies": {
29
- "@easy-cms/core": "^1.15.0"
29
+ "@easy-cms/core": "^1.16.0"
30
30
  },
31
31
  "publishConfig": {
32
32
  "access": "public"