@easy-cms/drizzle 0.6.0 → 0.8.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
@@ -19,9 +19,23 @@ interface SqlRunner {
19
19
  /** Runs the statements in one transaction: all or nothing. */
20
20
  transaction(statements: readonly Statement[]): Promise<void>;
21
21
  }
22
+ /** How to read a value out of a JSON element for comparing. */
23
+ type JsonValueType = 'text' | 'number' | 'integer' | 'boolean';
24
+ /** The JSON element being tested. Paths are field names, already checked against the config. */
25
+ interface JsonElement {
26
+ /** A value inside the element, e.g. `['heading', 'en']`; `[]` for the element itself. */
27
+ get(path: readonly string[], type: JsonValueType): SQL;
28
+ /** A JSON array inside the element, to test with `someElement`. */
29
+ list(path: readonly string[]): SQL;
30
+ }
22
31
  /** What differs between SQL dialects. */
23
32
  interface Dialect {
24
33
  readonly name: 'sqlite' | 'postgres';
34
+ /**
35
+ * One writer at a time (SQLite): writes from this process wait for each other instead of
36
+ * failing with "database is locked".
37
+ */
38
+ readonly singleWriter?: boolean;
25
39
  table(name: string, columns: Record<string, ColumnBuilder>, indexes: (t: Record<string, AnyColumn>) => IndexBuilder[]): AnyTable;
26
40
  index(name: string): {
27
41
  on(column: AnyColumn): IndexBuilder;
@@ -39,6 +53,13 @@ interface Dialect {
39
53
  json(name: string): ColumnBuilder;
40
54
  /** Case-insensitive substring match; `pattern` is already escaped and wrapped in `%`. */
41
55
  like(column: AnyColumn, pattern: string): SQL;
56
+ /**
57
+ * True when some element of a JSON array (a column, or `JsonElement.list`) matches `condition`
58
+ * (any element when it returns undefined). Anything but an array has no elements.
59
+ */
60
+ someElement(array: AnyColumn | SQL, condition: (el: JsonElement) => SQL | undefined): SQL;
61
+ /** The value at `path` in the first element of a JSON array that has one, for sorting. */
62
+ firstElementValue(array: AnyColumn | SQL, path: readonly string[], type: JsonValueType): SQL;
42
63
  /** Placeholder for the n-th parameter (1-based) in raw SQL. */
43
64
  param(n: number): string;
44
65
  /** CREATE TABLE IF NOT EXISTS for the migrations table. */
@@ -111,6 +132,12 @@ interface SchemaModel {
111
132
  readonly tables: Readonly<Record<string, AnyTable>>;
112
133
  /** Changes whenever the generated schema changes. Used to detect missing migrations. */
113
134
  readonly hash: string;
135
+ /** Locales of localized fields, recorded with snapshots so a new default locale keeps data. */
136
+ readonly localization: SchemaLocalization | null;
137
+ }
138
+ interface SchemaLocalization {
139
+ readonly defaultLocale: string;
140
+ readonly locales: readonly string[];
114
141
  }
115
142
  declare const snake: (name: string) => string;
116
143
  /** Builds Drizzle tables for every collection and global in the config. */
@@ -139,6 +166,12 @@ declare class Migrator {
139
166
  private snapshotOf;
140
167
  /** SQL statements that turn `prev` into `cur`. Renames become drop + add unless interactive. */
141
168
  private diff;
169
+ /**
170
+ * A new default locale changes which locale the plain column holds (`title` is the default
171
+ * locale's, `title__en` another's). Moves the values so every locale keeps its own.
172
+ * Snapshots from before 0.7 do not record the locales, so nothing can be moved for them.
173
+ */
174
+ private localeMoves;
142
175
  ensureTable(): Promise<void>;
143
176
  private record;
144
177
  private applied;
@@ -161,4 +194,4 @@ declare class Migrator {
161
194
  private files;
162
195
  }
163
196
 
164
- export { type AnyColumn, type AnyTable, type ColumnBuilder, type Connection, DEFAULT_MIGRATION_DIR, DEFAULT_TABLE_PREFIX, type Dialect, type DrizzleAdapterOptions, type DrizzleDb, type IndexBuilder, Migrator, type SchemaModel, type Snapshot, type SqlRunner, type Statement, buildSchema, connectDatabase, snake, unionSnapshot };
197
+ export { type AnyColumn, type AnyTable, type ColumnBuilder, type Connection, DEFAULT_MIGRATION_DIR, DEFAULT_TABLE_PREFIX, type Dialect, type DrizzleAdapterOptions, type DrizzleDb, type IndexBuilder, type JsonElement, type JsonValueType, Migrator, type SchemaModel, type Snapshot, type SqlRunner, type Statement, buildSchema, connectDatabase, snake, unionSnapshot };
package/dist/index.js CHANGED
@@ -168,6 +168,248 @@ async function deleteDocument(db, model, id) {
168
168
  import { mkdir, readdir, readFile, writeFile } from "fs/promises";
169
169
  import { join } from "path";
170
170
  import { SchemaError } from "@easy-cms/core";
171
+
172
+ // src/schema.ts
173
+ import { createHash } from "crypto";
174
+ import { ConfigError } from "@easy-cms/core";
175
+ function tableModels(schema) {
176
+ const all = [];
177
+ const walk = (model) => {
178
+ all.push(model);
179
+ for (const child of model.children) walk(child.table);
180
+ };
181
+ for (const collection of schema.collections.values()) walk(collection.root);
182
+ return all;
183
+ }
184
+ function migrationsTableName(prefix) {
185
+ return `${prefix}migrations`;
186
+ }
187
+ var snake = (name) => name.replace(/([a-z0-9])([A-Z])/g, "$1_$2").replace(/[^A-Za-z0-9]+/g, "_").toLowerCase();
188
+ function columnKind(field) {
189
+ switch (field.type) {
190
+ case "number":
191
+ return "number";
192
+ case "boolean":
193
+ return "boolean";
194
+ case "json":
195
+ case "richText":
196
+ // Blocks are stored whole as JSON: rows of different kinds don't fit one child table.
197
+ case "blocks":
198
+ return "json";
199
+ case "upload":
200
+ case "relationship":
201
+ return "integer";
202
+ default:
203
+ return "text";
204
+ }
205
+ }
206
+ function buildSchema(config, prefix, dialect) {
207
+ const builder = {
208
+ dialect,
209
+ prefix,
210
+ tableNames: /* @__PURE__ */ new Set([migrationsTableName(prefix)]),
211
+ // The hash covers the logical schema only, so it stays stable across releases for the same config.
212
+ description: [],
213
+ localization: config.localization
214
+ };
215
+ const collections = /* @__PURE__ */ new Map();
216
+ const tables = {};
217
+ for (const collection of config.collections) {
218
+ const root = buildTable(
219
+ builder,
220
+ `${prefix}${snake(collection.slug)}`,
221
+ "root",
222
+ collection.fields,
223
+ {
224
+ drafts: collection.drafts === true
225
+ }
226
+ );
227
+ collections.set(collection.slug, { config: collection, root });
228
+ collectTables(root, tables);
229
+ }
230
+ const globalsName = claimName(builder, `${prefix}globals`);
231
+ const globals = dialect.table(
232
+ globalsName,
233
+ {
234
+ slug: dialect.text("slug").primaryKey(),
235
+ data: dialect.json("data").notNull(),
236
+ status: dialect.text("status"),
237
+ updated_at: dialect.text("updated_at").notNull()
238
+ },
239
+ () => []
240
+ );
241
+ tables[globalsName] = globals;
242
+ builder.description.push(["globals", globalsName]);
243
+ const hash = createHash("sha256").update(JSON.stringify(builder.description)).digest("hex").slice(0, 16);
244
+ const localization = config.localization ? { defaultLocale: config.localization.defaultLocale, locales: config.localization.locales } : null;
245
+ return { collections, globals, tables, hash, localization };
246
+ }
247
+ function collectTables(model, into) {
248
+ into[model.name] = model.table;
249
+ for (const child of model.children) collectTables(child.table, into);
250
+ }
251
+ function claimName(builder, name) {
252
+ if (builder.tableNames.has(name)) {
253
+ throw new ConfigError([
254
+ {
255
+ path: name,
256
+ message: `two tables would be named "${name}"`,
257
+ hint: "rename one of the collections or fields"
258
+ }
259
+ ]);
260
+ }
261
+ builder.tableNames.add(name);
262
+ return name;
263
+ }
264
+ function makeColumn(dialect, name, kind) {
265
+ switch (kind) {
266
+ case "number":
267
+ return dialect.number(name);
268
+ case "integer":
269
+ return dialect.integer(name);
270
+ case "boolean":
271
+ return dialect.boolean(name);
272
+ case "json":
273
+ return dialect.json(name);
274
+ case "text":
275
+ return dialect.text(name);
276
+ }
277
+ }
278
+ function buildTable(builder, name, kind, fields, options) {
279
+ const { dialect } = builder;
280
+ claimName(builder, name);
281
+ const columns = {};
282
+ const described = [];
283
+ const columnModels = [];
284
+ const children = [];
285
+ const indexes = [];
286
+ const add = (column, builderColumn, desc2) => {
287
+ if (Object.hasOwn(columns, column)) {
288
+ throw new ConfigError([
289
+ {
290
+ path: `${name}.${column}`,
291
+ message: `two fields would use the column "${column}"`,
292
+ hint: "rename one of them"
293
+ }
294
+ ]);
295
+ }
296
+ columns[column] = builderColumn;
297
+ described.push([column, desc2]);
298
+ };
299
+ if (kind === "root") {
300
+ add("id", dialect.serial("id"), "id:int");
301
+ add("created_at", dialect.text("created_at").notNull(), "text!");
302
+ add("updated_at", dialect.text("updated_at").notNull(), "text!");
303
+ if (options.drafts) add("status", dialect.text("status").notNull().default("draft"), "status");
304
+ indexes.push({ column: "created_at", unique: false });
305
+ } else {
306
+ const parentKind = options.parentIdKind ?? "integer";
307
+ add(
308
+ "id",
309
+ kind === "array" ? dialect.text("id").primaryKey() : dialect.serial("id"),
310
+ `id:${kind}`
311
+ );
312
+ add(
313
+ "_parent_id",
314
+ parentKind === "text" ? dialect.text("_parent_id").notNull() : dialect.integer("_parent_id").notNull(),
315
+ parentKind
316
+ );
317
+ add("_order", dialect.integer("_order").notNull(), "int!");
318
+ indexes.push({ column: "_parent_id", unique: false });
319
+ if (options.localized) {
320
+ add(
321
+ "_locale",
322
+ dialect.text("_locale").notNull().default(options.localized),
323
+ `locale:${options.localized}`
324
+ );
325
+ indexes.push({ column: "_locale", unique: false });
326
+ }
327
+ if (kind === "values" && options.valueField) {
328
+ const valueKind = columnKind(options.valueField);
329
+ add("value", makeColumn(dialect, "value", valueKind), valueKind);
330
+ indexes.push({ column: "value", unique: false });
331
+ }
332
+ }
333
+ const walk = (list, path, topLevel) => {
334
+ for (const field of list) {
335
+ const fieldPath = [...path, field.name];
336
+ const base = fieldPath.map(snake).join("_");
337
+ if (field.type === "group") {
338
+ walk(field.fields, fieldPath, false);
339
+ } else if (field.type === "array") {
340
+ const localization = field.localized ? builder.localization : null;
341
+ const child = buildTable(builder, `${name}__${base}`, "array", field.fields, {
342
+ parentIdKind: kind === "array" ? "text" : "integer",
343
+ ...localization ? { localized: localization.defaultLocale } : {}
344
+ });
345
+ children.push({
346
+ kind: "array",
347
+ path: fieldPath,
348
+ field,
349
+ table: child,
350
+ ...localeInfo(localization)
351
+ });
352
+ described.push([base, "array", child.name]);
353
+ } else if ((field.type === "select" || field.type === "relationship") && field.hasMany) {
354
+ const localization = field.localized ? builder.localization : null;
355
+ const child = buildTable(builder, `${name}__${base}`, "values", [], {
356
+ parentIdKind: kind === "array" ? "text" : "integer",
357
+ valueField: field,
358
+ ...localization ? { localized: localization.defaultLocale } : {}
359
+ });
360
+ children.push({
361
+ kind: "values",
362
+ path: fieldPath,
363
+ field,
364
+ table: child,
365
+ ...localeInfo(localization)
366
+ });
367
+ described.push([base, "values", child.name]);
368
+ } else {
369
+ const kindOfColumn = columnKind(field);
370
+ const localization = field.localized ? builder.localization : null;
371
+ const targets = localization ? localization.locales.map((locale) => ({
372
+ column: locale === localization.defaultLocale ? base : `${base}__${snake(locale)}`,
373
+ locale
374
+ })) : [{ column: base, locale: void 0 }];
375
+ for (const { column, locale } of targets) {
376
+ add(column, makeColumn(dialect, column, kindOfColumn), kindOfColumn);
377
+ columnModels.push(
378
+ locale === void 0 ? { path: fieldPath, field, column } : {
379
+ path: fieldPath,
380
+ field,
381
+ column,
382
+ locale,
383
+ defaultLocale: locale === localization?.defaultLocale
384
+ }
385
+ );
386
+ if (topLevel && kind === "root" && (field.unique || field.type === "slug")) {
387
+ indexes.push({ column, unique: true });
388
+ } else if (field.index || field.unique || field.type === "relationship" || field.type === "upload") {
389
+ indexes.push({ column, unique: false });
390
+ }
391
+ }
392
+ }
393
+ }
394
+ };
395
+ walk(fields, [], true);
396
+ const table = dialect.table(
397
+ name,
398
+ columns,
399
+ (t) => indexes.map(({ column, unique }) => {
400
+ const col3 = t[column];
401
+ return unique ? dialect.uniqueIndex(`${name}_${column}_unique`).on(col3) : dialect.index(`${name}_${column}_idx`).on(col3);
402
+ })
403
+ );
404
+ builder.description.push([name, kind, described, indexes]);
405
+ const model = { name, kind, table, fields, columns: columnModels, children };
406
+ return options.valueField ? { ...model, valueField: options.valueField } : model;
407
+ }
408
+ function localeInfo(localization) {
409
+ return localization ? { locales: localization.locales, defaultLocale: localization.defaultLocale } : {};
410
+ }
411
+
412
+ // src/migrations.ts
171
413
  var BREAKPOINT = "--> statement-breakpoint";
172
414
  var TABLE_PARTS = [
173
415
  "columns",
@@ -196,6 +438,17 @@ function unionSnapshot(prev, cur) {
196
438
  }
197
439
  return merged;
198
440
  }
441
+ var META = "easyCms";
442
+ var metaOf = (snapshot) => snapshot[META];
443
+ function withoutMeta(snapshot) {
444
+ const { [META]: _, ...rest } = snapshot;
445
+ return rest;
446
+ }
447
+ function snapshotTable(snapshot, name) {
448
+ return Object.values(snapshot.tables ?? {}).find(
449
+ (t) => t.name === name
450
+ );
451
+ }
199
452
  var DESTRUCTIVE = /\b(DROP TABLE|DROP COLUMN)\b|__new_/i;
200
453
  var DEV_PUSH = "dev";
201
454
  var Migrator = class {
@@ -209,15 +462,58 @@ var Migrator = class {
209
462
  q(name) {
210
463
  return this.options.dialect.name === "postgres" ? `"${name}"` : `\`${name}\``;
211
464
  }
212
- snapshotOf(schema) {
213
- return this.options.dialect.snapshot(schema ? schema.tables : {});
465
+ async snapshotOf(schema) {
466
+ const snapshot = await this.options.dialect.snapshot(schema ? schema.tables : {});
467
+ const meta = { localization: schema?.localization ?? null };
468
+ return { ...snapshot, [META]: meta };
214
469
  }
215
470
  /** SQL statements that turn `prev` into `cur`. Renames become drop + add unless interactive. */
216
471
  async diff(prev, cur, interactive) {
217
472
  const { dialect } = this.options;
218
- if (interactive) return dialect.migration(prev, cur);
219
- const union = unionSnapshot(prev, cur);
220
- return [...await dialect.migration(prev, union), ...await dialect.migration(union, cur)];
473
+ const moves = this.localeMoves(prev, metaOf(prev), metaOf(cur));
474
+ const [from, to] = [withoutMeta(prev), withoutMeta(cur)];
475
+ if (interactive && moves.length === 0) return dialect.migration(from, to);
476
+ const union = unionSnapshot(from, to);
477
+ return [
478
+ ...await dialect.migration(from, union),
479
+ ...moves,
480
+ ...await dialect.migration(union, to)
481
+ ];
482
+ }
483
+ /**
484
+ * A new default locale changes which locale the plain column holds (`title` is the default
485
+ * locale's, `title__en` another's). Moves the values so every locale keeps its own.
486
+ * Snapshots from before 0.7 do not record the locales, so nothing can be moved for them.
487
+ */
488
+ localeMoves(prev, before, after) {
489
+ const was = before?.localization;
490
+ const now = after?.localization;
491
+ if (!was || !now || was.defaultLocale === now.defaultLocale) return [];
492
+ const q = (name) => this.q(name);
493
+ const statements = [];
494
+ for (const model of tableModels(this.options.schema)) {
495
+ const old = snapshotTable(prev, model.name);
496
+ if (!old) continue;
497
+ const has = (column) => Object.hasOwn(old.columns ?? {}, column);
498
+ const keep = [];
499
+ const clear = [];
500
+ const fill = [];
501
+ for (const column of model.columns) {
502
+ if (!column.defaultLocale || !has(column.column)) continue;
503
+ const base = column.column;
504
+ const moved = model.columns.find(
505
+ (c) => c.path.join(".") === column.path.join(".") && c.locale === was.defaultLocale
506
+ );
507
+ if (moved && !has(moved.column)) keep.push(`${q(moved.column)} = ${q(base)}`);
508
+ clear.push(`${q(base)} = NULL`);
509
+ const incoming = `${base}__${snake(now.defaultLocale)}`;
510
+ if (has(incoming)) fill.push(`${q(base)} = ${q(incoming)}`);
511
+ }
512
+ for (const set of [keep, clear, fill]) {
513
+ if (set.length > 0) statements.push(`UPDATE ${q(model.name)} SET ${set.join(", ")}`);
514
+ }
515
+ }
516
+ return statements;
221
517
  }
222
518
  async ensureTable() {
223
519
  await this.runner.query(this.options.dialect.migrationsTableSQL(this.options.table));
@@ -417,7 +713,53 @@ var SYSTEM_COLUMNS = {
417
713
  var col2 = (model, name) => model.table[name];
418
714
  var ID_FIELD = { name: "id", type: "relationship", to: "" };
419
715
  var LIKE_TYPES = /* @__PURE__ */ new Set(["text", "textarea", "email", "slug", "select"]);
420
- function resolvePath(model, segments, drafts) {
716
+ var JSON_TYPES = {
717
+ number: "number",
718
+ boolean: "boolean",
719
+ relationship: "integer",
720
+ upload: "integer"
721
+ };
722
+ function resolveJsonValue(fields, rest, shown, localization, blocks) {
723
+ if (rest.length === 0) return { lists: [], path: null, type: "text", field: void 0 };
724
+ if (blocks && rest.length === 1 && rest[0] === "blockType")
725
+ return { lists: [], path: ["blockType"], type: "text", field: void 0 };
726
+ let list = fields;
727
+ const path = [];
728
+ for (let i = 0; i < rest.length; i++) {
729
+ const segment = rest[i];
730
+ const field = list.find((f) => f.name === segment);
731
+ const at = [shown, ...rest.slice(0, i + 1)].join(".");
732
+ if (!field) throw new QueryError(`Unknown field "${at}"`);
733
+ path.push(segment);
734
+ if (field.type === "group") {
735
+ list = field.fields;
736
+ continue;
737
+ }
738
+ if (field.type === "json" || field.type === "richText")
739
+ throw new QueryError(`Cannot query "${at}": ${field.type} fields are not queryable`);
740
+ let next = i + 1;
741
+ if (field.localized && localization) {
742
+ const named = rest[next] !== void 0 && localization.locales.includes(rest[next]);
743
+ path.push(named ? rest[next] : localization.defaultLocale);
744
+ if (named) next++;
745
+ }
746
+ const inner = rest.slice(next);
747
+ const shownHere = [shown, ...rest.slice(0, next)].join(".");
748
+ if ("hasMany" in field && field.hasMany === true) {
749
+ if (inner.length > 0) throw new QueryError(`Cannot query inside "${shownHere}"`);
750
+ return { lists: [path], path: [], type: JSON_TYPES[field.type] ?? "text", field };
751
+ }
752
+ if (field.type === "array" || field.type === "blocks") {
753
+ const rows = field.type === "array" ? field.fields : field.blocks.flatMap((block) => block.fields);
754
+ const value = resolveJsonValue(rows, inner, shownHere, localization, field.type === "blocks");
755
+ return { ...value, lists: [path, ...value.lists] };
756
+ }
757
+ if (inner.length > 0) throw new QueryError(`Cannot query inside "${at}"`);
758
+ return { lists: [], path, type: JSON_TYPES[field.type] ?? "text", field };
759
+ }
760
+ throw new QueryError(`"${[shown, ...rest].join(".")}" is a group; query one of its fields`);
761
+ }
762
+ function resolvePath(model, segments, drafts, localization = null) {
421
763
  const [first] = segments;
422
764
  if (first === void 0) throw new QueryError("empty field path");
423
765
  if (segments.length === 1) {
@@ -454,18 +796,29 @@ function resolvePath(model, segments, drafts) {
454
796
  };
455
797
  }
456
798
  if (child) return { kind: "child", child, rest: segments.slice(i + 1) };
457
- const locale = field.localized ? segments[i + 1] : void 0;
799
+ let locale = field.localized ? segments[i + 1] : void 0;
800
+ if (field.type === "blocks" && locale !== void 0 && !localization?.locales.includes(locale))
801
+ locale = void 0;
458
802
  const last = field.localized && locale !== void 0 ? i + 1 : i;
803
+ const same = (c) => c.path.join(".") === path.join(".");
804
+ const columnOf = () => field.localized ? model.columns.find(
805
+ (c) => same(c) && (locale === void 0 ? c.defaultLocale : c.locale === locale)
806
+ ) : model.columns.find(same);
807
+ if (field.type === "blocks") {
808
+ const column2 = columnOf();
809
+ if (!column2) throw new QueryError(`Unknown locale "${locale}" for "${shown}"`);
810
+ const rest = segments.slice(last + 1);
811
+ const rows = field.blocks.flatMap((block) => block.fields);
812
+ const value = resolveJsonValue(rows, rest, shown, localization, true);
813
+ return { kind: "blocks", column: col2(model, column2.column), value };
814
+ }
459
815
  if (last < segments.length - 1) {
460
816
  throw new QueryError(`Cannot query inside "${shown}" (${field.type} field)`);
461
817
  }
462
- if (field.type === "json" || field.type === "richText" || field.type === "blocks") {
818
+ if (field.type === "json" || field.type === "richText") {
463
819
  throw new QueryError(`Cannot query "${shown}": ${field.type} fields are not queryable`);
464
820
  }
465
- const same = (c) => c.path.join(".") === path.join(".");
466
- const column = field.localized ? model.columns.find(
467
- (c) => same(c) && (locale === void 0 ? c.defaultLocale : c.locale === locale)
468
- ) : model.columns.find(same);
821
+ const column = columnOf();
469
822
  if (field.localized && !column)
470
823
  throw new QueryError(`Unknown locale "${locale}" for "${shown}"`);
471
824
  if (!column) throw new QueryError(`Unknown field "${shown}"`);
@@ -521,14 +874,16 @@ function operatorSQL(dialect, column, field, op, raw, path) {
521
874
  }
522
875
  }
523
876
  var WhereBuilder = class {
524
- constructor(db, dialect, drafts) {
877
+ constructor(db, dialect, drafts, localization = null) {
525
878
  this.db = db;
526
879
  this.dialect = dialect;
527
880
  this.drafts = drafts;
881
+ this.localization = localization;
528
882
  }
529
883
  db;
530
884
  dialect;
531
885
  drafts;
886
+ localization;
532
887
  build(model, where) {
533
888
  if (where === void 0) return void 0;
534
889
  if (typeof where !== "object" || where === null || Array.isArray(where)) {
@@ -553,15 +908,38 @@ var WhereBuilder = class {
553
908
  }
554
909
  const entries = Object.entries(operators).filter(([, v]) => v !== void 0);
555
910
  if (entries.length === 0) throw new QueryError(`"${path}" has no operator`);
556
- const target = resolvePath(model, path.split("."), this.drafts && model.kind === "root");
911
+ const target = resolvePath(
912
+ model,
913
+ path.split("."),
914
+ this.drafts && model.kind === "root",
915
+ this.localization
916
+ );
557
917
  const parts = entries.map(([op, value]) => {
558
918
  if (!OPERATORS.has(op)) throw new QueryError(`Unknown operator "${op}" on "${path}"`);
559
919
  if (target.kind === "column")
560
920
  return operatorSQL(this.dialect, target.column, target.field, op, value, path);
921
+ if (target.kind === "blocks") return this.blocks(target, op, value, path);
561
922
  return this.child(model, target.child, target.rest, op, value, path, target.locale);
562
923
  });
563
924
  return parts.length === 1 ? parts[0] : and(...parts);
564
925
  }
926
+ /**
927
+ * Conditions inside blocks test the JSON rows: some block (and, for nested lists, some row in
928
+ * it) must match.
929
+ */
930
+ blocks(target, op, value, path) {
931
+ const { lists, path: inner, type, field } = target.value;
932
+ if (inner === null && op !== "exists")
933
+ throw new QueryError(`"${path}" is a list; query one of its fields`);
934
+ const match = (el, level) => {
935
+ const list = lists[level];
936
+ if (list) return this.dialect.someElement(el.list(list), (row) => match(row, level + 1));
937
+ if (inner === null) return void 0;
938
+ return operatorSQL(this.dialect, el.get(inner, type), field, op, value, path);
939
+ };
940
+ const some = this.dialect.someElement(target.column, (el) => match(el, 0));
941
+ return inner === null && value === false ? not(some) : some;
942
+ }
565
943
  /** Conditions on array rows or hasMany values become (NOT) EXISTS subqueries. */
566
944
  child(parent, child, rest, op, value, path, locale) {
567
945
  const table = child.table;
@@ -590,249 +968,27 @@ var WhereBuilder = class {
590
968
  }
591
969
  return subquery(this.build(table, { [rest.join(".")]: { [op]: value } }));
592
970
  }
971
+ /** A column, or the value in the first block that has it. */
972
+ sortValue(target, path) {
973
+ if (target.kind === "column") return target.column;
974
+ if (target.kind === "blocks" && target.value.lists.length === 0 && target.value.path) {
975
+ return this.dialect.firstElementValue(target.column, target.value.path, target.value.type);
976
+ }
977
+ throw new QueryError(`Cannot sort by "${path}"`);
978
+ }
593
979
  orderBy(model, sort) {
594
980
  const order = sort.map((entry) => {
595
981
  const descending = entry.startsWith("-");
596
982
  const path = descending ? entry.slice(1) : entry;
597
- const target = resolvePath(model, path.split("."), this.drafts);
598
- if (target.kind !== "column") throw new QueryError(`Cannot sort by "${path}"`);
599
- return descending ? desc(target.column) : asc2(target.column);
983
+ const target = resolvePath(model, path.split("."), this.drafts, this.localization);
984
+ const value = this.sortValue(target, path);
985
+ return descending ? desc(value) : asc2(value);
600
986
  });
601
987
  order.push(desc(col2(model, "id")));
602
988
  return order;
603
989
  }
604
990
  };
605
991
 
606
- // src/schema.ts
607
- import { createHash } from "crypto";
608
- import { ConfigError } from "@easy-cms/core";
609
- function migrationsTableName(prefix) {
610
- return `${prefix}migrations`;
611
- }
612
- var snake = (name) => name.replace(/([a-z0-9])([A-Z])/g, "$1_$2").replace(/[^A-Za-z0-9]+/g, "_").toLowerCase();
613
- function columnKind(field) {
614
- switch (field.type) {
615
- case "number":
616
- return "number";
617
- case "boolean":
618
- return "boolean";
619
- case "json":
620
- case "richText":
621
- // Blocks are stored whole as JSON: rows of different kinds don't fit one child table.
622
- case "blocks":
623
- return "json";
624
- case "upload":
625
- case "relationship":
626
- return "integer";
627
- default:
628
- return "text";
629
- }
630
- }
631
- function buildSchema(config, prefix, dialect) {
632
- const builder = {
633
- dialect,
634
- prefix,
635
- tableNames: /* @__PURE__ */ new Set([migrationsTableName(prefix)]),
636
- // The hash covers the logical schema only, so it stays stable across releases for the same config.
637
- description: [],
638
- localization: config.localization
639
- };
640
- const collections = /* @__PURE__ */ new Map();
641
- const tables = {};
642
- for (const collection of config.collections) {
643
- const root = buildTable(
644
- builder,
645
- `${prefix}${snake(collection.slug)}`,
646
- "root",
647
- collection.fields,
648
- {
649
- drafts: collection.drafts === true
650
- }
651
- );
652
- collections.set(collection.slug, { config: collection, root });
653
- collectTables(root, tables);
654
- }
655
- const globalsName = claimName(builder, `${prefix}globals`);
656
- const globals = dialect.table(
657
- globalsName,
658
- {
659
- slug: dialect.text("slug").primaryKey(),
660
- data: dialect.json("data").notNull(),
661
- status: dialect.text("status"),
662
- updated_at: dialect.text("updated_at").notNull()
663
- },
664
- () => []
665
- );
666
- tables[globalsName] = globals;
667
- builder.description.push(["globals", globalsName]);
668
- const hash = createHash("sha256").update(JSON.stringify(builder.description)).digest("hex").slice(0, 16);
669
- return { collections, globals, tables, hash };
670
- }
671
- function collectTables(model, into) {
672
- into[model.name] = model.table;
673
- for (const child of model.children) collectTables(child.table, into);
674
- }
675
- function claimName(builder, name) {
676
- if (builder.tableNames.has(name)) {
677
- throw new ConfigError([
678
- {
679
- path: name,
680
- message: `two tables would be named "${name}"`,
681
- hint: "rename one of the collections or fields"
682
- }
683
- ]);
684
- }
685
- builder.tableNames.add(name);
686
- return name;
687
- }
688
- function makeColumn(dialect, name, kind) {
689
- switch (kind) {
690
- case "number":
691
- return dialect.number(name);
692
- case "integer":
693
- return dialect.integer(name);
694
- case "boolean":
695
- return dialect.boolean(name);
696
- case "json":
697
- return dialect.json(name);
698
- case "text":
699
- return dialect.text(name);
700
- }
701
- }
702
- function buildTable(builder, name, kind, fields, options) {
703
- const { dialect } = builder;
704
- claimName(builder, name);
705
- const columns = {};
706
- const described = [];
707
- const columnModels = [];
708
- const children = [];
709
- const indexes = [];
710
- const add = (column, builderColumn, desc2) => {
711
- if (Object.hasOwn(columns, column)) {
712
- throw new ConfigError([
713
- {
714
- path: `${name}.${column}`,
715
- message: `two fields would use the column "${column}"`,
716
- hint: "rename one of them"
717
- }
718
- ]);
719
- }
720
- columns[column] = builderColumn;
721
- described.push([column, desc2]);
722
- };
723
- if (kind === "root") {
724
- add("id", dialect.serial("id"), "id:int");
725
- add("created_at", dialect.text("created_at").notNull(), "text!");
726
- add("updated_at", dialect.text("updated_at").notNull(), "text!");
727
- if (options.drafts) add("status", dialect.text("status").notNull().default("draft"), "status");
728
- indexes.push({ column: "created_at", unique: false });
729
- } else {
730
- const parentKind = options.parentIdKind ?? "integer";
731
- add(
732
- "id",
733
- kind === "array" ? dialect.text("id").primaryKey() : dialect.serial("id"),
734
- `id:${kind}`
735
- );
736
- add(
737
- "_parent_id",
738
- parentKind === "text" ? dialect.text("_parent_id").notNull() : dialect.integer("_parent_id").notNull(),
739
- parentKind
740
- );
741
- add("_order", dialect.integer("_order").notNull(), "int!");
742
- indexes.push({ column: "_parent_id", unique: false });
743
- if (options.localized) {
744
- add(
745
- "_locale",
746
- dialect.text("_locale").notNull().default(options.localized),
747
- `locale:${options.localized}`
748
- );
749
- indexes.push({ column: "_locale", unique: false });
750
- }
751
- if (kind === "values" && options.valueField) {
752
- const valueKind = columnKind(options.valueField);
753
- add("value", makeColumn(dialect, "value", valueKind), valueKind);
754
- indexes.push({ column: "value", unique: false });
755
- }
756
- }
757
- const walk = (list, path, topLevel) => {
758
- for (const field of list) {
759
- const fieldPath = [...path, field.name];
760
- const base = fieldPath.map(snake).join("_");
761
- if (field.type === "group") {
762
- walk(field.fields, fieldPath, false);
763
- } else if (field.type === "array") {
764
- const localization = field.localized ? builder.localization : null;
765
- const child = buildTable(builder, `${name}__${base}`, "array", field.fields, {
766
- parentIdKind: kind === "array" ? "text" : "integer",
767
- ...localization ? { localized: localization.defaultLocale } : {}
768
- });
769
- children.push({
770
- kind: "array",
771
- path: fieldPath,
772
- field,
773
- table: child,
774
- ...localeInfo(localization)
775
- });
776
- described.push([base, "array", child.name]);
777
- } else if ((field.type === "select" || field.type === "relationship") && field.hasMany) {
778
- const localization = field.localized ? builder.localization : null;
779
- const child = buildTable(builder, `${name}__${base}`, "values", [], {
780
- parentIdKind: kind === "array" ? "text" : "integer",
781
- valueField: field,
782
- ...localization ? { localized: localization.defaultLocale } : {}
783
- });
784
- children.push({
785
- kind: "values",
786
- path: fieldPath,
787
- field,
788
- table: child,
789
- ...localeInfo(localization)
790
- });
791
- described.push([base, "values", child.name]);
792
- } else {
793
- const kindOfColumn = columnKind(field);
794
- const localization = field.localized ? builder.localization : null;
795
- const targets = localization ? localization.locales.map((locale) => ({
796
- column: locale === localization.defaultLocale ? base : `${base}__${snake(locale)}`,
797
- locale
798
- })) : [{ column: base, locale: void 0 }];
799
- for (const { column, locale } of targets) {
800
- add(column, makeColumn(dialect, column, kindOfColumn), kindOfColumn);
801
- columnModels.push(
802
- locale === void 0 ? { path: fieldPath, field, column } : {
803
- path: fieldPath,
804
- field,
805
- column,
806
- locale,
807
- defaultLocale: locale === localization?.defaultLocale
808
- }
809
- );
810
- if (topLevel && kind === "root" && (field.unique || field.type === "slug")) {
811
- indexes.push({ column, unique: true });
812
- } else if (field.index || field.unique || field.type === "relationship" || field.type === "upload") {
813
- indexes.push({ column, unique: false });
814
- }
815
- }
816
- }
817
- }
818
- };
819
- walk(fields, [], true);
820
- const table = dialect.table(
821
- name,
822
- columns,
823
- (t) => indexes.map(({ column, unique }) => {
824
- const col3 = t[column];
825
- return unique ? dialect.uniqueIndex(`${name}_${column}_unique`).on(col3) : dialect.index(`${name}_${column}_idx`).on(col3);
826
- })
827
- );
828
- builder.description.push([name, kind, described, indexes]);
829
- const model = { name, kind, table, fields, columns: columnModels, children };
830
- return options.valueField ? { ...model, valueField: options.valueField } : model;
831
- }
832
- function localeInfo(localization) {
833
- return localization ? { locales: localization.locales, defaultLocale: localization.defaultLocale } : {};
834
- }
835
-
836
992
  // src/database.ts
837
993
  var DEFAULT_TABLE_PREFIX = "ecms_";
838
994
  var DEFAULT_MIGRATION_DIR = "easy-cms/migrations";
@@ -860,14 +1016,19 @@ async function connectDatabase(dialect, connection, args, options = {}) {
860
1016
  }
861
1017
  var DrizzleDatabase = class {
862
1018
  constructor(dialect, connection, schema, migrator) {
1019
+ this.dialect = dialect;
863
1020
  this.connection = connection;
864
1021
  this.schema = schema;
865
1022
  this.migrator = migrator;
866
1023
  this.db = connection.db;
867
1024
  for (const [slug, model] of schema.collections) {
868
- this.builders.set(slug, new WhereBuilder(this.db, dialect, model.config.drafts === true));
1025
+ this.builders.set(
1026
+ slug,
1027
+ new WhereBuilder(this.db, dialect, model.config.drafts === true, schema.localization)
1028
+ );
869
1029
  }
870
1030
  }
1031
+ dialect;
871
1032
  connection;
872
1033
  schema;
873
1034
  migrator;
@@ -922,23 +1083,38 @@ var DrizzleDatabase = class {
922
1083
  const [row] = await this.db.select({ total: count() }).from(model.root.table).where(this.where(args.collection, args.where));
923
1084
  return Number(row?.total ?? 0);
924
1085
  }
1086
+ writing = Promise.resolve();
1087
+ /** Runs a write; with a single-writer database, after the writes before it are done. */
1088
+ write(run) {
1089
+ if (!this.dialect.singleWriter) return run();
1090
+ const result = this.writing.then(run);
1091
+ this.writing = result.catch(() => {
1092
+ });
1093
+ return result;
1094
+ }
925
1095
  async create(args) {
926
1096
  const model = this.model(args.collection);
927
- const id = await this.db.transaction(
928
- (tx) => insertDocument(tx, model.root, args.data, model.config.drafts === true)
1097
+ const id = await this.write(
1098
+ () => this.db.transaction(
1099
+ (tx) => insertDocument(tx, model.root, args.data, model.config.drafts === true)
1100
+ )
929
1101
  );
930
1102
  return await this.findById({ collection: args.collection, id });
931
1103
  }
932
1104
  async update(args) {
933
1105
  const model = this.model(args.collection);
934
- await this.db.transaction(
935
- (tx) => replaceDocument(tx, model.root, args.id, args.data, model.config.drafts === true)
1106
+ await this.write(
1107
+ () => this.db.transaction(
1108
+ (tx) => replaceDocument(tx, model.root, args.id, args.data, model.config.drafts === true)
1109
+ )
936
1110
  );
937
1111
  return await this.findById({ collection: args.collection, id: args.id });
938
1112
  }
939
1113
  async delete(args) {
940
1114
  const model = this.model(args.collection);
941
- await this.db.transaction((tx) => deleteDocument(tx, model.root, args.id));
1115
+ await this.write(
1116
+ () => this.db.transaction((tx) => deleteDocument(tx, model.root, args.id))
1117
+ );
942
1118
  }
943
1119
  async findGlobal(args) {
944
1120
  const table = this.schema.globals;
@@ -959,7 +1135,9 @@ var DrizzleDatabase = class {
959
1135
  status: status ?? null,
960
1136
  updated_at: updatedAt
961
1137
  };
962
- await this.db.insert(table).values(values).onConflictDoUpdate({ target: table.slug, set: values });
1138
+ await this.write(
1139
+ () => this.db.insert(table).values(values).onConflictDoUpdate({ target: table.slug, set: values })
1140
+ );
963
1141
  return await this.findGlobal({ slug: args.slug });
964
1142
  }
965
1143
  createMigration(args) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@easy-cms/drizzle",
3
- "version": "0.6.0",
3
+ "version": "0.8.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.3"
24
24
  },
25
25
  "peerDependencies": {
26
- "@easy-cms/core": "^0.6.0"
26
+ "@easy-cms/core": "^0.8.0"
27
27
  },
28
28
  "devDependencies": {
29
- "@easy-cms/core": "^0.6.0"
29
+ "@easy-cms/core": "^0.8.0"
30
30
  },
31
31
  "publishConfig": {
32
32
  "access": "public"