@easy-cms/drizzle 0.7.0 → 0.9.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
@@ -21,11 +21,21 @@ interface SqlRunner {
21
21
  }
22
22
  /** How to read a value out of a JSON element for comparing. */
23
23
  type JsonValueType = 'text' | 'number' | 'integer' | 'boolean';
24
- /** A value inside the JSON element being tested, e.g. `['heading', 'en']`. */
25
- type JsonGetter = (path: readonly string[], type: JsonValueType) => SQL;
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
+ }
26
31
  /** What differs between SQL dialects. */
27
32
  interface Dialect {
28
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;
29
39
  table(name: string, columns: Record<string, ColumnBuilder>, indexes: (t: Record<string, AnyColumn>) => IndexBuilder[]): AnyTable;
30
40
  index(name: string): {
31
41
  on(column: AnyColumn): IndexBuilder;
@@ -44,10 +54,15 @@ interface Dialect {
44
54
  /** Case-insensitive substring match; `pattern` is already escaped and wrapped in `%`. */
45
55
  like(column: AnyColumn, pattern: string): SQL;
46
56
  /**
47
- * True when some element of a JSON array column matches `condition` (any element when it
48
- * returns undefined). Path segments are field names, already checked against the config.
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
+ /**
62
+ * For sorting: the value at `path` in the first element that has one, stepping through the
63
+ * nested `lists` in each element (in order) like `someElement`.
49
64
  */
50
- someElement(column: AnyColumn, condition: (get: JsonGetter) => SQL | undefined): SQL;
65
+ firstElementValue(array: AnyColumn | SQL, lists: readonly (readonly string[])[], path: readonly string[], type: JsonValueType): SQL;
51
66
  /** Placeholder for the n-th parameter (1-based) in raw SQL. */
52
67
  param(n: number): string;
53
68
  /** CREATE TABLE IF NOT EXISTS for the migrations table. */
@@ -182,4 +197,4 @@ declare class Migrator {
182
197
  private files;
183
198
  }
184
199
 
185
- export { type AnyColumn, type AnyTable, type ColumnBuilder, type Connection, DEFAULT_MIGRATION_DIR, DEFAULT_TABLE_PREFIX, type Dialect, type DrizzleAdapterOptions, type DrizzleDb, type IndexBuilder, type JsonGetter, type JsonValueType, Migrator, type SchemaModel, type Snapshot, type SqlRunner, type Statement, buildSchema, connectDatabase, snake, unionSnapshot };
200
+ 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
@@ -719,34 +719,43 @@ var JSON_TYPES = {
719
719
  relationship: "integer",
720
720
  upload: "integer"
721
721
  };
722
- function resolveBlockValue(blocks, rest, shown, localization) {
723
- if (rest.length === 1 && rest[0] === "blockType")
724
- return { path: ["blockType"], type: "text", field: void 0 };
725
- let fields = blocks.blocks.flatMap((block) => block.fields);
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;
726
727
  const path = [];
727
728
  for (let i = 0; i < rest.length; i++) {
728
729
  const segment = rest[i];
729
- const field = fields.find((f) => f.name === segment);
730
+ const field = list.find((f) => f.name === segment);
730
731
  const at = [shown, ...rest.slice(0, i + 1)].join(".");
731
732
  if (!field) throw new QueryError(`Unknown field "${at}"`);
732
733
  path.push(segment);
733
734
  if (field.type === "group") {
734
- fields = field.fields;
735
+ list = field.fields;
735
736
  continue;
736
737
  }
737
- const hasMany = "hasMany" in field && field.hasMany === true;
738
- if (hasMany || field.type === "array" || field.type === "blocks" || field.type === "json" || field.type === "richText") {
739
- throw new QueryError(`Cannot query "${at}" inside blocks`);
740
- }
741
- let last = i;
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;
742
741
  if (field.localized && localization) {
743
- const next = rest[i + 1];
744
- const named = next !== void 0 && localization.locales.includes(next);
745
- path.push(named ? next : localization.defaultLocale);
746
- if (named) last = i + 1;
742
+ const named = rest[next] !== void 0 && localization.locales.includes(rest[next]);
743
+ path.push(named ? rest[next] : localization.defaultLocale);
744
+ if (named) next++;
747
745
  }
748
- if (last < rest.length - 1) throw new QueryError(`Cannot query inside "${at}"`);
749
- return { path, type: JSON_TYPES[field.type] ?? "text", field };
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 };
750
759
  }
751
760
  throw new QueryError(`"${[shown, ...rest].join(".")}" is a group; query one of its fields`);
752
761
  }
@@ -799,7 +808,8 @@ function resolvePath(model, segments, drafts, localization = null) {
799
808
  const column2 = columnOf();
800
809
  if (!column2) throw new QueryError(`Unknown locale "${locale}" for "${shown}"`);
801
810
  const rest = segments.slice(last + 1);
802
- const value = rest.length === 0 ? null : resolveBlockValue(field, rest, shown, localization);
811
+ const rows = field.blocks.flatMap((block) => block.fields);
812
+ const value = resolveJsonValue(rows, rest, shown, localization, true);
803
813
  return { kind: "blocks", column: col2(model, column2.column), value };
804
814
  }
805
815
  if (last < segments.length - 1) {
@@ -913,19 +923,22 @@ var WhereBuilder = class {
913
923
  });
914
924
  return parts.length === 1 ? parts[0] : and(...parts);
915
925
  }
916
- /** Conditions inside blocks test each block of the JSON column: some block must match. */
926
+ /**
927
+ * Conditions inside blocks test the JSON rows: some block (and, for nested lists, some row in
928
+ * it) must match.
929
+ */
917
930
  blocks(target, op, value, path) {
918
- const { value: inner } = target;
919
- if (!inner) {
920
- if (op !== "exists")
921
- throw new QueryError(`"${path}" is a blocks field; query one of its fields`);
922
- const any = this.dialect.someElement(target.column, () => void 0);
923
- return value === false ? not(any) : any;
924
- }
925
- return this.dialect.someElement(
926
- target.column,
927
- (get) => operatorSQL(this.dialect, get(inner.path, inner.type), inner.field, op, value, path)
928
- );
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;
929
942
  }
930
943
  /** Conditions on array rows or hasMany values become (NOT) EXISTS subqueries. */
931
944
  child(parent, child, rest, op, value, path, locale) {
@@ -955,13 +968,22 @@ var WhereBuilder = class {
955
968
  }
956
969
  return subquery(this.build(table, { [rest.join(".")]: { [op]: value } }));
957
970
  }
971
+ /** A column, or the first value in blocks (and the lists in them, in order) that has it. */
972
+ sortValue(target, path) {
973
+ if (target.kind === "column") return target.column;
974
+ if (target.kind === "blocks" && target.value.path) {
975
+ const { lists, path: inner, type } = target.value;
976
+ return this.dialect.firstElementValue(target.column, lists, inner, type);
977
+ }
978
+ throw new QueryError(`Cannot sort by "${path}"`);
979
+ }
958
980
  orderBy(model, sort) {
959
981
  const order = sort.map((entry) => {
960
982
  const descending = entry.startsWith("-");
961
983
  const path = descending ? entry.slice(1) : entry;
962
984
  const target = resolvePath(model, path.split("."), this.drafts, this.localization);
963
- if (target.kind !== "column") throw new QueryError(`Cannot sort by "${path}"`);
964
- return descending ? desc(target.column) : asc2(target.column);
985
+ const value = this.sortValue(target, path);
986
+ return descending ? desc(value) : asc2(value);
965
987
  });
966
988
  order.push(desc(col2(model, "id")));
967
989
  return order;
@@ -995,6 +1017,7 @@ async function connectDatabase(dialect, connection, args, options = {}) {
995
1017
  }
996
1018
  var DrizzleDatabase = class {
997
1019
  constructor(dialect, connection, schema, migrator) {
1020
+ this.dialect = dialect;
998
1021
  this.connection = connection;
999
1022
  this.schema = schema;
1000
1023
  this.migrator = migrator;
@@ -1006,6 +1029,7 @@ var DrizzleDatabase = class {
1006
1029
  );
1007
1030
  }
1008
1031
  }
1032
+ dialect;
1009
1033
  connection;
1010
1034
  schema;
1011
1035
  migrator;
@@ -1060,23 +1084,38 @@ var DrizzleDatabase = class {
1060
1084
  const [row] = await this.db.select({ total: count() }).from(model.root.table).where(this.where(args.collection, args.where));
1061
1085
  return Number(row?.total ?? 0);
1062
1086
  }
1087
+ writing = Promise.resolve();
1088
+ /** Runs a write; with a single-writer database, after the writes before it are done. */
1089
+ write(run) {
1090
+ if (!this.dialect.singleWriter) return run();
1091
+ const result = this.writing.then(() => retryWhileBusy(run));
1092
+ this.writing = result.catch(() => {
1093
+ });
1094
+ return result;
1095
+ }
1063
1096
  async create(args) {
1064
1097
  const model = this.model(args.collection);
1065
- const id = await this.db.transaction(
1066
- (tx) => insertDocument(tx, model.root, args.data, model.config.drafts === true)
1098
+ const id = await this.write(
1099
+ () => this.db.transaction(
1100
+ (tx) => insertDocument(tx, model.root, args.data, model.config.drafts === true)
1101
+ )
1067
1102
  );
1068
1103
  return await this.findById({ collection: args.collection, id });
1069
1104
  }
1070
1105
  async update(args) {
1071
1106
  const model = this.model(args.collection);
1072
- await this.db.transaction(
1073
- (tx) => replaceDocument(tx, model.root, args.id, args.data, model.config.drafts === true)
1107
+ await this.write(
1108
+ () => this.db.transaction(
1109
+ (tx) => replaceDocument(tx, model.root, args.id, args.data, model.config.drafts === true)
1110
+ )
1074
1111
  );
1075
1112
  return await this.findById({ collection: args.collection, id: args.id });
1076
1113
  }
1077
1114
  async delete(args) {
1078
1115
  const model = this.model(args.collection);
1079
- await this.db.transaction((tx) => deleteDocument(tx, model.root, args.id));
1116
+ await this.write(
1117
+ () => this.db.transaction((tx) => deleteDocument(tx, model.root, args.id))
1118
+ );
1080
1119
  }
1081
1120
  async findGlobal(args) {
1082
1121
  const table = this.schema.globals;
@@ -1097,7 +1136,9 @@ var DrizzleDatabase = class {
1097
1136
  status: status ?? null,
1098
1137
  updated_at: updatedAt
1099
1138
  };
1100
- await this.db.insert(table).values(values).onConflictDoUpdate({ target: table.slug, set: values });
1139
+ await this.write(
1140
+ () => this.db.insert(table).values(values).onConflictDoUpdate({ target: table.slug, set: values })
1141
+ );
1101
1142
  return await this.findGlobal({ slug: args.slug });
1102
1143
  }
1103
1144
  createMigration(args) {
@@ -1113,6 +1154,26 @@ var DrizzleDatabase = class {
1113
1154
  await this.connection.close();
1114
1155
  }
1115
1156
  };
1157
+ var BUSY_TIMEOUT = 1e4;
1158
+ async function retryWhileBusy(run) {
1159
+ const until = Date.now() + BUSY_TIMEOUT;
1160
+ for (let delay = 5; ; delay = Math.min(delay * 2, 250)) {
1161
+ try {
1162
+ return await run();
1163
+ } catch (error) {
1164
+ if (!isBusy(error) || Date.now() + delay > until) throw error;
1165
+ await new Promise((resolve2) => setTimeout(resolve2, delay + Math.random() * delay));
1166
+ }
1167
+ }
1168
+ }
1169
+ function isBusy(error) {
1170
+ for (let e = error; e; e = e.cause) {
1171
+ const code = e.code;
1172
+ if (typeof code === "string" && code.startsWith("SQLITE_BUSY")) return true;
1173
+ if (e === e.cause) break;
1174
+ }
1175
+ return false;
1176
+ }
1116
1177
  export {
1117
1178
  DEFAULT_MIGRATION_DIR,
1118
1179
  DEFAULT_TABLE_PREFIX,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@easy-cms/drizzle",
3
- "version": "0.7.0",
3
+ "version": "0.9.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.7.0"
26
+ "@easy-cms/core": "^0.9.0"
27
27
  },
28
28
  "devDependencies": {
29
- "@easy-cms/core": "^0.7.0"
29
+ "@easy-cms/core": "^0.9.0"
30
30
  },
31
31
  "publishConfig": {
32
32
  "access": "public"