@dudousxd/nestjs-durable-store-mikro-orm 0.23.0 → 0.25.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
@@ -1,6 +1,6 @@
1
1
  import * as _mikro_orm_core from '@mikro-orm/core';
2
2
  import { EntityRepository, EntityRepositoryType, EntitySchema, MikroORM } from '@mikro-orm/core';
3
- import { StepKind, RunStatus, StateStore, WorkflowRun, RetentionPolicy, StepCheckpoint, RunQuery, RunFacetQuery, RunFacetRow, SignalWaiter } from '@dudousxd/nestjs-durable-core';
3
+ import { StepKind, RunStatus, StateStore, WorkflowRun, RetentionPolicy, StepCheckpoint, RunQuery, RunFacetQuery, RunFacetRow, RunValueAxis, RunValueFacetOptions, RunValueFacetRow, SignalWaiter } from '@dudousxd/nestjs-durable-core';
4
4
 
5
5
  /**
6
6
  * Custom repositories for the durable entities, so a host app can `@InjectRepository`/inject a
@@ -196,15 +196,36 @@ declare class MikroOrmStateStore implements StateStore {
196
196
  * predicates only raw SQL can express — the `tag` JSON LIKE and one EXISTS per attribute filter.
197
197
  * Shared so a facet count and the page it labels are taken over the SAME set. */
198
198
  private runQueryBuilder;
199
+ /** `tags LIKE '%"etl"%'` — the quoted token, so `etl` doesn't match `etl-foo`. See the note in
200
+ * {@link listRuns} for why this is raw SQL rather than a MikroORM condition. */
201
+ private tagLikeSql;
199
202
  /** `GROUP BY status, origin` over the same predicates {@link listRuns} pages — one aggregate, so a
200
203
  * console can show whole-set counts next to a bounded page instead of downloading every run to
201
204
  * count them in the browser. */
202
205
  runFacets(query: RunFacetQuery): Promise<RunFacetRow[]>;
206
+ /** Distinct values of one filter axis over the same predicates {@link listRuns} pages. A run-table
207
+ * column is a `GROUP BY` like {@link runFacets}, exact over the whole matching set; the tag and
208
+ * attribute axes live outside the row (json array / side table) and are counted from a bounded
209
+ * page of runs instead — see {@link RunValueFacetOptions.scan}. */
210
+ runValueFacets(axis: RunValueAxis, query: RunFacetQuery, opts?: RunValueFacetOptions): Promise<RunValueFacetRow[]>;
211
+ /**
212
+ * The axes whose values do not live in a column of the run row: `tag` (inside a JSON array) and the
213
+ * two search-attribute axes (a side table). Both read the newest `scan` matching runs.
214
+ *
215
+ * Neither reads a run's PAYLOAD. The obvious implementation — page `listRuns` and count in
216
+ * memory — pulls `input`, `output` and `error` for every run in the window, which on a control
217
+ * plane with real payloads is tens of megabytes fetched to produce a list of a hundred short
218
+ * strings, every time a picker opens. These project the one column each question needs instead:
219
+ * the tags column, or the run ids that then drive one grouped read of the side table.
220
+ */
221
+ private scannedValueFacets;
203
222
  /** One attribute predicate as a raw EXISTS subquery on the side-table, correlated to the outer run
204
223
  * alias. `<>` (ne) also excludes runs where the attribute is absent (missing-key-never-matches):
205
224
  * EXISTS already requires a present row, so EXISTS(... <> ...) is exactly ne-with-present. Numeric
206
- * operands compare the num column, everything else the str column. Identifiers are quoted per
207
- * driver and operands are inlined as literals (string operands are escaped by doubling quotes). */
225
+ * operands compare the num column, everything else the str column. `in` contributes one comparison
226
+ * per member, ORed inside the single EXISTS — one subquery either way, and an `in` over no values
227
+ * yields no comparison and so matches nothing. Identifiers are quoted per driver and operands are
228
+ * inlined as literals (string operands are escaped by doubling quotes). */
208
229
  private attributeExistsSql;
209
230
  /** Resolve the side-table's actual column names from MikroORM metadata, so the raw EXISTS matches
210
231
  * the active naming strategy (underscore vs camelCase) rather than guessing. The run PK column is
package/dist/index.js CHANGED
@@ -547,7 +547,7 @@ __name(durableEntities, "durableEntities");
547
547
  var ENTITIES = durableEntities();
548
548
 
549
549
  // src/mikro-orm-state-store.ts
550
- import { attributeColumnFor, attributeOperand, mergeRunFacetRows, normalizeAttributeRows, parseDuration, sqlComparator } from "@dudousxd/nestjs-durable-core";
550
+ import { RUN_VALUE_FACET_SCAN, attributePredicateOperands, axisIsRunColumn, mergeRunFacetRows, mergeRunValueFacetRows, normalizeAttributeRows, parseDuration } from "@dudousxd/nestjs-durable-core";
551
551
  import { raw } from "@mikro-orm/core";
552
552
 
553
553
  // src/schema.ts
@@ -1031,20 +1031,47 @@ var MikroOrmStateStore = class _MikroOrmStateStore {
1031
1031
  * applied by the QueryBuilder path in both callers. */
1032
1032
  runWhere(query) {
1033
1033
  const where = {};
1034
+ const and = [];
1034
1035
  if (query.workflow) where.workflow = query.workflow;
1036
+ if (query.workflows) and.push({
1037
+ workflow: {
1038
+ $in: query.workflows
1039
+ }
1040
+ });
1035
1041
  if (query.namespace !== void 0) where.namespace = query.namespace;
1042
+ if (query.namespaces) and.push({
1043
+ namespace: {
1044
+ $in: query.namespaces
1045
+ }
1046
+ });
1036
1047
  if (query.origin !== void 0) where.origin = query.origin;
1048
+ if (query.origins) {
1049
+ const named = query.origins.filter((o) => o !== null);
1050
+ const branches = [];
1051
+ if (named.length) branches.push({
1052
+ origin: {
1053
+ $in: named
1054
+ }
1055
+ });
1056
+ if (query.origins.some((o) => o === null)) branches.push({
1057
+ origin: null
1058
+ });
1059
+ and.push(branches.length ? {
1060
+ $or: branches
1061
+ } : {
1062
+ origin: {
1063
+ $in: []
1064
+ }
1065
+ });
1066
+ }
1037
1067
  if (query.status && query.statuses) {
1038
- where.$and = [
1039
- {
1040
- status: query.status
1041
- },
1042
- {
1043
- status: {
1044
- $in: query.statuses
1045
- }
1068
+ and.push({
1069
+ status: query.status
1070
+ }, {
1071
+ status: {
1072
+ $in: query.statuses
1046
1073
  }
1047
- ];
1074
+ });
1048
1075
  } else if (query.status) {
1049
1076
  where.status = query.status;
1050
1077
  } else if (query.statuses) {
@@ -1052,6 +1079,7 @@ var MikroOrmStateStore = class _MikroOrmStateStore {
1052
1079
  $in: query.statuses
1053
1080
  };
1054
1081
  }
1082
+ if (and.length) where.$and = and;
1055
1083
  return where;
1056
1084
  }
1057
1085
  async listRuns(query) {
@@ -1060,7 +1088,7 @@ var MikroOrmStateStore = class _MikroOrmStateStore {
1060
1088
  const orderBy = {
1061
1089
  createdAt: "desc"
1062
1090
  };
1063
- if (query.tag || query.attributes?.length) {
1091
+ if (query.tag || query.tags || query.attributes?.length) {
1064
1092
  const qb = this.runQueryBuilder(em, query, where).orderBy(orderBy);
1065
1093
  if (query.limit != null) qb.limit(query.limit);
1066
1094
  if (query.offset != null) qb.offset(query.offset);
@@ -1086,17 +1114,23 @@ var MikroOrmStateStore = class _MikroOrmStateStore {
1086
1114
  const quote = this.idQuote(em);
1087
1115
  const cols = this.attributeColumns(em);
1088
1116
  const qb = em.createQueryBuilder(WorkflowRunEntity, "r").where(where);
1089
- if (query.tag) {
1090
- const tagsCol = this.tagsColumn(em);
1091
- const pattern = `%"${query.tag.replace(/'/g, "''")}"%`;
1092
- const colExpr = this.jsonAsText(em, `${quote("r")}.${quote(tagsCol)}`);
1093
- qb.andWhere(`${colExpr} LIKE '${pattern}'`);
1117
+ if (query.tag) qb.andWhere(this.tagLikeSql(em, query.tag, quote));
1118
+ if (query.tags) {
1119
+ const branches = query.tags.map((t) => this.tagLikeSql(em, t, quote));
1120
+ qb.andWhere(branches.length ? `(${branches.join(" OR ")})` : "1 = 0");
1094
1121
  }
1095
1122
  for (const f of query.attributes ?? []) {
1096
1123
  qb.andWhere(this.attributeExistsSql(f, quote, "r", cols));
1097
1124
  }
1098
1125
  return qb;
1099
1126
  }
1127
+ /** `tags LIKE '%"etl"%'` — the quoted token, so `etl` doesn't match `etl-foo`. See the note in
1128
+ * {@link listRuns} for why this is raw SQL rather than a MikroORM condition. */
1129
+ tagLikeSql(em, tag, quote) {
1130
+ const pattern = `%"${tag.replace(/'/g, "''")}"%`;
1131
+ const colExpr = this.jsonAsText(em, `${quote("r")}.${quote(this.tagsColumn(em))}`);
1132
+ return `${colExpr} LIKE '${pattern}'`;
1133
+ }
1100
1134
  /** `GROUP BY status, origin` over the same predicates {@link listRuns} pages — one aggregate, so a
1101
1135
  * console can show whole-set counts next to a bounded page instead of downloading every run to
1102
1136
  * count them in the browser. */
@@ -1117,20 +1151,103 @@ var MikroOrmStateStore = class _MikroOrmStateStore {
1117
1151
  count: Number(r.count)
1118
1152
  })));
1119
1153
  }
1154
+ /** Distinct values of one filter axis over the same predicates {@link listRuns} pages. A run-table
1155
+ * column is a `GROUP BY` like {@link runFacets}, exact over the whole matching set; the tag and
1156
+ * attribute axes live outside the row (json array / side table) and are counted from a bounded
1157
+ * page of runs instead — see {@link RunValueFacetOptions.scan}. */
1158
+ async runValueFacets(axis, query, opts) {
1159
+ if (!axisIsRunColumn(axis)) {
1160
+ return this.scannedValueFacets(axis, query, opts);
1161
+ }
1162
+ const em = this.fork();
1163
+ const meta = em.getMetadata().get(WorkflowRunEntity);
1164
+ const col = meta.properties[axis.field]?.fieldNames?.[0] ?? axis.field;
1165
+ const rows = await this.runQueryBuilder(em, query, this.runWhere(query)).select([
1166
+ `r.${col}`
1167
+ ]).addSelect(raw("count(*) as count")).groupBy([
1168
+ `r.${col}`
1169
+ ]).execute();
1170
+ return mergeRunValueFacetRows(rows.map((row) => ({
1171
+ value: row[axis.field] ?? null,
1172
+ count: Number(row.count)
1173
+ })), opts);
1174
+ }
1175
+ /**
1176
+ * The axes whose values do not live in a column of the run row: `tag` (inside a JSON array) and the
1177
+ * two search-attribute axes (a side table). Both read the newest `scan` matching runs.
1178
+ *
1179
+ * Neither reads a run's PAYLOAD. The obvious implementation — page `listRuns` and count in
1180
+ * memory — pulls `input`, `output` and `error` for every run in the window, which on a control
1181
+ * plane with real payloads is tens of megabytes fetched to produce a list of a hundred short
1182
+ * strings, every time a picker opens. These project the one column each question needs instead:
1183
+ * the tags column, or the run ids that then drive one grouped read of the side table.
1184
+ */
1185
+ async scannedValueFacets(axis, query, opts) {
1186
+ const em = this.fork();
1187
+ const scan = opts?.scan ?? RUN_VALUE_FACET_SCAN;
1188
+ const where = this.runWhere(query);
1189
+ const runMeta = em.getMetadata().get(WorkflowRunEntity);
1190
+ const idCol = runMeta.properties.id?.fieldNames?.[0] ?? "id";
1191
+ if (axis.field === "tag") {
1192
+ const tagsCol = this.tagsColumn(em);
1193
+ const rows = await this.runQueryBuilder(em, query, where).select([
1194
+ `r.${tagsCol}`
1195
+ ]).orderBy({
1196
+ createdAt: "desc"
1197
+ }).limit(scan).execute();
1198
+ const values = [];
1199
+ for (const row of rows) {
1200
+ const raw2 = row.tags ?? row[tagsCol];
1201
+ const tags = typeof raw2 === "string" ? JSON.parse(raw2) : raw2;
1202
+ if (!Array.isArray(tags)) continue;
1203
+ for (const tag of tags) values.push({
1204
+ value: String(tag),
1205
+ count: 1
1206
+ });
1207
+ }
1208
+ return mergeRunValueFacetRows(values, opts);
1209
+ }
1210
+ const idRows = await this.runQueryBuilder(em, query, where).select([
1211
+ `r.${idCol}`
1212
+ ]).orderBy({
1213
+ createdAt: "desc"
1214
+ }).limit(scan).execute();
1215
+ const runIds = idRows.map((row) => String(row.id ?? row[idCol]));
1216
+ if (runIds.length === 0) return [];
1217
+ const attributes = await em.find(RunAttributeEntity, axis.field === "attributeValue" ? {
1218
+ runId: {
1219
+ $in: runIds
1220
+ },
1221
+ key: axis.key
1222
+ } : {
1223
+ runId: {
1224
+ $in: runIds
1225
+ }
1226
+ });
1227
+ return mergeRunValueFacetRows(attributes.map((row) => ({
1228
+ value: axis.field === "attributeKey" ? row.key : row.strValue ?? (row.numValue == null ? null : String(row.numValue)),
1229
+ count: 1
1230
+ })), opts);
1231
+ }
1120
1232
  /** One attribute predicate as a raw EXISTS subquery on the side-table, correlated to the outer run
1121
1233
  * alias. `<>` (ne) also excludes runs where the attribute is absent (missing-key-never-matches):
1122
1234
  * EXISTS already requires a present row, so EXISTS(... <> ...) is exactly ne-with-present. Numeric
1123
- * operands compare the num column, everything else the str column. Identifiers are quoted per
1124
- * driver and operands are inlined as literals (string operands are escaped by doubling quotes). */
1235
+ * operands compare the num column, everything else the str column. `in` contributes one comparison
1236
+ * per member, ORed inside the single EXISTS — one subquery either way, and an `in` over no values
1237
+ * yields no comparison and so matches nothing. Identifiers are quoted per driver and operands are
1238
+ * inlined as literals (string operands are escaped by doubling quotes). */
1125
1239
  attributeExistsSql(f, quote, alias, cols) {
1126
- const col = attributeColumnFor(f) === "numValue" ? cols.numValue : cols.strValue;
1127
- const cmp = sqlComparator(f.op);
1128
- const operand = attributeOperand(f);
1129
- const literal = typeof operand === "number" ? String(operand) : `'${String(operand).replace(/'/g, "''")}'`;
1240
+ const comparisons = attributePredicateOperands(f).map(({ column, comparator, operand }) => {
1241
+ const col = column === "numValue" ? cols.numValue : cols.strValue;
1242
+ const literal = typeof operand === "number" ? String(operand) : `'${String(operand).replace(/'/g, "''")}'`;
1243
+ return `${quote("a")}.${quote(col)} ${comparator} ${literal}`;
1244
+ });
1245
+ if (comparisons.length === 0) return "1 = 0";
1130
1246
  const a = quote(cols.table);
1131
1247
  const sub = quote("a");
1132
1248
  const outerId = `${quote(alias)}.${quote(cols.runPk)}`;
1133
- return `EXISTS (SELECT 1 FROM ${a} ${sub} WHERE ${sub}.${quote(cols.runId)} = ${outerId} AND ${sub}.${quote(cols.key)} = '${f.key.replace(/'/g, "''")}' AND ${sub}.${quote(col)} ${cmp} ${literal})`;
1249
+ const match = comparisons.length === 1 ? comparisons[0] : `(${comparisons.join(" OR ")})`;
1250
+ return `EXISTS (SELECT 1 FROM ${a} ${sub} WHERE ${sub}.${quote(cols.runId)} = ${outerId} AND ${sub}.${quote(cols.key)} = '${f.key.replace(/'/g, "''")}' AND ${match})`;
1134
1251
  }
1135
1252
  /** Resolve the side-table's actual column names from MikroORM metadata, so the raw EXISTS matches
1136
1253
  * the active naming strategy (underscore vs camelCase) rather than guessing. The run PK column is