@dudousxd/nestjs-durable-store-mikro-orm 0.25.0 → 0.26.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.cts CHANGED
@@ -203,22 +203,40 @@ declare class MikroOrmStateStore implements StateStore {
203
203
  * console can show whole-set counts next to a bounded page instead of downloading every run to
204
204
  * count them in the browser. */
205
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
206
  /**
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.
207
+ * Distinct values of one filter axis over the same predicates {@link listRuns} pages, with counts —
208
+ * what a console's value pickers list.
209
+ *
210
+ * All three axis families are answered by ONE grouped query against the filtered run set, so the
211
+ * counts are exact over that whole set (not a sample), the ordering is stable, and a picker can
212
+ * page and search server-side. That last part is what makes a picker usable on the axis that needs
213
+ * it most: tag cardinality grows with the data, so the values an operator is looking for are
214
+ * routinely outside any top slice, and a search that only filtered the fetched page could never
215
+ * find them.
214
216
  *
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.
217
+ * The predicates come from {@link runQueryBuilder} as a subquery rather than being rewritten here,
218
+ * so a picker and the list it filters can never disagree about which runs they are talking about.
219
+ */
220
+ runValueFacets(axis: RunValueAxis, query: RunFacetQuery, opts?: RunValueFacetOptions): Promise<RunValueFacetRow[]>;
221
+ /** The filtered run set as a derived table, carrying only the columns the axis needs. One source
222
+ * of truth with `listRuns`: the same builder, the same predicates. */
223
+ private runSubquery;
224
+ /** Where the values of an axis come from, and how to name one — a column of the filtered runs, an
225
+ * element of their tags array, or a row of the attribute side table. */
226
+ private valueSource;
227
+ /**
228
+ * The dialect's way of turning a JSON array column into one row per element — the only portable
229
+ * thing about it being that all three have one. Without it a tag picker cannot be grouped in the
230
+ * database at all, and falls back to deduping a bounded page of runs in memory: counts that
231
+ * describe a sample, and an order that cannot be paged.
220
232
  */
221
- private scannedValueFacets;
233
+ private jsonArrayJoin;
234
+ /** `lower(value) like '%needle%'` — the picker's search box, applied BEFORE the page is cut so a
235
+ * match outside the top slice is still reachable. */
236
+ private searchCondition;
237
+ /** A grouped value as the string a picker shows. Drivers return a JSON element or a numeric
238
+ * attribute as something other than a string; `null` stays null (the absent bucket). */
239
+ private text;
222
240
  /** One attribute predicate as a raw EXISTS subquery on the side-table, correlated to the outer run
223
241
  * alias. `<>` (ne) also excludes runs where the attribute is absent (missing-key-never-matches):
224
242
  * EXISTS already requires a present row, so EXISTS(... <> ...) is exactly ne-with-present. Numeric
package/dist/index.d.ts CHANGED
@@ -203,22 +203,40 @@ declare class MikroOrmStateStore implements StateStore {
203
203
  * console can show whole-set counts next to a bounded page instead of downloading every run to
204
204
  * count them in the browser. */
205
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
206
  /**
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.
207
+ * Distinct values of one filter axis over the same predicates {@link listRuns} pages, with counts —
208
+ * what a console's value pickers list.
209
+ *
210
+ * All three axis families are answered by ONE grouped query against the filtered run set, so the
211
+ * counts are exact over that whole set (not a sample), the ordering is stable, and a picker can
212
+ * page and search server-side. That last part is what makes a picker usable on the axis that needs
213
+ * it most: tag cardinality grows with the data, so the values an operator is looking for are
214
+ * routinely outside any top slice, and a search that only filtered the fetched page could never
215
+ * find them.
214
216
  *
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.
217
+ * The predicates come from {@link runQueryBuilder} as a subquery rather than being rewritten here,
218
+ * so a picker and the list it filters can never disagree about which runs they are talking about.
219
+ */
220
+ runValueFacets(axis: RunValueAxis, query: RunFacetQuery, opts?: RunValueFacetOptions): Promise<RunValueFacetRow[]>;
221
+ /** The filtered run set as a derived table, carrying only the columns the axis needs. One source
222
+ * of truth with `listRuns`: the same builder, the same predicates. */
223
+ private runSubquery;
224
+ /** Where the values of an axis come from, and how to name one — a column of the filtered runs, an
225
+ * element of their tags array, or a row of the attribute side table. */
226
+ private valueSource;
227
+ /**
228
+ * The dialect's way of turning a JSON array column into one row per element — the only portable
229
+ * thing about it being that all three have one. Without it a tag picker cannot be grouped in the
230
+ * database at all, and falls back to deduping a bounded page of runs in memory: counts that
231
+ * describe a sample, and an order that cannot be paged.
220
232
  */
221
- private scannedValueFacets;
233
+ private jsonArrayJoin;
234
+ /** `lower(value) like '%needle%'` — the picker's search box, applied BEFORE the page is cut so a
235
+ * match outside the top slice is still reachable. */
236
+ private searchCondition;
237
+ /** A grouped value as the string a picker shows. Drivers return a JSON element or a numeric
238
+ * attribute as something other than a string; `null` stays null (the absent bucket). */
239
+ private text;
222
240
  /** One attribute predicate as a raw EXISTS subquery on the side-table, correlated to the outer run
223
241
  * alias. `<>` (ne) also excludes runs where the attribute is absent (missing-key-never-matches):
224
242
  * EXISTS already requires a present row, so EXISTS(... <> ...) is exactly ne-with-present. Numeric
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 { RUN_VALUE_FACET_SCAN, attributePredicateOperands, axisIsRunColumn, mergeRunFacetRows, mergeRunValueFacetRows, normalizeAttributeRows, parseDuration } from "@dudousxd/nestjs-durable-core";
550
+ import { ENGINE_MINTED_TAG_PREFIXES, RUN_VALUE_FACET_LIMIT, attributePredicateOperands, axisIsRunColumn, facetOrigin, mergeRunFacetRows, normalizeAttributeRows, parseDuration } from "@dudousxd/nestjs-durable-core";
551
551
  import { raw } from "@mikro-orm/core";
552
552
 
553
553
  // src/schema.ts
@@ -1151,83 +1151,118 @@ var MikroOrmStateStore = class _MikroOrmStateStore {
1151
1151
  count: Number(r.count)
1152
1152
  })));
1153
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}. */
1154
+ /**
1155
+ * Distinct values of one filter axis over the same predicates {@link listRuns} pages, with counts —
1156
+ * what a console's value pickers list.
1157
+ *
1158
+ * All three axis families are answered by ONE grouped query against the filtered run set, so the
1159
+ * counts are exact over that whole set (not a sample), the ordering is stable, and a picker can
1160
+ * page and search server-side. That last part is what makes a picker usable on the axis that needs
1161
+ * it most: tag cardinality grows with the data, so the values an operator is looking for are
1162
+ * routinely outside any top slice, and a search that only filtered the fetched page could never
1163
+ * find them.
1164
+ *
1165
+ * The predicates come from {@link runQueryBuilder} as a subquery rather than being rewritten here,
1166
+ * so a picker and the list it filters can never disagree about which runs they are talking about.
1167
+ */
1158
1168
  async runValueFacets(axis, query, opts) {
1159
- if (!axisIsRunColumn(axis)) {
1160
- return this.scannedValueFacets(axis, query, opts);
1161
- }
1162
1169
  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,
1170
+ const quote = this.idQuote(em);
1171
+ const limit = Math.max(0, opts?.limit ?? RUN_VALUE_FACET_LIMIT);
1172
+ const offset = Math.max(0, opts?.offset ?? 0);
1173
+ if (limit === 0) return [];
1174
+ const runs = this.runSubquery(em, query, axis);
1175
+ const { source, valueExpr } = this.valueSource(em, axis, runs, quote);
1176
+ const conditions = [
1177
+ this.searchCondition(valueExpr, opts?.search)
1178
+ ].filter((part) => part !== void 0);
1179
+ const where = conditions.length ? ` where ${conditions.join(" and ")}` : "";
1180
+ const engineLast = axis.field === "tag" ? `case when ${valueExpr} like '${ENGINE_MINTED_TAG_PREFIXES[0]}%' then 1 else 0 end, ` : "";
1181
+ const sql = `select ${valueExpr} as value, count(*) as count from ${source}${where} group by ${valueExpr} order by ${engineLast}count(*) desc, ${valueExpr} asc limit ${limit} offset ${offset}`;
1182
+ const rows = await em.getConnection().execute(sql);
1183
+ return rows.map((row) => ({
1184
+ // Blank and absent are the same thing to an operator on the origin axis — see facetOrigin.
1185
+ value: axis.field === "origin" ? facetOrigin(row.value) : this.text(row.value),
1172
1186
  count: Number(row.count)
1173
- })), opts);
1187
+ }));
1188
+ }
1189
+ /** The filtered run set as a derived table, carrying only the columns the axis needs. One source
1190
+ * of truth with `listRuns`: the same builder, the same predicates. */
1191
+ runSubquery(em, query, axis) {
1192
+ const meta = em.getMetadata().get(WorkflowRunEntity);
1193
+ const idCol = meta.properties.id?.fieldNames?.[0] ?? "id";
1194
+ const columns = axis.field === "tag" ? [
1195
+ `r.${idCol}`,
1196
+ `r.${this.tagsColumn(em)}`
1197
+ ] : axisIsRunColumn(axis) ? [
1198
+ `r.${meta.properties[axis.field]?.fieldNames?.[0] ?? axis.field}`
1199
+ ] : [
1200
+ `r.${idCol}`
1201
+ ];
1202
+ return this.runQueryBuilder(em, query, this.runWhere(query)).select(columns).getFormattedQuery();
1203
+ }
1204
+ /** Where the values of an axis come from, and how to name one — a column of the filtered runs, an
1205
+ * element of their tags array, or a row of the attribute side table. */
1206
+ valueSource(em, axis, runs, quote) {
1207
+ const meta = em.getMetadata().get(WorkflowRunEntity);
1208
+ if (axisIsRunColumn(axis)) {
1209
+ const col = meta.properties[axis.field]?.fieldNames?.[0] ?? axis.field;
1210
+ return {
1211
+ source: `(${runs}) as ${quote("r")}`,
1212
+ valueExpr: `${quote("r")}.${quote(col)}`
1213
+ };
1214
+ }
1215
+ if (axis.field === "tag") {
1216
+ return {
1217
+ source: `(${runs}) as ${quote("r")} ${this.jsonArrayJoin(em, `${quote("r")}.${quote(this.tagsColumn(em))}`, quote)}`,
1218
+ valueExpr: `${quote("t")}.${quote("value")}`
1219
+ };
1220
+ }
1221
+ const cols = this.attributeColumns(em);
1222
+ const idCol = meta.properties.id?.fieldNames?.[0] ?? "id";
1223
+ const a = quote("a");
1224
+ const join = `${quote(cols.table)} as ${a} join (${runs}) as ${quote("r")} on ${a}.${quote(cols.runId)} = ${quote("r")}.${quote(idCol)}`;
1225
+ if (axis.field === "attributeKey") {
1226
+ return {
1227
+ source: join,
1228
+ valueExpr: `${a}.${quote(cols.key)}`
1229
+ };
1230
+ }
1231
+ const key = axis.key.replace(/'/g, "''");
1232
+ return {
1233
+ source: `${join} and ${a}.${quote(cols.key)} = '${key}'`,
1234
+ valueExpr: `coalesce(${a}.${quote(cols.strValue)}, cast(${a}.${quote(cols.numValue)} as char))`
1235
+ };
1174
1236
  }
1175
1237
  /**
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.
1238
+ * The dialect's way of turning a JSON array column into one row per element — the only portable
1239
+ * thing about it being that all three have one. Without it a tag picker cannot be grouped in the
1240
+ * database at all, and falls back to deduping a bounded page of runs in memory: counts that
1241
+ * describe a sample, and an order that cannot be paged.
1184
1242
  */
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);
1243
+ jsonArrayJoin(em, colRef, quote) {
1244
+ const platform = String(em.getPlatform().constructor.name).toLowerCase();
1245
+ const t = quote("t");
1246
+ if (platform.includes("postgre")) {
1247
+ return `cross join lateral jsonb_array_elements_text(${colRef}::jsonb) as ${t}(${quote("value")})`;
1209
1248
  }
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);
1249
+ if (platform.includes("mysql") || platform.includes("mariadb")) {
1250
+ return `join json_table(cast(${colRef} as json), '$[*]' columns (${quote("value")} varchar(512) path '$')) as ${t}`;
1251
+ }
1252
+ return `join json_each(${colRef}) as ${t}`;
1253
+ }
1254
+ /** `lower(value) like '%needle%'` — the picker's search box, applied BEFORE the page is cut so a
1255
+ * match outside the top slice is still reachable. */
1256
+ searchCondition(valueExpr, search) {
1257
+ const needle = search?.trim().toLowerCase();
1258
+ if (!needle) return void 0;
1259
+ const escaped = needle.replace(/'/g, "''").replace(/[%_]/g, (c) => `\\${c}`);
1260
+ return `lower(${valueExpr}) like '%${escaped}%'`;
1261
+ }
1262
+ /** A grouped value as the string a picker shows. Drivers return a JSON element or a numeric
1263
+ * attribute as something other than a string; `null` stays null (the absent bucket). */
1264
+ text(value) {
1265
+ return value === null || value === void 0 ? null : String(value);
1231
1266
  }
1232
1267
  /** One attribute predicate as a raw EXISTS subquery on the side-table, correlated to the outer run
1233
1268
  * alias. `<>` (ne) also excludes runs where the attribute is absent (missing-key-never-matches):