@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.cjs +139 -22
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +24 -3
- package/dist/index.d.ts +24 -3
- package/dist/index.js +140 -23
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
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.
|
|
207
|
-
*
|
|
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 {
|
|
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
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
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
|
-
|
|
1091
|
-
const
|
|
1092
|
-
|
|
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.
|
|
1124
|
-
*
|
|
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
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
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
|
-
|
|
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
|