@zhuoyuezs/ml-platform 0.1.1 → 0.1.4

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.
Files changed (26) hide show
  1. package/DEVELOPMENT.md +96 -9
  2. package/README.md +89 -38
  3. package/checksums.json +37 -32
  4. package/package.json +5 -1
  5. package/release-policy.json +10 -0
  6. package/release.json +13 -9
  7. package/runtime/business-client/README.md +13 -0
  8. package/runtime/business-client/package-lock.json +2 -2
  9. package/runtime/business-client/package.json +1 -1
  10. package/runtime/business-client/src/catalog.js +40 -17
  11. package/runtime/business-client/src/cli.js +150 -26
  12. package/runtime/business-client/src/config.js +6 -2
  13. package/runtime/business-client/src/http.js +88 -17
  14. package/scripts/lib.js +98 -4
  15. package/scripts/main.js +96 -11
  16. package/skills/feature-management/SKILL.md +187 -18
  17. package/skills/feature-management/assets/catalog-template/datasets/example_temperature_training.v1.json +16 -0
  18. package/skills/feature-management/assets/catalog-template/feature_sets/example_temperature_core.v1.json +1 -0
  19. package/skills/feature-management/assets/catalog-template/features/example_temperature_mean_5m.v1.json +9 -2
  20. package/skills/feature-management/assets/catalog-template/operator_package/tests/test_operator.py +51 -24
  21. package/skills/feature-management/assets/catalog-template/operators/example_temperature_features.v1.json +16 -1
  22. package/skills/feature-management/assets/catalog-template/parameters/example_temperature.v1.json +1 -0
  23. package/skills/feature-management/references/commands.md +66 -4
  24. package/skills/feature-management/references/contracts.md +43 -6
  25. package/skills/feature-management/references/operator-authoring.md +15 -7
  26. package/skills/feature-management/references/platform-capability-guide.md +44 -0
@@ -34,7 +34,7 @@ function objectList(value, label) { if (!Array.isArray(value) || value.some((ite
34
34
  function stringMap(value, label) { object(value, label); if (Object.entries(value).some(([name, item]) => typeof name !== "string" || typeof item !== "string")) throw new Error(`${label} must contain only string keys and values`); return value; }
35
35
  function unique(values, label) { if (new Set(values).size !== values.length) throw new Error(`${label} must be unique`); }
36
36
  function ref(value, label) { exactKeys(value, ["name", "version", "project"], ["name", "version"], label); return { name: identifier(value.name, `${label} name`), version: identifier(value.version, `${label} version`) }; }
37
- function parameterRef(value) { exactKeys(value, ["parameter", "version", "project"], ["parameter", "version"], "feature input"); return { parameter: identifier(value.parameter, "feature input parameter"), version: identifier(value.version, "feature input version") }; }
37
+ function parameterRef(value, parentProject) { exactKeys(value, ["parameter", "version", "project"], ["parameter", "version"], "feature input"); const project = identifier(defaultIfAbsent(value, "project", "default"), "feature input project"); if (parentProject && project !== parentProject) throw new Error("feature input cross-project reference is not allowed"); return { parameter: identifier(value.parameter, "feature input parameter"), version: identifier(value.version, "feature input version"), project }; }
38
38
  function key(value) { return `${value.name}:${value.version}`; }
39
39
  function datasetKey(value) { return `${value.dataset_id}:${value.dataset_version}`; }
40
40
  function referenceKey(value) { return `${value.name}:${value.version}`; }
@@ -60,13 +60,16 @@ function normalizeCodeArtifact(value) { object(value, "operator code_artifact");
60
60
  function normalizeParameter(payload) {
61
61
  exactKeys(payload, ["schema_version", "project", "name", "display_name", "version", "data_type", "unit", "expected_frequency", "source", "time_semantics", "availability_sla", "value_field", "quality_rules", "rounding", "owner"], ["name", "display_name", "version", "source"], "parameter");
62
62
  const source = object(payload.source, "parameter source");
63
+ if (Object.prototype.hasOwnProperty.call(source, "parameters")) throw new Error("parameter source uses unsupported field 'parameters'; use 'params'");
64
+ if (source.mode === "sql" && typeof source.sql === "string" && source.sql.trimEnd().endsWith(";")) throw new Error("SQL Parameter source.sql must not end with a semicolon");
63
65
  const time = object(defaultIfAbsent(payload, "time_semantics", {}), "parameter time_semantics");
64
- return { schema_version: pythonString(defaultIfAbsent(payload, "schema_version", "ml_data_platform.parameter/v1")), name: identifier(payload.name, "parameter name"), display_name: requiredString(payload.display_name, "parameter display_name"), version: identifier(payload.version, "parameter version"), data_type: pythonString(defaultIfAbsent(payload, "data_type", "time_series")), unit: pythonString(defaultIfAbsent(payload, "unit", "")), expected_frequency: pythonString(defaultIfAbsent(payload, "expected_frequency", "10min")), source: { adapter: pythonString(defaultIfAbsent(source, "adapter", "synthetic")), mode: choice(defaultIfAbsent(source, "mode", "direct_column"), ["direct_column", "sql", "derived"], "parameter source mode"), source_metric: optionalString(source.source_metric, "source_metric"), source_group: optionalString(source.source_group, "source_group"), measurement: optionalString(source.measurement, "measurement"), field: optionalString(source.field, "field"), tags: stringMap(defaultIfAbsent(source, "tags", {}), "source tags"), schema: optionalString(aliasIfAbsent(source, "schema", "schema_name"), "schema"), table: optionalString(source.table, "table"), time_column: optionalString(source.time_column, "time_column"), value_column: optionalString(source.value_column, "value_column"), metric_name_column: optionalString(source.metric_name_column, "metric_name_column"), metric_name: optionalString(source.metric_name, "metric_name"), unit_column: optionalString(source.unit_column, "unit_column"), unit: optionalString(aliasIfAbsent(source, "unit", "source_unit"), "source unit"), filters: stringMap(defaultIfAbsent(source, "filters", {}), "source filters"), sql: optionalString(source.sql, "source sql"), params: object(defaultIfAbsent(source, "params", {}), "source params") }, time_semantics: { event_time_field: pythonString(defaultIfAbsent(time, "event_time_field", "event_time")), ingested_at_field: pythonString(defaultIfAbsent(time, "ingested_at_field", "ingested_at")), timezone: pythonString(defaultIfAbsent(time, "timezone", "Asia/Shanghai")), availability: normalizeAvailability(time.availability) }, availability_sla: normalizeAvailabilitySla(payload.availability_sla), value_field: pythonString(defaultIfAbsent(payload, "value_field", "value")), quality_rules: normalizeQualityRules(object(defaultIfAbsent(payload, "quality_rules", {}), "parameter quality_rules")), rounding: normalizeRounding(payload.rounding), owner: pythonString(defaultIfAbsent(payload, "owner", "demo")) };
66
+ return { schema_version: pythonString(defaultIfAbsent(payload, "schema_version", "ml_data_platform.parameter/v1")), project: identifier(defaultIfAbsent(payload, "project", "default"), "parameter project"), name: identifier(payload.name, "parameter name"), display_name: requiredString(payload.display_name, "parameter display_name"), version: identifier(payload.version, "parameter version"), data_type: pythonString(defaultIfAbsent(payload, "data_type", "time_series")), unit: pythonString(defaultIfAbsent(payload, "unit", "")), expected_frequency: pythonString(defaultIfAbsent(payload, "expected_frequency", "10min")), source: { adapter: pythonString(defaultIfAbsent(source, "adapter", "synthetic")), mode: choice(defaultIfAbsent(source, "mode", "direct_column"), ["direct_column", "sql", "derived"], "parameter source mode"), source_metric: optionalString(source.source_metric, "source_metric"), source_group: optionalString(source.source_group, "source_group"), measurement: optionalString(source.measurement, "measurement"), field: optionalString(source.field, "field"), tags: stringMap(defaultIfAbsent(source, "tags", {}), "source tags"), schema: optionalString(aliasIfAbsent(source, "schema", "schema_name"), "schema"), table: optionalString(source.table, "table"), time_column: optionalString(source.time_column, "time_column"), value_column: optionalString(source.value_column, "value_column"), metric_name_column: optionalString(source.metric_name_column, "metric_name_column"), metric_name: optionalString(source.metric_name, "metric_name"), unit_column: optionalString(source.unit_column, "unit_column"), unit: optionalString(aliasIfAbsent(source, "unit", "source_unit"), "source unit"), filters: stringMap(defaultIfAbsent(source, "filters", {}), "source filters"), sql: optionalString(source.sql, "source sql"), params: object(defaultIfAbsent(source, "params", {}), "source params") }, time_semantics: { event_time_field: pythonString(defaultIfAbsent(time, "event_time_field", "event_time")), ingested_at_field: pythonString(defaultIfAbsent(time, "ingested_at_field", "ingested_at")), timezone: pythonString(defaultIfAbsent(time, "timezone", "Asia/Shanghai")), availability: normalizeAvailability(time.availability) }, availability_sla: normalizeAvailabilitySla(payload.availability_sla), value_field: pythonString(defaultIfAbsent(payload, "value_field", "value")), quality_rules: normalizeQualityRules(object(defaultIfAbsent(payload, "quality_rules", {}), "parameter quality_rules")), rounding: normalizeRounding(payload.rounding), owner: pythonString(defaultIfAbsent(payload, "owner", "demo")) };
65
67
  }
66
68
 
67
- function normalizeOperator(payload) { exactKeys(payload, ["schema_version", "project", "name", "version", "type", "function_hash", "entrypoint", "code_hash", "package_uri", "code_artifact", "input_schema", "output_schema", "config_schema", "runtime", "resources", "deterministic", "supports_batch", "supports_online", "owner"], ["name", "version"], "operator"); const artifact = payload.code_artifact == null ? null : normalizeCodeArtifact(payload.code_artifact); const normalized = { schema_version: pythonString(defaultIfAbsent(payload, "schema_version", "ml_data_platform.operator/v1")), name: identifier(payload.name, "operator name"), version: identifier(payload.version, "operator version"), type: choice(defaultIfAbsent(payload, "type", "standard"), ["standard", "custom_python", "spark_udf", "feature"], "operator type"), function_hash: pythonString(defaultIfAbsent(payload, "function_hash", "demo")), entrypoint: optionalString(payload.entrypoint, "operator entrypoint"), code_hash: optionalString(payload.code_hash, "operator code_hash"), package_uri: optionalString(payload.package_uri, "operator package_uri"), code_artifact: artifact, input_schema: object(defaultIfAbsent(payload, "input_schema", {}), "operator input_schema"), output_schema: object(defaultIfAbsent(payload, "output_schema", {}), "operator output_schema"), config_schema: object(defaultIfAbsent(payload, "config_schema", {}), "operator config_schema"), runtime: object(defaultIfAbsent(payload, "runtime", {}), "operator runtime"), resources: object(defaultIfAbsent(payload, "resources", {}), "operator resources"), deterministic: bool(defaultIfAbsent(payload, "deterministic", true), "operator deterministic"), supports_batch: bool(defaultIfAbsent(payload, "supports_batch", true), "operator supports_batch"), supports_online: bool(defaultIfAbsent(payload, "supports_online", false), "operator supports_online"), owner: pythonString(defaultIfAbsent(payload, "owner", "demo")) }; if (artifact && normalized.code_hash !== artifact.sha256) throw new Error("operator code_hash must equal code_artifact.sha256"); if (artifact && normalized.package_uri !== artifact.uri) throw new Error("operator package_uri must equal code_artifact.uri"); return normalized; }
68
- function normalizeFeature(payload) { exactKeys(payload, ["schema_version", "project", "name", "version", "inputs", "operator", "operator_version", "config", "output_column", "output_dtype", "offline_online_supported", "owner", "description"], ["name", "version", "inputs", "operator", "operator_version", "output_column"], "feature"); const inputs = objectList(payload.inputs, "feature inputs").map(parameterRef); if (!inputs.length) throw new Error("feature inputs must not be empty"); unique(inputs.map(parameterReferenceKey), "feature inputs"); if (typeof payload.output_column !== "string" || !outputColumnPattern.test(payload.output_column)) throw new Error("feature output_column is invalid"); return { schema_version: pythonString(defaultIfAbsent(payload, "schema_version", "ml_data_platform.feature/v1")), name: identifier(payload.name, "feature name"), version: identifier(payload.version, "feature version"), inputs, operator: identifier(payload.operator, "feature operator"), operator_version: identifier(payload.operator_version, "feature operator_version"), config: object(defaultIfAbsent(payload, "config", {}), "feature config"), output_column: payload.output_column, output_dtype: pythonString(defaultIfAbsent(payload, "output_dtype", "float64")), offline_online_supported: bool(defaultIfAbsent(payload, "offline_online_supported", true), "offline_online_supported"), owner: pythonString(defaultIfAbsent(payload, "owner", "demo")), description: pythonString(defaultIfAbsent(payload, "description", "")) }; }
69
- function normalizeFeatureSet(payload) { exactKeys(payload, ["schema_version", "project", "name", "version", "features", "owner", "description"], ["name", "version", "features"], "feature set"); const features = objectList(payload.features, "feature set features").map((item) => ref(item, "feature reference")); if (!features.length) throw new Error("feature_set features must not be empty"); unique(features.map(referenceKey), "feature_set features"); return { schema_version: pythonString(defaultIfAbsent(payload, "schema_version", "ml_data_platform.feature_set/v1")), name: identifier(payload.name, "feature set name"), version: identifier(payload.version, "feature set version"), features, owner: pythonString(defaultIfAbsent(payload, "owner", "demo")), description: pythonString(defaultIfAbsent(payload, "description", "")) }; }
69
+ function normalizeOperator(payload) { exactKeys(payload, ["schema_version", "project", "name", "version", "type", "function_hash", "entrypoint", "code_hash", "package_uri", "code_artifact", "input_schema", "output_schema", "config_schema", "runtime", "resources", "deterministic", "supports_batch", "supports_online", "owner"], ["name", "version"], "operator"); const artifact = payload.code_artifact == null ? null : normalizeCodeArtifact(payload.code_artifact); const normalized = { schema_version: pythonString(defaultIfAbsent(payload, "schema_version", "ml_data_platform.operator/v1")), project: identifier(defaultIfAbsent(payload, "project", "default"), "operator project"), name: identifier(payload.name, "operator name"), version: identifier(payload.version, "operator version"), type: choice(defaultIfAbsent(payload, "type", "standard"), ["standard", "custom_python", "spark_udf", "feature"], "operator type"), function_hash: pythonString(defaultIfAbsent(payload, "function_hash", "demo")), entrypoint: optionalString(payload.entrypoint, "operator entrypoint"), code_hash: optionalString(payload.code_hash, "operator code_hash"), package_uri: optionalString(payload.package_uri, "operator package_uri"), code_artifact: artifact, input_schema: object(defaultIfAbsent(payload, "input_schema", {}), "operator input_schema"), output_schema: object(defaultIfAbsent(payload, "output_schema", {}), "operator output_schema"), config_schema: object(defaultIfAbsent(payload, "config_schema", {}), "operator config_schema"), runtime: object(defaultIfAbsent(payload, "runtime", {}), "operator runtime"), resources: object(defaultIfAbsent(payload, "resources", {}), "operator resources"), deterministic: bool(defaultIfAbsent(payload, "deterministic", true), "operator deterministic"), supports_batch: bool(defaultIfAbsent(payload, "supports_batch", true), "operator supports_batch"), supports_online: bool(defaultIfAbsent(payload, "supports_online", false), "operator supports_online"), owner: pythonString(defaultIfAbsent(payload, "owner", "demo")) }; if (artifact && normalized.code_hash !== artifact.sha256) throw new Error("operator code_hash must equal code_artifact.sha256"); if (artifact && normalized.package_uri !== artifact.uri) throw new Error("operator package_uri must equal code_artifact.uri"); return normalized; }
70
+ function normalizeRegistryOperator(payload) { const value = object(payload, "operator"); if (!hasOwn(value, "deleted_at")) return normalizeOperator(value); const { deleted_at, ...contract } = value; void deleted_at; return normalizeOperator(contract); }
71
+ function normalizeFeature(payload) { exactKeys(payload, ["schema_version", "project", "name", "version", "inputs", "operator", "operator_version", "config", "output_column", "output_dtype", "offline_online_supported", "owner", "description"], ["name", "version", "inputs", "operator", "operator_version", "output_column"], "feature"); const project = identifier(defaultIfAbsent(payload, "project", "default"), "feature project"); const inputs = objectList(payload.inputs, "feature inputs").map((item) => parameterRef(item, project)); if (!inputs.length) throw new Error("feature inputs must not be empty"); unique(inputs.map(parameterReferenceKey), "feature inputs"); if (typeof payload.output_column !== "string" || !outputColumnPattern.test(payload.output_column)) throw new Error("feature output_column is invalid"); return { schema_version: pythonString(defaultIfAbsent(payload, "schema_version", "ml_data_platform.feature/v1")), project, name: identifier(payload.name, "feature name"), version: identifier(payload.version, "feature version"), inputs, operator: identifier(payload.operator, "feature operator"), operator_version: identifier(payload.operator_version, "feature operator_version"), config: object(defaultIfAbsent(payload, "config", {}), "feature config"), output_column: payload.output_column, output_dtype: pythonString(defaultIfAbsent(payload, "output_dtype", "float64")), offline_online_supported: bool(defaultIfAbsent(payload, "offline_online_supported", true), "offline_online_supported"), owner: pythonString(defaultIfAbsent(payload, "owner", "demo")), description: pythonString(defaultIfAbsent(payload, "description", "")) }; }
72
+ function normalizeFeatureSet(payload) { exactKeys(payload, ["schema_version", "project", "name", "version", "features", "owner", "description"], ["name", "version", "features"], "feature set"); const features = objectList(payload.features, "feature set features").map((item) => ref(item, "feature reference")); if (!features.length) throw new Error("feature_set features must not be empty"); unique(features.map(referenceKey), "feature_set features"); return { schema_version: pythonString(defaultIfAbsent(payload, "schema_version", "ml_data_platform.feature_set/v1")), project: identifier(defaultIfAbsent(payload, "project", "default"), "feature set project"), name: identifier(payload.name, "feature set name"), version: identifier(payload.version, "feature set version"), features, owner: pythonString(defaultIfAbsent(payload, "owner", "demo")), description: pythonString(defaultIfAbsent(payload, "description", "")) }; }
70
73
  function timestamp(value, label) {
71
74
  requiredString(value, label);
72
75
  const match = timestampPattern.exec(value);
@@ -90,15 +93,15 @@ function timestamp(value, label) {
90
93
  return value;
91
94
  }
92
95
  function validateReferenceProject(value, parentProject, label) { if (value.project == null) return; const project = identifier(value.project, `${label} project`); if (project !== parentProject) throw new Error(`${label} cross-project reference is not allowed`); }
93
- function normalizeParameterRequest(value, parentProject) { object(value, "dataset parameter request"); validateReferenceProject(value, parentProject, "dataset parameter request"); let alignment = null; if (value.alignment != null) { object(value.alignment, "parameter alignment"); alignment = { grid: pythonString(defaultIfAbsent(value.alignment, "grid", "10min")), method: choice(defaultIfAbsent(value.alignment, "method", "last_before_or_at"), ["last_before_or_at", "nearest", "none"], "parameter alignment method") }; } return { name: identifier(value.name, "dataset parameter name"), version: identifier(value.version, "dataset parameter version"), alias: optionalString(value.alias, "dataset parameter alias"), alignment, required: bool(defaultIfAbsent(value, "required", true), "dataset parameter required"), missing_policy: choice(defaultIfAbsent(value, "missing_policy", "report_only"), ["report_only", "fail_on_requested_range_gap", "drop_target_and_lookback"], "dataset parameter missing_policy") }; }
94
- function normalizeMissingPolicy(value, parentProject) { exactKeys(value, ["parameter", "version", "project", "policy"], ["parameter", "version"], "dataset parameter missing policy"); validateReferenceProject(value, parentProject, "dataset parameter missing policy"); return { parameter: identifier(value.parameter, "dataset parameter missing policy parameter"), version: identifier(value.version, "dataset parameter missing policy version"), policy: choice(defaultIfAbsent(value, "policy", "report_only"), ["report_only", "fail_on_requested_range_gap", "drop_target_and_lookback"], "dataset parameter missing policy policy") }; }
96
+ function normalizeParameterRequest(value, parentProject) { object(value, "dataset parameter request"); validateReferenceProject(value, parentProject, "dataset parameter request"); let alignment = null; if (value.alignment != null) { object(value.alignment, "parameter alignment"); alignment = { grid: pythonString(defaultIfAbsent(value.alignment, "grid", "10min")), method: choice(defaultIfAbsent(value.alignment, "method", "last_before_or_at"), ["last_before_or_at", "nearest", "none"], "parameter alignment method") }; } return { name: identifier(value.name, "dataset parameter name"), version: identifier(value.version, "dataset parameter version"), project: parentProject, alias: optionalString(value.alias, "dataset parameter alias"), alignment, required: bool(defaultIfAbsent(value, "required", true), "dataset parameter required"), missing_policy: choice(defaultIfAbsent(value, "missing_policy", "report_only"), ["report_only", "fail_on_requested_range_gap", "drop_target_and_lookback"], "dataset parameter missing_policy") }; }
97
+ function normalizeMissingPolicy(value, parentProject) { exactKeys(value, ["parameter", "version", "project", "policy"], ["parameter", "version"], "dataset parameter missing policy"); validateReferenceProject(value, parentProject, "dataset parameter missing policy"); return { parameter: identifier(value.parameter, "dataset parameter missing policy parameter"), version: identifier(value.version, "dataset parameter missing policy version"), project: parentProject, policy: choice(defaultIfAbsent(value, "policy", "report_only"), ["report_only", "fail_on_requested_range_gap", "drop_target_and_lookback"], "dataset parameter missing policy policy") }; }
95
98
  function normalizePreprocess(value) { object(value, "dataset preprocess"); return { name: identifier(value.name, "preprocess name"), version: identifier(value.version, "preprocess version"), function_hash: pythonString(defaultIfAbsent(value, "function_hash", "demo")), config: object(defaultIfAbsent(value, "config", {}), "preprocess config") }; }
96
- function normalizeTarget(value) { object(value, "dataset target"); const result = structuredClone(value); if (result.parameter && typeof result.parameter === "object" && !Array.isArray(result.parameter)) delete result.parameter.project; return result; }
99
+ function normalizeTarget(value, parentProject) { object(value, "dataset target"); const result = structuredClone(value); if (result.parameter && typeof result.parameter === "object" && !Array.isArray(result.parameter)) result.parameter = parameterRef(result.parameter, parentProject); return result; }
97
100
  function distinct(left, right, label) { if (left === right) throw new Error(`${label} inputs must be different`); }
98
101
  function normalizeRelationshipCheck(value, label) { object(value, label); const type = choice(value.type, ["compare", "absolute_difference", "ratio_range", "sum_equals", "co_presence"], `${label} type`); if (type === "co_presence") { exactKeys(value, ["type"], ["type"], label); return { type }; } if (type === "compare") { exactKeys(value, ["type", "left", "operator", "right", "tolerance"], ["type", "left", "operator", "right"], label); const left = identifier(value.left, `${label} left`); const right = identifier(value.right, `${label} right`); distinct(left, right, label); return { type, left, operator: choice(value.operator, ["lt", "lte", "gt", "gte", "eq", "ne"], `${label} operator`), right, tolerance: nonNegativeNumber(defaultIfAbsent(value, "tolerance", 0), `${label} tolerance`) }; } if (type === "absolute_difference") { exactKeys(value, ["type", "left", "right", "max_difference"], ["type", "left", "right", "max_difference"], label); const left = identifier(value.left, `${label} left`); const right = identifier(value.right, `${label} right`); distinct(left, right, label); return { type, left, right, max_difference: nonNegativeNumber(value.max_difference, `${label} max_difference`) }; } if (type === "ratio_range") { exactKeys(value, ["type", "numerator", "denominator", "min", "max", "inclusive_min", "inclusive_max"], ["type", "numerator", "denominator"], label); const numerator = identifier(value.numerator, `${label} numerator`); const denominator = identifier(value.denominator, `${label} denominator`); distinct(numerator, denominator, label); const range = validRange([defaultIfAbsent(value, "min", null), defaultIfAbsent(value, "max", null)]); return { type, numerator, denominator, min: range[0], max: range[1], inclusive_min: bool(defaultIfAbsent(value, "inclusive_min", true), `${label} inclusive_min`), inclusive_max: bool(defaultIfAbsent(value, "inclusive_max", true), `${label} inclusive_max`) }; } exactKeys(value, ["type", "terms", "target", "tolerance"], ["type", "terms", "target"], label); if (!Array.isArray(value.terms) || value.terms.some((item) => typeof item !== "string" || !item)) throw new Error(`${label} terms must be a list of non-empty strings`); const terms = value.terms.map((item) => identifier(item, `${label} term`)); if (!terms.length) throw new Error(`${label} terms must not be empty`); unique(terms, `${label} terms`); const target = identifier(value.target, `${label} target`); if (terms.includes(target)) throw new Error(`${label} target must not also be a term`); return { type, terms, target, tolerance: nonNegativeNumber(defaultIfAbsent(value, "tolerance", 0), `${label} tolerance`) }; }
99
102
  function relationshipAliases(check) { if (["compare", "absolute_difference"].includes(check.type)) return [check.left, check.right]; if (check.type === "ratio_range") return [check.numerator, check.denominator]; if (check.type === "sum_equals") return [...check.terms, check.target]; return []; }
100
103
  function normalizeRelationshipRule(value, relationshipId) { const label = `parameter relationship ${relationshipId} rule`; exactKeys(value, ["id", "stage", "check", "acceptance", "enforcement", "description"], ["id", "check"], label); return { id: identifier(value.id, `${label} id`), stage: choice(defaultIfAbsent(value, "stage", "normalized_source"), ["normalized_source", "post_preprocess", "aligned_grid"], `${label} stage`), check: normalizeRelationshipCheck(value.check, `${label} check`), acceptance: normalizeAcceptance(object(defaultIfAbsent(value, "acceptance", {}), `${label} acceptance`), label), enforcement: choice(defaultIfAbsent(value, "enforcement", "warn"), ["observe", "warn", "fail"], `${label} enforcement`), description: pythonString(defaultIfAbsent(value, "description", "")) }; }
101
- function normalizeParameterRelationship(value) { exactKeys(value, ["id", "inputs", "alignment", "rules", "description"], ["id", "inputs", "rules"], "parameter relationship"); const id = identifier(value.id, "parameter relationship id"); const inputs = objectList(value.inputs, "parameter relationship inputs").map((item) => { exactKeys(item, ["alias", "parameter", "version"], ["alias", "parameter", "version"], "parameter relationship input"); return { alias: identifier(item.alias, "parameter relationship input alias"), parameter: identifier(item.parameter, "parameter relationship input parameter"), version: identifier(item.version, "parameter relationship input version") }; }); if (inputs.length < 2) throw new Error("parameter relationship requires at least two inputs"); unique(inputs.map((item) => item.alias), "parameter relationship input aliases"); unique(inputs.map(parameterReferenceKey), "parameter relationship inputs"); const alignmentValue = object(defaultIfAbsent(value, "alignment", {}), "parameter relationship alignment"); exactKeys(alignmentValue, ["method", "tolerance"], [], "parameter relationship alignment"); const tolerance = optionalDuration(alignmentValue.tolerance, "parameter relationship alignment tolerance"); if (tolerance != null && durationSeconds(tolerance) === 0) throw new Error("parameter relationship alignment tolerance must be positive"); const alignment = { method: choice(defaultIfAbsent(alignmentValue, "method", "last_before_or_at"), ["exact", "last_before_or_at"], "parameter relationship alignment method"), tolerance }; const rules = objectList(value.rules, "parameter relationship rules").map((item) => normalizeRelationshipRule(item, id)); if (!rules.length) throw new Error("parameter relationship rules must not be empty"); unique(rules.map((item) => item.id), `parameter relationship ${id} rule ids`); const aliases = new Set(inputs.map((item) => item.alias)); for (const rule of rules) { if (rule.stage !== "aligned_grid") throw new Error(`parameter relationship ${id} rule ${rule.id} must use stage=aligned_grid`); const missing = relationshipAliases(rule.check).filter((item) => !aliases.has(item)); if (missing.length) throw new Error(`parameter relationship ${id} rule ${rule.id} references undeclared aliases: ${missing.join(", ")}`); } return { id, inputs, alignment, rules, description: pythonString(defaultIfAbsent(value, "description", "")) }; }
104
+ function normalizeParameterRelationship(value, parentProject) { exactKeys(value, ["id", "inputs", "alignment", "rules", "description"], ["id", "inputs", "rules"], "parameter relationship"); const id = identifier(value.id, "parameter relationship id"); const inputs = objectList(value.inputs, "parameter relationship inputs").map((item) => { exactKeys(item, ["alias", "parameter", "version", "project"], ["alias", "parameter", "version"], "parameter relationship input"); validateReferenceProject(item, parentProject, "parameter relationship input"); return { alias: identifier(item.alias, "parameter relationship input alias"), parameter: identifier(item.parameter, "parameter relationship input parameter"), version: identifier(item.version, "parameter relationship input version"), project: parentProject }; }); if (inputs.length < 2) throw new Error("parameter relationship requires at least two inputs"); unique(inputs.map((item) => item.alias), "parameter relationship input aliases"); unique(inputs.map(parameterReferenceKey), "parameter relationship inputs"); const alignmentValue = object(defaultIfAbsent(value, "alignment", {}), "parameter relationship alignment"); exactKeys(alignmentValue, ["method", "tolerance"], [], "parameter relationship alignment"); const tolerance = optionalDuration(alignmentValue.tolerance, "parameter relationship alignment tolerance"); if (tolerance != null && durationSeconds(tolerance) === 0) throw new Error("parameter relationship alignment tolerance must be positive"); const alignment = { method: choice(defaultIfAbsent(alignmentValue, "method", "last_before_or_at"), ["exact", "last_before_or_at"], "parameter relationship alignment method"), tolerance }; const rules = objectList(value.rules, "parameter relationship rules").map((item) => normalizeRelationshipRule(item, id)); if (!rules.length) throw new Error("parameter relationship rules must not be empty"); unique(rules.map((item) => item.id), `parameter relationship ${id} rule ids`); const aliases = new Set(inputs.map((item) => item.alias)); for (const rule of rules) { if (rule.stage !== "aligned_grid") throw new Error(`parameter relationship ${id} rule ${rule.id} must use stage=aligned_grid`); const missing = relationshipAliases(rule.check).filter((item) => !aliases.has(item)); if (missing.length) throw new Error(`parameter relationship ${id} rule ${rule.id} references undeclared aliases: ${missing.join(", ")}`); } return { id, inputs, alignment, rules, description: pythonString(defaultIfAbsent(value, "description", "")) }; }
102
105
  function normalizeDataset(payload) {
103
106
  object(payload, "dataset"); payload = Object.fromEntries(Object.entries(payload).filter(([name]) => name !== "deleted_at"));
104
107
  exactKeys(payload, ["schema_version", "project", "dataset_id", "dataset_version", "mode", "read_policy", "snapshot_id", "as_of", "time_range", "parameters", "feature_set", "preprocess", "output", "source_read", "realtime_fetch", "abnormal_windows", "prediction", "target", "parameter_relationships", "parameter_missing_policies", "rowset", "rowset_splits", "endpoint_policy"], ["dataset_id", "time_range", "feature_set"], "dataset");
@@ -116,14 +119,27 @@ function normalizeDataset(payload) {
116
119
  unique(missingPolicies.map(parameterReferenceKey), "dataset parameter_missing_policies Parameter references");
117
120
  const overlap = parameters.map(referenceKey).filter((item) => new Set(missingPolicies.map(parameterReferenceKey)).has(item)); if (overlap.length) throw new Error("dataset parameter_missing_policies is only for Feature-only Parameters");
118
121
  if (payload.endpoint_policy != null) { const drops = [...parameters.filter((item) => item.missing_policy === "drop_target_and_lookback"), ...missingPolicies.filter((item) => item.policy === "drop_target_and_lookback")]; if (drops.length) throw new Error("dataset endpoint_policy is incompatible with drop_target_and_lookback"); }
119
- const output = { schema_version: pythonString(defaultIfAbsent(payload, "schema_version", "ml_data_platform.dataset_manifest/v1")), dataset_id: identifier(payload.dataset_id, "dataset id"), dataset_version: identifier(defaultIfAbsent(payload, "dataset_version", "v1"), "dataset version"), mode: choice(defaultIfAbsent(payload, "mode", "training"), ["training", "inference"], "dataset mode"), read_policy: readPolicy, snapshot_id: snapshotId, as_of: asOf, time_range: { start: timestamp(time.start, "dataset time_range start"), end: timestamp(time.end, "dataset time_range end"), grid: pythonString(defaultIfAbsent(time, "grid", "10min")) }, parameters, feature_set: ref(object(payload.feature_set, "dataset feature set"), "dataset feature set"), preprocess: objectList(defaultIfAbsent(payload, "preprocess", []), "dataset preprocess").map(normalizePreprocess), output: object(defaultIfAbsent(payload, "output", {}), "dataset output") };
122
+ const output = { schema_version: pythonString(defaultIfAbsent(payload, "schema_version", "ml_data_platform.dataset_manifest/v1")), project: parentProject, dataset_id: identifier(payload.dataset_id, "dataset id"), dataset_version: identifier(defaultIfAbsent(payload, "dataset_version", "v1"), "dataset version"), mode: choice(defaultIfAbsent(payload, "mode", "training"), ["training", "inference"], "dataset mode"), read_policy: readPolicy, snapshot_id: snapshotId, as_of: asOf, time_range: { start: timestamp(time.start, "dataset time_range start"), end: timestamp(time.end, "dataset time_range end"), grid: pythonString(defaultIfAbsent(time, "grid", "10min")) }, parameters, feature_set: ref(object(payload.feature_set, "dataset feature set"), "dataset feature set"), preprocess: objectList(defaultIfAbsent(payload, "preprocess", []), "dataset preprocess").map(normalizePreprocess), output: object(defaultIfAbsent(payload, "output", {}), "dataset output") };
120
123
  if (missingPolicies.length) output.parameter_missing_policies = missingPolicies;
121
124
  for (const name of ["source_read", "realtime_fetch", "abnormal_windows", "prediction", "rowset", "rowset_splits", "endpoint_policy"]) if (payload[name] != null) output[name] = payload[name];
122
- if (payload.target != null) output.target = normalizeTarget(payload.target);
123
- const relationships = objectList(defaultIfAbsent(payload, "parameter_relationships", []), "dataset parameter_relationships").map(normalizeParameterRelationship); unique(relationships.map((item) => item.id), "dataset parameter_relationship ids"); if (relationships.length) output.parameter_relationships = relationships;
125
+ if (payload.target != null) output.target = normalizeTarget(payload.target, parentProject);
126
+ const relationships = objectList(defaultIfAbsent(payload, "parameter_relationships", []), "dataset parameter_relationships").map((item) => normalizeParameterRelationship(item, parentProject)); unique(relationships.map((item) => item.id), "dataset parameter_relationship ids"); if (relationships.length) output.parameter_relationships = relationships;
124
127
  return output;
125
128
  }
126
129
 
130
+ function normalizeRegistryDataset(payload) {
131
+ const value = object(payload, "registered dataset");
132
+ const responseOnly = ["entity_keys", "label_materializations"];
133
+ const contract = Object.fromEntries(Object.entries(value).filter(([name]) => !responseOnly.includes(name)));
134
+ const normalized = normalizeDataset(contract);
135
+ for (const name of responseOnly) {
136
+ if (!hasOwn(value, name)) continue;
137
+ if (!Array.isArray(value[name])) throw new Error(`registered dataset ${name} must be a list`);
138
+ normalized[name] = structuredClone(value[name]);
139
+ }
140
+ return normalized;
141
+ }
142
+
127
143
  function assetPath(root, relative, suffix) { if (typeof relative !== "string" || !relative) throw new Error("catalog asset path must be a non-empty string"); if (path.isAbsolute(relative)) throw new Error(`catalog asset path must be relative: ${relative}`); const target = path.resolve(root, relative); if (path.extname(target).toLowerCase() !== suffix) throw new Error(`catalog asset must use ${suffix}: ${relative}`); if (!target.startsWith(`${root}${path.sep}`)) throw new Error(`catalog asset path escapes catalog directory: ${relative}`); if (!fs.existsSync(target) || !fs.statSync(target).isFile()) throw new Error(`catalog asset not found: ${target}`); return target; }
128
144
  function readJson(file) { try { return JSON.parse(fs.readFileSync(file, "utf8")); } catch (error) { throw new Error(`cannot read JSON asset ${file}: ${error.message}`); } }
129
145
  function readObject(file) { return object(readJson(file), `JSON file ${file}`); }
@@ -145,13 +161,13 @@ function loadCatalog(directory) {
145
161
  }
146
162
 
147
163
  async function fetchAll(client, endpoint, params = {}) { const rows = []; for (let offset = 0; ; offset += 500) { const response = await client.get(endpoint, { ...params, limit: 500, offset }); if (Array.isArray(response)) return response; const page = response.items; if (!Array.isArray(page)) throw new Error(`${endpoint} returned an invalid list response`); rows.push(...page); if (response.total == null || rows.length >= response.total || page.length < 500) return rows; } }
148
- async function snapshot(client, targetProject) { const scope = targetProject ? { project: targetProject } : {}; const [parameters, operators, features, featureSets, datasets] = await Promise.all([fetchAll(client, "/parameters", scope), fetchAll(client, "/operators", scope), fetchAll(client, "/features", scope), fetchAll(client, "/feature-sets", scope), fetchAll(client, "/datasets", { ...scope, include_deleted: true })]); return { parameters: new Map(parameters.map((x) => [key(normalizeParameter(x)), normalizeParameter(x)])), operators: new Map(operators.map((x) => [key(normalizeOperator(x)), normalizeOperator(x)])), features: new Map(features.map((x) => [key(normalizeFeature(x)), normalizeFeature(x)])), feature_sets: new Map(featureSets.map((x) => [key(normalizeFeatureSet(x)), normalizeFeatureSet(x)])), datasets: new Map(datasets.filter((x) => !x.deleted_at).map((x) => [datasetKey(normalizeDataset(x)), normalizeDataset(x)])) }; }
164
+ async function snapshot(client, targetProject) { const scope = { project: targetProject ?? "default" }; const [parameters, operators, features, featureSets, datasets] = await Promise.all([fetchAll(client, "/parameters", scope), fetchAll(client, "/operators", scope), fetchAll(client, "/features", scope), fetchAll(client, "/feature-sets", scope), fetchAll(client, "/datasets", { ...scope, include_deleted: true })]); return { parameters: new Map(parameters.map((x) => [key(normalizeParameter(x)), normalizeParameter(x)])), operators: new Map(operators.map((x) => [key(normalizeRegistryOperator(x)), normalizeRegistryOperator(x)])), features: new Map(features.map((x) => [key(normalizeFeature(x)), normalizeFeature(x)])), feature_sets: new Map(featureSets.map((x) => [key(normalizeFeatureSet(x)), normalizeFeatureSet(x)])), datasets: new Map(datasets.filter((x) => !x.deleted_at).map((x) => [datasetKey(normalizeRegistryDataset(x)), normalizeRegistryDataset(x)])) }; }
149
165
 
150
166
  function requiresPackage(spec) { return ["custom_python", "feature", "spark_udf"].includes(spec.type) && spec.code_artifact == null && !String(spec.package_uri ?? "").startsWith("pythonpath://"); }
151
167
  function validateCatalog(catalog, current) {
152
168
  const parameters = new Set([...current.parameters.keys(), ...catalog.parameters.map(key)]); const operators = new Map([...current.operators, ...catalog.operators.map((x) => [key(x.spec), x.spec])]); const features = new Map([...current.features, ...catalog.features.map((x) => [key(x), x])]); const featureSets = new Map([...current.feature_sets, ...catalog.feature_sets.map((x) => [key(x), x])]);
153
169
  for (const item of catalog.operators) if (!item.package_path && requiresPackage(item.spec)) throw new Error(`catalog operator ${key(item.spec)} requires a wheel package path`);
154
- for (const feature of catalog.features) { for (const input of feature.inputs) if (!parameters.has(parameterReferenceKey(input))) throw new Error(`catalog feature ${key(feature)} references missing parameter ${parameterReferenceKey(input)}`); const operatorKey = `${feature.operator}:${feature.operator_version}`; const operator = operators.get(operatorKey); if (!operator) throw new Error(`catalog feature ${key(feature)} references missing operator ${operatorKey}`); if (operator.type !== "feature") throw new Error(`catalog feature ${key(feature)} requires OperatorSpec.type='feature'`); const declaredInputs = new Set(operator.input_schema.parameters ?? []); if (declaredInputs.size) for (const input of feature.inputs) if (!declaredInputs.has(parameterReferenceKey(input))) throw new Error(`catalog feature ${key(feature)} input is not declared by ${operatorKey}`); const columns = operator.output_schema.columns ?? []; if (columns.length && !columns.includes(feature.output_column)) throw new Error(`catalog feature ${key(feature)} output_column is not declared by ${operatorKey}`); }
170
+ for (const feature of catalog.features) { for (const input of feature.inputs) if (!parameters.has(parameterReferenceKey(input))) throw new Error(`catalog feature ${key(feature)} references missing parameter ${parameterReferenceKey(input)}`); const operatorKey = `${feature.operator}:${feature.operator_version}`; const operator = operators.get(operatorKey); if (!operator) throw new Error(`catalog feature ${key(feature)} references missing operator ${operatorKey}`); if (operator.type !== "feature") throw new Error(`catalog feature ${key(feature)} requires OperatorSpec.type='feature'`); const declaredInputs = new Set(operator.input_schema.parameters ?? []); if (declaredInputs.size) for (const input of feature.inputs) { const inputKey = parameterReferenceKey(input); const qualifiedInputKey = `${operator.project ?? feature.project ?? "default"}/${inputKey}`; if (!declaredInputs.has(inputKey) && !declaredInputs.has(qualifiedInputKey)) throw new Error(`catalog feature ${key(feature)} input is not declared by ${operatorKey}`); } const columns = operator.output_schema.columns ?? []; if (columns.length && !columns.includes(feature.output_column)) throw new Error(`catalog feature ${key(feature)} output_column is not declared by ${operatorKey}`); }
155
171
  for (const set of catalog.feature_sets) { const names = []; for (const item of set.features) { const feature = features.get(referenceKey(item)); if (!feature) throw new Error(`catalog feature set ${key(set)} references missing feature ${referenceKey(item)}`); names.push(feature.name); } unique(names, `catalog feature set ${key(set)} final output names`); }
156
172
  for (const dataset of catalog.datasets) {
157
173
  const datasetId = datasetKey(dataset); const declared = new Set();
@@ -163,22 +179,29 @@ function validateCatalog(catalog, current) {
163
179
  for (const item of dataset.preprocess) if (!operators.has(referenceKey(item))) throw new Error(`catalog dataset ${datasetId} references missing preprocess operator ${referenceKey(item)}`);
164
180
  }
165
181
  }
182
+ function validateCatalogProject(catalog, targetProject) {
183
+ const projects = [...catalog.parameters, ...catalog.operators.map((item) => item.spec), ...catalog.features, ...catalog.feature_sets, ...catalog.datasets].map((item) => item.project ?? "default");
184
+ const uniqueProjects = [...new Set(projects)];
185
+ if (uniqueProjects.length > 1) throw new Error(`catalog contains multiple projects: ${uniqueProjects.sort().join(", ")}`);
186
+ const expected = targetProject ?? "default";
187
+ if (uniqueProjects.length && uniqueProjects[0] !== expected) throw new Error(`catalog project ${uniqueProjects[0]} does not match target project ${expected}`);
188
+ }
166
189
  function canonical(value) { if (Array.isArray(value)) return value.map(canonical); if (value && typeof value === "object") return Object.fromEntries(Object.keys(value).sort().map((name) => [name, canonical(value[name])])); return value; }
167
190
  function equal(left, right) { return JSON.stringify(canonical(left)) === JSON.stringify(canonical(right)); }
168
191
  function assertImmutable(label, assetKey, candidate, existing) { if (existing && !equal(candidate, existing)) { const error = new Error(`${label} version is immutable: ${assetKey}`); error.code = "EEXIST"; throw error; } }
169
192
  function sha256File(file) { return `sha256:${crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex")}`; }
170
193
 
171
194
  async function applyCatalog(catalog, client, dryRun, targetProject) {
172
- const current = await snapshot(client, targetProject); validateCatalog(catalog, current); const groups = { parameters: catalog.parameters.length, operators: catalog.operators.length, features: catalog.features.length, feature_sets: catalog.feature_sets.length, datasets: catalog.datasets.length }; const summary = Object.fromEntries(Object.entries(groups).map(([name, count]) => [name, { declared: count, planned: 0, applied: 0, unchanged: 0 }]));
195
+ validateCatalogProject(catalog, targetProject); const current = await snapshot(client, targetProject); validateCatalog(catalog, current); const groups = { parameters: catalog.parameters.length, operators: catalog.operators.length, features: catalog.features.length, feature_sets: catalog.feature_sets.length, datasets: catalog.datasets.length }; const summary = Object.fromEntries(Object.entries(groups).map(([name, count]) => [name, { declared: count, planned: 0, applied: 0, unchanged: 0 }]));
173
196
  for (const asset of catalog.parameters) assertImmutable("parameter", key(asset), asset, current.parameters.get(key(asset)));
174
197
  for (const item of catalog.operators) { const existing = current.operators.get(key(item.spec)); if (!existing) continue; let candidate = item.spec; if (item.package_path) { if (existing.code_hash !== sha256File(item.package_path) || !existing.code_artifact) throw new Error(`operator version is immutable: ${key(item.spec)}; publish a new version`); candidate = normalizeOperator({ ...item.spec, code_hash: existing.code_artifact.sha256, package_uri: existing.code_artifact.uri, code_artifact: existing.code_artifact }); } assertImmutable("operator", key(item.spec), candidate, existing); }
175
198
  const plain = async (assets, existing, label, endpoint, keyFn = key, params) => { for (const asset of assets) { const assetKey = keyFn(asset); assertImmutable(label, assetKey, asset, existing.get(assetKey)); if (existing.has(assetKey)) summary[label].unchanged += 1; else if (dryRun) summary[label].planned += 1; else { await client.post(endpoint, asset, params); summary[label].applied += 1; } } };
176
199
  try {
177
200
  await plain(catalog.parameters, current.parameters, "parameters", "/parameters");
178
- for (const item of catalog.operators) { const assetKey = key(item.spec); if (current.operators.has(assetKey)) summary.operators.unchanged += 1; else if (dryRun) summary.operators.planned += 1; else { let candidate = item.spec; if (item.package_path) { const uploaded = await client.uploadOperatorPackage(targetProject ?? "default", item.spec.name, item.spec.version, item.package_path); candidate = normalizeOperator({ ...item.spec, code_hash: uploaded.code_artifact.sha256, package_uri: uploaded.code_artifact.uri, code_artifact: uploaded.code_artifact }); } await client.post("/operators", candidate); summary.operators.applied += 1; } }
201
+ for (const item of catalog.operators) { const assetKey = key(item.spec); if (current.operators.has(assetKey)) summary.operators.unchanged += 1; else if (dryRun) summary.operators.planned += 1; else { let candidate = item.spec; if (item.package_path) { const uploaded = await client.uploadOperatorPackage(item.spec.project, item.spec.name, item.spec.version, item.package_path); candidate = normalizeOperator({ ...item.spec, code_hash: uploaded.code_artifact.sha256, package_uri: uploaded.code_artifact.uri, code_artifact: uploaded.code_artifact }); } await client.post("/operators", candidate); summary.operators.applied += 1; } }
179
202
  await plain(catalog.features, current.features, "features", "/features"); await plain(catalog.feature_sets, current.feature_sets, "feature_sets", "/feature-sets"); await plain(catalog.datasets, current.datasets, "datasets", "/datasets", datasetKey, { force: false });
180
203
  } catch (error) { throw new Error(`catalog apply failed after progress=${JSON.stringify(canonical(summary))}: ${error.message}`); }
181
204
  return { status: dryRun ? "validated" : "applied", profile: "server", catalog: { name: catalog.manifest.name, version: catalog.manifest.version, path: catalog.path }, summary };
182
205
  }
183
206
 
184
- module.exports = { applyCatalog, fetchAll, loadCatalog, normalizeDataset, normalizeFeature, normalizeFeatureSet, normalizeOperator, normalizeParameter, validateCatalog };
207
+ module.exports = { applyCatalog, fetchAll, loadCatalog, normalizeDataset, normalizeFeature, normalizeFeatureSet, normalizeOperator, normalizeRegistryDataset, normalizeRegistryOperator, normalizeParameter, validateCatalog, validateCatalogProject };
@@ -6,14 +6,18 @@ const fs = require("fs");
6
6
  const path = require("path");
7
7
  const { configPath, expandHome, loadApiUrl, normalizeApiUrl, saveApiUrl } = require("./config");
8
8
  const { PlatformApiClient } = require("./http");
9
- const { applyCatalog, loadCatalog, normalizeDataset } = require("./catalog");
9
+ const { applyCatalog, loadCatalog, normalizeDataset, normalizeRegistryDataset } = require("./catalog");
10
10
  const { version: CLIENT_VERSION } = require("../package.json");
11
11
 
12
12
  const BUSINESS_COMMANDS = new Set([
13
13
  "version", "create-project", "list-projects", "get-project", "delete-project",
14
14
  "configure", "show-config", "health", "list-parameters", "list-operators", "list-features",
15
- "list-feature-sets", "list-datasets", "list-jobs", "resolve-manifest", "build-dataset",
16
- "get-job", "wait-job", "get-dataset-artifact", "download-dataset-artifact", "fetch-inference-data", "apply",
15
+ "list-feature-sets", "list-datasets", "get-dataset", "list-jobs", "resolve-manifest",
16
+ "resolve-dataset", "build-dataset", "build-registered-dataset",
17
+ "add-parameter", "add-feature", "add-feature-set", "add-operator", "publish-operator",
18
+ "add-dataset", "update-dataset", "delete-dataset", "list-dataset-artifacts",
19
+ "register-operator", "show-operator-specs", "delete-operator", "run-operator",
20
+ "get-job", "wait-job", "retry-job", "cancel-job", "get-dataset-artifact", "download-dataset-artifact", "fetch-inference-data", "apply",
17
21
  ]);
18
22
 
19
23
  const COMMAND_USAGE = {
@@ -29,12 +33,30 @@ const COMMAND_USAGE = {
29
33
  "list-operators": "list-operators [--project PROJECT] [-q QUERY] [--limit N] [--offset N]",
30
34
  "list-features": "list-features [--project PROJECT] [-q QUERY] [--limit N] [--offset N]",
31
35
  "list-feature-sets": "list-feature-sets [--project PROJECT] [-q QUERY] [--limit N] [--offset N]",
32
- "list-datasets": "list-datasets [--project PROJECT] [-q QUERY] [--limit N] [--offset N]",
36
+ "list-datasets": "list-datasets [--project PROJECT] [-q QUERY] [--limit N] [--offset N] [--include-deleted]",
37
+ "add-parameter": "add-parameter SPEC_JSON",
38
+ "add-feature": "add-feature SPEC_JSON",
39
+ "add-feature-set": "add-feature-set SPEC_JSON",
40
+ "add-operator": "add-operator SPEC_JSON",
41
+ "publish-operator": "publish-operator SPEC_JSON --package PATH",
42
+ "add-dataset": "add-dataset MANIFEST_JSON [--force]",
43
+ "update-dataset": "update-dataset MANIFEST_JSON [--force]",
44
+ "delete-dataset": "delete-dataset DATASET_ID DATASET_VERSION [--project PROJECT]",
45
+ "list-dataset-artifacts": "list-dataset-artifacts [--project PROJECT] [--dataset-id ID] [--dataset-version VERSION] [--include-deleted] [--limit N] [--offset N]",
46
+ "get-dataset": "get-dataset DATASET_ID DATASET_VERSION [--project PROJECT] [--include-deleted]",
33
47
  "list-jobs": "list-jobs [-q QUERY] [--limit N] [--offset N]",
34
48
  "resolve-manifest": "resolve-manifest MANIFEST [--out PATH]",
35
- "build-dataset": "build-dataset MANIFEST [--source-mode direct] [--upload-chronon-metadata] [--partition-duration DURATION] [--max-parallelism N] [--no-resume] [--wait] [--poll-interval SECONDS] [--wait-timeout-seconds SECONDS]",
49
+ "resolve-dataset": "resolve-dataset DATASET_ID DATASET_VERSION [--project PROJECT] [--out PATH]",
50
+ "build-dataset": "build-dataset MANIFEST [--engine chronon] [--source-mode direct] [--upload-chronon-metadata] [--partition-duration DURATION] [--max-parallelism N] [--no-resume] [--wait] [--poll-interval SECONDS] [--wait-timeout-seconds SECONDS]",
51
+ "build-registered-dataset": "build-registered-dataset DATASET_ID DATASET_VERSION [--project PROJECT] [--engine chronon] [--source-mode direct] [--upload-chronon-metadata] [--partition-duration DURATION] [--max-parallelism N] [--no-resume] [--wait] [--poll-interval SECONDS] [--wait-timeout-seconds SECONDS]",
36
52
  "get-job": "get-job JOB_ID",
37
53
  "wait-job": "wait-job JOB_ID [--poll-interval SECONDS] [--wait-timeout-seconds SECONDS]",
54
+ "retry-job": "retry-job JOB_ID",
55
+ "cancel-job": "cancel-job JOB_ID",
56
+ "register-operator": "register-operator SPEC_JSON",
57
+ "show-operator-specs": "show-operator-specs [--project PROJECT] [-q QUERY] [--limit N] [--offset N]",
58
+ "delete-operator": "delete-operator NAME VERSION [--project PROJECT]",
59
+ "run-operator": "run-operator NAME VERSION [--project PROJECT] [--config JSON]",
38
60
  "get-dataset-artifact": "get-dataset-artifact DATASET_ID MANIFEST_HASH [--project PROJECT]",
39
61
  "download-dataset-artifact": "download-dataset-artifact DATASET_ID MANIFEST_HASH [--project PROJECT] --out-dir PATH [--force]",
40
62
  "fetch-inference-data": "fetch-inference-data MANIFEST --cutoff-time TIMESTAMP [--max-workers N] [--max-source-lag-hours HOURS] [--no-validate-freshness] [--allow-missing]",
@@ -48,7 +70,10 @@ function usage(command) {
48
70
  }
49
71
 
50
72
  function parse(args) {
51
- const options = { profile: "server", timeout: finiteNumber(process.env.ML_PLATFORM_API_TIMEOUT_SECONDS ?? 30, "--request-timeout-seconds") };
73
+ const timeoutValue = process.env.ML_PLATFORM_API_TIMEOUT_SECONDS
74
+ || process.env.DATA_PLATFORM_DEMO_API_TIMEOUT_SECONDS
75
+ || 30;
76
+ const options = { profile: "server", timeout: finiteNumber(timeoutValue, "--request-timeout-seconds") };
52
77
  let index = 0;
53
78
  while (index < args.length && args[index].startsWith("--")) {
54
79
  const flag = args[index++];
@@ -62,7 +87,7 @@ function parse(args) {
62
87
  if (!(options.timeout > 0)) throw new Error("--request-timeout-seconds must be positive");
63
88
  options.command = args[index++];
64
89
  options.rest = args.slice(index);
65
- options.apiUrl ??= process.env.ML_PLATFORM_API_URL || loadApiUrl();
90
+ options.apiUrl ??= process.env.ML_PLATFORM_API_URL || process.env.DATA_PLATFORM_DEMO_API_URL || loadApiUrl();
66
91
  return options;
67
92
  }
68
93
 
@@ -101,7 +126,7 @@ function positional(rest, label) { const value = rest.shift(); if (!value || val
101
126
 
102
127
  function client(options) {
103
128
  if (options.profile !== "server") throw new Error("the business client supports only --profile server");
104
- if (!options.apiUrl) throw new Error("server profile requires --api-url, ML_PLATFORM_API_URL, or a saved configure target");
129
+ if (!options.apiUrl) throw new Error("server profile requires --api-url, ML_PLATFORM_API_URL, DATA_PLATFORM_DEMO_API_URL, or a saved configure target");
105
130
  return new PlatformApiClient(options.apiUrl, options.timeout);
106
131
  }
107
132
 
@@ -112,6 +137,54 @@ function readObject(file) {
112
137
  }
113
138
 
114
139
  function readDataset(file) { return normalizeDataset(readObject(file)); }
140
+ function readSpec(file) { return readObject(file); }
141
+
142
+ function datasetEndpoint(project, datasetId, datasetVersion) {
143
+ return `/datasets/${encodeURIComponent(project)}/${encodeURIComponent(datasetId)}/${encodeURIComponent(datasetVersion)}`;
144
+ }
145
+
146
+ async function registeredDataset(api, project, datasetId, datasetVersion, includeDeleted = false) {
147
+ const payload = await api.get(datasetEndpoint(project, datasetId, datasetVersion), { include_deleted: includeDeleted });
148
+ if (!payload || typeof payload !== "object" || Array.isArray(payload)) throw new Error("registered dataset response must be a JSON object");
149
+ return payload;
150
+ }
151
+
152
+ function buildOptions(rest) {
153
+ const engine = take(rest, "--engine", "chronon");
154
+ if (engine !== "chronon") throw new Error("--engine must be chronon");
155
+ const sourceMode = take(rest, "--source-mode");
156
+ if (sourceMode !== undefined && sourceMode !== "direct") throw new Error("--source-mode must be direct");
157
+ const maxParallelism = integer(take(rest, "--max-parallelism", 1), "--max-parallelism");
158
+ if (maxParallelism < 1) throw new Error("--max-parallelism must be at least 1");
159
+ const wait = boolean(rest, "--wait");
160
+ const pollInterval = finiteNumber(take(rest, "--poll-interval", 2), "--poll-interval");
161
+ if (!(pollInterval > 0)) throw new Error("--poll-interval must be positive");
162
+ const rawTimeout = take(rest, "--wait-timeout-seconds");
163
+ const waitTimeout = rawTimeout === undefined ? undefined : finiteNumber(rawTimeout, "--wait-timeout-seconds");
164
+ if (waitTimeout !== undefined && !(waitTimeout > 0)) throw new Error("--wait-timeout-seconds must be positive");
165
+ return {
166
+ engine,
167
+ sourceMode,
168
+ upload: boolean(rest, "--upload-chronon-metadata"),
169
+ partitionDuration: take(rest, "--partition-duration"),
170
+ maxParallelism,
171
+ resume: !boolean(rest, "--no-resume"),
172
+ wait,
173
+ pollInterval,
174
+ waitTimeout,
175
+ };
176
+ }
177
+
178
+ async function submitBuild(api, manifest, build) {
179
+ let result = await api.post("/datasets/build", manifest, { engine: build.engine, source_mode: build.sourceMode, upload_chronon_metadata: build.upload, partition_duration: build.partitionDuration, max_parallelism: build.maxParallelism, resume: build.resume });
180
+ let exitCode = 0;
181
+ if (build.wait) {
182
+ if (!result || typeof result !== "object" || !result.job_id) throw new Error("server build did not return a job_id to wait for");
183
+ result = await waitForJob(api, String(result.job_id), build.pollInterval, build.waitTimeout);
184
+ if (["failed", "cancelled"].includes(result.status)) exitCode = 1;
185
+ }
186
+ return { result, exitCode };
187
+ }
115
188
 
116
189
  async function waitForJob(api, jobId, pollInterval, timeoutSeconds) {
117
190
  if (!(pollInterval > 0)) throw new Error("job poll interval must be positive");
@@ -157,12 +230,28 @@ async function runBusinessCli(argv) {
157
230
  } else if (options.command === "list-projects") result = await client(options).get("/projects");
158
231
  else if (options.command === "get-project") result = await client(options).get(`/projects/${encodeURIComponent(positional(rest, "project name"))}`);
159
232
  else if (options.command === "delete-project") result = await client(options).delete(`/projects/${encodeURIComponent(positional(rest, "project name"))}`);
160
- else if (options.command.startsWith("list-") && options.command !== "list-jobs") {
233
+ else if (["add-parameter", "add-feature", "add-feature-set", "add-operator", "register-operator"].includes(options.command)) {
234
+ const spec = readSpec(positional(rest, "spec JSON"));
235
+ const endpoint = { "add-parameter": "/parameters", "add-feature": "/features", "add-feature-set": "/feature-sets", "add-operator": "/operators", "register-operator": "/operators" }[options.command];
236
+ result = await client(options).post(endpoint, spec);
237
+ } else if (options.command === "publish-operator") {
238
+ const specPath = positional(rest, "OperatorSpec JSON"); const packagePath = take(rest, "--package");
239
+ if (!packagePath) throw new Error("--package is required");
240
+ const spec = readSpec(specPath); const api = client(options); const project = spec.project || "default";
241
+ const upload = await api.uploadOperatorPackage(project, spec.name, spec.version, packagePath);
242
+ if (!upload || typeof upload.code_artifact !== "object") throw new Error("operator package upload response has no code_artifact");
243
+ const published = { ...spec, code_hash: upload.code_artifact.sha256, package_uri: upload.code_artifact.uri, code_artifact: upload.code_artifact };
244
+ result = { status: "published", profile: "server", path: null, operator: published, registration: await api.post("/operators", published) };
245
+ } else if (options.command === "get-dataset") {
246
+ const datasetId = positional(rest, "dataset id"); const datasetVersion = positional(rest, "dataset version"); const project = take(rest, "--project", "default");
247
+ result = await registeredDataset(client(options), project, datasetId, datasetVersion, boolean(rest, "--include-deleted"));
248
+ }
249
+ else if (options.command.startsWith("list-") && !["list-jobs", "list-dataset-artifacts"].includes(options.command)) {
161
250
  const endpoints = { "list-parameters": "/parameters", "list-operators": "/operators", "list-features": "/features", "list-feature-sets": "/feature-sets", "list-datasets": "/datasets" };
162
251
  const project = take(rest, "--project");
163
- const query = take(rest, "--q", take(rest, "-q")); const rawLimit = take(rest, "--limit"); const rawOffset = take(rest, "--offset");
164
- if (rawLimit !== undefined || rawOffset !== undefined) { const limit = Number(rawLimit ?? 50); const offset = Number(rawOffset ?? 0); if (!Number.isInteger(limit) || limit < 1 || limit > 500) throw new Error("limit must be between 1 and 500"); if (!Number.isInteger(offset) || offset < 0) throw new Error("offset must be >= 0"); result = await client(options).get(endpoints[options.command], { q: query, limit, offset, project }); }
165
- else result = await fetchAll(client(options), endpoints[options.command], { q: query, project });
252
+ const query = take(rest, "--q", take(rest, "-q")); const includeDeleted = options.command === "list-datasets" && boolean(rest, "--include-deleted"); const rawLimit = take(rest, "--limit"); const rawOffset = take(rest, "--offset");
253
+ if (rawLimit !== undefined || rawOffset !== undefined) { const limit = Number(rawLimit ?? 50); const offset = Number(rawOffset ?? 0); if (!Number.isInteger(limit) || limit < 1 || limit > 500) throw new Error("limit must be between 1 and 500"); if (!Number.isInteger(offset) || offset < 0) throw new Error("offset must be >= 0"); result = await client(options).get(endpoints[options.command], { q: query, limit, offset, project, include_deleted: includeDeleted }); }
254
+ else result = await fetchAll(client(options), endpoints[options.command], { q: query, project, include_deleted: includeDeleted });
166
255
  } else if (options.command === "list-jobs") {
167
256
  const query = take(rest, "--q", take(rest, "-q")); const rawLimit = take(rest, "--limit"); const rawOffset = take(rest, "--offset");
168
257
  if (rawLimit !== undefined || rawOffset !== undefined) { const limit = Number(rawLimit ?? 50); const offset = Number(rawOffset ?? 0); if (!Number.isInteger(limit) || limit < 1 || limit > 500) throw new Error("limit must be between 1 and 500"); if (!Number.isInteger(offset) || offset < 0) throw new Error("offset must be >= 0"); result = await client(options).get("/jobs", { q: query, limit, offset }); }
@@ -173,26 +262,52 @@ async function runBusinessCli(argv) {
173
262
  result = await client(options).post("/datasets/resolve", readDataset(manifest));
174
263
  const out = take(rest, "--out");
175
264
  if (out) { const target = resolvedPath(out); fs.mkdirSync(path.dirname(target), { recursive: true }); fs.writeFileSync(target, `${JSON.stringify(result, null, 2)}\n`); }
265
+ } else if (options.command === "resolve-dataset") {
266
+ const datasetId = positional(rest, "dataset id"); const datasetVersion = positional(rest, "dataset version"); const project = take(rest, "--project", "default"); const api = client(options);
267
+ const manifest = normalizeRegistryDataset(await registeredDataset(api, project, datasetId, datasetVersion));
268
+ result = await api.post("/datasets/resolve", manifest);
269
+ const out = take(rest, "--out");
270
+ if (out) { const target = resolvedPath(out); fs.mkdirSync(path.dirname(target), { recursive: true }); fs.writeFileSync(target, `${JSON.stringify(result, null, 2)}\n`); }
176
271
  } else if (options.command === "build-dataset") {
177
272
  const manifest = positional(rest, "manifest");
178
- const sourceMode = take(rest, "--source-mode");
179
- if (sourceMode !== undefined && sourceMode !== "direct") throw new Error("--source-mode must be direct");
180
- const upload = boolean(rest, "--upload-chronon-metadata");
181
- const partitionDuration = take(rest, "--partition-duration"); const maxParallelism = integer(take(rest, "--max-parallelism", 1), "--max-parallelism"); const resume = !boolean(rest, "--no-resume");
182
- const api = client(options);
183
- result = await api.post("/datasets/build", readDataset(manifest), { engine: "chronon", source_mode: sourceMode, upload_chronon_metadata: upload, partition_duration: partitionDuration, max_parallelism: maxParallelism, resume });
184
- if (boolean(rest, "--wait")) {
185
- if (!result || typeof result !== "object" || !result.job_id) throw new Error("server build did not return a job_id to wait for");
186
- const rawTimeout = take(rest, "--wait-timeout-seconds");
187
- result = await waitForJob(api, String(result.job_id), finiteNumber(take(rest, "--poll-interval", 2), "--poll-interval"), rawTimeout === undefined ? undefined : finiteNumber(rawTimeout, "--wait-timeout-seconds"));
188
- if (["failed", "cancelled"].includes(result.status)) exitCode = 1;
189
- }
273
+ const build = buildOptions(rest); const api = client(options);
274
+ ({ result, exitCode } = await submitBuild(api, readDataset(manifest), build));
275
+ } else if (options.command === "build-registered-dataset") {
276
+ const datasetId = positional(rest, "dataset id"); const datasetVersion = positional(rest, "dataset version"); const project = take(rest, "--project", "default"); const build = buildOptions(rest); const api = client(options);
277
+ const manifest = normalizeRegistryDataset(await registeredDataset(api, project, datasetId, datasetVersion));
278
+ ({ result, exitCode } = await submitBuild(api, manifest, build));
279
+ } else if (["add-dataset", "update-dataset"].includes(options.command)) {
280
+ const manifest = readDataset(positional(rest, "manifest JSON")); const force = boolean(rest, "--force"); const project = manifest.project || "default";
281
+ if (options.command === "add-dataset") result = await client(options).post("/datasets", manifest, { force });
282
+ else result = await client(options).put(`/datasets/${encodeURIComponent(project)}/${encodeURIComponent(manifest.dataset_id)}/${encodeURIComponent(manifest.dataset_version)}`, manifest, { force });
283
+ } else if (options.command === "delete-dataset") {
284
+ const id = positional(rest, "dataset id"); const version = positional(rest, "dataset version"); const project = take(rest, "--project", "default");
285
+ result = await client(options).delete(`/datasets/${encodeURIComponent(project)}/${encodeURIComponent(id)}/${encodeURIComponent(version)}`);
286
+ } else if (options.command === "list-dataset-artifacts") {
287
+ const project = take(rest, "--project"); const datasetId = take(rest, "--dataset-id"); const datasetVersion = take(rest, "--dataset-version"); const includeDeleted = boolean(rest, "--include-deleted");
288
+ result = await listCollection(client(options), "/dataset-artifacts", { project, dataset_id: datasetId, dataset_version: datasetVersion, include_deleted: includeDeleted }, rest);
289
+ } else if (options.command === "show-operator-specs") {
290
+ const project = take(rest, "--project"); const query = take(rest, "--q", take(rest, "-q"));
291
+ result = await listCollection(client(options), "/operators", { project, q: query }, rest);
292
+ } else if (options.command === "delete-operator") {
293
+ const name = positional(rest, "operator name"); const version = positional(rest, "operator version"); const project = take(rest, "--project", "default");
294
+ result = await client(options).delete(`/operators/${encodeURIComponent(project)}/${encodeURIComponent(name)}/${encodeURIComponent(version)}`);
295
+ } else if (options.command === "run-operator") {
296
+ const name = positional(rest, "operator name"); const version = positional(rest, "operator version"); const project = take(rest, "--project", "default"); const rawConfig = take(rest, "--config", "{}");
297
+ let config; try { config = JSON.parse(rawConfig); } catch (_) { throw new Error("--config must be valid JSON"); }
298
+ result = await client(options).post(`/operators/${encodeURIComponent(project)}/${encodeURIComponent(name)}/${encodeURIComponent(version)}/run`, config);
190
299
  } else if (options.command === "get-job") result = await client(options).get(`/jobs/${encodeURIComponent(positional(rest, "job id"))}`);
191
300
  else if (options.command === "wait-job") {
192
301
  const jobId = positional(rest, "job id"); const poll = finiteNumber(take(rest, "--poll-interval", 2), "--poll-interval"); const rawTimeout = take(rest, "--wait-timeout-seconds");
193
302
  result = await waitForJob(client(options), jobId, poll, rawTimeout === undefined ? undefined : finiteNumber(rawTimeout, "--wait-timeout-seconds"));
194
303
  if (["failed", "cancelled"].includes(result.status)) exitCode = 1;
195
304
  }
305
+ else if (options.command === "cancel-job") {
306
+ result = await client(options).post(`/jobs/${encodeURIComponent(positional(rest, "job id"))}/cancel`);
307
+ }
308
+ else if (options.command === "retry-job") {
309
+ result = await client(options).post(`/jobs/${encodeURIComponent(positional(rest, "job id"))}/retry`);
310
+ }
196
311
  else if (options.command === "get-dataset-artifact") {
197
312
  const dataset = positional(rest, "dataset id"); const hash = positional(rest, "manifest hash"); const project = take(rest, "--project", "default");
198
313
  result = await client(options).get(`/dataset-artifacts/${encodeURIComponent(project)}/${encodeURIComponent(dataset)}/${encodeURIComponent(hash)}`);
@@ -202,7 +317,7 @@ async function runBusinessCli(argv) {
202
317
  result = await client(options).downloadDatasetArtifact(project, dataset, hash, resolvedPath(outDir), boolean(rest, "--force"));
203
318
  } else if (options.command === "fetch-inference-data") {
204
319
  const manifest = positional(rest, "manifest"); const cutoffTime = take(rest, "--cutoff-time"); if (!cutoffTime) throw new Error("--cutoff-time is required");
205
- const realtime = {}; const maxWorkers = take(rest, "--max-workers"); const maxLag = take(rest, "--max-source-lag-hours"); if (maxWorkers !== undefined) realtime.max_workers = integer(maxWorkers, "--max-workers"); if (maxLag !== undefined) realtime.max_source_lag_hours = finiteNumber(maxLag, "--max-source-lag-hours"); if (boolean(rest, "--no-validate-freshness")) realtime.validate_freshness = false; if (boolean(rest, "--allow-missing")) realtime.allow_missing = true;
320
+ const realtime = {}; const maxWorkers = take(rest, "--max-workers"); const maxLag = take(rest, "--max-source-lag-hours"); if (maxWorkers !== undefined) { realtime.max_workers = integer(maxWorkers, "--max-workers"); if (realtime.max_workers < 1) throw new Error("--max-workers must be at least 1"); } if (maxLag !== undefined) { realtime.max_source_lag_hours = finiteNumber(maxLag, "--max-source-lag-hours"); if (realtime.max_source_lag_hours < 0) throw new Error("--max-source-lag-hours must be non-negative"); } if (boolean(rest, "--no-validate-freshness")) realtime.validate_freshness = false; if (boolean(rest, "--allow-missing")) realtime.allow_missing = true;
206
321
  const request = { manifest: readDataset(manifest), cutoff_time: cutoffTime }; if (Object.keys(realtime).length) request.realtime_fetch = realtime; result = await client(options).post("/inference-data/fetch", request);
207
322
  } else if (options.command === "apply") {
208
323
  const directory = positional(rest, "catalog directory"); const dryRun = boolean(rest, "--dry-run"); const targetProject = take(rest, "--project");
@@ -215,7 +330,16 @@ async function runBusinessCli(argv) {
215
330
 
216
331
  async function fetchAll(api, endpoint, params = {}) { const items = []; for (let offset = 0; ; offset += 500) { const page = await api.get(endpoint, { ...params, limit: 500, offset }); if (Array.isArray(page)) return page; if (!page || !Array.isArray(page.items)) throw new Error(`${endpoint} returned an invalid paginated response`); items.push(...page.items); if (page.total == null || items.length >= page.total || page.items.length < 500) return items; } }
217
332
 
218
- module.exports = { BUSINESS_COMMANDS, fetchAll, parse, runBusinessCli, usage, waitForJob };
333
+ async function listCollection(api, endpoint, params, rest) {
334
+ const rawLimit = take(rest, "--limit"); const rawOffset = take(rest, "--offset");
335
+ if (rawLimit === undefined && rawOffset === undefined) return fetchAll(api, endpoint, params);
336
+ const limit = Number(rawLimit ?? 50); const offset = Number(rawOffset ?? 0);
337
+ if (!Number.isInteger(limit) || limit < 1 || limit > 500) throw new Error("limit must be between 1 and 500");
338
+ if (!Number.isInteger(offset) || offset < 0) throw new Error("offset must be >= 0");
339
+ return api.get(endpoint, { ...params, limit, offset });
340
+ }
341
+
342
+ module.exports = { BUSINESS_COMMANDS, fetchAll, listCollection, parse, runBusinessCli, usage, waitForJob };
219
343
 
220
344
  if (require.main === module) {
221
345
  runBusinessCli(process.argv.slice(2)).then((code) => { process.exitCode = code; }).catch((error) => {
@@ -11,11 +11,15 @@ function expandHome(value) {
11
11
  }
12
12
 
13
13
  function configPath() {
14
- if (process.env.ML_PLATFORM_CONFIG_PATH?.trim()) return path.resolve(expandHome(process.env.ML_PLATFORM_CONFIG_PATH));
14
+ for (const variable of ["ML_PLATFORM_CONFIG_PATH", "DATA_PLATFORM_DEMO_CONFIG_PATH"]) {
15
+ if (process.env[variable]?.trim()) return path.resolve(expandHome(process.env[variable]));
16
+ }
15
17
  const base = process.env.XDG_CONFIG_HOME
16
18
  ? path.resolve(process.env.XDG_CONFIG_HOME)
17
19
  : path.join(os.homedir(), ".config");
18
- return path.join(base, "ml-platform", "config.json");
20
+ const primary = path.join(base, "ml-platform", "config.json");
21
+ const legacy = path.join(base, "data-platform-demo", "config.json");
22
+ return !fs.existsSync(primary) && fs.existsSync(legacy) ? legacy : primary;
19
23
  }
20
24
 
21
25
  function normalizeApiUrl(value) {